我们经常会遇到存在依赖关系 的问题:比如课程先修、任务调度、路径规划(只能单向移动)等。这类问题抽象到图论中,就是有向无环图(DAG)的处理,而拓扑排序就是解决这类问题的核心算法。
一、核心概念
1.1 什么是拓扑排序
拓扑排序是对 ** 有向无环图(DAG)** 的所有顶点进行线性排序,使得对于图中任意一条有向边 u → v,顶点 u 在排序结果中一定出现在 v 之前。
简单来说:把有依赖关系的节点排成一个合法的线性序列。
1.2 核心前提:必须是有向无环图(DAG)
拓扑排序仅适用于无环的有向图:
- 如果图中有环(比如 A→B,B→A),则不存在合法的拓扑序列
- 本题中地窖只能从小编号移动到大编号,天然无环,适配拓扑排序
1.3 两种经典实现算法
- Kahn 算法(入度表 + 队列,最常用) 统计每个节点的入度,不断将入度为 0 的节点加入队列,删除其出边,重复操作直到所有节点处理完毕。
- DFS 拓扑排序 深度优先遍历,节点递归结束后加入栈,最终倒序输出即为拓扑序列。
二、拓扑排序的经典应用场景
拓扑排序不只是排序工具,更是解决DAG 相关问题的利器,核心应用:
- 判断有向图是否存在环;
- DAG 上的最短 / 最长路径(本题核心考点);
- 任务调度、课程安排、编译依赖等依赖关系问题;
- 有向图的节点优先级排序。
三、例题:NOIP1996 挖地雷(P2196)
3.1 题目描述
有
N(N≤20)个地窖,每个地窖有固定数量的地雷,地窖之间有单向路径:
- 只能从当前地窖移动到编号更大且联通的地窖;
- 从任意地窖开始挖,无满足条件的节点时结束;
- 求能挖到的最多地雷数 ,并输出挖地雷的路径。
3.2 题目关键分析
- 图的建模 :地窖为节点,联通路径为有向边
i→j (j>i),构成有向无环图(DAG); - 问题转化 :求 DAG 上的最长路径(路径权值为地雷数之和);
- 对比暴力 DFS :你之前写的 DFS 可以通过本题(N≤20),但数据量变大后会超时;而拓扑排序 + DP 时间复杂度仅
O(N+M),是最优解。
3.3 解题思路
- 建图:用邻接表存储地窖的连接关系,统计每个节点的入度;
- 拓扑排序:用 Kahn 算法得到 DAG 的拓扑序列;
- 动态规划 :
- 定义
dp[i]:以节点i为终点的最大地雷数; - 转移方程:
dp[j] = max(dp[j], dp[i] + boom[j])(i→j有边);
- 定义
- 路径记录:用前驱数组记录每个节点的上一个节点,最终回溯得到最优路径。
3.4 代码实现(拓扑排序 + DP + 路径记录)
cpp
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
const int MAXN = 25;
int n;
int boom[MAXN]; // 每个地窖的地雷数
vector<int> adj[MAXN]; // 邻接表存图
int in_degree[MAXN]; // 入度表
int dp[MAXN]; // dp[i]: 以i为终点的最大地雷数
int pre[MAXN]; // 记录路径前驱节点
vector<int> topo; // 拓扑序列
// Kahn算法拓扑排序
void topological_sort() {
queue<int> q;
// 入度为0的节点入队
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.push_back(u);
// 遍历邻接点,更新入度
for (int v : adj[u]) {
in_degree[v]--;
if (in_degree[v] == 0) {
q.push(v);
}
}
}
}
// 回溯输出路径
void print_path(int x) {
if (pre[x] == 0) {
cout << x;
return;
}
print_path(pre[x]);
cout << " " << x;
}
int main() {
cin >> n;
// 输入地雷数
for (int i = 1; i <= n; ++i) {
cin >> boom[i];
dp[i] = boom[i]; // 初始化:每个节点自身为起点,地雷数为自身值
pre[i] = 0; // 初始化前驱为0
}
// 输入连接关系,建图
for (int i = 1; i <= n-1; ++i) {
for (int j = i+1; j <= n; ++j) {
int x;
cin >> x;
if (x == 1) {
adj[i].push_back(j); // i->j 有边
in_degree[j]++; // j的入度+1
}
}
}
// 1. 拓扑排序
topological_sort();
// 2. 按拓扑序列DP求最长路径
for (int u : topo) {
for (int v : adj[u]) {
if (dp[v] < dp[u] + boom[v]) {
dp[v] = dp[u] + boom[v];
pre[v] = u; // 记录前驱
}
}
}
// 3. 找最大地雷数对应的终点
int max_boom = 0, end_node = 1;
for (int i = 1; i <= n; ++i) {
if (dp[i] > max_boom) {
max_boom = dp[i];
end_node = i;
}
}
// 4. 输出路径和结果
print_path(end_node);
cout << endl << max_boom << endl;
return 0;
}
3.5 代码解析
- 建图 :严格按照题目输入格式,用邻接表存储
i→j的有向边,统计入度; - 拓扑排序:Kahn 算法生成拓扑序列,保证处理节点时,所有前驱节点已处理完毕;
- DP 转移:按拓扑序列遍历,更新每个节点的最大地雷数,同时记录前驱;
- 路径回溯:从最优终点递归回溯前驱节点,输出合法路径。
输入样例:
5
10 8 4 7 6
1 1 1 0
0 0 0
1 1
1
输出结果:
1 3 4 5
27
四、拓扑排序解题通用模板
4.1 Kahn 算法拓扑排序模板
cpp
vector<int> adj[MAXN];
int in_degree[MAXN];
vector<int> topo;
void topo_sort(int n) {
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.push_back(u);
for(int v:adj[u]){
if(--in_degree[v]==0) q.push(v);
}
}
}
4.2 DAG 最长路径通用模板
cpp
int dp[MAXN];
// 初始化dp[i] = 节点i的权值
for(auto u:topo){
for(auto v:adj[u]){
if(dp[v] < dp[u] + weight[v]){
dp[v] = dp[u] + weight[v];
pre[v] = u; // 记录路径
}
}
}
五、总结
- 拓扑排序 :仅适用于有向无环图(DAG),核心是将依赖关系线性化;
- 最优应用 :DAG 上的最长 / 最短路径,拓扑排序 + DP 是标准解法;
- 本题关键:地窖只能向小编号到大编号移动,天然构成 DAG,完美适配拓扑排序。