7. 整数反转
英文题目名称: Reverse Integer
标签: 数学
思路:
第一反应是使用传统的通过符号位的变化判断补码整数加减法是否溢出的方法, 但是写完狠狠报错, 然后才反应过来这题还涉及到乘法.
由于涉及十进制乘法, 判断溢出也需要以十进制的思维进行.
- 对于 $x > 0$ 的情况, 溢出等价于
- $x < 0$ 的情况同理.
提交后发现执行效率不是很好.
最后通过查看题解了解到上述的条件还可以进一步优化, 优化到只需要检查 $\text{current_result}$ 就能判断溢出的程度, 根本不需要检查 $\text{digit}$.
之所以能够进行优化是因为上述条件是离散的, 完全可以将所有情况列出来, 对同类情况进行合并同类项, 并对定义域进行降维.
还是对于 $x > 0$ 的情况, 简单移项可知上述溢出条件又等价于
$$ \begin{equation} (\text{current_result} - 214748364) \times 10 > 7 - \text{digit}. \end{equation} $$- 当 $\text{current_result} - 214748364 < 0$ 时, 上式不等号左边必然小于等于 $-10$, 因此不等式必然不成立.
- 当 $\text{current_result} - 214748364 > 0$ 时, 上式不等号左边必然大于等于 $10$, 因此不等式必然成立.
- 当 $\text{current_result} - 214748364 = 0$ 时, 情况比较特殊, 需要进一步分析:
- 由于 $x$ 此时仍然不为零 (若为零则已经可以返回结果, 不需要再判断溢出), 这说明 $x$ 的十进制形式必然是 $\[\text{digit}\]463847412$ (即 $214748364$ 倒过来再加上一位).
- 又因为 $x$ 必然没有溢出, 于是可知 $\text{digit}$ 必然小于等于 $2$.
- 此时上式不等号左边为零, 而右边大于零, 因此不等式必然不成立.
综上所述, $x > 0$ 的情况下原溢出条件等价于 $\text{current_result} - 214748364 > 0$.
$x < 0$ 的情况同理.
代码:
|
|
8. 字符串转换整数 (atoi)
英文题目名称: String to Integer (atoi)
标签: 字符串
思路:
- 本题题型为模拟题, 没什么技巧.
- 一开始还以为可以使用 7. 整数反转中的用于判断是否溢出的结论进行速通, 后来发现该题的结论是有前提条件的, 并且在本题中并不成立, 因此只能手动判断是否溢出.
代码:
|
|
9. 回文数
英文题目名称: Palindrome Number
标签: 数学
思路:
- 首先排除一些边界情况. 根据题意规定, 负号也会被反转, 因此负数必定不是回文数. 传入的整数不具有前导零, 如果其末尾为零则必定不是回文数.
- 只考虑最严格的不先将整数转换为字符串再继续的做法. 最自然的想法是将整数的所有数字水平翻转然后看反转后的结果是否和翻转前的相同. 由于本题限制整数的范围为 32 位补码整数, 同时是翻转位数, 因此可以套用 7. 整数反转中的结论判断翻转过程中是否有可能溢出.
代码:
|
|
12. 整数转罗马数字
英文题目名称: Integer to Roman
标签: 哈希表, 数学, 字符串
思路:
- 可以选择模拟或硬编码两种做法. 之所以能够使用硬编码的做法是因为罗马数字表示法将千, 百, 十, 和个位的部分拆开进行表示, 每个部分有各自的编码且互不相交, 因此在整数到罗马数字之间形成了一个双射.
- 下面选择模拟做法. 模拟做法实际上就是在模拟硬编码的过程, 大致可描述为分块, 处理, 以及拼接三个步骤的循环. 首先将整数分为千位, 百位, 十位和个位的块, 然后依次对各个块进行编码得到对应的子串, 最终结果由这些子串拼接而成.
代码:
|
|
13. 罗马数字转整数
英文题目名称: Roman to Integer
标签: 哈希表, 数学, 字符串
思路:
- 罗马数字表示法使用字母表示原子整数, 同时使用不同的字母拼接方向表示原子整数之间的加减法, 进而表示整个整数.
- 在罗马数字表示法下, 向右拓展代表增加, 并且拓展的单位总是不超过主单位, 例如
I
向右拓展为II
, 其中拓展的单位为I
, 主单位也为I
; 而向左拓展代表减少, 并且拓展的单位同样不超过主单位, 例如V
向左拓展为IV
, 其中拓展的单位为I
, 主单位为V
. - 因此当从右往左遍历一个罗马数字时, 如果发现单位减少说明遇到了向左拓展的单位, 需要减去; 反之则需要加上. 最终所得结果即为原始罗马数字所对应的整数.
代码:
|
|
14. 最长公共前缀
英文题目名称: Longest Common Prefix
标签: 字典树, 字符串
思路:
- 一开始以为可以使用二分法, 写完提交却狠狠报错, 然后才反应过来公共前缀是 "all of these elements" 类型的而不是 "one of these elements" 类型的, 无法使用二分法, 只能老老实实从左往右每个位置依次检查, 没什么技术含量.
代码:
|
|
15. 三数之和
英文题目名称: 3Sum
标签: 数组, 双指针, 排序
思路:
这道题我老早以前做过, 依稀记得做法是将三指针中的一个指针固定住以将原问题转化为双指针问题.
对于双指针问题, 自己今天在脑海里使用一个由一维数组导出的二维矩阵 (矩阵中每个元素为一个二元组) 对其进行模拟之后有了新的体会. 假设左指针对应二维矩阵的 $i$ 下标, 右指针对应二维矩阵的 $j$ 下标, 由于一维数组已升序排序, 所导出的二维矩阵同样关于 $i$ 和 $j$ 下标升序有序. 初始时 $(i, j)$ 位于矩阵的右上角, 此后每次检查右上角二元组之和时总是会发生下列三种情况之一 (注意由矩阵的定义可知其为对称阵):
- 右上角二元组之和小于目标值. 此时易知矩阵第一行中的任何二元组之和均不再可能大于等于目标值 (因为二维矩阵关于 $j$ 下标升序, 而 $j$ 下标此时位于最右端, 向左走只会令二元组之和减少). 这便将第一行以及 (由对称阵性质) 第一列的元素全部排除, 于是原矩阵归约为行数少 1, 列数少 1 的新矩阵, $(i, j)$ 向下移动 1 位 (对应于左指针右移 1 位), 移动后的 $(i, j)$ 位于新矩阵的右上角.
- 右上角二元组之和大于目标值. 同理, 易知矩阵最后一列以及最后一行的二元组可全部被排除, 原矩阵归约为行数少 1, 列数少 1 的新矩阵, $(i, j)$ 向左移动 1 位 (对应于右指针左移 1 位), 移动后的 $(i, j)$ 位于新矩阵的右上角.
- 右上角二元组之和等于目标值. 同理, 易知矩阵第一行, 第一列, 最后一行, 以及最后一列可同时被排除, 原矩阵归约为行数少 2, 列数少 2 的新矩阵, $(i, j)$ 向左下方移动 1 位 (对应于左/右指针分别向右/左移 1 位), 移动后的 $(i, j)$ 位于新矩阵的右上角.
易知归约终止的标志是 $(i, j)$ 移动至原矩阵的主对角线, 即 $i = j$.
由于题目要求不能有重复, 首先想到的是将答案放到一个 set 中, 但直觉让我觉得很不舒服, 因此思路马上转移到如何编织合适的遍历策略来让结果天然是去重的. 最终的解决方案是首先对数组进行排序 (升序), 然后主指针从左到右进行遍历, 并在固定主指针的情况下使用双指针进行遍历. 规定主指针元素总是作为最终三元组中的最左端元素, 左右指针元素分别对应三元组中的中间和最右端元素, 于是只需要在固定主指针的情况下令左右指针的遍历范围为主指针右端的区间即可保证遍历不遗漏. 而遍历的不重复则是通过每次跳过和主/左/右指针元素值相同的所有元素实现的.
根据官方题解评论区一位网友的评论, 作为一种优化, 如果发现从主指针
main_pointer
开始的连续三个元素构成的三元组之和nums[main_pointer] + nums[main_pointer + 1] + nums[main_pointer + 2]
已经大于 0, 由于数组升序有序, 余下未遍历的所有三元组之和均大于 0, 因此可终止全体遍历; 另一方面如果发现主指针元素和数组最右端两个元素构成的三元组之和nums[main_pointer] + nums[nums.size() - 1] + nums[nums.size() - 2]
已经小于 0, 同样由于数组升序有序, 在固定主指针的情况下余下未遍历的所有三元组之和均小于 0, 因此可终止对左右指针的遍历, 直接令主指针跳到下一个位置.
代码:
|
|
16. 最接近的三数之和
英文题目名称: 3Sum Closest
标签: 数组, 双指针, 排序
思路:
本题可以重用 15. 三数之和中的思路, 只需要稍微修改一下检查的目标即可:
- 如果三元组之和等于目标
target
, 那么答案已经找到, 可以直接 return; - 如果三元组之和小于目标
target
, 那么更新历史最大上有界三元组之和maximum_upper_bounded_sum
; - 如果三元组之和大于目标
target
, 那么更新历史最小下有界三元组之和minimum_lower_bounded_sum
.
最终结果要么为
target
自身, 要么为maximum_upper_bounded_sum
和minimum_lower_bounded_sum
中距离target
最近的那个.- 如果三元组之和等于目标
Tip
- 由于
maximum_upper_bounded_sum
和minimum_lower_bounded_sum
的初值分别为int
类型的两个极值, 最后在计算与target
之间的距离时可能会溢出, 因此需要先强制转换为long
类型.
代码:
|
|
17. 电话号码的字母组合
英文题目名称: Letter Combinations of a Phone Number
标签: 哈希表, 字符串, 回溯
思路:
- 没什么技术含量, 就是一个简单的 DFS.
代码:
|
|
18. 四数之和
英文题目名称: 4Sum
标签: 数组, 双指针, 排序
思路:
- 和 15. 三数之和同根同源, 三数之和下固定的是主指针, 拓展到四数之和下只需要同时固定主指针和副指针即可, 余下的部分由双指针解决.
- 本题的优化方式和三数之和中的相同, 但由于增加了一个副指针, 因此主副指针的跳过方式有一点不同, 需要特别处理.
代码:
|
|
19. 删除链表的倒数第 N 个结点
英文题目名称: Remove Nth Node From End of List
标签: 链表, 双指针
思路:
- 脑筋急转弯类型的题目, 第一次做的时候被坑过一遍, 后边想忘都忘不了. 由于待删除结点相对于链表末结点的距离已知, 同时一个结点是否为链表末结点是可以检测的, 因此可以使用双指针的方法, 先将左右指针错开与待删除结点和链表末结点之间的距离 (即
n - 1
) 相同的距离, 然后左右指针再同步前进, 当右指针指向链表末结点时左指针便必然指向待删除结点. - 由于本题是单链表, 需要将指针指向待删除结点的父结点, 为了处理待删除结点恰好为头结点的情况, 可以使用一个伪头结点 (dummy head) 作为头结点的父结点 (需要注意的是待删除结点的父结点与链表末结点之间的距离为
n
).
代码:
|
|
20. 有效的括号
英文题目名称: Valid Parentheses
标签: 栈, 字符串
思路:
- 第一反应是存储左括号, 并在右括号到来时检查栈顶是否为对应的左括号. 但是这题非常细节, 每次提交都会发现一个坑, 归纳如下:
- 即使通过所有左括号匹配也并不意味着整个模式匹配成功, 因为此时栈中还有可能剩余一些未匹配的左括号.
- 匹配左括号时需要先检查栈是否为空.
- 看了自己很早之前提交的一次解答, 发现其实完全可以存储右括号, 并在右括号到来时检查栈顶是否为自身. 这样做就把匹配左括号的三个
if
语句归约为一个==
判断, 时间效率直接爆炸.
代码 (匹配左括号版本):
|
|
代码 (匹配右括号自身版本):
|
|
21. 合并两个有序链表
英文题目名称: Merge Two Sorted Lists
标签: 递归, 链表
思路:
- 没什么技术含量. 使用
merged_list_dummy_head
作为合并链表的伪头结点, 然后老老实实一个一个将list1
和list2
中的结点往合并链表中搬, 搬到最后两个链表必然不能同时为非空, 于是只需要将非空的那个链表中的余下结点拼接至当前合并链表的末尾即可 (易知拼接后的合并链表仍然有序).
代码:
|
|
22. 括号生成
英文题目名称: Generate Parentheses
标签: 字符串, 动态规划, 回溯
思路:
- 第一反应是分治法, 例如将 $n = 3$ 拆成 $n = 2 + 1$, $n = 1 + 1 + 1$ 等等, 但越想越觉得思维难度颇高 (当时还没有想到其实只需要枚举第一个拆分部分的长度并结合递归即可构造出所有有效拆分), 同时还需要像动态规划那样把中间值保存起来, 这令我很不舒服. 于是放弃分治法, 转向回溯法:
- 首先观察到在 $n$ 固定的情况下, 任何一个有效括号组合的长度均为 $2n$, 因此可以从左往右对一个由 $2n$ 个槽位构成的字符串中的每一个槽位枚举该槽位应该放左括号还是右括号来进行生成. 其次也并不是每一个槽位都可以放左括号和右括号:
- 能够放左括号当且仅当还有未放置的左括号剩余;
- 能够放右括号当且仅当还有未放置的 (之前放置的左括号所需要匹配的) 右括号剩余.
代码:
|
|
23. 合并 K 个升序链表
英文题目名称: Merge k Sorted Lists
标签: 链表, 分治, 堆(优先队列), 归并排序
思路:
- 不知道这题以前做没做过, 但是一看到 k 个有序链表脑子里便自动过拟合到了败者树和堆排序那边去了. 但这里不是硬盘, 因此直接使用堆排序即可. 堆的操作的复杂度为 $O(\log(k))$, 共需要比较 $kn$ 次, 每次都需要至多一次插入和至多一次删除, 因此时间复杂度为 $O(kn\log(k))$.
- 最朴素的做法是顺次合并两个相邻的有序链表, 但是这样做的话每次合并得到的新链表长度均为两个旧链表的长度之和, 累积效应显著 (时间复杂度为 $O(k^2n)$). 优化的话可以考虑 Huffman Tree (或等价的 2 路归并), 每一轮合并的时间复杂度均为 $O(kn)$, 共合并 $\log(k)$ 轮, 因此总的时间复杂度仍为 $O(kn\log(k))$.
代码:
|
|