news 2026/9/30 12:10:09

算法面试经典四题拆解:Fizz Buzz、两数之和、合并有序数组与链表设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
算法面试经典四题拆解:Fizz Buzz、两数之和、合并有序数组与链表设计

这题我太有发言权了。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、两数之和、合并两个有序数组、设计链表,它们就像是开工前的热身操——每个都不难,咬合在一起,却能帮你一遍遍校准编码手感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 12:09:32

hindsight:基于MCP与Docker的LLM Agent记忆回溯机制设计与部署

1. 从“hindsight”说起&#xff1a;为什么我们需要给Agent装上“后视之明”“hindsight”这个词&#xff0c;直译过来就是“后见之明”&#xff0c;也就是事后诸葛亮。但在Agent Memory这个领域里&#xff0c;它恰恰指向了一个非常核心的痛点&#xff1a;大语言模型驱动的智能…

作者头像 李华
网站建设 2026/9/30 12:08:16

从零搭建AI工程系统:模型之外的完整闭环实践

做AI工程这件事&#xff0c;很多人都被“模型”两个字锁住了。看了几篇教程&#xff0c;跑通了一个resnet或者调用了一个大模型API&#xff0c;就觉得自己在搞AI工程了。实际上去企业里走一圈就会发现&#xff0c;真正值钱的、真正有瓶颈的&#xff0c;从来不是那行model.fit&a…

作者头像 李华
网站建设 2026/9/30 12:07:26

TensorFlow 2024:环境配置、Keras训练与部署实操指南

先说结论&#xff1a;TensorFlow 没凉&#xff0c;但也不再是那个“什么都是它”的时代了。 我这两年被问得最多的两个问题&#xff0c;一个是“TensorFlow 还能学吗”&#xff0c;另一个是“我装 TF 怎么老是报错”。前者是焦虑&#xff0c;后者是现实。焦虑我解决不了&#…

作者头像 李华
网站建设 2026/9/30 12:07:09

从复位向量到RTOS任务:STM32上电启动流程全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 12:07:07

基于DeepSeek的跨模态视频转技术文档流水线实践

简介&#xff1a;这份PDF文档面向希望掌握跨模态开发与视频内容自动生成技术的开发者、算法工程师及高校研究者&#xff0c;系统讲解如何借助DeepSeek模型完成从文本描述到视频内容的自动生成。资源包共1个PDF文件&#xff0c;大小约2.07MB&#xff0c;内容完整、目录清晰&…

作者头像 李华
网站建设 2026/9/30 12:06:52

从零手搓AI工程:深入底层实现与性能优化实践

1. 从零手搓AI工程&#xff1a;为什么我不建议你直接调包很多人一听到“AI工程”这四个字&#xff0c;第一反应就是打开某个云平台&#xff0c;拖几个组件&#xff0c;调一下API&#xff0c;然后跑通了事。我刚开始接触这个领域的时候也是这么想的&#xff0c;觉得底层的东西有…

作者头像 李华