GESP 8级

拓扑排序

8级 · 高级数据结构与DP
拓扑排序是对有向无环图(DAG)的顶点进行排序,使得对于图中的每条有向边 (u → v),u 都排在 v 的前面。简单说就是:安排一个执行顺序,让所有"依赖关系"都得到满足。
💡 这是什么?
生活比喻:想象你在准备一顿大餐,每道菜都有"前置工序":
• 做炒饭 → 先要煮饭
• 做蛋炒饭 → 先要煮饭 + 打蛋
• 做汤 → 先要烧水
• 上菜 → 先要做完炒饭和汤

这些"先要"就是依赖关系。拓扑排序就是帮你安排一个合理的做菜顺序,让每道菜需要的前置工序都在它之前完成。

关键概念:
• 有向图:边有方向,A→B 表示"A 是 B 的前置"
• DAG(有向无环图):有向图中没有环。如果有环(A→B→C→A),就不存在合法的拓扑排序
• 入度(in-degree):一个节点有多少条边指向它。入度为0意味着没有前置依赖,可以最先执行
🌟 为什么重要?
• 任务调度:操作系统进程调度、编译器依赖分析
• 课程安排:先修课程必须在后面课程之前学
• 考试必考:GESP 8级高频考点,代码短但思想重要
• DP优化:在 DAG 上做动态规划时,需要拓扑排序来确定 DP 顺序
• 检测环:如果拓扑排序结果的长度 < 顶点数,说明图中有环
📋 前置知识(学这个之前你需要知道)
1. 有向图(GESP 4级):理解有向边、邻接表
2. 入度概念:一个节点被多少条边指向
3. BFS 广度优先搜索(GESP 3级):Kahn 算法就是基于 BFS 的
4. 队列(GESP 1级):queue 的使用
📐 Kahn 算法(BFS版拓扑排序)步骤
① 统计所有节点的入度 in[i]
② 把所有入度为 0 的节点加入队列
③ 取出队首节点 u,加入结果序列
④ 对 u 的每个邻居 v:in[v]--
   如果 in[v] == 0,把 v 入队
⑤ 重复③④直到队列为空
⑥ 如果结果序列长度 < n,说明有环
💻 完整代码(带详细注释)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int N = 100010;

vector<int> adj[N];  // 邻接表:adj[u] 存 u 指向的所有节点
int in[N];            // in[i] = 节点 i 的入度

// 拓扑排序:返回排序结果,如果结果长度 < n 说明有环
vector<int> topoSort(int n) {
    queue<int> q;  // 存放入度为0的节点

    // 第1步:找到所有入度为0的节点,入队
    for (int i = 1; i <= n; i++) {  // 注意:节点编号从1开始
        if (in[i] == 0) {
            q.push(i);  // 没有前置依赖,可以最先执行
        }
    }

    vector<int> order;  // 存放拓扑排序结果

    // 第2步:BFS 过程
    while (!q.empty()) {
        int u = q.front();  // 取出一个入度为0的节点
        q.pop();
        order.push_back(u);  // 加入结果序列

        // 把 u "去掉"后,它指向的所有节点入度-1
        for (int v : adj[u]) {
            in[v]--;       // u 被处理了,v 的一个前置完成了
            if (in[v] == 0) {
                q.push(v);  // v 的所有前置都完成了,可以执行了
            }
        }
    }

    return order;  // 如果 order.size() < n,说明图中有环
}

int main() {
    int n, m;  // n 个节点,m 条边
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;  // u → v 表示 u 是 v 的前置
        adj[u].push_back(v);  // u 指向 v
        in[v]++;              // v 的入度+1
    }

    vector<int> result = topoSort(n);

    if (result.size() < n) {
        cout << "图中有环,无法拓扑排序!" << endl;
    } else {
        for (int x : result)
            cout << x << " ";
        cout << endl;
    }
    return 0;
}
🔍 算法执行过程举例
5个节点的有向图:
1→2, 1→3, 2→4, 3→4, 4→5

第1步:统计入度
   in[1]=0, in[2]=1, in[3]=1, in[4]=2, in[5]=1
第2步:入度为0的节点入队 → 队列: [1]
第3步:取出1,order=[1]
   1→2: in[2] 从1变0 → 入队
   1→3: in[3] 从1变0 → 入队
   队列: [2, 3]
第4步:取出2,order=[1,2]
   2→4: in[4] 从2变1
   队列: [3]
第5步:取出3,order=[1,2,3]
   3→4: in[4] 从1变0 → 入队
   队列: [4]
第6步:取出4,order=[1,2,3,4]
   4→5: in[5] 从1变0 → 入队
   队列: [5]
第7步:取出5,order=[1,2,3,4,5]
结果:合法拓扑序,5个节点全部入列
⏱ 复杂度分析
时间复杂度:O(n + m),n 是节点数,m 是边数
每个节点入队出队各一次,每条边被访问一次

拓扑排序的结果不唯一!上面的例子中 [1,3,2,4,5] 也是合法的。
⚠️ 易错点
1. 混淆有向/无向图 → 拓扑排序只能用于有向图。无向图没有入度的概念。
2. 忘记检查环 → 一定要检查 result.size() < n,有环时拓扑排序不完整。
3. 入度统计错误 → 加边时 in[v]++ 不能忘。初始化时要统计所有边。
4. 节点编号从0还是从1 → 注意题目要求,循环时要对应。
5. 多个入度为0的节点 → 它们的顺序可以任意,结果不唯一是正常的。
🎯 练习建议
入门练习:
• 洛谷 P1347 排序(判断能否拓扑排序 + 检测环)
• 洛谷 P4017 [USACO08DEC]食物链(拓扑排序 + 计数)

进阶练习:
• 洛谷 P1113 杂物(DAG 上的 DP + 拓扑排序)
• 洛谷 P2015 二叉苹果树(树形DP的前置)

学习方法:
① 先在纸上画一个小图,手动模拟入度变化的过程
② 代码很短(核心只有10行左右),要能默写
③ 记住:拓扑排序是很多高级算法的基础(DAG DP、关键路径等)