刷 LeetCode 这件事,最打击人的往往不是题目本身有多难,而是“明明同类型题刷过不少,换一个场景、换一组条件,立刻认不出来”。尤其是准备校招、社招算法面试的同学,每天都和每日一题、LeetCode 周赛 430 这种新题打交道,如果只靠背题干和堆题量,很容易陷入“刷了 200 题,仍怕新题”的困境。
其实 LeetCode 考察的算法思维是相对有限的。无论题目包装成数组、字符串、矩阵还是链表,最终基本都会落到少数几种“解题模式”上。本文把这几年面试题和竞赛题里出现频率最高的 8 种 LeetCode 解题模式总结成一套方法,每种模式都给出可复制的代码模板和典型题目,你可以把模板直接拿去做专项练习,也可以用这套框架去分析 LeetCode 热门 100 题。新手建议按顺序阅读,有基础的也可以直接跳到某一节对照排查。
1. 为什么刷了很多题,遇到新题还是不会做?
先看一个很常见的现象:把算法题当作“记忆题”来刷。
今天刷了链表反转,背下迭代写法;明天刷二叉树层序遍历,背下队列模板;后天刷两数之和,背下哈希表写法。看起来每天都在做题,实际上只是把每个题单独记忆,题目之间的共性并没有被抽象出来。一旦题目条件稍作修改,或者从英文翻译过来的题干比较绕,记忆匹配失败,思路就断了。
LeetCode 真正考察的不是几千道题的题库,而是有限几种思维模型的迁移能力。所谓解题模式,就是把一类看起来不同、但底层结构相同的问题归纳成同一个解法框架。例如下面四个问题,数据都是nums = [1, 2, 3],目标值都是 3,但“目标形式”完全不同:
| 问题形态 | 典型提问 | 对应模式 |
|---|---|---|
| 找两个数,使和等于 target | “返回下标” | 双指针 / 哈希 |
| 统计连续子数组和为 target 的个数 | “有多少个连续子数组” | 前缀和 + 哈希 |
| 枚举组合,使元素和等于 target | “返回所有组合” | 回溯 |
| 每个数可选可不选,求到达 target 的方案数 | “返回方案数” | 0/1 背包动态规划 |
同一种数据,同一个目标数字,四个问题分别属于四种完全不同的模式。如果不能先判断“题目让我输出什么、具有什么约束”,直接套代码必然错。
周赛和每日一题之所以能拉开差距,不是因为出现了全新算法,而是因为新题经常给旧模式换一层业务皮肤。你能不能在读完题后快速判断出“这题本质是二分答案”“这题本质是滑动窗口”,决定了你能不能按时 AC。这套能力不靠硬背,靠对模式特征的系统总结。
2. 解题模式识别的底层方法
在给模板前,先建立一套“模式识别”的思考顺序。拿到一道题,不要急着写代码,按下面三步走。
2.1 先看输出目标
题目问什么,直接决定算法类型:
- 问“是否存在、是否能到达”:通常是 DFS、BFS,或哈希表判断。
- 问“有多少种方案”:优先考虑动态规划、回溯、组合数学。
- 问“最大/最小/最短”:滑动窗口、二分答案、BFS 最短路、动态规划,需要根据数据结构再细化。
- 问“列出所有具体方案”:回溯几乎是默认答案。
- 问“第 K 大 / 前 K 个高频”:排序、堆、快速选择。
2.2 再看数据范围
LeetCode 题目会在 constraints 中明确数组长度,这是很关键的提示:
- n ≤ 20:大概率可以暴力、DFS 全排列、状态压缩。
- n ≤ 10^3:O(n²) 可以接受,双层循环或者二维 DP。
- n ≤ 10^5:需要 O(n log n) 或 O(n),排序、二分、双指针、堆等。
- n ≤ 10^6 及以上:基本只能 O(n),参考前缀和、滑动窗口、哈希。
很多新手不看 constraints,直接写回溯,超时后再看题解,发现最优解只需要一次遍历。先估复杂度,能筛掉大量错误方向。
2.3 最后抓题目关键词
有一些关键词可以帮我们快速定位候选模式:
- “连续子数组/子串” → 前缀和、滑动窗口。
- “有序数组” → 双指针、二分。
- “单调性/最大化最小值/最小化最大值” → 二分答案。
- “所有组合/排列/路径” → 回溯。
- “从矩阵某区域向外扩散、感染” → BFS / DFS。
- “数据流中求中位数、Top K” → 堆。
下面这张模式识别表,可以在刷题初期贴在边上做参考:
| 题干信号 | 目标类型 | 首选模式 |
|---|---|---|
| 连续区间和、区间数量 | 统计数量/区间和 | 前缀和(+哈希) |
| 有序数组两两组合、反转数组 | 找满足条件的对 | 双指针 |
| 子串/子数组长度最大或最小 | 连续区间最优 | 滑动窗口 |
| 值域单调、可以 check 可行性 | 最小/最大可行值 | 二分答案 |
| 子集、组合、排列、棋盘路径 | 所有解 | 回溯 |
| 当前状态依赖前面状态 | 最优值/方案数 | 动态规划 |
| 图/矩阵连通性、最短扩散步数 | 是否存在/最短步数 | BFS / DFS |
| 第 K 大/前 K 高频 | Top K | 堆 |
模式识别不是玄学。多刷题后你会发现,每道题都等于“数据结构 + 算法模式 + 边界条件”。模板解决的是中间的算法模式部分,边界条件和题目细节仍然需要你认真读题。
3. 八种高频 LeetCode 解题模式与代码模板
3.1 前缀和:处理“连续子数组求区间和”类问题
前缀和是一种用空间换时间的经典预处理。定义数组pre[i]表示原数组前 i 个元素的和,那么[l, r)这个左闭右开区间的和,可以用pre[r] - pre[l]一步得到。
遇到连续子数组求和类问题,前缀和可以把 O(n²) 的区间枚举降到 O(1) 查询;如果再配合哈希表,还能解决“和为 target 的连续子数组个数”这类问题,典型代表是 LeetCode 560。
模板代码(可直接在 LeetCode 560 中提交):
from typing import List from collections import defaultdict class Solution: def subarraySum(self, nums: List[int], k: int) -> int: pre = 0 cnt = defaultdict(int) cnt[0] = 1 ans = 0 for x in nums: pre += x # 如果之前存在 pre - k,意味着存在连续子数组和为 k ans += cnt[pre - k] cnt[pre] += 1 return ans理解这段代码的关键是“哈希表存的是前缀和出现的次数”。我们遍历数组时维护当前前缀和pre,如果曾经出现过前缀和pre - k,那么从那个位置之后到当前位置的这段连续子数组,和就是 k。
常见误区:经典前缀和数组写法中,pre长度是n + 1,下标要错开,否则很容易越界。哈希表写法里的cnt[0] = 1也不能漏,它表示“前缀和为 0 出现了一次”,对应子数组从数组开头开始的场景。
典型题目:LeetCode 303 区域和检索、LeetCode 560 和为 K 的子数组、LeetCode 437 路径总和 III(把树路径也用前缀和思路处理)。
3.2 双指针:有序数组与链表问题的高效解法
双指针并不是某一种专属数据结构,它有两种常见形态:
第一种是对撞指针,常用于有序数组。初始化left指向开头、right指向结尾,根据当前两个指针指向元素的和与 target 的大小关系,决定移动哪一侧。因为数组有序,指针移动方向是确定的,所以不会漏解。
以 LeetCode 167 两数之和 II 为例:
from typing import List class Solution: def twoSum(self, numbers: List[int], target: int) -> List[int]: left, right = 0, len(numbers) - 1 while left < right: total = numbers[left] + numbers[right] if total == target: return [left + 1, right + 1] if total < target: left += 1 else: right -= 1 return [-1, -1]每次移动一个位置,最多遍历完整数组一次,时间复杂度 O(n)。这里能这样移动的前提是数组已经非递减排序,如果数组未排序,就需要先排序或改用哈希表。
第二种是快慢指针。经典场景是链表:快指针每次走两步,慢指针每次走一步。如果链表存在环,快指针一定会追上慢指针,因此可以判断链表是否有环,并找到环的入口。
双指针模式的核心价值在于把“两两组合”的 O(n²) 暴力枚举降到 O(n)。LeetCode 15 三数之和、LeetCode 11 盛最多水的容器都是同一种思维。
易错点:使用对撞指针时,注意别在循环内同时无脑移动两个指针。三数之和这类题目还要在获得答案后跳过重复元素,否则结果会产生重复三元组。
3.3 滑动窗口:子串和子数组的“定长/变长”控制
滑动窗口和双指针容易混淆,但关注点不同。滑动窗口通常处理的是“连续子串、连续子数组满足某个条件”的问题,窗口由左边界left和右边界right共同维护。右指针不断扩张,把新元素纳入窗口;当窗口不再满足题目要求时,左指针收缩窗口,直到窗口恢复合法。
以 LeetCode 209 长度最小的子数组为例,题目要求找出最短的连续子数组,使其和大于等于 target:
from typing import List class Solution: def minSubArrayLen(self, target: int, nums: List[int]) -> int: n = len(nums) left = 0 total = 0 ans = float("inf") for right in range(n): total += nums[right] while total >= target: ans = min(ans, right - left + 1) total -= nums[left] left += 1 return 0 if ans == float("inf") else ans这段代码里,for right in range(n)负责扩展窗口,while total >= target负责判断窗口是否要收缩。每次收缩前都会尝试更新答案,因为收缩后窗口不再满足条件,所以合法窗口只可能在收缩前出现。
易错点:有的题目窗口收缩条件复杂,例如 LeetCode 76 最小覆盖子串,需要维护“每种字符还需要多少个”的欠账数量。如果只记住“至少包含 target 字符”这个表面条件,很容易把 left 的收缩条件写错。建议把“窗口满足什么条件”单独抽成一个变量来表示,例如valid或need_cnt,而不是在 while 条件里临时统计。
滑动窗口能够把 O(n²) 的枚举子串优化到 O(n),因为每个元素最多被 right 加入一次、被 left 移出一次。
3.4 二分查找与二分答案:单调性比“有序数组”更本质
很多初学二分时只知道“在有序数组里查找 target”,但 LeetCode 里大量题目并不是直接查找数组元素,而是“搜索答案”。这类题有一个明显特征:答案在一个整数区间里,并且随着答案增大,题目给定的判定结果呈现单调变化。
先看标准二分查找模板,找有序数组中第一个大于等于 target 的位置:
from typing import List def lower_bound(nums: List[int], target: int) -> int: lo, hi = 0, len(nums) while lo < hi: mid = (lo + hi) // 2 if nums[mid] < target: lo = mid + 1 else: hi = mid return lo这段代码使用左闭右开区间,循环条件是lo < hi。当nums[mid] < target时,说明 mid 以及左侧都不可能满足,因此lo = mid + 1;否则hi = mid。最终lo就是第一个满足条件的位置。
二分答案的通用模板如下:
def can(mid) -> bool: # 根据题目实现:判断答案 mid 是否可行 pass lo, hi = 0, max_possible_answer # 根据题目确定值域 while lo < hi: mid = (lo + hi) // 2 if can(mid): hi = mid # mid 可行,尝试更小的答案 else: lo = mid + 1 # mid 不可行,答案必须更大 return lo很多看起来完全不沾边的题都能套这个模板。例如“爱吃香蕉的狒狒”LeetCode 875,虽然问的是吃香蕉速度,但速度越大吃完所需时间越短,满足单调性,在速度区间上二分即可。具体推导会在第 5 节展开。
易错点:二分最容易错的是边界和死循环。统一使用“左闭右开 +lo < hi+ 更新lo = mid + 1或hi = mid”可以避免很大一部分死循环问题。不要混用不同模板。
3.5 回溯:子集、组合、排列的统一解决方案
回溯本质是带剪枝的深度优先搜索。很多题目要求“返回所有满足条件的方案”,这类结果数量多,无法用普通 DP 直接计数,于是选择系统的搜索树遍历。
回溯核心代码只有三步:做选择、递归、撤销选择。
以 LeetCode 39 组合总和为例,数字可以被重复使用:
from typing import List class Solution: def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: ans = [] path = [] def dfs(start: int, rest: int) -> None: if rest == 0: ans.append(path[:]) return for i in range(start, len(candidates)): if candidates[i] > rest: continue path.append(candidates[i]) dfs(i, rest - candidates[i]) # 允许重复,所以从 i 开始 path.pop() dfs(0, target) return ans这里可以用单个start参数控制组合不允许重复的无序性。如果是求全排列,每个元素只能使用一次,且顺序不同算不同答案,则需要用used数组标记哪些元素已经被选到当前路径中。
回溯的两种形态要区分清楚:
- 组合型问题用
start,控制下一层只能从后面元素开始。 - 排列型问题用
used,每个元素只能选一次,但顺序可变。
当原始数组本身包含重复数字,且要求结果不能重复时,先排序,再在 for 循环内判断:
if i > start and nums[i] == nums[i - 1]: continue这行剪枝的含义是“同一层递归中,跳过已经处理过的相同数字”。
回溯复杂度通常不可接受,因为它本来就是在暴力搜索全部解。真正考察的是你是否通过 sort、start、used 和可行性剪枝减少了无效路径。写递归时尽量用局部变量维护path,并记得在递归返回后pop(),否则结果会出现残留。
3.6 动态规划:状态定义比转移模板更重要
动态规划是许多开发者的痛点,因为它不像回溯那样有一个万能 for 循环模板。但反过来看,动态规划的代码量往往很短,难点集中在“状态定义”和“状态转移”上。
最基础的入门模型是线性 DP。以 LeetCode 198 打家劫舍为例,不能偷相邻房屋,求最大金额:
from typing import List class Solution: def rob(self, nums: List[int]) -> int: n = len(nums) if n == 0: return 0 if n == 1: return nums[0] dp = [0] * n dp[0] = nums[0] dp[1] = max(nums[0], nums[1]) for i in range(2, n): dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]) return dp[n - 1]dp[i]表示偷到第 i 间房屋时能获得的最大金额。对第 i 间房屋只有两种选择:不偷,则dp[i] = dp[i-1];偷,则第 i-1 间不能偷,当前值等于dp[i-2] + nums[i]。二者取最大即可。
另一个高频模型是背包 DP,特别是在“每个元素选或不选、计算方案数/能否组成某值”的题目中,0/1 背包的一维数组模板是必须掌握的:
dp = [0] * (capacity + 1) dp[0] = 1 for x in nums: for c in range(capacity, x - 1, -1): dp[c] += dp[c - x] # 方案数版;如果是最大价值,改成 max() return dp[capacity]这里的关键点是内层循环必须倒序,否则同一个元素会被重复使用,变成完全背包。如果你发现“每个物品只能选一次但结果偏大”,通常就是内层循环方向写反了。
动态规划没有“一招鲜”的模板,建议按题型积累:线性 DP、背包 DP、区间 DP、状态压缩 DP。遇到新题时,不要急着找模板,先定义状态:dp[i]