1. 这四道题,为什么值得放到一起刷
刷算法题这件事,最怕的就是东一榔头西一棒子。今天看个动态规划,明天追个图论,刷了三个月回头一看,遇到链表还是卡在指针上,遇到哈希还是只记得 API 不记得思路。我见过太多人栽在这个问题上:题目刷了不少,知识体系却是散的。
如果把 LeetCode 上几千道题拆开看,真正支撑起面试的底层能力就那么几块:循环与分支、哈希表、双指针、链表操作。而“Fizz Buzz、两数之和、合并两个有序数组、设计链表”这四道题,恰好把这四块全部覆盖了。它们不是随机拼凑的一组题,而是一套极具代表性的“组合拳”——从最简单的条件分支,到空间换时间的哈希优化,再到原地合并有序数组的双指针思维,最后到需要硬啃指针细节的链表实现。难度从 Easy 到 Medium 循序渐进,每一道题都是一类题型的“母题”。
这篇文章就是围绕这四道题展开的。我会把每道题的考点、多种解法、代码实现、易错点全部拆开揉碎,再补充一些我在刷题和面试中实际踩过的坑。不管你是刚开始刷题的新手,还是准备冲刺心仪团队 offer 的求职者,这套题都值得你花一个下午认真过一遍。更重要的是,读完这篇文章,你能从“会做这四道题”上升到“知道这类题在考什么、怎么迁移”,这才是刷题的正确姿势。
2. Fizz Buzz:条件分支与取模的“送分题陷阱”
2.1 题目回顾与常规解法
题目很简单:给定一个整数 n,从 1 到 n 遍历每个数,按照规则生成字符串数组:
- 如果数字同时是 3 和 5 的倍数,输出 "FizzBuzz"。
- 如果数字是 3 的倍数,输出 "Fizz"。
- 如果数字是 5 的倍数,输出 "Buzz"。
- 其他情况,输出数字本身。
我第一次刷这道题的时候,心里想的是:这不是侮辱智商吗?循环加判断就完了。于是很自然地写下这样的代码:
function fizzBuzz(n) { const result = []; for (let i = 1; i <= n; i++) { if (i % 3 === 0) { result.push("Fizz"); } else if (i % 5 === 0) { result.push("Buzz"); } else if (i % 15 === 0) { result.push("FizzBuzz"); } else { result.push(String(i)); } } return result; }这段代码是错的,而且错得很典型。15 能被 3 整除,所以 15、30、45 这些数会先走第一个分支直接返回 "Fizz",永远不会进入i % 15 === 0的判断。我的这个错误版本恰好暴露了这道题最关键的考点:多个条件分支之间的优先级和覆盖关系。
2.2 常规解法的隐藏坑:优先级才是核心考点
正确写法其实只要调整一下判断顺序,把“同时能被 3 和 5 整除”这个条件放在最前面:
function fizzBuzz(n) { const result = []; for (let i = 1; i <= n; i++) { if (i % 15 === 0) { result.push("FizzBuzz"); } else if (i % 3 === 0) { result.push("Fizz"); } else if (i % 5 === 0) { result.push("Buzz"); } else { result.push(String(i)); } } return result; }有人可能会说:这不就是一道 Easy 题吗,知道先判断 15 不就行了?但面试和笔试中,这道题真正考察的不是“会不会写 if-else”,而是你能否预判条件之间的重叠关系。i % 15 === 0等价于i % 3 === 0 && i % 5 === 0,但如果你把它写在后面,前面的分支会先把这些数“消化”掉,这就是典型的“分支覆盖陷阱”。
这道题在真实面试中也经常被用来观察候选人的代码习惯。有些人会直接判断i % 3 === 0 && i % 5 === 0,这样顺序就无所谓了;有些人会维护一个字符串变量,满足一个条件就拼接一段,最后再判断是否为空。我实测下来,面试官更认可的是逻辑清晰、顺序简洁的写法,字符串拼接法虽然灵活,但容易在边界条件下写出多余判断。
2.3 进阶:不用取模的计数器解法
如果面试官追一句“能不能不用%运算符实现”,这道送分题马上就变了一个味道。这其实是在考察你对“取模”本质的理解——取模的本质是对周期性事件的计数。我们可以用三个计数器模拟这个周期:
function fizzBuzz(n) { const result = []; let fizzCount = 0; let buzzCount = 0; for (let i = 1; i <= n; i++) { fizzCount++; buzzCount++; if (fizzCount === 3 && buzzCount === 5) { result.push("FizzBuzz"); fizzCount = 0; buzzCount = 0; } else if (fizzCount === 3) { result.push("Fizz"); fizzCount = 0; } else if (buzzCount === 5) { result.push("Buzz"); buzzCount = 0; } else { result.push(String(i)); } } return result; }这个版本的逻辑本质是“到点触发”,每走到 3 的倍数就触发一次 Fizz,走到 5 的倍数就触发一次 Buzz,同时走到就直接触发 FizzBuzz。这种写法没有用任何取模运算,靠的是计数器复位。它看起来代码量更大,但它背后的状态机思想却非常重要——很多嵌入式开发场景、音视频帧处理、周期性任务的领域,都是这种“计数到阈值就触发”的模式。如果你能把这道题的取模解法和计数器解法都吃透,面试时聊到“为什么不用取模”就能答得很有底气。
2.4 面试实录与常见追问
这道题在面试中出现时,大概率不是单独考察,而是作为热身题或 Coding 环节的第一题。面试官会一边看你写代码,一边观察你的沟通习惯。我整理了几个常见的追问:
- 如果规则扩展成“同时被 3、5、7 整除输出 FizzBuzzWhizz”,你的代码怎么改?
- 能不能把 if-else 改成查表法或字典映射?
- 有没有办法避免字符串频繁拼接带来的性能开销?
最后一个问题其实很有意思。在 JavaScript 或 Python 中,字符串拼接在数据量极大时会有性能隐患。但 LeetCode 的原题要求返回字符串数组,所以“最后再统一拼接”这种优化反而多余。遇到这类追问,最稳妥的回答是:先确认数据规模,再决定要不要优化。超过亿级数据时,可以考虑用流式输出或预处理;普通面试场景下,O(n) 的复杂度已经足够。
3. 两数之和:从暴力到哈希,面试第一课
3.1 题目回顾与暴力解法
给定一个整数数组 nums 和一个整数目标值 target,要求找出和为目标值的两个整数,并返回它们的数组下标。题目保证只有一种答案,而且同一个元素不能使用两次。
这道题是 LeetCode 的第 1 题,也是无数人的算法入门题。很多人第一反应是两层循环暴力遍历:
function twoSum(nums, target) { for (let i = 0; i < nums.length; i++) { for (let j = i + 1; j < nums.length; j++) { if (nums[i] + nums[j] === target) { return [i, j]; } } } return []; }暴力解的问题很明显:O(n²) 的时间复杂度。当数组长度是 10 万时,两层循环意味着最多要执行约 50 亿次比较,这在笔试里大概率会超时。暴力解唯一的优势是空间复杂度 O(1),不需要额外数据结构。面试时你先把暴力解法说出来,再主动优化到 O(n),这本身就是一种展示思路层次的方式。
3.2 哈希表空间换时间的关键细节
哈希表解法核心思想只有一句话:遍历数组时,把已经见过的数存下来,然后快速查找“目标值 - 当前值”是否出现过。
function twoSum(nums, target) { const map = new Map(); for (let i = 0; i < nums.length; i++) { const complement = target - nums[i]; if (map.has(complement)) { return [map.get(complement), i]; } map.set(nums[i], i); } return []; }这里有一个所有初学者都会犯的经典错误:先存当前值,再查哈希表。比如 target 是 6,第一个元素是 3,如果先把 3 存进哈希表,再查target - 3 = 3,就会在哈希表中找到刚刚存入的自身,返回[0, 0]。题目明确要求“同一个元素不能使用两次”,所以必须先查再存,确保匹配到的元素一定在当前位置之前出现过。
我在实际面试中见过不少候选人栽在这个细节上,甚至有人写完之后没发现 bug。面试官提醒一下才恍然大悟。所以当你写这道题时,一定要在脑子里跑一遍这个流程:第 i 个元素进来,先问哈希表“有没有我要的补数”,没有的话把自己记下来。这个顺序就是这道题的全部核心。
3.3 为什么不能先存再查?“自己匹配自己”的问题
“自己匹配自己”这个问题值得单独说一下,因为它非常容易在笔试题的隐蔽用例中出现。假设 nums = [3, 2, 4],target = 6。如果先存后查:
- i=0,nums[0]=3,存入 map,此时 map 是 {3: 0}。
- 回到循环,查
6 - 3 = 3,map 里有 3,返回[0, 0]。
但实际上正确答案是[1, 2],因为 2 + 4 = 6。先存后查的代码在target恰好是某个元素的两倍时,就会错误地返回同一个下标两次。这个 bug 的触发条件非常隐蔽,LeetCode 的测试用例里专门设计了这类 case,直接提交就是 Wrong Answer。
很多人在刷题时觉得“不就差一行代码的顺序吗”,但在面试官眼里,这一行顺序暴露的是你对“哈希表状态”的理解决不到位。哈希表里存的数据是动态变化的,查询结果依赖于插入的时机。同样一道题,先存再查还是先查再存,结果可能完全不同。这种对状态的敏感度,恰恰是工程开发中排查隐蔽 bug 的核心能力。
3.4 面试追问:排序数组、大量重复元素怎么办
两数之和的经典追问有两个。第一个是:如果数组是排好序的,能不能不用额外空间?这时候可以用双指针。左指针指向数组开头,右指针指向结尾,每次比较两数之和,小了左移左边,大了右移右边,时间复杂度 O(n),空间复杂度 O(1)。但要注意,双指针的前提是你不需要返回原始下标,否则排序会打乱下标关系,只能用额外的数据结构记录下来。
第二个追问是:如果数组里有大量重复元素怎么办?哈希表照样能处理,因为 map 中同一个 key 只会保留最新下标,而题目保证只有一组答案,所以重复元素不会影响最终结果。如果面试官问“能不能找所有不重复的组合”,那就升级成了“三数之和”或“四数之和”的变体,需要用排序加双指针来去重,这就完全超出这道题的范畴了。
我的建议是:两数之和这道题,千万不要只背哈希解法。把暴力解和哈希解都吃透,同时了解排序数组场景下的双指针解法,面试时就有足够多的“弹药”应对追问。这道题本来就是 LeetCode 第 1 题,是很多大厂面试的“开场题”,答得好不好直接影响后续环节的节奏。
4. 合并两个有序数组:双指针与“从后往前”的思维转变
4.1 题目回顾与常规合并思路
给定两个有序整数数组 nums1 和 nums2,要求把 nums2 合并到 nums1 中。nums1 的长度是 m + n,其中前 m 个元素是有效数据,后面 n 个位置是占位用的 0;nums2 的长度是 n。要求原地修改 nums1,不能返回新数组。
这道题表面上是归并排序的“合并”步骤,但实际上有一个关键约束:要把结果放到 nums1 里,而且 nums1 的有效数据只占前 m 位,后面 n 位是空出来的。如果我直接从前往后合并,就会遇到一个尴尬的问题:nums1 的有效元素会被覆盖,还没比较完,原始数据就丢了。通常的解决办法是新建一个临时数组保存 nums1 的前 m 个元素,然后再用双指针归并,最后拷贝回 nums1。这种方式能解决问题,但额外空间是 O(m)。
面试官让你原地修改 nums1,考察的核心就是你能不能发现“尾部空间是可利用的”这个突破口。如果能想到从后往前填,就能在 O(1) 额外空间上解决,这比新开数组的方案高一个维度。
4.2 原地合并的突破口:尾部预留空间
为什么从前往后不行,从后往前就行?这个问题的本质是“覆盖顺序”。从前往后填的时候,nums1 前面的空位是要填有效元素的,但 nums1 开头的有效数据还在,你一旦往前面填了新数据,后面的旧数据还没来得及用就被覆盖了。反过来,从后往前填的时候,nums1 的尾部本来就是预留的空位,越往中间填,空位越大,永远不会覆盖还没被比较过的元素。这就是“尾部预留空间”给我们的天然优势。
很多人生搬硬套“双指针”这个概念,只记住了“两个指针各走各的”,却没理解指针方向选择的核心逻辑。实际上,双指针的走向是由数据存放方向决定的。尾部有空间,就从尾部开始;尾部没空间,就要考虑先拷贝腾挪。这个思路不仅适用于这道题,在后来的很多原地操作类题目中都会反复出现。
4.3 三指针代码实现与边界处理
合并两个有序数组的经典写法是三个指针:p1 指向 nums1 有效区域的末尾(即 m - 1),p2 指向 nums2 的末尾(即 n - 1),p 指向 nums1 数组的末尾(即 m + n - 1)。每次比较 nums1[p1] 和 nums2[p2],把较大的值写入 nums1[p],然后移动对应的指针。
function merge(nums1, m, nums2, n) { let p1 = m - 1; let p2 = n - 1; let p = m + n - 1; while (p1 >= 0 && p2 >= 0) { if (nums1[p1] > nums2[p2]) { nums1[p] = nums1[p1]; p1--; } else { nums1[p] = nums2[p2]; p2--; } p--; } // 如果 nums2 还有剩余,直接拷贝到 nums1 前面 while (p2 >= 0) { nums1[p] = nums2[p2]; p2--; p--; } }这段代码有两个边界要特别注意。第一个边界是循环结束条件:当 p1 和 p2 其中一个小于 0 时,说明对应数组已经用完了。第二个边界是循环结束后,只处理了p2 >= 0的情况,为什么不需要处理p1 >= 0?
这里是我刷这道题时最有收获的一个点:如果 p1 还有剩余,说明 nums1 剩余的元素已经是有序的,而且它们本来就排在目标位置的前缀区域;既然 p 已经指向对应位置,这些元素不需要移动,直接留在原地就完成了合并。而 p2 还有剩余时,nums2 的元素必须手动拷贝进 nums1 的前面。很多人会对称地再写一个 while 循环去处理 p1,其实完全没必要,反而多写了一段无效代码。
从复杂度上看,时间 O(m + n),空间 O(1)。这个方案已经是最优解。面试时如果能把这个边界分析讲清楚,比“背下来一段代码”要加分得多。
4.4 这一类题目的迁移思路
合并两个有序数组是“归并思想的入门题”,它的变体非常多。最常见的迁移方向是“合并两个有序链表”。链表的合并不需要考虑数组的覆盖问题,只需要维护一个哨兵节点和一个尾指针,不断比较两个链表的头节点,把较小的接上去,最后接上剩余部分。复杂度同样是 O(n),但指针操作比数组麻烦不少。
另一个迁移方向是“合并 K 个有序链表”或“合并 K 个有序数组”,这就要用到优先队列(堆)来维护当前最小的头节点。如果你把“合并两个有序数组”吃透,再去刷“合并 K 个有序数组”时,你的思维路径会很清晰:两两归并可以用堆优化,从 k 个头里反复取最小,每个元素取一次,总复杂度是 O(n log k)。
最后还有一个特别容易混淆的题叫“Merge Sorted Array”的变体:如果两个数组都在同一个数组里,中间用分隔符隔开,怎么合并?这种题本质上还是同一个套路,只是要把有效区域的下标计算清楚。可以说,掌握了这道题,“双指针 + 归并”这一类题型的基本盘就稳了。
5. 设计链表:手写基础数据结构到底在考什么
5.1 题目回顾与核心考点
“设计链表”这道题要求实现一个链表类,支持以下操作:获取指定下标的节点值、在头部插入、在尾部插入、在指定下标插入、删除指定下标的节点。它的意义和前面的题完全不同——前面的题是“用已知数据结构解题”,这一道是“自己动手实现数据结构”。
我一开始以为这就是个链表基础题,随便写写就行。真正刷完之后我才发现,这道题考察的东西远不止“会不会写 next 指针”,而是对边界条件、指针顺序、内存管理意识的综合检验。很多人能满分写出两数之和,却在这道题上写出一堆边界 bug,原因就是平时用的工具太高级,一旦自己实现底层结构就露怯。
这道题在 LeetCode 上的编号是 707,考察方向完全是“数据结构基本功”。面试中也很常见,尤其是偏后端或底层的岗位,面试官希望看到你能脱离高级语言封装,把链表这种最基础的结构拆清楚。
5.2 用哨兵节点简化边界
单链表最麻烦的问题是“头节点特判”。比如在头部插入节点时,普通写法要先判断链表是否为空,还要单独更新头指针;在删除节点时,删除头节点和删除中间节点的逻辑完全不同。这些特判让代码变得又长又容易出错。
解决这个问题有一个非常经典的手段:虚拟头节点(dummy head)。哨兵节点本身不存储有效数据,但它永远存在,next 指向真正的头节点。这样一来,无论链表为空还是非空,插入和删除操作都统一成了“在同一种前驱节点上操作”,省掉了大量 if-else。
举个例子,删除下标为 index 的节点,如果不用哨兵节点,你需要判断 index 是否为 0,是的话直接让 head = head.next;不是的话要找前驱节点。而用了哨兵节点之后,无论 index 是多少,都从 dummyHead 开始走 index 步,然后让前驱节点的 next 指向后继的后继,代码就统一了。
这个技巧在实际工程中用途极广,很多开源代码里的链表实现都用了哨兵节点。它不太起眼,但能极大减少边界 bug 的出现概率。
5.3 单链表实现与每一步的指针操作
下面给出一份可运行的单链表实现。我用 JavaScript 写,因为 LeetCode 上 JS 也支持 ES6 class 的写法。
class ListNode { constructor(val) { this.val = val; this.next = null; } } var MyLinkedList = function() { this.dummyHead = new ListNode(0); this.size = 0; }; MyLinkedList.prototype.get = function(index) { if (index < 0 || index >= this.size) return -1; let current = this.dummyHead.next; while (index > 0) { current = current.next; index--; } return current.val; }; MyLinkedList.prototype.addAtHead = function(val) { const newNode = new ListNode(val); newNode.next = this.dummyHead.next; this.dummyHead.next = newNode; this.size++; }; MyLinkedList.prototype.addAtTail = function(val) { let current = this.dummyHead; while (current.next !== null) { current = current.next; } current.next = new ListNode(val); this.size++; }; MyLinkedList.prototype.addAtIndex = function(index, val) { if (index < 0 || index > this.size) return; let current = this.dummyHead; while (index > 0) { current = current.next; index--; } const newNode = new ListNode(val); newNode.next = current.next; current.next = newNode; this.size++; }; MyLinkedList.prototype.deleteAtIndex = function(index) { if (index < 0 || index >= this.size) return; let current = this.dummyHead; while (index > 0) { current = current.next; index--; } current.next = current.next.next; this.size--; };这个实现里藏着几个非常关键的指针操作细节。插入节点时,一定要先设置新增节点的 next,再修改前驱节点的 next。如果把顺序反过来,先让current.next = newNode,那原来的后继节点就丢失了,后面再接 newNode 的 next 指向的就是自己,链表直接成环。删除节点时,current.next = current.next.next本质上是让前驱节点跳过目标节点,目标节点虽然还在内存里,但已经没有引用指向它了,后续会被垃圾回收。
这个实现唯一可以优化的是 addAtTail 操作。每次都从 dummyHead 走到链表尾部,复杂度是 O(n),频繁在尾部插入时会比较慢。面试时如果你主动提出维护一个 tail 指针,把 addAtTail 降到 O(1),这是一个明显的加分项。但维护 tail 指针的代价是:所有在头部插入、任意位置删除、按 index 找到节点的操作,都需要同步更新 tail,边界情况更多,新手很容易写挂。我个人建议,面试时先写出单链表版本,讲清楚复杂度,再主动提一句“如果需要频繁尾部插入,可以加一个 tail 指针优化”,就已经足够展示水平了。
5.4 常见边界错误与双链表扩展
我见过太多人在这道题上翻车,翻车原因高度集中在这几个地方:
第一个是 addAtIndex 的 index 边界。原题要求0 <= index <= size时允许插入,当 index 等于 size 时,效果等同于 addAtTail。很多人只写了index < 0 || index >= this.size的判断,导致在尾部插入失败。第二个是 deleteAtIndex 的边界,这个操作只允许0 <= index < size,如果写成index <= this.size,删除时访问 current.next.next 就会报空指针。第三个是忘记维护 size,导致 get 的边界判断失效。
这几个 bug 都非常典型,每一类都可以单独拿出来做一个小型“代码评审案例”。我的经验是:写链表题时,先把“允许的 index 范围”写在注释里,再写循环体,能显著降低越界 bug 的概率。
这道题还有一个自然的扩展方向:双链表。双链表每个节点多一个 prev 指针,删除节点时可以直接一步完成,不用找前驱,但插入和删除时要同时维护两个方向的指针,容易多写漏写。LeetCode 的 707 题也开放了双链表的实现空间。如果你想挑战更高难度,可以尝试先写单链表版本通过全部测试用例,再改成双链表版本,对比两者的代码量和出错率。这个过程能帮你真正理解“空间换时间、代码复杂度换运行效率”的权衡。
6. 常见问题与面试复盘实录
6.1 时间、空间复杂度对照速查
刷完这四道题,建议你把这四道题拉到一个表里做一次横向对比。这能帮你在大脑中建立“题目 -> 解法 -> 复杂度”的快速映射,面试时被问到“这个方案的空间复杂度是多少”时,你不需要现场推导,直接就能答出来。
| 题目 | 最优解法 | 时间复杂度 | 空间复杂度 | 核心考点 |
|---|---|---|---|---|
| Fizz Buzz | 条件分支 + 取模 | O(n) | O(1)(结果数组除外) | 分支优先级、周期思维 |
| 两数之和 | 哈希表 | O(n) | O(n) | 空间换时间、查询状态 |
| 合并两个有序数组 | 三指针从后往前 | O(m + n) | O(1) | 原地操作、尾部空间利用 |
| 设计链表 | 哨兵节点 + 单链表 | get/插入/删除为 O(n),头插 O(1) | O(n) | 指针操作、边界处理 |
这个表的每一行都值得展开记忆。比如“两数之和”的空间复杂度为什么是 O(n),因为哈希表最多存 n 个键值对;“合并两个有序数组”为什么能做到 O(1),因为题目刻意在 nums1 尾部预留了空间;“设计链表”为什么 get 是 O(n),因为链表不支持随机访问,必须从头遍历到 index。
6.2 现场写代码时的“加分动作”
面试和笔试不一样。笔试只需要代码正确,面试更看重你写代码的过程。我总结了几个实际面试中很好用的加分习惯,这四道题都适用:
先说出思路再动笔。比如做两数之和时,可以先说“我打算用一个哈希表保存已经遍历过的数和下标,每次查找补数是否存在”,让面试官知道你是有思考的,而不是在那里瞎试。写代码前先写边界判断。合并有序数组时,先在注释里写清楚“p1 和 p2 哪个先耗尽就停止循环,然后处理剩余部分”,再写具体逻辑。写完代码后主动跑一个测试用例。比如设计链表时,测一遍 addAtIndex(0, x)、addAtTail、deleteAtIndex 的完整流程,说明你会主动验证自己的代码而不是写完就扔。
这些动作本身不加代码行数,但对面试评价的提升非常明显。我做过模拟面试官,看到候选人在白板上画指针、写注释、口头跑用例,基本就能给他打一个“思路清晰”的标签。反之,一个人闷头写完也不解释,哪怕代码是对的,面试官也很难确认他是真的懂还是恰好背过。
6.3 复盘方法:错题本应该记什么
刷完这四道题,最重要的不是往后翻“下一题”,而是做一次复盘。我自己的复盘方法很简单:每道题准备一张卡片,正面写题目名,背面写四件事——考点、最优解法、时间空间复杂度、我卡住的地方。
这四道题我卡住的地方分别是:Fizz Buzz 第一次把 15 的判断写在后面;两数之和第一次先存后查导致“自己匹配自己”;合并有序数组第一次想不开,非要从前往后然后新建数组;设计链表第一次忘记维护 size,导致 get 无效。这些坑现在看起来都好低级,但如果你不记,下个月换一道差不多的题,你大概率还会在同类型的地方栽跟头。
复盘的意义是找到“通用薄弱点”,而不是盯着一道题。我的感受是:这四道题的核心薄弱点高度集中在“边界条件意识”和“状态变化顺序”上。一旦你意识到这一点,后续刷题时就会主动去关注这两类问题,提升速度会快很多。
7. 写在最后的几点体会
这四道题是我认为刷题入门阶段最值得反复咀嚼的一组题,没有之一。它们难度不高,但每一道都藏着至少一个“看似简单、实则关键”的细节。如果你现在刚开始刷题,我建议你先别急着追求题量,把这四道题手写三遍,每一遍都试着不看题解独立完成,然后对比三遍代码之间的差异,你会发现自己对边界条件的敏感度在快速上升。
我个人刷完这四道题后最大的变化是:不再害怕“简单题”了。以前总觉得 Easy 题没什么可刷,后来才明白,面试中翻车最惨的往往不是难题,而是这些想当然的“送分题”。Fizz Buzz 的分支顺序、两数之和的先查后存、合并有序数组的尾部往前的指针方向、设计链表的哨兵节点,哪一道都让无数候选人当场表演“代码翻车”。把这些基础点焊死,后面的进阶之路才走得稳。
如果你想在这个方向继续深入,我的建议是按这个顺序往下走:先刷“三数之和”,把两数之和的双指针思路延伸出去;再刷“合并两个有序链表”和“合并 K 个有序链表”,把归并思想吃透;最后用“设计双链表”来挑战自己。这组题刷完,你对于哈希、双指针、链表、归并这几大基础模块的理解,就会从“会做题”变成“会迁移”,进入真正能打的阶段。