两年前我刚开始刷算法题的时候,最怕的就是那种“看了解析觉得会了,合上答案又写不出来”的题。后来带新人才发现,这几乎是所有人的通病。所以陆陆续续写了差不多五十来道题的手写笔记,从最简单的数组双指针一路啃到树形DP,一边刷一边把每道题的思路拆碎了重写。这篇“算法题-07”算是系列里比较特殊的一篇,它没有把某一类特定题型拎出来深挖,而是把几道面试里反复出现的经典题放一起,用一种“横向对比”的视角去拆。你可能会发现,有的题表面上问的是“找某个数”,实际考的却是“怎么把O(n²)优化成O(n)”;有的题代码就十行,但背后的双指针思想能串起七八道题。这篇内容适合正在准备大厂面试、刷LeetCode但总是看完就忘的读者,也适合考研408在数据结构部分想建立整体框架的同学。文中的每道题我都会给到完整的推导过程、可运行的代码和踩坑记录,尽量做到看完就能自己写出来,下次遇见同类题型心里不慌。
1. 题目怎么选:这几道题背后的“能力考察”
刷题的坑我也踩过不少,最早是海量刷,每天四五道,看起来量很足,但面试时被问到一个变形题照样卡壳。后来复盘才发现,刷题有效果的关键不在“量”,而在“题型覆盖”和“解法收敛”。面试官真正想考察的不是你记住多少题,而是你在有限时间内能否快速分析问题、拆解条件、选择合适的算法策略。所以这一篇选的题目,覆盖面是这样的:一道哈希表的空间换时间经典题,一道双指针在数组上的收敛问题,一道链表的细节处理题,一道二叉树的层序变种题,外加一道动态规划的入门路径题。这些题难度跨度从简单到中等偏上,正好对应面试从热身到压力的全过程。
还有一个选题原则:每道题的解法,至少要能“一题三解”。比如两数之和,暴力解能过但没意义,哈希解是标准答案,排序加双指针则是另一条路。这种题的价值不在于“做对一次”,而在于“知道所有解法在什么场景下优先选哪条”。面试时你如果能主动说出“这题我还能用双指针做,但空间复杂度会多O(n),所以哈希更好”,这一句话比默默写对十道题都加分。
1.1 从热搜词看当前算法面试的侧重点
现在算法面试的题目来源确实有一些风向变化。字节这类公司喜欢出“原题变形”,题目看着眼熟,但条件里藏了限制;吉利这类偏前端的岗位,JS考察的比例在提升,很多LeetCode难度不小的题,要求你用JavaScript写,并且要考虑到原型链、闭包、隐式类型转换这些语言特性;考研408的方向则完全不同,它更偏数据结构底层原理和手动模拟过程,考察的是“这个操作在内存里到底发生了什么”。把这几个方向交叉一下,你会发现,真正高频被考的东西,其实还是那些最基础的算法范式:哈希、双指针、递归、分治、动规。背题没有用,你得把这些范式的特征吃透,知道什么时候该用、什么时候用了就错。
1.2 解题不是目的,建立“解法-题型”映射才是
我记得有一次帮一个师弟改简历,他的项目经历里写了“精通数据结构与算法”,我就拿了一道“三数之和”问他。他写了二十分钟,暴力解能跑通,但O(n³)的复杂度自己都看不下去。其实三数之和的考点很明确:它检验你对双指针收敛技巧的掌握程度,以及你是否理解排序预处理后,问题复杂度是怎么降下来的。这件事给我的启发就是:刷题的核心目标应该是建立一个“条件反射”,看到“有序数组+查找类问题”就想到二分或双指针,看到“两两配对求和/求差”就想到哈希,看到“极值或路径方案统计”就考虑DP。这篇的几道题,每一道我都会把“怎么从题干特征定位到解法”的逻辑链讲清楚,这个能力才是刷题真正要带走的东西。
2. 核心算法范式拆解:一题多解背后的思维模型
开始讲具体题目之前,我想先把贯穿整篇的几种算法范式拿出来说一下。这有点像学功夫前的扎马步,看似无聊,但所有招式都从这里面长出来。我见过太多人上来就死记代码模板,但面试时题目稍微拐个弯就“武功全废”,根本原因就是马步没扎稳。
2.1 哈希思想:空间换时间的核心
哈希的思想本质就一句话:把“查找”的时间从O(n)降到O(1)。但很多人没想清楚的是,哈希表里存储的“键”和“值”应该怎么设计,这才是这题的灵魂。以两数之和为例,给定一个数组 nums 和一个目标值 target,要求找出和为 target 的两个数的下标。暴力法是两层循环,外层固定一个数,内层找另一个数,复杂度O(n²)。哈希解法是:遍历一遍数组,每遇到一个数,就算它的“补数”target - nums[i],然后去哈希表里查这个补数之前有没有出现过。
这里的“键”为什么选“数值本身”,而不是“下标”?因为最频繁的操作是“根据值找下标”,哈希表天然就是这个用途。但这里有个很隐蔽的坑:如果数组里有重复元素怎么办?比如 nums = [3, 3],target = 6,答案是[0, 1]。第一次遍历到第一个3,补数是3,哈希表为空,就把{3: 0}存进去;第二次遍历到第二个3,补数是3,哈希表里已经有{3: 0}了,直接返回[0, 1],完美。但如果你在一开始就把所有元素一次性存入哈希表,重复键会互相覆盖,就会得到错误结果。这一点,每次面试都有不少人被问住。
我做了一个小对比表格,方便你直观感受:
| 解法 | 时间复杂度 | 空间复杂度 | 关键点 | 适用场景 |
|---|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 双重循环 | 数组极小 |
| 哈希表一次遍历 | O(n) | O(n) | 边查边存 | 绝大多数场景 |
| 排序+双指针 | O(n log n) | O(1)(原地排序) | 需要返回下标时需额外存储 | 有序数组或无需下标 |
还有一个延伸:如果题目改成“返回两个数本身的值,而不是下标”,那排序+双指针就是最优解法,因为不需要额外空间记录下标了。这也是面试官喜欢追问的点,看你有没有“按需选解”的思维。
2.2 双指针收敛:有序数组的杀手锏
双指针是一个大的技巧门类,现在这一篇里我先讲最经典的对撞指针,也就是两个指针一左一右,根据条件向中间移动,直到相遇为止。这种解法的前提是数组已经有序(或者题目本身就保证有序),典型题目就是“两数之和 II - 输入有序数组”、三数之和、盛最多水的容器。
核心逻辑其实就两点:如果当前指针指向的两个数之和大于 target,说明“大数那边太大了”,需要让右指针左移;如果小于 target,说明“小数那边不够大”,需要让左指针右移。每一步移动都排除掉了一大批候选组合,所以时间复杂度能被压缩到O(n)。我第一次看懂这个思路的时候,觉得它简直像“贪吃蛇自己在收缩”——每一步都基于已确认的信息,绝对不回头,效率自然高。
我在写这一题的时候,正好遇到一个非常有意思的细节:如果题目要求的不是“存在性”而是“所有不重复组合”,那去重就是最大的坑。比如[-1, 0, 1, 2, -1, -4]这个例子里,如果不去重,会输出两组[-1, 0, 1]重复组合。去重的方式也不是排序完了直接跳过重复元素那么简单,而是要在双指针移动的过程中,每次找到一组答案后,左右指针都要跳过重复值。这个细节很多人会漏,后面写代码的时候我会特别标出来。
2.3 链表细节控制:指针操作最容易出错的地方
链表题在面试中出现频率极高,但很多人对它的态度是“能写,但写着写着就乱了”。这很正常,因为链表的操作本质上是对一堆节点的指针重新排列,任何一步顺序错了,结果就全错了。尤其是反转链表这道题,代码不超过十行,但能一遍写对的人真的不多。
反转链表的核心逻辑,是维护三个指针:prev、curr、next。每次循环,先把 curr.next 保存到 next(防止后面断链),然后把 curr.next 指向 prev,再整体往下移动 prev 和 curr。这个步骤里最常见的问题就是顺序:很多人先做“curr.next = prev”,然后再保存 next,结果链表直接断在第二步,后续遍历变成访问空指针。我自己的习惯是,写每一行代码之前都在心里模拟一次“如果我现在把下一个节点丢了,后面能不能找回来”。链表的操作,安全永远是第一位的。
这里补充一个我自己总结的规律:凡是涉及链表反转、合并、交换相邻节点这类“结构性变化”的题目,最好先在纸上把每个节点的连接关系画出来,标好操作顺序,再动手写代码。虽然多花了三十秒,但避免了反复调试浪费的五分钟,更重要的是,面试官看重的是你的逻辑是否周密,而不是你眼神好不好使,能不能一把过。
2.4 二叉树的层序变种:从“遍历”到“构建”
二叉树这块,层序遍历本身是基础操作,用队列实现,每层按顺序输出。但面试里真正的高频考法是把它做成变种题,最常见的两种:按之字形顺序打印二叉树,和根据层序遍历结果反序列化重建二叉树。前者简单,只需要在偶数层反转一次列表;后者难,需要正确处理空节点的占位标记。
我遇到的真题是“给定层序遍历序列,其中空节点用 null 表示,请重建整棵树”。这道题很有意思,因为它把“遍历”和“构建”两个方向串起来了。解法核心还是那一个队列:把根节点入队,然后按层遍历序列,每次从队列中弹出节点作为“父节点”,从序列里按顺序取出它的左右子树值,构造新节点并挂接,然后再把新节点入队。这个过程中最关键的点是:你必须保证“序列下标推进的速度”和“队列中节点的弹出速度”完全同步,否则序列读完了,树还没建完,或者队列清空了,序列还剩一堆数据。
这种题的面试价值在于,它在考一个很底层的认知:层序遍历的序列化格式是唯一的,反序列化过程其实就是“遍历过程的逆运算”。如果你理解到这一层,不管题目怎么包装(比如增加一个“让左右子树交换”之类的操作),你都能很快定位到本质。代码写法上,用队列做BFS是最自然的,但我见过有人非要用递归做反序列化,然后被空节点占位绕得晕头转向,得不偿失。
2.5 动态规划入门:路径问题中的状态转移
DP是很多人的心魔,但其实入门级题目没那么可怕。我先拿一道经典的“不同路径”来带一下。一个机器人位于 m x n 网格的左上角,每次只能向下或者向右移动一步,问到达右下角有多少条不同的路径。这道题的答案很简单,就是组合数 C(m+n-2, m-1),但面试时直接甩公式并不会加分,你得把状态转移的推导过程讲明白。
状态定义:dp[i][j] 表示从起点走到坐标(i, j)的路径总数。因为每个格子只能从上面或左边到达,所以状态转移方程就是 dp[i][j] = dp[i-1][j] + dp[i][j-1]。边界条件是第一行和第一列,都只有一种走法。这个递推关系,看起来简单到不像话,但它已经是DP的核心骨架了:明确状态、找到转移、确定边界。
我在教别人的时候,会把这个递推过程比喻成“倒着算账”:你想知道到终点有多少种走法,不要去直接数,而是问“最后一步是从上面来的,还是从左边来的?”这样层层倒退,问题规模就一步步缩小了。等你想清楚这个问题,代码基本就是填表的事了。另一个很常考的变种是“带障碍物的不同路径”,也就是网格里有障碍物,遇到障碍物就置0,思路完全一致,只是多了一个判断条件。能把这道基础题吃透,后面遇到“最小路径和”“爬楼梯”这些题,代码模板都是一通百通。
3. 实操演示:从题目复述到代码落地的完整过程
这一节我会把每一道题的完整解题流程写出来,包括我对题干的解读、测试样例的设计、代码实现、以及运行时的输出验证。这些代码我都用 Python 写了可运行的版本,兼具可读性和面试手写时的简洁度。JavaScript 换个语法就能跑,思路完全一样。
3.1 两数之和:哈希表一次遍历的标准写法
题目描述:给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回它们的数组下标。你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。
这道题我已经讲过思路,直接上代码:
def two_sum(nums, target): # 哈希表存储:数值 -> 下标 # 边遍历边存储,这样能避免重复元素覆盖问题 seen = {} for i, num in enumerate(nums): complement = target - num # 如果补数已经在哈希表中,说明找到了答案 if complement in seen: return [seen[complement], i] # 没有找到,就把当前元素存入哈希表 seen[num] = i return []代码非常简单,但有几个值得注意的习惯:第一,不要用 nums.index() 去查下标,那本质上又是一次O(n)扫描,把哈希省下来的时间又浪费回去了。第二,判断补数在不在表里的时候,直接if complement in seen就行,不要用if complement in seen.keys(),后者会额外生成一个视图对象。第三,如果你在做算法题的过程中想让代码更严谨,可以在for循环结束之后加一句return [],虽然题目说了必有解,但写明确会让代码风格更完备。
运行验证用nums = [2, 7, 11, 15], target = 9,输出结果就是[0, 1]。我建议你把nums = [3, 2, 4], target = 6也跑一遍,这个案例能帮你确认自己写的不是“同一个元素用两次”:答案是[1, 2]而不是[0, 0],因为3+3虽然也等于6,但下标0不能重复使用。这种样例设计能力在面试中很加分,它说明你在用测试用例反向验证代码逻辑。
3.2 三数之和:排序+双指针+去重的全流程
题目描述:给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c,使得 a + b + c = 0?请你找出所有满足条件且不重复的三元组。
这道题是两数之和的升级版,但解法思路变了:排序,固定一个数,再用双指针在剩余区间中找两个数,使它们的和等于目标零。我带你完整走一遍:
def three_sum(nums): # 排序是双指针的前提 nums.sort() n = len(nums) res = [] # 固定第一个数,剩下区间交给双指针 for i in range(n - 2): # 如果第一个数已经大于0,后面的数都比它大,三数之和不可能为0了 if nums[i] > 0: break # 跳过重复的第一个数,避免产生重复三元组 if i > 0 and nums[i] == nums[i - 1]: continue # 左指针从i+1开始,右指针从数组末尾开始 left, right = i + 1, n - 1 while left < right: total = nums[i] + nums[left] + nums[right] if total == 0: res.append([nums[i], nums[left], nums[right]]) # 关键步骤:去重。跳过所有与当前相同的左指针数值 while left < right and nums[left] == nums[left + 1]: left += 1 # 同样,跳过所有与当前相同的右指针数值 while left < right and nums[right] == nums[right - 1]: right -= 1 # 找到一组答案后,双向收缩,继续寻找 left += 1 right -= 1 elif total < 0: # 总和太小,左指针右移,增大总和 left += 1 else: # 总和太大,右指针左移,减小总和 right -= 1 return res这里面有三个细节,是我反复测试后觉得必须重点说明的:
第一,排序这个操作是必须的,它能把“任意数组找三个数”的问题转成“有序数组用双指针收敛”的问题。排序本身O(n log n)的时间复杂度在所有排序解法里已经算很优秀的了,而且它带来的双指针优势会让整体性能远好于暴力解法。
第二,外层for循环跳过重复值的方式是nums[i] == nums[i - 1],注意不是nums[i] == nums[i + 1]。为什么?因为i是当前固定的第一个数,我们要保证“同一个值只作为第一个数出现一次”;如果用i + 1去比较,就会误杀[0, 0, 0]这种合法答案(在数组为[0,0,0]时,期望输出就是[[0,0,0]],如果跳过就会漏解)。这个例子是我当时踩过坑才总结出来的。
第三,找到一组答案之后,left和right为什么要同时收缩?因为我们已经用while循环把和当前值相等的所有重复值都跳过了,所以收缩后的左右指针指向的一定是新值。如果你只缩一个指针,那下一轮判断大概率还是会得到相同的组合(比如左移后仍是重复值),白做无效操作。这里“找到后立即去重+收缩”是双指针题型的高频考点,每次都必须想清楚。
3.3 反转链表:三步走与断链防护
题目描述:定义一个函数,输入一个链表的头节点,反转该链表并输出反转后链表的头节点。
这道题在LeetCode上编号是206,在面试中出现频率属于顶级。代码短小精悍,但写对的人不多,主要问题出在“指针操作的顺序”上。
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def reverse_list(head): prev = None curr = head while curr is not None: # 第一步:先保存当前节点的下一个节点,防止断链 next_node = curr.next # 第二步:反转指针,让当前节点指向它的前一个节点 curr.next = prev # 第三步:整体前进,prev和curr都往后移 prev = curr curr = next_node return prev整个过程可以用一句话记:“保存、指向、前进”。我把每一步的心理动机展开说:
- 第一步
next_node = curr.next必须要放在第一句。很多初学者先curr.next = prev,再想保存curr.next,发现原来是后一个节点的引用已经被覆盖了,链表从中间断成两截。 - 第二步
curr.next = prev是反转的核心。经过多次循环之后,prev 指向“当前节点之前的那个节点”,也就是反转后它应该指向的位置。 - 第三步
prev = curr; curr = next_node顺序不能变,一旦顺序变了,prev 就会被提前覆盖,反转过程就会错乱。
这里还可以考虑一下递归写法。递归的本质是“递到链表末尾,再从末尾往回指”。代码可以很简洁:
def reverse_list_recursive(head): if head is None or head.next is None: return head new_head = reverse_list_recursive(head.next) head.next.next = head head.next = None return new_head但我的建议是面试的时候优先写迭代版。递归虽然看起来酷,但“head.next.next = head”这两行其实更难解释清楚,而且如果链表特别长,递归还可能导致栈溢出,让面试官对你的“工程意识”打折扣。迭代版代码量多两行,但每一步都清晰可控,反而更容易拿高分。
3.4 之字形层序遍历:队列+BFS的变种实现
题目描述:给定一棵二叉树,返回其节点值的之字形层序遍历,即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行。
这道题算是层序遍历的经典变种,LeetCode编号103。它的核心逻辑仍然是用队列做BFS,唯一多出来的就是层数的奇偶判断。我用代码说明:
from collections import deque class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def zigzag_level_order(root): if not root: return [] res = [] queue = deque([root]) left_to_right = True # 第一层从左往右 while queue: level_size = len(queue) level_vals = [] for _ in range(level_size): node = queue.popleft() level_vals.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) # 根据当前层方向决定是否需要反转 if not left_to_right: level_vals.reverse() res.append(level_vals) # 下一层方向取反 left_to_right = not left_to_right return res这里有一个稍微高级的优化思路,也是我在后面刷“剑指Offer”的时候才想通的:不要用level_vals.reverse(),而是依然按照原顺序遍历,但在放入level_vals的时候根据方向选择“从头部插入”还是“从尾部插入”。比如:
# 方向为从左到右时,正常append if left_to_right: level_vals.append(node.val) else: # 从右到左时,改成从头插入 level_vals.insert(0, node.val)不过insert(0, ...)的时间复杂度是O(n),如果每一层都用它,效率反而不如append + reverse(reverse也是O(n),但常数更小)。我在面试中遇到这道题时,会主动跟面试官讲这个权衡,然后选择 “append + reverse” 作为最终方案。这种“主动讨论时空权衡”的交流,往往能让面试官看到你不是在背题,而是在真正思考。
3.5 不同路径:从DFS到DP的递进
题目描述:一个机器人位于一个 m x n 网格的左上角,机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角,问总共有多少条不同的路径?
我前面已经讲了状态转移方程,这里直接上代码,再用一个样例具体演示。
def unique_paths(m, n): # dp[i][j] 表示到达坐标(i, j)的路径数 dp = [[1] * n for _ in range(m)] # 第一行和第一列都是1,已经在初始化时填好 for i in range(1, m): for j in range(1, n): # 当前格子的路径数 = 上方格子路径数 + 左方格子路径数 dp[i][j] = dp[i - 1][j] + dp[i][j - 1] return dp[m - 1][n - 1]我用 m = 3, n = 7 手动验算一下:最终结果是28,代码输出就是28,正确。你可能好奇为什么第一行第一列直接初始化为1,因为第一行的格子只能靠一直往右走到达,路径只有一条;第一列同理,只能靠一直往下走。这个初始化是DP里最容易被忽略的部分,一旦漏了,数组里全是0,整张表都填不起来。
关于这道题,还有一个空间优化版本:既然每一行只依赖上一行和当前行左边的值,完全可以用一维数组滚动更新。
def unique_paths_optimized(m, n): dp = [1] * n for i in range(1, m): for j in range(1, n): dp[j] = dp[j] + dp[j - 1] return dp[n - 1]这个优化后的代码行数更少,空间从O(m*n)降到O(n)。面试时如果你能先把基础版写出来,再主动提一句“其实这里的空间还可以继续优化,因为每一行的计算只依赖上一行”,面试官通常都会很满意。这道题是DP的一个很好的“示例样本”,通过它你可以把后面遇到的大部分“网格路径类”DP题的套路摸透。
4. 常见问题与调试实录:这些坑我替你踩过了
题目本身总会做对,但那些“为什么我的代码一跑就超时”“为什么某段逻辑看着没问题但结果就是不对”的问题,才是真正让人崩溃的地方。我把这个系列里遇到的高频问题和对应的排查思路整理出来,希望能帮你在写题时少走弯路。
4.1 哈希表相关:覆盖和顺序问题
两数之和这道题,最容易踩的坑是“把所有元素先一次性存到哈希表,再遍历查找”。我前面讲过这样会导致重复元素覆盖的问题,但其实还有一个更隐蔽的:哈希表的存储顺序。如果你把所有元素先存一遍,再遍历数组时去查补数,你会遇到[3, 3]这种输入:第二个3确实能查到第一个3,但第一个3会被第二个3覆盖,下标就错了。所以我一再强调,正确的姿势是“边遍历边存”,查完当前元素再把它放入哈希表,这样才能保证不会“自己配自己”。
判断哈希表里是不是存在某键时,还有一个 Python 特有的坑:dict.has_key()在 Python 3 已经废除了,很多人还在用,会直接报 AttributeError。正确写法是if key in dict。另外,如果键可能是整数0,而你要判断的是“键是否存在”,不要写if dict.get(key),因为get返回0时会被当成False,逻辑直接出错。这种细节,笔试不报错,但面试现场一报错就是灾难。
4.2 双指针类的越界与死循环
三数之和这类双指针题,最常见的 bug 就是 left 或 right 走过头。我见过有人这么写:
while nums[left] == nums[left + 1]: left += 1这段代码一旦遇到left已经走到n - 1,left + 1就越界了。正确写法一定要加上区间判断:
while left < right and nums[left] == nums[left + 1]: left += 1left < right这个条件,既是去重的边界,也是安全的边界,不能省。同样,右指针在while循环中也要检查left < right。这个坑在指针类题目里几乎人人都踩过,我第一次写三数之和的时候,就因为这个越界问题,运行直接报 IndexError,调试了好几分钟才反应过来。
另外一个很隐蔽的问题,是“在内层 while 循环中修改了 left 或 right,外层循环是否有保护”。比如外层 while 的条件是while left < right,但如果在一次进入循环后,内层去重代码把 left 跳到了 right 甚至越过 right,下一次外层循环判断就出问题了。最好的办法是在内层循环里时刻带着left < right判断,确保指针不会相互交错。
4.3 链表操作:断链和循环引用
链表题调试起来最痛苦,因为报错信息往往只是“AttributeError: 'NoneType' object has no attribute 'next'”,看起来毫无头绪。我自己总结的排查方法是:在每一步指针操作后,都问自己三个问题——当前节点的 next 指向哪里?我是否丢失了对后续节点的唯一引用?如果我把当前节点的 next 修改了,后续遍历还能继续吗?
反转链表里我一再强调先保存后指向,就是为了防断链。另一个容易犯的错误是“反转后返回了错误的头节点”,比如返回 prev 还是 curr,很多人分不清。循环结束时,curr 已经是 None,说明走到链表末尾了,此时 prev 正好停在原链表的最后一个节点上,也就是反转后的头节点。如果你返回 curr,就相当于返回了一个 None,整个算法白写了。
4.4 二叉树 BFS:层数与空节点的处理
之字形遍历这道题,很多人在“用不用再建一个队列”这件事上纠结。其实不需要,一次入队出队完全够用,关键是level_size = len(queue)这一步必须在for循环前记录下来,因为for循环过程中队列长度一直在变,如果直接for i in range(len(queue)),循环次数会被动态改变,导致层与层之间的数据都混在一起。这是个非常经典的 BFS 陷阱。
另一个关于反序列化的坑:如果层序遍历序列里面用 0 表示空节点,那你可能遇到“节点值为0”和“这个节点是空的”这两种情况。这个时点一定要和面试官确认输入格式,到底是 null、None、0 还是 '#',不要想当然。我见过有人用if node.val == 0来判断空节点,结果遇到的测试数据里正好有值为0的节点,整道题直接崩了。
4.5 DP中的数组初始化和索引错位
DP题的bug通常比别的题更隐蔽,因为代码不报错,但答案就是错的。不同路径这道题,最常见的两个错误:一是数组dp没有正确初始化,导致dp[0][0]的值不对,后续累加全部出错;二是在循环中把行列写反,比如用了dp[j][i]而不是dp[i][j],如果 m 和 n 不相等,很可能某一次索引越界,但如果是方阵(m=n),越界不会发生,答案却是错的。这种“方阵掩盖错误”的情况,面试时最容易蒙混过关,也最容易让你在后续追问中翻车。
我的自查习惯是,写完dp代码后用两个小样例跑一遍:一个 1x1 的边界矩阵,期望结果1;一个 2x3 或 3x2 的矩阵,看结果是否符合手算。如果这两个都通过,那逻辑基本就是对的。很多人总觉得测试样例要选大的才有效,恰恰相反,在算法题里,边界值和小矩阵最能把逻辑漏洞暴露出来。
5. 面试与应试中的关键技巧:从“会做”到“讲明白”
我刷了这么久算法题,最深的体会是:笔试能过的人很多,但面试时能把一道题讲得清晰透彻的人真的不多。面试官看的不光是你最终写出来的代码,更是你的思维过程。所以这里单独写一节,分享几个我觉得很实用的面试表达技巧。
5.1 先确认题目条件,再动手
不管是电话面试还是现场手写,我都建议你先花三十秒和面试官确认几个关键信息:输入规模是多少?数组是否有序?数据范围有没有负数?返回值是下标还是值?链表是否有环?这个习惯有两个作用。第一,它避免了你误解题意,白写二十分钟;第二,它让你接下来的解法有了充分的“铺垫”,比如你知道了数组无序,就可以说“因为是无序的,所以哈希表是最自然的选择”。这会让面试官觉得你是真的在思考题目,而不是一上来就套模板。
我记得有一次面试,面试官给了一道“搜索旋转排序数组”,我没注意到数组里可能包含重复元素,直接按无重复的解法写了,结果测试数据里出现了重复值,答案不一致。面试官提示我“考虑一下重复元素会带来什么问题”,我才意识到边界条件没有确认清楚。从那以后,我养成了一个习惯:拿到题先口述一遍自己的理解,等面试官确认后再动手。这个习惯在几乎所有面试里都帮我省了大麻烦。
5.2 代码风格影响评分
笔试环境里没人管你的代码风格,但面试手写代码时,面试官是有明确评价的。我建议刻意保持这几个习惯:变量名不要用 a、b、c 这种没有语义的单个字母;函数名和使用逻辑尽量贴合题目语义;在关键步骤旁边加注释,说明“这一步在干什么”。别小看这些细节,它能帮你争取到“沟通能力”“代码规范”这些维度的加分。
举个很简单的例子,同样是反转链表的代码,如果你写的是:
def f(head): p = None c = head while c: n = c.next c.next = p p = c c = n return p面试官虽然能看懂,但阅读成本很高;如果写的是带注释和全称变量的版本,观感完全不一样。刷题时你可能追求速度忽略这些,但面试时一定要提醒自己放慢节奏,写出“可读性强”的代码。
5.3 一题多解是回答深度的分水岭
写出一道题的答案只是及格线,真正拉开差距的是你能不能主动说出“还有没有其他解法”。比如两数之和,写完哈希解法之后,你可以补一句:“这道题如果数组是有序的,我会用双指针,这样可以把空间复杂度降到O(1)。”再比如不同路径,写完二维DP之后,你可以提一句“这个还可以用组合数学直接求,因为机器人一共要走 m-1 次下和 n-1 次右,排列组合的数量就是 C(m+n-2, m-1)”。
这些延伸的内容,不必长篇大论,点到为止即可。面试官听到这些,基本就能判断出你对题目的理解深度远超“背答案”水平。
5.4 时间/空间复杂度的表达不要背模板
很多人被问到“这个算法的时间复杂度是多少”的时候,脱口而出“O(n)”,但面试官紧接着问一句“为什么”,就卡住了。我建议你在平时练习时就习惯性地分析每一步逻辑所带来的复杂度,而不是背答案。比如两数之和:我们遍历了一次数组,每次循环只做哈希查找和插入,所以时间复杂度是O(n);哈希表最多存储n个键值对,所以空间复杂度是O(n)。这样分析,信息完整,逻辑清晰,完全不需要死记硬背。反过来,如果你连自己的代码为什么是O(n)都说不清楚,那面试官大概率会认为你是在背模板。
6. 从算法题到工程思维:刷题到底在刷什么
说实话,进了大厂之后,纯算法题在日常业务开发中直接出现的场景并不多。你不太可能天天写“三数之和”,也不太可能真的需要手写一棵平衡二叉树的旋转操作,但这不代表刷题没有意义。我觉得算法题真正训练的是三样东西:分解问题的能力、选择数据结构的能力、以及审慎验证的习惯。
第一个,分解问题的能力在工程里就是“需求拆解”。给你一个模糊需求,你能不能把它拆成若干个子模块,每个子模块用什么数据结构实现,这就是算法题里“理解题目-设计解法-编码落地”的翻版。第二个,选择数据结构的能力更直接了。你写一个功能模块,什么时候用哈希表,什么时候用队列,什么时候必须用栈,这些选择在很多场景下直接决定了系统的性能。第三个,审慎验证的习惯其实就是“测试思维”。写代码前想好边界条件,写完用几个样例验证,这个习惯在工程里的价值比任何一门框架都高。
我自己带过一个小团队,面试候选人的时候会更关注候选人遇到不会的题时的心态和反应。有一种候选人,题目刚看完就说“这题我没见过”,然后停下来不说话了;另一种候选人,同样没见过这道题,但是会试着从熟悉的问题出发,把未知向已知转化,比如“这个题很像两数之和,我能不能借鉴哈希的思路”。后者往往才是真正能把算法能力迁移到工程里的人。所以刷题本身不是目的,它其实是训练你的思维方式,让你在面对陌生问题时不慌、有路、能落地。
7. 这一篇之外:后续刷题路线的建议
如果这一篇的题目你都能自己写出代码并讲清楚思路,恭喜你,你已经具备了继续深入的基础。接下来的路线,我根据自己的经历给一个实用建议:
第一,把每个基础范式对应的“代表题”做透。哈希对应两数之和、三数之和、字母异位词分组;双指针对应LeetCode 11盛最多水的容器、15三数之和、42接雨水;链表对应206反转链表、141环形链表、21合并两个有序链表;二叉树对应102层序遍历、103之字形遍历、124二叉树中的最大路径和;DP对应70爬楼梯、62不同路径、300最长递增子序列。这些题每个都值得刷两遍,第二遍刷的时候尝试不参考任何资料,自己在零基础上推一遍思路。
第二,做“专题周”而不是随机乱刷。建议每周只刷一个主题,比如这周只做双指针,下周只做二叉树。这样做的优势是,你很容易总结出规律:原来双指针问题为什么总能O(n)解决?因为它把二维穷举压缩成了一维的指针移动;原来二叉树层序遍历的模板是 queue + while + for 三层结构。一旦形成了类似的“模式识别”,见到新题就能快速归类。
第三,不要害怕做不来hard题。很多人的挫败感来自于一上来就刷hard题,一个小时过去了还是没思路。我的建议是:easy和medium是构建信心的,hard是用来见识“原来还能这么想”的。每次做完一道hard题,不要停下来,立刻去看别人的题解,重点看他们的思考路径而不是代码。你会发现,再难的题,也是由若干个基础范式组合而成的。比如滑动窗口最大值,本质就是“单调队列+窗口滑动”;最大回文子串,本质就是“中心扩展+双指针”。
第四,刷题过程中一定要会用调试器或者 print 观察中间结果。我见过很多人在本地 IDE 跑不过就硬看代码,其实只要打印一下关键变量,逻辑瞬间就清楚了。比如三数之和,你可以在内层循环开头打印 left、right、total。打印出来你就知道是去重逻辑写错了,还是指针移动条件有问题。调试也是工程能力的一种,面试时能用 print 定位问题,反而能给面试官留下一个“有实战经验”的印象。