【算法基础篇】(三十六)图论基础之拓扑排序:从原理到实战,搞定 DAG 图的 “先后次序” 难题


目录

​编辑

前言

[一、拓扑排序的核心概念:先搞懂 "是什么" 和 "为什么"](#一、拓扑排序的核心概念:先搞懂 “是什么” 和 “为什么”)

[1.1 有向无环图(DAG)](#1.1 有向无环图(DAG))

[1.2 AOV 网:顶点表示活动的网络](#1.2 AOV 网:顶点表示活动的网络)

[1.3 拓扑排序的定义](#1.3 拓扑排序的定义)

[1.4 拓扑排序的应用场景](#1.4 拓扑排序的应用场景)

[二、拓扑排序的核心算法:Kahn 算法 ------ 基于队列的入度法](#二、拓扑排序的核心算法:Kahn 算法 —— 基于队列的入度法)

[2.1 Kahn 算法的基本思想](#2.1 Kahn 算法的基本思想)

[2.2 Kahn 算法的图解过程](#2.2 Kahn 算法的图解过程)

[步骤 1:初始化](#步骤 1:初始化)

[步骤 2:处理队头顶点 1](#步骤 2:处理队头顶点 1)

[步骤 3:处理队头顶点 2](#步骤 3:处理队头顶点 2)

[步骤 4:处理队头顶点 3](#步骤 4:处理队头顶点 3)

[步骤 5:处理队头顶点 4](#步骤 5:处理队头顶点 4)

[步骤 6:处理队头顶点 5](#步骤 6:处理队头顶点 5)

结果

[2.3 Kahn 算法的代码实现(邻接表版)](#2.3 Kahn 算法的代码实现(邻接表版))

代码说明:

[2.4 拓扑排序的 DFS 实现(拓展)](#2.4 拓扑排序的 DFS 实现(拓展))

代码说明:

[2.5 两种实现方式的对比](#2.5 两种实现方式的对比)

三、拓扑排序的经典例题实战:从模板到应用

[例题 1:【模板】拓扑排序 / 家谱树(洛谷 B3644)](#例题 1:【模板】拓扑排序 / 家谱树(洛谷 B3644))

题目描述:

输入格式:

输出格式:

示例输入:

示例输出:

解法分析:

代码实现:

运行结果:

[例题 2:摄像头(洛谷 P2712)](#例题 2:摄像头(洛谷 P2712))

题目描述:

输入格式:

输出格式:

示例输入:

示例输出:

解法分析:

关键注意点:

代码实现:

运行结果:

[例题 3:最大食物链计数(洛谷 P4017)](#例题 3:最大食物链计数(洛谷 P4017))

题目描述:

输入格式:

输出格式:

示例输入:

示例输出:

解法分析:

代码实现:

运行结果:

[例题 4:杂务(洛谷 P1113)](#例题 4:杂务(洛谷 P1113))

题目描述:

输入格式:

输出格式:

示例输入:

示例输出:

解法分析:

代码实现:

运行结果:

四、拓扑排序的常见问题与注意事项

[4.1 图的存储方式选择](#4.1 图的存储方式选择)

[4.2 入度数组的初始化](#4.2 入度数组的初始化)

[4.3 环检测的正确性](#4.3 环检测的正确性)

[4.4 拓扑序列的不唯一性](#4.4 拓扑序列的不唯一性)

[4.5 大规模图的处理](#4.5 大规模图的处理)

[4.6 字典序最小的拓扑序列(拓展)](#4.6 字典序最小的拓扑序列(拓展))

总结


前言

在现实生活中,我们经常会遇到这样的场景:

  • 学习课程时,必须先学完 "高等数学" 才能学 "数据结构",先学 "数据结构" 才能学 "算法";
  • 项目开发时,必须先完成 "需求分析" 才能开始 "架构设计",先完成 "架构设计" 才能进行 "编码开发";
  • 任务调度时,必须先完成 "准备工作" 才能执行 "核心任务",先执行 "核心任务" 才能进行 "收尾工作"。

这些场景的共同特点是**"存在依赖关系"** ------ 某些事件必须在其他事件完成后才能发生。而在图论中,这种 "有依赖关系的事件" 可以用 "有向无环图(DAG)" 来建模,解决这类 "先后次序" 问题的核心算法,就是拓扑排序(Topological Sorting)

拓扑排序是图论中的基础算法,也是面试和算法竞赛的高频考点。它不仅能解决 "任务调度""课程安排" 等经典问题,还能用于判断图中是否存在环(比如检测循环依赖)。本文将从拓扑排序的基本概念出发,用通俗的语言拆解算法原理,结合大量图解,再通过一些经典例题巩固实战能力。无论你是算法初学者还是有一定基础的开发者,相信都能从这篇文章中彻底吃透拓扑排序。下面就让我们正式开始吧!


一、拓扑排序的核心概念:先搞懂 "是什么" 和 "为什么"

在正式讲解算法之前,我们必须先理清几个关键概念 ------ 只有明确了 "基础定义",后续的算法学习才能事半功倍。

1.1 有向无环图(DAG)

拓扑排序的前提是 "图中没有环",因此我们首先要明确 "有向无环图" 的定义:

  • 有向图 :边带有方向的图,即边**<u, v>** 表示从顶点 u 到顶点 v 的单向关系(比如 "学完 u 才能学 v");
  • 无环图 :图中不存在回路(环),即不存在一条从某个顶点出发,经过若干条边后能回到自身的路径;
  • 有向无环图(DAG):同时满足 "有向" 和 "无环" 的图,是拓扑排序的唯一适用场景。

举个例子:课程学习图(顶点表示课程,边表示 "先修关系")就是典型的 DAG;而如果出现 "学 A 需要先学 B,学 B 需要先学 C,学 C 需要先学 A" 的情况,就形成了环,这类图无法进行拓扑排序。

1.2 AOV 网:顶点表示活动的网络

在实际应用中,DAG 图常被用来表示 "活动之间的依赖关系",这类图被称为AOV 网(Activity On Vertex Network)

  • 顶点表示 "活动"(比如课程、任务、步骤);
  • 有向边**<u, v>**表示 "活动 u 必须先于活动 v 进行"(比如 "学完 u 才能学 v""完成 u 才能开始 v")。

AOV 网的核心要求是 "无环"------ 如果存在环,就意味着存在 "循环依赖",导致所有依赖于环的活动都无法进行(比如 "学 A 需要先学 B,学 B 需要先学 A",两者都无法开始)。因此,AOV 网的合法性检测(是否有环)和合法次序安排(拓扑排序)是其核心问题。

1.3 拓扑排序的定义

拓扑排序是对 DAG 图的顶点进行排序的过程,排序后的顶点序列需满足:

  • 对于图中的任意一条有向边 <u, v>,顶点 u 在序列中一定位于顶点 v 之前。

简单来说,拓扑排序的结果是一个 "满足所有依赖关系的顶点序列"。

举个例子:对于课程学习图(顶点:1 - 高等数学,2 - 线性代数,3 - 数据结构,4 - 算法),边为 <1,3><2,3><3,4>,则以下序列都是合法的拓扑排序:

  • 1, 2, 3, 4\](先学高数,再学线代,再学数据结构,最后学算法);

  • 1, 2, 4, 3\](不合法!因为边 `<3,4>` 要求 3 在 4 之前)。

一个 DAG 图的拓扑排序可能有多个,但如果图中存在环,则不存在拓扑排序。

1.4 拓扑排序的应用场景

拓扑排序的核心是 "处理依赖关系",因此其应用场景非常广泛:

  1. 课程安排:根据课程先修关系,安排合理的学习顺序;
  2. 任务调度:根据任务依赖关系,安排任务的执行顺序(比如项目开发流程);
  3. 编译顺序:编译器根据代码文件的依赖关系(比如 A 文件引用 B 文件),确定编译顺序;
  4. 依赖包安装:软件安装时,根据依赖关系(比如安装 A 需要先安装 B),确定安装顺序;
  5. 环检测:判断图中是否存在环(比如检测循环依赖)。

了解了这些基本概念后,我们就可以正式进入算法的学习了。

二、拓扑排序的核心算法:Kahn 算法 ------ 基于队列的入度法

拓扑排序的实现方法有两种:Kahn 算法(基于队列的入度法)DFS 深度优先搜索法。其中,Kahn 算法逻辑直观、易于理解,且能同时检测图中是否有环,是实际应用中最常用的方法。本文将重点讲解 Kahn 算法,DFS 方法将在后续作为拓展内容介绍。

2.1 Kahn 算法的基本思想

Kahn 算法的核心是 "基于顶点的入度(In-degree)进行排序 "。入度是指指向该顶点的边的条数(比如课程 3 的入度为 2,因为有两条边 <1,3><2,3> 指向它,表示有两个先修课程)。

算法的基本思路的是:

  1. 初始化:计算所有顶点的入度,将入度为 0 的顶点(没有任何依赖,可直接开始的活动)加入队列;
  2. 迭代处理
    • 取出队头顶点 u,将其加入拓扑排序序列;
    • 遍历 u 的所有邻接顶点 v(即依赖于 u 的活动),删除边 <u, v>(相当于完成了 u,解除了对 v 的一个依赖);
    • v 的入度减为 0(所有依赖都已完成),将 v 加入队列;
  3. 终止条件:队列为空。此时,若拓扑排序序列的长度等于顶点数,则排序成功(图为 DAG);否则,排序失败(图中存在环)。

举个通俗的例子:把每个顶点看作一个 "任务",入度为 0 的任务是 "可以立即开始的任务"。我们每次选一个可以立即开始的任务完成,然后解除它对后续任务的依赖;如果某个后续任务的所有依赖都被解除(入度为 0),就可以开始执行它。直到所有任务都完成(排序成功)或没有可执行的任务(存在环,排序失败)。

2.2 Kahn 算法的图解过程

为了让大家更直观地理解,我们用一个具体的 DAG 图来演示 Kahn 算法的执行过程:

  • 图 G:顶点 1-5,边为 <1,3><1,4><2,4><3,5><4,5>
  • 各顶点入度:1 的入度 = 0,2 的入度 = 0,3 的入度 = 1,4 的入度 = 2,5 的入度 = 2;

步骤 1:初始化

  • 计算入度数组:in = [0, 0, 0, 1, 2, 2](索引 0 无用,顶点 1-5 对应索引 1-5);
  • 将入度为 0 的顶点(1、2)加入队列,队列:[1, 2]
  • 拓扑排序序列为空:[]

步骤 2:处理队头顶点 1

  • 取出顶点 1,加入序列:[1]
  • 遍历 1 的邻接顶点(3、4):
    • 顶点 3:入度减 1(1→0),入度为 0,加入队列,队列:[2, 3]
    • 顶点 4:入度减 1(2→1),入度不为 0,不加入队列;
  • 队列现在为:[2, 3]

步骤 3:处理队头顶点 2

  • 取出顶点 2,加入序列:[1, 2]
  • 遍历 2 的邻接顶点(4):
    • 顶点 4:入度减 1(1→0),入度为 0,加入队列,队列:[3, 4]
  • 队列现在为:[3, 4]

步骤 4:处理队头顶点 3

  • 取出顶点 3,加入序列:[1, 2, 3]
  • 遍历 3 的邻接顶点(5):
    • 顶点 5:入度减 1(2→1),入度不为 0,不加入队列;
  • 队列现在为:[4]

步骤 5:处理队头顶点 4

  • 取出顶点 4,加入序列:[1, 2, 3, 4]
  • 遍历 4 的邻接顶点(5):
    • 顶点 5:入度减 1(1→0),入度为 0,加入队列,队列:[5]
  • 队列现在为:[5]

步骤 6:处理队头顶点 5

  • 取出顶点 5,加入序列:[1, 2, 3, 4, 5]
  • 遍历 5 的邻接顶点(无);
  • 队列为空。

结果

  • 拓扑排序序列长度为 5(等于顶点数),排序成功,序列为 [1, 2, 3, 4, 5](也可能是其他合法序列,比如 [2, 1, 3, 4, 5],取决于队列的取出顺序);
  • 图中无环,是合法的 DAG。

2.3 Kahn 算法的代码实现(邻接表版)

Kahn 算法的实现需要用到 "邻接表" 存储图(高效遍历邻接顶点)和 "入度数组" 存储每个顶点的入度。以下是完整的 C++ 代码实现:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int N = 1e5 + 10;  // 顶点数上限

int n, m;                // n:顶点数,m:边数
vector<int> edges[N];    // 邻接表存储图:edges[u] 存储u的所有邻接顶点v
int in_degree[N];        // 入度数组:in_degree[v] 表示v的入度
vector<int> topo_order;  // 存储拓扑排序结果

// Kahn算法实现拓扑排序
bool kahn() {
    // 步骤1:初始化队列,将所有入度为0的顶点加入
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (in_degree[i] == 0) {
            q.push(i);
        }
    }

    // 步骤2:迭代处理队列
    while (!q.empty()) {
        int u = q.front();  // 取出队头顶点u
        q.pop();
        topo_order.push_back(u);  // 加入拓扑序列

        // 遍历u的所有邻接顶点v,解除依赖
        for (int v : edges[u]) {
            in_degree[v]--;  // v的入度减1
            if (in_degree[v] == 0) {  // 若v的入度为0,加入队列
                q.push(v);
            }
        }
    }

    // 步骤3:判断是否存在环
    return topo_order.size() == n;
}

int main() {
    cin >> n >> m;

    // 初始化入度数组为0
    memset(in_degree, 0, sizeof in_degree);

    // 读入m条边(u→v)
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        edges[u].push_back(v);  // 邻接表添加边
        in_degree[v]++;        // v的入度加1
    }

    // 执行拓扑排序
    bool success = kahn();

    if (success) {
        // 输出拓扑排序序列
        cout << "拓扑排序成功,序列为:";
        for (int i = 0; i < topo_order.size(); i++) {
            cout << topo_order[i] << " ";
        }
        cout << endl;
    } else {
        // 存在环,输出提示
        cout << "图中存在环,无法进行拓扑排序!" << endl;
    }

    return 0;
}

代码说明:

  1. 数据结构

    • edges[N]:邻接表,存储图的边,edges[u] 是一个 vector,包含所有从 u 出发的边的终点 v
    • in_degree[N]:入度数组,存储每个顶点的入度;
    • topo_ordervector,存储拓扑排序的结果序列;
    • queue<int>:用于存储入度为 0 的顶点,实现 BFS 式的迭代处理。
  2. 核心逻辑

    • 初始化时,遍历所有顶点,将入度为 0 的顶点加入队列;
    • 每次取出队头顶点,加入拓扑序列,然后遍历其邻接顶点,更新入度;
    • 若邻接顶点的入度减为 0,加入队列;
    • 最终通过判断拓扑序列长度是否等于顶点数,确定是否存在环。
  3. 时间复杂度

    • 邻接表遍历所有边:O (m)
    • 队列操作(入队和出队):O (n)
    • 总时间复杂度:O (n + m),效率极高,适用于大规模图(比如 n=1e5,m=1e6)。
  4. 空间复杂度

    • 邻接表:O (m)
    • 入度数组和拓扑序列:O (n)
    • 队列:O (n)(最坏情况下所有顶点入度为 0,同时入队);
    • 总空间复杂度:O (n + m)

2.4 拓扑排序的 DFS 实现(拓展)

除了 Kahn 算法,拓扑排序还可以通过 DFS 深度优先搜索实现。其核心思想是:

  1. 对每个未访问的顶点进行 DFS,遍历其所有邻接顶点;
  2. 当一个顶点的所有邻接顶点都被访问完毕(即所有依赖都已处理),将该顶点加入拓扑序列(注意:是逆序加入,最终需要反转序列);
  3. 若 DFS 过程中发现回边(访问到已访问但未处理完的顶点),则说明图中存在环。

以下是 DFS 实现的完整代码:

cpp 复制代码
#include <iostream>
#include <vector>
#include <stack>
using namespace std;

const int N = 1e5 + 10;

int n, m;
vector<int> edges[N];    // 邻接表存储图
bool visited[N];         // 标记顶点是否已访问
bool in_stack[N];        // 标记顶点是否在当前DFS栈中(用于检测环)
vector<int> topo_order;  // 存储拓扑序列(逆序)
bool has_cycle = false;  // 标记图中是否存在环

// DFS函数:u为当前顶点
void dfs(int u) {
    visited[u] = true;
    in_stack[u] = true;  // 将u加入当前DFS栈

    // 遍历u的所有邻接顶点v
    for (int v : edges[u]) {
        if (!visited[v]) {
            dfs(v);
            if (has_cycle) return;  // 若已发现环,直接返回
        } else if (in_stack[v]) {
            // 发现回边,存在环
            has_cycle = true;
            return;
        }
    }

    // u的所有邻接顶点都已处理完毕,加入拓扑序列
    in_stack[u] = false;  // 从当前DFS栈中移除
    topo_order.push_back(u);
}

// 拓扑排序的DFS实现
void topo_sort_dfs() {
    // 初始化
    memset(visited, false, sizeof visited);
    memset(in_stack, false, sizeof in_stack);
    has_cycle = false;
    topo_order.clear();

    // 对每个未访问的顶点执行DFS
    for (int i = 1; i <= n; i++) {
        if (!visited[i] && !has_cycle) {
            dfs(i);
        }
    }

    // 若不存在环,反转拓扑序列(因为DFS是逆序加入的)
    if (!has_cycle) {
        reverse(topo_order.begin(), topo_order.end());
    }
}

int main() {
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        edges[u].push_back(v);
    }

    // 执行DFS版拓扑排序
    topo_sort_dfs();

    if (has_cycle) {
        cout << "图中存在环,无法进行拓扑排序!" << endl;
    } else {
        cout << "拓扑排序成功,序列为:";
        for (int i = 0; i < topo_order.size(); i++) {
            cout << topo_order[i] << " ";
        }
        cout << endl;
    }

    return 0;
}

代码说明:

  1. 环检测in_stack数组用于标记当前 DFS 路径上的顶点。如果访问到一个已在当前路径上的顶点(in_stack[v] = true),说明存在环;
  2. 拓扑序列生成:DFS 过程中,只有当一个顶点的所有邻接顶点都被处理完毕(即所有依赖都已完成),才会将该顶点加入序列。由于 DFS 是 "深度优先",加入的顺序是逆拓扑序,因此最终需要反转序列;
  3. 时间复杂度 :同样是O (n + m),与 Kahn 算法一致;
  4. 适用场景:DFS 实现更适合用于 "需要递归处理依赖" 的场景,但对于大规模图(n=1e5),递归可能导致栈溢出,因此更推荐使用 Kahn 算法(迭代实现,无栈溢出风险)。

2.5 两种实现方式的对比

对比维度 Kahn 算法(基于队列的入度法) DFS 算法(基于递归的逆序法)
核心思想 基于入度,迭代处理入度为 0 的顶点 基于递归,逆序记录处理完所有依赖的顶点
环检测 自然支持(序列长度是否等于顶点数) 需要额外维护 in_stack 数组检测回边
实现难度 低(逻辑直观,迭代实现) 中(需理解递归和逆序序列)
适用场景 大规模图、迭代编程场景 小规模图、递归编程场景
空间复杂度 O (n + m)(邻接表 + 队列 + 入度数组) O (n + m)(邻接表 + 递归栈 + visited 数组)
栈溢出风险 无(迭代实现) 有(递归深度过大时)

实际应用中,Kahn 算法更为常用,尤其是在处理大规模图或需要避免栈溢出的场景下。本文后续的例题将主要基于 Kahn 算法实现。

三、拓扑排序的经典例题实战:从模板到应用

理论学习之后,必须通过实战巩固。下面为大家精选 5 道洛谷上的经典例题,涵盖模板题、环检测、动态规划结合等场景,帮助大家灵活运用拓扑排序。

例题 1:【模板】拓扑排序 / 家谱树(洛谷 B3644)

题目链接:https://www.luogu.com.cn/problem/B3644

题目描述:

有个人的家族很大,辈分关系很混乱,请你帮整理一下这种关系。给出每个人的后代的信息,输出一个序列,使得每个人的后辈都比那个人后列出。如果有多种不同的序列,输出任意一种即可。

输入格式:

第一行一个整数 N(1 ≤ N ≤ 100),表示家族的人数。接下来 N 行,第 i 行描述第 i 个人的后代编号 a_{i,j},表示 a_{i,j} 是 i 的后代。每行最后是 0 表示描述完毕。

输出格式:

输出一个序列,使得每个人的后辈都比那个人后列出。

示例输入:

复制代码
5
0
4 5 1 0
1 0
5 3 0
3 0

示例输出:

复制代码
2 4 5 3 1

解法分析:

这是拓扑排序的模板题,直接套用 Kahn 算法即可:

  • 顶点:每个人(编号 1-N);
  • 边:**<<i, a_{i,j}>**表示 i 是 **a_{i,j}**的前辈,a_{i,j} 是 i 的后辈(即 i 必须在 **a_{i,j}**之前);
  • 入度:a_{i,j} 的入度加 1(因为有一个前辈 i);
  • 拓扑排序的结果就是满足 "前辈在前,后辈在后" 的序列。

代码实现:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int N = 110;

int n;
vector<int> edges[N];
int in_degree[N];
vector<int> topo_order;

int main() {
    cin >> n;

    // 初始化入度数组
    memset(in_degree, 0, sizeof in_degree);

    // 读入每条边(i→j,表示i是j的前辈)
    for (int i = 1; i <= n; i++) {
        int j;
        while (cin >> j, j != 0) {  // 读到0结束
            edges[i].push_back(j);
            in_degree[j]++;
        }
    }

    // Kahn算法
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (in_degree[i] == 0) {
            q.push(i);
        }
    }

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        topo_order.push_back(u);

        for (int v : edges[u]) {
            in_degree[v]--;
            if (in_degree[v] == 0) {
                q.push(v);
            }
        }
    }

    // 输出拓扑序列
    for (int i = 0; i < topo_order.size(); i++) {
        cout << topo_order[i] << " ";
    }
    cout << endl;

    return 0;
}

运行结果:

输入示例对应的输出为 2 4 5 3 1,符合 "前辈在前,后辈在后" 的要求(比如 2 是 4、5、1 的前辈,4 是 5、3 的前辈,5 是 3 的前辈,3 是 1 的前辈)。

例题 2:摄像头(洛谷 P2712)

题目链接:https://www.luogu.com.cn/problem/P2712

题目描述:

食品店里有 n 个摄像头,每个摄像头能拍摄到固定位置。一个摄像头能被砸毁的条件是该摄像头所在位置不被其他摄像头监视。请计算是否可以砸掉所有摄像头,如不能则输出还没砸掉的摄像头的数量。

输入格式:

第一行一个整数 n,表示摄像头的个数。接下来 n 行,每行包含摄像头的位置 x,以及该摄像头可以监视到的位置数 m,之后 m 个数 y 是此摄像头可以监视到的位置。(砸了摄像头后,该摄像头监视的位置就无法监视了)

输出格式:

若可以砸掉所有摄像头则输出 "YES",否则输出还没砸掉的摄像头的数量。

示例输入:

复制代码
5
1 1 2
2 1 1
3 1 7
4 1 1
5 0

示例输出:

复制代码
2

解法分析:

这道题的核心是环检测------ 摄像头之间的监视关系形成一个有向图,若存在环,则环中的摄像头无法被砸毁(每个摄像头都被环中的其他摄像头监视);若不存在环,则所有摄像头都可以被砸毁。

具体建模:

  • 顶点:摄像头所在的位置(注意:不同摄像头可能在同一位置,但题目中每个摄像头的位置是唯一的?不,题目中 "位置 x" 是摄像头的位置,多个摄像头可能在同一位置,但监视关系是基于位置的);
  • <x, y> 表示位置 x 的摄像头能监视位置 y(即若要砸毁位置 y 的摄像头,必须先砸毁位置 x 的摄像头,因为 x 监视 y);
  • 入度:位置 y 的入度加 1(表示有一个摄像头监视 y);
  • 拓扑排序:能被砸毁的摄像头是拓扑序列中的顶点(入度为 0 的摄像头可以先砸毁,砸毁后解除对其他位置的监视);
  • 无法被砸毁的摄像头数量 = 总摄像头数 - 拓扑序列中包含的摄像头位置数(注意:只有存在摄像头的位置才计入)。

关键注意点:

  • 有些位置可能没有摄像头(比如示例中的 7),这些位置不需要考虑;
  • 摄像头的位置可能重复?题目中说 "n 个摄像头",每个摄像头有一个位置 x,可能存在多个摄像头在同一位置,但砸毁该位置的一个摄像头后,该位置的所有摄像头都被砸毁(因为位置被砸了),因此建模时按 "位置" 为顶点,而非 "摄像头"。

代码实现:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int MAX_POS = 510;  // 位置编号最大为500(根据题目隐含条件)

int n;
vector<int> edges[MAX_POS];  // 邻接表:edges[x] 存储x能监视的位置y
int in_degree[MAX_POS];      // 入度数组
bool has_camera[MAX_POS];    // 标记位置x是否有摄像头

int main() {
    cin >> n;

    // 初始化
    memset(in_degree, 0, sizeof in_degree);
    memset(has_camera, false, sizeof has_camera);

    // 读入每个摄像头的信息
    for (int i = 0; i < n; i++) {
        int x, m, y;
        cin >> x >> m;
        has_camera[x] = true;  // 标记位置x有摄像头
        while (m--) {
            cin >> y;
            edges[x].push_back(y);
            in_degree[y]++;
        }
    }

    // Kahn算法:找出所有能被砸毁的位置(拓扑序列中的位置)
    queue<int> q;
    for (int x = 0; x <= MAX_POS; x++) {
        // 位置x有摄像头,且入度为0(不被其他摄像头监视)
        if (has_camera[x] && in_degree[x] == 0) {
            q.push(x);
        }
    }

    int destroyed = 0;  // 被砸毁的摄像头数量
    while (!q.empty()) {
        int x = q.front();
        q.pop();
        destroyed++;  // 砸毁位置x的摄像头

        // 遍历x能监视的位置y,解除监视(y的入度减1)
        for (int y : edges[x]) {
            in_degree[y]--;
            // 若y有摄像头且入度为0,加入队列
            if (has_camera[y] && in_degree[y] == 0) {
                q.push(y);
            }
        }
    }

    // 总摄像头数 - 被砸毁的数量 = 未被砸毁的数量
    int remaining = n - destroyed;
    if (remaining == 0) {
        cout << "YES" << endl;
    } else {
        cout << remaining << endl;
    }

    return 0;
}

运行结果:

示例输入中,摄像头位置 1 和 2 形成环(1 监视 2,2 监视 1),无法被砸毁;位置 3 监视 7(无摄像头),入度为 0,可被砸毁;位置 4 监视 1(入度 1,砸毁 1 后入度为 0,可被砸毁);位置 5 无监视对象,入度为 0,可被砸毁。被砸毁的摄像头数为 3,总摄像头数为 5,因此未被砸毁的数量为 2,输出2,与示例一致。

例题 3:最大食物链计数(洛谷 P4017)

题目链接:

题目描述:

给你一个食物网,求这个食物网中最大食物链的数量。("最大食物链" 指生物学意义上的食物链,即最左端是生产者,最右端是消费者)。结果需模 80112002。

输入格式:

第一行两个正整数 n、m,表示生物种类 n 和吃与被吃的关系数 m。接下来 m 行,每行两个正整数 A 和 B,表示被吃的生物 A 和吃 A 的生物 B(即 A→B,A 是 B 的食物)。

输出格式:

一行一个整数,表示最大食物链的数量模 80112002 的结果。

示例输入:

复制代码
5 7
1 2
1 3
2 3
3 5
2 5
4 5
3 4

示例输出:

复制代码
5

解法分析:

这道题是拓扑排序结合动态规划的经典问题:

  • 食物网是一个 DAG 图(不存在循环捕食,否则会形成环);
  • 最大食物链的起点是 "生产者"(入度为 0 的生物,没有天敌);
  • 最大食物链的终点是 "消费者"(出度为 0 的生物,没有猎物);
  • 动态规划状态:dp[i]表示以生物 i 为终点的最大食物链的数量
  • 状态转移:对于边 <A, B>(A 是 B 的食物),dp[B] += dp[A](所有以 A 为终点的食物链,都可以延伸到 B);
  • 初始化:dp[i] = 1(对于生产者,自身是一条长度为 1 的食物链);
  • 最终结果:所有出度为 0 的生物的**dp[i]**之和。

代码实现:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int N = 5010;
const int MOD = 80112002;

int n, m;
vector<int> edges[N];    // 邻接表:edges[A] 存储吃A的生物B(A→B)
int in_degree[N];        // 入度数组
int out_degree[N];       // 出度数组
int dp[N];               // dp[i]:以i为终点的最大食物链数量

int main() {
    cin >> n >> m;

    // 初始化
    memset(in_degree, 0, sizeof in_degree);
    memset(out_degree, 0, sizeof out_degree);
    memset(dp, 0, sizeof dp);

    // 读入m条边(A→B,A被B吃)
    for (int i = 0; i < m; i++) {
        int A, B;
        cin >> A >> B;
        edges[A].push_back(B);
        in_degree[B]++;
        out_degree[A]++;
    }

    // Kahn算法:拓扑排序 + 动态规划
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (in_degree[i] == 0) {
            q.push(i);
            dp[i] = 1;  // 生产者的食物链数量为1
        }
    }

    while (!q.empty()) {
        int A = q.front();
        q.pop();

        // 遍历A的所有捕食者B
        for (int B : edges[A]) {
            dp[B] = (dp[B] + dp[A]) % MOD;  // 状态转移
            in_degree[B]--;
            if (in_degree[B] == 0) {
                q.push(B);
            }
        }
    }

    // 计算所有消费者(出度为0)的dp之和
    int result = 0;
    for (int i = 1; i <= n; i++) {
        if (out_degree[i] == 0) {
            result = (result + dp[i]) % MOD;
        }
    }

    cout << result << endl;

    return 0;
}

运行结果:

示例输入中,生产者是 1(入度为 0),消费者是 5(出度为 0)。以 5 为终点的食物链有:1→2→3→5、1→2→5、1→3→5、1→3→4→5、1→2→3→4→5,共 5 条,因此输出5,与示例一致。

例题 4:杂务(洛谷 P1113)

题目链接:https://www.luogu.com.cn/problem/P1113

题目描述:

John 的农场在挤奶前有很多杂务要完成,有些杂务必须在其他杂务完成后才能进行。请计算完成所有杂务所需的最短时间(互相无关的杂务可以同时进行)。

输入格式:

第一行一个整数 n(3 ≤ n ≤ 10000),表示杂务的数目。接下来 n 行,每行包含:工作序号(1~n)、完成时间 len、若干必须完成的准备工作(以 0 结束)。

输出格式:

一个整数,表示完成所有杂务所需的最短时间。

示例输入:

复制代码
7
1 5 0
2 2 1 0
3 3 2 0
4 6 1 0
5 1 2 4 0
6 8 2 4 0
7 4 3 5 6 0

示例输出:

复制代码
23

解法分析:

这道题是拓扑排序结合最长路径的问题(因为杂务需要按依赖关系进行,且无关杂务可同时进行,最短总时间是 "最长路径" 的长度):

  • 杂务是顶点,准备工作关系是边 <u, v>(u 是 v 的准备工作,u 必须先完成);
  • 动态规划状态:dp[i] 表示完成杂务 i 所需的最短时间(即从起点到 i 的最长路径长度);
  • 状态转移:对于边 <u, v>dp[v] = max(dp[v], dp[u] + len[v])(完成 v 的时间是完成 u 的时间加上 v 自身的时间,取最大值因为 v 可能有多个准备工作,需全部完成才能开始);
  • 初始化:dp[i] = len[i](若杂务 i 无准备工作,完成时间就是自身的时间);
  • 最终结果:所有杂务的dp[i]中的最大值(因为最后完成的杂务决定了总时间)。

代码实现:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int N = 10010;

int n;
vector<int> edges[N];    // 邻接表:edges[u] 存储依赖u的杂务v(u→v)
int in_degree[N];        // 入度数组
int len[N];              // len[i]:杂务i的完成时间
int dp[N];               // dp[i]:完成杂务i所需的最短时间

int main() {
    cin >> n;

    // 初始化
    memset(in_degree, 0, sizeof in_degree);
    memset(len, 0, sizeof len);
    memset(dp, 0, sizeof dp);

    // 读入每个杂务的信息
    for (int i = 1; i <= n; i++) {
        int id, l, prep;
        cin >> id >> l;
        len[id] = l;
        dp[id] = l;  // 初始化:完成时间至少是自身的时间
        while (cin >> prep, prep != 0) {
            edges[prep].push_back(id);  // 准备工作prep→杂务id
            in_degree[id]++;
        }
    }

    // Kahn算法:拓扑排序 + 动态规划(最长路径)
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (in_degree[i] == 0) {
            q.push(i);
        }
    }

    int max_time = 0;  // 完成所有杂务的最短时间
    while (!q.empty()) {
        int u = q.front();
        q.pop();

        // 更新max_time
        if (dp[u] > max_time) {
            max_time = dp[u];
        }

        // 遍历所有依赖u的杂务v
        for (int v : edges[u]) {
            // 状态转移:完成v的时间 = max(当前时间, 完成u的时间 + v的时间)
            if (dp[v] < dp[u] + len[v]) {
                dp[v] = dp[u] + len[v];
            }
            in_degree[v]--;
            if (in_degree[v] == 0) {
                q.push(v);
            }
        }
    }

    cout << max_time << endl;

    return 0;
}

运行结果:

示例输入中,杂务 7 的依赖最多(3、5、6),其完成时间是dp[3] + len[7] = (dp[2] + 3) + 4 = (dp[1] + 2 + 3) + 4 = (5 + 2 + 3) + 4 = 14?不,实际计算中,杂务 5 的完成时间是max(dp[2], dp[4]) + 1 = max(7, 11) + 1 = 12,杂务 6 的完成时间是max(dp[2], dp[4]) + 8 = max(7, 11) + 8 = 19,杂务 7 的完成时间是max(dp[3], dp[5], dp[6]) + 4 = max(9, 12, 19) + 4 = 23,因此总时间为 23,与示例一致。

四、拓扑排序的常见问题与注意事项

在实际应用中,拓扑排序容易出现一些细节错误,以下是常见问题的总结:

4.1 图的存储方式选择

  • 稀疏图(边数 m ≈ n) :优先使用邻接表vector<int> edges [N]),空间效率高,遍历速度快;
  • 稠密图(边数 m ≈ n²) :可使用邻接矩阵int edges [N][N]),但实际应用中稀疏图更常见,邻接表是首选。

4.2 入度数组的初始化

  • 入度数组必须初始化为 0,否则会出现垃圾值,导致入度判断错误;
  • 对于边 <u, v>,必须将 v 的入度加 1,而不是 u 的入度。

4.3 环检测的正确性

  • Kahn 算法中,环检测的依据是 "拓扑序列长度是否等于顶点数",这是绝对正确的(因为环中的顶点入度永远无法减为 0,不会被加入序列);
  • DFS 算法中,必须使用in_stack 数组检测回边,仅使用visited数组无法区分 "已处理完的顶点" 和 "当前路径上的顶点",会导致环检测错误。

4.4 拓扑序列的不唯一性

  • 一个 DAG 图的拓扑序列可能有多个,这是正常现象(比如示例 1 中,入度为 0 的顶点可能有多个,队列的取出顺序不同会导致序列不同);
  • 题目中若要求 "输出任意一种序列",则无需关心顺序;若要求 "字典序最小",则需要使用优先队列(小根堆)替代普通队列,每次取出入度为 0 的顶点中编号最小的。

4.5 大规模图的处理

  • 对于 n=1e5、m=1e6 的大规模图,必须使用 Kahn 算法(迭代实现),避免 DFS 的递归栈溢出;
  • 邻接表的存储需使用 vector,避免使用数组导致空间浪费。

4.6 字典序最小的拓扑序列(拓展)

如果题目要求输出 "字典序最小" 的拓扑序列(比如顶点编号从小到大),只需将 Kahn 算法中的普通队列替换为优先队列(小根堆),每次取出入度为 0 的顶点中编号最小的。

示例代码(基于例题 1 修改):

cpp 复制代码
#include <iostream>
#include <vector>
#include <priority_queue>  // 优先队列(小根堆)
using namespace std;

const int N = 110;

int n;
vector<int> edges[N];
int in_degree[N];
vector<int> topo_order;

int main() {
    cin >> n;
    memset(in_degree, 0, sizeof in_degree);

    for (int i = 1; i <= n; i++) {
        int j;
        while (cin >> j, j != 0) {
            edges[i].push_back(j);
            in_degree[j]++;
        }
    }

    // 小根堆:每次取出入度为0的顶点中编号最小的
    priority_queue<int, vector<int>, greater<int>> pq;
    for (int i = 1; i <= n; i++) {
        if (in_degree[i] == 0) {
            pq.push(i);
        }
    }

    while (!pq.empty()) {
        int u = pq.top();
        pq.pop();
        topo_order.push_back(u);

        for (int v : edges[u]) {
            in_degree[v]--;
            if (in_degree[v] == 0) {
                pq.push(v);
            }
        }
    }

    for (int x : topo_order) {
        cout << x << " ";
    }
    cout << endl;

    return 0;
}

总结

拓扑排序是图论中的基础算法,也是解决 "依赖关系" 问题的万能钥匙。希望本文能帮助你彻底掌握这一知识点,在后续的学习和实践中灵活运用。如果有任何疑问或建议,欢迎在评论区留言讨论!

相关推荐
Bruce_kaizy3 小时前
c++图论——基础拓扑排序
图论
如意鼠3 小时前
大模型教我成为大模型算法工程师之day20: 预训练语言模型 (Pre-trained Language Models)
人工智能·算法·语言模型
良木生香3 小时前
【诗句结构-初阶】详解栈和队列(2)---队列
c语言·数据结构·算法·蓝桥杯
郝学胜-神的一滴3 小时前
使用EBO绘制图形:解锁高效渲染与内存节省之道
c++·qt·游戏·设计模式·系统架构·图形渲染
yaoh.wang3 小时前
力扣(LeetCode) 69: x 的平方根 - 解法思路
python·算法·leetcode·面试·职场和发展·牛顿法·二分法
!停3 小时前
数据在内存中的存储(2)
开发语言·c++·算法
认真学GIS3 小时前
逐3小时降水量!全国2421个气象站点1951-2024年逐3小时尺度长时间序列降水量(EXCEL格式)数据
人工智能·算法·机器学习
智航GIS3 小时前
ArcGIS大师之路500技---039趋势面法
算法·arcgis
量子炒饭大师3 小时前
Cyber骇客的LIFO深渊与FIFO管道 ——【初阶数据结构与算法】栈与队列
c语言·数据结构·c++·链表