【图论】拓扑排序

🌟 一、什么是拓扑排序?

✅ 定义

拓扑排序是对有向无环图(DAG, Directed Acyclic Graph) 的顶点进行线性排序,使得对于图中的每一条有向边 u → v,在排序结果中,顶点 u 都出现在顶点 v 之前。

换句话说:所有“前驱”必须排在“后继”前面。


✅ 举个生活中的例子:课程学习顺序

假设你是一名大学生,有以下课程和先修要求:

课程先修课程
C1
C2C1
C3C1
C4C2, C3

我们可以画出一个有向图

C1 → C2
 ↓    ↓
 C3 → C4

箭头表示“必须先学”。

那么一个合法的拓扑排序是:C1 → C2 → C3 → C4C1 → C3 → C2 → C4,但不能是 C2 → C1 → ...,因为违反了依赖。


✅ 为什么必须是“有向无环图”?

  • 有向:因为依赖关系是有方向的(先修课 → 当前课)。
  • 无环:如果有环,比如 A → B → A,就形成死循环,无法排序。

❌ 举例:如果 C2 要求先修 C3,而 C3 又要求先修 C2,这就不可能完成,图中有环,无法拓扑排序。


🌟 二、图的表示方式

在 C++ 中,我们通常用邻接表(Adjacency List) 来表示图:

vector<vector<int>> adj;
  • adj[u] 是一个 vector,存储所有从 u 出发的边指向的顶点 v
  • 例如:adj[5] = {2, 0} 表示顶点 5 有边指向 2 和 0。

🌟 三、拓扑排序的核心思想:Kahn 算法(剥洋葱法)

🔍 算法流程(详细步骤)

我们用一个“任务依赖”图来演示:

5 → 2 → 3 → 1
↓         ↗
0       4 → 1

更清晰地表示依赖关系:

  • 2 依赖 5
  • 3 依赖 2
  • 1 依赖 3 和 4
  • 0 依赖 5
  • 1 依赖 4
步骤 1:计算每个顶点的入度(in-degree)
顶点入度说明
01被 5 指向
12被 3 和 4 指向
21被 5 指向
31被 2 指向
41题目中是 4→1,4 的入度是 0

我们根据 addEdge 调用:

g.addEdge(5, 2);  // 5→2
g.addEdge(5, 0);  // 5→0
g.addEdge(4, 0);  // 4→1

原代码中是:

g.addEdge(4, 0);
g.addEdge(4, 1);

所以:

  • 0 被 5 和 4 指向 → 入度=2
  • 1 被 4 和 3 指向 → 入度=2
  • 2 被 5 指向 → 入度=1
  • 3 被 2 指向 → 入度=1
  • 4 没有被指向 → 入度=0
  • 5 没有被指向 → 入度=0

所以初始入度:

顶点012345
入度221100

入度为0的顶点:4 和 5

步骤 2:将入度为0的顶点加入队列

队列 q = [4, 5](顺序取决于遍历顺序)

步骤 3:开始处理队列

我们一步步模拟:


🔁 第1轮:取出 4

  • 加入结果:topo = [4]

  • 遍历 4 的邻接点:adj[4] = {0, 1}

    • 处理 0:inDegree[0]-- → 2→1,仍不为0
    • 处理 1:inDegree[1]-- → 2→1,仍不为0

队列现在:[5]


🔁 第2轮:取出 5

  • 加入结果:topo = [4, 5]

  • 遍历 5 的邻接点:adj[5] = {2, 0}

    • 处理 2:inDegree[2]-- → 1→0 → 入队!
    • 处理 0:inDegree[0]-- → 1→0 → 入队!

队列现在:[2, 0]


🔁 第3轮:取出 2

  • 加入结果:topo = [4, 5, 2]

  • 遍历 2 的邻接点:adj[2] = {3}

    • 处理 3:inDegree[3]-- → 1→0 → 入队!

队列现在:[0, 3]


🔁 第4轮:取出 0

  • 加入结果:topo = [4, 5, 2, 0]
  • adj[0] 为空 → 无操作

队列现在:[3]


🔁 第5轮:取出 3

  • 加入结果:topo = [4, 5, 2, 0, 3]

  • 遍历 3 的邻接点:adj[3] = {1}

    • 处理 1:inDegree[1]-- → 1→0 → 入队!

队列现在:[1]


🔁 第6轮:取出 1

  • 加入结果:topo = [4, 5, 2, 0, 3, 1]
  • adj[1] 为空 → 结束

队列为空。


✅ 最终结果:[4, 5, 2, 0, 3, 1]

验证依赖关系:

  • 5→2:5 在 2 前 ✅
  • 5→0:5 在 0 前 ✅
  • 4→0:4 在 0 前 ✅
  • 4→1:4 在 1 前 ✅
  • 2→3:2 在 3 前 ✅
  • 3→1:3 在 1 前 ✅

全部满足!排序成功。


🌟 四、C++ 代码逐行详解

#include <iostream>
#include <vector>
#include <queue>
using namespace std;
  • vector:存储邻接表和结果
  • queue:用于 Kahn 算法的 BFS 过程
class Graph {
private:
    int V;
    vector<vector<int>> adj;   // adj[u] 存储 u 指向的所有 v
    vector<int> inDegree;      // inDegree[i] 表示顶点 i 的入度
  • V:顶点数量
  • adj:邻接表
  • inDegree:入度数组,初始化为0
public:
    Graph(int vertices) {
        V = vertices;
        adj.resize(V);           // 调整大小为 V 个 vector
        inDegree.resize(V, 0);   // 初始化入度为0
    }
  • 构造函数:初始化图结构
    void addEdge(int u, int v) {
        adj[u].push_back(v);     // u → v
        inDegree[v]++;           // v 的入度 +1
    }
  • 添加有向边 u → v
  • 注意:只影响 v 的入度
    vector<int> topologicalSort() {
        queue<int> q;
        vector<int> topoOrder;

        // 找出所有入度为0的顶点
        for (int i = 0; i < V; i++) {
            if (inDegree[i] == 0) {
                q.push(i);
            }
        }
  • 初始化队列:所有“没有依赖”的任务先入队
        while (!q.empty()) {
            int u = q.front();
            q.pop();
            topoOrder.push_back(u);  // u 可以执行了

            // 遍历 u 的所有后继 v
            for (int v : adj[u]) {
                inDegree[v]--;       // v 的入度减1(因为 u 已完成)
                if (inDegree[v] == 0) {
                    q.push(v);       // 如果 v 的依赖全完成,入队
                }
            }
        }
  • 核心循环:BFS 风格处理
  • 每完成一个任务 u,就通知所有依赖它的任务 v:“我完成了,你的依赖少一个”
        // 检查是否所有顶点都被排序
        if (topoOrder.size() != V) {
            cout << "图中存在环!" << endl;
            return {};
        }
        return topoOrder;
    }
  • 如果结果长度 ≠ 顶点数,说明有环(某些顶点永远无法入度为0)

🌟 五、为什么 Kahn 算法能检测环?

  • 在有环的图中,环上的顶点永远无法入度为0
  • 因为每个顶点都依赖环中前一个顶点,形成死锁。
  • 所以最终 topoOrder.size() < V,即可判断有环。

🌟 六、其他实现方式:DFS 法

原理:

  • 对每个未访问的顶点进行 DFS。
  • 当一个顶点的所有邻接点都被访问后,将该顶点压入栈
  • 最后栈中元素从顶到底就是拓扑序(或反过来输出)。
void dfs(int u, vector<bool>& visited, stack<int>& st) {
    visited[u] = true;
    for (int v : adj[u]) {
        if (!visited[v]) {
            dfs(v, visited, st);
        }
    }
    st.push(u);  // 所有后继处理完后再入栈
}

优点:代码简洁。
缺点:不如 Kahn 直观,且检测环需要额外标记(如递归栈)。


🌟 七、常见变种问题

  1. 输出字典序最小的拓扑序?

    • 使用优先队列(最小堆) 代替普通队列。
  2. 判断是否有唯一拓扑序?

    • 如果在任何时刻队列中有多个元素,则拓扑序不唯一。
  3. 求最长路径(关键路径)?

    • 在拓扑排序基础上进行动态规划。

🌟 八、总结:关键点

要点说明
适用图有向无环图(DAG)
核心思想剥洋葱:从入度为0的节点开始
数据结构队列 + 邻接表 + 入度数组
时间复杂度O(V + E)
空间复杂度O(V + E)
结果唯一性不唯一,取决于入队顺序
环检测结果长度 < V → 有环

✅ 最后一句话总结:

拓扑排序 = 找出一个满足所有“先决条件”的执行顺序。

就像做菜:先买菜 → 洗菜 → 切菜 → 炒菜 → 上桌。每一步都依赖前一步,拓扑排序就是帮你安排这个顺序。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值