Skip to content
NotesAlgorithm

算法设计与分析课程笔记,覆盖分治、图算法、最短路与复杂度基础。

Graph

一、 图论核心定义与存储结构 (Core Definitions & Graph Representation)

在正式探讨图算法之前,我们需要建立严密的图论数学模型与物理存储结构。

1.1 核心数学定义

图可以形式化地表示为 G(V,E),其中:

  • 顶点集合 (Vertices, V):代表具体的目标对象或实体。
  • 边集合 (Edges/Arcs, E):代表顶点之间的连接关系。

根据边的方向性,图主要分为两大类:

  • 有向图 (Directed Graph):边(亦称弧 Arcs)具有方向性。若存在弧 (u,v),则表示只能从顶点 u 走到顶点 v
  • 无向图 (Undirected Graph):边没有方向性。边 (u,v) 等价于可以从 uv,也可以从 vu。从理论视角来看,无向图是一种特殊的有向图,其中每条无向边 (u,v) 都可以拆分为两条有向弧 (u,v)(v,u)。在无向图中,边的数量 E 满足界限 $$0 \le E \le \frac{|V|(|V|-1)}{2} = O(|V|^2)$$。

1.2 图的存储机制

图的常见物理存储结构主要分为两种:

  1. 邻接矩阵 (Adjacency Matrix) 使用一个 |V|×|V| 的二维数组(矩阵)A 来表示图。对于矩阵中的元素 A[i][j]

    • (i,j)E,则 A[i][j]=1
    • (i,j)E,则 A[i][j]=0
    • 空间复杂度:$$O(|V|^2)$$。
  2. 邻接表 (Adjacency List) 对于每个顶点 uV,维护一个链表 adj[u],该链表存储了 u 的所有直接邻居节点。

    • 节点结构通常包含目标顶点 v 以及指向下一个邻居的指针 next
    • 空间复杂度:$$O(|V| + |E|)$$。在稀疏图中,邻接表的空间利用率远超邻接矩阵。

二、 深度优先搜索 (Depth First Search, DFS)

深度优先搜索(DFS)是图论中最基本且最强大的遍历算法之一,主要用于解决连通性(Reachability)及连通分量探测等问题。

2.1 核心算法思想与连通性分析

连通性问题旨在探究:给定图 G(V,E) 和起点 u,能否找到一条路径到达终点 v? 基本的探索(Explore)策略是:如果 vu 的邻居(在 u 的邻接表中),则 v 可达;进而,所有在 v 的邻接表中的顶点也是可达的。

为了防止在图中遇到环(Cycle)导致死循环,我们需要在算法引入状态标记 (Marking) 机制:当到达某个顶点时将其标记,后续不再重复探索已标记的顶点。

2.2 DFS 伪代码重构

text
// 算法:深度优先搜索 (DFS)
// 输入:图 G(V, E)
Function DFS(G):
    // 初始化所有顶点为未访问状态
    for each v in V:
        marked[v] = false
    
    // 遍历所有顶点,若未被访问则发起探索
    for each v in V:
        if marked[v] == false:
            Explore(v)

// 辅助函数:从特定顶点持续探索
Function Explore(v):
    marked[v] = true
    for each u in adj[v]: // 遍历 v 的所有邻接点 (v, u) ∈ E
        if marked[u] == false:
            Explore(u)

注:此算法在无向图中执行时,单次 Explore(v) 会精确找出包含 v 的整个连通分量 (Connected Component)。由于 Explore 的调用总次数等于图中所包含的连通分量的数量,DFS可以直接被用于计数和提取图的连通分量。

2.3 复杂度分析

  • 时间复杂度:$$O(|V| + |E|)$$ 证明思路:在 DFS(G) 函数中,主循环遍历每个顶点最多 |V| 次。每个顶点 v 仅在未被标记时触发一次 Explore(v)。在 Explore 的过程中,每条边(或弧)最多被检查一次(有向图)或两次(无向图),即对于所有顶点的邻接表遍历的总开销恰好对应整个图的边数 |E|。因此,整体时间复杂度为严格的线性关系 $$O(|V| + |E|)$$。
  • 空间复杂度:$$O(|V|)$$(递归调用栈深度及标记数组的空间开销)。

2.4 DFS 树与环的检测

在 DFS 遍历的过程中,我们可以根据顶点探索的先后关系生成一棵DFS树 (DFS Tree)。 在无向图中,边被划分为两类:

  • 树边 (Tree edges):DFS探索过程中实际走过的边。
  • 回边 (Back edges):指向 DFS 树中祖先节点的边。

核心定理(环的检测):无向图 G 存在环,当且仅当其 DFS 树中存在回边。 正确性证明框架

  • :若 DFS 树存在回边 (z,a),由于 az 的祖先,树中必然已有一条从 az 的树边路径,此路径加上回边 (z,a) 即构成了一个环。
  • :若图 G 存在环,在 DFS 遍历该环时,环中必有一个节点 a 被最先探索,沿着环探索到最后一个节点 z 时,必然发现 a 已被标记,此时 (z,a) 形成回边。

拓展:在有向图中,DFS 树的边更加丰富,分为树边 (Tree edges)、前向边 (Forward edges)、回边 (Back edges) 与 交叉边 (Cross edges)。同理,有向图无环的充要条件是 DFS 树无回边。


三、 有向无环图 (DAG) 与 拓扑排序 (Topological Ordering)

拓扑排序常用于具有前置依赖关系的任务调度(如课程先修要求)。

3.1 核心概念定义

  • 有向无环图 (Directed Acyclic Graph, DAG):一个不包含任何有向环的有向图。
  • 拓扑排序:将 DAG 中的所有顶点排成一个线性序列,使得对于图中的任意有向边 (u,v)u 在序列中总是出现在 v 之前。若图中存在环,则必然会产生矛盾(如 1341),即非 DAG 图无法进行拓扑排序。

关键引理:任何一个 DAG 必定存在至少一个“尾部顶点 (Tail)”(即没有任何出边的顶点)。 证明思路:从任意顶点 v 出发,不断顺着出边向下遍历。因为图是有限的且不包含环(不能走回头路),这条遍历路径最终必须停在一个没有出边的顶点上,该顶点即为 Tail。

3.2 朴素算法思想

朴素的拓扑排序算法逻辑是:在 DAG 中寻找到一个 Tail 顶点 将其放置在拓扑序列的末尾 将该顶点及其相关的边从图中删除 重复上述步骤直到图为空。 时间复杂度:每轮寻找 Tail 需要遍历图,总共需要 |V| 轮,最坏情况下时间复杂度达到 $$O(|V|^2)$$。

3.3 基于 DFS 的最优拓扑排序算法

为了进一步优化到线性时间,我们可以利用 DFS 在有向图中的运行特性:记录顶点的完成时间 (Finish Time)

算法机制

在 DFS 过程中,维护一个全局计时器 time。对于每个顶点,记录它被开始探索的时间 start[v],以及它的所有出边探索完毕、准备退出递归时的时间 finish[v]

text
// 算法:基于 DFS 的拓扑排序
// 全局变量:time = 0
Function Explore(v):
    start[v] = time; time++
    marked[v] = true
    for each u in adj[v]: // 遍历出边 (v, u)
        if marked[u] == false:
            Explore(u)
    finish[v] = time; time++
    
    // 核心优化:在顶点完成探索时,直接将其压入拓扑序列头部
    // 这保证了高 finish time 的节点排在序列前方
    Prepend v to topological_order_list

算法正确性与复杂度

正确性定理:将顶点按 finish 时间降序排列,即得到一个合法的拓扑排序。 证明过程:假设存在一条有向边 (u,v)。我们需要证明一定有 finish[u]>finish[v]

  • 若在探索 u 时发现边 (u,v):如果 v 尚未访问,则 v 成为 u 在 DFS 树中的后代(树边),v 的递归必定在 u 退出之前结束,故 finish[u]>finish[v];如果 v 已访问但已完成(交叉边或前向边),则 v 早就拿到了自己的 finish 时间,故依然有 finish[u]>finish[v]
  • (注意:在 DAG 中不存在回边,因此不存在 v 已访问且仍在活跃栈中的情况。)

时间复杂度:算法仅对原本的 DFS 增加常数时间的链表头部插入操作。整体时间复杂度保持不变,为严格的 $$O(|V| + |E|)$$。


四、 强连通分量 (Strongly Connected Components, SCC)

在有向图的连通性分析中,“相互可达性”是一个关键的度量维度。

4.1 核心数学定义与性质

  • 强连通性:如果对于顶点对 (u,v),既存在 uv 的路径,又存在 vu 的路径,则称 u,v 是强连通的。
  • 强连通分量 (SCC):是顶点集 V 的一个极大子集 (Maximal subset) CV,满足 C 中任意两个顶点互相强连通。
  • 传递性 (Transitivity):若 ab 强连通,bc 强连通,则 ac 必定强连通。在此性质下,若集合 C 为 SCC 且节点 b 能够与 C 中的任意节点 a 强连通,则 C{b} 也构成 SCC。
  • 分割定理:任意有向图的全部 SCC (C1,C2,...,Cm) 构成对顶点集 V 的一个严格划分 (Partition)。即 i=1mCi=V,且 CiCj= (ij)
    • 反证法证明不相交性:假设顶点 v 同时属于 C1C2。由传递性可知,C1C2 中的所有节点都可通过 v 相互联通,这意味着 C1C2 是一个更大的强连通集合,这与 C1,C2 是极大的分量相矛盾。

4.2 SCC 超级节点图 (SCC Graph) 与核心难题

如果我们将每个 SCC 视为一个“超级节点 (Super Node)”,压缩图中相关的边,那么新生成的 SCC 图必定是一个 DAG(有向无环图)

  • 证明:如果 SCC 图中存在循环 C1C2CkC1,则说明这 k 个超级节点内部的所有原本顶点均相互可达,它们本应合并成同一个 SCC,与定义矛盾。

计算难题:我们能否直接使用单次 DFS 提取出所有的 SCC? 不行。如果在普通的有向图中从任意节点执行 Explore(),当搜索越界通过“出向边 (Outgoing edges)”进入到下一个 SCC 时,会错误地将多个不同的 SCC 打包成一块返回。为了避免这个问题,我们必须且只能从 SCC 图(即那个 DAG 图)的尾部分量 (Tail SCC) 的内部节点开始发起 DFS。因为从 Tail SCC 出发没有任何出向边通往其他 SCC,Explore 操作正好完美收敛于当前 SCC。

4.3 Kosaraju 算法核心逻辑与深度释疑 (The Super Plan)

在试图提取有向图中的强连通分量 (SCC) 时,如果我们从任意节点 v 开始调用 Explore(v),最大的困境在于**“搜索越界 (Leakage)”**。由于出向边 (Outgoing edges) 的存在,DFS 会顺着出向边跨越不同的 SCC,导致最终提取出的集合错误地包含了多个 SCC。

为了解决这个问题,如果我们把图中的每个 SCC 视作一个“超级节点 (Super Node)”,压缩后的 SCC 图必然是一个有向无环图 (DAG)。若能找到这个 DAG 的尾部分量 (Tail SCC),从其内部的任意顶点发起 Explore(),因为没有任何通向外部 SCC 的出向边,搜索就会完美地封闭在该 SCC 内部。

核心困境 1:为什么不能在原图 G 上找 min(finish) 作为 Tail SCC?

在拓扑排序中,我们知道完成时间最小的节点是尾部节点。那么在一般有向图中,原图 G 上的第一轮 DFS 产生的完成时间最小的顶点,是否一定位于 Tail SCC 中? 绝对不是。 在包含环的一般有向图中,顶点 v 获得极小的 finish 时间,极有可能是因为它陷入了 DFS 遍历顺序造成的**“假死胡同”**。例如,它的出边恰好是一条指向其 DFS 树祖先的“回边 (Back edge)”,或者它跨越了交叉边但目标已被访问。它被迫提前结束递归拿到了最小 finish 时间,但这绝不意味着它在全局拓扑中没有通往其他 SCC 的出向路径。因此,最小完成时间高度依赖于 DFS 的随机起点和邻接表顺序,极端脆弱,无法用于定位 Tail SCC

核心定理:最大完成时间必定对应 Head SCC

既然最小完成时间失效了,那最大完成时间呢? 最大完成时间具有绝对的强壮性! 无论 DFS 的外部大循环从哪个顶点开始,全图具有最大 finish 时间的节点,必定隶属于 Head SCC (即没有任何外部入向边的超级节点)

严密的反证法证明 (Proof by Contradiction): 假设顶点 u 拥有全图最大的完成时间且位于 SCC1,但 SCC1 不是 Head SCC。 这意味着必然存在另一个 SCC2 中的顶点 v,满足存在一条边从 SCC2 指向 SCC1,即 v 能够到达 u

  1. Claim 1: u 必定比 v 先开始被探索 (start[u]<start[v])。因为如果 v 先开始探索,由于 v 能够到达 uu 必然成为 v 在 DFS 树中的后代,那么 u 的递归必定在 v 之前结束,即 finish[u]<finish[v],这与 u 拥有最大完成时间的假设矛盾。
  2. Claim 2: uv 的祖先。既然 u 先于 v 探索,且 finish[u]>finish[v],由 DFS 的括号定理推导,v 必然在 u 的活动区间内被访问,因此 uv 的祖先。
  3. Claim 3: u 能够到达 v。由于祖先必定有一条树边路径通向后代,故存在 uv 的路径。 得出矛盾! 前提已知 v 能够到达 u,现在又推导出 u 能够到达 v。根据强连通性的定义,uv 必须属于同一个 SCC,这与 SCC1SCC2 是两个不同的连通分量矛盾! 因此,拥有最大完成时间的顶点必定位于 Head SCC 中

绝妙转换 (The Amazing Idea):利用反向图 GR 寻找 Tail

我们手头的定理是:“最大完成时间对应 Head SCC”。但我们真正需要的是 Tail SCC(以便封闭探索)。 为了桥接这一供需矛盾,Kosaraju 算法引入了绝妙的拓扑映射: 原图 G 的 Tail SCC,在物理结构上完美等价于反向图 GR 的 Head SCC! 因此,我们只需要在反向图 GR 上寻找 Head SCC(即寻找最大 finish 时间的顶点),就能精准定位原图 G 的 Tail SCC。

核心困境 2:DFS 如何保证绝对不漏掉反向图 GR 中的任何顶点?

GR 上求最大 finish 时间时,如果图不连通或边方向古怪,会不会有节点没被遍历到? 不会。这得益于 DFS 主函数 (Master Function) 的全局外层循环设计

text
Function dfs(G):
    for each v in V:
        if marked[v] == false:
            explore(v)

无论是原图 G 还是反向图 GR,最外层的 for each v in V 循环保证了算法会强制对每一个未访问的节点主动发起搜索。任何孤立的节点或独立的连通分量,都会在外层循环枚举到它时,触发新的 explore(v) 成为一棵新的 DFS 树的根。因此,反图 GR 上的每一个点都会被绝对完备地访问,并被精准分配到一个 finish 时间


4.4 Kosaraju 算法完整执行流 (The Super Plan) 与正确性总结

基于上述严密的推导,Kosaraju 算法的执行流 (Super Plan) 如下:

  1. 构建反向图:构造 GR,时间复杂度 O(|V|+|E|)
  2. 第一轮 DFS (GR):在反向图 GR 上运行全局 DFS。利用全局外层循环确保所有顶点被访问,并维护一个按照 finish 时间降序排列的顶点列表。
  3. 第二轮 DFS (G):在原图 G 上,严格按照上述降序列表的顺序执行全局 DFS。
    • 按照降序列表,当前未被访问的第一个顶点,必然拥有当前残存图中的最大 finish 时间。
    • 根据前述定理,该顶点必定位于残存 GR 的 Head SCC,即残存原图 G 的 Tail SCC。
    • 在此顶点上发起 Explore(),搜索会完美封闭在当前 SCC 内部。当 Explore() 返回时,所有刚刚被 marked 的顶点共同构成一个完整的 SCC。

广义引理 (General Lemma) 支持: 如果 SCC1 在反向图 GR 中能到达 SCC2,那么必有 maxvSCC1finish[v]>maxuSCC2finish[u]。 正是这个引理,确保了我们在第二轮 DFS 中,只要一直取出 finish 时间最大的未访问节点,我们就永远是从当前残存图的最安全尾部 (Tail SCC) 进行剥离,从而以 O(|V|+|E|) 的最优线性时间复杂度,完美拆解出图中的所有强连通分量。