这题我太有发言权了。Fizz Buzz、两数之和、合并两个有序数组、设计链表——这四道题,基本就是算法面试的“开场白”,也是很多入门选手第一次体会到“原来代码还能这么写”的启蒙题。有的看起来简单到让人觉得是在侮辱智商,有的则藏着数据结构的基础功。但它们都指向同一个能力:把逻辑想清楚,再翻译成代码。
这篇文章我会把这四道题逐一拆开——从暴力解到最优解,从会写到写对,从做出来到讲清楚。包括一些不写一行代码就能看出问题的细节:边界条件、溢出、指针移动、哨兵节点。看完你会发现,面试考这四道题,从来不是为了考你会不会背答案,而是看你在压力下能不能把一道经典题目聊得足够清楚。
1. 这四道题到底在考什么
先说结论:Fizz Buzz 考的是“你在写 if 分支时,脑子里有没有执行顺序的概念”;两数之和考的是“你能不能意识到查找比遍历更值钱”;合并两个有序数组考的是“你对数组尾部空间和指针移动是否敏感”;设计链表考的是“写数据结构的熟练度和对指针的掌控力”。
把这四道题放在一起看,像不像一套给开发者的“基础体检”?它们覆盖了循环、分支、哈希表、双指针、链表、边界处理、类设计——这些单拆出来都是面试里的常客。而且这四道题非常适合蒙着眼睛手写代码来练感觉。不需要复杂的环境,有纸有笔,或者打开一个在线编辑器就能开干。
我的建议是:这些题不要只看别人的题解,然后觉得自己“会了”。你合上屏幕,能白板写出没有明显 bug 的版本吗?能说出每一步为什么要这么做吗?如果能,那说明你真的掌握了;如果不能,请把这篇文章看完,跟着我走一遍思路。
再说个经验之谈:很多人在 LeetCode 上把这些题标为“简单”之后,直接跳过不刷了。遇到面试真考了,一紧张容易翻车。比如 Fizz Buzz 把i % 3 == 0 && i % 5 == 0的顺序写错,或者把合并有序数组时常见的nums1长度混乱问题搞错。简单题考察的是基本功,基本功不牢,后面进阶题你只会更难受。
2. Fizz Buzz——最简单的题其实最考验细节
2.1 先写一版能跑的代码
Fizz Buzz 的题干非常经典:给定一个整数 n,从 1 到 n 遍历,如果能被 3 整除,输出 Fizz;如果能被 5 整除,输出 Buzz;如果既能被 3 又能被 5 整除,输出 FizzBuzz;其他情况输出数字本身。
你会发现大多数人第一次写出来的代码是这样的:
for (int i = 1; i <= n; i++) { if (i % 15 == 0) { System.out.println("FizzBuzz"); } else if (i % 3 == 0) { System.out.println("Fizz"); } else if (i % 5 == 0) { System.out.println("Buzz"); } else { System.out.println(i); } }这里我用i % 15 == 0代替了i % 3 == 0 && i % 5 == 0。两种写法都对,但15这个写法隐含着一个乘法思维:既能被 3 整除又能被 5 整除的数,一定能被 15 整除。反过来也成立。这不是什么高深的数学,只是提醒你:不要只盯着条件本身,可以稍微换算一下,合并条件。
2.2 最容易错的地方:分支顺序
我有一个朋友,去某大厂面试,面试官真的出了 Fizz Buzz。他三分钟就写完了,面试官看完之后点了点头,然后问:“如果我把i % 5 == 0这个分支提前到i % 3 == 0之前,会出什么问题?”
他想了一会儿才意识到——如果提前,15 会被先输出为 Buzz,而不是 FizzBuzz。这就是 Fizz Buzz 的核心陷阱:分支顺序。你必须把“既被 3 整除又被 5 整除”的情况放在最前面,否则结果会被后面的单条件分支提前截胡。
来看一个实际例子。如果 n = 15,你按先判断 15、再判断 3、再判断 5 的顺序,输出是 FizzBuzz;如果你先判断 5,15 会先满足i % 5 == 0从而输出 Buzz,导致结果错误。很多人写代码的时候想当然,觉得所有分支都是平等的,其实 if-else if 是串行匹配的,先到先得。
要注意一种更“优雅”的写法,也是不少 Java 程序员会优先想到的:
for (int i = 1; i <= n; i++) { String res = ""; if (i % 3 == 0) res += "Fizz"; if (i % 5 == 0) res += "Buzz"; if (res.isEmpty()) res = String.valueOf(i); System.out.println(res); }这种写法的好处是:不再依赖于分支顺序,因为每次都在追加字符串,只有两者都不满足时才回退到数字本身。它能顺便规避 15 那个问题,不用特意去优先判断组合情况。代价就是多了一次字符串拼接,性能上会略慢一点,但在算法面试中,性能和可读性你可以先选可读性。
2.3 这个题目还有多少花样可以翻
面试官如果觉得不过瘾,会往这些方向追问:
- 如果把“3 和 5”换成“其他数”,比如 4 和 7,你的代码还能复用吗?
- 如果要求改成:遇到 3 的倍数输出 Fizz,遇到 5 的倍数输出 Buzz,遇到 7 的倍数输出 Bazz,三者叠加怎么办?
- 如果数字到达 10 的 6 次方,你的循环和字符串拼接还撑得住吗?
这些本质上都是在同一个套路里加复杂度。第一问还好,改两个数字就行;第二问就要你考虑多个可叠加的映射规则,上面那种字符串追加方式会更好扩展;第三问就是在提醒你注意时间复杂度和内存占用。
你可能觉得 Fizz Buzz 太简单了,但它真的能测试出候选人写代码时是否有“防御性思维”。大部分人刷题只求 AC(Accepted),却忽略了代码设计上的弹性和可读性。多想想这些问题,对你的代码水平提升远比多做几道难偏题有帮助。
3. 两数之和——暴力解法到哈希表的思维跨越
3.1 先想暴力解法,别跳步
两数之和的题意很简单:给定一个整数数组 nums 和一个整数目标值 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}; } } }这段代码没有任何问题,逻辑完全正确,复杂度是 O(n²)。但问题在于:当 nums 长度到 10⁵ 级别,O(n²) 会直接把你的程序拖到无法接受的程度——大约要执行 10¹⁰ 量级的操作。所以你需要从“嵌套遍历找配对”的思想,切换成“用一个容器记录我见过的值”。
这背后是对循环成本的理解。每一次内层循环,其实都在做一次查找:查找有没有一个数字等于 target 减去当前值。那为什么不用哈希表来加速查找呢?查找的时间复杂度能从 O(n) 降到 O(1) 均摊。
3.2 哈希表的正确打开方式
核心思路很简单:遍历数组时,把每一个元素的值作为 key,下标作为 value 存到哈希表里。每次遍历到一个新元素 num,先检查 target - num 在不在哈希表里。如果在,直接返回当前索引和哈希表中那个键对应的索引;如果不在,把当前元素放进哈希表。
Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (map.containsKey(complement)) { return new int[]{map.get(complement), i}; } map.put(nums[i], i); }你可以看到,循环从两层变成了一层,代价是额外 O(n) 的空间来存储哈希表。这种“拿空间换时间”的经典做法,在算法题里几乎是万能钥匙。你会意识到:遍历中如果伴随着大量的重复“查找”操作,就该想想有没有什么数据结构能更快地完成它。
3.3 几个很容易在写代码时踩到的细节
第一,重复元素问题。比如 nums = [3, 3],target = 6。第一次遍历到第一个 3 时,map 为空,检查 complement 为 3 不存在,于是把第一个 3 存入 map。第二次遍历到第二个 3 时,complement 是 3,map 里已经有第一个 3 了,于是返回 [0, 1]。这个场景毫无问题。但如果你的数字有负数,也完全不受影响,因为 complement 可能是负数,哈希表照常处理。
第二,返回的是下标,不是数值。很多人写着写着,把 value 存成数字本身,结果返回成了数值,面试官一问就露馅。你要记住:这道题要的是索引,所以哈希表的 value 一定是索引。
第三,关于 HashMap 的遍历顺序。有人说,从前往后遍历是不是要先把整个数组都塞进 map 再查找?不需要。边遍历边塞,能天然避免同一个元素和自身匹配的情况。比如 nums = [5],target = 10,你如果先把 5 塞进去再开始查找,就会错误地匹配到同一个元素。边遍历边查找,从逻辑上就规避了这个问题。
我一般在写完这种“一锤子买卖”的查找代码后,会补一个测试用例验证:nums = [2, 7, 11, 15], target = 9,跑一轮,确认输出 [0, 1]。虽然简单,但能帮你快速排除空指针、类型错误等低级问题。面试手写代码时,宁可写慢一点,也要保证一遍对的概率。
4. 合并两个有序数组——为什么“从后往前”是正解
4.1 题目里的特殊条件,决定了最优解的方向
合并两个有序数组,题干下面通常有一句话:nums1 的长度是 m + n,其中前 m 个元素代表需要合并的部分,后 n 个是占位用的 0;nums2 的长度是 n。你需要把 nums2 合并到 nums1 中,最终让 nums1 整体有序。
如果这道题允许你用一个额外的新数组,那就简单了——把两个数组的元素逐个比较大小,依次放入新数组。但 LeetCode 上的要求是“就地合并”,即不允许新建一个和 nums1 等长的数组。这时候解法就有了讲究。
为什么不创建一个新数组,再让 nums1 指向新数组?在某些语言里看起来可行,但在面试场景下,面试官想看的就是你对“原地修改”的理解。而且这道题的经典做法,也藏着一种很聪明的思维——利用数组尾部没有占用的空间来从后往前填充。
4.2 从前往后会出现什么麻烦
假设正面合并:从 nums1 的第 0 个位置开始,比较 nums1[0] 和 nums2[0],把较小的放进 nums1[0]。直接覆盖原有的 nums1 元素,会丢失数据。你得用一个临时变量或移动元素的方式处理,复杂度瞬间上来了。
这就像你在一个已经排好队的队伍里插入一个人,你只能让后面所有人往后退。但如果你从队伍最末尾开始安排位置,每次直接放入正确的人,就没人会被挤掉。
这个思路翻译成指针就是:用三个指针,分别指向 nums1 有效元素的末尾(p1 = m - 1)、nums2 的末尾(p2 = n - 1),以及 nums1 整个数组的末尾(p = m + n - 1)。每次比较 nums1[p1] 和 nums2[p2],取大的放到 nums1[p],然后向前移动对应的指针。
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--]; } }注意循环条件是p2 >= 0,不是p1 >= 0。当 nums2 被消耗完,剩下 nums1 的元素已经在正确位置上,不用再动。当 nums1 被消耗完,而 nums2 还有剩余,就直接把剩下的 nums2 依次填到 nums1 前面。这个逻辑如果不理清楚,写起来很容易数组越界。
4.3 合并有序数组的面试延伸,远不止这一道
很多面试官问完这题,会连着追问:如果两个数组都是链表怎么办?如果要求找第 k 大的合并后元素怎么办?其实这些都是同一个能力——指针操作与边界控制的迁移。你先在一个数组题上练得足够熟练,后面遇到链表的合并、甚至接雨水那种更复杂的双指针题,才会觉得顺手。
我个人在练习这个题时,会刻意训练自己不看答案,手推一个小案例来验证代码。比如 nums1 = [1, 2, 3, 0, 0, 0],nums2 = [2, 5, 6],m = 3,n = 3。你手动走一遍倒序比较:先拿 3 和 6 比,把 6 放到最后;再拿 3 和 5 比,把 5 放倒数第二;再拿 3 和 2 比,把 3 放倒数第三……直到结束。这样手推几轮,你对这个指针逻辑的理解会非常透彻。
很多时候刷题容易陷入“背题解”的误区。合并两个有序数组正是一个好例子,你需要理解的是“为什么从后往前不会覆盖未使用位置”。一旦你想明白了,这道题就再也不会错了。
5. 设计链表——手写数据结构的基础功
5.1 从一个能工作的骨架开始
设计链表这题,LeetCode 上有几个要求:实现 get(index)、addAtHead(val)、addAtTail(val)、addAtIndex(index, val)、deleteAtIndex(index) 这些基本操作。很多人觉得这题麻烦,主要是不太习惯从头定义一个链表节点类。
先定义一个节点类:
class ListNode { int val; ListNode next; ListNode(int val) { this.val = val; } }再定义链表主体:
class MyLinkedList { int size; ListNode head; public MyLinkedList() { size = 0; head = null; } }这里我故意没有加哨兵节点,先用最朴素的方式实现一遍。朴素实现的问题是:在头部插入时,需要分情况判断 head 是否为空;在尾部插入时,需要遍历到最后一个节点。写起来麻烦,容易漏判断。这时候哨兵节点的价值就体现出来了。
5.2 哨兵节点:链表的“假头部”
哨兵节点是一个不存储有意义值、只是作为链表起点存在的节点。初始化时,head指向哨兵节点。这样一来,所有插入删除逻辑都不用再对“链表是否为空”做特殊判断,因为链表的逻辑头节点永远存在。操作时永远通过head.next来表示真正的第一个元素。
class MyLinkedList { int size; ListNode head; public MyLinkedList() { size = 0; head = new ListNode(0); head.next = null; } }这就像你给一个队列前面加了一块“空地”,无论是人走掉还是新来一个人,你都不用重新定义“队伍的起始位置在哪里”。哨兵节点不是银弹,但确实能让链表类题目的代码简洁非常多,而且不容易在边界条件上翻车。
5.3 增删查时最容易出错的三个细节
当 addAtIndex 合法时,常见写法是这样的:
public void addAtIndex(int index, int val) { if (index < 0 || index > size) { return; } ListNode node = new ListNode(val); ListNode prev = head; for (int i = 0; i < index; i++) { prev = prev.next; } node.next = prev.next; prev.next = node; size++; }几个容易踩的细节:
一是“index 等于 size 时”也允许插入,这时候相当于尾部插入。很多人把判断写成index >= size直接 return,就漏掉了这个合法操作。
二是连接到新节点时,一定要先把新节点的 next 指向 prev.next,再把 prev.next 指向新节点。顺序反了,链表会断掉。这个错误我见过很多人犯,尤其是刚上手链表的时候。
三是 deleteAtIndex 时,要判断 index 是否在[0, size)范围内。注意范围不同,插入允许 index == size,删除不允许。有些面试官会故意把这两个边界混在一起问,你如果没分清,直接写错。
设计链表这题,做完之后,强烈建议你再把 get 操作单独拿出来,写一个循环遍历的版本。虽简单,但能帮你测试双向链表、循环链表、跳表这些进阶结构的基础手感和节奏。
6. 四道题的避坑速查,与我的练习心得
6.1 常见问题速查表
为了让你在刷题的时候快速回忆,这四道题的关键坑位,我整理成一张表:
| 题目 | 核心套路 | 常见错误 | 一句话心得 |
|---|---|---|---|
| Fizz Buzz | 分支顺序 / 字符串拼接 | 先判断单条件导致 FizzBuzz 输出错误 | 边界条件越简单,越要重视分支优先级 |
| 两数之和 | 哈希表查找 | 返回数值而不是下标;忘处理补数 | 遇到查找需求,优先想哈希表 |
| 合并两个有序数组 | 双指针从后往前 | 忘记考虑 p1 < 0 的情况或覆盖未用元素 | 原地合并时先想覆盖风险 |
| 设计链表 | 哨兵节点 + 基础指针操作 | addAtIndex 漏掉 index == size;删除边界写错 | 链表操作就是 prev.next 的重新搭接 |
这张表最后会变成你复习时的一页纸。每次觉得自己刷题没进展的时候,就把这些经典题再过一遍,反复训练能有效提升手感和信心。
6.2 为什么经典题更值得多次回炉
很多人有一种心态:一道题 AC 了就再也不看了。但我个人的经验,这类题恰恰要至少三刷。
第一刷:直接看题解,搞懂思路,AC。 第二刷:一周后,不看题解,自己默写。 第三刷:面试前一周,用白板或记事本手写,重点检查边界条件。
你会发现,第二刷和第三刷时,你比第一次更关注“为什么”,而不仅仅是“怎么解”。比如两数之和,为什么不能先塞满 map 再查找?合并两个有序数组,为什么最后不用管 nums1 剩余的元素?这些理解比那几行代码值钱得多。
如果你正在准备面试,不需要疯狂追求几百题的题量。把这四道经典题以及它们背后的延伸知识做到位,基础会异常扎实。算法面试到最后,很多看似复杂的题,本质上也是这些基本功的组合。
我自己刷题多年来最大的感受就是:稳定输出简单题,比偶尔做出难题更重要。Fizz Buzz、两数之和、合并两个有序数组、设计链表,它们就像是开工前的热身操——每个都不难,咬合在一起,却能帮你一遍遍校准编码手感。