Files
maths-cs-ai-compendium-zh/chapter 14: data structures and algorithms/00. foundations.md
T
flykhan 2536c937e3 feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)
翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。

第01章 向量 | 第02章 矩阵 | 第03章 微积分
第04章 统计学 | 第05章 概率论 | 第06章 机器学习
第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音
第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络
第13章 计算与操作系统 | 第14章 数据结构与算法
第15章 生产级软件工程 | 第16章 SIMD与GPU编程
第17章 AI推理 | 第18章 ML系统设计
第19章 应用人工智能 | 第20章 前沿人工智能

翻译说明:
- 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留
- mkdocs.yml 配置中文导航 + language: zh
- README.md 已翻译为中文(兼 docs/index.md)
- docs/ 目录包含指向各章文件的 symlink
- 约 29,000 行中文内容,排除 .cache/ 构建缓存
2026-05-03 10:23:20 +08:00

501 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 基础:大O表示法、递归、回溯与动态规划
*在深入学习数据结构和算法之前,你需要掌握四个基础概念:衡量效率的大O表示法、将问题分解为子问题的递归、带剪枝的穷举搜索——回溯,以及避免冗余计算的动态规划。本文件从基本原理出发逐一讲解。*
- 本章后续文件默认你已经熟悉了这四个概念。如果你跳过本文件,那么后面文件中的 $O(n \log n)$ 标注、递归树遍历、回溯模板和 DP 状态转移对你来说就会像是魔法而非工程。
## 为什么是模式,而非死记硬背
- LeetCode、NeetCode 和 HackerRank 上有成千上万的编程题。没有人能记住全部,试图这么做是注定失败的策略。面试官不会从固定题库中选题——他们会修改、组合、伪装。背下来的"两数之和"解法,当面试官问你一个从未见过的变体时毫无用处。
- 好消息是:核心模式大约只有 **15-20 种**(双指针、滑动窗口、BFS/DFS、DP、回溯等)。所有问题,无论表面多新颖,最终都归结为这些模式中的一个或几个组合。面试考的不是你是否见过这道题,而是你是否能**剥离上下文**——故事、具体数据类型、边界情况——识别出底层的模式。
- 考虑这三个问题:
- "在数组中找到两个数,使其和等于一个目标值。"
- "找到两个分子,使其结合能之和等于一个阈值。"
- "给定一个账户余额列表,找到两个账户的余额之和等于一笔债务。"
- 它们看起来截然不同。但它们是同一个问题:**两数之和**。上下文(数字、分子、账户)无关紧要。其结构是:在集合中搜索补数 → 哈希表查找。
- 这就是本章通过**直觉教授模式**而非通过重复教授解题方法的原因。对于每个模式,我们都会解释:
- **问题中的什么结构特征**指示了这个模式(输入已排序 → 双指针;子数组约束 → 滑动窗口;最优子结构 + 重叠子问题 → DP)。
- **为什么这个模式有效**——数学或逻辑推理,而不仅仅是"它能给出正确答案"。
- **如何适配它**——通过展示简单、中等和困难变体,在这些变体中相同的核心思想应用于不同的上下文。
- 当你深入理解*为什么*滑动窗口有效(约束的单调性意味着扩展/收缩就足够了),你就可以将其应用到任何具有该结构的问题上,即使是未曾见过的问题。当你只是背下了"无重复字符的最长子串"的代码,一旦问题发生变化,你就会束手无策。
- 实践策略:
1. **学习模式**(本章)。
2. **练习识别模式**,在伪装的问题中(每个文件末尾的 NeetCode 练习题)。
3. **练习实现**,在时间压力下。
4. 面试中:阅读题目 → 剥离上下文 → 识别模式 → 实现。
---
## 大O表示法
- 当我们说一个算法"快"或"慢"时,需要一种精确的衡量方式。**大O表示法**描述了随着输入规模 $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)$。
```python
total = 0
for x in arr: # n 次迭代
total += x # 每次迭代 O(1)
# 总计:O(n)
```
- **嵌套循环**:迭代次数相乘。
```python
for i in range(n): # n 次迭代
for j in range(n): # 每次 n 次迭代
process(i, j) # O(1)
# 总计:O(n^2)
```
- **每次减半的循环**:$O(\log n)$。每次迭代将问题规模减半,所以需要 $\log_2 n$ 次迭代。
```python
i = n
while i > 0:
process(i)
i //= 2
# 总计:O(log n)
```
- **内循环依赖于外循环的嵌套循环**:
```python
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)$。
```python
# 不好: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)$——排序占主导。
- **平摊复杂度**:某些操作偶尔很昂贵,但平摊下来很便宜。动态数组的追加操作平摊复杂度为 $O(1)$,因为罕见的 $O(n)$ 扩容被分摊到 $n$ 次便宜的追加操作中。不要混淆平摊 $O(1)$ 和最坏情况 $O(1)$。
### 空间复杂度
- 空间复杂度遵循同样的大O规则,只是应用于内存使用而非时间。
- **原地**算法使用 $O(1)$ 额外空间(不计输入)。快速排序是 $O(\log n)$ 空间(递归栈深度)。归并排序是 $O(n)$(合并时使用的临时数组)。
- **递归栈**:每次递归调用都会使用栈空间。深度为 $n$ 的递归使用 $O(n)$ 空间,即使每次调用没有分配额外内存。这就是为什么在具有 $n$ 个节点的图上进行递归 DFS 使用 $O(n)$ 空间。
- 面试中,始终同时说明时间和空间复杂度。$O(n)$ 时间、$O(n)$ 空间的解法通常可以接受,但 $O(n)$ 时间、$O(1)$ 空间的解法更好。面试官可能会要求你优化其中一个。
---
## 递归
- **递归**是指函数调用自身来解决同一问题的更小实例。它是处理具有递归结构的问题最自然的方式:树、嵌套数据、分治法和数学序列。
- 每个递归函数都有两部分:
1. **基本情况**:可以直接解决的最小的实例(无需递归)。这是递归停止的条件。
2. **递归情况**:将问题分解为更小的子问题,递归求解,然后合并结果。
### 示例:阶乘
```python
def factorial(n):
if n <= 1: # 基本情况
return 1
return n * factorial(n - 1) # 递归情况
```
- `factorial(4)` 的执行过程:
- `factorial(4)` 调用 `factorial(3)`
- `factorial(3)` 调用 `factorial(2)`
- `factorial(2)` 调用 `factorial(1)`
- `factorial(1)` 返回 `1`(基本情况)
- `factorial(2)` 返回 `2 * 1 = 2`
- `factorial(3)` 返回 `3 * 2 = 6`
- `factorial(4)` 返回 `4 * 6 = 24`
- 每次调用都被压入**调用栈**。栈一直增长直到到达基本情况,然后随着每次调用的返回而展开。如果递归太深(例如 Python 中的 `factorial(1000000)`),栈会溢出(`RecursionError`)。Python 的默认递归限制是 1000。
### 如何以递归方式思考
- 关键的思维转变是:**信任递归**。在编写递归函数时,假设递归调用已经正确返回了更小子问题的答案。你只需要:
1. 处理基本情况。
2. 将问题分解为更小的部分。
3. 合并结果。
- 你不需要在脑中跟踪每一次递归调用。这就像试图通过在心里执行每次迭代来理解一个循环。相反,验证:"如果递归调用给了我更小输入的正确结果,那么我的组合步骤是否给出了完整输入的正确结果?"
### 示例:链表上的递归
- 递归反转链表:
```python
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)` 正确反转了链表的剩余部分并返回新的头节点。我们只需将当前节点附加到末尾。
### 示例:树上的递归
- 计算二叉树的高度:
```python
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) # 当前节点增加 1 层
```
- 这种模式——"递归左子树,递归右子树,合并结果"——解决了绝大多数树的问题(见文件03)。
### 递归 vs 迭代
- 每个递归算法都可以转换为迭代算法(使用显式栈或循环)。迭代避免了调用栈开销和栈溢出风险。
- **何时优先使用递归**:问题具有自然的递归结构(树、嵌套数据、分治法)。递归解法更简洁、更易于推理。
- **何时优先使用迭代**:递归深度可能非常大(例如,处理包含 $10^6$ 个节点的链表)。迭代解法避免了栈溢出。
- **尾递归**:如果递归调用是函数中的最后一个操作(递归调用返回后没有后续工作),则该递归调用是"尾递归"的。某些语言(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` 或转为迭代 |
---
## 回溯
- **回溯**是一种系统地探索所有可能解法的方法,通过逐步构建解并在发现部分解不可能得到有效答案时立即放弃。
- 可以把它想象成走迷宫。在每个岔路口,你选择一条路。如果碰到死胡同,你就回到上一个岔路口尝试不同的路。你不会从头开始——你**回溯**到最近的一个决策点。
### 三个步骤
每个回溯算法都遵循相同的模式:
1. **选择**:选择一个候选来扩展当前的部分解。
2. **探索**:递归地尝试从这个候选构建一个完整的解。
3. **撤销**:撤销选择(回溯)并尝试下一个候选。
```python
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 皇后)。
- 问题要求**寻找任何有效配置**:数独求解、迷宫寻路。
- 搜索空间很大但可以**剪枝**:大多数部分解可以在完全探索之前被提前拒绝。
### 剪枝如何使其变快
- 没有剪枝时,回溯会探索所有可能的组合——指数级时间。**剪枝**则提前砍掉分支:
```python
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 万 → 40,000。好的剪枝使指数级算法在中等规模的 $n$ 下变得可行。
### 生成所有子集(最简单的回溯)
```python
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$。
### 生成所有排列
```python
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)` —— 所有条目共享同一个列表 | `result.append(path[:])``path.copy()` |
| 未回溯(撤销) | 状态不断增长,后面的候选看到过时的状态 | 递归调用后始终执行 `path.pop()``state.remove()` |
| 循环起始位置错误 | 子集中有重复项,或排列中出现了不应有的重复使用 | 使用 `start` 参数避免重新访问之前的索引 |
| 跳过剪枝 | 探索明显无效的分支 | 在递归调用前添加 `if not is_valid: continue` |
---
## 动态规划
- **动态规划(DP)**是一种优化技术,适用于相同子问题被反复求解的情况。DP 不重复计算,而是每个子问题只解一次并存储结果。
- DP 适用于具有两个性质的问题:
1. **最优子结构**:最优解可以由子问题的最优解构建而成。
2. **重叠子问题**:相同的子问题在递归中多次出现。
### 斐波那契数列的动机
- 朴素递归斐波那契数列:
```python
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
```
- 对于 `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}$ 次操作——不可行。
- 使用**记忆化**(自顶向下 DP):
```python
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)$ 空间。
- 使用**制表法**(自底向上 DP):
```python
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 问题,遵循以下步骤:
1. **定义状态**`dp[i]`(或 `dp[i][j]`)代表什么?这是最难的一步。状态必须捕获足够的信息以做出最优决策。
2. **写出递推关系**`dp[i]` 如何与更小的子问题关联?这是转移公式。
3. **确定基本情况**:哪些是最小的子问题,可以直接求解?
4. **确定迭代顺序**:哪些子问题必须先于哪些子问题求解?自底向上:按照确保依赖关系已解决的顺序迭代。自顶向下:递归会自动处理。
5. **优化空间**(可选):如果 `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步——空间优化**:只需要最后两个值。
```python
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 的分类
- **1D DP**:状态依赖于单个索引。示例:爬楼梯、打家劫舍、最大子数组。
- **2D DP**:状态依赖于两个索引。示例:最长公共子序列(`dp[i][j]` 表示字符串1的前 $i$ 个字符和字符串2的前 $j$ 个字符)、编辑距离、网格路径问题。
- **区间 DP**:状态是一个区间 `dp[i][j]`,表示 `arr[i..j]` 上的子问题。示例:矩阵链乘法、戳气球。
- **背包 DP**:状态是物品索引和容量。示例:0/1 背包、零钱兑换、子集和。
- **位掩码 DP**:状态包含一个位掩码,表示哪些元素已被使用。示例:旅行商问题、分配问题。状态空间为 $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-indexed 时访问 `text1[i]` | `dp` 大小为 `(m+1) x (n+1)`,访问 `text1[i-1]` |
---
## 融会贯通
- 这四个概念构成一个递进关系:
1. **大O表示法**告诉你一个方法是否足够快。
2. **递归**将问题分解为子问题。
3. **回溯**是递归 + 选择 + 撤销,用于穷举搜索。
4. **DP**是递归 + 缓存,用于具有重叠子问题的优化。
- 当你遇到一个新问题时:
- 估计输入规模 $n$。什么样的 Big O 是可接受的?
- 如果暴力解法是指数级的,且问题要求枚举/寻找配置:**回溯**(配合剪枝使其可行)。
- 如果暴力解法是指数级的,且问题要求最优值或计数,并且你看到重叠子问题:**DP**。
- 如果问题具有减半搜索空间的结构:**二分查找**或**分治法**。
- 如果问题涉及序列且有子数组约束:**滑动窗口**或**双指针**。
- 如果问题需要快速查找:**哈希表**。