图论#

存图方式(邻接表/矩阵/边)、DFS/BFS 遍历、Bellman-Ford、Dijkstra、Floyd、多叉树、生成树、拓扑排序与强连通。

图论#

存图方式#

邻接表#

无权图:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))

    node1 --> node2 --> node3 --> node4
    node2 --> node4
    node4 --> node1
    
vector<int> adj[N];

adj[1].push_back(2);
adj[2].push_back(3);
adj[2].push_back(4);
adj[3].push_back(4);
adj[4].push_back(1);

带权图:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))

    node1 -- 5 --> node2 -- 7 --> node3 -- 5 --> node4
    node2 -- 6 --> node4
    node4 -- 2 --> node1
    
vector<pair<int, int>> adj[N];

adj[1].push_back({2, 5});
adj[2].push_back({3, 7});
adj[2].push_back({4, 6});
adj[3].push_back({4, 5});
adj[4].push_back({1, 2});

遍历从节点 s 出发能够到达的所有节点:

for (auto u : adj[s]) {
    // process node u
}

邻接矩阵#

无权图:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))

    node1 --> node2 --> node3 --> node4
    node2 --> node4
    node4 --> node1
    

带权图:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))

    node1 -- 5 --> node2 -- 7 --> node3 -- 5 --> node4
    node2 -- 6 --> node4
    node4 -- 2 --> node1
    

不论是无权图还是带权图,都可以用下面的二维数组表示:

int adj[N][N];

#

无权图:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))

    node1 --> node2 --> node3 --> node4
    node2 --> node4
    node4 --> node1
    
vector<pair<int, int>> edges;

edges.push_back({1, 2});
edges.push_back({2, 3});
edges.push_back({2, 4});
edges.push_back({3, 4});
edges.push_back({4, 1});

带权图:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))

    node1 -- 5 --> node2 -- 7 --> node3 -- 5 --> node4
    node2 -- 6 --> node4
    node4 -- 2 --> node1
    
vector<tuple<int, int, int>> edges;

edges.push_back({1, 2, 5});
edges.push_back({2, 3, 7});
edges.push_back({2, 4, 6});
edges.push_back({3, 4, 5});
edges.push_back({4, 1, 2});

图的遍历#

深度优先遍历:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))
    node5((5))

    node1 --- node2 --- node3 --- node5
    node2 --- node5
    node1 --- node4
    
vector<int> adj[N];
bool visited[N];

void dfs(int s) {
    if (visited[s]) return;
    visited[s] = true;
    // process node s
    for (auto u : adj[s]) {
        dfs(u);
    }
}

广度优先遍历:

        flowchart LR
    node1((1))
    node2((2))
    node3((3))
    node4((4))
    node5((5))
    node6((6))

    node1 --- node2 --- node3 --- node6
    node2 --- node5
    node5 --- node6
    node1 --- node4
    
queue<int> q;
bool visited[N];
int distance[N];

void bfs(queue<int>& q, bool& visited, int& distance) {
    visited[x]  = true;
    distance[x] = 0;
    q.push(x);
    while (!q.empty()) {
        int s = q.front();
        q.pop();
        // process node s
        for (auto u : adj[s]) {
            if (visited[u]) continue;
            visited[u]  = true;
            distance[u] = distance[s] + 1;
            q.push(u);
        }
    }
}

最短路径算法#

Bellman-Ford 算法#

用于解决单源最短路径问题(不能包含负权环)。

for (int i = 1; i <= n; i++)
    distance[i] = INF;
distance[x] = 0;
for (int i = 1; i <= n - 1; i++) {
    for (auto e : edges) { // 用边存图
        int a, b, w;
        tie(a, b, w) = e;
        distance[b]  = min(distance[b], distance[a] + w);
    }
}

Note

SPFA 算法是 Bellman-Ford 的优化版本。

Dijkstra 算法#

Dijkstra 比 Bellman-Ford 更加高效,因为它只遍历每条边一次。

for (int i = 1; i <= n; i++)
    distance[i] = INF;
distance[x] = 0;
q.push({0, x}); // 必须使用优先队列
while (!q.empty()) {
    int a = q.top().second;
    q.pop();
    if (processed[a]) continue;
    processed[a] = true;
    for (auto u : adj[a]) { // 邻接表
        int b = u.first, w = u.second;
        if (distance[a] + w < distance[b]) {
            distance[b] = distance[a] + w;
            q.push({-distance[b], b});
        }
    }
}

Floyd 算法#

// LeetCode 1334
// 状态转移方程
vector memo(n, vector(n, vector<int>(n))); // 记忆化搜索去掉重复计算
auto dfs = [&](this auto&& dfs, int k, int i, int j) -> int {
    if (k < 0) { // 递归边界
        return w[i][j];
    }
    auto& res = memo[k][i][j]; // 注意这里是引用(不用引用时是值传递,无法修改 memo[k][i][j])
    if (res) {                 // 之前计算过
        return res;
    }
    return res = min(dfs(k - 1, i, j), dfs(k - 1, i, k) + dfs(k - 1, k, j));
};

多叉树#

遍历多叉树#

void dfs(int s, int e) {
    // process node s
    for (auto u : adj[s]) {
        if (u != e) dfs(u, s);
    }
}

s 表示当前节点,e 表示前一个节点。u != e 表示不能访问已经访问过的节点。初始条件如下:

dfs(x, 0);

动态规划#

计算每个节点的子节点数量。

void dfs(int s, int e) {
    count[s] = 1;
    for (auto u : adj[s]) {
        if (u == e) continue;
        dfs(u, s);
        count[s] += count[u];
    }
}

生成树#

最小生成树(MST):连接所有顶点且边权和最小的树。两种经典算法:

并查集#

并查集(Union-Find / DSU):维护元素所属集合,支持近似 \(O(1)\)(反阿克曼函数)的查询合并,是 Kruskal 与连通性问题的基石。

struct DSU {
    vector<int> parent, sz; // sz 记录集合大小(按大小合并)
    DSU(int n) : parent(n), sz(n, 1) { iota(parent.begin(), parent.end(), 0); }

    int find(int x) { // 路径压缩
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }

    bool unite(int a, int b) { // 合并,返回是否真的合并了
        a = find(a);
        b = find(b);
        if (a == b) return false;
        if (sz[a] < sz[b]) swap(a, b); // 小集合并入大集合
        parent[b]  = a;
        sz[a]     += sz[b];
        return true;
    }

    bool same(int a, int b) { return find(a) == find(b); }
};

Note

路径压缩 + 按大小(或按秩)合并后,单次操作复杂度为 \(O(\alpha(n))\),可视为常数。 并查集还可维护带权(到根的距离)与种类(边权并查集,如食物链问题)。

Kruskal 算法#

按边权从小到大排序,依次尝试加入,用并查集判环(两点已连通则跳过)。复杂度 \(O(m \log m)\),适用于稀疏图

// edges: {w, u, v}
int kruskal(int n, vector<array<int, 3>>& edges) {
    sort(edges.begin(), edges.end());
    DSU dsu(n);
    int ans = 0, cnt = 0;
    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) {
            ans += w;
            if (++cnt == n - 1) break; // 已连成树
        }
    }
    return cnt == n - 1 ? ans : -1; // -1 表示图不连通
}

Prim 算法#

从任意顶点出发,每次把”距离当前树最近”的边纳入(用优先队列取最小),复杂度 \(O(m \log n)\),适用于稠密图(朴素版 \(O(n^2)\) 更优)。

// 邻接表 g[u]: {v, w}
int prim(int n, vector<vector<pair<int, int>>>& g) {
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
    vector<int> vis(n, 0), dis(n, INT_MAX);
    dis[0] = 0;
    pq.push({0, 0});
    int ans = 0, cnt = 0;
    while (!pq.empty()) {
        auto [d, u] = pq.top();
        pq.pop();
        if (vis[u]) continue;
        vis[u]  = 1;
        ans    += d;
        if (++cnt == n) break;
        for (auto& [v, w] : g[u])
            if (!vis[v] && w < dis[v]) {
                dis[v] = w;
                pq.push({w, v});
            }
    }
    return cnt == n ? ans : -1;
}

有向图#

拓扑排序#

对有向无环图(DAG)按依赖关系排序:Kahn 算法用队列维护入度为 0 的节点,逐层删除并更新入度。若最终入队节点数 < n,说明图中有环。

// 邻接表 g,入度数组 indeg
vector<int> topoSort(int n, vector<vector<int>>& g, vector<int>& indeg) {
    queue<int> q;
    for (int i = 0; i < n; i++)
        if (indeg[i] == 0) q.push(i);
    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);
    }
    return order.size() == n ? order : vector<int>(); // 空表示有环
}

Note

  • 拓扑排序可用于课程表(LC207/210)、任务依赖、DAG 上的 DP 顺序;

  • 要求字典序最小拓扑序时,把队列换成小根堆

  • 求图中是否有环:拓扑排序后节点数不足 n 即有环;DFS 三色法亦可判环。

强连通图#

强连通分量(SCC):有向图中两两互相可达的极大子图。缩点(把每个 SCC 压缩成一个点)后原图变成 DAG,可用于 2SAT、连通性问题。

Kosaraju 算法#

两次 DFS:① 按完成时间记录出栈序;② 在反向图上按出栈序逆序 DFS,每次遍历到的一个连通块即一个 SCC。复杂度 \(O(n + m)\)

// g 原图,rg 反向图;seq 为第一次 DFS 的出栈序
void kosaraju(int n, vector<vector<int>>& g, vector<vector<int>>& rg) {
    vector<int> vis(n, 0), seq;
    function<void(int)> dfs1 = [&](int u) {
        vis[u] = 1;
        for (int v : g[u])
            if (!vis[v]) dfs1(v);
        seq.push_back(u); // 记录出栈序
    };
    for (int i = 0; i < n; i++)
        if (!vis[i]) dfs1(i);

    vector<int> comp(n, -1);
    int cnt                       = 0;
    function<void(int, int)> dfs2 = [&](int u, int c) {
        comp[u] = c;
        for (int v : rg[u])
            if (comp[v] == -1) dfs2(v, c);
    };
    for (int i = n - 1; i >= 0; i--) // 按出栈序逆序在反向图 DFS
        if (comp[seq[i]] == -1) dfs2(seq[i], cnt++);
    // comp[u] 即 u 所属 SCC 编号,cnt 为 SCC 个数
}

2SAT 问题#

每个布尔变量 \(x_i\) 拆成两个节点(\(x_i\) 真 / 假),把形如 \((a \lor b)\) 的限制转为蕴含边 \((\neg a \to b,\ \neg b \to a)\),然后求 SCC:若 \(x_i\) 与其否定在同一 SCC 则无解;否则按拓扑序(SCC 编号)赋值——SCC 编号小的为真(Kosaraju 中先被访问的 SCC 拓扑序靠后,取 comp[x] > comp[¬x] 为真即可)。

Note

2SAT 的典型应用:安排互斥事件、配对问题(如每个点选 0/1 且满足若干”至少一个为真”的限制)。 完整模板:建图 → Kosaraju/Tarjan 求 SCC → 逐变量比较两节点 SCC 编号判断可满足性并赋值。