【计算机算法与设计(6)】BFS、DFS、拓扑排序、连通分支

文章目录

  • [要点6:能够熟练掌握和运用 BFS、DFS、拓扑排序、图的连通分支分解算法](#要点6:能够熟练掌握和运用 BFS、DFS、拓扑排序、图的连通分支分解算法)
    • [📚 学习路线图](#📚 学习路线图)
    • 本文内容一览(快速理解)
    • [一、图的周游算法概述(Graph Traversal Overview):理解图的基本访问方法](#一、图的周游算法概述(Graph Traversal Overview):理解图的基本访问方法)
      • [1.1 图的表示(Graph Representation):邻接表和邻接矩阵](#1.1 图的表示(Graph Representation):邻接表和邻接矩阵)
    • 二、广度优先搜索(BFS):按跳数距离逐层搜索
      • [2.1 BFS的基本思想(Basic Idea):逐层向外展开](#2.1 BFS的基本思想(Basic Idea):逐层向外展开)
      • [2.2 BFS算法实现(Algorithm Implementation):使用队列](#2.2 BFS算法实现(Algorithm Implementation):使用队列)
      • [2.3 BFS的复杂度(Complexity):O(n+m)](#2.3 BFS的复杂度(Complexity):O(n+m))
      • [2.4 BFS的应用:图的二着色(Two-Colorability)](#2.4 BFS的应用:图的二着色(Two-Colorability))
    • 三、深度优先搜索(DFS):尽可能深地搜索
      • [3.1 DFS的基本思想(Basic Idea):尽可能深地搜索然后回溯](#3.1 DFS的基本思想(Basic Idea):尽可能深地搜索然后回溯)
      • [3.2 DFS算法实现(Algorithm Implementation):递归形式](#3.2 DFS算法实现(Algorithm Implementation):递归形式)
      • [3.3 DFS的边分类(Edge Classification):树边、反向边、前向边、交叉边](#3.3 DFS的边分类(Edge Classification):树边、反向边、前向边、交叉边)
    • [四、拓扑排序(Topological Sort):对有向无环图的顶点排序](#四、拓扑排序(Topological Sort):对有向无环图的顶点排序)
      • [4.1 拓扑排序的算法(Algorithm):基于DFS](#4.1 拓扑排序的算法(Algorithm):基于DFS)
      • [4.2 拓扑排序的正确性证明(Correctness Proof)](#4.2 拓扑排序的正确性证明(Correctness Proof))
    • [五、图的连通分支分解(Connected Components Decomposition):划分图的连通部分](#五、图的连通分支分解(Connected Components Decomposition):划分图的连通部分)
      • [5.1 无向图的连通分支(Connected Components of Undirected Graph)](#5.1 无向图的连通分支(Connected Components of Undirected Graph))
      • [5.2 有向图的强连通分支分解(Strongly Connected Components)](#5.2 有向图的强连通分支分解(Strongly Connected Components))
    • [📝 本章总结](#📝 本章总结)

要点6:能够熟练掌握和运用 BFS、DFS、拓扑排序、图的连通分支分解算法

📌 适合对象 :算法学习者、计算机科学学生

⏱️ 预计阅读时间 :70-80分钟

🎯 学习目标 :掌握图的两种基本周游算法(BFS和DFS),理解拓扑排序的原理和应用,掌握图的连通分支分解算法

📚 参考PPT:第 8 章-PPT-N2(图的周游算法)- BFS、DFS、拓扑排序、连通分支相关内容


📚 学习路线图

图的周游算法 广度优先搜索
BFS 深度优先搜索
DFS 应用:最短路径
二着色 应用:拓扑排序
强连通分支 拓扑排序
Topological Sort 强连通分支分解
SCC Decomposition


本文内容一览(快速理解)

  1. 图的周游:对一个图的每个顶点及每条边按某种顺序逐一访问
  2. 广度优先搜索(BFS):按跳数距离逐层向外展开搜索,使用队列
  3. 深度优先搜索(DFS):尽可能深地搜索,使用递归或栈
  4. 拓扑排序:对有向无环图(DAG)的顶点排序,使得边的方向与序列一致
  5. 强连通分支分解:将有向图的顶点划分为不相交的强连通分支

一、图的周游算法概述(Graph Traversal Overview):理解图的基本访问方法

这一章要建立的基础:理解图的周游算法是图算法的基础,为解决问题提供访问框架和顺序。

核心问题:如何系统地访问图中的所有顶点和边?


!NOTE

📝 关键点总结:对一个图的每个顶点及每条边按某种顺序逐一访问称做图的周游(graph traversal)。周游本身不是目的,而是为解决应用问题提供快速有效的访问框架和顺序。在解决具体问题时,需要在周游时加入其它的操作从而得到期望的结果。

1.1 图的表示(Graph Representation):邻接表和邻接矩阵

概念的本质

图可以用两种主要方式表示:

  1. 邻接表(Adjacency List)

    • 为每个顶点维护一个链表,存储该顶点的所有邻居
    • 空间需求: O ( ∣ V ∣ + ∣ E ∣ ) O(|V| + |E|) O(∣V∣+∣E∣)
    • 优点:空间效率高,适合稀疏图
    • 缺点:无法快速确定 ( u , v ) (u,v) (u,v)是否是图中的一条边
  2. 邻接矩阵(Adjacency Matrix)

    • 用 ∣ V ∣ × ∣ V ∣ |V| \times |V| ∣V∣×∣V∣矩阵表示, A [ i ] [ j ] = 1 A[i][j] = 1 A[i][j]=1表示存在边 ( i , j ) (i,j) (i,j)
    • 空间需求: Θ ( ∣ V ∣ 2 ) \Theta(|V|^2) Θ(∣V∣2)
    • 优点:可以快速确定 ( u , v ) (u,v) (u,v)是否是图中的一条边
    • 缺点:空间开销大,适合稠密图

图解说明
图的表示 邻接表
O(V+E)空间 邻接矩阵
O(V²)空间 适合稀疏图 适合稠密图

💡 说明:有向图的邻接表里,每条目里只记录出行边,不记录入行边。对邻接链表进行简单修改,也可以将其修改为权重图,从而每条边具有一个实数权重。

实际例子

复制代码
图的表示示例:

无向图:
顶点:{1, 2, 3, 4}
边:{(1,2), (1,3), (2,4), (3,4)}

邻接表:
1: [2, 3]
2: [1, 4]
3: [1, 4]
4: [2, 3]

邻接矩阵:
   1  2  3  4
1  0  1  1  0
2  1  0  0  1
3  1  0  0  1
4  0  1  1  0

二、广度优先搜索(BFS):按跳数距离逐层搜索

这一章要建立的基础:理解BFS是根据顶点到源点的跳数距离,一层一层地、逐层向外展开搜索的算法。

核心问题:如何按距离从近到远系统地访问图中的所有顶点?


!NOTE

📝 关键点总结:广度优先搜索是根据顶点s到每个顶点的【跳数】距离,一层一层地、逐层向外展开搜索的;相邻两层之间的距离恰好为1,即首先搜索的"层"是距离为0的,即顶点s,然后是距离为1的,即顶点s的所有直接邻居,再然后是距离为2的。

2.1 BFS的基本思想(Basic Idea):逐层向外展开

概念的本质

广度优先周游策略(假设图是连通的):

  1. 第一步 :访问顶点 s s s(根节点自身)
  2. 第二步 :逐一访问与 s s s直接相邻顶点,即 A d j ( s ) Adj(s) Adj(s)中所有顶点(根节点的邻居)
  3. 第三步 :依次访问 A d j ( v 1 ) Adj(v_1) Adj(v1), A d j ( v 2 ) Adj(v_2) Adj(v2), ... \ldots ..., A d j ( v k ) Adj(v_k) Adj(vk)中的顶点(根节点的邻居的(新)邻居)
  4. 第四步:逐一访问在上一步中被访问的顶点的前向邻居
  5. 重复:重复第四步,直到所有顶点都被访问到

图解说明
源点s
距离0 第1层
距离1 第2层
距离2 第3层
距离3 ...

💡 说明:可以看出,广度优先搜索是根据顶点s到每个顶点的【跳数】距离,一层一层地、逐层向外展开搜索的;相邻两层之间的距离恰好为1。

类比理解

就像水波扩散:

  • 在池塘中心扔一块石头(源点 s s s)
  • 水波一层一层向外扩散
  • 每一层代表距离中心相同距离的所有点

实际例子

复制代码
BFS搜索过程:

图:s -- a -- b
    |    |
    c    d

第0层(距离0):s
第1层(距离1):a, c
第2层(距离2):b, d

BFS树:
    s
   / \
  a   c
  |   |
  b   d

2.2 BFS算法实现(Algorithm Implementation):使用队列

概念的本质

BFS算法使用三种颜色和队列:

  • 白色:尚未被访问
  • 灰色:刚刚被访问(自身已访问,但邻居尚未全部访问)
  • 黑色:所有前向邻居都已被访问

算法伪码

复制代码
BFS(G(V, E), s)   //Breadth-First Search
1.   for each vertex u ∈ V -- {s} do    //初始化
2.        color[u] ← white;
3.        d[u] ← ∞;
4.        π(u) ← nil;
5.   endfor
6.   color[s] ← Gray;          
7.   d[s] ← 0;
8.   Q ← ∅;        //队列Q用于存储灰色顶点,初始为空
9.   Enqueue(Q, s);     //将s进队,初始化完成
10. while Q ≠ ∅ do
11.     u ← Dequeue (Q);     //取出队首元素
12.     for each v ∈ Adj[u] do       //邻居访问次序可以是任意的
13.         if color[v] == white then
14.               color[v] ← gray;
15.               d[v] ← d[u] + 1;
16.               π(v) ← u;
17.               Enqueue(Q, v);   //先进先出,新来的加到队尾
18.         endif
19.     endfor
20.     color[u] ← black;
21. endwhile
22. End

图解说明
是 是 否 否 初始化
s入队 队列非空? 出队顶点u 访问u的所有邻居v v是白色? v变灰
d[v] = d[u]+1
v入队 跳过 u变黑 结束

💡 说明:采用先进先出(FIFO,First-In-First-Out)的队列来存储灰色顶点------即那些自身已被访问但尚有邻居等待开发的顶点。每个顶点v有两个伴随变量:1) s到v的距离d(v); 2) v的父亲指针π(v)。

实际例子

复制代码
BFS执行过程:

图:s -- a
    |    |
    b -- c

初始化:Q = [s], d[s] = 0

第1轮:u = s
  - 访问a:a变灰,d[a] = 1,a入队,Q = [a]
  - 访问b:b变灰,d[b] = 1,b入队,Q = [a, b]
  - s变黑

第2轮:u = a
  - 访问c:c变灰,d[c] = 2,c入队,Q = [b, c]
  - a变黑

第3轮:u = b
  - c已访问(非白色),跳过
  - b变黑

第4轮:u = c
  - c变黑

结果:BFS树 = {(s,a), (s,b), (a,c)}
距离:d[s]=0, d[a]=1, d[b]=1, d[c]=2

2.3 BFS的复杂度(Complexity):O(n+m)

概念的本质

BFS的复杂度是 O ( n + m ) O(n+m) O(n+m),这是因为:

  • 初始化部分 :只需 O ( n ) O(n) O(n)时间
  • while循环部分 :每个顶点被进队和出队列各一次,需 O ( n ) O(n) O(n)时间
  • 访问邻居 :对 u u u的前向邻居 A d j ( u ) Adj(u) Adj(u)逐一访问的时间与邻居数量 ∣ A d j ( u ) ∣ |Adj(u)| ∣Adj(u)∣成正比
  • 总时间 :无向图中,所有顶点的度数之和等于边数的两倍,即 ∑ u ∣ A d j ( u ) ∣ = 2 m \sum_{u} |Adj(u)| = 2m ∑u∣Adj(u)∣=2m;有向图中所有顶点的出度之和就等于 m m m,所以访问所有顶点的邻居总共需要的时间是 O ( m ) O(m) O(m)

图解说明
BFS复杂度 初始化:O(n) 队列操作:O(n) 访问邻居:O(m) 总复杂度:O(n+m)

💡 说明 :任何周游算法都必须访问图中每个顶点和边【假定图是连通的】,因此广度优先算法是一个最优算法。BFS树 = 最短距离树(假定每条边长度为1)= { ( π ( v ) , v ) ∣ v ∈ V − s } \{(\pi(v), v) | v \in V-s\} {(π(v),v)∣v∈V−s}。

实际例子

复制代码
BFS复杂度分析:

图:n = 5个顶点,m = 6条边

初始化:5个顶点 → O(5) = O(n)
队列操作:每个顶点进队出队各一次 → O(5) = O(n)
访问邻居:
  - 顶点1:2个邻居
  - 顶点2:3个邻居
  - 顶点3:2个邻居
  - 顶点4:2个邻居
  - 顶点5:1个邻居
  总计:2+3+2+2+1 = 10 = 2m → O(m)

总复杂度:O(n) + O(n) + O(m) = O(n+m)

2.4 BFS的应用:图的二着色(Two-Colorability)

概念的本质

图的二着色问题:把一个无向图着色就是给图中每个顶点分配一种颜色(或一个号码)并使得图中任意两个相邻顶点的颜色不同。

算法思路

  • 用红(red)、蓝(blue)两色为图着色
  • 起始顶点 s s s的颜色着为红色
  • 首次访问点 v v v时,染成与其父亲 u u u相反的颜色
  • 如从 u u u到 v v v是后续访问,检查 c o l o r ( u ) color(u) color(u)和 c o l o r ( v ) color(v) color(v)是否相同
  • 如果相同,停止着色并报告该图不可二着色

图解说明
是 否 是 否 起始顶点s
红色 访问s的邻居 邻居v是白色? v染成与s相反的颜色
(蓝色) color(v) == color(s)? 不可二着色
存在奇回路 继续 继续BFS

💡 说明 :当 k > 2 k > 2 k>2时, k k k-着色问题是NP-完全问题, k = 2 k = 2 k=2有多项式算法。如果算法检测出 c o l o r ( u ) = = c o l o r ( v ) color(u) == color(v) color(u)==color(v),说明存在奇回路,图不可二着色。

实际例子

复制代码
二着色示例:

图:a -- b
    |    |
    c -- d

BFS过程:
1. a染红色
2. b染蓝色(与a相反)
3. c染蓝色(与a相反)
4. d访问时,发现d的邻居b和c都是蓝色
   - 检查:color(b) == color(c) == blue
   - 但b和c不相邻,所以可以继续
   - d染红色(与b和c相反)

结果:图可以二着色
{a: 红, b: 蓝, c: 蓝, d: 红}

如果存在三角形(奇回路):
a -- b
|    |
c -- d(与a,b,c都相邻)

则无法二着色

三、深度优先搜索(DFS):尽可能深地搜索

这一章要建立的基础:理解DFS是尽可能深地搜索图的算法,使用递归或栈实现。

核心问题:如何尽可能深地搜索图,然后回溯?


!NOTE

📝 关键点总结:深度优先搜索的周游策略:1) 图可连通也可不连通,可有向也可无向;2) 只要图中有还没访问的顶点,取一个尚未访问的顶点(如s),调用子程序。子程序的主要步骤是:首先访问s,然后访问s的邻居;当DFS访问到某顶点u时并继续访问u的(前向)邻居时,只从中选择一个尚未访问过的邻居v作为自己的儿子。然后DFS搜索进程暂时弃u的其它邻居不顾,而是从新收的儿子v出发继续访问v的邻居...当从v出发的所有访问全部完成后,DFS会回溯(backtrack)到父节点u。

3.1 DFS的基本思想(Basic Idea):尽可能深地搜索然后回溯

概念的本质

DFS的周游策略:

  1. 选择起点 :只要图中有还没访问的顶点,取一个尚未访问的顶点 s s s
  2. 深度搜索 :从 s s s开始,选择一个尚未访问过的邻居 v v v,继续从 v v v深度搜索
  3. 回溯 :当从 v v v出发的所有访问全部完成后,DFS会回溯到父节点 u u u
  4. 继续 :从 u u u去访问它的下一个尚未被访问过的邻居节点
  5. 重复:如果图不连通或不是强连通的有向图,可能需要做多轮DFS搜索

图解说明
否 是 是 否 是 否 访问s 选择未访问的邻居v 递归访问v v的所有邻居
都已访问? 继续从v深度搜索 回溯到父节点 父节点还有
未访问邻居? 继续回溯 还有未访问顶点? 结束

💡 说明 :需要注意的是,从 v v v开始的搜索过程中,有可能其父节点 u u u刚才没有访问的邻居已经被 v v v或 v v v的子孙访问到。这样,当DFS从 v v v回溯到 u u u时, u u u的这些邻居已经成为别人的儿子, u u u对它们的访问只构成后续访问,不会形成父子关系。

类比理解

就像走迷宫:

  • 选择一条路一直走到头
  • 如果走到死胡同,就回溯到上一个路口
  • 尝试另一条路
  • 直到探索完所有路径

实际例子

复制代码
DFS搜索过程:

图:a -- b -- c
    |    |
    d    e

从a开始DFS:
1. 访问a(d[a] = 1)
2. 选择邻居b,访问b(d[b] = 2)
3. 选择邻居c,访问c(d[c] = 3)
4. c没有未访问邻居,完成c(f[c] = 4),回溯到b
5. b还有未访问邻居e,访问e(d[e] = 5)
6. e没有未访问邻居,完成e(f[e] = 6),回溯到b
7. b的所有邻居已访问,完成b(f[b] = 7),回溯到a
8. a还有未访问邻居d,访问d(d[d] = 8)
9. d没有未访问邻居,完成d(f[d] = 9),回溯到a
10. a的所有邻居已访问,完成a(f[a] = 10)

DFS树:a -- b -- c
        |    |
        d    e

3.2 DFS算法实现(Algorithm Implementation):递归形式

概念的本质

DFS算法使用三种颜色和时间戳:

  • 白色:尚未被访问
  • 灰色:刚被访问(发现时刻)
  • 黑色:所有前向邻居都已被访问(完成时刻)

主程序伪码

复制代码
DFS(G(V, E))
1.  for each vertex u ∈ V
2.      color[u] ← White     //初始化每个点为白色
3.      π[u] ← Nil
4.  endfor
5.  time ← 0         //初始时间为0
6.  for each vertex u ∈ V
7.      if color[u] == White     //如有白色顶点,调用DFS子程序
8.          then DFS-Visit(u)
9.      endif
10. endfor
End

DFS子程序伪码(递归形式)

复制代码
DFS-Visit(s)
color[s] ← Gray          //顶点s 由白变灰
time ← time + 1          
d[s] ← time      //为顶点s打上发现时刻的时间戳d[s]
for each v ∈ Adj[s]
    if color[v] == White
        then    π(v) ← s
                DFS-Visit(v)     //递归访问s的邻居v
    endif
endfor
color[s] ← Black         //此时,对s的访问已完成
time ← time + 1  
f[s] ← time                      //为顶点s打上完成时刻的时间戳
End

图解说明
是 否 是 否 DFS-Visit(s) s变灰
d[s] = time++ 访问s的每个邻居v v是白色? π(v) = s
递归DFS-Visit(v) 后续访问
跳过 s的所有邻居
都已访问? s变黑
f[s] = time++

💡 说明 :每个顶点 u u u有两个事件点,第一个事件点是 u u u刚被访问,由白变灰,称为发现时刻,记做 d ( u ) d(u) d(u);第二个事件点是 u u u由灰变黑,称为完成时刻,记做 f ( u ) f(u) f(u)。如果 v v v是 u u u的儿子,那么DFS先完成对 v v v的访问后才完成对 u u u的访问。从而可知, [ d ( v ) , f ( v ) ] ⊆ [ d ( u ) , f ( u ) ] [d(v), f(v)] \subseteq [d(u), f(u)] [d(v),f(v)]⊆[d(u),f(u)], ∀ π ( v ) = = u \forall \pi(v) == u ∀π(v)==u。

实际例子

复制代码
DFS时间戳示例:

图:a -- b
    |
    c

DFS过程:
1. DFS-Visit(a)
   - d[a] = 1, color[a] = Gray
   - 访问b:DFS-Visit(b)
     - d[b] = 2, color[b] = Gray
     - b没有未访问邻居
     - f[b] = 3, color[b] = Black
   - 访问c:DFS-Visit(c)
     - d[c] = 4, color[c] = Gray
     - c没有未访问邻居
     - f[c] = 5, color[c] = Black
   - f[a] = 6, color[a] = Black

时间戳:
a: [1, 6]
b: [2, 3] ⊆ [1, 6]
c: [4, 5] ⊆ [1, 6]

3.3 DFS的边分类(Edge Classification):树边、反向边、前向边、交叉边

概念的本质

DFS会导致图中边的分类:

  1. 树边(Tree Edge) :首次访问时形成的边, ( u , v ) (u,v) (u,v)中 v v v是白色
  2. 反向边(Back Edge) : v v v是 u u u的一个祖先,判断条件: v v v是灰色
  3. 前向边(Forward Edge) : v v v是 u u u的一个后代,判断条件: v v v是黑色且 d ( u ) < d ( v ) d(u) < d(v) d(u)<d(v)
  4. 交叉边(Cross Edge) : u u u和 v v v无直系亲属关系,判断条件: v v v是黑色且 d ( u ) > d ( v ) d(u) > d(v) d(u)>d(v)

图解说明
DFS边分类 树边
Tree Edge 反向边
Back Edge
v是灰色 前向边
Forward Edge
v是黑色且d(u) 交叉边
Cross Edge
v是黑色且d(u)>d(v)

💡 说明 :从顶点 u u u出发访问顶点 v v v时,若 v v v不是白色,那么 ( u , v ) (u, v) (u,v)是一条非树边(Non-tree edge)。无向图中既无前向边也无交叉边,因为无向图中不存在单向边。

实际例子

复制代码
DFS边分类示例:

有向图:
a → b → c
↓   ↑   ↓
d ← e   f

DFS从a开始:
- (a,b):树边(b是白色)
- (b,c):树边(c是白色)
- (c,f):树边(f是白色)
- (a,d):树边(d是白色)
- (d,e):树边(e是白色)
- (e,b):反向边(b是灰色,是e的祖先)
- (c,f):前向边(如果f是c的后代)
- (d,c):交叉边(c是黑色且d(u)>d(v))

四、拓扑排序(Topological Sort):对有向无环图的顶点排序

这一章要建立的基础:理解拓扑排序是对有向无环图(DAG)的顶点排序,使得边的方向与序列一致。

核心问题:如何对有向无环图的顶点排序,使得所有边的方向与序列一致?


!NOTE

📝 关键点总结:拓扑排序是对一个有向无环图(DAG,Directed Acyclic Graph)中的顶点排序,使得该序列的顺序和图中每一条边都一致:只要 ( a , b ) (a, b) (a,b)是图中一条边,在序列中,顶点 a a a就一定出现在 b b b的前面。

4.1 拓扑排序的算法(Algorithm):基于DFS

概念的本质

拓扑排序算法:

  1. 调用 D F S ( G ( V , E ) ) DFS(G(V, E)) DFS(G(V,E))对图 G G G进行深度优先搜索
  2. 在DFS进行过程中,当一个顶点完成时,将它输出并插入到已输出序列的前面
  3. 按序列的顺序输出各顶点

算法伪码

复制代码
Topological-Sort(G(V, E))
调用DFS(G(V, E))对图G进行深度优先搜索。
在DFS进行过程中,当一个顶点完成时,将它输出并插入到已输出序列的前面。
按序列的顺序输出各顶点。
End

图解说明
有向无环图DAG 调用DFS 顶点完成时
插入序列前面 输出序列
拓扑排序结果

💡 说明 :有时,我们希望从某一给定顶点 s s s开始进行拓扑排序,并且只对从 s s s可以有路径到达的顶点排序,那么,我们只需要做一轮从 s s s开始的深度优先搜索即可。我们用 T o p o l o g i c a l − S o r t ( G ( V , E ) , s ) Topological-Sort(G(V, E), s) Topological−Sort(G(V,E),s)表示这样一种拓扑排序。

类比理解

就像课程安排:

  • 有些课程有先修课程要求
  • 拓扑排序给出一个选课顺序
  • 使得每门课的先修课程都在它之前完成

实际例子

复制代码
拓扑排序示例:

课程依赖关系(DAG):
数学 → 物理
数学 → 化学
物理 → 计算机
化学 → 计算机

DFS过程(完成时刻):
1. 数学完成 → f[数学] = 1
2. 物理完成 → f[物理] = 2
3. 化学完成 → f[化学] = 3
4. 计算机完成 → f[计算机] = 4

拓扑排序(按完成时刻从大到小):
计算机, 化学, 物理, 数学

验证:所有边的方向与序列一致
- 数学 → 物理:数学在物理前面 ✓
- 数学 → 化学:数学在化学前面 ✓
- 物理 → 计算机:物理在计算机前面 ✓
- 化学 → 计算机:化学在计算机前面 ✓

4.2 拓扑排序的正确性证明(Correctness Proof)

概念的本质

拓扑排序算法的正确性证明:

我们只须证明,图中一条边 ( u , v ) (u, v) (u,v)中的两个顶点 u u u和 v v v,一定会先输出 v v v,后输出 u u u。也就是要证明 f ( v ) < f ( u ) f(v) < f(u) f(v)<f(u)。

两种情况

  1. d ( u ) < d ( v ) d(u) < d(v) d(u)<d(v)

    • 发现 u u u时, v v v是 u u u的一个白色的邻居
    • 由白路径定理,DFS要先完成对 v v v的访问之后才能完成对 u u u的访问
    • 所以有 f ( v ) < f ( u ) f(v) < f(u) f(v)<f(u)
  2. d ( u ) > d ( v ) d(u) > d(v) d(u)>d(v)

    • 发现 v v v时, u u u是一个白色的顶点
    • 由于图 G G G无回路,不存在从 v v v到 u u u的路径
    • 所以,在DFS完成对 v v v的访问之后才有可能去发现 u u u
    • 所以也有 f ( v ) < f ( u ) f(v) < f(u) f(v)<f(u)

图解说明
是 否 边(u,v) d(u) < d(v)? v是u的白色邻居
f(v) < f(u) 图无回路
f(v) < f(u) 拓扑排序正确

💡 说明 :需要说明的是,拓扑排序算法执行过程中,我们不能采用顶点发现时间的早晚来进行顶点排序。这是因为:对于上面证明过程中所说的两种情况,如果采用顶点发现时间的话, u u u和 v v v的次序将不再具有唯一性,因而无法保证结果的正确性。

实际例子

复制代码
拓扑排序正确性验证:

DAG:a → b → c
      ↓
      d

情况1:d(a) < d(b)
- a先被发现
- b是a的白色邻居
- DFS先完成b,再完成a
- f(b) < f(a)
- 拓扑排序:..., b, ..., a, ... ✓

情况2:d(a) > d(b)
- b先被发现
- 图无回路,不存在从b到a的路径
- DFS先完成b,再发现a
- f(b) < f(a)
- 拓扑排序:..., b, ..., a, ... ✓

五、图的连通分支分解(Connected Components Decomposition):划分图的连通部分

这一章要建立的基础:理解连通分支是图的最大连通子图,强连通分支是有向图的最大强连通子图。

核心问题:如何将一个图(或有向图)的顶点划分为不相交的连通分支(或强连通分支)?


!NOTE

📝 关键点总结:连通分支:无向图的一个最大连通子图。强连通分支:有向图的一个最大强连通子图(每个顶点都有一条通向其它所有顶点的路径)。如果有向图的一个子图是强连通的,则称其为强连通子图。如果一个"强连通子图"已经最大化,即不能再加入其它任何一个顶点而仍然保持强连通特性,那么这个子图称为强连通分支。

5.1 无向图的连通分支(Connected Components of Undirected Graph)

概念的本质

无向图的连通分支:

  • 无向图的一个最大连通子图
  • 如果图不连通,可以用DFS或BFS找出所有连通分支
  • 方法:对每个未访问的顶点,调用一次DFS或BFS

算法思路

复制代码
Connected-Components(G(V, E))
1.  for each vertex u ∈ V
2.      color[u] ← White
3.  endfor
4.  component_id ← 0
5.  for each vertex u ∈ V
6.      if color[u] == White
7.          then component_id ← component_id + 1
8.               DFS-Visit(u)  //或BFS
9.               //标记所有访问到的顶点属于component_id
10.     endif
11. endfor
End

图解说明
是 否 无向图 图连通? 1个连通分支 多个连通分支 对每个未访问顶点
调用DFS/BFS 得到所有连通分支

💡 说明:如果图不连通或不是强连通的有向图,可能需要做多轮DFS搜索来确保访问到网络中所有顶点。每次子程序的调用产生一棵DFS树。多次调用会产生一个包含多棵DFS树的森林------如果原图不连通的话。

实际例子

复制代码
连通分支示例:

无向图:
1 -- 2 -- 3
4 -- 5

连通分支:
- 分支1:{1, 2, 3}
- 分支2:{4, 5}

算法过程:
1. 从顶点1开始DFS,访问到1,2,3 → 分支1
2. 从顶点4开始DFS,访问到4,5 → 分支2

5.2 有向图的强连通分支分解(Strongly Connected Components)

概念的本质

有向图的强连通分支:

  • 如果一个有向图中每个顶点都有一条通向其它所有顶点的路径,那么这个有向图称为一个强连通图
  • 强连通分支是最大化的强连通子图
  • 有向图的强连通分支问题就是把一个有向图的顶点划分为不相交的若干个强连通分支

强连通分支算法

  1. 对图 G G G作DFS搜索并标出各顶点 u u u的 d [ u ] / f [ u ] d[u]/f[u] d[u]/f[u]
  2. 构造图 G G G的转置图 G T G^T GT, G T G^T GT是把 G G G中每条边反向后得到的图
  3. 从具有最大的完成时刻的顶点(如 u u u)出发对图 G T G^T GT进行一轮DFS搜索。所有能访问到的顶点形成一个强连通分支并且被输出
  4. 如果还有未访问到的顶点,则在这些尚未访问的顶点中重复第3步直到所有顶点都在某一轮DFS搜索之后输出完毕

图解说明
是 否 有向图G 第1轮DFS
标记d[u]/f[u] 构造转置图G^T
(边反向) 按f[u]从大到小
对G^T做DFS 每轮DFS访问到的
顶点 = 一个强连通分支 还有未访问顶点? 得到所有强连通分支

💡 说明 :图 G G G的强连通分支与其转置图 G T G^T GT的强连通分支相同。两轮DFS,既解决了如何判断分支内强连通的问题,也解决了如何自然地将强连通分支一个个切下来的问题。

实际例子

复制代码
强连通分支分解示例:

有向图G:
a → b → c
↑   ↓   ↓
d ← e   f

第1步:对G做DFS,标记完成时刻
假设:f[a] = 6, f[b] = 5, f[c] = 4, f[d] = 3, f[e] = 2, f[f] = 1

第2步:构造转置图G^T(边反向)
a ← b ← c
↓   ↑   ↑
d → e   f

第3步:按f[u]从大到小对G^T做DFS
- 从a开始(f[a]最大):访问a,d → 强连通分支1:{a,d}
- 从b开始:访问b,e → 强连通分支2:{b,e}
- 从c开始:访问c → 强连通分支3:{c}
- 从f开始:访问f → 强连通分支4:{f}

结果:4个强连通分支

📝 本章总结

核心要点回顾

  1. 图的周游

    • 对一个图的每个顶点及每条边按某种顺序逐一访问
    • 为解决问题提供访问框架和顺序
  2. 广度优先搜索(BFS)

    • 按跳数距离逐层向外展开搜索
    • 使用队列(FIFO)存储灰色顶点
    • 复杂度: O ( n + m ) O(n+m) O(n+m)
    • 应用:最短路径(边权为1)、图的二着色
  3. 深度优先搜索(DFS)

    • 尽可能深地搜索,然后回溯
    • 使用递归或栈实现
    • 复杂度: O ( n + m ) O(n+m) O(n+m)
    • 应用:拓扑排序、强连通分支分解
  4. 拓扑排序

    • 对有向无环图(DAG)的顶点排序
    • 基于DFS,按完成时刻从大到小排序
    • 应用:课程安排、任务调度
  5. 连通分支分解

    • 无向图:对每个未访问顶点调用DFS/BFS
    • 有向图:两轮DFS(原图+转置图)
    • 应用:网络分析、社交网络

知识地图
图的周游算法 BFS
广度优先 DFS
深度优先 应用:最短路径
二着色 应用:拓扑排序
强连通分支 拓扑排序
DAG排序 强连通分支
两轮DFS

关键决策点

  • 何时使用BFS:需要按距离层次访问,找最短路径(边权为1)
  • 何时使用DFS:需要深度探索,拓扑排序,强连通分支
  • 如何找连通分支:对每个未访问顶点调用一次DFS/BFS
  • 如何找强连通分支:两轮DFS(原图标记时间戳,转置图按时间戳从大到小DFS)

💡 延伸学习:BFS和DFS是图算法的基础,掌握它们有助于我们:

  1. 理解更复杂的图算法(最短路径、最小生成树等)
  2. 解决实际问题(网络分析、社交网络、任务调度等)
  3. 为学习算法设计提供重要工具
相关推荐
monster000w3 小时前
大模型微调过程
人工智能·深度学习·算法·计算机视觉·信息与通信
小小晓.3 小时前
Pinely Round 4 (Div. 1 + Div. 2)
c++·算法
SHOJYS3 小时前
学习离线处理 [CSP-J 2022 山东] 部署
数据结构·c++·学习·算法
biter down3 小时前
c++:两种建堆方式的时间复杂度深度解析
算法
zhishidi3 小时前
推荐算法优缺点及通俗解读
算法·机器学习·推荐算法
WineMonk3 小时前
WPF 力导引算法实现图布局
算法·wpf
2401_837088504 小时前
双端队列(Deque)
算法
ada7_4 小时前
LeetCode(python)108.将有序数组转换为二叉搜索树
数据结构·python·算法·leetcode
奥特曼_ it4 小时前
【机器学习】python旅游数据分析可视化协同过滤算法推荐系统(完整系统源码+数据库+开发笔记+详细部署教程)✅
python·算法·机器学习·数据分析·django·毕业设计·旅游
仰泳的熊猫4 小时前
1084 Broken Keyboard
数据结构·c++·算法·pat考试