拓补排序讲解

一、什么是拓扑排序

拓扑排序 是对有向无环图(DAG, Directed Acyclic Graph) 中的节点进行线性排序的一种算法。排序结果满足:对于图中的每一条有向边 u → v u \to v u→v,节点 u u u 在序列中都出现在节点 v v v 之前。

直观理解

课程先修关系为例:

  • 课程 u u u 是课程 v v v 的先修课 ⇒ \Rightarrow ⇒ 图中存在一条边 u → v u \to v u→v
  • 拓扑排序就是找到一个合理的上课顺序,使得每门课程的所有先修课都在它之前被修完

存在性判定

拓扑排序并非对所有有向图都存在

  • 有向无环图(DAG):一定存在至少一个拓扑排序
  • 存在环的有向图 :不存在拓扑排序(如样例2中的 1 → 2 → 3 → 1 1 \to 2 \to 3 \to 1 1→2→3→1 形成循环依赖,无法消除)

因此,拓扑排序算法通常也兼具判环的功能。


二、核心思想:Kahn 算法

Kahn 算法(基于入度的 BFS),其核心思想非常直观:

2.1 关键概念:入度(In-degree)

  • 入度:指向某个节点的边的数量,表示有多少门课程是该课程的先修课
  • 出度:从某个节点指出的边的数量

在课程先修问题中:

  • in[v] == 0 表示课程 v v v 没有先修课(或所有先修课都已修完),可以立即学习
  • 学完课程 u u u 后,所有以 u u u 为先修课的课程 v v v 的入度减 1 1 1(相当于"解锁"了后续课程)

2.2 算法流程

复制代码
1. 初始化:将所有入度为 0 的节点加入队列
2. 循环:当队列非空时
   a. 取出一个节点 x,加入答案序列
   b. 遍历 x 的所有出边 x → y
   c. 将 y 的入度减 1
   d. 如果 y 的入度变为 0,将 y 加入队列
3. 检查:如果答案序列长度等于 n,则成功;否则图中存在环

2.3 如何能得到字典序最小?

使用优先队列(小根堆)priority_queue<ll, vector<ll>, greater<ll>>)而非普通队列。

原因 :当多个节点的入度同时变为 0 0 0 时,我们总是优先选择编号最小的那个节点。由于堆的每次弹出都是当前可选节点中的最小值,这保证了在每一步局部最优的选择下,最终得到全局字典序最小的拓扑序列。


三、代码详解

以下基于模板,逐段解析拓扑排序的完整实现。

3.1 图的存储与入度数组

cpp 复制代码
vector<ll> G[maxn];  // G[u] 存储从 u 出发的所有边,即 u 的所有后继课程
ll in[maxn];          // in[v] 存储节点 v 的入度
ll n, m;             // n: 课程数, m: 先修关系数
变量 含义
G[u] 邻接表,存储从课程 u u u 直接指向的所有后续课程
in[v] 课程 v v v 当前的入度(未完成的先修课数量)

3.2 建图

cpp 复制代码
void sol()
{
    cin >> n >> m;
    rep(i, 1, m)
    {
        ll u, v;
        cin >> u >> v;      // u 是 v 的先修课
        G[u].push_back(v);  // 建立有向边 u → v
        in[v]++;            // v 的入度加 1
    }
    topsort();
}

建图过程示例(样例1):

复制代码
输入:
5 4
1 3    → G[1] = {3}, in[3] = 1
2 3    → G[2] = {3}, in[3] = 2
3 4    → G[3] = {4}, in[4] = 1
2 5    → G[2] = {3,5}, in[5] = 1

最终入度:
in[1] = 0, in[2] = 0, in[3] = 2, in[4] = 1, in[5] = 1

3.3 拓扑排序

cpp 复制代码
void topsort()
{
    // 小根堆:保证每次取出的都是当前可修课程中编号最小的
    priority_queue<ll, vector<ll>, greater<ll>> q;
    
    // 第一步:将所有入度为 0 的课程加入堆
    rep(i, 1, n) if (in[i] == 0) q.push(i);
    
    vector<ll> ans;  // 存储拓扑排序结果
    
    while (q.size())
    {
        ll x = q.top();   // 取出当前编号最小的可修课程
        q.pop();
        ans.push_back(x); // 加入答案序列
        
        // 遍历所有以 x 为先修课的课程 y
        for (auto y : G[x])
        {
            in[y]--;           // 修完 x,相当于 y 的一个先修条件被满足
            if (in[y] == 0)    // y 的所有先修课都已修完
                q.push(y);     // y 变为可修,加入候选堆
        }
    }
    
    // 判环:如果成功修完 n 门课程,则无环;否则存在环
    if (ans.size() == n)
    {
        for (auto x : ans) cout << x << ' ';
        cout << endl;
    }
    else
        cout << "Impossible" << endl;
}

执行流程可视化(样例1):

复制代码
初始入度: [0, 0, 2, 1, 1] (对应课程 1~5)

第1轮: 堆中有 {1, 2},弹出最小值 1
       修完 1,解锁 G[1] = {3}
       in[3] 从 2 减到 1(仍 > 0,不入堆)
       ans = [1]

第2轮: 堆中有 {2},弹出 2
       修完 2,解锁 G[2] = {3, 5}
       in[3] 从 1 减到 0,入堆
       in[5] 从 1 减到 0,入堆
       ans = [1, 2]

第3轮: 堆中有 {3, 5},弹出最小值 3
       修完 3,解锁 G[3] = {4}
       in[4] 从 1 减到 0,入堆
       ans = [1, 2, 3]

第4轮: 堆中有 {4, 5},弹出最小值 4
       修完 4,G[4] 为空
       ans = [1, 2, 3, 4]

第5轮: 堆中有 {5},弹出 5
       ans = [1, 2, 3, 4, 5]

结果: 1 2 3 4 5 ✓

样例2的执行(含环检测):

复制代码
4 4
1 2 → in[2] = 1
2 3 → in[3] = 1
3 1 → in[1] = 1
3 4 → in[4] = 1

初始: 所有节点入度均 ≥ 1,堆为空!
while 循环不执行,ans 为空
ans.size() = 0 ≠ 4,输出 "Impossible"

四、完整代码

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
#define rep(i,a,n) for(ll i=a;i<=n;++i)
#define per(i,n,a) for(ll i=n;i>=a;--i)
typedef long long ll;
const int maxn = 2e5 + 10;
const int mod = 1e9 + 7;

vector<ll> G[maxn];   // 邻接表存图
ll in[maxn];          // 入度数组
ll n, m;

void topsort()
{
    // 小根堆:维护当前所有入度为0的节点,保证字典序最小
    priority_queue<ll, vector<ll>, greater<ll>> q;
    
    // 初始化:将所有无先修课(入度为0)的课程入堆
    rep(i, 1, n) if (in[i] == 0) q.push(i);
    
    vector<ll> ans;   // 拓扑排序结果
    
    while (q.size())
    {
        ll x = q.top();
        q.pop();
        ans.push_back(x);
        
        // 遍历 x 的所有后继,相当于"解锁"后续课程
        for (auto y : G[x])
        {
            in[y]--;
            if (in[y] == 0)   // 所有先修课已完成
                q.push(y);
        }
    }
    
    // 判环:无法修完全部课程,说明存在循环依赖
    if (ans.size() == n)
    {
        for (auto x : ans) cout << x << ' ';
        cout << endl;
    }
    else
    {
        cout << "Impossible" << endl;
    }
}

void sol()
{
    cin >> n >> m;
    rep(i, 1, m)
    {
        ll u, v;
        cin >> u >> v;
        G[u].push_back(v);  // u → v
        in[v]++;            // v 的入度加 1
    }
    topsort();
}

int main()
{
    sol();
    return 0;
}

五、复杂度分析

指标 复杂度 说明
建图 O ( n + m ) O(n + m) O(n+m) 初始化数组 + 读入 m m m 条边
拓扑排序 O ( n log ⁡ n + m ) O(n \log n + m) O(nlogn+m) 每个节点入堆/出堆一次(堆操作 O ( log ⁡ n ) O(\log n) O(logn)),每条边遍历一次
空间复杂度 O ( n + m ) O(n + m) O(n+m) 邻接表存储 + 入度数组 + 堆

如果使用普通队列 (不要求字典序最小),时间复杂度可优化到 O ( n + m ) O(n + m) O(n+m),因为避免了堆的 log ⁡ n \log n logn 开销。


六、DFS 实现(补充)

除了 Kahn 算法,拓扑排序还可以用 DFS + 栈 实现:

cpp 复制代码
vector<ll> G[maxn];
bool vis[maxn], instk[maxn];  // vis: 是否访问过, instk: 是否在递归栈中
vector<ll> ans;

bool dfs(ll u)
{
    vis[u] = instk[u] = true;
    for (auto v : G[u])
    {
        if (instk[v]) return false;   // 发现回边,存在环
        if (!vis[v] && !dfs(v)) return false;
    }
    instk[u] = false;
    ans.push_back(u);   // 后序遍历加入
    return true;
}

bool topsort_dfs()
{
    rep(i, 1, n) if (!vis[i]) if (!dfs(i)) return false;
    reverse(ans.begin(), ans.end());  // 逆后序即为拓扑序
    return true;
}

两种方法对比:

特性 Kahn 算法(BFS) DFS 算法
核心思想 从入度为 0 的节点开始,逐层剥离 后序遍历,逆序输出
判环方式 最终检查序列长度是否等于 n n n 利用递归栈检测回边
字典序控制 用优先队列直接控制 需额外排序,较麻烦
适用场景 需要字典序、动态维护入度 需要输出逆拓扑序、结合强连通分量

七、常见问题与注意事项

7.1 重边与自环

  • 重边 (多条相同的 u → v u \to v u→v):需要在建图时一并记录,in[v] 需要增加相应的次数。如果题目保证输入两两不同(如本题),则无需特殊处理。
  • 自环 ( u → u u \to u u→u):该节点入度至少为 1,永远无法入度为 0,算法会正确判定为"Impossible"。

7.2 不唯一解的处理

拓扑排序的结果通常不唯一 。例如样例1中,如果不用小根堆而用普通队列,可能得到 2 1 3 4 5 等合法序列。

7.3 与动态规划的关系

拓扑排序是 DAG 上动态规划的前置步骤。很多问题需要先进行拓扑排序,确定 DP 的状态转移顺序,确保计算某个节点时,其所有前驱状态都已经计算完毕。


八、总结

拓扑排序是处理有向图依赖关系的基础算法,广泛应用于课程选修、任务调度、编译顺序、Makefile 依赖解析等场景。

相关推荐
深圳市快瞳科技有限公司1 小时前
宠物行为识别:将日常行为转化为可量化的健康指标
人工智能·算法·计算机视觉·宠物
稚南城才子,乌衣巷风流2 小时前
长链剖分(Long Chain Decomposition)算法详解
算法·深度优先·图论
小保CPP2 小时前
OpenCV C++基于模糊数学的图像滤波
c++·人工智能·opencv·计算机视觉
变量未定义~2 小时前
环境治理、染色时间、小蓝组网
算法
Ulyanov2 小时前
雷达导引头仿真中的坐标系——从惯性系到量测角
开发语言·python·算法·系统仿真·雷达信号处理·雷达导引头
玖玥拾2 小时前
LeetCode 13 罗马数字转整数
笔记·算法·leetcode
m沐沐3 小时前
【计算机视觉】OpenCV 物体跟踪——原理、算法与CSRT跟踪器实战
人工智能·python·深度学习·opencv·算法·计算机视觉·人脸识别
明日清晨3 小时前
VS2022禁用 NRVO/返回值优化(不调用拷贝构造函数处理方式)
c++
阿米亚波3 小时前
【C++ STL】std::array
java·开发语言·c++