面试算法题这件事,我一直有个观点:刷题数量和面试通过率,从来都不是线性关系。见过太多人把LeetCode刷了五六百题,结果面试官换一道变形题就卡住;也见过有人只刷了一两百题,但每道题都能把思路讲明白,把边界条件说清楚,反而顺利拿下offer。问题不在题量,在方法。
这篇是“面试常考算法题”系列的第一篇,先聊最核心的高频题型:双指针、滑动窗口、链表、二叉树、动态规划。这些都是技术面试中出现频率最高的方向,无论你面Java、Python、前端还是测试开发,这几类题型几乎是必考的。我会从“面试官到底想考什么”出发,拆解每类题型的底层逻辑、解题模板、以及现场写码时最容易翻车的细节。内容偏实战,适合正在准备面试的读者,也适合带新人的技术leader做参考。
1. 面试算法题到底在考什么:先把考官手里的评分表看明白
很多候选人有一个错误的预设:面试算法题,就是考“你会不会做这道题”。实际上,技术面试官考察的从来不是“答案正确”,而是“你如何得到这个答案”。
1.1 技术面候选人的四项基本能力
我参与过不少校招和社招的面试,也和其他面试官交流过对候选人的评估标准。综合来看,一道算法题在面试现场,至少承担了以下四个维度的考察:
- 问题澄清能力:拿到题目后,是直接闷头写,还是会先确认输入范围、数据规模、是否存在重复元素、是否有序。这一步能筛掉一大批人。
- 边界处理意识:空数组、单元素、极大值、溢出情况,候选人是否主动想到。这直接反映工程习惯。
- 复杂度分析能力:面试官会问“这个解法的时间复杂度和空间复杂度是多少”,很多人能写出代码,但分析不清楚。
- 沟通与协作能力:你是一个人在白板上默写,还是会边写边把思路说出来,遇到卡顿会不会主动和面试官交流。这决定了你入职后是否好合作。
1.2 “八股文”式背题的误区
现在网上流行各种“面试八股文”“刷题模板”,这些内容作为入门没问题,但最大的问题在于:只给了答案,没有给推导过程。面试官只要把原题稍微改一个条件,比如把“数组”改成“链表”,把“整数”改成“字符串”,把“求最大值”改成“求最小值”,背模板的人就露馅了。
面试官日常看到的场景是:候选人A,上来就开始写代码,写的确实是对的,但问“为什么这样不会越界”,答不上来;候选人B,先花两分钟确认数据规模,说“如果数组长度是十万,O(n²)会超时,所以我需要O(n)方案”,然后给出思路。哪怕B最后代码有小bug,面试评价往往也高于A。
1.3 高频算法题的三大来源
从面试官出题的角度看,算法题基本有三个来源:
- 经典教材题:比如《剑指Offer》和LeetCode Hot 100里的题。这些题考察的算法思想基础,区分度好,大家默认候选人应该掌握。
- 经典题的变形:原题换个壳,考察候选人能否识别出本质。例如“最小覆盖子串”是滑动窗口,“寻找两个正序数组的中位数”是二分+边界处理。
- 结合业务的场景题:比如“海量日志中统计Top K”“检测循环引用”这类,本质还是堆、哈希表、快慢指针,但包装了实际业务背景。
看清这一点对准备面试很关键:你需要练习的不是“记住这道题的答案”,而是“识别这道题背后的算法模式”。这也是本文所有拆解的核心思路。
2. 数组与双指针:面试中出现频率最高的送分题,也是失分重灾区
数组类问题几乎每场面试都会遇到。而处理数组最常用的技巧之一,就是双指针。
2.1 双指针算法到底在解决什么问题
双指针的核心价值是:用两个指针的移动,替代一层循环,把时间复杂度从O(n²)降到O(n)。最典型的场景是“有序数组中找两数之和”这类题目。
举个例子:给定一个有序数组和一个目标值,找出数组中两个数,使它们的和等于目标值。暴力做法是两层循环枚举所有组合,时间复杂度O(n²)。双指针做法是:一个指针指向数组头部,一个指针指向尾部,计算两者之和,如果大于目标值,说明需要减小和,右指针左移;如果小于目标值,说明需要增大和,左指针右移。这样每个元素最多被访问一次,时间复杂度O(n)。
$$ two_sum(nums, target):\ \larr \text{ } i=0, j=n-1\ \text{if } nums[i]+nums[j] == target: return [i, j]\ \text{else if } nums[i]+nums[j] < target: i \mathrel{+}= 1\ \text{else: } j \mathrel{-}= 1 $$
这个算法思路非常简单,但我在面试中看到大量候选人栽在同一个地方:写代码时没有确认数组是否有序。如果题目没说明有序,双指针法直接失效,必须先排序(但排序会改变索引,所以涉及返回索引的题需要额外的处理)。
2.2 快慢指针:原地去重与环检测
双指针的另一个重要分支是快慢指针。面试中出现频率极高的“有序数组原地去重”,标准解法就是快慢指针。
def remove_duplicates(nums): if not nums: return 0 slow = 0 # slow指向最后一个不重复元素的位置 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1这段代码的含义是:slow指针维护“已处理区域”的边界,fast指针负责探索新元素。每次发现新的不重复元素,就把它搬到slow的下一个位置,最终slow+1就是去重后的数组长度。核心思想是“用一个指针维护结果区域,另一个指针遍历原数组”。
同样的思想可以迁移到“移动零”这道题:把数组中所有0移到末尾,同时保持非零元素的相对顺序。思路完全一致,slow维护非零区域的边界,fast遍历数组,遇到非零就交换到slow位置。
2.3 面试中的真实翻车场景
我在模拟面试中见过一位候选人做“判断链表是否有环”这道题,背过答案,能写出fast走两步、slow走一步的代码,但被问到“为什么fast必须走两步,走三步行不行”时,卡住了。
这个问题其实考察的是对快慢指针原理的理解。假设链表有环,环的长度为L,当slow进入环时,fast已经在环内某处。如果fast每次比slow多走一步,那么两者的相对速度是“每步接近1个节点”,最终一定能相遇。如果fast每次比slow多走三步,相对速度是3,当环长度L能被3整除时,两者可能永远追不上(每次都跳过)。所以标准答案fast走两步、slow走一步,是保证“一定能相遇”的最小安全速度。
这类“为什么”问题,是面试官区分“背题”和“真懂”的关键。准备算法题时,建议对每道做过的题都问自己一遍:这个解法为什么是对的?能不能举个例子证明它不会死循环?
2.4 双指针题的面试话术与边界意识
面试现场写双指针题,建议按以下节奏展开:
- 先确认条件:“请问数组是有序的吗?数据规模大概是多少?能否使用额外空间?”即使题目已经写明,也最好口头确认一遍,这能给面试官留下严谨的印象。
- 给出暴力解并分析复杂度:“我可以先用两层循环,O(n²),但数据量大时会超时,所以我考虑用双指针把复杂度降到O(n)。”
- 说明正确性依据:“因为数组有序,当左+右大于target时,右指针左边的任何元素加上当前位置都只会更大,所以右指针左移不会漏解。”这里把数学依据说清楚,是加分项。
- 写代码时关注边界:数组为空、只有一个元素、两个指针相撞时的退出条件。
- 写完主动提测试用例:空数组、恰好一正一负、全是相同元素。
这套流程等于把面试官想问的问题,抢先说了出来,整个面试节奏就会被你掌控。
3. 滑动窗口:把“子串子数组”问题变成一套模板
“无重复字符的最长子串”“最小覆盖子串”“长度最小的子数组”——这些题本质都是一个模式:在一个线性结构上,维护一个动态的区间,区间满足某个条件,要求区间的最大或最小长度。这类题的最优解,十有八九是滑动窗口。
3.1 什么时候该想到滑动窗口
判断一道题是否适用滑动窗口,看两个特征:
- 考察对象是连续的子串/子数组,不是子序列(子序列通常用动态规划)。
- 题目中有“最长/最短/恰好包含”这类关键词,且窗口的状态可以通过两个端点来描述。
举个例子:“给定一个数组nums和一个正整数s,找出满足其和≥s的长度最小的连续子数组”。暴力做法是枚举所有子数组,O(n²)。滑动窗口的做法是:右指针不断扩张窗口,当窗口内和满足条件时,记录长度,然后左指针收缩窗口,寻找更短的合法窗口。
3.2 一个通用滑动窗口模板
滑动窗口的代码逻辑几乎都是一样的,核心是维护窗口内数据的哈希表(或计数器),根据条件决定窗口何时扩张、何时收缩。
def sliding_window(s, target_condition): n = len(s) left = 0 window = {} # 维护窗口内元素的计数 ans = 0 # 根据题目要求更新 for right in range(n): # 1. 将s[right]加入窗口 window[s[right]] = window.get(s[right], 0) + 1 # 2. 当窗口不满足条件时,收缩左边界 while not condition(window): window[s[left]] -= 1 if window[s[left]] == 0: del window[s[left]] left += 1 # 3. 此时窗口满足条件,更新答案 ans = max(ans, right - left + 1) return ans“无重复字符的最长子串”套这个模板,条件就是“窗口内所有字符计数都为1”。
def length_of_longest_substring(s: str) -> int: n = len(s) left = 0 window = {} ans = 0 for right in range(n): window[s[right]] = window.get(s[right], 0) + 1 while window[s[right]] > 1: window[s[left]] -= 1 left += 1 ans = max(ans, right - left + 1) return ans3.3 窗口伸缩的平衡条件与答案更新时机
滑动窗口最容易出错的地方,是while收缩的时机和答案更新的时机。很多候选人在这个细节上翻车。
原则是这样的:答案是“某个满足约束的窗口的宽度”,但需要区分是最大窗口还是最小窗口。
- 如果求“最长”,比如最长无重复子串,窗口扩张后如果满足条件,就可以尝试更新答案;如果不满足,就收缩窗口直到满足条件,收缩完再更新。
- 如果求“最短”,比如最短子数组和,窗口扩张后如果不满足条件,继续扩张;一旦满足条件,就先把当前窗口宽度记录下来(候选答案),然后收缩窗口试图找到更短的满足条件的窗口,每次收缩后如果仍满足条件,继续更新答案。
一个容易踩的坑是“窗口收缩到什么时候停”。以“最小覆盖子串”为例,答案是包含目标字符串所有字符的最短子串。窗口收缩的条件是“当前窗口仍然包含目标字符串的所有字符”,一旦不满足,就停止收缩,继续右移。很多候选人会把条件写成“当前窗口长度大于目标字符串长度”,这只有在特定题型下才成立,不能通用。
3.4 面试实战:先讲“为什么right左移是安全的”
滑动窗口的难点不在代码,在于论证滑动窗口不会漏掉最优解。面试官大概率会问:“你这个做法为什么是对的?为什么滑动窗口不会漏掉一个更长的合法子串?”
回答思路是:当窗口[left, right]已经满足条件时,如果左指针向右移动(缩小窗口)后,窗口不再满足条件,说明以这个新left为起点的所有子串中,最短的合法子串就是当前窗口之前的那个len(right-left+2)。因为right是当前遍历到的位置,窗口缩到不满足条件所需的宽度,就是当前起点下能达到的最小宽度。因此,不需要再从left+1开始重新枚举,直接推进right即可。
把这段逻辑清晰地说出来,面试官对你的评价会大幅提升。这是滑动窗口和暴力解之间“优化逻辑”的核心,也是很多人只会写代码、讲不出道理的地方。
4. 链表操作:画图比背代码重要得多
链表在面试中的出现频率极高,而且几乎都是送分题,但失分率依然很高。为什么?因为链表的操作涉及大量指针(或引用)的重新指向,边界条件多,稍不注意就出现空指针异常或死循环。
4.1 链表题失分的三个典型原因
我总结过候选人做链表题时的常见问题:
- 不画图直接写。链表操作是典型的“空间想象题”,不画图靠脑补,多半会错。
- 忘记处理头节点。反转链表后,新的头节点是原来的尾节点,很多人在返回时直接返回head,导致结果错误。
- 指针覆盖顺序搞反。比如删除节点时,先修改了next,导致后面的节点丢失。
4.2 哨兵节点:统一边界逻辑的利器
链表操作中,最让我推荐的习惯是:引入哨兵节点(dummy node)。哨兵节点是一个虚拟的头节点,它的next指向真正的头节点。这样做的好处是:不需要单独处理“操作位置在头节点”的情况,统一逻辑。
举例:“删除链表中倒数第N个节点”。如果不用哨兵节点,删除头节点的逻辑和删除中间节点的逻辑是分开的,容易漏。用哨兵节点,代码就变成:
def remove_nth_from_end(head, n): dummy = ListNode(0, head) fast = dummy slow = dummy # fast先走n+1步,这样当fast走到None时,slow正好在倒数第n个节点的前一个 for _ in range(n + 1): fast = fast.next while fast: fast = fast.next slow = slow.next slow.next = slow.next.next return dummy.next这个解法用到了“快慢指针找倒数第N个节点”+“哨兵节点统一边界”两个技巧,非常经典。面试中一旦写出这个结构,基本就是满分答案。
4.3 三个必会的链表模板
链表题看似花哨,但真正高频的模板就三个:
模板一:反转链表(迭代版)
def reverse_list(head): prev = None curr = head while curr: next_node = curr.next # 先保存下一个节点 curr.next = prev # 反转指针 prev = curr # prev前进 curr = next_node # curr前进 return prev反转链表是很多链表题的基础,比如“回文链表”“反转链表的一部分”“两数相加”都会用到。核心逻辑就是三行:保存next、修改next指向、移动prev和curr。面试时一定要把这三行写在纸上,对照图说清楚。
模板二:找链表中点(快慢指针)
def middle_node(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow注意这里循环条件是fast and fast.next,如果写成fast.next and fast.next.next也会出问题(链表长度为偶数时返回的位置会不同)。具体是返回左中点还是右中点,取决于题目要求,面试时最好口头确认。
模板三:合并两个有序链表
def merge_two_lists(l1, l2): dummy = ListNode(0) tail = dummy while l1 and l2: if l1.val < l2.val: tail.next = l1 l1 = l1.next else: tail.next = l2 l2 = l2.next tail = tail.next tail.next = l1 if l1 else l2 return dummy.next这里用哨兵节点dummy来构建新链表,最后直接返回dummy.next,省去了判断“哪个链表先遍历完”的额外逻辑。合并链表是“合并K个有序链表”“排序链表”等题的基础,必须熟练到能默写。
4.4 现场写链表题最容易踩的坑:指针覆盖顺序
我在模拟面试中反复看到的一个错误,是在反转链表时没有保存next_node就直接修改curr.next,导致后面节点全部丢失。
错误写法: while curr: curr.next = prev # 先改了next,原链表断裂 prev = curr curr = curr.next # 此时curr.next已经是prev了,不再是原next这个错误非常隐蔽,因为代码看起来逻辑通顺,但执行一遍就会发现无限循环或链表丢失。链表操作的黄金法则是:先保存后修改。任何需要修改某个节点.next的操作,先把原来的next保存到临时变量,再动手改。面试时养成这个习惯,能避开大部分链表bug。
另一个容易踩的坑是返回头节点。反转链表时,新的头节点是prev,不是head。很多人写了半天,最后return head,返回的是尾节点(反转后head的next已经指向None),导致整个链表看起来像空链表。
4.5 实战案例复盘:两数相加的“逐位模拟”怎么聊
“两数相加”这道题也是链表中的高频题:给两个非空链表表示两个非负整数,数字按逆序存储,每个节点存一位数,求两数之和,同样以链表形式返回。
这道题的本质是模拟竖式加法。核心变量有三个:p1和p2分别遍历两个链表,一个carry变量存储进位。每次循环计算val = p1.val + p2.val + carry,新节点的值为val % 10,进位为val // 10。循环结束后,如果carry不为0,还要额外补一个节点。
面试时,这道题的分寸在于:是否主动聊到两个链表长度不等的情况。可以这样说:“如果其中一个链表遍历完了,另一个还有剩余节点,我只需要把剩余节点和进位继续相加,所以循环条件是while p1 or p2 or carry。”这句话一出来,面试官就知道你考虑过边界条件。
5. 二叉树:递归、迭代与层层推进的抽象能力
二叉树是面试算法题的“常青树”。原因在于,它考察的不是死记硬背,而是递归思维的熟练度,以及将递归改写为迭代的能力。二叉树的高度、宽度、路径、最近公共祖先等,都是面试官的心头好。
5.1 递归三要素,写之前先问自己三个问题
二叉树题目90%都可以用递归解决。递归的写法有固定套路,我称之为“递归三要素”:
- 这个函数的定义是什么(入参、返回值、要完成的事)。
- 递归的终止条件是什么(通常是节点为空)。
- 当前层要做什么(处理当前节点、递归调用左右子树、汇总结果)。
以“求二叉树最大深度”为例:
def max_depth(root): if root is None: return 0 left_depth = max_depth(root.left) right_depth = max_depth(root.right) return max(left_depth, right_depth) + 1三要素在这里非常清晰:函数定义是“计算以root为根的树的最大深度”;终止条件是root为空返回0;当前层要做的是“分别求左右子树深度,取较大值再加1”。只要三要素想清楚了,递归代码基本不会写错。
5.2 递归转迭代:栈模拟的底层逻辑
面试官经常会在你写完递归后追加一问:“如果不让用递归,你还能写吗?”这考查的是对栈的理解。
以二叉树的中序遍历为例,递归写法是:
def inorder(root): if not root: return [] return inorder(root.left) + [root.val] + inorder(root.right)改成迭代需要手动模拟栈:
def inorder_iter(root): res = [] stack = [] cur = root while cur or stack: while cur: stack.append(cur) cur = cur.left cur = stack.pop() res.append(cur.val) cur = cur.right return res这里的核心思想是:while cur不断往左走并压栈,相当于递归调用左子树;弹出栈顶节点,相当于“递归返回后处理当前节点”;然后转向右子树,相当于进入右子树的递归。理解了这一点,前序和后序的迭代写法也能推出来。
5.3 层序遍历模板:按层输出的通用解
“按层输出二叉树的节点值”也是高频题,解法是BFS+队列:
def level_order(root): if not root: return [] res = [] queue = [root] while queue: level_size = len(queue) level = [] for _ in range(level_size): node = queue.pop(0) level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) res.append(level) return res关键点在于每次循环前先取len(queue),这个长度就是当前层的节点数。这样循环内处理的就是当前层,循环末尾新入队的都是下一层的节点。很多候选人直接在循环里pop而不先记录level_size,会导致层与层之间的节点混在一起。
5.4 递归里修改全局变量的隐藏坑
二叉树递归题里,有一类“返回所有路径”的题,比如“二叉树的所有路径”“路径总和Ⅱ”。这类题需要在递归过程中收集中间结果,最容易出现的问题是:用同一个列表变量在不同分支间共享,导致回溯时数据错乱。
正确做法是:每次递归时传递当前路径的一个新副本,或者递归返回后手动回溯(删除刚添加的节点)。面试时,我建议优先用“新副本”的方式,因为逻辑清晰,不容易错;但也要理解手动回溯的写法,因为某些场景下新副本空间开销大,面试官可能会追问优化方案。
典型错误:
# 错误:拿同一个path列表传给左右子树,导致分支间数据污染 def dfs(root, path): if not root: return path.append(root.val) if not root.left and not root.right: res.append(path) dfs(root.left, path) dfs(root.right, path)正确写法之一是:
def dfs(root, path): if not root: return new_path = path + [root.val] if not root.left and not root.right: res.append(new_path) return dfs(root.left, new_path) dfs(root.right, new_path)注意path + [root.val]在Python中生成的是新列表,不会影响其他分支。如果面试官要求优化空间,可以改成回溯写法:加入节点、递归、删除节点,三个动作配对出现。
6. 动态规划:从“背转移方程”到“推导转移方程”
动态规划是面试算法题里最让人头疼的部分。它不像双指针或链表那样有明确的代码模板,每道题的转移方程都不一样。但也正因为如此,它是区分候选人算法功底的关键分水岭。
6.1 动态规划题的第一步:定义状态
我在面试中观察到的最大问题是:很多候选人一上来就想“转移方程是什么”,然后对着方程硬套,结果变形题就崩了。正确的思考顺序是:先想清楚“状态”怎么定义,再想状态之间怎么转移。
以“打家劫舍”为例:你是一个小偷,沿街盗窃,不能偷相邻的两家,求能偷到的最大金额。暴力做法是枚举所有偷或不偷的组合,复杂度O(2^n)。动态规划的做法是:
定义状态dp[i]表示“从前i个房屋中能偷到的最大金额”。注意这个定义隐含了一个决策:对于第i个房屋,要么偷要么不偷。如果不偷第i个,那dp[i] = dp[i-1];如果偷第i个,那第i-1个不能偷,dp[i] = dp[i-2] + nums[i]。
所以转移方程是:
$$ dp[i] = \max(dp[i-1], dp[i-2] + nums[i]) $$
初始化:
$$ dp[0] = nums[0],\quad dp[1] = \max(nums[0], nums[1]) $$
整个推导过程只有四步:定义状态、确定转移、设定初始值、确定遍历顺序。每道DP题都可以套这个框架。
6.2 为什么“状态定义”是最容易卡住的地方
状态定义没有统一模板,但有一些常见套路:
- 一维线性DP:dp[i]表示前i个元素的结果,比如“爬楼梯”“打家劫舍”“最长递增子序列”。
- 二维区间DP:dp[i][j]表示区间[i, j]的结果,比如“最长回文子串”“戳气球”。
- 背包类DP:dp[i][j]表示前i个物品、容量为j时的最优值,比如“0-1背包”“分割等和子集”。
- 状态机DP:dp[i][0/1]表示第i天在某种状态下的最优值,比如“买卖股票的最佳时机”系列。
面试考到动态规划时,如果你能说出“这道题的状态定义是xxx,因为它可以划分为xxx子结构”,哪怕转移方程推导慢一点,面试官也会认可。最怕的是连状态都定义不出来,直接陷入沉默。
6.3 面试现场:动手推导和口头推演
现场写DP题,我建议遵循一个执行顺序:
- 先举一个小例子手动推演。比如“打家劫舍”里,nums=[2,7,9,3],手动写出dp数组:[2,7,11,11]。这个小例子不仅能帮自己理清状态,也能让面试官看到你的推导过程。
- 把转移方程用自然语言说出来。比如“当前最大金额,要么是前一家的最大金额,要么是前两家加上当前这家”。
- 再写代码。这样面试官看到的是“你在解题”,而不是“你在默写答案”。
- 写完代码后,用手动推演的例子跑一遍。这一步非常加分,能顺带验证数组下标是否越界。
6.4 DP空间优化的常见手法
面试官在DP题后的常见追问是:“空间复杂度能优化吗?”。大多数一维DP都可以用滚动数组把O(n)优化到O(1)。
以“打家劫舍”为例:
def rob(nums): prev2 = 0 # dp[i-2] prev1 = 0 # dp[i-1] for num in nums: cur = max(prev1, prev2 + num) prev2 = prev1 prev1 = cur return prev1这里只保留了前两个状态。滚动数组的本质是:转移方程只依赖前几个状态,所以不需要保存整个数组。这个技巧在面试中非常实用,对二维DP还可以用“滚动行”的方式优化空间。
6.5 实在不会做时,怎么“止损”
面试现场如果完全没思路,有几个保底策略:
- 先说暴力解法:比如“我可以先枚举所有子集,复杂度2^n,数据规模小的时候能过”。至少能拿一部分分。
- 尝试递归+备忘录:写一个暴力递归,如果发现存在大量重复子问题,就加一个缓存数组(memo),这就是记忆化搜索,很多DP题用记忆化搜索也能通过。而且记忆化搜索比递推更容易写对,适合临场发挥。
- 和面试官沟通:可以说“我目前想到的是暴力解法,感觉有重复计算,但还没想清楚怎么用DP优化,您能给我一点提示吗?”面试官通常会给你一个方向,比如“你觉得当前决策是否只依赖前一个状态”。
我见过不止一个候选人,用“暴力递归+记忆化”把一道DP题写出来了,最终评价并不差。面试官给分从来不是只看最优解,而是看你的思维过程。
7. 刷题路线与复盘方法:把“做过”变成“会做”
前面讲了五大类高频题型的核心逻辑,但还有一个更现实的问题:时间有限,到底怎么安排刷题计划?我的建议是:按题型刷,不按题号刷。
7.1 按题型刷而不是按热度刷
LeetCode的“热题100”适合入门感受难度,但真正高效的刷法是按题型模块化推进。比如花一周专门刷双指针和滑动窗口,再花一周专门刷链表,接着二叉树,然后动态规划。每类题型集中刷10-20道,总结出通用模板和思维套路。
这样做的好处是:你能在短时间内积累大量同类题,自然而然地归纳出“这类题的解法套路”。如果你今天刷一道链表、明天刷一道动态规划,大脑无法形成有效的模式识别,刷100题可能还是混乱的。
7.2 每道题刷三遍的正确打开方式
我个人的经验是:一道有价值的题,至少刷三遍。
- 第一遍:不看答案,尝试独立写出暴力解或能想到的最优解。如果写不出来,看题解,理解思路后自己关掉题解重写一遍。
- 第二遍:过2-3天后重做这道题,只要求能把思路讲清楚+代码写出来,尽量优化到最优解。
- 第三遍:一周后盲写代码,并准备一段“为什么这样做是对的”的口头解释。
这三遍的目的分别是:建立初步认知、巩固思路、形成条件反射。特别是第三遍的口头解释,直接对应面试场景,很多人笔试时能写出代码,但面试现场说不清楚,就是缺乏这一遍练习。
7.3 建立自己的“一句话题解”库
我在准备面试时,会用一个表格维护自己的刷题记录,大概长这样:
| 题目 | 核心考点 | 一句话题解 | 易错点 |
|---|---|---|---|
| 无重复字符的最长子串 | 滑动窗口 | 右指针扩张,窗口内出现重复则收缩左指针,更新最大宽度 | 收缩条件写错 |
| 反转链表 | 链表 | prev/curr/next三指针逐节点反转,返回prev | 指针覆盖顺序 |
| 最大子数组和 | DP/贪心 | dp[i]=max(nums[i], dp[i-1]+nums[i]) | 初始化 |
| 合并两个有序链表 | 链表 | dummy哨兵节点+tail依次连接较小子节点 | 返回dummy.next |
这个表格的价值在于:考前复习效率极高。你不需要把所有代码重写一遍,只需要看“一句话题解”和“易错点”,就能快速唤起记忆。
7.4 面试前一周到底该做什么
面试前一周,不建议再刷新题,而是做三件事:
- 重刷高频题:把前面整理的“一句话题解”表里标红的高频题,全部手写一遍,重点检查边界条件和复杂度分析。
- 口头复述思路:找一个人(或者对着录音设备),随机抽题,用30秒说思路,然后写代码。模拟真实面试的节奏。
- 整理自己的“失误清单”:把做错过的题、踩过的坑汇总成一份清单,考前过一遍。我见过太多人在面试中重复犯平时刷题时犯过的错,就是因为没有把错题整理出来。
结尾
最后分享一个我个人的小习惯:刷完每一道题,找一个完全没做过这道题的人,把解题思路讲给他听。如果在讲的过程中你能让他听懂,那这道题才是真的掌握了。如果讲着讲着自己卡住了,那基本就是某个边界条件或某个为什么没想清楚,赶紧回去补。
面试常考算法题(一)先写到这。这期聊的双指针、滑动窗口、链表、二叉树、动态规划,是面试中出现频率最高的几大方向。后续我打算再写一篇专项内容,把二分查找、堆/优先队列、图的遍历、回溯算法这类同样高频的方向拆开讲。算法面试这东西,说到底是“刷题的数量决定下限,复盘的质量决定上限”,一起加油。