第42章 图论进阶:强连通分量、割点与桥、二分图匹配

图论学到这个阶段,咱们已经能处理不少实际问题了。但说实话,前面那些基础遍历和最短路径,在真实项目中往往不够用。我记得有一次做社交网络分析,需要找出用户群体中哪些人是「铁杆小圈子」——互相都关注的那种。这不就是强连通分量吗?

这一章,我带你啃下四个硬骨头:Tarjan算法Kosaraju算法割点与桥,还有二分图匹配。每个都是面试高频,也是工程实战利器。

图论进阶核心知识体系 图论进阶 Tarjan SCC Kosaraju SCC 割点与桥 二分图匹配 DFS + 栈 + low数组 正反两次DFS low[v] >= dfn[u] 匈牙利算法 核心思想:利用DFS序 + 时间戳 + 回溯更新 时间复杂度均为 O(V+E) 或 O(VE)

1. 强连通分量:Tarjan算法

强连通分量(SCC),说白了就是有向图里的一群节点,它们之间任意两点都能互相到达。我当年第一次写Tarjan时,被那个low数组绕晕了好几天。后来想通了——low[u]记录的是u能回溯到的最早祖先,就这么简单。

核心思想:DFS遍历时给每个节点打上时间戳dfn,同时维护一个low值。当dfn[u] == low[u]时,说明找到了一个SCC的根。

// Tarjan算法求SCC —— C语言实现
#define MAXN 10005
int dfn[MAXN], low[MAXN], idx;
int stack[MAXN], top, in_stack[MAXN];
int scc_id[MAXN], scc_cnt;
vector<int> G[MAXN];

void tarjan(int u) {
    dfn[u] = low[u] = ++idx;
    stack[++top] = u;
    in_stack[u] = 1;
    
    for (int i = 0; i < G[u].size(); i++) {
        int v = G[u][i];
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);  // 回溯更新
        } else if (in_stack[v]) {
            low[u] = min(low[u], dfn[v]);  // 后向边
        }
    }
    
    if (dfn[u] == low[u]) {
        scc_cnt++;
        int v;
        do {
            v = stack[top--];
            in_stack[v] = 0;
            scc_id[v] = scc_cnt;
        } while (v != u);
    }
}

我的小技巧:调试Tarjan时,我习惯打印每个节点的dfn和low值。如果发现某个节点的low值一直等于dfn,那它八成就是SCC的根节点。

2. Kosaraju算法:另一种思路

Kosaraju的思路更直白——先对原图做一次DFS记录出栈顺序,再对反图按出栈逆序做DFS。每次DFS找到的就是一个SCC。

我个人更喜欢Tarjan,因为一次DFS搞定,代码量小。但Kosaraju也有优势:思路好理解,不容易写错。

// Kosaraju算法框架
void dfs1(int u) {
    visited[u] = 1;
    for each v in G[u]:
        if (!visited[v]) dfs1(v);
    order.push_back(u);  // 记录出栈顺序
}

void dfs2(int u) {
    scc[u] = scc_cnt;
    for each v in revG[u]:
        if (!scc[v]) dfs2(v);
}

// 主流程
for each u in V:
    if (!visited[u]) dfs1(u);
for each u in reverse(order):
    if (!scc[u]) scc_cnt++, dfs2(u);

注意:Kosaraju需要存储反向图,空间开销翻倍。如果图特别大(比如上百万节点),Tarjan更省内存。

3. 割点与桥:Tarjan的变体

割点和桥解决的是无向图问题。割点就是删掉后图不连通的节点,桥就是删掉后图不连通的边。

判断条件其实就两条:

  • 割点:对于根节点,有至少两个子节点;对于非根节点,存在子节点v使得low[v] >= dfn[u]
  • 桥:对于边(u,v),如果low[v] > dfn[u],那么这条边就是桥

嗯,这里要注意——桥的判断没有等号。为什么?因为如果有等号,说明v还能绕回u,那这条边就不是唯一的连接了。

// 求割点
void cut_point(int u, int parent) {
    dfn[u] = low[u] = ++idx;
    int child = 0;
    
    for each v in G[u]:
        if (!dfn[v]) {
            child++;
            cut_point(v, u);
            low[u] = min(low[u], low[v]);
            if (parent != -1 && low[v] >= dfn[u])
                is_cut[u] = 1;  // u是割点
        } else if (v != parent) {
            low[u] = min(low[u], dfn[v]);
        }
    
    if (parent == -1 && child > 1)
        is_cut[u] = 1;  // 根节点特判
}

避坑指南:我曾经在求桥时把条件写成了low[v] >= dfn[u],结果把一堆普通边也标记成了桥。调试了一下午才发现是等号的问题。记住:桥是严格大于,割点才是大于等于。

4. 二分图判定与最大匹配

二分图判定其实很简单——用染色法。从任意节点开始,给相邻节点染不同颜色,如果出现冲突就不是二分图。

最大匹配用的是匈牙利算法。核心思想就一句话:能匹配就匹配,匹配不了就尝试让之前的匹配对象换一个

// 匈牙利算法求最大匹配
int match[MAXN], vis[MAXN];

int dfs(int u) {
    for each v in G[u]:
        if (!vis[v]) {
            vis[v] = 1;
            if (match[v] == -1 || dfs(match[v])) {
                match[v] = u;
                return 1;
            }
        }
    return 0;
}

int hungarian() {
    int res = 0;
    memset(match, -1, sizeof(match));
    for (int i = 1; i <= n; i++) {
        memset(vis, 0, sizeof(vis));
        if (dfs(i)) res++;
    }
    return res;
}

我的经验:匈牙利算法的时间复杂度是O(VE),对于稀疏图表现很好。但如果图比较稠密,建议用Hopcroft-Karp算法,能快到O(E√V)。

5. 四种算法的对比

算法 解决的问题 时间复杂度 空间复杂度 我的推荐指数
Tarjan SCC 有向图强连通分量 O(V+E) O(V) ⭐⭐⭐⭐⭐
Kosaraju 有向图强连通分量 O(V+E) O(V+E) ⭐⭐⭐⭐
Tarjan 割点/桥 无向图割点与桥 O(V+E) O(V) ⭐⭐⭐⭐⭐
匈牙利算法 二分图最大匹配 O(VE) O(V+E) ⭐⭐⭐⭐

说实话,这四个算法在面试中出现的频率非常高。尤其是Tarjan,几乎成了大厂图论题的标配。我建议你把Tarjan的模板背熟,理解low数组的更新逻辑,遇到类似问题就能举一反三了。

总结一下:这一章我们学了四种图论进阶算法。Tarjan和Kosaraju解决的是有向图的「连通性」问题,割点和桥解决的是无向图的「脆弱性」问题,匈牙利算法解决的是「配对」问题。每个算法都有它独特的应用场景,理解它们的核心思想比死记代码更重要。


公众号:蓝海资料掘金营,微信deep3321