🔼 详细讲解 C++ 中的有向无环图(DAG)和拓扑排序(Topological Sort)
1. 先说"有向无环图"
| 概念 | 详细说明 |
|---|---|
| 有向图(Directed Graph) | 每条边都有 起点 → 终点,顺序是重要的。 |
| 无环(Acyclic) | 没有从任意一个顶点出发沿着边走回到自己。 |
| 有向无环图(DAG) | 同时满足「有向」且「无环」的图。 |
| 特点 | • 可以对顶点做 线性排序(拓扑序)。<br>• 常见于项目任务调度、编译依赖、课程排课等。 |
举例
下面这张图就是一个 6‑节点的 DAG(可更新)。
A ──► B ──►C
│ │ ▲
▼ ▼ │
D ──► E ──► F
A → B,B → C,C → F、A → D、D → E、E → F- 你可以看到,没有任何一条路会回到起点。
2. 在 C++ 中表示 DAG
2.1 数据结构:邻接表(Adjacency List)
采用
std::vector<std::vector<int>>或std::vector<std::vector<Edge>>,最内层包含所有指向后续节点 。这样即可按 边数 O(E) 进行遍历和更新。
cpp
#include <vector>
#include <unordered_map>
#include <iostream>
#include <queue>
#include <stack>
#include <list>
/* 用 0‑n-1 编号的有向图: 0 是顶点 A,1 是 B,以此类推。 */
using Graph = std::vector<std::vector<int>>; // 这里使用 int 代表顶点索引
// 建图函数(从外部的边列表构建)
Graph buildGraph(int n, const std::vector<std::pair<int, int>>& edges) {
Graph g(n);
for (auto [u, v] : edges) {
g[u].push_back(v); // u -> v
}
return g;
}
2.2 另一个常见的实现:unordered_map<std::string, std::vector<std::string>>
如果你想用「顶点名称」而不是整数:
cpp
using UGraph = std::unordered_map<std::string, std::vector<std::string>>;
UGraph buildUGraph(const std::vector<std::pair<std::string, std::string>>& edges) {
UGraph g;
for (const auto& e : edges) {
g[e.first].push_back(e.second); // 注意:不存在的 key 会自动生成空 vector
}
return g;
}
注意 :顶点名字的集合
keys必须完整,否则后面访问将产生空指针。
3. 判断是否真的 DAG(无环检测)
"拓扑排序" 的实现本身可以检测环 ,但我们也可以单独检测它。两种常用方法:
3.1 DFS + 颜色标记
0→ 未访问1→ 当前递归栈中(灰色)2→ 完全访问(黑色)
cpp
bool dfsCycle(const Graph& g, int u,
std::vector<int>& color) {
color[u] = 1; // 灰色
for (int v : g[u]) {
if (color[v] == 0 && dfsCycle(g, v, color))
return true; // 发现回边
if (color[v] == 1) // 同一递归栈里再次访问 -> 循环
return true;
}
color[u] = 2; // 黑色,完全访问
return false;
}
bool hasCycle(const Graph& g) {
int n = g.size();
std::vector<int> color(n, 0);
for (int i = 0; i < n; ++i) {
if (color[i] == 0 && dfsCycle(g, i, color))
return true;
}
return false;
}
3.2 Kahn 的拓扑算法(也能检测环)
- 计算每个点的 入度(in‑degree)
- 采用 队列:所有入度为 0 的点都可先出队
- 每弹出一个点,减少其相邻点的入度
- 若最终弹出的点数 < n,则图里存在循环。
cpp
bool hasCycleWithKahn(const Graph& g) {
int n = g.size();
std::vector<int> indeg(n, 0);
for (int u = 0; u < n; ++u)
for (int v : g[u]) indeg[v]++;
std::queue<int> q;
for (int i = 0; i < n; ++i) if (indeg[i] == 0) q.push(i);
int cnt = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
cnt++;
for (int v : g[u]) {
if (--indeg[v] == 0) q.push(v);
}
}
return cnt != n; // 若cnt < n → 存在环
}
关键点 :Kahn算法在运行过程中会得到一条拓扑序列;如果序列长度不足
n,那必然有环。
4. 拓扑排序
目的 :得到一个顶点序列,使得每条边
u → v都满足u在v之前。
4.1 DFS 版本(递归或显式栈)
- 用
visited探索 - 递归结束后 push 该点到
stack - 最后 逆序(或者直接返回栈的反向迭代)
cpp
void dfsTopo(const Graph& g, int u,
std::vector<bool>& vis,
std::stack<int>& st) {
vis[u] = true;
for (int v : g[u]) if (!vis[v]) dfsTopo(g, v, vis, st);
st.push(u); // 递归返回时再 push
}
std::vector<int> topoSortDFS(const Graph& g) {
int n = g.size();
std::vector<bool> vis(n, false);
std::stack<int> st;
for (int i = 0; i < n; ++i)
if (!vis[i]) dfsTopo(g, i, vis, st);
std::vector<int> order;
while (!st.empty()) {
order.push_back(st.top());
st.pop();
}
return order; // 从前到后:拓扑序
}
复杂度 :
O(V + E),递归深度为O(V)。
优点 :代码简短,天然适合递归式 DFS。
缺点:对非常深的图递归容易栈溢出(可改用显式栈)。
4.2 Kahn 算法(迭代版本)
- 先算 入度
- 用 队列(或栈)存所有入度为 0 的点
- 每次弹出点
u,把u放到结果中 - 对
u的每条出边u→v,把indeg[v]--,indeg[v]==0时入队
cpp
std::vector<int> topoSortKahn(const Graph& g) {
int n = g.size();
std::vector<int> indeg(n, 0);
for (int u = 0; u < n; ++u)
for (int v : g[u]) indeg[v]++;
std::queue<int> q;
for (int i = 0; i < n; ++i)
if (indeg[i] == 0) q.push(i);
std::vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : g[u]) {
if (--indeg[v] == 0) q.push(v);
}
}
if (order.size() != n) {
throw std::runtime_error("Graph has a cycle -- no topological order");
}
return order;
}
复杂度 :
O(V + E)
优点 :直接用队列,避免递归栈溢出;
缺点:需要显式维护入度数组。
5. 完整示例:把所有代码拼在一起
cpp
/* 𝑉 = 6 , 顶点 0=A, 1=B, 2=C, 3=D, 4=E, 5=F */
#include <bits/stdc++.h>
using namespace std;
using Graph = vector<vector<int>>;
// ① 构图
Graph buildGraph(int n, const vector<pair<int,int>>& edges) {
Graph g(n);
for (auto [u,v] : edges) g[u].push_back(v);
return g;
}
// ② 递归 DFS 拓扑排序
void dfsTopo(const Graph& g, int u, vector<bool>& vis, stack<int>& st) {
vis[u] = true;
for (int v : g[u]) if (!vis[v]) dfsTopo(g,v,vis,st);
st.push(u);
}
vector<int> topoDFS(const Graph& g) {
int n=g.size();
vector<bool> vis(n,false);
stack<int> st;
for(int i=0;i<n;++i) if(!vis[i]) dfsTopo(g,i,vis,st);
vector<int> order;
while(!st.empty()){order.push_back(st.top()); st.pop();}
return order;
}
// ③ Kahn 拓扑排序
vector<int> topoKahn(const Graph& g) {
int n=g.size();
vector<int> indeg(n,0);
for(int u=0;u<n;++u) for(int v : g[u]) indeg[v]++;
queue<int> q;
for(int i=0;i<n;++i) if(indeg[i]==0) q.push(i);
vector<int> res;
while(!q.empty()){
int u=q.front();q.pop();
res.push_back(u);
for(int v: g[u]) if(--indeg[v]==0) q.push(v);
}
if(res.size()!=n) throw runtime_error("Cycle detected");
return res;
}
// ④ 主函数演示
int main() {
vector<pair<int,int>> eds = {
{0,1}, {0,3},
{1,2}, {1,4},
{2,5}, {4,5}
};
Graph g = buildGraph(6, eds);
// 先确认无环
if (hasCycle(g)) {
cerr << "Graph is NOT a DAG!\n";
return 0;
}
// 取两种实现的拓扑序
auto ord1 = topoDFS(g);
auto ord2 = topoKahn(g);
auto print = [](const vector<int>& ord){
for(int v: ord) cerr << char('A'+v) << ' ';
};
cerr << "DFS topo: ";
print(ord1);
cerr << "\nKahn topo: ";
print(ord2);
cerr << endl;
}
编译
g++ -std=c++17 -O2 -pipe -static -s -o dag_topo dag_topo.cpp ./dag_topo输出(示例)
DFS topo: A D E B C F Kahn topo: A D B E C F
两个输出均满足「所有边从前往后」。
6. 关键概念与常见陷阱
| 小贴士 | 说明 |
|---|---|
| 顶点编号 | 采用 0‑n-1 或者别名(字符串)都行,但一定统一。 |
| 自环(self‑loop) | u → u 永远会导致环。不要忘记检查。 |
| 图的密度 | 采用邻接矩阵(vector<vector<bool>>)会占 O(V^2) 空间,除非 V 非常小。 |
| 递归栈深度 | 对于 V 超过 10^5 的图,建议不使用递归 DFS(改为显式栈或 Kahn)。 |
| 多起点 | 拓扑排序不必从 0 开始,而要遍历所有未被访问的节点,保证"全部"被排序。 |
| 可忽略 | 路径/边权重不影响拓扑排序。 |
7. 拓扑排序的实际运用实例
-
编译顺序
- 头文件 / 模块之间的依赖关系
- 先编译
main所需的utils再编译utils自身
-
课程安排
- 先修
数学 101→数学 201
- 先修
-
构造灯塔链表
- 把依赖关系做成
next指针,使用拓扑排序来决定插入位置
- 把依赖关系做成
-
任务调度
- 需要在 8h 的工作日内完成若干任务,先安排必须先做的技能(例如
法律 → 税务 → 合同)。
- 需要在 8h 的工作日内完成若干任务,先安排必须先做的技能(例如
8. 进一步阅读 & 练习
| 主题 | 书籍 / 参考 | 练习题 |
|---|---|---|
| Topological Sorting | 《算法导论》(CLRS)第 22 章 | 1️⃣ 把一个有向无环图拼成最短路径树<br>2️⃣ 找到所有「起点」与「终点」 |
| DAG Applications | 《高性能图计算》 | 1️⃣ 代码执行计划排程<br>2️⃣ 依赖分析的 Makefile 生成 |
| DFS/tree-acyclic | 《算法设计手册》 | 1️⃣ 找到无向图中的「桥」与「割点」 |
你可以直接在上面 C++ 示例的
main()函数里改不同的图,验证topoSortKahn()与topoDFS()的结果。记得在修改边后再次
hasCycle()检测,以免出现奇怪的报错。
🎉 小结
-
DAG :有向且无环的图,能被 拓扑排序。
-
实现:
- 使用邻接表 (
vector<vector<int>>) - 通过 DFS 或 Kahn 算法得到拓扑序
- 任何算法都能检测环。
- 使用邻接表 (
-
复杂度 :均为
O(V + E)。 -
实际意义:项目管理、编译器、调度等多方面实际需求。
如果你对任何一个步骤还不太清楚,欢迎再回到某一点细化。
祝你在 C++ 图算法的路上愉快 🚀!