算法面试基础题深度解析:Fizz Buzz、两数之和、合并数组与链表设计
发布时间:2026/10/1 3:29:51来源:尧图网络
最近带几个准备面试的朋友刷题我发现一个特别普遍的现象很多人上来就冲困难题、背所谓的高频题反而把Fizz Buzz、两数之和、合并两个有序数组、设计链表这四道题当成“做完就算完成”的填空题。可真到了面试现场被面试官追问一句“如果数据量变成几十万你的解法还成立吗”或者“你的链表实现里删除末尾节点时发生了什么”立刻就卡壳了。这篇文章我想认真聊聊这四道题。它们表面看毫无关联——一道是基础逻辑一道是数组查找一道是双指针合并一道是数据结构设计——但站在面试准备的视角它们恰好是同一条基本功训练链路上递进的四个台阶。把这几道题做透比潦草地刷二十道中等难度题更有价值。1. Fizz Buzz看似送分其实在考你的条件分支设计1.1 先看最常见的标准解法题目描述不用多说从 1 到 n遇到 3 的倍数输出 Fizz遇到 5 的倍数输出 Buzz同时是 3 和 5 的倍数输出 FizzBuzz其他情况输出数字本身。大部分人第一次写出来的版本长这样public ListString fizzBuzz(int n) { ListString ans new ArrayList(); for (int i 1; i n; i) { if (i % 3 0 i % 5 0) { ans.add(FizzBuzz); } else if (i % 3 0) { ans.add(Fizz); } else if (i % 5 0) { ans.add(Buzz); } else { ans.add(String.valueOf(i)); } } return ans; }这个写法能通过逻辑也对。但我想说的是把条件i % 3 0 i % 5 0放在最前面其实隐含了一个顺序问题。初看没什么可一旦面试官把规则改一下比如“能被 7 整除的数也要输出一个词”或者“如果同时满足多个条件就把词拼接在一起”这种分支写法就要返工。1.2 为什么“先判断 15”比“先判断 3 再判断 5”更严谨在实际面试里我更推荐写成i % 15 0if (i % 15 0) { ans.add(FizzBuzz); } else if (i % 3 0) { ans.add(Fizz); } else if (i % 5 0) { ans.add(Buzz); } else { ans.add(String.valueOf(i)); }逻辑完全等价但表达更简洁。更重要的是它逼着你先想到“15 的倍数 同时满足两个条件”这个数学表达而不是机械地写两个。很多人在日常业务代码里习惯了“能跑就行”刷题时也带着这个习惯结果被问到“为什么 15 的判断要放在前面”时支支吾吾说不清楚。这里的核心考点是else if的链式判断是有顺序语义的。一旦前面的条件命中后面不会再执行。如果不把 15 的倍数放到最前就会出现 30 输出 Fizz 而不是 FizzBuzz 的 bug。反过来如果你先判断 3 的倍数就 return那 15 的倍数永远走不到后面那条 5 的倍数分支。这个顺序意识比 Fizz Buzz 本身值钱得多。1.3 用字符串拼接解耦规则从容应对变体真正的加分项是这样一种写法不急着判断“最终输出谁”而是逐个规则叠加public ListString fizzBuzz(int n) { ListString ans new ArrayList(); for (int i 1; i n; i) { StringBuilder sb new StringBuilder(); if (i % 3 0) sb.append(Fizz); if (i % 5 0) sb.append(Buzz); if (sb.length() 0) sb.append(i); ans.add(sb.toString()); } return ans; }这种实现的优势在于把每个规则独立成一条if互不依赖。面试官如果现场加需求“能被 7 整除输出 Whizz”你只需要增加一条规则追加逻辑原来的代码一行都不用动。可读性和扩展性一下子拉开了。我在实际面试中见过不少候选人对这道题的态度是“太简单了没必要讲思路”结果在“能不能说说时间复杂度和空间复杂度”这个问题上翻车。Fizz Buzz 的时间复杂度是 O(n)空间复杂度是 O(1)不考虑结果集。就这么简单的一个点很多人回答得含糊。我的建议是哪怕遇到再简单的题也把解题思路拆成“遍历、判断、收集结果”三个步骤讲给面试官听这才是交流而不是闷头写代码。2. 两数之和暴力解只是起点哈希表才是这道题想教你的东西2.1 暴力双循环为什么不是好答案两数之和应该是 LeetCode 上知名度最高的一道题了给定一个整数数组 nums 和一个目标值 target找出和为 target 的两个数返回下标。暴力做法是双重循环public int[] twoSum(int[] nums, int target) { for (int i 0; i nums.length; i) { for (int j i 1; j nums.length; j) { if (nums[i] nums[j] target) { return new int[]{i, j}; } } } return new int[]{-1, -1}; }这段代码在数据量很小时完全没问题。但如果 nums 的长度是十万、百万呢O(n²) 的耗时就是十亿、万亿级别无法接受。面试官问两数之和表面上是考你会不会哈希表实际上是看你能不能从“找组合”的思维切换到“找差值”的思维。2.2 哈希表的本质把第二层循环换成一次查询换个角度想我需要找两个数 a 和 b满足a b target。当我站在数组的某个元素nums[i]上时我真正需要知道的只有一件事target - nums[i]这个数之前有没有出现过“之前有没有出现过”就是典型的哈希表应用场景。用一个 Map 记录已经遍历过的值和它的下标每次只需要 O(1) 时间的查询整体时间复杂度降到 O(n)public int[] twoSum(int[] nums, int target) { MapInteger, Integer map new HashMap(); for (int i 0; i nums.length; i) { int need target - nums[i]; if (map.containsKey(need)) { return new int[]{map.get(need), i}; } map.put(nums[i], i); } return new int[]{-1, -1}; }空间上是典型的“用空间换时间”额外引入了一个哈希表空间复杂度 O(n)。很多人在面试时能背出这个解法但被问“为什么要把当前值存进 map 之后再继续遍历”时答不上来。这里其实藏着这道题最容易踩的一个细节。2.3 先查后存而不是先存后查假设数组是[3, 3]target 是 6。正确的做法是遍历第一个 3查 map 里有没有另一个 3 —— 没有把下标 0 存进去。遍历第二个 3查 map 里有没有另一个 3 —— 有返回[0, 1]。一切正常。但如果把顺序反过来先存再查遍历第一个 3先把下标 0 存进 map。查 map 里有没有6 - 3 3—— 有而且是刚存进去的那个 3。得到的结果就是[0, 0]同一个元素被用两次完全错误。更隐蔽的变种错误是“存的是值而不是下标”或者用数组值去比较导致重复匹配。所以“先查后存”不仅是一个代码习惯更是逻辑上保证不重复使用当前元素的必要条件。这个细节面试官几乎 100% 会追问。2.4 变体迁移有序数组的双指针思路两数之和还有一个经典变种数组本身是有序的。这时不需要哈希表用双指针从两端往中间走public int[] twoSum(int[] nums, int target) { int left 0, right nums.length - 1; while (left right) { int sum nums[left] nums[right]; if (sum target) return new int[]{left, right}; else if (sum target) left; else right--; } return new int[]{-1, -1}; }为什么有序之后双指针成立因为当你把 left 向右移动时两数之和只会变大把 right 向左移动时两数之和只会变小。有序性给了我们“决策方向”的依据。把这个知识点和基本款连起来看你会发现哈希表和双指针是解决两数之和的两种不同思路一个适用于无序数组一个适用于有序数组。面试时能主动说出“如果数组有序还可以用双指针把空间复杂度降到 O(1)”这比死记硬背代码要加分得多。3. 合并两个有序数组原地合并的真正难点不在合并而在“不覆盖”3.1 正向遍历的最大陷阱合并两个有序数组LeetCode 88的题干比较特殊nums1的长度是m n前 m 个位置是有效元素后 n 个位置是占位用的 0nums2的长度是 n最后要把合并结果整体放进nums1且要求原地操作。很多人一上来就写正向双指针用两个指针从头开始比较nums1[i]和nums2[j]谁小就把谁放到nums1的第 k 个位置。问题马上就会出现nums1的有效元素在数组的开头当你把较大值往后挪的时候会把还没比较的原始元素覆盖掉。举个最简单的例子nums1 [1, 2, 3, 0, 0, 0]nums2 [2, 5, 6]。正向操作时第一次比较 1 和 2把 1 放回原位没问题继续往后当需要放置一个较大的值到nums1前面位置时很可能覆盖掉还没有轮到它“出战”的元素。要解决这个问题就得额外开一个临时数组存nums1的有效部分——这虽然能做对但完全违背了“原地合并”的意图空间复杂度被拉高到 O(m)。3.2 逆向双指针从尾部开始才是真正的“原地”正确做法是让两个指针分别从有效区间的末尾出发p1指向nums1的第 m - 1 个元素p2指向nums2的第 n - 1 个元素p指向nums1数组末尾也就是m n - 1每次比较nums1[p1]和nums2[p2]把更大的那个放到nums1[p]位置然后对应的指针前移public void merge(int[] nums1, int m, int[] nums2, int n) { int p1 m - 1; int p2 n - 1; int p m n - 1; while (p2 0) { if (p1 0 nums1[p1] nums2[p2]) { nums1[p--] nums1[p1--]; } else { nums1[p--] nums2[p2--]; } } }为什么逆向就没有覆盖问题因为nums1有效元素只在数组前段后面 n 个位置本来就是空的“缓冲地带”。从尾部开始填值每个被覆盖的位置都是已经处理完的废弃位置自然不会影响尚未比较的数据。这个思路的价值不仅在于解题更在于它揭示了“原地算法”的一个通用套路如果没有多余空间就从数组的尾部向头部反向操作把空间“撑”出来。3.3 边界条件m 或 n 为 0 的情况这道题的边界条件也很容易踩坑。如果m 0说明nums1里全是占位的 0循环中p1初始就是 -1。进入 while 后p1 0的判断直接短路所有元素都从nums2中取最后结果正确。如果n 0p2初始为 -1整个 while 循环一次都不会进入因为没有任何需要合并的元素。这两种情况看起来简单但很多人在笔试环节就是因为没考虑到p1可能变负而写出数组越界。另一个值得注意的细节是while (p2 0)的写法实际上隐含了一个结论——当nums2合并完不需要再把nums1的剩余元素做任何操作它们已经在正确的位置上。如果反过来写while (p1 0 p2 0)合并完后还得单独写一段循环把nums2的剩余元素复制过去代码会更繁琐。从循环条件上就规避一类分支是写好这类题的小窍门。3.4 为什么说这道题是归并排序的“最小单元”合并两个有序数组做到熟练之后再去写归并排序的merge阶段会非常轻松。归并排序之所以时间复杂度是 O(n log n)核心依据就是把两个有序子数组合并成一个有序数组只需线性时间 O(n)。面试里你提到这个联系面试官很可能会顺势追问“那你写一下链表的归并排序”“或者两个有序链表怎么合并”。合并两个有序链表就是这道题的链表版本思路完全同构只是把数组指针换成了链表节点。所以这道题看起来只是“做一个 merge 操作”实际上是为后续的高频题目做铺垫。我个人建议至少把数组版本做两遍第一遍放开限制开临时数组第二遍再要求自己原地完成体会两种写法在空间使用上的差异。4. 设计链表一道“大而全”的数据结构设计题如何拆解需求4.1 拿到设计题先想清楚接口清单设计链表LeetCode 707要求实现一个链表类包含get、addAtHead、addAtTail、addAtIndex、deleteAtIndex这些操作。说它“大而全”是因为它不像前面几道题只考一个点而是把链表最常用的几种操作全部集成在一起任何一个操作有 bug 都会被测试用例逮住。我的建议是动笔之前先把每个方法的语义边界写在草稿纸上。get(index)下标从 0 开始非法下标返回 -1。addAtHead(val)在头部插入。addAtTail(val)在尾部插入。addAtIndex(index, val)在下标为 index 的节点之前插入如果 index 等于链表长度插到末尾如果 index 大于长度什么都不做。deleteAtIndex(index)删除下标为 index 的节点非法下标什么都不做。把边界写清楚比写下代码更重要。因为链表题的大多数 bug 都出在“边界位置没想明白”。4.2 用虚拟头结点统一增删逻辑避免大量 if 分支链表实际写起来最烦的是什么处理头节点的特殊情况。比如删除头节点时因为没有前驱节点逻辑会比其他删除多一个分支插入头部位置时也需要单独绕一下。写多了 if 分支代码就会变得又臭又爱出错。一个非常实用的技巧是在真正的头节点之前加一个“虚拟头结点”dummy head。它的val是什么无所谓关键是它让每个真实节点都拥有了前驱于是“在任意位置插入”和“删除任意位置节点”的代码逻辑可以完全一致class MyLinkedList { private static class Node { int val; Node next; Node(int val) { this.val val; } } private Node head; // 虚拟头结点 private int size; public MyLinkedList() { head new Node(0); size 0; } public int get(int index) { if (index 0 || index size) return -1; Node cur head; for (int i 0; i index; i) { cur cur.next; } return cur.val; } public void addAtHead(int val) { addAtIndex(0, val); } public void addAtTail(int val) { addAtIndex(size, val); } public void addAtIndex(int index, int val) { if (index 0 || index size) return; Node prev head; for (int i 0; i index; i) { prev prev.next; } Node node new Node(val); node.next prev.next; prev.next node; size; } public void deleteAtIndex(int index) { if (index 0 || index size) return; Node prev head; for (int i 0; i index; i) { prev prev.next; } prev.next prev.next.next; size--; } }有了虚拟头结点addAtHead(0)和deleteAtIndex(0)都不需要特殊分支只需要找到下标为 index 的前驱节点即可。你会发现addAtHead、addAtTail甚至可以直接复用addAtIndex因为头部就是下标 0尾部就是下标size。整段代码的重复分支被压缩到最少。4.3 单链表实现的关键指针操作先接后断在addAtIndex里有一个操作顺序的细节node.next prev.next; prev.next node;这两行顺序能不能反过来不能。如果先把prev.next改成node那么原来的后继节点就“找不到了”新节点也就接不上正确的位置。这就是我一再强调的“先接后断”——先把新节点的 next 指向后继再把前驱的 next 指向新节点。很多初写链表的人在节点插入上栽跟头几乎都是因为这两行顺序写反了。删除操作类似删除某个节点时让前驱的 next 直接越过目标节点指向后继。这个动作只改前驱的引用被删除节点自己不用动。在需要手动管理内存的语言如 C 里还要记得释放目标节点的内存在 Java、Python 这类有垃圾回收的语言里这一步可以省略但仍建议在思维中保留“这个节点已经不可达了”的意识。4.4 双链表的额外约束prev 指针的维护必须成对进行如果面试官要求进阶到双向链表事情会复杂一些。每个节点多一个prev指针插入和删除时更新指针数量加倍。核心原则是每次增删操作你不能只改 next必须同时维护好前驱指向前驱的 prev否则链表遍历时就会断链。以双链表的addAtIndex为例假设prev是目标位置的前驱node是新节点next prev.nextnode.prev prev; node.next next; next.prev node; prev.next node;注意新增的两行同样有顺序讲究先把node的 prev 和 next 指好再把它“挂进”链表里。很多人在这一步漏掉next.prev node写完代码测试也能过一半用例但一旦涉及从尾部反向遍历或删除最后一个节点就会暴露出致命问题。4.5 设计题面试官真正在看什么设计链表的难点不在于单个操作而在于多个操作之间的状态一致性size是否每个操作都同步更新get和deleteAtIndex对非法下标的处理是否一致addAtIndex是否允许 index 等于 size这些问题在代码里都是很细的分支但面试官一眼就能看出你在写链表时有没有“全局观”。另外我能给到的实操建议是写完之后一定要手动跑一遍空链表场景。空链表时调get(0)返回什么空链表时调deleteAtIndex(0)应该什么都不做这些用例代码量很小但最暴露问题。我自己带人刷题时发现“空链表 下标 0”的组合是错误率最高的一类边界几乎每个人都在这栽过跟头。5. 四道题串起来就是算法基本功的“最小闭环”5.1 四道题分别训练什么能力如果把四道题放在一张表里对照各自对应的能力点非常清晰题目核心数据结构考点常见错误Fizz Buzz数组/字符串条件分支顺序、可扩展设计15 的倍数被前面的分支截走两数之和哈希表查找优化、空间换时间先存后查导致自配对合并两个有序数组数组 双指针原地操作、逆向思维正向遍历覆盖未处理数据设计链表链表指针操作、边界处理更新 size 遗漏、虚拟头结点缺失这四道题恰好覆盖了算法面试里最常被考察的四种能力模型逻辑分支能力、查找优化能力、指针操作能力、边界设计能力。它们都不算难但每道都对应着一个“低级错误高发区”。把错误都踩一遍、再想明白为什么错比直接背正确解法要扎实得多。5.2 我建议的练习顺序我给朋友定的顺序是先做Fizz Buzz热身并训练“开口讲思路”的习惯。再做合并两个有序数组因为它不涉及哈希表等复杂结构只考双指针和边界容易建立信心。接着做两数之和从暴力解法到哈希表解法感受复杂度优化带来的思维转变。最后啃设计链表把这套题当成一次小型的“代码组织能力体检”。这个顺序的另一个好处是每道题做完都能和下一道题产生联系。合并数组的逆向双指针能迁移到后面的链表归并两数之和的哈希表思路几乎贯穿后续所有“找配对”类问题设计链表则把前面几道题里潜在的“边界意识”集中放大。5.3 复盘时可以问自己的问题清单一道题做完不要急着跳到下一道。我会建议你对着下面这份清单自问一遍我能在不写代码的情况下把解题思路讲清楚吗前提条件是什么我的时间复杂度是多少空间复杂度是多少还有没有更优解数组为空、长度为 1、目标值不存在时我的代码行为正确吗如果数据规模扩大十倍我的解法还成立吗如果把数据结构换成链表、换成字符串同样的思路还能用吗尤其是第四和第五个问题价值很大。两数之和的无序数组解法是哈希表有序数组解法是双指针合并有序数组的逆向双指针放到合并有序链表里就变成了普通的节点引用操作。能迁移才算真正掌握。刷题这件事我一直的观点是“在精不在多”。与其一天刷十道题然后一周全忘光不如把像Fizz Buzz、两数之和、合并两个有序数组、设计链表这样的基础题每道都写三遍第一遍不求快只求对第二遍计时完整写出第三遍脱离 IDE 在纸上画清楚每一步指针变化。三遍之后你再去做中等难度的题目会发现很多所谓的新题不过是这几个基础模型的排列组合。这四道题看着不起眼但它们值得你认真对待。
网站建设高端定制企业官网