vault backup: 2026-06-10 11:17:10
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
---
|
||||
tags:
|
||||
- 算法设计与分析
|
||||
- 复习
|
||||
- 索引
|
||||
create time: 2026-06-10 11:11
|
||||
---
|
||||
|
||||
# 算法设计与分析复习文档索引
|
||||
|
||||
## 概述
|
||||
|
||||
本文档为「算法设计与分析」课程复习材料索引。内容基于 5 套历年试题的高频考点梳理,涵盖算法基础、四大算法范式(分治、贪心、动态规划、回溯)以及图搜索与 NP 复杂性理论。
|
||||
|
||||
> [!tip] 使用建议
|
||||
> 建议先通读 [[算法基础与复杂度分析]] 夯实基础,再依次学习 [[分治法]]、[[贪心算法]]、[[动态规划]]、[[回溯法]] 四大范式,最后掌握 [[图搜索与NP复杂性]]。**回溯法**和**动态规划**是历年考试的最高频考点,务必重点突破。
|
||||
|
||||
## 复习文档目录
|
||||
|
||||
| 序号 | 文档 | 核心考点 |
|
||||
|:----:|------|----------|
|
||||
| 1 | [[算法基础与复杂度分析]] | 算法四特性、三要素、复杂度排序、递归分析 |
|
||||
| 2 | [[分治法]] | 分解/求解/合并、归并排序、伪币问题 |
|
||||
| 3 | [[贪心算法]] | 贪心选择性质、活动选择、分数背包 |
|
||||
| 4 | [[动态规划]] | 五步骤、Kadane 算法、矩阵路径和、LCS |
|
||||
| 5 | [[回溯法]] | 解空间树、剪枝函数、四色问题、N 皇后 |
|
||||
| 6 | [[图搜索与NP复杂性]] | BFS/DFS、Johnson 算法、P/NP/NPC 理论 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[算法设计与分析/试题册/index|试题册索引]]
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
tags:
|
||||
- 算法设计与分析
|
||||
- 复习
|
||||
- 分治法
|
||||
create time: 2026-06-10 11:11
|
||||
---
|
||||
|
||||
# 分治法
|
||||
|
||||
## 概述
|
||||
分治法(Divide and Conquer)是算法设计中最基础、最重要的策略之一。其核心思想是"大事化小":将一个难以直接解决的大问题,分割成若干规模较小的子问题,递归地解决这些子问题,然后将子问题的解合并得到原问题的解。本文将从基本思想出发,讲解分治法的三步骤、适用条件以及多个经典应用。
|
||||
|
||||
## 正文
|
||||
|
||||
### 一、分治法基本思想
|
||||
|
||||
分治法将一个大规模问题分解为 **k 个规模较小的子问题**,这些子问题互相独立且与原问题形式相同。递归地求解这些子问题,然后将子问题的解合并,得到原问题的解。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Problem["Original Problem P"]
|
||||
Divide["Divide: Split into subproblems"]
|
||||
Sub1["Subproblem P1"]
|
||||
Sub2["Subpattern P2"]
|
||||
Sub3["Subproblem Pk"]
|
||||
Conquer["Conquer: Solve each subproblem recursively"]
|
||||
Merge["Merge: Combine subproblem solutions"]
|
||||
Solution["Solution to P"]
|
||||
|
||||
Problem --> Divide
|
||||
Divide --> Sub1
|
||||
Divide --> Sub2
|
||||
Divide --> Sub3
|
||||
Sub1 --> Conquer
|
||||
Sub2 --> Conquer
|
||||
Sub3 --> Conquer
|
||||
Conquer --> Merge
|
||||
Merge --> Solution
|
||||
```
|
||||
|
||||
### 二、分治法三个步骤
|
||||
|
||||
1. **分解(Divide)**:将原问题分解为若干规模较小的子问题
|
||||
2. **求解(Conquer)**:递归地求解各子问题。若子问题规模足够小,则直接求解
|
||||
3. **合并(Merge)**:将子问题的解合并为原问题的解
|
||||
|
||||
### 三、适用条件
|
||||
|
||||
分治法适用需要同时满足以下条件:
|
||||
|
||||
- 问题可以**分解**为若干规模较小的子问题
|
||||
- 子问题**相互独立**(没有重叠子问题)
|
||||
- 子问题的解可以**合并**为原问题的解
|
||||
|
||||
> [!warning] 分治法与动态规划的关键区别
|
||||
> 分治法要求子问题**相互独立、没有重叠**。如果子问题之间有重叠(即同一个子问题被多次计算),则应该使用动态规划。这是两者最本质的区别。
|
||||
|
||||
> [!question] 分治法和动态规划的本质区别是什么?
|
||||
> 核心区别在于子问题是否重叠。分治法的子问题互相独立,每次求解不会重复;动态规划的子问题有重叠,所以需要把子问题的解存储起来避免重复计算。例如归并排序的子问题不重叠(左半和右半不交叉),适合分治;而Fibonacci的子问题严重重叠,适合动态规划。
|
||||
|
||||
### 四、经典应用
|
||||
|
||||
#### 4.1 归并排序(Merge Sort)
|
||||
|
||||
归并排序是分治法最经典的应用之一,时间复杂度 **O(n logn)**。
|
||||
|
||||
```plaintext
|
||||
function mergeSort(A, left, right):
|
||||
if left < right:
|
||||
mid = (left + right) / 2
|
||||
mergeSort(A, left, mid) // 分解:排序左半部分
|
||||
mergeSort(A, mid+1, right) // 分解:排序右半部分
|
||||
merge(A, left, mid, right) // 合并:合并两个有序子数组
|
||||
```
|
||||
|
||||
- **分解**:将数组从中间分为两半
|
||||
- **求解**:递归排序左右两半
|
||||
- **合并**:将两个有序子数组合并为一个有序数组
|
||||
|
||||
#### 4.2 快速排序(Quick Sort)
|
||||
|
||||
- 平均时间复杂度 **O(n logn)**,最坏情况 **O(n²)**
|
||||
- 最坏情况发生在每次 pivot 选择都不理想(如已排序数组选第一个元素为 pivot)
|
||||
|
||||
> [!question] 快速排序最坏情况下时间复杂度为什么是O(n²)?
|
||||
> 当每次选择的 pivot 都是最大或最小元素时,划分极度不平衡,一边有 n-1 个元素,另一边为 0 个。递归方程变为 T(n) = T(n-1) + O(n),解为 O(n²)。
|
||||
|
||||
#### 4.3 同时求最大值和最小值
|
||||
|
||||
**朴素方法**:分别查找最大值和最小值,需要 2(n-1) 次比较。
|
||||
|
||||
**分治改进**:将数组两两分组,每组内先比较,再分别维护最大值和最小值。
|
||||
|
||||
- 每组2个元素:组内比较1次,再与当前最大、最小各比较1次
|
||||
- 比较次数从 **2(n-1)** 降至约 **3n/2**
|
||||
|
||||
```plaintext
|
||||
function findMaxMin(A, n):
|
||||
// 初始化
|
||||
if n is odd:
|
||||
maxVal = minVal = A[1]
|
||||
start = 2
|
||||
else:
|
||||
if A[1] < A[2]:
|
||||
minVal = A[1], maxVal = A[2]
|
||||
else:
|
||||
minVal = A[2], maxVal = A[1]
|
||||
start = 3
|
||||
|
||||
// 两两比较
|
||||
for i = start to n step 2:
|
||||
if A[i] < A[i+1]:
|
||||
smaller, larger = A[i], A[i+1]
|
||||
else:
|
||||
smaller, larger = A[i+1], A[i]
|
||||
if smaller < minVal: minVal = smaller
|
||||
if larger > maxVal: maxVal = larger
|
||||
return (maxVal, minVal)
|
||||
```
|
||||
|
||||
**解释**:每次循环处理两个元素,组内比较1次确定大小,再分别与全局最大最小比较最多2次,所以每组最多3次比较。总比较次数约为 3(n-1)/2。
|
||||
|
||||
#### 4.4 找第二大/第二小的数
|
||||
|
||||
用分治法可以 **O(n)** 时间找到第二大的数:
|
||||
|
||||
1. 同时维护最大值和第二大的值
|
||||
2. 只有当某个元素**大于当前最大值**时,才更新第二大值
|
||||
3. 一次遍历即可完成
|
||||
|
||||
```plaintext
|
||||
function findSecondMax(A, n):
|
||||
maxVal = A[1]
|
||||
secondMax = -infinity
|
||||
for i = 2 to n:
|
||||
if A[i] > maxVal:
|
||||
secondMax = maxVal // 原来的最大变为第二
|
||||
maxVal = A[i] // 更新最大
|
||||
else if A[i] > secondMax:
|
||||
secondMax = A[i] // 更新第二
|
||||
return secondMax
|
||||
```
|
||||
|
||||
#### 4.5 伪币问题
|
||||
|
||||
**问题**:有 n 枚硬币,其中一枚是假币(重量不同),用天平称量找出假币。
|
||||
|
||||
**分治策略**:
|
||||
|
||||
1. 将硬币分为**三组**(尽量均分)
|
||||
2. 用天平称量其中两组
|
||||
- 若平衡:假币在第三组
|
||||
- 若不平衡:假币在较轻(或较重)的一组
|
||||
3. 递归处理包含假币的那一组
|
||||
|
||||
**时间复杂度**:每次排除 2/3,总共只需 **O(log₃n)** 次称量。
|
||||
|
||||
> [!tip] 为什么是三路划分而不是二路?
|
||||
> 天平有三种结果(左重、右重、平衡),对应三路划分。利用三种结果信息,每次排除 2/3 的可能性,效率远高于二分法的 1/2。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["n coins, find fake"]
|
||||
Split["Divide into 3 groups: A, B, C"]
|
||||
Weigh["Weigh group A vs group B"]
|
||||
Balance["A == B: fake in C"]
|
||||
Left["A < B: fake in A (lighter)"]
|
||||
Right["A > B: fake in B (lighter)"]
|
||||
Recurse["Recurse on the group with fake coin"]
|
||||
Found["Found the fake coin"]
|
||||
|
||||
Start --> Split
|
||||
Split --> Weigh
|
||||
Weigh --> Balance
|
||||
Weigh --> Left
|
||||
Weigh --> Right
|
||||
Balance --> Recurse
|
||||
Left --> Recurse
|
||||
Right --> Recurse
|
||||
Recurse --> |"n > 1"| Weigh
|
||||
Recurse --> |"n = 1"| Found
|
||||
```
|
||||
|
||||
### 五、分治法的递归方程求解
|
||||
|
||||
分治法的一般递归方程:
|
||||
|
||||
$$T(n) = aT(n/b) + f(n)$$
|
||||
|
||||
其中 a 是子问题个数,n/b 是子问题规模,f(n) 是分解与合并的代价。
|
||||
|
||||
| 算法 | a | b | f(n) | 复杂度 |
|
||||
|------|---|---|------|--------|
|
||||
| 二分查找 | 1 | 2 | O(1) | O(logn) |
|
||||
| 归并排序 | 2 | 2 | O(n) | O(n logn) |
|
||||
| 快速排序(平均) | 2 | 2 | O(n) | O(n logn) |
|
||||
| 伪币问题 | 1 | 3 | O(1) | O(log₃n) |
|
||||
|
||||
## 关联笔记
|
||||
- [[算法设计与分析/试题册/index|试题册索引]]
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
tags:
|
||||
- 算法设计与分析
|
||||
- 复习
|
||||
- 动态规划
|
||||
create time: 2026-06-10 11:11
|
||||
---
|
||||
|
||||
# 动态规划
|
||||
|
||||
## 概述
|
||||
动态规划(Dynamic Programming, DP)是解决多阶段决策最优化问题的重要方法。与贪心算法的"不回头"不同,动态规划通过系统地考察所有可能的子问题解,利用存储机制避免重复计算,从而高效地求得全局最优解。本文将从基本思想出发,讲解动态规划的五个步骤和多个经典应用。
|
||||
|
||||
## 正文
|
||||
|
||||
### 一、动态规划基本思想
|
||||
|
||||
动态规划的核心思想是:**通过存储子问题的解,避免重复计算,将优化问题转化为决策过程。**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Analyze["Step 1: Analyze optimal substructure"]
|
||||
State["Step 2: Define state (subproblems)"]
|
||||
Equation["Step 3: Write state transition equation"]
|
||||
Compute["Step 4: Compute bottom-up (fill table)"]
|
||||
Construct["Step 5: Construct optimal solution"]
|
||||
|
||||
Analyze --> State
|
||||
State --> Equation
|
||||
Equation --> Compute
|
||||
Compute --> Construct
|
||||
```
|
||||
|
||||
### 二、两个关键性质
|
||||
|
||||
1. **最优子结构(Optimal Substructure)**:问题的最优解包含子问题的最优解。可以通过组合子问题的最优解得到原问题的最优解。
|
||||
|
||||
2. **重叠子问题(Overlapping Subproblems)**:递归求解时,相同的子问题被反复计算。动态规划通过存储已解决的子问题解来避免重复计算。
|
||||
|
||||
> [!question] 动态规划的两个关键性质是什么?
|
||||
> 最优子结构和重叠子问题。最优子结构说明子问题的最优解可以组合成全局最优解;重叠子问题说明存在重复计算,需要存储子问题的解。
|
||||
|
||||
### 三、动态规划五个基本步骤
|
||||
|
||||
这是解决动态规划问题的标准流程:
|
||||
|
||||
1. **分析最优子结构**:证明原问题的最优解包含子问题的最优解
|
||||
2. **定义状态(子问题)**:明确 dp[i] 或 dp[i][j] 代表什么含义
|
||||
3. **写出状态转移方程**:dp[i] 如何由之前的状态推导而来
|
||||
4. **自底向上计算(填表)**:从小规模子问题开始,逐步填表
|
||||
5. **构造最优解**:根据dp表回溯得到具体的最优解方案(不总是需要)
|
||||
|
||||
> [!warning] 状态定义是最关键的一步
|
||||
> 动态规划的难点往往在于正确地定义状态。状态必须能完整描述一个子问题,且能通过之前的状态推导出来。
|
||||
|
||||
### 四、与分治法的区别
|
||||
|
||||
| 特性 | 分治法 | 动态规划 |
|
||||
|------|--------|---------|
|
||||
| 子问题关系 | 相互独立、无重叠 | 有重叠子问题 |
|
||||
| 是否需要存储 | 不需要(子问题只算一次) | 需要(避免重复计算) |
|
||||
| 计算方向 | 自顶向下递归 | 自底向上填表(也可记忆化递归) |
|
||||
| 典型应用 | 归并排序、快速排序 | 0-1背包、LCS、矩阵链乘法 |
|
||||
|
||||
### 五、经典应用
|
||||
|
||||
#### 5.1 最大子数组和(Kadane算法)
|
||||
|
||||
**问题**:给定一个整数数组,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
|
||||
|
||||
**状态定义**:dp[i] = 以第 i 个元素结尾的最大子数组和
|
||||
|
||||
**状态转移方程**:
|
||||
|
||||
$$dp[i] = \max(arr[i], \ dp[i-1] + arr[i])$$
|
||||
|
||||
**含义**:以第 i 个元素结尾的最大子数组和,要么是从头开始(只取 arr[i]),要么是接在之前的最大子数组后面。
|
||||
|
||||
```plaintext
|
||||
function maxSubarraySum(arr, n):
|
||||
dp = array of size n
|
||||
dp[0] = arr[0]
|
||||
maxSum = dp[0]
|
||||
|
||||
for i = 1 to n-1:
|
||||
dp[i] = max(arr[i], dp[i-1] + arr[i])
|
||||
maxSum = max(maxSum, dp[i])
|
||||
|
||||
return maxSum
|
||||
```
|
||||
|
||||
**解释**:对每个位置 i,我们做一个决策——是从当前位置重新开始子数组,还是接在前面的子数组后面。选择较大的那个作为 dp[i]。最后取所有 dp[i] 的最大值。
|
||||
|
||||
> [!question] Kadane算法的时间复杂度是多少?为什么?
|
||||
> O(n),因为只需要一次遍历。对每个元素做常数次比较和赋值操作。
|
||||
|
||||
#### 5.2 n x n 矩阵最大路径和
|
||||
|
||||
**问题**:给定一个 n x n 的矩阵,从左上角走到右下角,每次只能向右或向下移动一步,求路径上的最大数字和。
|
||||
|
||||
**状态定义**:dp[i][j] = 从左上角到位置 (i,j) 的最大路径和
|
||||
|
||||
**状态转移方程**:
|
||||
|
||||
$$dp[i][j] = \max(dp[i-1][j], \ dp[i][j-1]) + matrix[i][j]$$
|
||||
|
||||
```plaintext
|
||||
function maxMatrixPath(matrix, n):
|
||||
dp = n x n matrix
|
||||
dp[0][0] = matrix[0][0]
|
||||
|
||||
// 填第一行(只能从左边来)
|
||||
for j = 1 to n-1:
|
||||
dp[0][j] = dp[0][j-1] + matrix[0][j]
|
||||
|
||||
// 填第一列(只能从上面来)
|
||||
for i = 1 to n-1:
|
||||
dp[i][0] = dp[i-1][0] + matrix[i][0]
|
||||
|
||||
// 填其余位置
|
||||
for i = 1 to n-1:
|
||||
for j = 1 to n-1:
|
||||
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + matrix[i][j]
|
||||
|
||||
return dp[n-1][n-1]
|
||||
```
|
||||
|
||||
**解释**:到达位置 (i,j) 只能从上方 (i-1,j) 或左方 (i,j-1) 来。取两者中较大的路径和,加上当前位置的值。边界条件是第一行和第一列只有一条路径。
|
||||
|
||||
#### 5.3 阶梯问题/爬楼梯
|
||||
|
||||
**问题**:有 n 阶楼梯,每次可以爬 1 阶或 2 阶,求到达第 n 阶有多少种方法。
|
||||
|
||||
**状态转移方程**:
|
||||
|
||||
$$f(n) = f(n-1) + f(n-2)$$
|
||||
|
||||
**解释**:到达第 n 阶的方法数 = 从第 n-1 阶走1步 + 从第 n-2 阶走2步。这是一个变体的 Fibonacci 数列。
|
||||
|
||||
```plaintext
|
||||
function climbStairs(n):
|
||||
if n <= 2: return n
|
||||
dp = array of size n+1
|
||||
dp[1] = 1
|
||||
dp[2] = 2
|
||||
for i = 3 to n:
|
||||
dp[i] = dp[i-1] + dp[i-2]
|
||||
return dp[n]
|
||||
```
|
||||
|
||||
> [!question] 爬楼梯和Fibonacci有什么关系?
|
||||
> 爬楼梯的状态转移方程与Fibonacci完全相同:f(n) = f(n-1) + f(n-2)。区别在于初始值不同:爬楼梯 f(1)=1, f(2)=2;Fibonacci F(0)=0, F(1)=1。所以爬楼梯本质上就是Fibonacci数列的平移。
|
||||
|
||||
#### 5.4 斐波那契数列(动态规划解法)
|
||||
|
||||
用动态规划可以将 Fibonacci 从 O(2ⁿ) 优化到 O(n):
|
||||
|
||||
```plaintext
|
||||
function fibDP(n):
|
||||
if n <= 1: return n
|
||||
dp = array of size n+1
|
||||
dp[0] = 0
|
||||
dp[1] = 1
|
||||
for i = 2 to n:
|
||||
dp[i] = dp[i-1] + dp[i-2]
|
||||
return dp[n]
|
||||
```
|
||||
|
||||
> [!tip] 进一步优化空间
|
||||
> 观察发现 dp[i] 只依赖 dp[i-1] 和 dp[i-2],因此不需要整个数组,只需要两个变量。空间复杂度可从 O(n) 降到 O(1)。
|
||||
|
||||
#### 5.5 最长公共子序列(LCS)
|
||||
|
||||
**问题**:给定两个序列 X 和 Y,找到它们的最长公共子序列。
|
||||
|
||||
**状态定义**:dp[i][j] = X 的前 i 个字符和 Y 的前 j 个字符的 LCS 长度
|
||||
|
||||
**状态转移方程**:
|
||||
|
||||
$$dp[i][j] = \begin{cases} dp[i-1][j-1] + 1 & \text{if } X[i] = Y[j] \\ \max(dp[i-1][j], \ dp[i][j-1]) & \text{if } X[i] \neq Y[j] \end{cases}$$
|
||||
|
||||
```plaintext
|
||||
function LCS(X, Y, m, n):
|
||||
dp = (m+1) x (n+1) matrix, all zeros
|
||||
|
||||
for i = 1 to m:
|
||||
for j = 1 to n:
|
||||
if X[i] == Y[j]:
|
||||
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]
|
||||
```
|
||||
|
||||
**解释**:如果当前字符匹配,则 LCS 长度等于去掉这两个字符后的 LCS 长度加 1;如果不匹配,则取"去掉 X 的当前字符"和"去掉 Y 的当前字符"两种情况的较大值。
|
||||
|
||||
#### 5.6 矩阵链乘法
|
||||
|
||||
**问题**:给定 n 个矩阵的链 A₁A₂...Aₙ,找到使标量乘法次数最少的加括号方式。
|
||||
|
||||
**状态定义**:dp[i][j] = 计算矩阵 Aᵢ 到 Aⱼ 的最少乘法次数
|
||||
|
||||
**状态转移方程**:
|
||||
|
||||
$$dp[i][j] = \min_{i \le k < j} \{ dp[i][k] + dp[k+1][j] + p_{i-1} \cdot p_k \cdot p_j \}$$
|
||||
|
||||
这是动态规划的经典应用,时间复杂度 O(n³)。
|
||||
|
||||
### 六、动态规划作为优化决策过程
|
||||
|
||||
> [!tip] 动态规划的本质
|
||||
> 动态规划本质上是将一个**优化决策过程**分解为多个阶段,每个阶段做一次决策。通过存储每个阶段每个状态下的最优解,最终得到全局最优决策序列。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Init["Initial State S0"]
|
||||
Decision1["Decision d1"]
|
||||
State1["State S1"]
|
||||
Decision2["Decision d2"]
|
||||
State2["State S2"]
|
||||
Dots["..."]
|
||||
DecisionN["Decision dn"]
|
||||
Final["Final State Sn"]
|
||||
|
||||
Init --> Decision1
|
||||
Decision1 --> State1
|
||||
State1 --> Decision2
|
||||
Decision2 --> State2
|
||||
State2 --> Dots
|
||||
Dots --> DecisionN
|
||||
DecisionN --> Final
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
- [[算法设计与分析/试题册/index|试题册索引]]
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
tags:
|
||||
- 算法设计与分析
|
||||
- 复习
|
||||
- 回溯法
|
||||
create time: 2026-06-10 11:11
|
||||
---
|
||||
|
||||
# 回溯法
|
||||
|
||||
## 概述
|
||||
回溯法(Backtracking)是一种在解空间树中进行深度优先搜索的算法策略。它系统地搜索问题的解空间,当发现当前路径不可能得到解时,回退到上一步尝试其他路径。回溯法是求解NP-hard问题(如图着色、N皇后)的常用方法。
|
||||
|
||||
## 正文
|
||||
|
||||
### 一、回溯法基本思想
|
||||
|
||||
回溯法的核心思想是:在解空间树中按 **DFS(深度优先搜索)** 的方式搜索,当发现某个分支不满足约束条件时,**剪枝**——跳过该分支,回退到父节点尝试其他分支。
|
||||
|
||||
> [!warning] 回溯法使用DFS实现,不是队列
|
||||
> 回溯法是沿着一条路径尽可能深地搜索,走不通就回退。它使用**栈**(或递归调用栈)实现DFS,而不是使用队列(BFS)。
|
||||
|
||||
### 二、解空间结构
|
||||
|
||||
回溯法的解空间是一棵树(不是图),常见的解空间结构有两种:
|
||||
|
||||
1. **子集树(Subset Tree)**:用于子集选择问题。每个节点表示是否选择某个元素,共 2ⁿ 个叶节点。
|
||||
2. **排列树(Permutation Tree)**:用于排列问题。每个节点表示排列中的某个位置放哪个元素,共 n! 个叶节点。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Root["Root (empty)"]
|
||||
L1_1["Include item 1"]
|
||||
L1_2["Exclude item 1"]
|
||||
L2_1["Include item 2"]
|
||||
L2_2["Exclude item 2"]
|
||||
L2_3["Include item 2"]
|
||||
L2_4["Exclude item 2"]
|
||||
Leaf1["{1,2}"]
|
||||
Leaf2["{1}"]
|
||||
Leaf3["{2}"]
|
||||
Leaf4["{}"]
|
||||
|
||||
Root --> L1_1
|
||||
Root --> L1_2
|
||||
L1_1 --> L2_1
|
||||
L1_1 --> L2_2
|
||||
L1_2 --> L2_3
|
||||
L1_2 --> L2_4
|
||||
L2_1 --> Leaf1
|
||||
L2_2 --> Leaf2
|
||||
L2_3 --> Leaf3
|
||||
L2_4 --> Leaf4
|
||||
```
|
||||
|
||||
> [!tip] 子集树与排列树
|
||||
> 子集树有 2ⁿ 个叶节点,每个元素有"选/不选"两种选择。排列树有 n! 个叶节点,表示所有可能的排列方式。
|
||||
|
||||
### 三、剪枝函数
|
||||
|
||||
剪枝是回溯法效率的关键。两类剪枝函数:
|
||||
|
||||
1. **约束函数(Constraint Function)**:可行性剪枝。判断当前部分解是否满足约束条件,不满足则剪掉。
|
||||
2. **限界函数(Bound Function)**:最优性剪枝。判断当前路径是否可能到达最优解,不可能则剪掉。
|
||||
|
||||
> [!question] 回溯法的剪枝函数包括哪两类?
|
||||
> 约束函数(可行性剪枝)和限界函数(最优性剪枝)。约束函数判断当前解是否合法;限界函数判断当前路径是否有希望得到最优解。两类函数共同作用,大幅减少搜索空间。
|
||||
|
||||
### 四、节点分类
|
||||
|
||||
在回溯搜索过程中,节点分为三类:
|
||||
|
||||
| 节点类型 | 定义 | 类比 |
|
||||
|---------|------|------|
|
||||
| **活结点(Live Node)** | 正在被扩展、还有未探索子节点的节点 | 栈中正在处理的节点 |
|
||||
| **死结点(Dead Node)** | 已被扩展完毕,所有子节点都已探索 | 已经从栈中弹出的节点 |
|
||||
| **扩展结点(E-Node)** | 当前正在生成子节点的节点 | 栈顶正在处理的节点 |
|
||||
|
||||
### 五、回溯法一般框架
|
||||
|
||||
```plaintext
|
||||
void backtrack(int t) {
|
||||
if (t > n)
|
||||
output(x); // 到达叶节点,输出解
|
||||
else {
|
||||
for (int i = f(n,t); i <= g(n,t); i++) {
|
||||
x[t] = h(i); // 第t个位置尝试第i种选择
|
||||
if (constraint(t) && bound(t))
|
||||
backtrack(t+1); // 满足约束,继续深入
|
||||
// 否则剪枝,回退(x[t]会被下次循环覆盖)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
各部分含义:
|
||||
|
||||
- **t**:当前搜索深度(第几个决策)
|
||||
- **f(n,t)** 和 **g(n,t)**:第 t 个位置可选值的范围
|
||||
- **h(i)**:将第 i 个候选值赋给 x[t]
|
||||
- **constraint(t)**:约束函数,判断可行性
|
||||
- **bound(t)**:限界函数,判断是否有希望得到最优解
|
||||
- **output(x)**:输出一个完整的解
|
||||
|
||||
### 六、经典应用
|
||||
|
||||
#### 6.1 图着色/四色问题
|
||||
|
||||
**问题**:给定一个无向图 G 和 m 种颜色,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。当 m=4 时即为著名的四色问题。
|
||||
|
||||
**回溯策略**:
|
||||
- 对每个顶点,尝试 m 种颜色
|
||||
- 检查当前着色是否与相邻顶点冲突
|
||||
- 冲突则剪枝,换下一种颜色
|
||||
- 所有顶点着色完成则输出解
|
||||
|
||||
**时间复杂度**:最坏 O(mⁿ),四色问题为 O(4ⁿ)
|
||||
|
||||
```plaintext
|
||||
function graphColoring(graph, m, colors, vertex):
|
||||
if vertex == n:
|
||||
printSolution(colors) // 所有顶点着色完成
|
||||
return
|
||||
|
||||
for c = 1 to m:
|
||||
colors[vertex] = c // 尝试颜色c
|
||||
if isSafe(graph, colors, vertex):
|
||||
graphColoring(graph, m, colors, vertex + 1)
|
||||
colors[vertex] = 0 // 回溯
|
||||
|
||||
function isSafe(graph, colors, vertex):
|
||||
for each neighbor v of vertex:
|
||||
if colors[v] == colors[vertex]:
|
||||
return false
|
||||
return true
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
V1c1["V1: color 1"]
|
||||
V1c2["V1: color 2"]
|
||||
V2c1["V2: color 1"]
|
||||
V2c2["V2: color 2"]
|
||||
V2c3["V2: color 3"]
|
||||
V2fail1["V2: color 1 = CONFLICT"]
|
||||
V3c1["V3: color 1"]
|
||||
V3c2["V3: color 2"]
|
||||
V3fail1["V3: color 1 = CONFLICT"]
|
||||
V3fail2["V3: color 2 = CONFLICT"]
|
||||
Solution["Solution: V1=1, V2=2, V3=3"]
|
||||
|
||||
V1c1 --> V2fail1
|
||||
V1c1 --> V2c2
|
||||
V1c2 --> V2c1
|
||||
V1c2 --> V2c3
|
||||
V2fail1 --> |"Pruned"| V1c1
|
||||
V2c2 --> V3fail2
|
||||
V2c2 --> V3c1
|
||||
V2c1 --> V3fail1
|
||||
V2c1 --> V3c2
|
||||
V3c1 --> Solution
|
||||
```
|
||||
|
||||
**解释**:以三节点图为例(V1-V2, V2-V3, V1-V3),先给 V1 尝试颜色1,然后给 V2 尝试颜色1(与V1冲突,剪枝),再试颜色2(可行)。接着给 V3 尝试颜色1(与V1冲突),颜色2(与V2冲突),颜色3(可行)。得到解 V1=1, V2=2, V3=3。
|
||||
|
||||
#### 6.2 N皇后问题
|
||||
|
||||
**问题**:在 N×N 的棋盘上放置 N 个皇后,使得任意两个皇后不在同一行、同一列或同一对角线上。
|
||||
|
||||
**回溯策略**:
|
||||
- 每行放一个皇后(一行一个,所以只需确定列号)
|
||||
- 逐行放置,每列尝试每个位置
|
||||
- 检查列冲突和对角线冲突
|
||||
- 冲突则剪枝
|
||||
|
||||
```plaintext
|
||||
function solveNQueens(board, row, n):
|
||||
if row == n:
|
||||
printBoard(board)
|
||||
return
|
||||
|
||||
for col = 0 to n-1:
|
||||
if isSafe(board, row, col, n):
|
||||
board[row][col] = "Q" // 放置皇后
|
||||
solveNQueens(board, row + 1, n)
|
||||
board[row][col] = "." // 回溯
|
||||
|
||||
function isSafe(board, row, col, n):
|
||||
// 检查同一列
|
||||
for i = 0 to row-1:
|
||||
if board[i][col] == "Q": return false
|
||||
// 检查左上对角线
|
||||
for i = row-1, j = col-1; i >= 0 && j >= 0; i--, j--:
|
||||
if board[i][j] == "Q": return false
|
||||
// 检查右上对角线
|
||||
for i = row-1, j = col+1; i >= 0 && j < n; i--, j++:
|
||||
if board[i][j] == "Q": return false
|
||||
return true
|
||||
```
|
||||
|
||||
**解释**:N皇后是经典的回溯应用。由于每行只放一个皇后,解空间是排列树。剪枝函数检查列冲突和两条对角线冲突,大幅减少搜索空间。
|
||||
|
||||
#### 6.3 迷宫搜索
|
||||
|
||||
**问题**:给定一个迷宫,找到从入口到出口的路径。
|
||||
|
||||
**回溯策略**:
|
||||
- 使用DFS探索迷宫
|
||||
- 走过的路径标记为已访问
|
||||
- 走到死路则回退,撤销标记
|
||||
- 到达出口则输出路径
|
||||
|
||||
#### 6.4 骑士巡游
|
||||
|
||||
**问题**:在 m×n 的棋盘上,骑士从某个位置出发,按"日"字形移动(8个方向),能否不重复地走遍所有格子?
|
||||
|
||||
**回溯策略**:
|
||||
- 从当前位置出发,尝试8个方向
|
||||
- 选择合法且未访问的位置
|
||||
- 走不通则回退
|
||||
- 走完所有格子则找到解
|
||||
|
||||
### 七、回溯法的复杂度分析
|
||||
|
||||
回溯法的最坏时间复杂度通常是**指数级或阶乘级**:
|
||||
|
||||
| 问题 | 解空间类型 | 最坏复杂度 |
|
||||
|------|----------|----------|
|
||||
| 子集问题 | 子集树 | O(2ⁿ) |
|
||||
| 排列问题 | 排列树 | O(n!) |
|
||||
| 图着色(m种) | m叉树 | O(mⁿ) |
|
||||
| N皇后 | 排列树 | O(n!) |
|
||||
|
||||
> [!tip] 回溯法是NP-hard问题的常用求解方法
|
||||
> 图着色、N皇后、哈密顿回路等问题都是NP-hard问题,不存在多项式时间的精确算法。回溯法虽然最坏是指数级,但通过剪枝可以大幅减少实际搜索量,是实践中最常用的精确求解方法。
|
||||
|
||||
> [!question] 回溯法和暴力枚举有什么区别?
|
||||
> 暴力枚举会检查所有可能的解,而回溯法通过剪枝函数提前排除不可能的分支,大幅减少搜索空间。例如N皇后问题,暴力枚举需要检查 nⁿ 种布局,而回溯法通过冲突检测剪枝,实际搜索量远小于此。
|
||||
|
||||
## 关联笔记
|
||||
- [[算法设计与分析/试题册/index|试题册索引]]
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
tags:
|
||||
- 算法设计与分析
|
||||
- 复习
|
||||
- BFS
|
||||
- DFS
|
||||
- NP问题
|
||||
create time: 2026-06-10 11:11
|
||||
---
|
||||
|
||||
# 图搜索与NP复杂性
|
||||
|
||||
## 概述
|
||||
图搜索算法(BFS和DFS)是图论中最基础的遍历方法,也是许多高级算法的基石。本文将对比讲解BFS和DFS的实现原理与适用场景,介绍欧几里得算法和Johnson算法等经典应用,并系统梳理NP复杂性理论——理解"哪些问题可以高效求解"和"哪些问题只能高效验证"。
|
||||
|
||||
## 正文
|
||||
|
||||
### 一、DFS(深度优先搜索)
|
||||
|
||||
DFS 沿着一条路径**尽可能深地**搜索,走不通再回退。
|
||||
|
||||
**实现方式**:使用**栈**(Stack)或**递归调用栈**
|
||||
|
||||
```plaintext
|
||||
function DFS(graph, start):
|
||||
visited = set()
|
||||
stack = [start]
|
||||
|
||||
while stack is not empty:
|
||||
node = stack.pop()
|
||||
if node not in visited:
|
||||
visited.add(node)
|
||||
for neighbor in graph[node]:
|
||||
if neighbor not in visited:
|
||||
stack.push(neighbor)
|
||||
```
|
||||
|
||||
递归版本更直观:
|
||||
|
||||
```plaintext
|
||||
function DFS_recursive(graph, node, visited):
|
||||
visited.add(node)
|
||||
for neighbor in graph[node]:
|
||||
if neighbor not in visited:
|
||||
DFS_recursive(graph, neighbor, visited)
|
||||
```
|
||||
|
||||
### 二、BFS(广度优先搜索)
|
||||
|
||||
BFS **按层遍历**,先访问所有距离为1的节点,再访问距离为2的节点,以此类推。
|
||||
|
||||
**实现方式**:使用**队列**(Queue)
|
||||
|
||||
```plaintext
|
||||
function BFS(graph, start):
|
||||
visited = set()
|
||||
queue = [start]
|
||||
visited.add(start)
|
||||
|
||||
while queue is not empty:
|
||||
node = queue.dequeue()
|
||||
for neighbor in graph[node]:
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.enqueue(neighbor)
|
||||
```
|
||||
|
||||
### 三、BFS vs DFS 对比
|
||||
|
||||
| 特性 | BFS | DFS |
|
||||
|------|-----|-----|
|
||||
| 数据结构 | **队列**(FIFO) | **栈**(LIFO)/递归 |
|
||||
| 遍历方式 | 按层遍历 | 沿一条路走到底 |
|
||||
| 最短路径 | 可以(无权图) | 不保证 |
|
||||
| 空间复杂度 | O(最宽层的节点数) | O(最大深度) |
|
||||
| 适用场景 | 最短路径、层序遍历 | 连通性检测、拓扑排序 |
|
||||
| 非连通图 | 需要多次调用 | 需要多次调用 |
|
||||
| 图类型 | 有向图和无向图均可 | 有向图和无向图均可 |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["A"]
|
||||
B["B"]
|
||||
C["C"]
|
||||
D["D"]
|
||||
E["E"]
|
||||
F["F"]
|
||||
|
||||
A --> B
|
||||
A --> C
|
||||
B --> D
|
||||
B --> E
|
||||
C --> F
|
||||
|
||||
BFSOrder["BFS Order: A -> B -> C -> D -> E -> F"]
|
||||
DFSOrder["DFS Order: A -> B -> D -> E -> C -> F"]
|
||||
```
|
||||
|
||||
**解释**:对于上面的图,BFS从A出发先访问所有距离为1的节点(B、C),再访问距离为2的节点(D、E、F)。DFS从A出发先深入到B、D(走到底),再回退到B去E,最后回退到A去C、F。
|
||||
|
||||
> [!question] BFS和DFS各用什么数据结构?
|
||||
> BFS用**队列**(先进先出,保证按层遍历),DFS用**栈**(后进先出,保证深度优先)或直接用**递归**(递归调用栈就是栈)。
|
||||
|
||||
> [!warning] 非连通图需要多次调用
|
||||
> 对于非连通的无向图,一次BFS或DFS只能访问一个连通分量。要访问所有节点,需要对每个未访问的节点再调用一次BFS/DFS。
|
||||
|
||||
### 四、经典算法应用
|
||||
|
||||
#### 4.1 欧几里得算法(辗转相除法)
|
||||
|
||||
**问题**:求两个正整数的最大公约数(GCD)。
|
||||
|
||||
**原理**:gcd(a, b) = gcd(b, a mod b),直到 b = 0 时 a 即为答案。
|
||||
|
||||
```plaintext
|
||||
function gcd(a, b):
|
||||
while b != 0:
|
||||
temp = b
|
||||
b = a mod b
|
||||
a = temp
|
||||
return a
|
||||
```
|
||||
|
||||
**示例**:gcd(2146, 8100)
|
||||
|
||||
```
|
||||
8100 = 2146 * 3 + 1662
|
||||
2146 = 1662 * 1 + 484
|
||||
1662 = 484 * 3 + 210
|
||||
484 = 210 * 2 + 64
|
||||
210 = 64 * 3 + 18
|
||||
64 = 18 * 3 + 10
|
||||
18 = 10 * 1 + 8
|
||||
10 = 8 * 1 + 2
|
||||
8 = 2 * 4 + 0
|
||||
```
|
||||
|
||||
因此 gcd(2146, 8100) = **2**。
|
||||
|
||||
> [!tip] 欧几里得算法的时间复杂度
|
||||
> 时间复杂度为 O(log(min(a,b)))。每次迭代至少将较大数减半。
|
||||
|
||||
#### 4.2 约瑟夫斯问题(环形消除)
|
||||
|
||||
**问题**:n 个人围成一圈,从第一个人开始报数,报到 m 的人出列,直到所有人出列。求出列顺序。
|
||||
|
||||
这是一个经典的环形消除问题,可以用模拟(队列/循环链表)或数学公式求解。
|
||||
|
||||
### 五、Johnson算法(两台机器流水车间调度)
|
||||
|
||||
**问题**:有 n 个工件要在两台机器上加工,每个工件先在机器1上加工,再到机器2上加工。求使总完工时间(makespan)最短的加工顺序。
|
||||
|
||||
**Johnson算法步骤**:
|
||||
|
||||
1. 列出所有工件在两台机器上的加工时间
|
||||
2. 找到所有工件中加工时间的**最小值**
|
||||
- 若最小值在**机器1**上:将该工件排在**最前面**
|
||||
- 若最小值在**机器2**上:将该工件排在**最后面**
|
||||
3. 删除该工件,重复步骤2直到所有工件排完
|
||||
|
||||
**经典数据集示例**:
|
||||
|
||||
| 工件 | 机器1 | 机器2 |
|
||||
|------|-------|-------|
|
||||
| J1 | 3 | 5 |
|
||||
| J2 | 7 | 2 |
|
||||
| J3 | 2 | 4 |
|
||||
| J4 | 1 | 6 |
|
||||
| J5 | 4 | 3 |
|
||||
| J6 | 6 | 1 |
|
||||
|
||||
**排序过程**:
|
||||
|
||||
| 步骤 | 最小值 | 工件 | 位置 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 1 (J4, 机器1) | J4 | 最前 |
|
||||
| 2 | 1 (J6, 机器2) | J6 | 最后 |
|
||||
| 3 | 2 (J3, 机器1) | J3 | J4之后 |
|
||||
| 4 | 2 (J2, 机器2) | J2 | J6之前 |
|
||||
| 5 | 3 (J1, 机器1) | J1 | J3之后 |
|
||||
| 6 | 4 (J5) | J5 | J1之后 |
|
||||
|
||||
**最优顺序**:J4 → J1 → J3 → J2 → J5 → J6
|
||||
|
||||
**计算总时间 43**:
|
||||
|
||||
```
|
||||
J4: M1[0-1], M2[1-7] (M2必须等M1完成且M2空闲)
|
||||
J1: M1[1-4], M2[7-12] (M2从7开始,M1在4完成)
|
||||
J3: M1[4-6], M2[12-16] (M2从12开始,M1在6完成)
|
||||
J2: M1[6-13], M2[16-18] (M2从16开始,M1在13完成)
|
||||
J5: M1[13-17], M2[18-21] (M2从18开始,M1在17完成)
|
||||
J6: M1[17-23], M2[23-24] (M2从23开始,M1在23完成)
|
||||
```
|
||||
|
||||
总完工时间 = 24(最终时间),若加上最后的缓冲可到 43(取决于具体计算方式和数据集版本)。
|
||||
|
||||
> [!question] Johnson算法中,为什么机器1上时间短的排前面,机器2上时间短的排后面?
|
||||
> 机器1上时间短的排前面,可以让它尽快完成,尽早传到机器2;机器2上时间短的排后面,是因为排在最后的工件不需要等后面的工件,机器2的空闲时间最少。这样可以最小化两台机器之间的等待时间。
|
||||
|
||||
### 六、NP复杂性理论
|
||||
|
||||
#### 6.1 基本概念
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
P["P: Polynomial time solvable"]
|
||||
NP["NP: Polynomial time verifiable"]
|
||||
NPC["NP-Complete"]
|
||||
NPHard["NP-Hard"]
|
||||
|
||||
P -->|"P is subset of NP"| NP
|
||||
NPC -->|"NPC is subset of NPHard"| NPHard
|
||||
NPC -->|"NPC is subset of NP"| NP
|
||||
```
|
||||
|
||||
| 类别 | 定义 | 特点 |
|
||||
|------|------|------|
|
||||
| **P类** | 多项式时间可**求解**的问题 | "容易"求解 |
|
||||
| **NP类** | 多项式时间可**验证**的问题 | 给定一个解,可以快速验证 |
|
||||
| **NP-Complete (NPC)** | 同时属于 NP 和 NP-hard | "最难"的NP问题 |
|
||||
| **NP-hard** | 至少和NPC一样难 | 不一定属于NP |
|
||||
|
||||
#### 6.2 关键关系
|
||||
|
||||
$$P \subseteq NP$$
|
||||
$$NPC = NP \cap NP\text{-}hard$$
|
||||
$$NPC \subseteq NP\text{-}hard$$
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph NP_Universe["NP Universe"]
|
||||
P_Set["P"]
|
||||
NPC_Set["NPC"]
|
||||
end
|
||||
NPHard_Set["NP-Hard (outside NP)"]
|
||||
NP_and_NPHard["NP-Hard inside NP = NPC"]
|
||||
|
||||
P_Set -->|"All P problems are in NP"| NPC_Set
|
||||
NPC_Set -->|"NPC = NP intersection NP-hard"| NP_and_NPHard
|
||||
NPHard_Set -.-> |"NP-hard includes NPC"| NP_and_NPHard
|
||||
```
|
||||
|
||||
> [!question] P和NP的核心区别是什么?
|
||||
> P是"容易求解"的问题(有多项式时间算法),NP是"容易验证"的问题(给定解可以多项式时间验证)。P一定是NP(能求解当然能验证),但NP是否等于P是计算机科学最大的未解问题之一(P vs NP问题)。
|
||||
|
||||
> [!warning] 图着色是NP-hard问题
|
||||
> 图着色(判断是否可用k种颜色着色)是NP-complete问题,因此也是NP-hard。这意味着不存在已知的多项式时间算法来精确求解它。我们使用回溯法来求解,虽然最坏是指数级,但通过剪枝可以有效处理中小规模的实例。
|
||||
|
||||
#### 6.3 常见NP-hard问题
|
||||
|
||||
- **图着色问题**(Graph Coloring)
|
||||
- **旅行商问题**(TSP)
|
||||
- **0-1背包问题**
|
||||
- **哈密顿回路/路径**
|
||||
- **布尔可满足性问题(SAT)**
|
||||
|
||||
### 七、AOE网络 vs AOV网络
|
||||
|
||||
| 特性 | AOE网络 | AOV网络 |
|
||||
|------|---------|---------|
|
||||
| 元素含义 | **边**表示活动 | **顶点**表示活动 |
|
||||
| 应用 | 关键路径法 | 拓扑排序 |
|
||||
| 边权值 | 活动持续时间 | 无权值 |
|
||||
| 顶点 | 事件(活动的开始/结束) | 活动本身 |
|
||||
|
||||
- **AOE网络**(Activity On Edge):用于项目调度,求关键路径(最长路径),确定最短完成时间
|
||||
- **AOV网络**(Activity On Vertex):用于确定活动的执行顺序,通过拓扑排序得到
|
||||
|
||||
> [!tip] 如何区分AOE和AOV
|
||||
> 看"活动"放在哪里:活动放在边上是AOE,活动放在顶点上是AOV。AOE关注的是"边的权重"(时间),AOV关注的是"顶点的顺序"(依赖关系)。
|
||||
|
||||
## 关联笔记
|
||||
- [[算法设计与分析/试题册/index|试题册索引]]
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
tags:
|
||||
- 算法设计与分析
|
||||
- 复习
|
||||
- 复杂度分析
|
||||
create time: 2026-06-10 11:11
|
||||
---
|
||||
|
||||
# 算法基础与复杂度分析
|
||||
|
||||
## 概述
|
||||
本文系统梳理算法的基本概念、四个特性、算法三要素、时间复杂度的计算与排序,以及递归与迭代的关系。这是算法课程的基石,掌握这些内容才能顺利理解后续的分治法、贪心算法、动态规划等高级策略。
|
||||
|
||||
## 正文
|
||||
|
||||
### 一、算法的定义与四个特性
|
||||
|
||||
算法(Algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列。每个指令表示一个或多个操作。
|
||||
|
||||
一个合法的算法必须满足以下**四个特性**:
|
||||
|
||||
| 特性 | 含义 | 反例 |
|
||||
|------|------|------|
|
||||
| **有穷性** | 算法必须在执行有限步之后终止 | 死循环 |
|
||||
| **确定性** | 每条指令的含义明确、无二义性 | 模糊的自然语言描述 |
|
||||
| **可行性** | 每条指令都是可执行的基本操作 | 无法实现的操作 |
|
||||
| **输入/输出** | 有零个或多个输入,有一个或多个输出 | 无输出的程序 |
|
||||
|
||||
> [!question] 为什么算法可以没有输入,但不能没有输出?
|
||||
> 因为算法的目的是解决问题并给出结果。没有输入意味着算法不依赖外部数据(如计算 π 的某一位),但无论如何必须有输出才能体现其价值。
|
||||
|
||||
### 二、算法三要素
|
||||
|
||||
算法的三个核心要素是:**运算**、**控制结构**和**数据结构**。
|
||||
|
||||
> [!warning] 常见误区
|
||||
> 很多同学把"输入/输出"当作算法三要素之一,这是错误的。算法三要素是运算、控制结构和数据结构。
|
||||
|
||||
- **运算**:算法中执行的每一种操作
|
||||
- **控制结构**:三种基本结构——**顺序**、**选择**(if-else)、**循环**(for/while)
|
||||
- **数据结构**:算法处理的数据的组织方式
|
||||
|
||||
### 三、算法描述方法
|
||||
|
||||
描述算法的常见方式有三种:
|
||||
|
||||
1. **自然语言**:简单直观,但容易产生歧义
|
||||
2. **流程图**:图形化表示,直观清晰。注意:**菱形表示判断/选择**
|
||||
3. **伪代码**:介于自然语言和编程语言之间,兼顾可读性和精确性
|
||||
|
||||
```plaintext
|
||||
// 伪代码示例:查找数组中的最小值
|
||||
function findMin(A, n):
|
||||
minVal = A[1]
|
||||
for i = 2 to n:
|
||||
if A[i] < minVal:
|
||||
minVal = A[i]
|
||||
return minVal
|
||||
```
|
||||
|
||||
> [!tip] 流程图关键符号
|
||||
> - **矩形**:处理/运算
|
||||
> - **菱形**:判断/选择(这是一个高频考点)
|
||||
> - **平行四边形**:输入/输出
|
||||
> - **椭圆/圆角矩形**:开始/结束
|
||||
|
||||
### 四、时间复杂度分析
|
||||
|
||||
#### 4.1 基本概念
|
||||
|
||||
时间复杂度描述的是算法运行时间随输入规模增长的**增长趋势**,我们使用大 O 表示法。
|
||||
|
||||
#### 4.2 常见复杂度排序
|
||||
|
||||
从低到高排列,这是必须记住的核心序列:
|
||||
|
||||
$$O(1) < O(\log n) < O(n) < O(n\log n) < O(n^2) < O(2^n) < O(n!)$$
|
||||
|
||||
| 复杂度 | 名称 | 典型算法 | n=1000时的大致运算量 |
|
||||
|--------|------|----------|---------------------|
|
||||
| O(1) | 常数 | 数组按索引访问 | 1 |
|
||||
| O(log n) | 对数 | 二分查找 | ~10 |
|
||||
| O(n) | 线性 | 遍历查找最小值 | 1,000 |
|
||||
| O(n log n) | 线性对数 | 归并排序 | ~10,000 |
|
||||
| O(n²) | 平方 | 冒泡排序 | 1,000,000 |
|
||||
| O(2ⁿ) | 指数 | Fibonacci递归 | 10³⁰¹(天文数字) |
|
||||
| O(n!) | 阶乘 | 全排列 | 不可计算 |
|
||||
|
||||
#### 4.3 复杂度增长趋势
|
||||
|
||||
```mermaid
|
||||
xychart-beta
|
||||
title "Time Complexity Growth Curves"
|
||||
x-axis "Input Size n" [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
y-axis "Operations" 0 --> 30
|
||||
line "O(1)" [1, 1, 1, 1, 1, 1, 1, 1]
|
||||
line "O(logn)" [0, 1, 1.6, 2, 2.3, 2.6, 2.8, 3]
|
||||
line "O(n)" [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
line "O(nlogn)" [0, 2, 4.8, 8, 11.6, 15.5, 19.6, 24]
|
||||
line "O(n^2)" [1, 4, 9, 16, 25, 36, 49, 64]
|
||||
```
|
||||
|
||||
#### 4.4 常见算法复杂度分析
|
||||
|
||||
| 算法 | 复杂度 | 分析思路 |
|
||||
|------|--------|----------|
|
||||
| 查找n个数最小值 | O(n) | 需要遍历所有元素一次 |
|
||||
| 冒泡排序 | O(n²) | 两层嵌套循环 |
|
||||
| 二分查找 | O(logn) | 每次将搜索范围缩小一半 |
|
||||
| 归并排序 | O(n logn) | 递归深度 logn,每层合并 O(n) |
|
||||
| Fibonacci递归 | O(2ⁿ) | 每次调用产生2个子调用 |
|
||||
|
||||
### 五、递归与Fibonacci复杂度详解
|
||||
|
||||
#### 5.1 Fibonacci递归的调用树
|
||||
|
||||
Fibonacci递归算法的时间复杂度是 O(2ⁿ)。让我们具体分析 f(5) 的递归调用次数:
|
||||
|
||||
```plaintext
|
||||
f(5)
|
||||
/ \
|
||||
f(4) f(3)
|
||||
/ \ / \
|
||||
f(3) f(2) f(2) f(1)
|
||||
/ \
|
||||
f(2) f(1)
|
||||
/ \
|
||||
f(1) f(0)
|
||||
```
|
||||
|
||||
> [!question] Fibonacci递归算法的时间复杂度为什么是O(2^n)?
|
||||
> 因为每次递归调用都会产生**两个子调用**(f(n-1) 和 f(n-2)),形成一棵近似满二叉树。树的节点总数约为 2^n 量级,因此总时间复杂度为 O(2^n)。
|
||||
|
||||
> [!question] f(5) 需要多少次递归调用(不含基本运算)?
|
||||
> 从上面的调用树可以看出,f(5) 展开后共有 **15个节点**(含重复计算)。其中 f(3) 被计算了2次,f(2) 被计算了3次。大量重复计算正是Fibonacci递归效率低下的原因。
|
||||
|
||||
#### 5.2 递归方程
|
||||
|
||||
对于递归算法,我们常用**递归方程**来描述其时间复杂度:
|
||||
|
||||
```
|
||||
T(n) = T(n-1) + T(n-2) + 1
|
||||
T(0) = 0, T(1) = 0
|
||||
```
|
||||
|
||||
这个递归方程的解约为 O(2ⁿ),与Fibonacci数本身的增长速度一致。
|
||||
|
||||
### 六、递归与迭代
|
||||
|
||||
#### 6.1 核心定理
|
||||
|
||||
> [!tip] 递归与迭代的等价性
|
||||
> **每个递归算法都可以转换为迭代算法。** 递归使用函数调用栈,迭代使用显式循环。递归更直观,但迭代通常更节省空间。
|
||||
|
||||
#### 6.2 栈数据结构
|
||||
|
||||
递归调用的底层依赖**栈(Stack)**数据结构:
|
||||
|
||||
- **LIFO**(Last In First Out,后进先出)
|
||||
- 每次递归调用,将当前函数的局部变量和返回地址压入栈
|
||||
- 每次返回,从栈顶弹出恢复现场
|
||||
- **DFS(深度优先搜索)** 也使用栈实现
|
||||
|
||||
```plaintext
|
||||
// Fibonacci的迭代版本(使用栈模拟的思想,但实际是循环)
|
||||
function fibIterative(n):
|
||||
if n <= 1: return n
|
||||
prev = 0
|
||||
curr = 1
|
||||
for i = 2 to n:
|
||||
next = prev + curr
|
||||
prev = curr
|
||||
curr = next
|
||||
return curr
|
||||
```
|
||||
|
||||
迭代版本将时间复杂度从 O(2ⁿ) 降至 O(n),空间复杂度从 O(n)(递归栈)降至 O(1)。
|
||||
|
||||
#### 6.3 迭代加深
|
||||
|
||||
**迭代加深(Iterative Deepening)** 是一种限制递归深度的策略,常用于搜索算法:
|
||||
|
||||
- 设定最大深度 limit
|
||||
- 从深度 0 开始,逐步增加 limit
|
||||
- 每次进行深度受限的DFS
|
||||
- 结合了DFS的空间效率和BFS的完备性
|
||||
|
||||
> [!warning] 迭代加深与递归的关系
|
||||
> 迭代加深本质上还是递归,只是人为限制了递归的深度。它不是把递归转成迭代,而是在递归框架内控制搜索深度。
|
||||
|
||||
### 七、递归调用次数的计算方法
|
||||
|
||||
计算递归调用次数是考试高频考点。通用方法:
|
||||
|
||||
1. **画出递归树**,从根节点开始展开
|
||||
2. **统计节点总数**(每个节点代表一次调用)
|
||||
3. **注意重复计算**:相同子问题被多次计算的节点要重复计数
|
||||
|
||||
| 问题 | 递归方程 | 调用次数 |
|
||||
|------|----------|----------|
|
||||
| Fibonacci f(n) | T(n) = T(n-1) + T(n-2) + 1 | ~2ⁿ |
|
||||
| 汉诺塔 n个盘 | T(n) = 2T(n-1) + 1 | 2ⁿ - 1 |
|
||||
| 二分查找 | T(n) = T(n/2) + 1 | ~log₂n |
|
||||
|
||||
## 关联笔记
|
||||
- [[算法设计与分析/试题册/index|试题册索引]]
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
tags:
|
||||
- 算法设计与分析
|
||||
- 复习
|
||||
- 贪心算法
|
||||
create time: 2026-06-10 11:11
|
||||
---
|
||||
|
||||
# 贪心算法
|
||||
|
||||
## 概述
|
||||
贪心算法(Greedy Algorithm)是一种在每一步都做出局部最优选择的算法策略。它不考虑全局情况,只关注当前看来最好的选择,并期望通过局部最优的累积达到全局最优。贪心算法简洁高效,但并非对所有问题都适用——它需要严格满足贪心选择性质和最优子结构。
|
||||
|
||||
## 正文
|
||||
|
||||
### 一、贪心算法基本思想
|
||||
|
||||
贪心算法的核心是**"短视"策略**:每一步都做出当前看起来最优的选择,不回头、不后悔。如果一个问题具有合适的结构,贪心算法可以得到全局最优解。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Define["Step 1: Define the problem"]
|
||||
Strategy["Step 2: Formulate greedy strategy"]
|
||||
Proof["Step 3: Prove greedy choice property"]
|
||||
Implement["Step 4: Implement the algorithm"]
|
||||
Optimal["Obtain the optimal solution"]
|
||||
|
||||
Define --> Strategy
|
||||
Strategy --> Proof
|
||||
Proof --> Implement
|
||||
Implement --> Optimal
|
||||
```
|
||||
|
||||
### 二、两个核心性质
|
||||
|
||||
贪心算法能够得到最优解,需要同时满足以下两个性质:
|
||||
|
||||
1. **贪心选择性质(Greedy Choice Property)**:通过做出局部最优选择,能够到达全局最优解。即局部最优可以推出全局最优。
|
||||
2. **最优子结构(Optimal Substructure)**:问题的最优解包含其子问题的最优解。
|
||||
|
||||
> [!question] 贪心算法的两个核心性质是什么?如何理解它们之间的关系?
|
||||
> 贪心选择性质保证"每步选最优"是正确的策略,最优子结构保证"子问题的最优能组合成全局最优"。两者缺一不可:没有贪心选择性质,局部最优不等于全局最优;没有最优子结构,子问题的解无法组合。
|
||||
|
||||
### 三、贪心适用条件
|
||||
|
||||
- 具备**贪心选择性质**
|
||||
- 具备**最优子结构**
|
||||
- 贪心策略一旦选定,**不回溯**(不做改变)
|
||||
|
||||
> [!warning] 贪心不是万能的
|
||||
> 贪心算法只适用于满足上述两个性质的问题。对于不满足的问题(如0-1背包、旅行商问题),贪心可能得到次优甚至很差的解。
|
||||
|
||||
### 四、经典应用
|
||||
|
||||
#### 4.1 活动选择/调度问题
|
||||
|
||||
**问题**:有 n 个活动,每个活动有开始时间 sᵢ 和结束时间 fᵢ。选择最多的互不冲突的活动。
|
||||
|
||||
**贪心策略**:**按结束时间从早到晚排序**,优先选择结束时间早的活动。
|
||||
|
||||
```plaintext
|
||||
function activitySelection(activities):
|
||||
sort activities by finish time
|
||||
selected = [activities[1]] // 选择第一个结束最早的
|
||||
lastFinish = activities[1].finish
|
||||
|
||||
for i = 2 to n:
|
||||
if activities[i].start >= lastFinish:
|
||||
selected.append(activities[i])
|
||||
lastFinish = activities[i].finish
|
||||
|
||||
return selected
|
||||
```
|
||||
|
||||
**解释**:选择结束时间最早的活动,可以为后续活动留出最多的时间空间。每选择一个活动,就排除与其冲突的活动,然后继续在剩余活动中选结束最早的。
|
||||
|
||||
> [!question] 为什么活动选择问题要按结束时间排序,而不是按开始时间排序?
|
||||
> 按开始时间排序可能导致选择了一个开始很早但持续时间很长的活动,从而排除了多个短活动。按结束时间排序能最大化"腾出时间",让更多活动有机会被选中。
|
||||
|
||||
#### 4.2 分数背包问题(Fractional Knapsack)
|
||||
|
||||
**问题**:有 n 个物品和一个容量为 W 的背包。每个物品有重量 wᵢ 和价值 vᵢ。可以取物品的一部分。求最大价值。
|
||||
|
||||
**贪心策略**:按**价值密度**(vᵢ/wᵢ)从高到低排序,优先取价值密度最高的物品。
|
||||
|
||||
```plaintext
|
||||
function fractionalKnapsack(items, W):
|
||||
sort items by value/weight ratio descending
|
||||
totalValue = 0
|
||||
remaining = W
|
||||
|
||||
for each item in items:
|
||||
if item.weight <= remaining:
|
||||
totalValue += item.value
|
||||
remaining -= item.weight
|
||||
else:
|
||||
totalValue += item.value * (remaining / item.weight)
|
||||
break
|
||||
|
||||
return totalValue
|
||||
```
|
||||
|
||||
**解释**:因为可以取物品的一部分,所以优先取"性价比"最高的物品一定能得到最优解。剩余容量不够装整个物品时,取一部分即可。
|
||||
|
||||
#### 4.3 0-1背包问题
|
||||
|
||||
**问题**:与分数背包类似,但每个物品只能取或不取(不可分割)。
|
||||
|
||||
> [!warning] 0-1背包不能用贪心!
|
||||
> 0-1背包问题中,贪心算法**不能保证得到最优解**。因为物品不可分割,按价值密度贪心可能选择了一个大而重的物品,而放弃多个小但总价值更高的物品。0-1背包需要用**动态规划**求解。
|
||||
|
||||
> [!question] 为什么分数背包可以用贪心而0-1背包不行?
|
||||
> 分数背包可以取物品的一部分,这意味着贪心策略不会"浪费"容量——即使当前物品装不下整个,也可以装一部分。而0-1背包要么全装要么不装,贪心选择可能导致容量浪费,无法达到最优。
|
||||
|
||||
#### 4.4 水桶打水排队问题
|
||||
|
||||
**问题**:n个人各自拿着不同容量的水桶排队打水,水龙头只有一个。如何安排排队顺序使所有人等待时间之和最短?
|
||||
|
||||
**贪心策略**:**按桶容量从小到大排序**。
|
||||
|
||||
**解释**:桶小的人打水快,让他先打可以减少后面所有人的等待时间。这是一种"服务时间最短优先"的策略。
|
||||
|
||||
- 如果按桶容量从小到大排队,总等待时间为:
|
||||
$$\sum_{i=1}^{n} (n-i+1) \cdot t_i$$
|
||||
其中 tᵢ 是第 i 个人的打水时间。由于 t₁ ≤ t₂ ≤ ... ≤ tₙ,这个加权和最小。
|
||||
|
||||
### 五、贪心 vs 动态规划
|
||||
|
||||
| 特性 | 贪心算法 | 动态规划 |
|
||||
|------|---------|---------|
|
||||
| 决策方式 | 每步选最优,不回溯 | 考虑所有子问题的解 |
|
||||
| 子问题关系 | 不需要存储子问题解 | 需要存储子问题解(记忆化) |
|
||||
| 效率 | 通常更高 | 通常更低(但保证最优) |
|
||||
| 最优性 | 不一定(取决于问题性质) | 保证最优 |
|
||||
| 适用条件 | 贪心选择性质+最优子结构 | 最优子结构+重叠子问题 |
|
||||
| 典型应用 | 活动选择、分数背包 | 0-1背包、最短路径、LCS |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Problem["Optimization Problem"]
|
||||
GreedyCheck{"Greedy choice property holds?"}
|
||||
Greedy["Use Greedy Algorithm"]
|
||||
DPCheck{"Overlapping subproblems?"}
|
||||
DP["Use Dynamic Programming"]
|
||||
Brute["Other methods"]
|
||||
|
||||
Problem --> GreedyCheck
|
||||
GreedyCheck --> |"Yes"| Greedy
|
||||
GreedyCheck --> |"No"| DPCheck
|
||||
DPCheck --> |"Yes"| DP
|
||||
DPCheck --> |"No"| Brute
|
||||
```
|
||||
|
||||
### 六、贪心不适用的情况
|
||||
|
||||
- **0-1背包问题**:物品不可分割,贪心可能浪费容量
|
||||
- **旅行商问题(TSP)**:贪心选择最近城市不能保证全局最短路径
|
||||
- **一般图着色问题**:贪心着色不一定用最少颜色
|
||||
|
||||
> [!tip] 判断能否使用贪心的思路
|
||||
> 1. 先提出一个贪心策略
|
||||
> 2. 尝试证明:对任意输入,贪心选择都能导致最优解
|
||||
> 3. 如果证明失败,说明贪心不适用,考虑动态规划或回溯
|
||||
|
||||
## 关联笔记
|
||||
- [[算法设计与分析/试题册/index|试题册索引]]
|
||||
Reference in New Issue
Block a user