ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

面试算法六道题精讲:从链表到动态规划的通用解题套路

面试算法六道题精讲:从链表到动态规划的通用解题套路

1. 六道题为什么值得反复做:从"刷完"到"刷透"的分水岭

面试算法题这个东西,很多人刷了几百道,真到面试现场还是卡壳。问题不在于题量,而在于有没有把一类题"做透"。我前后带过几个准备校招的朋友,也自己复盘过不少次面试经历,发现一个规律:真正拉开差距的,不是谁刷的题多,而是谁能把一组有代表性的题吃干榨净,从每道题里抽出可迁移的思维模型。

这次拿到的六道题,来源是常见的面试题库平台,覆盖了链表、二叉树、动态规划、双指针、栈与队列这几大高频板块。它们看起来是六道独立的题,但如果你只把它们当成六个孤立的练习,做完就忘,那基本等于白做。我的做法是:把六道题当成一个"最小完备集",每道题都追问三个问题——这道题考的是什么底层能力?它的暴力解和最优解差在哪里?如果面试官把条件改一改,我还能不能接住?

这篇文章就是围绕这三个问题展开的。我会逐题拆解思路、给出可运行的代码、标注容易踩的坑,更重要的是分享我在实际做题和面试中总结出来的"迁移套路"。适合正在准备技术面试的在校生、想跳槽的开发者,也适合单纯想把算法基本功重新捡起来的人。哪怕你现在连链表和数组的区别都说不利索,跟着走一遍也能有收获,因为我会把每一步的"为什么"讲清楚,而不是甩一段代码让你自己悟。

先说一个总的原则:面试算法题考的不是你会不会写代码,而是你能不能在有限时间内,把一个模糊问题拆成清晰的步骤,并且用代码准确表达出来。所以刷题时最忌讳的就是"看一眼题解,哦原来是这样,下一道"。正确的姿势是:先自己想,卡住了记录卡在哪,再看题解,然后合上题解自己重写一遍,最后隔两天再默写一遍。这六道题我就是按这个流程过的,下面逐一说。

2. 链表类题目的通用拆解框架

2.1 链表题为什么总在"指针"上翻车

链表题是面试里的常客,因为它特别能暴露一个人对指针操作的理解深度。数组你可以靠下标随便访问,链表不行,你只能顺着 next 指针一格一格走。很多人写链表题翻车,根本原因就一个:在移动指针之前,没有先把下一步需要的信息保存下来。

举个最常见的场景——反转链表。新手容易写成这样:让当前节点的 next 指向前一个节点,然后想移动到下一个节点,结果发现"下一个节点"已经找不到了,因为它的指针刚被你改掉了。这就是典型的"顺序错误"。正确的做法是三步走:先存下一个节点,再改当前节点的指向,最后移动前驱和当前指针。这个"先存后改再移"的口诀,几乎能套用到所有链表指针操作题上。

我在实际做题时养成了一个习惯:每写一行涉及指针赋值的代码,就在脑子里画一遍当前链表的状态。画不了就说明你没想清楚。这个习惯帮我避开了至少一半的链表 bug。

2.2 反转链表的迭代与递归两套写法

反转链表这道题,迭代和递归两种写法都得会,因为面试官经常追问"你能用递归再写一遍吗"。

迭代版本的核心就是三个指针:prev(前驱)、curr(当前)、next(暂存)。初始时 prev 为空,curr 指向头节点。循环体里:先把 curr.next 存到 next,再把 curr.next 指向 prev,然后 prev 移到 curr,curr 移到 next。循环结束时 curr 为空,prev 就是新的头节点。

def reverse_list(head): prev = None curr = head while curr: next_node = curr.next # 先存 curr.next = prev # 再改 prev = curr # 后移 curr = next_node return prev

递归版本稍微绕一点,但理解了就很简单。递归的思路是:假设从第二个节点开始往后的部分已经反转好了,我只需要把第二个节点的 next 指向头节点,头节点的 next 指向空,然后返回新的头节点。

def reverse_list_recursive(head): if not head or not head.next: return head new_head = reverse_list_recursive(head.next) head.next.next = head head.next = None return new_head

这里有个坑:head.next = None这行千万别漏。漏了的话,原来的头节点和第二个节点之间会形成环,整个链表就废了。我见过不少人递归写对了大半,就栽在这一行上。

2.3 快慢指针:链表题的万能钥匙

除了反转,链表题里另一个高频套路是快慢指针。判断链表有没有环、找链表中点、找倒数第 k 个节点,全靠它。

判断有环的逻辑很直观:快指针每次走两步,慢指针每次走一步。如果链表有环,快指针一定会在环里追上慢指针;如果没环,快指针会先走到空。这个"追上"的直觉很好理解——就像两个人在环形跑道上跑步,跑得快的人迟早会从后面追上慢的人。

找中点也是同理:快指针走到头的时候,慢指针刚好在中间。这里有个细节要注意:当节点数是偶数时,快慢指针的初始化方式决定了慢指针最终停在前半段的最后一个还是后半段的第一个。这个差异在做"重排链表"这类题时非常关键,写之前一定要想清楚你要的是哪个。

提示:快慢指针的初始化,常见有两种——都从头节点出发,或者快指针先走一步。选哪种取决于题目要求的中点是偏左还是偏右,别死记,画个四节点和五节点的链表自己推一遍就明白了。

3. 二叉树遍历:递归思维与迭代实现的取舍

3.1 前中后序遍历的递归本质

二叉树是算法面试的另一座大山。前序、中序、后序遍历,递归写法几乎是本能反应,因为它们的定义本身就是递归的:前序是"根左右",中序是"左根右",后序是"左右根"。你只要把访问根节点的位置换一下,三行代码就能切换三种遍历。

def inorder(root): if not root: return inorder(root.left) # 左 print(root.val) # 根 inorder(root.right) # 右

递归写法的好处是简洁、不易出错,坏处是当树很深时会爆栈。所以面试官经常会追问:"能用迭代写吗?"这时候就考验你对"递归本质上是栈"这句话的理解了。

3.2 用显式栈模拟递归调用

迭代实现遍历,核心是用一个栈来模拟系统调用栈。前序遍历最简单:先把根压栈,然后循环弹出节点、访问、再把右孩子和左孩子依次压栈(注意顺序,因为栈是后进先出,想先访问左孩子就得后压左孩子)。

中序遍历稍微难一点,因为要先一路走到最左边。思路是:用一个指针从根开始,只要指针不为空就压栈并往左走;指针为空了就弹出栈顶访问,然后转向右孩子。

def inorder_iterative(root): stack = [] curr = root while curr or stack: while curr: stack.append(curr) curr = curr.left curr = stack.pop() print(curr.val) curr = curr.right

后序遍历的迭代是最绕的,因为它要求"左右根",而栈天然适合处理"根左右"。有个取巧的办法:按"根右左"的顺序遍历,最后把结果反转,就得到"左右根"了。这个技巧我第一次见的时候觉得挺妙,后来发现面试里用出来,面试官往往会点头。

3.3 层序遍历与BFS的边界处理

层序遍历用的是队列而不是栈,属于广度优先搜索(BFS)。它的经典写法是:每轮循环先记录当前队列的长度,这个长度就是当前层的节点数,然后一次性处理完这一层的所有节点,把它们的左右孩子入队。

from collections import deque def level_order(root): if not root: return [] result = [] queue = deque([root]) while queue: level_size = len(queue) level = [] for _ in range(level_size): node = queue.popleft() level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(level) return result

这里的关键是level_size = len(queue)这一行。如果你不记录这个长度,直接while queue里逐个处理,就没法区分哪些节点属于同一层。这个"分层"的技巧在"二叉树的最大宽度""锯齿形层序遍历"等题里都会用到,值得单独记一下。

注意:层序遍历里判断左右孩子是否存在再入队,比入队后再判断空要清晰。虽然两种写法都对,但前者能少一次循环内的空判断,代码也更易读。

4. 动态规划:从暴力递归到状态转移的进化路径

4.1 动态规划到底在"规划"什么

动态规划(DP)是很多人面试时的噩梦,因为它不像链表、二叉树那样有直观的图形可以画。但其实 DP 的核心思想非常朴素:如果一个问题的解可以由它的子问题的解推导出来,并且这些子问题会被重复计算,那就把子问题的解存起来,避免重复劳动。

理解 DP 最好的路径是"三步走":先写出暴力递归,再发现重叠子问题,最后用记忆化或递推消除重复计算。跳过第一步直接看状态转移方程,是很多人学不会 DP 的根本原因——你连暴力解都没想清楚,怎么可能直接写出优化解?

4.2 爬楼梯与斐波那契:DP的入门模型

爬楼梯是最经典的 DP 入门题:每次可以爬 1 阶或 2 阶,问爬到第 n 阶有多少种方法。暴力递归的思路是:到达第 n 阶,要么从第 n-1 阶爬 1 阶上来,要么从第 n-2 阶爬 2 阶上来,所以f(n) = f(n-1) + f(n-2)。

但直接递归会重复计算大量子问题,时间复杂度是指数级的。用记忆化(自顶向下)或者递推(自底向上)就能降到 O(n)。

def climb_stairs(n): if n <= 2: return n prev, curr = 1, 2 for _ in range(3, n + 1): prev, curr = curr, prev + curr return curr

这里用两个变量滚动更新,把空间复杂度压到了 O(1)。这个"滚动数组"的技巧在 DP 里非常常用,只要状态转移只依赖前几个状态,就能用有限变量代替整个数组。

4.3 状态定义与转移方程:DP的两大命门

DP 题难,难在两件事:状态怎么定义,转移方程怎么写。状态定义决定了你的 dp 数组每个位置代表什么含义,转移方程决定了这些位置之间怎么推导。

以"最长递增子序列"为例。状态定义是dp[i]表示以第 i 个元素结尾的最长递增子序列长度。转移方程是:对于每个 i,遍历它前面所有的 j,如果nums[j] < nums[i],那么dp[i] = max(dp[i], dp[j] + 1)。最后答案是 dp 数组里的最大值,而不是 dp[n-1],因为最长递增子序列不一定以最后一个元素结尾。

def length_of_lis(nums): if not nums: return 0 dp = [1] * len(nums) for i in range(1, len(nums)): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) return max(dp)

这个 O(n²) 的解法面试里够用了,但如果面试官要求优化到 O(n log n),就得用"贪心 + 二分查找"的思路,维护一个递增的辅助数组。这个进阶解法我建议先理解 O(n²) 版本,再去看优化,否则容易两头都记不牢。

提示:写 DP 时,先确定"答案对应 dp 数组的哪个位置"。很多题答案是 dp[n-1],但像最长递增子序列、最大子数组和这类题,答案是整个 dp 数组的最值。这个细节错了,代码逻辑再对也拿不到分。

5. 双指针与滑动窗口:把O(n²)降到O(n)的实战套路

5.1 双指针的两种形态:对撞与同向

双指针是性价比极高的技巧,因为它往往能把 O(n²) 的暴力解优化到 O(n)。它有两种典型形态:对撞指针(一个从头、一个从尾,向中间靠拢)和同向指针(两个指针都从头出发,一快一慢)。

对撞指针的经典应用是"两数之和(有序数组)"和"盛最多水的容器"。以两数之和为例:数组有序的前提下,左指针指向最小值,右指针指向最大值,如果两数之和小于目标,左指针右移;大于目标,右指针左移。这样每次移动都能排除掉一整行或一整列的可能性,所以是 O(n)。

同向指针的经典应用是"删除有序数组中的重复项"和"移动零"。核心思路是用一个慢指针维护"已处理好的区域",快指针负责扫描。快指针遇到符合条件的元素,就把它放到慢指针的位置,慢指针前进。

5.2 滑动窗口:处理子串子数组问题的利器

滑动窗口本质上是同向双指针的一种特化,专门用来处理"连续子数组/子串"的问题。它的模板非常固定:右指针不断扩张窗口,当窗口不满足条件时,左指针收缩窗口,过程中记录答案。

以"无重复字符的最长子串"为例。右指针向右扩展,把字符加入窗口;如果遇到重复字符,左指针就向右移动,直到窗口内不再有重复。用一个哈希集合记录窗口内的字符,就能在 O(1) 时间内判断重复。

def length_of_longest_substring(s): char_set = set() left = 0 max_len = 0 for right in range(len(s)): while s[right] in char_set: char_set.remove(s[left]) left += 1 char_set.add(s[right]) max_len = max(max_len, right - left + 1) return max_len

这个模板的通用性极强,把"判断窗口是否合法"和"更新答案"这两处换掉,就能解一大类题。我建议把这个模板背下来,面试时能省不少思考时间。

5.3 窗口收缩时机的判断逻辑

滑动窗口最容易出错的地方,是什么时候收缩左指针。有的题要求窗口"恰好满足"某条件,有的题要求"不满足就收缩",这两种逻辑写起来差别很大。

我的经验是:先明确"窗口的合法状态"是什么。对于"最长"类问题,通常是"窗口不合法时收缩";对于"最短"类问题,通常是"窗口合法时收缩并记录答案"。这个区分想清楚了,代码结构就清晰了。

注意:滑动窗口里right - left + 1是窗口长度,这个 +1 千万别漏。我见过太多人因为漏了这个 1,答案永远差一位,debug 半天才发现。

6. 栈与队列:看似简单却暗藏玄机的数据结构

6.1 用栈实现队列与用队列实现栈

"用两个栈实现队列"和"用两个队列实现栈"是考察对这两种数据结构本质理解的经典题。栈是后进先出,队列是先进先出,要用一种模拟另一种,核心在于"倒腾"数据。

用两个栈实现队列:一个入栈(push 时用),一个出栈(pop 时用)。入队时直接压入入栈;出队时,如果出栈为空,就把入栈的所有元素依次弹出并压入出栈,这样顺序就反过来了,再从出栈弹出即可。

class MyQueue: def __init__(self): self.in_stack = [] self.out_stack = [] def push(self, x): self.in_stack.append(x) def pop(self): if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack.pop()

这个"倒腾"操作有个关键性质:每个元素最多被倒腾一次,所以均摊时间复杂度是 O(1)。面试时如果被问到时间复杂度,一定要说"均摊",而不是简单说 O(1) 或 O(n),这个细节能体现你的严谨。

6.2 单调栈:下一个更大元素的通用解法

单调栈是栈的一个高级应用,专门解决"下一个更大/更小元素"这类问题。它的核心是维护一个单调递增或递减的栈,当新元素破坏单调性时,就弹出栈顶并记录答案。

以"每日温度"为例:给定一个温度数组,求每一天要等多少天才能遇到更高的温度。用单调递减栈,栈里存的是下标。遍历温度数组,当当前温度大于栈顶下标对应的温度时,说明栈顶那天找到了"下一个更高温度",弹出并计算天数差。

def daily_temperatures(temperatures): n = len(temperatures) answer = [0] * n stack = [] for i in range(n): while stack and temperatures[i] > temperatures[stack[-1]]: prev = stack.pop() answer[prev] = i - prev stack.append(i) return answer

单调栈的难点在于"想清楚栈里存什么、什么时候弹"。我的经验是:栈里存下标而不是值,因为很多时候需要计算距离;弹出时机就是"找到了答案"的时机。把这两点想清楚,单调栈的题基本都能套。

6.3 有效括号:栈的入门必修课

有效括号是栈最基础的应用,但基础不代表简单。它的逻辑是:遇到左括号就压栈,遇到右括号就看栈顶是不是对应的左括号,是就弹出,不是或栈空就返回 false。遍历结束后栈必须为空。

def is_valid(s): mapping = {')': '(', ']': '[', '}': '{'} stack = [] for char in s: if char in mapping: top = stack.pop() if stack else '#' if mapping[char] != top: return False else: stack.append(char) return not stack

这里用了一个小技巧:用一个字典把右括号映射到对应的左括号,这样判断逻辑就统一了,不用写一堆 if-else。另外,stack.pop() if stack else '#'这个写法处理了栈空的情况,用一个不可能匹配的字符占位,避免额外的边界判断。

提示:有效括号的变体很多,比如"最长有效括号""删除最少括号使字符串有效"。这些变体的基础都是这道题,把基础打牢,变体就是加一层逻辑的事。

7. 六道题背后的通用能力地图

把这六道题拆完,你会发现它们其实在考同一批底层能力,只是换了不同的外衣。

题目类型核心考察能力可迁移的思维模型
链表反转指针操作的顺序意识先存后改再移
二叉树遍历递归与迭代的转换用栈/队列模拟调用栈
动态规划状态定义与转移推导暴力递归到记忆化再到递推
双指针/滑动窗口排除法的边界控制用单调性排除不可能解
栈与队列数据结构的本质理解用辅助结构倒腾顺序
单调栈维护单调性的时机判断弹出即找到答案

这张表是我自己复盘时整理的,它帮我意识到:面试算法题的数量是无限的,但底层思维模型是有限的。与其盲目刷题,不如把每个模型吃透,遇到新题时先判断它属于哪个模型,再套用对应的套路。

我在实际面试中还有一个体会:面试官更看重你的思考过程,而不是最终代码。所以做题时一定要边写边说,把你的思路、你的犹豫、你的取舍都表达出来。哪怕最后代码没写完,只要思路清晰,面试官也会给不错的评价。反过来,闷头写完代码但讲不清楚为什么这么写,反而容易被扣分。

最后分享一个我自己的练习方法:每做完一道题,合上题解,用一句话总结"这道题的核心套路是什么",然后把它归类到上面那张表里。坚持一段时间,你会发现自己看到新题时,脑子里会自动跳出"这题是滑动窗口"或者"这题得用单调栈",这种条件反射就是刷题刷到位了的标志。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进