详细讲解 C++ 有向无环图(DAG)及拓扑排序

🔼 详细讲解 C++ 中的有向无环图(DAG)和拓扑排序(Topological Sort)

1. 先说"有向无环图"

概念 详细说明
有向图(Directed Graph) 每条边都有 起点 → 终点,顺序是重要的。
无环(Acyclic) 没有从任意一个顶点出发沿着边走回到自己。
有向无环图(DAG) 同时满足「有向」且「无环」的图。
特点 • 可以对顶点做 线性排序(拓扑序)。<br>• 常见于项目任务调度、编译依赖、课程排课等。

举例

下面这张就是一个 6‑节点的 DAG(可更新)。

复制代码

A ──► B ──►C
│ │ ▲
▼ ▼ │
D ──► E ──► F

  • A → B, B → C, C → FA → DD → EE → 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 都满足 uv 之前。

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. 拓扑排序的实际运用实例

  1. 编译顺序

    • 头文件 / 模块之间的依赖关系
    • 先编译 main 所需的 utils 再编译 utils 自身
  2. 课程安排

    • 先修 数学 101数学 201
  3. 构造灯塔链表

    • 把依赖关系做成 next 指针,使用拓扑排序来决定插入位置
  4. 任务调度

    • 需要在 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 :有向且无环的图,能被 拓扑排序

  • 实现

    1. 使用邻接表 (vector<vector<int>>)
    2. 通过 DFS 或 Kahn 算法得到拓扑序
    3. 任何算法都能检测环。
  • 复杂度 :均为 O(V + E)

  • 实际意义:项目管理、编译器、调度等多方面实际需求。

如果你对任何一个步骤还不太清楚,欢迎再回到某一点细化。

祝你在 C++ 图算法的路上愉快 🚀!

相关推荐
欧米欧1 小时前
C++算法之双指针算法
开发语言·c++
承渊政道2 小时前
【递归、搜索与回溯算法】(掌握记忆化搜索的核心套路)
数据结构·c++·算法·leetcode·macos·动态规划·宽度优先
REDcker2 小时前
跨平台编译详解 工具链配置与工程化实践
linux·c++·windows·macos·c·跨平台·编译
闻缺陷则喜何志丹2 小时前
【 线性筛 调和级数】P7281 [COCI 2020/2021 #4] Vepar|普及+
c++·算法·洛谷·线性筛·调和级数
zzzsde2 小时前
【Linux】线程概念与控制(1)线程基础与分页式存储管理
linux·运维·服务器·开发语言·算法
穿条秋裤到处跑2 小时前
每日一道leetcode(2026.04.23):等值距离和
算法·leetcode·职场和发展
少许极端2 小时前
算法奇妙屋(四十九)-贡献法
java·算法·leetcode·贡献法
叶子野格2 小时前
《C语言学习:数组》11
c语言·开发语言·c++·学习·visual studio
武帝为此2 小时前
【特征选择方法】
算法·数学建模