Article
算法导论-CH20-基本图算法
算法导论-CH20-图的基本概念和数据结构,待补充摘要。
- Preorder Traversal Demo
- Recursive s-t connectivity
- Demo: DepthFirstPaths.
- Demo Topological.java

CS61B 课程笔记:图与图的遍历 (Graphs and Traversals)
本笔记结合了 CS61B Lecture 22 (Graphs 1) 与 Lecture 23 (Graphs 2) 的课堂内容及个人手写笔记,对树遍历、图的定义、图的表示方法、深度优先搜索(DFS)及广度优先搜索(BFS)进行了系统性的整理与修正。
一、 树与树的遍历 (Tree Traversals)
1. 树的定义 (Tree Definition)
树(Tree)是图的一种特例。一个结构要被称为树,必须满足以下两个条件:
- 拥有一组节点(Nodes/Vertices)与一组连接这些节点的边(Edges)。
- 约束条件:任意两个节点之间有且仅有一条路径(即不存在环,且全连通)。
📌 注意:并非所有的家族谱系都是树,如果存在近亲结婚等情况导致产生环路,它在图论上就不是一棵树。
2. 树的遍历分类与直观理解
遍历(Traversal)本质上是按照某种特定的顺序“访问”树中的所有节点。与线性表(List)不同,树的非线性结构决定了它有多种遍历顺序,每种顺序都有其独特的应用场景。
(1) 层序遍历 (Level Order Traversal)
- 顺序:从上到下,从左到右(类似于英文的阅读顺序)。
- 直观感受:探索每个层级的所有元素,再进入下一层。
(2) 深度优先遍历 (Depth-First Traversals, DFS)
深度优先有三种经典方式:前序(Preorder)、中序(Inorder)*和*后序(Postorder)。
| 遍历方式 | 递归定义 | 形象比喻 / 直观体悟 | 典型应用场景 |
|---|---|---|---|
| 前序 (Preorder) | 先访问当前节点,再递归遍历其子节点。即:Node -> Left -> Right | 像书本大纲打印:先展示第 1 章标题(父节点),再深入展示第 1.1 节、1.2 节(子节点)。 | 打印目录树(Directory Listing)、复制树结构。 |
| 中序 (Inorder) | 先递归遍历左子树,再访问当前节点,最后递归右子树。即:Left -> Node -> Right | 重力投影法:感觉可以将所有节点垂直投影到地面上。从左到右读出投影后的字母,即为中序遍历结果。 | 二叉搜索树(BST)的中序遍历能够输出一个升序序列。 |
| 后序 (Postorder) | 先递归遍历子节点,最后访问当前节点。即:Left -> Right -> Node | 计算文件夹大小:想要知道一个父文件夹的大小,必须先计算其所有子文件夹的大小并进行累加,最后加上父文件夹自身的大小。 | 统计文件系统大小、自底向上的表达式求值。 |
3. 树遍历的形象化技巧与例题
🌟 中序遍历的投影法 (Visual Trick)
如下图所示,我们将二叉搜索树的所有节点垂直向下做投影:
D
/ \
B F
/ \ / \
A C E G
================= (地面)
A B C D E F G <-- 投影结果(天然有序)
- 投影结果:
A -> B -> C -> D -> E -> F -> G
🌟 绕圈标记法 (The Perimeter Trick)
如果我们沿着树的外围画一条逆时针的环绕曲线:

- 前序遍历:每次经过节点的左侧时进行访问(Visit)。
- 中序遍历:每次经过节点的底部时进行访问(Visit)。
- 后序遍历:每次经过节点的右侧时进行访问(Visit)。
📂 课例:后序遍历计算文件夹大小
在计算文件夹大小时,必须采用自底向上的后序遍历。其递归伪代码实现如下:
public int postOrderSize(BSTNode x) {
if (x == null) {
return 0;
}
int total = 0;
// 1. 先计算所有子文件夹的大小(递归 Left & Right)
for (BSTNode c : x.children()) {
total += postOrderSize(c);
}
// 2. 最后加上当前文件夹自身的文件大小(访问 Node)
total += x.fileSize();
return total;
}
二、 图的基本概念与表示法 (Graph & Representations)
1. 图的定义 (Graph Definition)
图(Graph)由以下两部分组成:
- 一组顶点(Vertices / Nodes,记作 )。
- 一组边(Edges,记作 ),每条边连接两个顶点。
在 CS61B 中,除非特别声明,我们默认讨论的都是简单图(Simple Graph):
- 无自环(No Loops):没有边将一个顶点连接到它自身。
- 无平行边(No Parallel Edges):任意两个顶点之间最多只有一条边相连。
树与图的关系
“所有的树都是图,但不是所有的图都是树。” 树是一个**无环(Acyclic)且连通(Connected)**的无向图。
2. 图的分类方式
- 有向图 (Directed Graph) vs 无向图 (Undirected Graph):边是否有方向。
- 有环图 (Cyclic Graph) vs 无环图 (Acyclic Graph):图中是否存在至少一个环路。
- 带权图 (Weighted Graph):边或节点上带有标签(如距离、花费等权重)。
3. 图的表示方法 (Graph Representations)
为了在计算机中存储和操作图,通常有以下三种具体的表示方法。假设图中有 个顶点, 条边。
(1) 邻接矩阵 (Adjacency Matrix)
- 原理:使用一个大小为 的二维布尔数组
adj[i][j]。如果顶点i与j之间存在边,则adj[i][j] = true(或1),否则为false(或0)。 - 直观感受:两节点之间有联系就是
1,没有就是0。 - 特点:对于无向图,该矩阵关于主对角线对称。
- 空间复杂度:。
(2) 边集 (Edge Sets)
- 原理:用一个集合(如
HashSet<Edge>)来存储所有的边,每一条边可以用一个二元组(u, v)表示。 - 空间复杂度:。
(3) 邻接表 (Adjacency List) —— 最常用
- 原理:维护一个大小为 的数组(或列表),数组的每个索引
v处存储一个列表,该列表包含所有与顶点v相邻的顶点。 - 直观感受:使用列表的数组来实现。
- 特点:对于稀疏图(Sparse Graph,)非常高效,是实际应用中最常见的图表示法。
- 空间复杂度:。
4. 课例:不同表示法下的图打印时间复杂度分析
题目:实现一个 print(Graph G) 函数,打印出图中所有的边。其结构如下:
public static void print(Graph G) {
for (int v = 0; v < G.V(); v += 1) {
for (int w : G.adj(v)) {
System.out.println(v + "-" + w);
}
}
}
❓ 思考 1:如果图使用“邻接表”表示,时间复杂度是多少?
- 分析:
- 外层循环
v执行 次。 - 内层循环通过迭代器访问
G.adj(v)。在邻接表中,所有顶点的邻接链表长度之和为 (对于无向图,每条边被存储两次)。 - 因此,内层循环的总迭代次数为 。
- 外层循环
- 结论:时间复杂度为 。
❓ 思考 2:如果图使用“邻接矩阵”表示,时间复杂度是多少?
- 分析:
- 虽然我们调用的是
G.adj(v),但因为底层是邻接矩阵,为了找出某个顶点v的所有邻居,迭代器在底层必须扫描整个长度为 的矩阵行。 - 因此,每一次调用
G.adj(v)的迭代过程都需要 的时间。 - 外部循环执行 次,每次都需要扫描长度为 的行。
- 虽然我们调用的是
- 结论:时间复杂度退化为 。
三、 深度优先搜索 (Depth First Search, DFS)
1. 动因:s-t 连通性问题 (s-t Connectivity)
-
问题:给定源点 和目标点 ,判断它们之间是否存在一条路径(即 和 是否连通)。
-
朴素递归尝试:
判断 s == t?如果是,返回 true。 否则,对于 s 的每一个邻居 v: 如果 connected(v, t) 返回 true,则返回 true。 返回 false。 -
致命缺陷(容易陷入 Loop): 如果图中存在环(例如 相连),在查询
connected(0, 7)时,由于没有记录哪些节点已经被访问过,程序会在connected(0, 7) -> connected(1, 7) -> connected(0, 7)之间无限循环导致栈溢出。
2. DFS 核心思想与 SOP (标准作业流程)
为了解决环路导致的无限循环问题,我们需要在访问节点时进行标记(Mark)。
💡 DFS 本质
探索某个邻居的整个子图,直到不能再深入为止,然后才回溯去探索下一个邻居。
📋 DFS 递归 SOP
- 标记当前节点 为已访问(
marked[s] = true)。 - 判断当前节点 是否为目标节点 。如果是,返回
true。 - 对于当前节点 的每一个未被标记(Unmarked)的邻居 :
- 递归调用 DFS 探索 。
- 如果所有邻居都探索完毕仍未找到通路,返回
false。
3. DFS 代码实现 (DepthFirstPaths)
以下是基于普林斯顿 Graph API 的经典 DFS 路径查找类实现。它不仅能判断连通性,还能记录具体的路径(通过 edgeTo 数组)。
/** DFS 核心递归流程 —— 这个代码必须要熟练掌握 */
private void dfs(Graph G, int v) {
marked[v] = true; // 1. 标记当前节点
for (int w : G.adj(v)) { // 2. 遍历邻居
if (!marked[w]) { // 3. 只访问未标记的邻居
edgeTo[w] = v; // 4. 记录 w 是由 v 走过来的
dfs(G, w); // 5. 递归调用
}
}
}
/** 判断起点 s 是否有一条路径通往顶点 v */
public boolean hasPathTo(int v) {
return marked[v];
}
/** 返回从起点 s 到顶点 v 的完整路径,若不连通则返回 null */
public Iterable<Integer> pathTo(int v) {
if (!hasPathTo(v)) {
return null;
}
List<Integer> path = new ArrayList<>();
// 从终点 v 沿着 edgeTo 往回倒推,直到起点 s
for (int x = v; x != s; x = edgeTo[x]) {
path.add(x);
}
path.add(s);
Collections.reverse(path); // 反转列表,使其从 s 开始到 v 结束
return path;
}
}
4. DFS 时间与空间复杂度分析
(1) 时间复杂度(基于邻接表表示):
- 为什么顶点走一次? 因为有
marked数组的保护,每个顶点 只会触发一次dfs(G, v)调用。故顶点访问总开销为 。 - 为什么边会走两次? 在无向图中,对于每条边
(u, v):- 当我们在顶点
u时,会检查v是否被标记。 - 当我们在顶点
v时,会检查u是否被标记。 - 也就是说,每条边都会被检查恰好 2 次(有向图中检查 1 次)。因此,边检查的总开销为 。
- 当我们在顶点
- 为什么不能简写为 ? 即使图中一条边都没有(),构造函数中初始化大小为 的
marked和edgeTo数组也需要耗费 的时间。
(2) 空间复杂度:
- 需要创建长度为 的
marked数组和edgeTo数组,以及最坏情况下深度为 的递归调用栈。
四、 广度优先搜索 (Breadth First Search, BFS)
1. 动因:s-t 最短路径问题
DFS 能够帮我们找到一条从 到 的路径,但这条路径不一定是最短的(DFS 喜欢一头扎到黑)。如果我们希望找到步数最少(边数最少)的路径,就需要使用广度优先搜索(BFS)。
💡 BFS 本质
BFS 类似于树的层序遍历。它按照距离起点的远近(即边数),呈“波纹状”向外层层扫荡。
2. BFS 核心工具:队列 (Queue)
由于 BFS 需要先访问完“当前距离的所有节点”,再访问“更远距离的节点”,我们无法使用递归(递归本质上是利用系统栈,具有先进后出的 DFS 特性)。 我们必须借助一个队列(Queue)作为工作集(常称为 Fringe / 边缘集 ),利用其先进先出 (FIFO) 的特性。
3. BFS 标准作业流程 (SOP) —— 必须掌握
- 初始化一个队列
fringe,将起点s放入队列,并将s标记为已访问(marked[s] = true)。 - 当队列
fringe不为空时,重复以下步骤:- (1) 从队列头部移出(Dequeue)一个顶点
v。 - (2) 对于
v的每一个未被标记(Unmarked)的邻居n:- 标记
n:marked[n] = true。 - 记录路径:
edgeTo[n] = v。 - 记录距离:
distTo[n] = distTo[v] + 1。 - 入队:将
n添加到队列尾部(Enqueue)。
- 标记
- (1) 从队列头部移出(Dequeue)一个顶点
4. BFS 模拟执行过程 (Dry Run)
假设有图如下,起点为 0:
1 --- 2 --- 5
/ | \
0 | 6 --- 7
\ | /
4 --------- 8
| 步骤 | 移出顶点 | 正在检查的邻居 | 队列 fringe 的状态 | 关键更新信息 |
|---|---|---|---|---|
| 初始 | - | - | [0] | marked[0]=T, distTo[0]=0 |
| 1 | 0 | 1, 4 | [1, 4] | marked[1,4]=T, edgeTo[1,4]=0, distTo=1 |
| 2 | 1 | 2(0已标记) | [4, 2] | marked[2]=T, edgeTo[2]=1, distTo[2]=2 |
| 3 | 4 | 8(1已标记) | [2, 8] | marked[8]=T, edgeTo[8]=4, distTo[8]=2 |
| 4 | 2 | 5(1已标记) | [8, 5] | marked[5]=T, edgeTo[5]=2, distTo[5]=3 |
| 5 | 8 | 6(5,4已标记) | [5, 6] | marked[6]=T, edgeTo[6]=8, distTo[6]=3 |
| 6 | 5 | 无未标记邻居 | [6] | - |
| 7 | 6 | 7 | [7] | marked[7]=T, edgeTo[7]=6, distTo[7]=4 |
| 8 | 7 | 无未标记邻居 | [] | 队列空,运行结束。 |
5. BFS 代码实现 (BreadthFirstPaths)
import java.util.LinkedList;
import java.util.Queue;
public class BreadthFirstPaths {
private boolean[] marked;
private int[] edgeTo;
private int[] distTo; // 记录从起点 s 到每个顶点的最短边数
private final int s;
public BreadthFirstPaths(Graph G, int s) {
this.s = s;
this.marked = new boolean[G.V()];
this.edgeTo = new int[G.V()];
this.distTo = new int[G.V()];
// 初始化距离数组为最大值,表示初始不可达
for (int v = 0; v < G.V(); v++) {
distTo[v] = Integer.MAX_VALUE;
}
bfs(G, s);
}
/** BFS 核心迭代实现 —— 必须会手写 */
private void bfs(Graph G, int s) {
Queue<Integer> fringe = new LinkedList<>();
// 1. 起点初始化
fringe.add(s);
marked[s] = true;
distTo[s] = 0;
while (!fringe.isEmpty()) {
// 2. 移出队列头节点
int v = fringe.poll();
// 3. 遍历未标记邻居
for (int w : G.adj(v)) {
if (!marked[w]) {
fringe.add(w); // 放入队列尾部
marked[w] = true; // 立即标记为已访问(防止重复入队)
edgeTo[w] = v; // 记录路径
distTo[w] = distTo[v] + 1; // 累加距离
}
}
}
}
public boolean hasPathTo(int v) {
return marked[v];
}
public int distTo(int v) {
return distTo[v];
}
}
五、 对比与总结 (Summary)
1. DFS 与 BFS 核心性质对比表格
| 性质 / 算法 | 深度优先搜索 (DFS) | 广度优先搜索 (BFS) |
|---|---|---|
| 核心数据结构 | 系统递归栈(Stack) | 显式队列(Queue) |
| 探索策略 | 孤军深入,一往无前 | 齐头并进,层层扫荡 |
| 经典应用 | 连通性检测(s-t)、环路检测、拓扑排序 | 最短路径(无权图)、最小生成树基础 |
| 时间复杂度 (邻接表) | ||
| 时间复杂度 (邻接矩阵) | ||
| 空间复杂度 |
2. 核心考点与闭坑指南
- 防环机制:在图的遍历中,不论是 DFS 还是 BFS,必须在入队或递归前将节点标记为
marked = true。否则一旦图中有环,必然陷入死循环。 - 入队即标记(BFS 关键):在 BFS 中,节点被检测到未标记后,应该立刻设为
marked[w] = true并入队。如果等出队时才标记,会导致同一个节点被多次重复加入队列,造成无意义的空间浪费和时间增加。 - 图表示法决定性能上限:
- 邻接表上做 DFS/BFS 是高效的 ;
- 邻接矩阵上由于遍历每个节点的邻居都强制需要 ,因此算法整体复杂度会退化为 。
五、 有向无环图(DAG)与拓扑排序(Topological Sort)
1. 有向无环图 (Directed Acyclic Graph, DAG)
有向无环图(DAG)是一个有方向且不存在任何环的图。DAG 在现实中被广泛用来建模“依赖关系”或者有“先后次序”的流程,例如排课计划、工程项目的工序逻辑等。
2. 拓扑排序的直观理解
💡 拓扑排序的本质
拓扑排序(Topological Sort):在图中找到一个节点的线性序列,使得图中任意一条有向边 , 在序列中都出现在 之前。也就是说,这个序列满足了任务先后发生的逻辑顺序。
🌟 排序的图形直观
如果你在纸上将拓扑排序好的节点排成一横列,你会发现图中所有的有向边(箭头)都是从左边指向右边的,绝无向左逆流的箭头(如下图所示)。
[ C ] ------> [ F ] ------> [ G ]
\ /
`--------> [ A ] ------>`
⚠️ 约束前提:拓扑排序有且仅在有向无环图(DAG)中才能实现。 如果图中有环,必然存在某种互为因果的死循环(如 ),此时不可能排出一个合理的先后发生顺序。
3. 拓扑排序算法:逆后序法 (Reverse Postorder)
手写笔记中提到:Topological ordering is given by the reverse of postorder list.(拓扑序是由 DFS 逆后序列表给出的)。
📋 拓扑排序 SOP
- 初始化一个空列表
postorder记录 DFS 的后序返回顺序,以及一个布尔数组marked。 - 依次遍历图中的所有顶点,如果顶点
v未被标记,则调用 DFS(v)。- DFS(v) 流程:
- (1) 标记
v为已访问(marked[v] = true)。 - (2) 递归访问
v的所有未标记邻居。 - (3) 当
v的所有邻居都递归完毕、准备从递归函数返回时,将v加入postorder列表的尾部。
- (1) 标记
- DFS(v) 流程:
- 整个图的顶点都遍历完成后,将整个
postorder列表进行反转(Reverse)。 - 反转后的结果即为一个合法的拓扑排序。
4. 课例:8 顶点拓扑排序轨迹模拟 (Trace)
在课件中,有如下 DAG。我们通过 DFS 来寻找它的逆后序。
C ---> F ---> G
| |
v v
A ---> D
| |
v v
B ---> E ---> H
假设我们首先从 入度(indegree)为 0 的顶点 A 出发调用 DFS:
🔄 模拟调用回溯链
- 调用
dfs(A),标记marked[A] = true - 访问邻居
B调用dfs(B),标记marked[B] = true - 访问邻居
E调用dfs(E),标记marked[E] = true - 访问邻居
H调用dfs(H),标记marked[H] = trueH没有出边。dfs(H)准备返回 记录后序:postorder = [H]
- 回溯到
E,E的邻居都访问完了,dfs(E)返回 记录后序:postorder = [H, E] - 回溯到
B,B的邻居都访问完了,dfs(B)返回 记录后序:postorder = [H, E, B] - 回溯到
A,发现A还有另一个未标记邻居D调用dfs(D),标记marked[D] = true D的唯一邻居E已被标记。dfs(D)准备返回 记录后序:postorder = [H, E, B, D]- 回溯并结束
dfs(A)记录后序:postorder = [H, E, B, D, A]
由于图中有未标记节点,接下来我们从 C 出发调用 dfs(C): 10. 调用 dfs(C),标记 marked[C] = true 11. 访问邻居 F 调用 dfs(F),标记 marked[F] = true 12. 访问邻居 G 调用 dfs(G),标记 marked[G] = true * G 没有未标记出边。dfs(G) 返回 记录后序:postorder = [H, E, B, D, A, G] 13. 回溯并结束 dfs(F) 记录后序:postorder = [H, E, B, D, A, G, F] 14. 回溯并结束 dfs(C) 记录后序:postorder = [H, E, B, D, A, G, F, C]
🏁 结果整理
- 最终后序 (Postorder):
[H, E, B, D, A, G, F, C] - 逆后序即拓扑排序 (Reverse Postorder):
[C, F, G, A, D, B, E, H]
5. 拓扑排序代码实现 (Topological)
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Topological {
private boolean[] marked;
private List<Integer> postorder; // 存储后序遍历结果
public Topological(Graph G) {
this.marked = new boolean[G.V()];
this.postorder = new ArrayList<>();
// 遍历所有节点,确保非连通图也能完全拓扑排序 (有重启的 DFS)
for (int v = 0; v < G.V(); v++) {
if (!marked[v]) {
dfs(G, v);
}
}
}
private void dfs(Graph G, int v) {
marked[v] = true;
for (int w : G.adj(v)) {
if (!marked[w]) {
dfs(G, w);
}
}
postorder.add(v); // 后序:子节点全部递归完毕后,再加入列表
}
/** 返回拓扑排序结果 */
public Iterable<Integer> order() {
List<Integer> reversePost = new ArrayList<>(postorder);
Collections.reverse(reversePost); // 反转后序得到拓扑序
return reversePost;
}
}
- 时间复杂度: (每个顶点访问一次,每条边扫描一遍)。
- 空间复杂度:。
六、 DAG 上的最短路径与最长路径算法(DAG SPT & LPT)
1. Dijkstra 算法在负权边下的局限性
在存在负权边(Negative Edges)的图中,经典的 Dijkstra 算法会失效。
- 失效原因:Dijkstra 算法基于贪心策略。一个节点一旦被移出优先队列(Visited),它的最短路径值就被固定,之后不会被重新松弛(Relax)。但如果后续路径中存在大幅度的负权边,之前固定下的距离可能并不是真正最小的。
2. DAG 最短路径算法 (DAG SPT):按拓扑序松弛
在 DAG(有向无环图)中,不论是否存在负权边,我们都可以利用拓扑序,在 极其高效的时间内求出单源最短路径。
💡 核心机制
按照拓扑排序的顺序依次遍历顶点,每次遍历到一个顶点时,对它所有的出边进行松弛(Relax)操作。因为拓扑排序保证了在处理顶点 时,所有能到达 的前期顶点都已经被处理完毕,因此 此时已经是绝对正确的最短路径值,绝不会再发生变动!
📋 DAG SPT 代码实现
public class AcyclicSP {
private double[] distTo;
private DirectedEdge[] edgeTo;
public AcyclicSP(EdgeWeightedDigraph G, int s) {
distTo = new double[G.V()];
edgeTo = new DirectedEdge[G.V()];
for (int v = 0; v < G.V(); v++) {
distTo[v] = Double.POSITIVE_INFINITY;
}
distTo[s] = 0.0;
// 1. 获取 DAG 的拓扑排序
Topological top = new Topological(G);
// 2. 按照拓扑序依次松弛每个顶点的出边
for (int v : top.order()) {
relax(G, v);
}
}
private void relax(EdgeWeightedDigraph G, int v) {
for (DirectedEdge e : G.adj(v)) {
int w = e.to();
if (distTo[w] > distTo[v] + e.weight()) {
distTo[w] = distTo[v] + e.weight();
edgeTo[w] = e;
}
}
}
}
3. DAG 最长路径算法 (DAG LPT)
❓ 最长路径问题的痛点
在普通图中求单源最长简单路径是一个极度困难的 NP-Hard 问题(目前最好的已知算法也是指数级别的,即 )。 然而,在 DAG 中,我们可以使用极其聪明的数学性质在 内解决最长路径。
🌟 核心公式与规约思想
由于:
我们可以将 DAG LPT 完美规约(Reduce)到 DAG SPT 上:
- 边权取反:创建原图 的一个副本 ,将其中所有边的权重乘以 。
- 求解最短路径:在 上运行
DAG SPT算法。 - 距离还原:将得到的
distTo数组的值再次乘以 ,即可得到原图的最长路径大小(此时的edgeTo指向完全不需要修改,就是正确的路径)。
七、 规约(Reductions)
1. 规约的定义 (Reduction Definition)
规约(Reduction)是理论计算机科学中最为核心的工具之一。
💡 浅显比喻
“爬山” 规约到 “坐缆车”:如果我们能通过坐缆车来到达山顶,那么爬山这个问题就迎刃而解了(即:缆车服务是解决爬山问题的一个更强/等价的子程序)。
📋 学术定义
如果解决任务 的子程序可以直接用来解决任务 ,我们就说:问题 可以规约到问题 ( reduces to )。
在刚才的例子中,“DAG 最长路径问题 (DAG-LPT)” 成功规约到了 “DAG 最短路径问题 (DAG-SPT)”。
2. 经典 A-level 规约例题:3SAT 规约到独立集问题 (3SAT Reduces to Independent Set)
规约不仅存在于图论问题之间,甚至能将逻辑问题与图论问题进行跨界转换。
(1) 问题背景
- 3SAT 问题:给定一个布尔逻辑表达式 (如 ),寻找一组逻辑变量的真值指派(True/False),使得整个表达式为真。
- 独立集问题 (Independent Set):在图中寻找一个包含 个顶点的集合,使得这 个顶点两两之间都没有边相连。
(2) 规约构造 SOP (如何把 3SAT 转换成图)
给定 3SAT 实例 ,构造独立集图 :
- 子句变三角形:对于 中的每一个三变量子句(如 ),在图 中建立 个对应的顶点,并将它们两两相连组成一个三角形。这意味着在独立集中,每个三角形(子句)里最多只能选出 个顶点。
- 矛盾相连:将图中所有互为相反数的文字节点相连(例如将所有的 节点与 节点连一条边)。这保证了我们不能同时将 和 都选入独立集。
- 设定目标值 : 子句的总数量。
🏁 规约结论
如果在构造出来的图 中能够找到一个大小为 的独立集,那么这个独立集选中的顶点就对应了 3SAT 的一组可行解(这些被选中的变量全设为 True,即可使整个 3SAT 表达式被满足)。
八、 总结 (Summary)
1. 核心性质对比大图谱
| 算法 / 问题 | 适用图类型 | 核心思想 / 机制 | 时间复杂度 | 空间复杂度 | 解决痛点 |
|---|---|---|---|---|---|
| DFS Paths | 任意图 | 递归栈,标记防止无限 Loop | s-t 连通性路径查找 | ||
| BFS Paths | 任意无权图 | 显式队列 (Fringe) 层层扫荡 | 无权图中的最短路径 | ||
| 拓扑排序 | DAG | DFS 逆后序 (Reverse Postorder) | 任务先后依赖性排序 | ||
| DAG SPT | DAG (可含负边) | 按照拓扑序依次松弛出边 | 在含负权边的图中求最短路 | ||
| DAG LPT | DAG | 取反边权 运行 DAG SPT 还原 | 高效求解最长路径 |
2. 核心考点与闭坑指南
- 拓扑排序判定:考试中如果让你写拓扑排序,第一步要检查图是否为 DAG。只要图里包含环,拓扑排序就彻底不存在。
- 后序(Postorder)与逆后序(Reverse Postorder):不要混淆。DFS 遍历完邻居后把当前节点放进 list 尾部是后序。将后序反转(Reverse)才是拓扑排序。
- 规约性质的方向性:如果 规约到 ,说明 至少和 一样难。若 有高效算法,则 也有高效算法;反之,若 被证明极其难解,那么 也一定极其难解。