拓扑排序算法总结 C++实现

拓扑排序:从“先修课”说起

想象你是大一新生,看着培养方案里错综复杂的课程依赖关系:

  • 学《数据结构》前得先修《C语言》
  • 学《算法设计》前得先修《数据结构》和《离散数学》
  • 《离散数学》没有先修要求
  • 《C语言》也没有先修要求

你面临一个问题:如何排出一个合理的上课顺序,使得每门课的先修课都在它之前上?

这就是拓扑排序要解决的核心问题——将有向无环图(DAG)中所有顶点排成一个线性序列,使得对每条边 u→v,u 在序列中都出现在 v 之前。

一、理论基石

拓扑排序的定义

对一个有向无环图 G 进行拓扑排序,是将 G 中所有顶点排成一个线性序列,使得图中任意一对顶点 u 和 v,若存在边 u→v,则 u 在线性序列中出现在 v 之前。

重要性质

  • 拓扑排序不是唯一的(可能存在多种合理顺序)
  • 图中存在环时,无法完成拓扑排序(想想:鸡生蛋,蛋生鸡,怎么排序?)
  • 拓扑排序是图是否有环的判定工具

二、两大经典算法

2.1 Kahn 算法(BFS 思路,更直观)

核心思想:不断寻找入度为 0 的顶点,将其输出并移除。

就像选课:哪门课没有先修课要求,就可以先上;上完后,依赖它的课的“未满足先修课数量”就减 1。

算法步骤

  1. 统计所有顶点的入度
  2. 将所有入度为 0 的顶点加入队列
  3. 当队列非空时:
    • 取出队首顶点 u,加入结果序列
    • 遍历 u 的所有邻接点 v,将 v 的入度减 1
    • 若 v 的入度变为 0,则将 v 入队
  4. 如果结果序列长度等于顶点数,则排序成功;否则说明图中有环

    详细步骤
  • 初始状态
    • 入度:1:0, 2:1, 3:1, 4:2
    • 队列:[1](只有 1 入度为 0)
    • 拓扑序列:[]
  • 步骤 1:处理顶点 1
    • 1 出队 → 序列:[1]
    • 1 的邻接点 2、3 入度各 -1
    • 新入度:2:0, 3:0, 4:2
    • 队列:[2, 3]
  • 步骤 2:处理顶点 2
    • 2 出队 → 序列:[1, 2]
    • 2 的邻接点 4 入度 -1 → 4:1
    • 队列:[3]
  • 步骤 3:处理顶点 3
    • 3 出队 → 序列:[1, 2, 3]
    • 3 的邻接点 4 入度 -1 → 4:0
    • 队列:[4]
  • 步骤 4:处理顶点 4
    • 4 出队 → 序列:[1, 2, 3, 4]
    • 队列为空,结束
  • 结果
    • 拓扑序列:[1, 2, 3, 4](也可能是 [1, 3, 2, 4],取决于队列顺序)

C++ 实现

#include <iostream>
#include <vector>
#include <queue>
using namespace std;

vector<int> topologicalSortKahn(int n, vector<vector<int>>& edges) {
    // 建图 + 统计入度
    vector<vector<int>> adj(n);
    vector<int> inDegree(n, 0);
    
    for (const auto& edge : edges) {
        int u = edge[0], v = edge[1]; // 边 u -> v
        adj[u].push_back(v);
        inDegree[v]++;
    }
    
    // 将所有入度为0的顶点入队
    queue<int> q;
    for (int i = 0; i < n; i++) {
        if (inDegree[i] == 0) {
            q.push(i);
        }
    }
    
    vector<int> result;
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        result.push_back(u);
        
        // 遍历所有邻接点
        for (int v : adj[u]) {
            inDegree[v]--;
            if (inDegree[v] == 0) {
                q.push(v);
            }
        }
    }
    
    // 如果结果数量不等于顶点数,说明有环
    if (result.size() != n) {
        return {}; // 返回空数组表示存在环
    }
    
    return result;
}

复杂度分析:O(V + E),每个顶点和边各处理一次。

2.2 DFS 算法(递归思路,更巧妙)

核心思想:对图进行深度优先遍历,顶点在“完成探索”时加入结果,最后逆序。

一个顶点的“完成”意味着它的所有后继都已被访问。那么后完成的顶点应该排在前面——这正是“逆后序”就是拓扑序的原因。

算法步骤

  1. 对每个未访问的顶点执行 DFS
  2. 在 DFS 中,先递归访问所有邻接点
  3. 递归返回后,将当前顶点加入结果栈
  4. 最后将栈中元素依次弹出,得到拓扑序列
  5. 如果在 DFS 过程中遇到“正在访问”的顶点,说明存在环

    详细步骤
    • DFS (1) → 标记 1 = 访问中 → 递归 2
    • DFS (2) → 标记 2 = 访问中 → 递归 4
    • DFS (4) → 无邻接 → 标记 4 = 已访问 → 栈:[4]
    • 回溯 2 → 标记 2 = 已访问 → 栈:[4, 2]
    • 回溯 1 → 递归 3
    • DFS (3) → 递归 4(已访问)→ 标记 3 = 已访问 → 栈:[4, 2, 3]
    • 回溯 1 → 标记 1 = 已访问 → 栈:[4, 2, 3, 1]
    • 栈逆序:[1, 3, 2, 4](拓扑序)

C++ 实现

#include <iostream>
#include <vector>
#include <stack>
using namespace std;

bool dfs(int u, vector<vector<int>>& adj, vector<int>& visited, stack<int>& st) {
    visited[u] = 1; // 标记为正在访问
    
    for (int v : adj[u]) {
        if (visited[v] == 1) {
            return false; // 发现后向边,存在环
        }
        if (visited[v] == 0) {
            if (!dfs(v, adj, visited, st)) {
                return false;
            }
        }
    }
    
    visited[u] = 2; // 标记为已完成
    st.push(u);    // 完成时入栈
    return true;
}

vector<int> topologicalSortDFS(int n, vector<vector<int>>& edges) {
    vector<vector<int>> adj(n);
    for (const auto& edge : edges) {
        adj[edge[0]].push_back(edge[1]);
    }
    
    vector<int> visited(n, 0); // 0=未访问, 1=访问中, 2=已完成
    stack<int> st;
    
    for (int i = 0; i < n; i++) {
        if (visited[i] == 0) {
            if (!dfs(i, adj, visited, st)) {
                return {}; // 存在环
            }
        }
    }
    
    vector<int> result;
    while (!st.empty()) {
        result.push_back(st.top());
        st.pop();
    }
    return result;
}

三、两种算法对比

特性Kahn (BFS)DFS
数据结构队列栈(隐式递归)
直观程度很直观(不断消去无依赖的节点)稍抽象(后序遍历的逆)
环检测最后判断结果长度递归中直接检测后向边
字典序最小改用优先队列即可实现难以实现字典序
应用场景任务调度、课程安排依赖分析、编译顺序

四、常见应用场景

  1. 课程安排系统:如开篇例子,确定上课顺序
  2. 构建系统:Make、Maven 等工具决定编译顺序
  3. 包管理器:解析软件包依赖关系
  4. 任务调度:有依赖关系的任务执行顺序
  5. 死锁检测:检测进程资源分配图中的环

五、易错点与调试技巧

  1. 混淆入度计算出度:Kahn 算法使用的是入度
  2. 图不连通:两种算法都要确保遍历所有顶点
  3. 结果长度不等于顶点数:务必检查,这是环的明确信号
  4. DFS 三种状态要分清:未访问(0)、访问中(1)、已完成(2),尤其注意“访问中”碰到“访问中”才是环

六、完整测试示例

int main() {
    // 0 -> 1 -> 3
    // 0 -> 2 -> 3
    int n = 4;
    vector<vector<int>> edges = {{0,1}, {0,2}, {1,3}, {2,3}};
    
    // 测试 Kahn 算法
    vector<int> result1 = topologicalSortKahn(n, edges);
    cout << "Kahn 算法结果: ";
    for (int v : result1) cout << v << " ";  // 可能的输出: 0 1 2 3
    cout << endl;
    
    // 测试 DFS 算法
    vector<int> result2 = topologicalSortDFS(n, edges);
    cout << "DFS 算法结果: ";
    for (int v : result2) cout << v << " ";  // 可能的输出: 0 2 1 3
    cout << endl;
    
    // 测试有环的情况
    vector<vector<int>> cyclicEdges = {{0,1}, {1,2}, {2,0}};
    vector<int> result3 = topologicalSortKahn(3, cyclicEdges);
    if (result3.empty()) {
        cout << "检测到环!无法进行拓扑排序。" << endl;
    }
    
    return 0;
}

七、总结

拓扑排序的精髓可以概括为:

  • 对象:有向无环图(DAG)
  • 目标:找到一种不违反依赖关系的线性顺序
  • 方法一:贪心地移除入度为 0 的节点(Kahn)
  • 方法二:DFS 后序遍历的逆序(递归)
  • 复杂度:O(V + E),线性时间

掌握拓扑排序,你就掌握了处理“依赖关系”的利器。无论是刷题还是做项目,这种“先来后到”的智慧都会频繁派上用场。

记住那个选课的比喻——拓扑排序就是在告诉你:先做什么,后做什么,顺序不能乱。

拓扑排序的题型识别信号

当题目中出现以下关键词或模式时,就应该立刻联想到拓扑排序:

信号一:显式的“依赖关系”或“先后顺序”

  • “A 必须在 B 之前完成”
  • “学习课程 A 之前需要先学习课程 B”
  • “任务之间有前置依赖”
  • 题目直接给了 prerequisitesdependencies 这样的参数名

经典题目

  • LeetCode 207. 课程表(判断能否完成所有课程)
  • LeetCode 210. 课程表 II(输出一种上课顺序)
  • LeetCode 1136. 平行课程(同时上多门课,问最少学期数)

信号二:“排序”+“有向关系”

  • 给出一组偏序关系(如 A > B),要求给出一个符合所有关系的全序排列
  • 字符串排序、数字排序中隐藏着偏序关系

经典题目

  • LeetCode 269. 火星词典(根据已排序的单词推导字母顺序)
  • LeetCode 444. 序列重建(判断序列是否是唯一的拓扑序)

信号三:图中找环/判断可行性

  • 判断一个有向图是否包含环
  • 判断一系列依赖条件是否矛盾

经典题目

  • LeetCode 802. 找到最终的安全状态(反向图+拓扑排序找不在环上的节点)

信号四:层级/深度/并行度问题

  • “最多可以同时做多少件事”
  • “最少需要多少步/多少轮完成”
  • 拓扑排序天然有“层”的概念

经典题目

  • LeetCode 310. 最小高度树(多源 BFS 拓扑剥洋葱)
  • LeetCode 1203. 项目管理(双层拓扑排序)

题型分类与解题模板

类型一:可行性判断(判环)

题型特征:问“能否完成”,返回 true/false

解题思路:跑一遍拓扑排序,看结果长度是否等于节点数

bool canFinish(int n, vector<vector<int>>& prerequisites) {
    // 建图 + 统计入度 + Kahn算法
    // return result.size() == n;
}

代表题目:LC 207 课程表


类型二:求任一拓扑序

题型特征:问“给出一种可能的顺序”

解题思路:标准拓扑排序,返回结果数组

vector<int> findOrder(int n, vector<vector<int>>& prerequisites) {
    // 标准Kahn算法,返回result
}

代表题目:LC 210 课程表 II


类型三:求字典序最小的拓扑序

题型特征:有多种可能时,要求字典序最小的那种

解题思路:将 Kahn 算法中的普通队列换成小顶堆(优先队列)

priority_queue<int, vector<int>, greater<int>> pq; // 小顶堆
// 每次取编号最小的入度为0的节点

代表题目:某些课程表的变形题,要求输出字典序最小的上课顺序


类型四:求拓扑序中的层级数(并行度/最小轮数)

题型特征:“每学期可以同时上多门课,最少需要几学期?”
本质:求拓扑序的最大深度/层数

解题思路:BFS 时记录层数

int minimumSemesters(int n, vector<vector<int>>& relations) {
    // 标准建图+入度统计
    queue<int> q;
    for (int i = 0; i < n; i++) {
        if (inDegree[i] == 0) q.push(i);
    }
    
    int semesters = 0;
    int taken = 0;
    while (!q.empty()) {
        int size = q.size();  // 当前层的大小
        for (int i = 0; i < size; i++) {
            int course = q.front(); q.pop();
            taken++;
            for (int next : adj[course]) {
                if (--inDegree[next] == 0) q.push(next);
            }
        }
        semesters++;  // 每处理完一层,学期+1
    }
    return taken == n ? semesters : -1;
}

代表题目:LC 1136 平行课程


类型五:从有序序列反推拓扑关系

题型特征:给你一个已排序的结果(如排好序的单词列表),反推字母之间的顺序关系

解题思路

  1. 比较相邻元素,找出第一对不同的字符 → 建立偏序边
  2. 建图后跑拓扑排序
string alienOrder(vector<string>& words) {
    // 1. 从相邻单词中提取边
    for (int i = 0; i < words.size() - 1; i++) {
        string w1 = words[i], w2 = words[i+1];
        int len = min(w1.size(), w2.size());
        for (int j = 0; j < len; j++) {
            if (w1[j] != w2[j]) {
                adj[w1[j]-'a'].insert(w2[j]-'a'); // 建立边
                break;
            }
        }
    }
    // 2. 拓扑排序
}

代表题目:LC 269 火星词典(会员题,但很经典)


类型六:反向图 + 拓扑排序(找安全节点/不在环上的节点)

题型特征:找“最终会到达终点/不会陷入循环”的节点

解题思路

  1. 建立反向图
  2. 从终点(出度为0的节点)开始反向拓扑排序
  3. 能被访问到的就是安全节点
vector<int> eventualSafeNodes(vector<vector<int>>& graph) {
    int n = graph.size();
    // 建立反向图
    vector<vector<int>> reverseAdj(n);
    vector<int> outDegree(n, 0);
    for (int i = 0; i < n; i++) {
        for (int v : graph[i]) {
            reverseAdj[v].push_back(i); // 反向边
        }
        outDegree[i] = graph[i].size();
    }
    
    // 从出度为0的节点开始拓扑排序
    queue<int> q;
    for (int i = 0; i < n; i++) {
        if (outDegree[i] == 0) q.push(i);
    }
    // ... Kahn算法的反向版本
}

代表题目:LC 802 找到最终的安全状态


类型七:拓扑排序的唯一性判断

题型特征:问“拓扑序是否唯一”

解题思路:在 Kahn 算法过程中,如果队列中同时存在超过1个元素,说明此时有多个选择,拓扑序不唯一

bool isUnique(int n, vector<vector<int>>& edges) {
    // 标准Kahn算法
    while (!q.empty()) {
        if (q.size() > 1) return false; // 不唯一!
        // ...
    }
    return true;
}

代表题目:LC 444 序列重建


刷题建议路线

  1. 入门:LC 207 课程表(判环)
  2. 巩固:LC 210 课程表 II(输出拓扑序)
  3. 进阶:LC 310 最小高度树(拓扑剥洋葱,无向图变种)
  4. 应用:LC 269 火星词典(从序列反推关系)
  5. 综合:LC 1203 项目管理(双层拓扑)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值