CC++ & Algorithm

拓扑排序

中等5
语言版本:C++
概述:拓扑排序是给一连串有先后顺序的任务排个队,保证谁先谁后不乱套。

拓扑排序:给任务排个队,不打架!

你有没有过这样的烦恼:早上想穿鞋,结果发现袜子还没穿?又或者,你想先吃早饭再刷牙,却被妈妈喊回去重来?生活中很多事情都有严格的先后顺序。比如做一道菜,你要先洗菜、切菜,再下锅炒,不能反过来。如果有一大堆任务,它们之间有很多这样的“先做A,再做B”的关系,我们怎么找出一个合理的顺序,让所有任务都能在它的前提任务完成后才开始呢?这就是拓扑排序要解决的问题。

拓扑排序是图论里一种非常实用的算法。它把每个任务看作一个点(顶点),如果任务A必须在任务B之前完成,我们就画一条从A指向B的箭头(有向边)。这样所有任务和它们之间的先后关系就构成了一张有向图。注意,这张图里绝对不能有环——比如“你先穿鞋再穿袜子”和“你先穿袜子再穿鞋”同时出现,就会矛盾,谁也没法先开始。这种没有环的有向图叫做有向无环图(DAG),只有DAG才能进行拓扑排序。

拓扑排序的结果不唯一,只要满足所有先后关系,任何一个顺序都可以。比如起床穿衣服,你可以先穿袜子再穿鞋,也可以先穿裤子再穿袜子,都是合理的。


核心思想:从“没任何前置任务”的开始

想象你在做一张任务清单,每个任务都标着它需要先完成哪些其他任务。你该怎么做?很简单:先找那些没有任何前置任务的任务(比如“穿袜子”没有前置,而“穿鞋”的前置是“穿袜子”),把它们先做了。然后,一旦你完成了某个任务,它就会“解放”那些依赖它的任务——比如“穿袜子”完成后,“穿鞋”的前置任务就少了一个。不断重复,直到所有任务都被做完了。如果最后还有任务一直没被做,说明它们之间有循环依赖,没法完成。

这个找任务先后顺序的过程,就是Kahn算法(卡恩算法)。它用了一个叫“入度”的概念。入度是指向一个顶点的边的数量,简单说就是“这个任务有多少个前置任务”。一开始入度为0的任务就是那些没有前置的,可以立刻做。

具体步骤:

  1. 统计每个任务的入度(前置数量)。
  2. 把所有入度为0的任务放进一个队列(或者栈)里。
  3. 从队列中取出一个任务,把它加入最终顺序。
  4. 对于这个任务指向的所有后续任务,把它们的入度减1(相当于这个前置任务完成了)。
  5. 如果减完后某个后续任务的入度变成0,就把它也加入队列。
  6. 重复步骤3~5,直到队列为空。
  7. 最后检查顺序里的任务数量是否等于总任务数。相等说明成功,否则说明存在环。

代码一步步拆解

下面我们用一个具体的例子来写代码。假设有6个任务(编号0到5),它们的先后关系如下:

  • 任务0必须在任务2之前(0 → 2)
  • 任务1必须在任务2和任务3之前(1 → 2,1 → 3)
  • 任务2必须在任务4之前(2 → 4)
  • 任务3必须在任务5之前(3 → 5)
  • 任务4必须在任务5之前(4 → 5)

我们可以画成一张图:0和1是起点,2和3依赖它们,4和5在最后。这完全是一个有向无环图。

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

int main() {
    int n = 6;                  // 任务数量,6个任务编号0~5
    vector<int> adj[6];         // 邻接表,adj[u]存储u指向的所有任务
    int indeg[6] = {0};         // 入度数组,indeg[v]表示任务v有多少前置任务

    // 添加边(先后关系):任务u必须在任务v之前
    adj[0].push_back(2);        // 0→2,所以2的入度+1
    indeg[2]++;                 // 任务2增加一个前置任务0
    adj[1].push_back(2);        // 1→2,任务2又多一个前置
    indeg[2]++;                 // 现在任务2的入度变成2(需要0和1都完成)
    adj[1].push_back(3);        // 1→3
    indeg[3]++;                 // 任务3入度变成1
    adj[2].push_back(4);        // 2→4
    indeg[4]++;                 // 任务4入度变成1
    adj[3].push_back(5);        // 3→5
    indeg[5]++;                 // 任务5入度变成1
    adj[4].push_back(5);        // 4→5
    indeg[5]++;                 // 任务5入度变成2(需要3和4都完成)

    queue<int> q;               // 队列,用于存放当前入度为0的任务
    // 遍历所有任务,把一开始就没有前置的(入度为0)加入队列
    for (int i = 0; i < n; ++i) {
        if (indeg[i] == 0) {
            q.push(i);          // 任务i可以立即开始
        }
    }

    vector<int> order;          // 存储最终的拓扑排序结果
    while (!q.empty()) {
        int u = q.front();      // 取出一个可以做的任务
        q.pop();
        order.push_back(u);     // 把它加入顺序列表

        // 遍历这个任务指向的所有后续任务
        for (int v : adj[u]) {
            indeg[v]--;          // 因为u完成了,v的前置任务少了一个
            if (indeg[v] == 0) { // 如果v的前置全完成了,v也可以做了
                q.push(v);
            }
        }
    }

    // 检查是否所有任务都被安排进去了
    if (order.size() != n) {
        cout << "存在环,无法拓扑排序" << endl;
    } else {
        cout << "一个合理的顺序: ";
        for (int x : order) {
            cout << x << " ";   // 输出任务编号
        }
        cout << endl;
    }
    return 0;
}

运行结果会输出类似 0 1 2 3 4 5 或者 1 0 2 3 4 5 这样的顺序。由于0和1没有依赖关系,谁先都可以。


新手容易犯的错误

  1. 忘记初始化入度数组:代码中 int indeg[6] = {0}; 把全部初始化为0,必须保证一开始每个任务的入度都是0,然后每加一条边累加。有些同学只给部分初始化,导致结果错误。

  2. 入度更新只做一次:在循环中,indeg[v]-- 必须放在 for 循环里,每处理一个前置任务就要减1。不能只减一次就认为所有前置都完成了。

  3. 没有检查环:如果最终的顺序大小不等于任务总数,说明图中有环。有些同学忘了这一步,以为只要队列空就结束了,但可能还有没加入队列的任务。

  4. 用错容器:代码里用 queue 保证顺序是先进先出,但其实用 stackvector 也可以,只是输出的顺序可能不同。但要注意不能用 set 随意取出,因为入度为0的任务可能有多个,我们需要把它们都存起来。

  5. 图中存在自环或平行边:如果某个任务指向自己(0→0),那就是自环,入度永远减不完,肯定无法拓扑排序。另外如果两条重复的边,入度会重复加,导致实际需要的前置任务数量比真实的多,排序会错误。


完整示例:计算课程顺序

假设你是一个初中生,要选6门选修课,有些课必须先修其他课才能上。课程编号和前置关系如下:

  • 0:基础数学(无前置)
  • 1:基础英语(无前置)
  • 2:代数(必须先修0)
  • 3:语法(必须先修1)
  • 4:几何(必须先修2)
  • 5:写作(必须先修3和4)

这正是上面代码中的例子。运行后你会得到 0 1 2 3 4 5 这样的顺序,表示你可以先学基础数学和基础英语(顺序随意),然后学代数,然后学语法,然后几何,最后写作。你也可以先基础英语再基础数学,只要保证代数在几何之前、语法在写作之前即可。

如果你想自己测试有环的情况,可以故意加一条边,比如让任务5指向任务1(写作必须先于基础英语?),那么图就变成了环(1→3→5→1),程序会输出“存在环,无法拓扑排序”。试试看!


相关指引

  • 有向无环图(DAG):拓扑排序只能用于DAG,判断一个图有没有环是图论的基础问题。
  • 关键路径(CPM):在项目管理中,每个任务还有耗时,拓扑排序可以帮助我们找出整个项目的最短完成时间,以及哪些任务不能延迟。
  • 动态规划:很多DAG上的最短路、最长路问题,都可以先拓扑排序,然后按顺序递推计算。
  • 并查集:虽然并查集不直接处理拓扑排序,但它可以用来判断无向图的连通性,是图论的另一块基础。

下次你再遇到一堆乱七八糟的任务,比如游戏里的技能树、学校里的课程安排、甚至做菜流程,都可以想想拓扑排序——给任务排个队,让生活不再乱套!

例题精讲

1单选题

在Kahn算法(基于入度的拓扑排序)中,通常使用哪种数据结构来管理当前入度为0的顶点?

A
B队列
C优先队列
D无序集合
2判断题

一个有向无环图的拓扑排序序列一定是唯一的。

3填空题
以下代码是使用Kahn算法实现拓扑排序的部分代码,请补充空缺处的语句。

vector<int> topoSort(int n, vector<int> adj[]) {
    vector<int> indegree(n, 0);
    for (int i = 0; i < n; i++)
        for (int v : adj[i])
            indegree[v]++;
    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]) {
            ___;  // 填空
            if (indegree[v] == 0) q.push(v);
        }
    }
    if (result.size() != n) return {};
    return result;
}
4单选题

以下哪个不是拓扑排序的典型应用?

A课程安排(先修课程)
B编译器中的依赖顺序分析
C求解最小生成树
D项目管理中的任务调度
5判断题

使用深度优先搜索(DFS)进行拓扑排序时,如果检测到一条后向边(即访问到一个已在当前递归栈中的顶点),则说明该图存在环,无法进行拓扑排序。