
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。如果先存后查i0nums[0]3存入 map此时 map 是 {3: 0}。回到循环查6 - 3 3map 里有 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 个位置是占位用的 0nums2 的长度是 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 - 1p2 指向 nums2 的末尾即 n - 1p 指向 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 个有序链表”把归并思想吃透最后用“设计双链表”来挑战自己。这组题刷完你对于哈希、双指针、链表、归并这几大基础模块的理解就会从“会做题”变成“会迁移”进入真正能打的阶段。