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/ 构建缓存
This commit is contained in:
2026-05-03 10:23:20 +08:00
commit 2536c937e3
400 changed files with 49040 additions and 0 deletions
@@ -0,0 +1,500 @@
# 基础:大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**。
- 如果问题具有减半搜索空间的结构:**二分查找**或**分治法**。
- 如果问题涉及序列且有子数组约束:**滑动窗口**或**双指针**。
- 如果问题需要快速查找:**哈希表**。
@@ -0,0 +1,526 @@
# 数组与哈希
*数组和哈希表是编程中最基础的两种数据结构。本文件涵盖它们底层的运行机制,然后构建关键的问题解决模式:双指针、滑动窗口、前缀和以及基于哈希的查找,通过逐步增加难度的题目,并在每一步指出常见陷阱。*
- 如果你深入理解数组和哈希表,你可以解决约40%的编码面试题。这两种结构无处不在,因为它们提供了算法最需要的两样东西:**快速索引访问**(数组)和**按键快速查找**(哈希表)。
- 本文件教授的是模式,而非解法。目标是当你看到一个新问题时,你能识别出适用哪个模式以及为什么,而不是试图回忆一个背下来的解法。
## 数组
- **数组**是一片连续的内存块,元素以固定偏移量存储。访问元素 $i$ 的成本是 $O(1)$,因为地址就是 `base + i * element_size`。这是最快的数据访问方式,也是数组成为默认选择的原因。
- **动态数组**Python 的 `list`、Java 的 `ArrayList`、C++ 的 `vector`)在满时自动增长。其策略是**平摊加倍**:当数组满时,分配一个两倍大小的新数组并将所有元素复制过去。复制成本为 $O(n)$,但这种情况很少发生(每 $n$ 次插入一次),所以每次插入的平摊成本是 $O(1)$。
- **缓存局部性**是数组在实践中很快的原因,而不仅仅是理论上。因为元素是连续存储的,访问一个元素会将其邻近元素加载到 CPU 缓存中(第13章)。遍历数组是缓存友好的;在链表中跟随指针则不是。这个常数因子差异在实际中可能达到 10-100 倍。
| 操作 | 数组 | 动态数组 |
|-----------|-------|---------------|
| 按索引访问 | $O(1)$ | $O(1)$ |
| 追加 | 不适用 | $O(1)$ 平摊 |
| 在位置 $i$ 插入 | $O(n)$ | $O(n)$ |
| 在位置 $i$ 删除 | $O(n)$ | $O(n)$ |
| 搜索(未排序) | $O(n)$ | $O(n)$ |
- **陷阱**:在数组中间插入或删除是 $O(n)$,因为所有后续元素都必须移动。如果你需要频繁在中间插入,考虑使用链表或其他方法。
## 字符串
- **字符串**是一个字符数组。在 Python 中,字符串是不可变的:每次拼接都会创建一个新的字符串。在循环中逐字符构建字符串是 $O(n^2)$,因为每次拼接都会复制到目前为止的整个字符串。
```python
# 不好:O(n^2) 字符串拼接
s = ""
for c in characters:
s += c # 每次复制整个字符串
# 好:O(n) 使用列表然后 join
parts = []
for c in characters:
parts.append(c)
s = "".join(parts)
```
- **陷阱**:在 Python 中,循环内的 `s += c` 是最常见的性能 bug 之一。始终先收集到列表中再 `.join()`
- **编码**ASCII 使用 7 位(128 个字符)。**UTF-8** 是可变长度的:ASCII 字符使用 1 字节,带重音字符使用 2 字节,中文/日文字符使用 3 字节,表情符号使用 4 字节。当问题说"小写英文字母"时,字母表大小为 26,这意味着你可以使用固定大小的数组而不是哈希表。
## 哈希表
- **哈希表**将键映射到值,平均情况下的查找、插入和删除都是 $O(1)$。它通过计算一个**哈希函数** $h(key)$ 将键转换为数组索引来实现。
- 哈希函数必须:**确定性的**(相同键总是得到相同哈希值)、**均匀的**(将键均匀分布到各个桶中)且**计算速度快**。
- **冲突**发生在两个不同的键哈希到相同的索引时。有两种主要策略:
- **链地址法**:每个桶存储一个键值对链表。发生冲突时,追加到链表。最坏情况(所有键哈希到同一个桶):$O(n)$。使用好的哈希函数时的平均情况:$O(1)$。
- **开放地址法**:发生冲突时,探测下一个空槽。**线性探测**检查下一个槽位,然后再下一个,以此类推。它缓存友好,但会遭受**聚集**问题(长串的已占用槽位)。**罗宾汉哈希**通过将"离家较近"的条目移位来减少方差。
- **负载因子** $\alpha = n / m$(元素数 / 桶数)决定了性能。当 $\alpha$ 超过阈值(通常为 0.75)时,表会**重新哈希**:分配一个更大的表并重新插入所有元素。这需要 $O(n)$ 时间,但不常发生。
- **哈希映射**Python 中的 `dict`、Java 中的 `HashMap`)存储键值对。**哈希集合**(Python 中的 `set`、Java 中的 `HashSet`)只存储键(用于快速成员测试)。
| 操作 | 平均 | 最坏情况 |
|-----------|---------|------------|
| 查找 | $O(1)$ | $O(n)$ |
| 插入 | $O(1)$ | $O(n)$ |
| 删除 | $O(1)$ | $O(n)$ |
- **布隆过滤器**是空间高效的概率性集合。它可以告诉你"肯定不在集合中"或"可能在集合中"(具有可调的假阳性率)。它使用 $k$ 个哈希函数和一个位数组。用于数据库(避免对不存在的键进行磁盘读取)、Web 缓存和拼写检查器。
- **何时使用哈希表**:每当你需要用 $O(1)$ 的时间回答"我之前见过这个吗?"或"与这个键关联的计数/索引/值是什么?"时。如果你正在反复进行线性扫描寻找某物,哈希表几乎总能使其更快。
---
## 模式:哈希表查找
- 最基本的模式:使用哈希表将 $O(n)$ 扫描替换为 $O(1)$ 查找。
### 简单:两数之和
- **问题**:给定一个整数数组和一个目标值,返回两个数的索引,使它们的和等于目标值。
- **暴力解法** $O(n^2)$:检查每一对。
- **模式洞察**:对于每个数字 `num`,需要 `target - num` 存在于数组中的某处。与其扫描数组寻找它,不如将之前见过的数字存储在一个哈希表中。
```python
def two_sum(nums, target):
seen = {} # 值 -> 索引
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
```
- **为什么有效**:一次遍历数组。对于每个元素,哈希表查找是 $O(1)$。总计:$O(n)$ 时间,$O(n)$ 空间。
- **陷阱**:在检查补数之前不要将当前数字添加到哈希表,否则可能会让元素与自身匹配。上面代码中的顺序是正确的:先检查,后插入。
### 中等:字母异位词分组
- **问题**:给定一个字符串列表,将字母异位词分组在一起。("eat"、"tea"、"ate")是一组。
- **模式洞察**:异位词具有相同的字符但顺序不同。如果对每个字符串进行排序,异位词会产生相同的排序后键。使用这个排序后的键作为哈希表的键。
```python
from collections import defaultdict
def group_anagrams(strs):
groups = defaultdict(list)
for s in strs:
key = tuple(sorted(s)) # 或使用字符计数元组
groups[key].append(s)
return list(groups.values())
```
- **优化**:对每个字符串排序需要 $O(k \log k)$,其中 $k$ 是字符串长度。为了更快的键,统计字符频率并使用计数元组作为键:
```python
def group_anagrams_fast(strs):
groups = defaultdict(list)
for s in strs:
count = [0] * 26
for c in s:
count[ord(c) - ord('a')] += 1
groups[tuple(count)].append(s)
return list(groups.values())
```
- 这样每个字符串是 $O(k)$ 而不是 $O(k \log k)$。字符计数元组是一种**规范形式**:对组内所有成员都相同的表示。
- **陷阱**:在 Python 中,列表不可哈希(不能用作字典键)。你必须转换为元组。当人们尝试 `groups[count].append(s)` 时就会出错。
### 困难:最长连续序列
- **问题**:给定一个未排序的数组,找出最长连续序列的长度(例如,[100, 4, 200, 1, 3, 2] → 4,因为 [1, 2, 3, 4])。
- **暴力解法** $O(n \log n)$:对数组排序,然后扫描连续段。
- **模式洞察**:将所有数字放入哈希集以实现 $O(1)$ 查找。对于每个数字,检查它是否是一个序列的**起点**(即 `num - 1` 不在集合中)。如果是,则计算该序列能延伸多远。
```python
def longest_consecutive(nums):
num_set = set(nums)
best = 0
for num in num_set:
# 只从序列的开头开始计数
if num - 1 not in num_set:
length = 1
while num + length in num_set:
length += 1
best = max(best, length)
return best
```
- **为什么是 $O(n)$**:内部 `while` 循环在所有迭代中总共最多运行 $n$ 次(每个数字最多被访问两次:一次在外层循环,一次在 `while` 扩展中)。`if num - 1 not in num_set` 守卫确保我们只从序列起点开始计数。
- **陷阱**:如果没有 `if num - 1 not in num_set` 检查,你会从每个元素开始计数,在最坏情况下会变成 $O(n^2)$(例如,[1, 2, 3, ..., n] 会从每个起点扫描整个序列)。
---
## 模式:双指针
- **双指针**模式使用两个索引在数组中移动,通常从两端向中间或从同端以不同速度移动。它在数组已排序或需要比较成对元素时有效。
- **何时使用**:问题涉及成对、子数组或分区,并且数组已排序(或可在不丢失所需信息的情况下排序)。
### 简单:验证回文串
- **问题**:判断一个字符串是否是回文串,只考虑字母数字字符并忽略大小写。
- **模式**:一个指针在开头,一个在结尾。向中间移动,比较字符。
```python
def is_palindrome(s):
left, right = 0, len(s) - 1
while left < right:
# 跳过非字母数字字符
while left < right and not s[left].isalnum():
left += 1
while left < right and not s[right].isalnum():
right -= 1
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
```
- **陷阱**:忘记内部 while 循环中的 `left < right` 检查。没有它,在像 "!!!"(全部非字母数字)这样的字符串上指针可能越界。
### 中等:三数之和
- **问题**:找出数组中所有唯一的三元组,使其和为零。
- **模式**:对数组排序。固定一个元素,然后在剩余部分使用双指针找到和为固定元素相反数的对。
```python
def three_sum(nums):
nums.sort()
result = []
for i in range(len(nums) - 2):
# 跳过重复的固定元素
if i > 0 and nums[i] == nums[i - 1]:
continue
left, right = i + 1, len(nums) - 1
target = -nums[i]
while left < right:
total = nums[left] + nums[right]
if total < target:
left += 1
elif total > target:
right -= 1
else:
result.append([nums[i], nums[left], nums[right]])
# 跳过重复项
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
left += 1
right -= 1
return result
```
- **为什么有效**:排序是 $O(n \log n)$。对于每个固定元素,双指针扫描是 $O(n)$。总计:$O(n^2)$,这是该问题的最优解(你必须考虑所有成对组合)。
- **陷阱**:处理重复项是最难的部分。没有跳过重复的逻辑(对固定元素和双指针结果都是如此),你会返回重复的三元组。`if i > 0 and nums[i] == nums[i-1]: continue` 这行至关重要。
### 困难:接雨水
- **问题**:给定一个高度图(非负整数数组),计算下雨后它能接住多少水。
- **模式洞察**:对于每个位置,水位由它左边最大高度和右边最大高度中的最小值减去当前高度决定。从两端开始的双指针跟踪这些运行中的最大值。
```python
def trap(height):
left, right = 0, len(height) - 1
left_max, right_max = 0, 0
water = 0
while left < right:
if height[left] < height[right]:
if height[left] >= left_max:
left_max = height[left]
else:
water += left_max - height[left]
left += 1
else:
if height[right] >= right_max:
right_max = height[right]
else:
water += right_max - height[right]
right -= 1
return water
```
- **为什么有效**:关键的洞察是,如果 `height[left] < height[right]`,那么位置 `left` 处的水由 `left_max` 限制(我们知道右边有更高的柱子,所以右边不可能是瓶颈)。我们处理较短的一侧,保证另一侧有更高的柱子。
- **陷阱**:很多人试图先预计算 `left_max[i]``right_max[i]` 数组(这可行但使用 $O(n)$ 空间)。双指针方法实现了 $O(1)$ 空间。另外,在最大值更新中混淆 `>=``>` 会导致差一错误的水量计算。
---
## 模式:滑动窗口
- **滑动窗口**模式维护一个窗口(连续子数组),随着迭代扩展和收缩。它适用于询问满足某个条件的子数组或子串的问题。
- **何时使用**:问题要求满足约束条件的最长/最短子数组或子串,且扩展/收缩窗口是单调的(添加元素只能使约束更难/更容易满足,而不是两者兼有)。
- **模板**
```python
def sliding_window(arr):
left = 0
state = ... # 窗口状态(计数、和等)
best = ...
for right in range(len(arr)):
# 扩展:将 arr[right] 添加到窗口状态
update_state(state, arr[right])
# 收缩:当约束被违反时从左侧缩小
while constraint_violated(state):
remove_from_state(state, arr[left])
left += 1
# 更新答案
best = max(best, right - left + 1) # 或 min,取决于问题
return best
```
### 简单:买卖股票的最佳时机
- **问题**:给定每日价格,找出一笔交易(先买后卖)的最大利润。
- **模式**:跟踪到目前为止的最小价格(窗口的左边界),并在每一天计算利润。
```python
def max_profit(prices):
min_price = float('inf')
max_profit = 0
for price in prices:
min_price = min(min_price, price)
max_profit = max(max_profit, price - min_price)
return max_profit
```
- 这是一个退化的滑动窗口:左指针(最低价格)只在找到新最小值时向前移动。$O(n)$ 时间,$O(1)$ 空间。
### 中等:无重复字符的最长子串
- **问题**:找出不含重复字符的最长子串的长度。
- **模式**:通过移动 `right` 扩展窗口。当发现重复时,从左侧收缩直到重复被移除。
```python
def length_of_longest_substring(s):
char_index = {} # 字符 -> 它的最近索引
left = 0
best = 0
for right, char in enumerate(s):
if char in char_index and char_index[char] >= left:
left = char_index[char] + 1 # 跳过重复字符
char_index[char] = right
best = max(best, right - left + 1)
return best
```
- **为什么需要 `char_index[char] >= left`**:该字符可能来自当前窗口开始之前的映射。没有这个检查,你会错误地为当前窗口中实际不存在的字符收缩窗口。
- **陷阱**:使用集合并从左逐个删除字符是正确的但较慢。哈希表方法直接跳到正确的位置。
### 困难:最小覆盖子串
- **问题**:给定字符串 `s``t`,在 `s` 中找到包含 `t` 中所有字符的最小窗口。
- **模式**:扩展窗口以包含所有必需的字符,然后从左侧收缩以找到最小有效窗口。
```python
from collections import Counter
def min_window(s, t):
if not t or not s:
return ""
need = Counter(t) # 我们需要的字符及其计数
have = 0 # 我们已经拥有足够数量的唯一字符数
required = len(need) # 我们需要多少种唯一字符
left = 0
best = (float('inf'), 0, 0) # (长度, 左, 右)
window_counts = {}
for right in range(len(s)):
char = s[right]
window_counts[char] = window_counts.get(char, 0) + 1
# 检查此字符的计数是否满足要求
if char in need and window_counts[char] == need[char]:
have += 1
# 当窗口有效时从左侧收缩
while have == required:
# 更新最佳值
if (right - left + 1) < best[0]:
best = (right - left + 1, left, right)
# 移除最左边的字符
left_char = s[left]
window_counts[left_char] -= 1
if left_char in need and window_counts[left_char] < need[left_char]:
have -= 1
left += 1
length, start, end = best
return s[start:end + 1] if length != float('inf') else ""
```
- **陷阱**`have` 计数器是关键优化。没有它,你需要在每一步比较整个 `window_counts` 字典与 `need`,每次比较是 $O(|\text{unique chars}|)$。`have` 计数器使有效性检查变为 $O(1)$。
- **陷阱**:检查 `window_counts[char] == need[char]`(而不是 `>=`)确保我们每个字符只递增一次 `have`。如果使用 `>=`,我们会多计数。
---
## 模式:前缀和
- **前缀和**数组存储累积和:`prefix[i] = sum(arr[0:i])`。一旦在 $O(n)$ 时间内构建完成,任何子数组和都可以在 $O(1)$ 时间内计算:`sum(arr[l:r]) = prefix[r] - prefix[l]`
```python
def build_prefix(arr):
prefix = [0] * (len(arr) + 1)
for i in range(len(arr)):
prefix[i + 1] = prefix[i] + arr[i]
return prefix
# arr[l:r] 的和(包含 l,不包含 r)
def range_sum(prefix, l, r):
return prefix[r] - prefix[l]
```
- **何时使用**:问题涉及多个子数组和查询,或寻找具有特定和的子数组。
### 简单:区间求和查询
- **问题**:给定一个数组,回答多个"从索引 $l$ 到 $r$ 的和是多少?"的查询。
- 没有前缀和:每个查询是 $O(n)$。有前缀和:$O(n)$ 预计算,然后每个查询 $O(1)$。
### 中等:和为 K 的子数组
- **问题**:统计有多少个连续子数组的和等于 $k$。
- **模式洞察**:从索引 $l$ 到 $r$ 的子数组和等于 `prefix[r+1] - prefix[l]`。我们希望这个值等于 $k$,所以 `prefix[l] = prefix[r+1] - k`。对于每个位置,使用哈希表统计多少个更早的前缀和等于 `current_prefix - k`
```python
def subarray_sum(nums, k):
count = 0
prefix = 0
prefix_counts = {0: 1} # 空前缀和
for num in nums:
prefix += num
# 有多少更早的前缀和等于 prefix - k?
count += prefix_counts.get(prefix - k, 0)
prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
return count
```
- 这结合了前缀和与哈希表查找:$O(n)$ 时间,$O(n)$ 空间。
- **陷阱**:忘记初始化 `prefix_counts = {0: 1}`。空前缀(在任何元素之前)的和为 0。没有它,你会漏掉从索引 0 开始的子数组。
### 困难:除自身以外数组的乘积
- **问题**:给定一个数组,返回一个数组,其中每个元素是所有其他元素的乘积。你不能使用除法。
- **模式**:从左侧构建前缀乘积,从右侧构建后缀乘积。每个位置的答案是 `left_product * right_product`
```python
def product_except_self(nums):
n = len(nums)
result = [1] * n
# 左向遍历:result[i] = nums[0..i-1] 的乘积
prefix = 1
for i in range(n):
result[i] = prefix
prefix *= nums[i]
# 右向遍历:乘以 nums[i+1..n-1] 的乘积
suffix = 1
for i in range(n - 1, -1, -1):
result[i] *= suffix
suffix *= nums[i]
return result
```
- $O(n)$ 时间,$O(1)$ 额外空间(输出数组不计入)。它使用输出数组本身来存储中间前缀乘积,然后在第二遍遍历中乘入后缀乘积。
- **陷阱**:如果数组包含零,基于除法的方法会失败。这种前缀/后缀方法正确处理零,因为它从不做除法。
---
## 常见陷阱总结
| 陷阱 | 示例 | 修复 |
|---------|---------|-----|
| 窗口大小的差一错误 | `right - left` vs `right - left + 1` | 画一个2元素示例 |
| Python 中的可变默认值 | `def f(seen={})` 在调用间共享状态 | 使用 `def f(seen=None)` |
| 循环中的字符串拼接 | `s += c` 在 Python 中是 $O(n^2)$ | 使用 `list.append` + `"".join` |
| 前缀和中忘记 `{0: 1}` | 漏掉从索引 0 开始的子数组 | 始终用空前缀初始化 |
| 检查前添加哈希表 | 两数之和:在检查补数之前添加了 `num` | 先检查,后插入 |
| 未处理重复项 | 三数之和返回重复的三元组 | 跳过连续相等的值 |
| 整数溢出 | C++/Java 中大数组求和 | 使用 `long` 或检查边界 |
---
## 课后练习题(NeetCode
按顺序练习。每道题强化本文件中的一个模式。
### 哈希表查找
- [Contains Duplicate](https://neetcode.io/problems/contains-duplicate) — 热身:哈希集判断是否见过
- [Two Sum](https://neetcode.io/problems/two-sum) — 补数查找
- [Group Anagrams](https://neetcode.io/problems/anagram-groups) — 规范形式作为键
- [Top K Frequent Elements](https://neetcode.io/problems/top-k-elements-in-list) — 哈希表 + 桶排序
- [Longest Consecutive Sequence](https://neetcode.io/problems/longest-consecutive-sequence) — 哈希集配合序列起点技巧
- [Encode and Decode Strings](https://neetcode.io/problems/string-encode-and-decode) — 设计序列化方案
### 双指针
- [Valid Palindrome](https://neetcode.io/problems/is-palindrome) — 向内指针
- [Two Sum II (sorted)](https://neetcode.io/problems/two-integer-sum-ii) — 排序数组上的双指针
- [Three Sum](https://neetcode.io/problems/three-integer-sum) — 固定 + 双指针 + 去重
- [Container With Most Water](https://neetcode.io/problems/max-water-container) — 贪心双指针
- [Trapping Rain Water](https://neetcode.io/problems/trapping-rain-water) — 带运行最大值的双指针
### 滑动窗口
- [Best Time to Buy and Sell Stock](https://neetcode.io/problems/buy-and-sell-crypto) — 退化窗口
- [Longest Substring Without Repeating Characters](https://neetcode.io/problems/longest-substring-without-duplicates) — 扩展/收缩配合哈希表
- [Longest Repeating Character Replacement](https://neetcode.io/problems/longest-repeating-substring-with-replacement) — 窗口 + 最大频率技巧
- [Minimum Window Substring](https://neetcode.io/problems/minimum-window-with-characters) — 扩展到有效,收缩到最小
### 前缀和
- [Product of Array Except Self](https://neetcode.io/problems/products-of-array-discluding-self) — 前缀/后缀乘积
@@ -0,0 +1,410 @@
# 链表、栈和队列
*链表、栈和队列是更复杂数据结构的构建模块。本文件涵盖它们的运行机制,然后构建关键模式:快慢指针、单调栈和基于堆的优先队列,通过逐步增加难度的题目,并在每一步指出常见陷阱。*
- 数组提供了快速的随机访问但插入代价高。**链表**提供了快速插入但没有随机访问。**栈**和**队列**将访问限制在一端或两端,而正是这种限制使它们强大:通过限制你能做的事情,它们简化了你需要考虑的事情。
## 链表
- **单向链表**是一个节点链。每个节点存储一个值和一个指向下一个节点的指针。最后一个节点指向 `null`
```python
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
```
- **相对于数组的优势**:在已知位置插入或删除是 $O(1)$(只需重新指向指针)。无需移动元素。
- **劣势**:访问元素 $i$ 需要 $O(i)$ 次遍历(无随机访问)。缓存局部性差(节点分散在内存中)。
- **双向链表**增加了一个 `prev` 指针,支持向后遍历。用于 LRU 缓存(常数时间删除任何节点)和浏览器历史(前进/后退)。
| 操作 | 单向 | 双向 |
|-----------|--------|--------|
| 按索引访问 | $O(n)$ | $O(n)$ |
| 在头部插入 | $O(1)$ | $O(1)$ |
| 在尾部插入 | $O(n)$ 或 $O(1)$* | $O(1)$ |
| 删除给定节点 | $O(n)$** | $O(1)$ |
| 搜索 | $O(n)$ | $O(n)$ |
*有尾指针时。**需要前驱节点,需要遍历。
- **哨兵节点**(虚拟头/尾节点)简化了边界情况。没有虚拟头节点时,在头部插入或删除头部需要特殊代码。有了虚拟节点,每个真实节点都有前驱。
```python
# 无虚拟节点:头部删除需要特殊处理
def delete_head(head):
if not head:
return None
return head.next
# 有虚拟节点:统一逻辑
dummy = ListNode(0)
dummy.next = head
# 现在每次删除都是:prev.next = prev.next.next
```
- **陷阱**:忘记处理空列表(`head is None`)或单元素列表。始终测试这些边界情况。
---
## 模式:快慢指针(弗洛伊德算法)
- 使用两个以不同速度移动的指针来检测链表的属性。**慢**指针一次移动一步;**快**指针一次移动两步。
### 简单:环形链表
- **问题**:判断一个链表是否有环。
- **模式**:如果有环,快指针最终会追上慢指针(它们会相遇)。如果没有环,快指针会到达 `null`
```python
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
```
- **为什么有效**:如果环的长度为 $c$,快指针每步缩小1个节点的差距。它们必在慢指针进入环后的 $c$ 步内相遇。
- **陷阱**:检查 `fast and fast.next`(而不仅仅是 `fast.next`)。如果 `fast``None`,调用 `fast.next` 会崩溃。
### 中等:寻找链表的中间节点
- **问题**:返回中间节点。
- **模式**:当快指针到达末尾时,慢指针在中间。
```python
def find_middle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow # slow 在中间(偶数长度时为第二个中间节点)
```
### 中等:环形链表 II(寻找环的起点)
- **问题**:返回环开始的节点。
- **模式**:在快指针和慢指针相遇后,将一个指针重置到头部。两者以速度1移动。它们在环的起点相遇。
```python
def detect_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 将一个指针重置到头部
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
return None
```
- **为什么有效**:设从头到环起点的距离为 $a$,从环起点到相遇点的距离为 $b$。慢指针走了 $a + b$。快指针走了 $2(a + b)$。差值为一整圈:$a + b = c$(环长)。所以 $a = c - b$:从头到环起点的距离等于从相遇点到环起点的距离(沿环向前走)。
### 困难:K个一组反转链表
- **问题**:反转链表中每 $k$ 个连续节点。
```python
def reverse_k_group(head, k):
# 检查是否还有 k 个节点
node = head
for _ in range(k):
if not node:
return head
node = node.next
# 反转 k 个节点
prev, curr = None, head
for _ in range(k):
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
# 当前 head 现在是反转后的组的尾节点
# 递归处理剩余部分
head.next = reverse_k_group(curr, k)
return prev # prev 是这组的新头节点
```
- **陷阱**:原地反转模式(`prev, curr, nxt`)值得记住。画出来:每一步,你将 `curr.next` 指回 `prev`,然后推进所有三个指针。顺序搞错会破坏链表。
---
## 栈
- **栈**是 LIFO(后进先出):最近添加的元素最先被移除。想象一堆盘子。
- 操作:`push(x)` 添加到顶部,`pop()` 从顶部移除,`peek()` 查看顶部不移除。全部 $O(1)$。
- 栈是**递归**(调用栈)、**表达式求值**(中缀转后缀)和**撤销操作**(每个操作被入栈,撤销时弹出最后一个)背后的隐式结构。
### 简单:有效的括号
- **问题**:给定一个由括号 `()[]{}` 组成的字符串,判断它们是否平衡。
- **模式**:将左括号入栈。当看到右括号时,检查栈顶是否匹配。
```python
def is_valid(s):
stack = []
matching = {')': '(', ']': '[', '}': '{'}
for char in s:
if char in matching:
if not stack or stack[-1] != matching[char]:
return False
stack.pop()
else:
stack.append(char)
return len(stack) == 0
```
- **陷阱**:忘记最后检查 `len(stack) == 0`。字符串 "(((" 中没有不匹配的情况,但因为没有闭合的括号,它是无效的。
---
## 模式:单调栈
- **单调栈**维护按排序顺序排列的元素(递增或递减)。当新元素会破坏顺序时,你弹出元素直到顺序恢复。
- **何时使用**:问题要求"对每个元素,找到下一个/上一个更大/更小的元素。"栈的总时间复杂度为 $O(n)$,因为每个元素最多被入栈和出栈一次。
### 中等:每日温度
- **问题**:给定每日温度,对于每一天,找到需要等多少天才会升温。
- **模式**:使用一个索引栈。当当前温度高于栈顶时,弹出并记录距离。
```python
def daily_temperatures(temperatures):
n = len(temperatures)
result = [0] * n
stack = [] # 索引栈,温度递减
for i in range(n):
while stack and temperatures[i] > temperatures[stack[-1]]:
prev = stack.pop()
result[prev] = i - prev
stack.append(i)
return result
```
- 每个元素被入栈一次,最多出栈一次:总计 $O(n)$。
- **陷阱**:在栈中存储索引(而非值)。你需要索引来计算距离。
### 困难:柱状图中最大的矩形
- **问题**:给定一个条形高度数组,找出最大矩形的面积。
- **模式**:对于每个条形,找出它可以向左右延伸多远(即,每侧最近的更短条形)。单调递增栈高效地追踪这个信息。
```python
def largest_rectangle(heights):
stack = [] # 索引栈,高度递增
max_area = 0
heights.append(0) # 哨兵,用于最后清空栈
for i, h in enumerate(heights):
start = i
while stack and stack[-1][1] > h:
idx, height = stack.pop()
max_area = max(max_area, height * (i - idx))
start = idx # 当前条形可以延伸到被弹出条形开始的位置
stack.append((start, h))
heights.pop() # 移除哨兵
return max_area
```
- **陷阱**`start = idx` 这行很微妙。当我们弹出一个比当前条形更高的条形时,当前条形可以向后延伸至被弹出条形开始的位置(因为中间的所有条形至少和被弹出条形一样高)。缺少这行会得到错误的面积。
- **陷阱**:哨兵 `heights.append(0)` 确保栈中所有剩余的条形被处理。没有它,那些右侧从未遇到更短条形的条形会被遗漏。
---
## 队列
- **队列**是 FIFO(先进先出):元素从后面添加,从前面移除。想象商店里排队。
- **双端队列**deque)支持在两端 $O(1)$ 插入和删除。Python 的 `collections.deque` 是标准实现。
- 队列是 **BFS**(广度优先搜索,第14章文件04)、**任务调度**和**消息传递**背后的结构。
### 简单:用栈实现队列
- **问题**:仅使用两个栈实现一个队列。
- **模式**:使用一个栈进行入队操作,一个栈进行出队操作。当出队栈为空时,将所有元素从入队栈转移到出队栈(反转顺序)。
```python
class MyQueue:
def __init__(self):
self.push_stack = []
self.pop_stack = []
def push(self, x):
self.push_stack.append(x)
def pop(self):
if not self.pop_stack:
while self.push_stack:
self.pop_stack.append(self.push_stack.pop())
return self.pop_stack.pop()
def peek(self):
if not self.pop_stack:
while self.push_stack:
self.pop_stack.append(self.push_stack.pop())
return self.pop_stack[-1]
def empty(self):
return not self.push_stack and not self.pop_stack
```
- 每次操作的平摊复杂度 $O(1)$:每个元素最多在两个栈之间移动一次。
---
## 优先队列和堆
- **优先队列**总是返回最小(或最大)的元素,不论插入顺序。标准实现是**二叉堆**。
- **最小堆**是一棵完全二叉树,其中每个父节点都小于其子节点。最小值总是在根节点。以数组形式存储:节点 $i$ 的子节点在位置 $2i + 1$ 和 $2i + 2$。
| 操作 | 时间 |
|-----------|------|
| 插入 | $O(\log n)$ |
| 获取最小值 | $O(1)$ |
| 提取最小值 | $O(\log n)$ |
| 从数组构建堆 | $O(n)$ |
- Python 的 `heapq` 模块提供了最小堆。对于最大堆,将值取反。
```python
import heapq
# 最小堆
h = []
heapq.heappush(h, 5)
heapq.heappush(h, 2)
heapq.heappush(h, 8)
print(heapq.heappop(h)) # 2(最小)
# 最大堆技巧:取反
heapq.heappush(h, -10)
print(-heapq.heappop(h)) # 10(最大)
```
### 中等:数组中的第 K 个最大元素
- **问题**:找到第 k 个最大的元素。
- **模式**:维护一个大小为 $k$ 的最小堆。堆的根节点就是第 k 大的元素。如果堆有 $k$ 个元素且新元素大于根节点,则替换根节点。
```python
import heapq
def find_kth_largest(nums, k):
heap = nums[:k]
heapq.heapify(heap) # O(k)
for num in nums[k:]:
if num > heap[0]:
heapq.heapreplace(heap, num) # 弹出最小值,推入 numO(log k)
return heap[0]
```
- $O(n \log k)$ 时间,$O(k)$ 空间。当 $k \ll n$ 时,这比排序($O(n \log n)$)好得多。
- **陷阱**:使用大小为 $n$ 的最大堆并弹出 $k$ 次也可行但较慢:$O(n + k \log n)$。大小为 $k$ 的最小堆是最优方法。
### 困难:合并 K 个排序链表
- **问题**:合并 $k$ 个已排序链表为一个排序链表。
- **模式**:使用一个包含每个链表头节点的最小堆。弹出最小的节点,将其添加到结果中,并将其下一个节点推入堆中。
```python
import heapq
def merge_k_lists(lists):
heap = []
for i, lst in enumerate(lists):
if lst:
heapq.heappush(heap, (lst.val, i, lst))
dummy = ListNode(0)
curr = dummy
while heap:
val, i, node = heapq.heappop(heap)
curr.next = node
curr = curr.next
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
```
- $O(n \log k)$,其中 $n$ 是总节点数。堆中最多有 $k$ 个元素。
- **陷阱**:堆元组中的 `i`(索引)是用于打破平局的。没有它,当值相等时 Python 会尝试比较 `ListNode` 对象,这会崩溃因为 `ListNode` 不支持 `<`。索确保了一有效的比较。
---
## 常见陷阱总结
| 陷阱 | 示例 | 修复 |
|---------|---------|-----|
| `fast.next` 上的空指针 | 循环检测中使用 `while fast.next` | 检查 `fast and fast.next` |
| 未处理空链表 | 反转 `None` | 添加 `if not head` 守卫 |
| 栈下溢 | 从空栈弹出 | 检查 `len(stack) > 0``if stack` |
| 忘记哨兵 | 直方图遗漏了最后的条形 | 追加 0 来清空栈 |
| 堆中缺少平局打破 | 比较不可比较的对象 | 向堆元组添加索引 |
| 遍历时修改链表 | 遍历时删除节点 | 使用 prev/curr 模式或虚拟头节点 |
---
## 课后练习题(NeetCode
### 链表
- [反转链表](https://neetcode.io/problems/reverse-a-linked-list) — 基础的原地反转
- [合并两个有序链表](https://neetcode.io/problems/merge-two-sorted-linked-lists) — 双指针合并
- [环形链表](https://neetcode.io/problems/linked-list-cycle-detection) — 快慢指针
- [重排链表](https://neetcode.io/problems/reorder-linked-list) — 找中间 + 反转 + 合并
- [删除链表的倒数第 N 个节点](https://neetcode.io/problems/remove-node-from-end-of-linked-list) — 间距为 $n$ 的双指针
- [LRU 缓存](https://neetcode.io/problems/lru-cache) — 哈希表 + 双向链表
### 栈
- [有效的括号](https://neetcode.io/problems/validate-parentheses) — 括号匹配
- [最小栈](https://neetcode.io/problems/minimum-stack) — 在每层跟踪最小值
- [逆波兰表达式求值](https://neetcode.io/problems/evaluate-reverse-polish-notation) — 基于栈的求值
- [每日温度](https://neetcode.io/problems/daily-temperatures) — 单调递减栈
- [柱状图中最大的矩形](https://neetcode.io/problems/largest-rectangle-in-histogram) — 单调递增栈
- [车队](https://neetcode.io/problems/car-fleet) — 带到达时间的栈
### 堆 / 优先队列
- [数据流中的第 K 大元素](https://neetcode.io/problems/kth-largest-integer-in-a-stream) — 大小为 $k$ 的最小堆
- [最后一块石头的重量](https://neetcode.io/problems/last-stone-weight) — 最大堆模拟
- [最接近原点的 K 个点](https://neetcode.io/problems/k-closest-points-to-origin) — 按距离排序的最小堆
- [任务调度器](https://neetcode.io/problems/task-scheduler) — 贪心 + 最大堆 + 冷却时间
- [数据流的中位数](https://neetcode.io/problems/find-median-in-a-data-stream) — 双堆(下半部分用最大堆,上半部分用最小堆)
@@ -0,0 +1,381 @@
# 树
*树是层次化数据结构,是文件系统、数据库、编译器和无数面试题背后的基础。本文件涵盖二叉树、二叉搜索树、平衡树、前缀树、线段树、树状数组和并查集,包括遍历模式、递归思维以及逐步增加难度的题目。*
- **树**是一个连通的无环图(第13章)。最重要的变体是**二叉树**:每个节点最多有两个子节点(左和右)。树无处不在:编译器中的解析树、浏览器中的 DOM 树、机器学习中的决策树以及数据库中的 B 树。
- 解决树问题的关键洞察:**大多数树问题都可以递归解决**。结构是递归的(树是一个根节点加上两棵子树),因此解法也应是递归的。掌握"解决左子树、解决右子树、合并结果"的模式,你就能解决大多数树问题。
## 二叉树遍历
- 有四种标准的访问每个节点的方式:
- **中序遍历**(左、根、右):对于 BST,这会按排序顺序访问节点。
- **前序遍历**(根、左、右):用于序列化和复制树。
- **后序遍历**(左、右、根):用于删除和计算大小。
- **层序遍历**(BFS):使用队列逐层访问节点。
```python
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
def preorder(root):
if not root:
return []
return [root.val] + preorder(root.left) + preorder(root.right)
def postorder(root):
if not root:
return []
return postorder(root.left) + postorder(root.right) + [root.val]
from collections import deque
def level_order(root):
if not root:
return []
result, queue = [], deque([root])
while queue:
level = []
for _ in range(len(queue)):
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
```
- **陷阱**:上面的递归遍历在每一步都创建新列表(由于 `+` 拼接),这是 $O(n^2)$。为了效率,传递一个结果列表并原地追加:
```python
def inorder_efficient(root, result=None):
if result is None:
result = []
if root:
inorder_efficient(root.left, result)
result.append(root.val)
inorder_efficient(root.right, result)
return result
```
### 简单:二叉树的最大深度
```python
def max_depth(root):
if not root:
return 0
return 1 + max(max_depth(root.left), max_depth(root.right))
```
- **递归模式**:基本情况(null → 0),递归子节点,合并(1 + max)。同样的模式适用于数十种树问题。
### 简单:翻转二叉树
```python
def invert_tree(root):
if not root:
return None
root.left, root.right = invert_tree(root.right), invert_tree(root.left)
return root
```
### 中等:二叉树的最近公共祖先
- **问题**:找到既是 $p$ 又是 $q$ 的祖先的最低节点。
- **模式**:如果 $p$ 和 $q$ 都在左子树中,则 LCA 在左子树中。如果都在右子树中,则在右子树中。如果它们分开了(一个在左,一个在右),则当前节点就是 LCA。
```python
def lowest_common_ancestor(root, p, q):
if not root or root == p or root == q:
return root
left = lowest_common_ancestor(root.left, p, q)
right = lowest_common_ancestor(root.right, p, q)
if left and right:
return root # p 和 q 在不同子树中
return left if left else right
```
- **陷阱**:这假设 $p$ 和 $q$ 都在树中。如果它们可能不在,你需要额外的检查。
### 困难:二叉树中的最大路径和
- **问题**:找出任意两个节点之间的最大路径和(路径不需要经过根节点)。
```python
def max_path_sum(root):
best = [float('-inf')]
def dfs(node):
if not node:
return 0
left = max(dfs(node.left), 0) # 忽略负路径
right = max(dfs(node.right), 0)
# 经过当前节点的路径(可能作为"转弯点")
best[0] = max(best[0], node.val + left + right)
# 返回到父节点的最大增益
return node.val + max(left, right)
dfs(root)
return best[0]
```
- **关键洞察**:在每个节点,有两个问题:(1) *经过*这个节点的最佳路径是什么(左 + 节点 + 右)?(2) 这个节点可以贡献给其*父节点*的最佳路径是什么(节点 + max(左, 右),因为路径不能在两个层级分叉)?混淆这两者是最常见的错误。
## 二叉搜索树(BST
- **BST** 满足:对于每个节点,左子树中的所有值都较小,右子树中的所有值都较大。这实现了 $O(\log n)$ 的搜索、插入和删除(当平衡时)。
```python
def search_bst(root, target):
if not root:
return None
if target < root.val:
return search_bst(root.left, target)
elif target > root.val:
return search_bst(root.right, target)
else:
return root
def insert_bst(root, val):
if not root:
return TreeNode(val)
if val < root.val:
root.left = insert_bst(root.left, val)
else:
root.right = insert_bst(root.right, val)
return root
```
- **陷阱**:BST 操作仅在树平衡时才是 $O(\log n)$。由已排序插入构建的 BST 退化为链表:每次操作 $O(n)$。这就是平衡 BST(AVL、红黑树)存在的原因。
### 中等:验证二叉搜索树
```python
def is_valid_bst(root, lo=float('-inf'), hi=float('inf')):
if not root:
return True
if root.val <= lo or root.val >= hi:
return False
return (is_valid_bst(root.left, lo, root.val) and
is_valid_bst(root.right, root.val, hi))
```
- **陷阱**:只检查 `left.val < root.val < right.val` 是错误的。约束条件是左子树中*所有*节点都更小,而不仅仅是直接子节点。`lo`/`hi` 边界将这个约束向下传递。
### 中等:二叉搜索树中第 K 小的元素
- **模式**:BST 的中序遍历按排序顺序访问节点。访问的第 $k$ 个节点就是答案。
```python
def kth_smallest(root, k):
count = [0]
result = [None]
def inorder(node):
if not node or result[0] is not None:
return
inorder(node.left)
count[0] += 1
if count[0] == k:
result[0] = node.val
return
inorder(node.right)
inorder(root)
return result[0]
```
## 前缀树(Trie
- **前缀树**逐字符地将字符串存储在树中。每条边代表一个字符,从根到标记节点的路径代表存储的字符串。前缀树实现了 $O(L)$ 的查找,其中 $L$ 是字符串长度,无论存储了多少个字符串。
```python
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end = True
def search(self, word):
node = self.root
for char in word:
if char not in node.children:
return False
node = node.children[char]
return node.is_end
def starts_with(self, prefix):
node = self.root
for char in prefix:
if char not in node.children:
return False
node = node.children[char]
return True
```
- **何时使用**:自动补全、拼写检查、单词游戏、IP 路由表。每当你需要基于前缀的操作时。
### 困难:单词搜索 II
- **问题**:给定一个字符板和一个单词列表,找出所有可以通过遍历相邻单元格形成的单词。
- **模式**:从单词列表构建一个前缀树,然后从每个单元格使用前缀树进行 DFS,尽早剪枝分支(如果没有单词以当前前缀开头,则停止)。
- **陷阱**:没有前缀树的话,你需要为每个单词单独进行 DFS$O(w \cdot m \cdot n \cdot 4^L)$。前缀树跨单词共享前缀计算,大幅减少了工作量。
## 并查集(不相交集合)
- **并查集**跟踪一组不相交集合。两个操作:`find(x)` 返回 $x$ 所在集合的代表元,`union(x, y)` 合并包含 $x$ 和 $y$ 的集合。
```python
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.count = n # 连通分量数
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # 路径压缩
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False # 已经连通
# 按秩合并
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
self.count -= 1
return True
```
- 通过路径压缩和按秩合并,两个操作都是平摊 $O(\alpha(n)) \approx O(1)$(反阿克曼函数,实际上是常数)。
- **何时使用**:连通分量、无向图中的环检测、Kruskal 最小生成树、分组等价项。
### 中等:连通分量数量
```python
def count_components(n, edges):
uf = UnionFind(n)
for u, v in edges:
uf.union(u, v)
return uf.count
```
### 中等:冗余连接
- **问题**:找出从图中移除后使图成为树的那条边(即,创建环的那条边)。
- **模式**:逐一处理边。第一条两个端点已经在同一分量中的边就是创建环的边。
```python
def find_redundant(edges):
uf = UnionFind(len(edges) + 1)
for u, v in edges:
if not uf.union(u, v):
return [u, v] # 已经连通 → 这条边创建了环
```
## 线段树和树状数组
- **线段树**支持区间查询(子数组上的和、最小值、最大值)和单点更新,两者都是 $O(\log n)$。
- **树状数组**(二叉索引树)是前缀和查询和单点更新的更简单、更快的替代方案。它使用一种巧妙的位操作技巧:每个位置存储一个部分和,覆盖范围由最低设置位决定。
```python
class FenwickTree:
def __init__(self, n):
self.n = n
self.tree = [0] * (n + 1)
def update(self, i, delta):
i += 1 # 1-indexed
while i <= self.n:
self.tree[i] += delta
i += i & (-i) # 加上最低设置位
def prefix_sum(self, i):
i += 1
total = 0
while i > 0:
total += self.tree[i]
i -= i & (-i) # 移除最低设置位
return total
def range_sum(self, l, r):
return self.prefix_sum(r) - (self.prefix_sum(l - 1) if l > 0 else 0)
```
- **何时使用**:需要带更新的重复区间查询的问题。当你只需要前缀和时首选树状数组;当你需要任意区间操作(最小值、最大值、GCD)时使用线段树。
---
## 常见陷阱总结
| 陷阱 | 示例 | 修复 |
|---------|---------|-----|
| BST 只检查直接子节点 | `left.val < root.val` 遗漏了深层违规 | 传递 `lo`/`hi` 边界 |
| 递归中 $O(n^2)$ 列表拼接 | `inorder(left) + [val] + inorder(right)` | 追加到共享列表 |
| 忘记基本情况 | 空树上的无限递归 | `if not root: return` |
| 混淆经过路径和到父节点的路径 | 最大路径和:在两个层级分叉 | 向父节点返回单分支,单独跟踪双分支 |
| 树状数组 1-indexed vs 0-indexed | 树数组中的差一错误 | 入口处始终 `i += 1` |
| 并查集没有路径压缩 | 最坏情况下每次 `find` 是 $O(n)$ | `self.parent[x] = self.find(self.parent[x])` |
---
## 课后练习题(NeetCode
### 二叉树模式
- [翻转二叉树](https://neetcode.io/problems/invert-a-binary-tree) — 基础递归
- [二叉树的最大深度](https://neetcode.io/problems/depth-of-binary-tree) — 递归深度
- [相同的树](https://neetcode.io/problems/same-binary-tree) — 同步遍历
- [另一棵树的子树](https://neetcode.io/problems/subtree-of-a-binary-tree) — 嵌套递归
- [二叉树的层序遍历](https://neetcode.io/problems/level-order-traversal-of-binary-tree) — 带层级跟踪的 BFS
- [二叉树中的最大路径和](https://neetcode.io/problems/binary-tree-maximum-path-sum) — 带全局最优的 DFS
- [序列化与反序列化二叉树](https://neetcode.io/problems/serialize-and-deserialize-binary-tree) — 前序遍历 + null 标记
### BST 模式
- [验证二叉搜索树](https://neetcode.io/problems/valid-binary-search-tree) — 边界传播
- [二叉搜索树中第 K 小的元素](https://neetcode.io/problems/kth-smallest-integer-in-bst) — 中序遍历
- [二叉搜索树的最近公共祖先](https://neetcode.io/problems/lowest-common-ancestor-in-binary-search-tree) — 利用 BST 排序性质
### 前缀树
- [实现 Trie](https://neetcode.io/problems/implement-prefix-tree) — 基础前缀树操作
- [设计添加和搜索单词](https://neetcode.io/problems/design-word-search-data-structure) — 前缀树 + 带通配符的 DFS
- [单词搜索 II](https://neetcode.io/problems/search-for-word-ii) — 前缀树引导的回溯
### 并查集
- [连通分量数量](https://neetcode.io/problems/count-connected-components) — 基础并查集
- [冗余连接](https://neetcode.io/problems/redundant-connection) — 通过并查集检测环
@@ -0,0 +1,323 @@
# 图
*图建模了关系和连接——从社交网络到道路地图再到依赖链。本文件涵盖图的表示、BFS、DFS、最短路径、拓扑排序和连通分量,包括遍历和寻路模式,这些是图面试题中的核心。*
- 我们在第12章(邻接矩阵、拉普拉斯矩阵、谱性质)和第13章(树、平面性、着色)中已经介绍了图论。这里我们专注于**算法模式**:如何在代码中遍历、搜索和优化图。
- 两种基本的图算法是 **BFS****DFS**。几乎所有图问题都可以归结为其中一种,可能带有修改。掌握这两种算法,你就能解决绝大多数图问题。
## 图的表示
- **邻接表**:对于每个节点,存储一个邻居列表。空间:$O(|V| + |E|)$。最适合稀疏图(大多数现实世界的图)。
```python
# 无向图
graph = {
0: [1, 2],
1: [0, 3],
2: [0, 3],
3: [1, 2]
}
# 从边列表构建
def build_graph(n, edges):
graph = {i: [] for i in range(n)}
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # 有向图省略这一行
return graph
```
- **邻接矩阵**$n \times n$ 矩阵,其中 $A[i][j] = 1$ 如果边 $(i, j)$ 存在。空间:$O(|V|^2)$。最适合稠密图或需要 $O(1)$ 边查找时。
- **何时使用哪种**:绝大多数情况使用邻接表。只有当图很稠密($|E| \approx |V|^2$)或需要常数时间边存在性检查时才使用矩阵。
## 模式:BFS(广度优先搜索)
- BFS 使用队列**逐层**探索节点。它是以下问题的首选算法:
- **无权**图中的最短路径
- 层序遍历
- 寻找连通分量
- 任何询问"最小步数"的问题
```python
from collections import deque
def bfs(graph, start):
visited = {start}
queue = deque([start])
while queue:
node = queue.popleft()
for neighbour in graph[node]:
if neighbour not in visited:
visited.add(neighbour)
queue.append(neighbour)
```
- **关键**:在**入队时**添加到 `visited`,而不是在出队时。如果你在出队时标记已访问,同一个节点可能被不同前驱多次入队,浪费时间并可能导致错误结果。
### 简单:岛屿数量
- **问题**:给定一个由 '1'(陆地)和 '0'(水)组成的 2D 网格,计算岛屿的数量。
- **模式**:遍历网格。当找到一个 '1' 时,使用 BFS/DFS 将所有连通的陆地单元格标记为已访问。每次开始 BFS 就是一个岛屿。
```python
from collections import deque
def num_islands(grid):
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
# BFS 标记整个岛屿
queue = deque([(r, c)])
grid[r][c] = '0' # 标记已访问
while queue:
cr, cc = queue.popleft()
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
nr, nc = cr + dr, cc + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1':
grid[nr][nc] = '0'
queue.append((nr, nc))
return count
```
- **陷阱**`directions = [(0,1),(0,-1),(1,0),(-1,0)]` 模式用于四连通网格邻居,几乎每个网格问题都会用到。记住它。对于八连通,加上对角线。
- **陷阱**:修改输入网格(`grid[r][c] = '0'`)避免了需要单独的 `visited` 集合。在面试中这是可以接受的,但要明确说明权衡(改变了输入)。
### 中等:腐烂的橘子
- **问题**:新鲜橘子如果与腐烂橘子相邻则会腐烂。返回所有橘子都腐烂的最短时间(如果不可能则返回 -1)。
- **模式**:多源 BFS。将所有初始腐烂的橘子同时放入队列。每层 BFS 就是一个时间步。
```python
from collections import deque
def oranges_rotting(grid):
rows, cols = len(grid), len(grid[0])
queue = deque()
fresh = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 2:
queue.append((r, c))
elif grid[r][c] == 1:
fresh += 1
if fresh == 0:
return 0
time = 0
while queue and fresh > 0:
time += 1
for _ in range(len(queue)):
cr, cc = queue.popleft()
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
nr, nc = cr + dr, cc + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
grid[nr][nc] = 2
fresh -= 1
queue.append((nr, nc))
return time if fresh == 0 else -1
```
- **关键洞察**:多源 BFS 同时处理所有源。这给出了从*任何*源的最短距离,这正是"最后一个新鲜橘子腐烂需要多长时间"。
## 模式:DFS(深度优先搜索)
- DFS 尽可能深地探索,然后回溯。它使用栈(显式栈或通过递归使用调用栈)。DFS 是以下问题的首选:
- 环检测
- 拓扑排序
- 连通分量
- 回溯 / 穷举搜索
- 带约束的寻路
```python
def dfs(graph, node, visited=None):
if visited is None:
visited = set()
visited.add(node)
for neighbour in graph[node]:
if neighbour not in visited:
dfs(graph, neighbour, visited)
```
### 中等:课程表(环检测)
- **问题**:给定 $n$ 门课程和先修条件,判断是否能完成所有课程(即,没有循环依赖)。
- **模式**:在有向图中检测环。使用带有三种状态的 DFS:未访问、正在进行(在当前 DFS 路径上)、已完成。
```python
def can_finish(num_courses, prerequisites):
graph = {i: [] for i in range(num_courses)}
for course, prereq in prerequisites:
graph[course].append(prereq)
# 0 = 未访问, 1 = 进行中, 2 = 已完成
state = [0] * num_courses
def has_cycle(node):
if state[node] == 1:
return True # 回边 → 环
if state[node] == 2:
return False # 已经完全探索过
state[node] = 1 # 标记为进行中
for neighbour in graph[node]:
if has_cycle(neighbour):
return True
state[node] = 2 # 标记为已完成
return False
for course in range(num_courses):
if has_cycle(course):
return False
return True
```
- **为什么需要三种状态**:两种状态(已访问/未访问)无法区分"我正在探索这个节点"和"我已完成对这个节点的探索"。找到一个当前正在被探索的节点(状态 = 1)意味着我们发现了环。找到一个已经完全探索的节点(状态 = 2)只是交叉边,不是环。
### 中等:课程表 II(拓扑排序)
- **问题**:返回一个有效的课程顺序(拓扑排序)。
- **模式(Kahn 算法——基于 BFS)**:从没有入边的节点(入度为 0)开始。处理它们,减少它们邻居的入度。重复。
```python
from collections import deque
def find_order(num_courses, prerequisites):
graph = {i: [] for i in range(num_courses)}
indegree = [0] * num_courses
for course, prereq in prerequisites:
graph[prereq].append(course)
indegree[course] += 1
queue = deque([i for i in range(num_courses) if indegree[i] == 0])
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbour in graph[node]:
indegree[neighbour] -= 1
if indegree[neighbour] == 0:
queue.append(neighbour)
return order if len(order) == num_courses else [] # 空 = 存在环
```
- **陷阱**:如果结果中的节点数少于图中的节点数,则存在环(某些节点的入度从未降到 0)。
## 最短路径
### Dijkstra 算法
- 在**非负**加权图中从源点找到到所有其他节点的最短路径。使用优先队列(最小堆)。
```python
import heapq
def dijkstra(graph, start):
# graph: {node: [(neighbour, weight), ...]}
dist = {node: float('inf') for node in graph}
dist[start] = 0
heap = [(0, start)]
while heap:
d, node = heapq.heappop(heap)
if d > dist[node]:
continue # 过期条目
for neighbour, weight in graph[node]:
new_dist = d + weight
if new_dist < dist[neighbour]:
dist[neighbour] = new_dist
heapq.heappush(heap, (new_dist, neighbour))
return dist
```
- 时间:使用二叉堆为 $O((|V| + |E|) \log |V|)$。
- **陷阱**`if d > dist[node]: continue` 这行是必须的。没有它,你会处理过期的堆条目,可能退化到 $O(|V|^2)$。
- **陷阱**:Dijkstra 不适用于负权重。如果一条边有负权重,贪心假设(一旦节点被确定,其距离就是最优的)就不成立了。应改用 Bellman-Ford。
### 困难:网络延迟时间
- **问题**:给定 $n$ 个节点和加权有向边,找出信号从源点到达所有节点所需的时间。如果并非所有节点都可到达,返回 -1。
```python
def network_delay(times, n, k):
graph = {i: [] for i in range(1, n + 1)}
for u, v, w in times:
graph[u].append((v, w))
dist = dijkstra(graph, k)
max_time = max(dist.values())
return max_time if max_time < float('inf') else -1
```
## 强连通分量
- 在有向图中,**强连通分量(SCC)**是一个最大节点集合,其中每个节点都能到达其他所有节点。
- **Kosaraju 算法**(1) 在原始图上进行 DFS,记录完成顺序。(2) 转置图(反转所有边)。(3) 按完成顺序的逆序在转置图上进行 DFS。第3步中的每个 DFS 树就是一个 SCC。
- **何时使用**:寻找循环依赖、2-SAT、将有向图压缩为 SCC 的 DAG。
---
## 常见陷阱总结
| 陷阱 | 示例 | 修复 |
|---------|---------|-----|
| 在出队时标记已访问 | 同一节点被多次入队 | 在入队时标记已访问 |
| 有向图中只有两种状态 | 无法区分回边和交叉边 | 使用三种状态:未访问/进行中/已完成 |
| Dijkstra 用于负权重 | 错误的最短路径 | 使用 Bellman-Ford |
| 忘记 `if d > dist[node]: continue` | 处理过期堆条目 | 总是跳过当前距离更差的情况 |
| 网格边界检查 | 索引越界 | `0 <= nr < rows and 0 <= nc < cols` |
| 没有考虑 time=0 的边界情况 | 腐烂橘子:没有新鲜橘子 | 在 BFS 之前检查 `fresh == 0` |
| 将有向图构建为无向图 | 先修条件是单向的 | 只在一个方向添加边 |
---
## 课后练习题(NeetCode
### BFS 模式
- [岛屿数量](https://neetcode.io/problems/count-number-of-islands) — 网格 BFS/DFS
- [腐烂的橘子](https://neetcode.io/problems/rotting-fruit) — 多源 BFS
- [克隆图](https://neetcode.io/problems/clone-graph) — BFS + 哈希表克隆
- [太平洋大西洋水流](https://neetcode.io/problems/pacific-atlantic-water-flow) — 从两个海洋开始的 BFS
- [单词接龙](https://neetcode.io/problems/word-ladder) — 隐式图上的 BFS
### DFS 模式
- [岛屿的最大面积](https://neetcode.io/problems/max-area-of-island) — 带面积计数的 DFS
- [课程表](https://neetcode.io/problems/course-schedule) — 有向图中的环检测
- [课程表 II](https://neetcode.io/problems/course-schedule-ii) — 拓扑排序
- [连通分量数量](https://neetcode.io/problems/count-connected-components) — DFS 或并查集
- [图是否是树](https://neetcode.io/problems/valid-tree) — 连通 + 无环
### 最短路径
- [网络延迟时间](https://neetcode.io/problems/network-delay-time) — Dijkstra
- [K 站中转内最便宜的航班](https://neetcode.io/problems/cheapest-flight-path) — 带约束的修改版 BFS/Bellman-Ford
- [上升水温游泳](https://neetcode.io/problems/swim-in-rising-water) — 二分查找 + BFS 或网格上的 Dijkstra
### 进阶
- [外星文字典](https://neetcode.io/problems/foreign-dictionary) — 从字符顺序进行拓扑排序
@@ -0,0 +1,553 @@
# 排序、搜索与算法设计
*排序和搜索是最基础的算法操作。本文件涵盖排序算法、二分查找模式、分治法、贪心算法、动态规划和回溯。*
- 每个数据结构都支持算法,每个算法都依赖数据结构。本文件涵盖了**设计范式**:解决问题的高级策略。一旦你识别出适用哪种范式,实现就自然而然地跟进了。
## 排序算法
- 排序是计算机科学中研究最多的问题。理解这些算法可以建立对递归、分治法和复杂度分析的直觉。
| 算法 | 最好 | 平均 | 最坏 | 空间 | 稳定? |
|-----------|------|---------|-------|-------|---------|
| 冒泡排序 | $O(n)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 是 |
| 插入排序 | $O(n)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 是 |
| 归并排序 | $O(n \log n)$ | $O(n \log n)$ | $O(n \log n)$ | $O(n)$ | 是 |
| 快速排序 | $O(n \log n)$ | $O(n \log n)$ | $O(n^2)$ | $O(\log n)$ | 否 |
| 堆排序 | $O(n \log n)$ | $O(n \log n)$ | $O(n \log n)$ | $O(1)$ | 否 |
| 计数排序 | $O(n + k)$ | $O(n + k)$ | $O(n + k)$ | $O(k)$ | 是 |
| 基数排序 | $O(d(n + k))$ | $O(d(n + k))$ | $O(d(n + k))$ | $O(n + k)$ | 是 |
- **稳定**意味着相等元素保持其相对顺序。这在按多个键排序时很重要。
- 基于比较的排序的**下限**是 $\Omega(n \log n)$。证明使用决策树(第13章):任何比较排序必须区分所有 $n!$ 种排列,至少需要 $\log_2(n!) = \Omega(n \log n)$ 次比较。计数排序和基数排序通过不比较元素而超越了这个下限。
### 归并排序
- 将数组分成两半,递归排序每一半,然后合并已排序的两半。始终为 $O(n \log n)$$O(n)$ 额外空间。
```python
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= 保证稳定性
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
```
- **陷阱**:在合并中使用 `<` 而不是 `<=` 会破坏稳定性(右半部分的相等元素会排在左半部分之前)。
### 快速排序
- 选择一个**基准**,将元素分为"小于基准"和"大于基准"两组,递归排序每组。平均 $O(n \log n)$,最坏 $O(n^2)$(当基准总是最小或最大元素时)。
```python
def quicksort(arr, lo=0, hi=None):
if hi is None:
hi = len(arr) - 1
if lo >= hi:
return
pivot_idx = partition(arr, lo, hi)
quicksort(arr, lo, pivot_idx - 1)
quicksort(arr, pivot_idx + 1, hi)
def partition(arr, lo, hi):
pivot = arr[hi] # Lomuto 分区:基准是最后一个元素
i = lo
for j in range(lo, hi):
if arr[j] < pivot:
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[hi] = arr[hi], arr[i]
return i
```
- **基准策略**:最后一个元素(简单,对已排序输入不好)、随机(期望 $O(n \log n)$)、三数取中(实际选择)。在面试中始终优先选择随机基准以避免最坏情况的讨论。
- **陷阱**:快速排序的 $O(n^2)$ 最坏情况发生在已排序数组配合首/尾基准时。实践中,随机基准或三数取中消除了这个问题。
### 计数排序
- 当值在已知范围 $[0, k)$ 内的整数时,统计出现次数并重构:$O(n + k)$ 时间。不是基于比较的,因此可以超越 $O(n \log n)$。
```python
def counting_sort(arr, k):
count = [0] * k
for x in arr:
count[x] += 1
result = []
for val in range(k):
result.extend([val] * count[val])
return result
```
- **何时使用**:范围 $k$ 不比 $n$ 大很多。如果 $k = O(n)$,这是 $O(n)$。如果 $k \gg n$(例如,对范围 $[0, 10^9]$ 中的 10 个数字排序),计数排序会浪费内存。
---
## 模式:二分查找
- 二分查找通过在已排序数组中反复减半搜索空间来以 $O(\log n)$ 的时间找到目标。但二分查找远不止"在已排序数组中找一个数"。通用模式是:**在单调条件上进行搜索**。
- **模板**(避免差一错误的那一个):
```python
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = lo + (hi - lo) // 2 # 在其他语言中避免溢出
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1 # 未找到
```
- **下界**(第一个 $\geq$ target 的元素):
```python
def lower_bound(arr, target):
lo, hi = 0, len(arr)
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
```
- **陷阱**`lo <= hi``lo < hi` 的区别,以及 `hi = mid``hi = mid - 1` 的区别,决定了你是找到精确匹配还是边界。用一个2元素数组画出来验证。
### 简单:二分查找
- 标准问题。使用上面的模板。
### 中等:搜索旋转排序数组
- **问题**:一个排序数组在某个枢轴处被旋转。找到目标值。
- **模式**:在每一步,有一半总是有序的。确定哪一半是有序的,并检查目标是否在这一半中。
```python
def search_rotated(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
# 左半部分有序
if nums[lo] <= nums[mid]:
if nums[lo] <= target < nums[mid]:
hi = mid - 1
else:
lo = mid + 1
# 右半部分有序
else:
if nums[mid] < target <= nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return -1
```
- **陷阱**`nums[lo] <= nums[mid]` 中的 `<=`(而不是 `<`)至关重要。当 `lo == mid`(只剩2个元素)时,我们必须正确识别有序的一半。
### 困难:寻找两个有序数组的中位数
- **问题**:在 $O(\log(m + n))$ 时间内找到两个有序数组的中位数。
- **模式**:对较小数组的分割点进行二分查找。分割将两个数组分为两部分,使得左侧所有元素都小于右侧所有元素。
```python
def find_median(nums1, nums2):
if len(nums1) > len(nums2):
nums1, nums2 = nums2, nums1 # 确保 nums1 较短
m, n = len(nums1), len(nums2)
lo, hi = 0, m
half = (m + n + 1) // 2
while lo <= hi:
i = (lo + hi) // 2 # nums1 中的分割点
j = half - i # nums2 中的分割点
left1 = nums1[i - 1] if i > 0 else float('-inf')
right1 = nums1[i] if i < m else float('inf')
left2 = nums2[j - 1] if j > 0 else float('-inf')
right2 = nums2[j] if j < n else float('inf')
if left1 <= right2 and left2 <= right1:
# 正确分割
if (m + n) % 2 == 1:
return max(left1, left2)
return (max(left1, left2) + min(right1, right2)) / 2
elif left1 > right2:
hi = i - 1
else:
lo = i + 1
```
- 这是最难的二分查找问题之一。关键在于你搜索的不是一个值,而是一个**满足条件的分割点**。
### 元模式:对答案进行二分查找
- 许多看起来不像二分查找的问题可以通过对答案进行二分查找来解决。如果答案是一个数字,并且你可以写一个单调的函数 `is_feasible(x)`(对所有 $x \geq$ 最优值为 True,或对所有 $x \geq$ 最优值为 False),那么就在 $x$ 上进行二分查找。
- **示例**:"在 $d$ 天内运送所有包裹所需的最小运力是多少?"对运力进行二分查找。对于每个候选运力,贪心地检查是否可以在 $d$ 天内运送所有包裹。
```python
def ship_within_days(weights, days):
lo, hi = max(weights), sum(weights)
while lo < hi:
mid = (lo + hi) // 2
# 能否以运力 mid 在 <= days 天内运送完?
current_load, num_days = 0, 1
for w in weights:
if current_load + w > mid:
num_days += 1
current_load = 0
current_load += w
if num_days <= days:
hi = mid
else:
lo = mid + 1
return lo
```
---
## 模式:贪心算法
- **贪心**算法在每一步做出局部最优选择,希望这能导致全局最优解。贪心在问题具有**贪心选择性质**(局部最优导致全局最优)和**最优子结构**(最优解包含子问题的最优解)时有效。
### 中等:跳跃游戏
- **问题**:给定一个数组,其中 `nums[i]` 是在位置 $i$ 的最大跳跃长度,判断是否能够到达最后一个索引。
```python
def can_jump(nums):
max_reach = 0
for i, jump in enumerate(nums):
if i > max_reach:
return False # 无法到达这个位置
max_reach = max(max_reach, i + jump)
return True
```
- **为什么贪心有效**:我们只需要知道最远可达位置。如果当前位置超过了最远可达位置,我们就卡住了。否则,更新最远可达位置。
### 中等:合并区间
- **问题**:合并重叠的区间。
```python
def merge_intervals(intervals):
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for start, end in intervals[1:]:
if start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
```
- **模式**:按开始时间排序,然后贪心地合并。如果当前区间与上一个合并的区间重叠,则扩展它。否则,开始一个新的合并区间。
- **陷阱**:使用 `merged[-1][1] = end` 而不是 `merged[-1][1] = max(merged[-1][1], end)`。一个区间可能完全包含在另一个区间内(例如 [1, 10] 和 [2, 5])。
---
## 模式:动态规划
- **动态规划(DP)**通过将问题分解为重叠的子问题,每个子问题只解一次并存储结果。它适用于具有**最优子结构**和**重叠子问题**的问题。
- **两种方法**
- **自顶向下(记忆化)**:写出自然的递归解法,然后在字典中缓存结果。
- **自底向上(制表法)**:从最小的子问题开始向上构建表格。
- **如何识别 DP**:问题要求最优值(最小/最大)、计数或存在性,并且当前决策依赖于先前的决策。如果你画出递归树并看到重复的子问题,那就是 DP。
### 简单:爬楼梯
- **问题**:$n$ 个台阶,每次可以爬 1 或 2 个台阶。有多少种不同的方法?
- 这就是斐波那契数列:$f(n) = f(n-1) + f(n-2)$。
```python
def climb_stairs(n):
if n <= 2:
return n
a, b = 1, 2
for _ in range(3, n + 1):
a, b = b, a + b
return b
```
- $O(n)$ 时间,$O(1)$ 空间。不需要完整的记忆化表,因为每个状态只依赖于前两个。
### 中等:零钱兑换
- **问题**:给定硬币面额和一个目标金额,找到所需的最少硬币数量。
- **状态**`dp[amount]` = 凑成 `amount` 所需的最小硬币数。
- **转移**`dp[amount] = min(dp[amount - coin] + 1)` 对每个硬币。
- **基本情况**`dp[0] = 0`
```python
def coin_change(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for coin in coins:
if coin <= a and dp[a - coin] + 1 < dp[a]:
dp[a] = dp[a - coin] + 1
return dp[amount] if dp[amount] != float('inf') else -1
```
- **陷阱**:用 `float('inf')` 初始化(而不是 0 或 -1)。最小比较只有在不可达状态为无穷大时才有效。
### 中等:最长公共子序列
- **问题**:给定两个字符串,找出它们的最长公共子序列的长度。
- **状态**`dp[i][j]` = `text1[:i]``text2[:j]` 的 LCS。
- **转移**:如果 `text1[i-1] == text2[j-1]`,则 `dp[i][j] = dp[i-1][j-1] + 1`。否则,`dp[i][j] = max(dp[i-1][j], dp[i][j-1])`
```python
def longest_common_subsequence(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]
```
### 困难:0/1 背包
- **问题**:给定具有重量和价值的物品,以及容量 $W$,在不超出 $W$ 的情况下最大化总价值。
- **状态**`dp[i][w]` = 使用前 $i$ 个物品在容量 $w$ 下的最大价值。
- **转移**`dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])`(跳过或取用物品 $i$)。
```python
def knapsack(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w] # 跳过物品 i
if weights[i - 1] <= w:
dp[i][w] = max(dp[i][w],
dp[i - 1][w - weights[i - 1]] + values[i - 1])
return dp[n][capacity]
```
- **空间优化**:由于每一行只依赖于前一行,使用一维数组并从右向左迭代 $w$:
```python
def knapsack_optimised(weights, values, capacity):
dp = [0] * (capacity + 1)
for i in range(len(weights)):
for w in range(capacity, weights[i] - 1, -1): # 从右向左!
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
```
- **陷阱**:在一维版本中从左向右迭代会允许多次使用物品 $i$(无限背包)。从右向左确保每个物品最多使用一次。
---
## 模式:回溯
- **回溯**是带剪枝的穷举搜索。逐步构建解,一旦部分解不可能导致完整的有效解,就立即放弃(回溯)。
- **模板**
```python
def backtrack(candidates, path, result):
if is_solution(path):
result.append(path[:]) # 复制!
return
for candidate in get_candidates(path):
if is_valid(candidate, path):
path.append(candidate) # 选择
backtrack(candidates, path, result) # 探索
path.pop() # 撤销(回溯)
```
### 中等:子集
```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)
path.pop()
backtrack(0, [])
return result
```
### 中等:组合总和
- **问题**:找出所有和为目标值的唯一组合(元素可重复使用)。
```python
def combination_sum(candidates, target):
result = []
def backtrack(start, path, remaining):
if remaining == 0:
result.append(path[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break # 剪枝:已排序,后续候选都太大
path.append(candidates[i])
backtrack(i, path, remaining - candidates[i]) # i 而不是 i+1:允许重复使用
path.pop()
candidates.sort() # 排序以便剪枝
backtrack(0, [], target)
return result
```
- **陷阱**`backtrack(i, ...)` 允许重复使用同一元素。`backtrack(i + 1, ...)` 会移动到下一个元素(不可重复使用)。搞错这个是最常见的回溯 bug。
### 困难:N 皇后
- **问题**:在 $n \times n$ 的棋盘上放置 $n$ 个皇后,使得它们互不攻击。
```python
def solve_n_queens(n):
result = []
cols = set()
pos_diag = set() # (row + col) 在 / 对角线上为常数
neg_diag = set() # (row - col) 在 \ 对角线上为常数
board = [['.' ] * n for _ in range(n)]
def backtrack(row):
if row == n:
result.append([''.join(r) for r in board])
return
for col in range(n):
if col in cols or (row + col) in pos_diag or (row - col) in neg_diag:
continue
cols.add(col)
pos_diag.add(row + col)
neg_diag.add(row - col)
board[row][col] = 'Q'
backtrack(row + 1)
cols.remove(col)
pos_diag.remove(row + col)
neg_diag.remove(row - col)
board[row][col] = '.'
backtrack(0)
return result
```
- **关键洞察**:对角线编码。对于 `/` 对角线,`row + col` 是常数。对于 `\` 对角线,`row - col` 是常数。使用集合跟踪列和对角线使得有效性检查变为 $O(1)$。
---
## 常见陷阱总结
| 陷阱 | 示例 | 修复 |
|---------|---------|-----|
| 二分查找中 `lo <= hi` vs `lo < hi` | 边界差一错误 | 根据 `hi` 是包含还是排除来选择 |
| 从左到右的一维背包 | 物品被多次使用 | 0/1 背包从右向左迭代 |
| 回溯中未复制路径 | `result.append(path)` — 所有条目指向同一列表 | `result.append(path[:])``path.copy()` |
| `backtrack(i)` vs `backtrack(i+1)` | 重复使用 vs 不重复使用元素 | 匹配问题要求 |
| 排序后的回溯中缺少 `break` | 探索过大的候选 | 排序 + 候选超过剩余时 break |
| DP 初始化 | `dp[0]` 错误 → 所有后续值都错 | 仔细定义基本情况 |
| 未经证明的贪心 | 贪心并不总是有效 | 验证贪心选择性质 |
| 多键排序时不稳定 | 相等元素的相对顺序丢失 | 使用稳定排序(归并排序、Python 的 `sorted` |
---
## 课后练习题(NeetCode
### 二分查找
- [二分查找](https://neetcode.io/problems/binary-search) — 标准模板
- [搜索二维矩阵](https://neetcode.io/problems/search-2d-matrix) — 在展平矩阵上二分查找
- [Koko 吃香蕉](https://neetcode.io/problems/eating-bananas) — 对答案二分查找
- [搜索旋转排序数组](https://neetcode.io/problems/find-target-in-rotated-sorted-array) — 识别有序的一半
- [寻找旋转排序数组中的最小值](https://neetcode.io/problems/find-minimum-in-rotated-sorted-array) — 搜索拐点
- [寻找两个有序数组的中位数](https://neetcode.io/problems/median-of-two-sorted-arrays) — 基于分割的二分查找
### 贪心
- [跳跃游戏](https://neetcode.io/problems/jump-game) — 跟踪最远距离
- [跳跃游戏 II](https://neetcode.io/problems/jump-game-ii) — BFS 风格的层级跟踪
- [合并区间](https://neetcode.io/problems/merge-intervals) — 排序 + 合并
- [插入区间](https://neetcode.io/problems/insert-new-interval) — 寻找重叠区域
- [无重叠区间](https://neetcode.io/problems/non-overlapping-intervals) — 按结束时间排序
### 动态规划
- [爬楼梯](https://neetcode.io/problems/climbing-stairs) — 斐波那契 DP
- [打家劫舍](https://neetcode.io/problems/house-robber) — 取或不取 DP
- [打家劫舍 II](https://neetcode.io/problems/house-robber-ii) — 环形:运行两次
- [零钱兑换](https://neetcode.io/problems/coin-change) — 无限背包
- [最长公共子序列](https://neetcode.io/problems/longest-common-subsequence) — 两个字符串上的 2D DP
- [单词拆分](https://neetcode.io/problems/word-break) — 带集合查找的 DP
- [最长递增子序列](https://neetcode.io/problems/longest-increasing-subsequence) — $O(n^2)$ DP 或带二分查找的 $O(n \log n)$
- [编辑距离](https://neetcode.io/problems/edit-distance) — 经典 2D DP
- [分割等和子集](https://neetcode.io/problems/partition-equal-subset-sum) — 0/1 背包变体
### 回溯
- [子集](https://neetcode.io/problems/subsets) — 枚举所有子集
- [组合总和](https://neetcode.io/problems/combination-target-sum) — 允许重复使用的回溯
- [全排列](https://neetcode.io/problems/permutations) — 带使用集合的回溯
- [子集 II](https://neetcode.io/problems/subsets-ii) — 跳过重复项
- [单词搜索](https://neetcode.io/problems/search-for-word) — 网格回溯
- [分割回文串](https://neetcode.io/problems/palindrome-partitioning) — 回溯 + 回文检查
- [N 皇后](https://neetcode.io/problems/n-queens) — 约束传播