318 lines
9.8 KiB
Markdown
318 lines
9.8 KiB
Markdown
---
|
||
tags:
|
||
- 算法设计与分析
|
||
- 复习
|
||
- BFS
|
||
- DFS
|
||
- NP问题
|
||
create time: 2026-06-10 11:11
|
||
---
|
||
|
||
# 图搜索与NP复杂性
|
||
|
||
## 概述
|
||
图搜索算法(BFS和DFS)是图论中最基础的遍历方法,也是许多高级算法的基石。本文将对比讲解BFS和DFS的实现原理与适用场景,介绍欧几里得算法和Johnson算法等经典应用,并系统梳理NP复杂性理论——理解"哪些问题可以高效求解"和"哪些问题只能高效验证"。
|
||
|
||
## 正文
|
||
|
||
### 一、DFS(深度优先搜索)
|
||
|
||
DFS 沿着一条路径**尽可能深地**搜索,走不通再回退。
|
||
|
||
**实现方式**:使用**栈**(Stack)或**递归调用栈**
|
||
|
||
```cpp
|
||
#include <vector>
|
||
#include <stack>
|
||
#include <iostream>
|
||
using namespace std;
|
||
|
||
// DFS(栈实现)
|
||
// graph: 邻接表, start: 起始顶点
|
||
void DFS(vector<vector<int>>& graph, int start) {
|
||
int n = graph.size();
|
||
vector<bool> visited(n, false);
|
||
stack<int> st;
|
||
st.push(start);
|
||
|
||
while (!st.empty()) {
|
||
int node = st.top();
|
||
st.pop();
|
||
if (!visited[node]) {
|
||
visited[node] = true;
|
||
cout << node << " ";
|
||
for (int neighbor : graph[node]) {
|
||
if (!visited[neighbor])
|
||
st.push(neighbor);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
递归版本更直观:
|
||
|
||
```cpp
|
||
#include <vector>
|
||
#include <iostream>
|
||
using namespace std;
|
||
|
||
// DFS(递归版本)
|
||
// graph: 邻接表, node: 当前顶点, visited: 访问标记数组
|
||
void DFS_recursive(vector<vector<int>>& graph, int node, vector<bool>& visited) {
|
||
visited[node] = true;
|
||
cout << node << " ";
|
||
for (int neighbor : graph[node]) {
|
||
if (!visited[neighbor])
|
||
DFS_recursive(graph, neighbor, visited);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 二、BFS(广度优先搜索)
|
||
|
||
BFS **按层遍历**,先访问所有距离为1的节点,再访问距离为2的节点,以此类推。
|
||
|
||
**实现方式**:使用**队列**(Queue)
|
||
|
||
```cpp
|
||
#include <vector>
|
||
#include <queue>
|
||
#include <iostream>
|
||
using namespace std;
|
||
|
||
// BFS(队列实现)
|
||
// graph: 邻接表, start: 起始顶点
|
||
void BFS(vector<vector<int>>& graph, int start) {
|
||
int n = graph.size();
|
||
vector<bool> visited(n, false);
|
||
queue<int> q;
|
||
q.push(start);
|
||
visited[start] = true;
|
||
|
||
while (!q.empty()) {
|
||
int node = q.front();
|
||
q.pop();
|
||
cout << node << " ";
|
||
for (int neighbor : graph[node]) {
|
||
if (!visited[neighbor]) {
|
||
visited[neighbor] = true;
|
||
q.push(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 即为答案。
|
||
|
||
```cpp
|
||
// 欧几里得算法(辗转相除法)
|
||
// 求两个正整数的最大公约数
|
||
int gcd(int a, int b) {
|
||
while (b != 0) {
|
||
int temp = b;
|
||
b = a % 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|试题册索引]]
|