基础:大 O、递归、回溯与动态规划¶
一句话总结:学 DSA 前先啃四个底层概念——大 O 度量效率、递归拆分子问题、回溯做剪枝搜索、动态规划避免重复计算,后续所有章节都建立在这上面。
🗺️ 本章导览¶
- 本章覆盖 DSA:基础 → 数组哈希 → 链表栈队列 → 树 → 图 → 排序搜索
- 00. 基础 (本节):大 O、递归、回溯、动态规划
- 01. 数组与哈希:数组、字符串、哈希表
- 02. 链表、栈与队列: LinkedList、Stack、Queue、Deque
- 03. 树:二叉树、BST、堆、Trie、平衡树
- 04. 图: BFS、DFS、Dijkstra、拓扑排序
- 05. 排序与搜索:快排、归并、二分查找
在正式钻进数据结构与算法之前,你需要先掌握四个基础概念:大 O 记号衡量效率、递归把问题拆成子问题、回溯做带剪枝的穷举搜索、动态规划 (Dynamic Programming, DP) 避免重复计算。本节从第一性原理讲清每一个。
- 本章后续的文件都默认你已经吃透这四个想法。如果你跳过本节,后面文件里的 \(O(n \log n)\) 复杂度标注、递归树遍历、回溯模板、DP 状态转移都会让你觉得像变魔术,而不像工程。
为什么是模式,不是死记硬背¶
-
LeetCode、NeetCode、HackerRank 上有几千道题。没人能全部背下来,硬试就是必输的策略。面试官不是从固定题库里抽题——他们会魔改、组合、伪装。就算你把 "Two Sum" 的解法背得滚瓜烂熟,面试官换个你没见过的变体,你还是抓瞎。
-
好消息:真正核心的模式只有 15-20 个 (双指针、滑动窗口、广度优先搜索 / 深度优先搜索 (Breadth-First Search / Depth-First Search, BFS/DFS)、DP、回溯等等)。任何题目,不管表面多新鲜,本质都归结为其中一个或几个模式的组合。面试考的不是你有没有见过这道原题,而是能不能剥掉外衣——剥掉故事、剥掉具体数据类型、剥掉边界情况,把底下的模式认出来。
-
看这三个题:
- "在数组里找两个数,加起来等于目标值。"
- "找两个分子,让它们的结合能加起来达到阈值。"
- "给一组账户余额,找两个账户,它们加起来正好等于一笔欠款。"
-
看着不一样,其实是同一道题:Two Sum。上下文 (数字、分子、账户) 无关紧要。结构是:在集合中搜索补数 → 哈希表查找。
-
所以本章教的是基于直觉的模式,不是基于重复的解法。对每个模式,我们讲清楚:
- 问题的哪种结构性质在暗示这个模式 (输入已排序 → 双指针;子数组约束 → 滑动窗口;最优子结构 + 重叠子问题 → DP)。
- 这个模式为什么成立——数学或逻辑上的推理,而不是"反正它给出对的答案"。
- 怎么迁移——通过简单、中等、困难三个变体,看同一个核心思想在不同上下文里怎么用。
-
当你真的搞懂为什么滑动窗口成立 (约束的单调性保证了扩张 / 收缩足够覆盖所有情况),你就能把它用到任何有这种结构的问题上,哪怕你从来没见过。反过来,如果你只是背了 "无重复字符最长子串" 的代码,那题目稍微一变你就卡住。
-
实战策略:
- 学会模式 (本章)。
- 在伪装过的题里练识别 (每个文件末尾的 NeetCode 家庭作业)。
- 在时间压力下练实现。
- 面试时:读题 → 剥上下文 → 认模式 → 实现。
大 O 记号¶
-
当我们说一个算法"快"或"慢"时,需要一种精确的度量方式。大 O 记号 (Big O notation) 描述算法的运行时间 (或空间占用) 如何随输入规模 \(n\) 增长而变化,忽略常数因子和低阶项。
-
形式定义:\(f(n) = O(g(n))\) 意味着存在常数 \(c > 0\) 和 \(n_0\),使得对所有 \(n \geq n_0\) 都有 \(f(n) \leq c \cdot g(n)\)。用大白话讲:当输入变大时,\(f\) 的增长速度不超过 \(g\)。
-
为什么忽略常数?因为 \(2n\) 的算法和 \(5n\) 的算法都是 \(O(n)\)——它们的增长方式一样。换台更快的电脑,常数会变,但增长趋势不变。大 O 抓的是问题内在的难度,与硬件无关。
增长率的等级¶
- 从最快到最慢:
| 大 O | 名称 | 例子 | \(n = 10^6\) 时的操作数 |
|---|---|---|---|
| \(O(1)\) | 常数 | 数组访问、哈希查找 | 1 |
| \(O(\log n)\) | 对数 | 二分查找 | 20 |
| \(O(n)\) | 线性 | 线性扫描、单层循环 | \(10^6\) |
| \(O(n \log n)\) | 线性对数 | 归并排序、高效排序 | \(2 \times 10^7\) |
| \(O(n^2)\) | 平方 | 嵌套循环、暴力配对 | \(10^{12}\) (太慢) |
| \(O(n^3)\) | 立方 | 三重嵌套、矩阵乘法 | \(10^{18}\) (慢到没边) |
| \(O(2^n)\) | 指数 | 所有子集、暴力回溯 | \(10^{301030}\) (不可能) |
| \(O(n!)\) | 阶乘 | 所有排列 | 荒谬 |
-
经验法则:现代电脑每秒大约执行 \(10^8\)–\(10^9\) 次简单操作。1 秒时限下:
- \(O(n)\) 可用于 \(n \leq 10^8\)
- \(O(n \log n)\) 可用于 \(n \leq 10^7\)
- \(O(n^2)\) 可用于 \(n \leq 10^4\)
- \(O(2^n)\) 可用于 \(n \leq 25\)
-
这张表能让你一眼判断出你的方案够不够快。如果 \(n = 10^5\),你的解是 \(O(n^2)\),那就是 \(10^{10}\) 次操作——铁定超时。你得换个更好的算法。
大 O 怎么分析¶
- 单层循环扫过 \(n\) 个元素:\(O(n)\)。
- 嵌套循环:迭代次数相乘。
- 每次减半的循环:\(O(\log n)\)。每次迭代把问题规模减半,所以要 \(\log_2 n\) 次迭代。
- 内层依赖外层的嵌套循环:
for i in range(n):
for j in range(i): # j 从 0 到 i-1
process(i, j)
# 总计: 0 + 1 + 2 + ... + (n-1) = n(n-1)/2 = O(n^2)
- 递归:写出递推关系再求解 (第 13 章讲过主定理)。比如归并排序:\(T(n) = 2T(n/2) + O(n) = O(n \log n)\)。
常见陷阱¶
- 隐藏循环:Python 里
x in list是 \(O(n)\) (线性扫描),而x in set是 \(O(1)\)。在循环里对列表用in会得到 \(O(n^2)\),而不是 \(O(n)\)。
# 差: O(n^2) —— 对列表用 "in" 是 O(n)
for x in arr:
if x in another_list:
process(x)
# 好: O(n) —— 先转成 set
another_set = set(another_list)
for x in arr:
if x in another_set:
process(x)
-
字符串拼接:Python 里
s += c每次都复制整个字符串。在 \(n\) 次迭代的循环里:\(O(1 + 2 + \cdots + n) = O(n^2)\)。 -
排序主导:如果你的算法先排序 (\(O(n \log n)\)) 再做线性扫描 (\(O(n)\)),总的还是 \(O(n \log n)\)——排序主导。
-
摊还复杂度 (amortised complexity):某些操作偶尔昂贵,但平均下来便宜。动态数组的追加是摊还 \(O(1)\),因为罕见的 \(O(n)\) 扩容被摊到 \(n\) 次便宜的追加上。别把摊还 \(O(1)\) 和最坏情况 \(O(1)\) 混为一谈。
空间复杂度¶
-
空间复杂度和时间一样用大 O,只不过针对内存占用。
-
原地 (in-place) 算法用 \(O(1)\) 额外空间 (不算输入)。快排是 \(O(\log n)\) 空间 (递归栈深度)。归并排序是 \(O(n)\) (归并时需要临时数组)。
-
递归栈:每次递归调用都占栈空间。\(n\) 层深的递归用 \(O(n)\) 空间,就算每次调用不额外分配内存也一样。所以在 \(n\) 个节点的图上做递归 DFS,空间是 \(O(n)\)。
-
面试时时间和空间复杂度都要说。\(O(n)\) 时间 \(O(n)\) 空间的解通常能过,但 \(O(n)\) 时间 \(O(1)\) 空间的更好。面试官可能会让你继续优化其中一个。
递归¶
-
递归 (recursion) 就是函数调用自己来解决同问题的一个更小实例。对于有递归结构的问题——树、嵌套结构、分治、数学序列——它是最自然的方式。
-
每个递归函数都有两部分:
- 基础情况 (base case):最小的、能直接求解 (不用递归) 的实例。这是让递归停下来的东西。
- 递归情况 (recursive case):把问题拆成更小的子问题,递归求解,再合并结果。
例子:阶乘¶
-
factorial(4)的执行过程:factorial(4)调用factorial(3)factorial(3)调用factorial(2)factorial(2)调用factorial(1)factorial(1)返回1(基础情况)factorial(2)返回2 * 1 = 2factorial(3)返回3 * 2 = 6factorial(4)返回4 * 6 = 24
-
每次调用都压入调用栈 (call stack)。栈不断增长直到触到基础情况,然后每一层返回时逐层弹出。如果递归太深 (比如 Python 里
factorial(1000000)),栈就会溢出 (RecursionError)。Python 默认递归深度上限是 1000。
怎么用递归的方式思考¶
-
关键的心智转换:信任递归。写递归函数时,你就假设递归调用会给出更小子问题的正确答案。你要做的只有:
- 处理基础情况。
- 把问题拆小。
- 合并结果。
-
你不需要在脑子里追踪每一次递归调用。那就像想通过在脑子里跑每次迭代去理解一个循环一样,费力不讨好。你只需要验证:"如果递归调用对更小的输入给出了正确答案,那我的合并步骤对完整输入是否也给出正确答案?"
例子:在链表上递归¶
- 递归反转链表:
def reverse(head):
if not head or not head.next: # 基础情况: 0 个或 1 个节点
return head
new_head = reverse(head.next) # 反转后面的部分
head.next.next = head # 让后一个节点指回自己
head.next = None # 现在自己变成尾
return new_head
- 信任递归:
reverse(head.next)会正确反转剩下的链表并返回新的头。我们只要把当前节点接到末尾就行。
例子:在树上递归¶
- 计算二叉树的高度:
def height(root):
if not root: # 基础情况: 空树高度为 0
return 0
left_h = height(root.left) # 左子树高度
right_h = height(root.right) # 右子树高度
return 1 + max(left_h, right_h) # 加上当前这一层
- 这个模式——"递归左边、递归右边、合并"——能解绝大多数树的题 (见文件 03)。
递归 vs 迭代¶
-
任何递归算法都能改写成迭代 (用显式栈或循环)。迭代避免了调用栈的开销,也没有栈溢出的风险。
-
什么时候选递归:问题本身就有天然递归结构 (树、嵌套数据、分治)。递归解更简洁,也更好推理。
-
什么时候选迭代:递归深度可能非常大 (比如处理 \(10^6\) 个节点的链表)。迭代解避免栈溢出。
-
尾递归 (tail recursion):如果递归调用是函数的最后一个操作 (递归返回后没别的事要做),就叫尾递归。有些语言 (Scheme、Scala) 会把尾调用优化到常量栈空间。Python 不做尾调用优化,所以 Python 里的尾递归照样吃 \(O(n)\) 栈空间。
常见坑¶
| 坑 | 例子 | 修法 |
|---|---|---|
| 缺基础情况 | 无限递归 → 栈溢出 | 一定要定义什么时候停 |
| 基础情况写错 | 递归拆分时差一 | 用最小输入 (0、1、2) 测一遍 |
| 没缩小问题 | f(n) 调用 f(n) 而不是 f(n-1) |
保证子问题严格更小 |
| 重复计算 | 斐波那契 f(n) = f(n-1) + f(n-2) 指数级重算 |
用记忆化 (→ DP) |
| Python 递归深度 | factorial(10000) 崩了 |
用 sys.setrecursionlimit 或改成迭代 |
回溯¶
-
回溯 (backtracking) 是一种系统地探索所有可能解的方式:一步步构建候选解,一旦发现某个部分解不可能通向合法答案,就把它抛弃。
-
把它想象成走迷宫。每到一个岔口,你选一条路。撞到死胡同就退回上一个岔口,换另一条路走。你不是从头开始重来——你是回溯到最近的那个决策点。
三个步骤¶
每个回溯算法都是同一个模式:
- 选 (choose):挑一个候选者加进当前的部分解。
- 探 (explore):从这个候选者出发,递归地尝试构建完整解。
- 撤 (unchoose):撤销这个选择 (回溯),试下一个候选者。
def backtrack(state, choices, result):
if is_complete(state):
result.append(state.copy())
return
for choice in choices:
if is_valid(choice, state):
state.add(choice) # 1. 选
backtrack(state, choices, result) # 2. 探
state.remove(choice) # 3. 撤 (回溯)
- 撤这一步是回溯区别于普通递归的关键。不撤的话,状态会把所有选择都累加进去,你就没法探索其他分支了。
什么时候用回溯¶
- 问题要求枚举所有合法配置:所有排列、所有子集、所有合法排布 (比如 N 皇后)。
- 问题要求找出任意一个合法配置:解数独、找迷宫路径。
- 搜索空间很大但可以剪枝:大部分部分解可以在没完全展开前就被否决。
剪枝为什么能提速¶
- 不剪枝的话,回溯会枚举所有组合——指数时间。剪枝 (pruning) 提前砍掉分支:
for choice in choices:
if not is_valid(choice, state):
continue # 剪枝: 跳过整个子树
state.add(choice)
backtrack(state, choices, result)
state.remove(choice)
- 在 N 皇后 (文件 05) 里,放皇后前先检查列冲突和对角线冲突,把搜索树从 \(n^n\) 剪到大约 \(n!\) 个候选。对 \(n = 8\) 来说,是从 1600 万剪到 4 万。好的剪枝能让指数级算法在中等规模下也可行。
生成所有子集 (最简单的回溯)¶
def subsets(nums):
result = []
def backtrack(start, path):
result.append(path[:]) # 每个部分解都是一个合法子集
for i in range(start, len(nums)):
path.append(nums[i]) # 选
backtrack(i + 1, path) # 探 (i+1: 不重用)
path.pop() # 撤
backtrack(0, [])
return result
-
对
[1, 2, 3],递归树是:[]→[1]→[1,2]→[1,2,3](回溯) →[1,3](回溯) →[2]→[2,3](回溯) →[3]
-
树里每个节点都对应一次
backtrack调用。每个叶子 (以及中间节点) 都会产出一个子集。子集总数:\(2^n\)。
生成所有排列¶
def permutations(nums):
result = []
def backtrack(path, remaining):
if not remaining:
result.append(path[:])
return
for i in range(len(remaining)):
path.append(remaining[i]) # 选
backtrack(path, remaining[:i] + remaining[i+1:]) # 探
path.pop() # 撤
backtrack([], nums)
return result
- 排列总数:\(n!\)。每个排列要 \(O(n)\) 的工作去构建
remaining,所以总的是 \(O(n \cdot n!)\)。
常见坑¶
| 坑 | 例子 | 修法 |
|---|---|---|
| 忘记复制路径 | result.append(path) —— 所有条目共享同一个 list |
result.append(path[:]) 或 path.copy() |
| 忘记撤 | 状态一直增长,后续候选看到脏状态 | 递归调用后一定要 path.pop() 或 state.remove() |
| 循环起点写错 | 有重复的子集,或不该重用的排列 | 用 start 参数避免回头访问已用过的索引 |
| 忘记剪枝 | 明显不合法的分支还去探索 | 在递归调用前加 if not is_valid: continue |
动态规划¶
-
动态规划 (Dynamic Programming, DP) 是一种针对"同一个子问题被反复求解"的场景的优化技术。不再重算,DP 让每个子问题只算一次并把结果存下来。
-
DP 适用于同时具备两个性质的问题:
- 最优子结构 (optimal substructure):最优解可以由子问题的最优解拼出来。
- 重叠子问题 (overlapping subproblems):同样的子问题在递归里反复出现。
用斐波那契看动机¶
- 朴素递归斐波那契:
-
对
fib(5),递归树是:fib(5)调fib(4)和fib(3)fib(4)调fib(3)和fib(2)fib(3)被算了两次,fib(2)被算了三次
-
这是 \(O(2^n)\),因为每一层都分叉,而大部分分支都在重算同样的值。
fib(50)要 \(10^{15}\) 次以上的操作——完全跑不动。 -
加上记忆化 (memoisation) (自顶向下 DP):
def fib_memo(n, memo={}):
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
-
现在
fib(3)只算一次、存下来,后续调用直接查表。总的:\(O(n)\) 时间,\(O(n)\) 空间。 -
换成制表 (tabulation) (自底向上 DP):
def fib_tab(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
- 同样 \(O(n)\) 时间,但完全不用递归,从底往上构建解。由于每个值只依赖前两个,还能进一步优化到 \(O(1)\) 空间。
DP 的通用配方¶
对任何 DP 问题,按以下步骤:
-
定义状态:
dp[i](或dp[i][j]) 代表什么?这是最难的一步。状态必须包含足够多的信息,让你能做出最优决策。 -
写状态转移:
dp[i]怎么和更小的子问题挂钩?这就是转移方程。 -
确定基础情况:哪些最小的子问题能直接求解?
-
确定迭代顺序:哪些子问题必须先解?自底向上:按照能保证依赖先算出来的顺序迭代。自顶向下:递归会自动处理。
-
优化空间 (可选):如果
dp[i]只依赖上一行或前几个值,那就不用存整张表。
例子:思考过程¶
问题:给一个正整数数组,找出不相邻元素的最大和 (打家劫舍)。
第 1 步 —— 定义状态:dp[i] = 考虑元素 nums[0..i] 时的最大和。
第 2 步 —— 写转移:对元素 \(i\),我们要么:
- 跳过:dp[i] = dp[i-1] (不选元素 \(i\) 时的最优)。
- 选它:dp[i] = dp[i-2] + nums[i] (必须跳过元素 \(i-1\),然后加上元素 \(i\))。
所以:dp[i] = max(dp[i-1], dp[i-2] + nums[i])。
第 3 步 —— 基础情况:dp[0] = nums[0],dp[1] = max(nums[0], nums[1])。
第 4 步 —— 迭代顺序:从左到右 (每个状态依赖前两个状态)。
第 5 步 —— 空间优化:只需要保留最后两个值。
def rob(nums):
if len(nums) == 1:
return nums[0]
prev2, prev1 = nums[0], max(nums[0], nums[1])
for i in range(2, len(nums)):
curr = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, curr
return prev1
怎么识别 DP 题¶
- 问题要求最优 (最小代价、最大收益、最长序列) 或计数 (方案数)。
- 每一步都有选择 (拿 / 不拿、往左 / 往右、用这枚硬币还是不用),整体的最优答案依赖子问题的最优答案。
- 画递归树能看到重复子问题。
- 暴力是指数级的,但不同状态的数量远小于递归调用数。
DP 的几大类¶
-
一维 DP:状态只有一个索引。例子:爬楼梯、打家劫舍、最大子数组。
-
二维 DP:状态有两个索引。例子:最长公共子序列 (
dp[i][j]表示字符串 1 的前 \(i\) 个字符和字符串 2 的前 \(j\) 个字符)、编辑距离、网格路径问题。 -
区间 DP:状态是一个区间
dp[i][j],代表arr[i..j]上的子问题。例子:矩阵链乘、戳气球。 -
背包 DP:状态是物品索引 + 容量。例子:0/1 背包、零钱兑换、子集和。
-
状压 DP:状态里带一个位掩码,表示哪些元素被用过。例子:旅行商问题 (Travelling Salesman Problem, TSP)、指派问题。状态空间是 \(O(2^n \cdot n)\),\(n \leq 20\) 时可行。
自顶向下 vs 自底向上¶
| 自顶向下 (记忆化) | 自底向上 (制表) | |
|---|---|---|
| 实现 | 递归 + 缓存 | 迭代 + 表 |
| 计算 | 只算真正用到的子问题 | 所有 ≤ 目标的子问题都算 |
| 栈溢出风险 | 有 (深度递归) | 无 |
| 空间优化 | 难 | 易 (滚动数组) |
| 编码难度 | 通常更自然 (写完递归加个缓存) | 需要想清楚迭代顺序 |
- 面试时自顶向下通常写得更快。生产里通常首选自底向上,性能更好 (没有递归开销,缓存表现更好)。
常见坑¶
| 坑 | 例子 | 修法 |
|---|---|---|
| 状态定义错 | dp[i] 装不下决策所需信息 |
加维度 (比如 dp[i][j] 代替 dp[i]) |
| 缺基础情况 | dp[0] 错了 → 后面全错 |
手动验证基础情况 |
| 迭代顺序错 | 算 dp[i] 时它的依赖还没算 |
画出依赖箭头再按顺序迭代 |
dp 初始化不对 |
求最小时用 0 (应该用无穷大) | 最小化用 float('inf'),最大化用 float('-inf') |
| 忘了"跳过"选项 | 总是选当前元素 | 转移里通常是 max(take, skip) |
| 可变默认参数 | def f(memo={}) 会跨调用共享缓存 |
def f(memo=None): if memo is None: memo = {} |
| 2D DP 差一 | dp 是 1-索引却用 text1[i] |
dp 用 (m+1) x (n+1),访问 text1[i-1] |
把它们串起来¶
-
这四个概念是层层递进的:
- 大 O 告诉你这个方案够不够快。
- 递归把问题拆成子问题。
- 回溯是递归 + 选择 + 撤销,用于穷举搜索。
- DP 是递归 + 缓存,用于对重叠子问题做优化。
-
拿到新题时:
- 先估输入规模 \(n\)。什么大 O 是可以接受的?
- 如果暴力是指数级的,问题要枚举 / 找出配置:回溯 (带剪枝让它可行)。
- 如果暴力是指数级的,问题问最优或计数,且能看到重叠子问题:DP。
- 如果问题有"能把搜索空间对半砍"的结构:二分查找或分治。
- 如果问题在序列上带子数组约束:滑动窗口或双指针。
- 如果需要快速查找:哈希表。
-
本章后续文件把这些思想应用到具体的数据结构和模式上。
🎯 LeetCode 题号对照 (练手清单)¶
- 把上面的模式落到具体题目上练手。下面列出的题号来自 LeetCode 官方题库 (题号稳定不变),配合 NeetCode 的分类顺序,是全球公认的高频面试题集合。
- 顺序建议:简单 → 中等 → 困难。每个模式先做 1-2 道简单题内化模板,再挑中等题练变体。
递归 (Recursion)¶
- LC 509 Fibonacci Number (Easy):递归模板 + 记忆化入门。
- LC 206 Reverse Linked List (Easy):递归"信任子调用"的经典例子 (对应正文反转链表代码)。
- LC 104 Maximum Depth of Binary Tree (Easy):树上递归三段式 (递归左 + 递归右 + 合并)。
- LC 226 Invert Binary Tree (Easy):同上模板另一变体。
回溯 (Backtracking)¶
- LC 78 Subsets (Medium):子集枚举,对应正文
subsets代码。 - LC 46 Permutations (Medium):全排列,对应正文
permutations代码。 - LC 39 Combination Sum (Medium):带剪枝的组合搜索。
- LC 22 Generate Parentheses (Medium):约束条件下的构建。
- LC 79 Word Search (Medium):网格上的回溯 + 撤销标记。
- LC 51 N-Queens (Hard):正文提到的经典剪枝题。
- LC 37 Sudoku Solver (Hard):多约束回溯的天花板。
动态规划 (Dynamic Programming)¶
一维 DP:
- LC 70 Climbing Stairs (Easy):斐波那契的变形,DP 入门第一题。
- LC 198 House Robber (Medium):对应正文
rob代码。 - LC 53 Maximum Subarray (Medium):Kadane 算法,一维 DP 经典。
- LC 121 Best Time to Buy and Sell Stock (Easy):状态机 DP 入门。
- LC 300 Longest Increasing Subsequence (Medium):一维 DP + \(O(n \log n)\) 优化。
二维 DP:
- LC 62 Unique Paths (Medium):网格路径计数。
- LC 1143 Longest Common Subsequence (Medium):字符串二维 DP 模板题。
- LC 72 Edit Distance (Medium):字符串 DP 天花板 (对应正文提到的编辑距离)。
背包 DP:
- LC 322 Coin Change (Medium):完全背包最少硬币数。
- LC 416 Partition Equal Subset Sum (Medium):0/1 背包判定问题。
区间 DP:
- LC 312 Burst Balloons (Hard):正文提到的经典区间 DP。
补充 (后续文件的开胃菜)¶
- LC 1 Two Sum (Easy):哈希表模式 (文件 01),也是正文开头举例的原题。
- LC 704 Binary Search (Easy):二分查找模板 (文件 05)。
- LC 200 Number of Islands (Medium):BFS/DFS 入门 (文件 04)。
建议节奏:每周 5-8 题,先按模式做 (同一模式连做 3-5 道),再随机混合练识别。NeetCode 150 / Blind 75 都是基于上述题号精挑的子集,可作为路线图。
📌 本节要点¶
- 大 O 记号:描述算法随输入规模增长时的耗时或空间趋势,忽略常数因子。
- 增长率等级:从 \(O(1)\) 到 \(O(n!)\),按 \(10^8\)-\(10^9\) 次操作 / 秒判断规模上限。
- 递归两要素:基础情况 (何时停) + 递归情况 (拆小、递归、合并)。
- 信任递归:假设递归调用对更小输入返回正确答案,只验证合并步骤。
- 回溯三步:选 (choose)、探 (explore)、撤 (unchoose)——"撤"是回溯区别于普通递归的关键。
- 剪枝:尽早否决不可能合法的分支,把指数级搜索压到可行范围。
- DP 前提:最优子结构 + 重叠子问题。
- DP 配方:定义状态、写转移、定基础情况、定迭代顺序、必要时压空间。
- 记忆化 vs 制表:自顶向下写递归 + 缓存;自底向上写迭代 + 表,性能更好。
- 模式化思维:面试里 15-20 个核心模式覆盖绝大多数题,识别结构比背题重要。