第 19 章

强连通分量与网络流

第十九章:强连通分量与网络流

上一章我们解决了带权图上的两个经典优化问题——最短路径和最小生成树。这一章我们将进入图论的"高级领地":强连通分量(Strongly Connected Components, SCC)网络流(Network Flow)

强连通分量揭示了有向图的"宏观结构"——哪些节点之间可以互相到达?如果我们把每个强连通分量"缩"成一个超节点,整个图就变成一个 DAG(有向无环图),问题的复杂度大幅降低。这是图论中"化繁为简"的核心技术。

网络流则是一类看似理论化、实则应用极广的模型。从"管道中最多能流过多少水"到"如何将工人最优地分配给任务",网络流将大量组合优化问题统一到一个优雅的框架中。最大流最小割定理将"如何最大化流量"和"如何最小化破坏代价"这两个看似无关的问题完美联系在一起。

这一章的核心目标:(1) 掌握 Tarjan 算法求 SCC、割点和桥;(2) 理解 Kosaraju 算法的两次 DFS 设计;(3) 理解最大流问题和 Ford-Fulkerson 方法;(4) 了解二分图匹配与匈牙利算法;(5) 评估这些算法在面试中的考查频率和学习优先级。


Level 1 · 你需要知道的

19.1 强连通分量的概念

强连通:在有向图中,如果节点 u 可以到达 v,且 v 也可以到达 u,则称 u 和 v 是强连通的

强连通分量(SCC):一个极大的强连通子图——其中任意两个节点都是强连通的,且不能再加入任何外部节点仍保持强连通。

有向图示例:

    0 → 1 → 2
    ↑       ↓
    3 ← 4 ← 2
        ↓
        5 → 6
            ↑ ↓
            8 ← 7

强连通分量:
  SCC1: {0, 1, 2, 3, 4}  (这5个节点之间任意两个可互达)
  SCC2: {5}              (只有自己)
  SCC3: {6, 7, 8}        (6→7→8→6 形成环)

缩点后的 DAG:
  SCC1 → SCC2 → SCC3

为什么 SCC 重要?

  1. 简化问题:将 SCC 缩成单个节点后,原图变为 DAG。DAG 上的许多问题(拓扑排序、最长路径等)比一般有向图简单得多。
  2. 2-SAT 问题:判定布尔表达式可满足性(2-SAT)可以直接归约为 SCC 问题。
  3. 可达性分析:SCC 内部的节点互相可达,只需分析 SCC 之间的可达性。

19.2 Tarjan 算法

Tarjan 算法是求 SCC 最经典、最高效的方法。它只需要一次 DFS,时间复杂度 O(V + E)。

核心概念

关键判断:如果 dfn[u] == low[u],说明 u 是某个 SCC 的"根"——从 u 出发无法回到更早的祖先。此时从栈顶弹出直到 u(包括 u),这些节点组成一个 SCC。

from typing import List, Dict, Set

def tarjan_scc(graph: Dict[int, List[int]], n: int) -> List[List[int]]:
    """
    Tarjan 算法求强连通分量
    
    参数:
        graph: 有向图邻接表
        n: 节点数量 (0 到 n-1)
    
    返回:
        所有 SCC 的列表(每个 SCC 是一个节点列表)
    """
    dfn = [0] * n       # DFS 序
    low = [0] * n       # low 值
    on_stack = [False] * n
    stack = []
    timer = [0]         # 用列表模拟可变变量
    sccs = []
    
    def dfs(u: int):
        timer[0] += 1
        dfn[u] = low[u] = timer[0]
        stack.append(u)
        on_stack[u] = True
        
        for v in graph.get(u, []):
            if dfn[v] == 0:  # 未访问
                dfs(v)
                low[u] = min(low[u], low[v])
            elif on_stack[v]:  # 在栈中 = 在当前 SCC 候选中
                low[u] = min(low[u], dfn[v])
        
        # u 是 SCC 的根
        if dfn[u] == low[u]:
            scc = []
            while True:
                v = stack.pop()
                on_stack[v] = False
                scc.append(v)
                if v == u:
                    break
            sccs.append(scc)
    
    # 处理所有未访问的节点(图可能不连通)
    for i in range(n):
        if dfn[i] == 0:
            dfs(i)
    
    return sccs


# 使用示例
graph = {
    0: [1],
    1: [2],
    2: [3],
    3: [0, 4],
    4: [5],
    5: [6],
    6: [4, 7],
    7: []
}

sccs = tarjan_scc(graph, 8)
print("强连通分量:", sccs)
# 可能输出: [[7], [4, 6, 5], [0, 3, 2, 1]]
# 注意: SCC 的输出顺序是逆拓扑序

迭代版本(避免递归深度限制):

import sys

def tarjan_scc_iterative(graph: Dict[int, List[int]], n: int) -> List[List[int]]:
    """Tarjan 算法的迭代实现 - 避免递归栈溢出"""
    dfn = [0] * n
    low = [0] * n
    on_stack = [False] * n
    stack = []
    timer = [0]
    sccs = []
    
    for start in range(n):
        if dfn[start] != 0:
            continue
        
        # 手动模拟递归栈: (节点, 邻居迭代器位置)
        call_stack = [(start, 0)]
        timer[0] += 1
        dfn[start] = low[start] = timer[0]
        stack.append(start)
        on_stack[start] = True
        
        while call_stack:
            u, idx = call_stack[-1]
            neighbors = graph.get(u, [])
            
            if idx < len(neighbors):
                call_stack[-1] = (u, idx + 1)
                v = neighbors[idx]
                
                if dfn[v] == 0:
                    timer[0] += 1
                    dfn[v] = low[v] = timer[0]
                    stack.append(v)
                    on_stack[v] = True
                    call_stack.append((v, 0))
                elif on_stack[v]:
                    low[u] = min(low[u], dfn[v])
            else:
                # u 的所有邻居处理完毕
                if dfn[u] == low[u]:
                    scc = []
                    while True:
                        v = stack.pop()
                        on_stack[v] = False
                        scc.append(v)
                        if v == u:
                            break
                    sccs.append(scc)
                
                call_stack.pop()
                if call_stack:
                    parent = call_stack[-1][0]
                    low[parent] = min(low[parent], low[u])
    
    return sccs

19.3 割点与桥

割点(Articulation Point / Cut Vertex):如果删除某个节点(及其关联边)后,图的连通分量数增加,则该节点是割点。

桥(Bridge / Cut Edge):如果删除某条边后,图的连通分量数增加,则该边是桥。

割点和桥揭示了图的"脆弱点"——网络中的关键节点/链路。

def find_bridges_and_articulation_points(graph: Dict[int, List[int]], n: int):
    """
    使用 Tarjan 算法思路求无向图的割点和桥
    
    注意:这是在无向图上运行的!
    """
    dfn = [0] * n
    low = [0] * n
    timer = [0]
    bridges = []
    articulation_points = set()
    
    def dfs(u: int, parent: int):
        timer[0] += 1
        dfn[u] = low[u] = timer[0]
        children = 0  # u 在 DFS 树中的子节点数
        
        for v in graph.get(u, []):
            if v == parent:
                continue  # 忽略来时的边(无向图)
            
            if dfn[v] == 0:  # 树边
                children += 1
                dfs(v, u)
                low[u] = min(low[u], low[v])
                
                # 桥判定:v 无法不经过 (u,v) 到达 u 或 u 的祖先
                if low[v] > dfn[u]:
                    bridges.append((u, v))
                
                # 割点判定(非根情况)
                if parent != -1 and low[v] >= dfn[u]:
                    articulation_points.add(u)
            else:
                # 回边
                low[u] = min(low[u], dfn[v])
        
        # 根节点的割点判定:DFS 树中有 ≥2 个子节点
        if parent == -1 and children >= 2:
            articulation_points.add(u)
    
    for i in range(n):
        if dfn[i] == 0:
            dfs(i, -1)
    
    return bridges, articulation_points


# 示例
graph = {
    0: [1, 2],
    1: [0, 2],
    2: [0, 1, 3],
    3: [2, 4],
    4: [3, 5, 6],
    5: [4, 6],
    6: [4, 5]
}

bridges, aps = find_bridges_and_articulation_points(graph, 7)
print("桥:", bridges)      # [(2, 3), (3, 4)] 或类似
print("割点:", aps)        # {2, 3, 4} 或类似

判定逻辑解释

19.4 最大流问题概述

问题定义:给定一个有向图(流网络),有一个源点 s 和一个汇点 t,每条边有一个容量上限。求从 s 到 t 的最大流量。

约束条件

  1. 容量限制:每条边的流量 ≤ 容量。
  2. 流守恒:除 s 和 t 外,每个节点的流入量 = 流出量。
流网络示例:

    s ---10--→ A ---8--→ t
    |          ↑  ↘      ↑
    5          2    4     6
    |          |      ↘   |
    ↓          |       ↘  |
    B ---3--→  C ---6--→ t(B→C 容量3)

最大流 = 能从 s 推到 t 的最大总流量

19.5 Ford-Fulkerson 方法

Ford-Fulkerson 不是一个具体算法,而是一个方法框架:反复找从 s 到 t 的增广路径(augmenting path),沿路径增加流量,直到找不到增广路径为止。

残余图(Residual Graph):对于每条边 (u, v) 容量 c,流量 f:

增广路径:残余图中从 s 到 t 的路径。

from collections import deque

def bfs_find_path(residual: List[List[int]], source: int, sink: int, parent: List[int]) -> bool:
    """BFS 在残余图中找增广路径 (Edmonds-Karp)"""
    n = len(residual)
    visited = [False] * n
    visited[source] = True
    queue = deque([source])
    
    while queue:
        u = queue.popleft()
        for v in range(n):
            if not visited[v] and residual[u][v] > 0:
                visited[v] = True
                parent[v] = u
                if v == sink:
                    return True
                queue.append(v)
    
    return False


def edmonds_karp(capacity: List[List[int]], source: int, sink: int) -> int:
    """
    Edmonds-Karp 算法(Ford-Fulkerson + BFS)
    
    参数:
        capacity: capacity[u][v] = 边 (u,v) 的容量
        source: 源点
        sink: 汇点
    
    返回:
        最大流值
    """
    n = len(capacity)
    # 残余图初始等于容量图
    residual = [row[:] for row in capacity]
    
    max_flow = 0
    parent = [-1] * n
    
    while bfs_find_path(residual, source, sink, parent):
        # 找增广路径的瓶颈容量
        path_flow = float('inf')
        v = sink
        while v != source:
            u = parent[v]
            path_flow = min(path_flow, residual[u][v])
            v = u
        
        # 更新残余图
        v = sink
        while v != source:
            u = parent[v]
            residual[u][v] -= path_flow  # 正向减少
            residual[v][u] += path_flow  # 反向增加(允许退流)
            v = u
        
        max_flow += path_flow
        parent = [-1] * n
    
    return max_flow


# 示例
# 4 个节点: 0=源, 3=汇
capacity = [
    [0, 10, 10, 0],  # 0→1: 10, 0→2: 10
    [0, 0, 2, 8],    # 1→2: 2, 1→3: 8
    [0, 0, 0, 10],   # 2→3: 10
    [0, 0, 0, 0]
]

print(edmonds_karp(capacity, 0, 3))  # 18
# 路径1: 0→1→3, 流量 8
# 路径2: 0→2→3, 流量 10
# 总计 18(正好等于 s 的出边容量和 = min(10+10, 8+10) = 18)

Edmonds-Karp 复杂度:O(VE²)。每次 BFS 找最短增广路径保证了最多 O(VE) 次增广。

19.6 二分图最大匹配(匈牙利算法)

二分图(Bipartite Graph):节点可以分成两组 L 和 R,所有边都连接 L 中的节点和 R 中的节点。

最大匹配:找到最多的边,使得每个节点最多被一条边覆盖。

经典应用:工人分配任务、学生选课匹配、相亲配对等。

def hungarian(graph: Dict[int, List[int]], n_left: int, n_right: int) -> int:
    """
    匈牙利算法求二分图最大匹配
    
    参数:
        graph: 左侧节点的邻接表,graph[u] = [可匹配的右侧节点列表]
        n_left: 左侧节点数 (编号 0 到 n_left-1)
        n_right: 右侧节点数 (编号 0 到 n_right-1)
    
    返回:
        最大匹配数
    """
    # match_right[v] = 与右侧节点 v 匹配的左侧节点(-1 表示未匹配)
    match_right = [-1] * n_right
    
    def dfs(u: int, visited: List[bool]) -> bool:
        """尝试为左侧节点 u 找一个匹配"""
        for v in graph.get(u, []):
            if visited[v]:
                continue
            visited[v] = True
            
            # 如果 v 未匹配,或者 v 当前匹配的对象能找到其他匹配
            if match_right[v] == -1 or dfs(match_right[v], visited):
                match_right[v] = u
                return True
        
        return False
    
    result = 0
    for u in range(n_left):
        visited = [False] * n_right
        if dfs(u, visited):
            result += 1
    
    return result


# 示例:3个工人,4个任务
# 工人0: 可做任务 0, 1
# 工人1: 可做任务 1, 2
# 工人2: 可做任务 2, 3
graph = {
    0: [0, 1],
    1: [1, 2],
    2: [2, 3]
}

print(hungarian(graph, 3, 4))  # 3 (最大匹配: 工人0→任务0, 工人1→任务1, 工人2→任务2)

匈牙利算法的核心思想——增广路径

当为某个左节点 u 找匹配时,如果直接可选的右节点 v 已被占用,就尝试让 v 当前的匹配对象"让位"(去找其他匹配)。这个"让位"过程递归进行,本质上是在找一条从未匹配节点出发、交替经过非匹配边和匹配边、最终到达未匹配节点的"增广路径"。

时间复杂度:O(V × E)。对每个左节点做一次 DFS,每次 DFS 最多访问所有边。

19.7 完整应用示例

"""
综合示例:社交网络分析
"""

def social_network_analysis():
    """
    分析社交网络中的社群结构(SCC = 互相关注的群组)
    """
    # 关注关系(有向图)
    # 0关注1, 1关注2, 2关注0(互相关注环)
    # 3关注4, 4关注3(互相关注对)
    # 2关注3(单向)
    follow_graph = {
        0: [1],
        1: [2],
        2: [0, 3],
        3: [4],
        4: [3],
        5: [4]   # 5只关注4,没人关注5
    }
    
    sccs = tarjan_scc(follow_graph, 6)
    print("互相关注群组:", sccs)
    # [[3, 4], [0, 2, 1], [5]]
    # 群组1: {3,4} 互相关注
    # 群组2: {0,1,2} 互相关注
    # 群组3: {5} 孤立用户
    
    # 缩点后的 DAG 可用于分析信息传播路径
    print("群组间的传播方向: 群组{0,1,2} → 群组{3,4}")
    print("群组{5} → 群组{3,4}")


def task_assignment():
    """
    任务分配问题:最大化完成的任务数
    """
    # 5个开发者,6个任务
    # 每个开发者能做的任务
    developer_skills = {
        0: [0, 1, 2],      # 开发者0: 前端任务
        1: [1, 3],          # 开发者1: 部分前端+后端
        2: [2, 4],          # 开发者2: 前端+数据库
        3: [3, 4, 5],       # 开发者3: 后端+数据库+运维
        4: [5]              # 开发者4: 仅运维
    }
    
    max_tasks = hungarian(developer_skills, 5, 6)
    print(f"最多能同时完成 {max_tasks} 个任务")
    # 输出: 最多能同时完成 5 个任务


social_network_analysis()
task_assignment()

Level 2 · 它是怎么运行的

19.8 Kosaraju 算法(两次 DFS)

Kosaraju 算法是另一种求 SCC 的经典方法,概念上比 Tarjan 更直观,但需要两次 DFS。

核心思想

  1. 第一次 DFS:在原图上,按 DFS 完成顺序记录节点(后序遍历)。
  2. 反转图:将所有边方向反转。
  3. 第二次 DFS:按第一次 DFS 的逆后序(即拓扑排序序),在反转图上做 DFS。每次 DFS 能访问到的节点组成一个 SCC。

为什么正确? 直觉:

def kosaraju_scc(graph: Dict[int, List[int]], n: int) -> List[List[int]]:
    """
    Kosaraju 算法求强连通分量
    
    参数:
        graph: 有向图邻接表
        n: 节点数量
    
    返回:
        所有 SCC 的列表
    """
    # 步骤1:原图 DFS,记录后序遍历顺序
    visited = [False] * n
    finish_order = []  # 后序遍历顺序
    
    def dfs1(u: int):
        visited[u] = True
        for v in graph.get(u, []):
            if not visited[v]:
                dfs1(v)
        finish_order.append(u)
    
    for i in range(n):
        if not visited[i]:
            dfs1(i)
    
    # 步骤2:构建反转图
    reverse_graph: Dict[int, List[int]] = {i: [] for i in range(n)}
    for u in range(n):
        for v in graph.get(u, []):
            reverse_graph[v].append(u)
    
    # 步骤3:按逆后序在反转图上 DFS
    visited = [False] * n
    sccs = []
    
    def dfs2(u: int, scc: List[int]):
        visited[u] = True
        scc.append(u)
        for v in reverse_graph[u]:
            if not visited[v]:
                dfs2(v, scc)
    
    for u in reversed(finish_order):
        if not visited[u]:
            scc = []
            dfs2(u, scc)
            sccs.append(scc)
    
    return sccs


# 测试
graph = {0: [1], 1: [2], 2: [0, 3], 3: [4], 4: [3]}
print(kosaraju_scc(graph, 5))
# [[0, 1, 2], [3, 4]] — 两个 SCC

Tarjan vs Kosaraju 对比

特性 Tarjan Kosaraju
DFS 次数 1 2
需要反转图?
额外空间 栈 + dfn/low 数组 反转图 + finish_order
实现难度 较高(low 值理解) 较低(两次简单 DFS)
面试推荐 ✓(更高效) ✓(更好解释)
输出顺序 逆拓扑序 拓扑序

19.9 Tarjan 算法的 low 值详解

low[u] 的准确定义是:"从 u 的子树出发,通过恰好一条回边(back edge)或横叉边(cross edge to stack node),能到达的最小 dfn 值。"

容易混淆的点

# 对于树边 (u → v):
low[u] = min(low[u], low[v])   # 通过子树 v 能到达的最早祖先

# 对于回边 (u → v),v 在栈中:
low[u] = min(low[u], dfn[v])   # 注意是 dfn[v],不是 low[v]!
# 为什么用 dfn[v] 而不是 low[v]?
# 因为回边直接指向 v,能到达的是 v 本身(dfn[v])
# 如果用 low[v],会传递性地"穿过"多条回边,可能导致不同 SCC 的节点被错误合并

不过在实际实现中,使用 low[u] = min(low[u], low[v]) 对于回边也是正确的(在大多数情况下)——因为如果 v 在栈中且能通过 v 到达更早的节点,那 v 和 u 一定在同一个 SCC 中。很多实现(包括本章前面的代码)使用 dfn[v] 是因为这是 Tarjan 原论文的定义,理论上更精确。

执行过程追踪

图: 0→1→2→0, 2→3

DFS 开始于节点 0:
  访问 0: dfn[0]=1, low[0]=1, 栈=[0]
    访问 1: dfn[1]=2, low[1]=2, 栈=[0,1]
      访问 2: dfn[2]=3, low[2]=3, 栈=[0,1,2]
        邻居 0: 在栈中,low[2] = min(3, dfn[0]) = min(3,1) = 1
        邻居 3: 未访问
          访问 3: dfn[3]=4, low[3]=4, 栈=[0,1,2,3]
            无邻居
            dfn[3]==low[3] → SCC={3},从栈弹出3,栈=[0,1,2]
        low[2] = min(low[2], low[3]) = min(1, 4) = 1
      返回到1: low[1] = min(low[1], low[2]) = min(2, 1) = 1
    返回到0: low[0] = min(low[0], low[1]) = min(1, 1) = 1
    dfn[0]==low[0] → SCC={2,1,0},从栈弹出直到0

结果: SCC1={3}, SCC2={0,1,2}

19.10 最大流最小割定理

定理(Ford & Fulkerson, 1956):在任何流网络中,最大流的值等于最小割的容量。

割(Cut):将节点分为两组 S(包含 s)和 T(包含 t),割的容量 = 所有从 S 到 T 的边的容量之和。

直觉解释

应用:最小割的实际含义

  1. 网络安全:最少需要切断多少条通信链路才能使 s 和 t 断开连接?
  2. 图像分割:像素分前景/背景,最小化分割代价。
  3. 项目选择:选择一组项目使净收益最大(等价于最小割的对偶)。
def find_min_cut(capacity: List[List[int]], source: int, sink: int) -> Tuple[Set[int], Set[int]]:
    """
    找最小割:先求最大流,然后在残余图中从 source BFS 找可达节点
    """
    n = len(capacity)
    residual = [row[:] for row in capacity]
    parent = [-1] * n
    
    # 先运行 Edmonds-Karp 得到最终残余图
    while bfs_find_path(residual, source, sink, parent):
        path_flow = float('inf')
        v = sink
        while v != source:
            u = parent[v]
            path_flow = min(path_flow, residual[u][v])
            v = u
        v = sink
        while v != source:
            u = parent[v]
            residual[u][v] -= path_flow
            residual[v][u] += path_flow
            v = u
        parent = [-1] * n
    
    # 在残余图中从 source BFS 找所有可达节点 = S 集合
    visited = [False] * n
    queue = deque([source])
    visited[source] = True
    while queue:
        u = queue.popleft()
        for v in range(n):
            if not visited[v] and residual[u][v] > 0:
                visited[v] = True
                queue.append(v)
    
    S = {i for i in range(n) if visited[i]}
    T = {i for i in range(n) if not visited[i]}
    
    # 最小割的边 = 从 S 到 T 且原始容量 > 0 的边
    cut_edges = []
    for u in S:
        for v in T:
            if capacity[u][v] > 0:
                cut_edges.append((u, v, capacity[u][v]))
    
    return S, T, cut_edges

19.11 Dinic 算法

Dinic 算法是比 Edmonds-Karp 更高效的最大流算法。核心改进:不是每次只找一条增广路径,而是先 BFS 构建分层图(level graph),然后在分层图上用 DFS 一次找多条增广路径。

def dinic(capacity: List[List[int]], source: int, sink: int) -> int:
    """
    Dinic 算法求最大流
    
    时间复杂度: O(V²E),对单位容量图为 O(E√V)
    """
    n = len(capacity)
    residual = [row[:] for row in capacity]
    
    def bfs_level() -> bool:
        """BFS 构建分层图(level 数组)"""
        nonlocal level
        level = [-1] * n
        level[source] = 0
        queue = deque([source])
        while queue:
            u = queue.popleft()
            for v in range(n):
                if level[v] == -1 and residual[u][v] > 0:
                    level[v] = level[u] + 1
                    queue.append(v)
        return level[sink] != -1
    
    def dfs_blocking(u: int, pushed: int) -> int:
        """在分层图上 DFS 找阻塞流"""
        if u == sink:
            return pushed
        
        while iter_ptr[u] < n:
            v = iter_ptr[u]
            if level[v] == level[u] + 1 and residual[u][v] > 0:
                flow = dfs_blocking(v, min(pushed, residual[u][v]))
                if flow > 0:
                    residual[u][v] -= flow
                    residual[v][u] += flow
                    return flow
            iter_ptr[u] += 1
        
        return 0
    
    max_flow = 0
    level = [-1] * n
    
    while bfs_level():
        iter_ptr = [0] * n
        while True:
            flow = dfs_blocking(source, float('inf'))
            if flow == 0:
                break
            max_flow += flow
    
    return max_flow

Dinic vs Edmonds-Karp 复杂度对比

算法 时间复杂度 适用场景
Edmonds-Karp O(VE²) 通用
Dinic O(V²E) 通用(更快)
Dinic (单位容量) O(E√V) 二分图匹配等

为什么 Dinic 更快? 每次 BFS 后,从 source 到 sink 的最短路径长度至少增加 1(因为阻塞流封锁了当前最短路层级)。最短路径最多为 V-1,所以最多做 V-1 次 BFS。每次 BFS + 阻塞流的时间为 O(VE),总计 O(V²E)。

19.12 二分图匹配的网络流视角

匈牙利算法本质上是在二分图上找增广路径——这和网络流的增广路径方法完全一致!实际上,二分图最大匹配可以直接归约为最大流问题:

构造流网络:
1. 添加超级源 s,连接到所有左侧节点(容量 1)
2. 添加超级汇 t,所有右侧节点连接到 t(容量 1)
3. 原图中 L→R 的边,容量设为 1

最大流 = 最大匹配

    s → L1 → R1 → t
    s → L2 → R2 → t
    s → L3 → R3 → t
    (交叉连接省略)

König 定理:在二分图中,最大匹配数 = 最小顶点覆盖数。这是最大流最小割定理在二分图上的特例。

证明思路:最大匹配 = 网络中最大流 = 网络中最小割。而在这个特殊网络中,最小割对应一组容量为 1 的边被切断,每条边对应一个节点被"覆盖"。

19.13 缩点后的 DAG 应用

SCC 缩点后得到 DAG,可以进一步解决:

  1. 可达性查询:DAG 上用拓扑排序 + DP 判断哪些 SCC 之间可达。
  2. 最长路径:DAG 上的最长路径(关键路径法)。
  3. 2-SAT:如果变量 x 和 ¬x 在同一个 SCC 中,则无解。
def scc_dag(graph: Dict[int, List[int]], n: int):
    """
    缩点:将原图转化为 SCC 的 DAG
    """
    sccs = tarjan_scc(graph, n)
    
    # 每个节点属于哪个 SCC
    scc_id = [0] * n
    for i, scc in enumerate(sccs):
        for node in scc:
            scc_id[node] = i
    
    # 构建 DAG(SCC 之间的边)
    num_sccs = len(sccs)
    dag = {i: set() for i in range(num_sccs)}
    
    for u in range(n):
        for v in graph.get(u, []):
            if scc_id[u] != scc_id[v]:
                dag[scc_id[u]].add(scc_id[v])
    
    # 转为列表
    dag = {k: list(v) for k, v in dag.items()}
    
    return sccs, scc_id, dag


# 2-SAT 应用示例
def solve_2sat(n: int, clauses: List[Tuple[int, int]]) -> List[bool]:
    """
    2-SAT 求解
    
    变量编号 0 到 n-1
    正文字 x 编号为 x,负文字 ¬x 编号为 x+n
    
    clauses: [(a, b), ...] 表示 (a ∨ b)
    其中 a, b 是文字编号
    """
    # 构建蕴含图
    # (a ∨ b) 等价于 (¬a → b) ∧ (¬b → a)
    total = 2 * n  # 正文字 + 负文字
    graph = {i: [] for i in range(total)}
    
    def neg(x):
        return x + n if x < n else x - n
    
    for a, b in clauses:
        graph[neg(a)].append(b)   # ¬a → b
        graph[neg(b)].append(a)   # ¬b → a
    
    sccs = tarjan_scc(graph, total)
    scc_id = [0] * total
    for i, scc in enumerate(sccs):
        for node in scc:
            scc_id[node] = i
    
    # 如果 x 和 ¬x 在同一个 SCC 中,无解
    for x in range(n):
        if scc_id[x] == scc_id[x + n]:
            return None  # 无解
    
    # 赋值:如果 scc_id[x] > scc_id[¬x],则 x = True
    # (Tarjan 输出逆拓扑序,编号小的在后面)
    assignment = [False] * n
    for x in range(n):
        assignment[x] = scc_id[x] > scc_id[x + n]
    
    return assignment

Level 3 · 规范怎么定义的

19.14 Tarjan 1972 年论文

论文:Robert E. Tarjan, "Depth-First Search and Linear Graph Algorithms", SIAM Journal on Computing, 1972.

这篇论文是图算法领域的里程碑,它系统性地展示了 DFS 的力量。Tarjan 在文中提出了:

  1. 强连通分量算法(本章的核心)
  2. 双连通分量算法(割点和桥的识别)
  3. DFS 的系统性分类:树边、回边、前向边、横叉边

Tarjan 的关键洞察:DFS 产生的搜索树具有非常好的结构性质。在有向图的 DFS 树中,所有非树边要么是回边(指向祖先),要么是前向边(指向后代),要么是横叉边(指向已完全处理的子树)。利用这些性质,可以在 O(V+E) 时间内解决大量图论问题。

low 值的数学定义(Tarjan 原文):

low(v) = min(
    dfn(v),
    min{dfn(w) | (u, w) 是一条回边,u 是 v 的后代(含 v 自身)}
)

也就是说,low(v) 是从 v 的子树出发,通过最多一条回边能到达的最早祖先的 DFS 序号。

Tarjan 的学术贡献:Robert Tarjan(1948-)是理论计算机科学的巨匠之一。他的贡献包括:

他在 1986 年获得图灵奖(与 John Hopcroft 共同获得),表彰他们在算法和数据结构方面的基础性贡献。

19.15 最大流最小割定理(Ford & Fulkerson 1956)

论文:L.R. Ford Jr. & D.R. Fulkerson, "Maximal Flow through a Network", Canadian Journal of Mathematics, 1956.

背景:这个定理的提出有冷战背景。Ford 和 Fulkerson 在 RAND Corporation 工作,该定理最初用于分析苏联铁路网络的瓶颈容量——即需要切断多少条铁路线才能瘫痪苏联的运输系统。

定理的形式化陈述

设 f* 是网络中的最大流,C* 是最小割的容量。则 f* = C*。

证明框架

  1. 弱对偶性(f ≤ C 对任意流 f 和割 C):

    • 任何从 s 到 t 的流 f 必须完全"通过"任何 s-t 割。
    • 流通过割的量 ≤ 割的容量(因为每条割边的流量 ≤ 容量)。
    • 因此 |f| ≤ cap(S, T) 对任何割 (S, T)。
  2. 强对偶性(存在 f, C 使得 f = C):

    • 当 Ford-Fulkerson 终止时,残余图中 s 无法到达 t。
    • 定义 S = {在残余图中 s 可达的节点},T = V \ S。
    • 从 S 到 T 的所有原始边都满载(残余容量 = 0)。
    • 从 T 到 S 的所有原始边流量为 0(否则有反向残余容量让 s 到达 T 中节点)。
    • 所以 |f| = cap(S, T)。加上弱对偶性,f 是最大流,(S,T) 是最小割。

Ford-Fulkerson 方法的终止性问题

如果容量都是整数,每次增广至少增加 1 单位流量,所以最多 |f*| 次增广,算法终止。但如果容量是无理数,Ford-Fulkerson(使用 DFS 找增广路径)可能不终止!Zwick (1995) 构造了这样的反例。

Edmonds-Karp(使用 BFS)保证终止且复杂度为 O(VE²),与容量大小无关。

19.16 网络流在实际中的应用

应用 1:二分图匹配(前面已讨论)

应用 2:项目选择问题

有 n 个项目,每个项目有收益 p_i(可正可负)。项目之间有依赖关系:如果选择项目 i,则必须选择项目 j。求最大净收益。

转化为最小割:

应用 3:图像分割

像素分前景/背景。每个像素有属于前景的"偏好值"和属于背景的"偏好值",相邻像素属于不同类的"惩罚值"。

转化为最小割:

应用 4:棒球淘汰问题

判断某支球队是否已经被数学淘汰(即使赢下所有剩余比赛也不可能获得冠军)。这可以用最大流来判断!

19.17 匈牙利算法的历史

匈牙利算法(Hungarian Algorithm)最早由 Harold Kuhn 在 1955 年提出,但他的算法基于匈牙利数学家 Dénes König(1931)和 Jenő Egerváry(1931)的工作。Kuhn 以此命名致敬这些匈牙利数学家。

Kuhn 的原始算法解决的是带权二分图最优匹配(Assignment Problem),时间复杂度 O(n³)。我们在 Level 1 中展示的简化版(无权最大匹配)是基于增广路径的 O(VE) 算法。

Hopcroft-Karp 算法(1973)改进了无权二分图最大匹配的复杂度到 O(E√V)——它的核心思想和 Dinic 算法几乎完全相同(分层图 + 多条增广路径)。

König 定理(1931):在二分图中,

这是 LP 对偶性在组合优化中的经典体现。

19.18 SCC 算法的理论比较

算法 时间 空间 遍历次数 发表年份
Tarjan O(V+E) O(V) 1 次 DFS 1972
Kosaraju-Sharir O(V+E) O(V+E) 2 次 DFS + 反转图 1981 (Sharir)
Path-based (Gabow) O(V+E) O(V) 1 次 DFS 2000
Dijkstra (partition) O(V+E) O(V) 1 次 DFS 1976

Kosaraju 的算法虽然发表于 Sharir 1981 年的技术报告中,但 Kosaraju 据说在 1978 年的一次讲座中就描述了它(未发表)。

Path-based 算法(Gabow 2000)用两个栈代替 Tarjan 的 low 值数组,概念上更清晰,但实际性能差异微小。


Level 4 · 边界与陷阱

19.19 这些算法在面试中的考查频率

让我们用数据说话。以下是基于 LeetCode 题目标签统计和主流面试经验分享的频率分析:

高频(必须掌握)

中频(建议掌握)

低频(了解即可,面试中极少直接考)

结论

  1. Dijkstra + MST (Kruskal) 是面试必备,必须能 20 分钟内默写。
  2. SCC 和网络流属于"知识储备",面试中直接考的概率 < 5%。
  3. 最大流最小割定理的思想在系统设计面试中偶尔有用(瓶颈分析)。
  4. 如果你准备的是 Google/Meta 等大厂的 hard 面试或竞赛,SCC 和网络流值得深入学习。

19.20 什么时候需要学这些

学习优先级推荐

优先级 1(面试必备):
  - Dijkstra(及变体)
  - BFS/DFS
  - 拓扑排序
  - 并查集 + Kruskal
  
优先级 2(进阶加分):
  - Bellman-Ford(负权场景)
  - Floyd-Warshall(全源)
  - 二分图判定
  - Tarjan 求割点/桥

优先级 3(高级/竞赛):
  - SCC (Tarjan/Kosaraju)
  - 2-SAT
  - 最大流 (Dinic)
  - 二分图最大匹配
  
优先级 4(理论深入/博士级):
  - 最小费用最大流
  - 一般图匹配 (Blossom 算法)
  - Gomory-Hu 树
  - 最大权闭合子图

信号:什么时候你需要用这些算法?

问题特征 可能的算法
"互相可达的分组" SCC
"删除后图不连通" 割点/桥
"最多能传输/分配多少" 最大流
"一一对应的最优匹配" 二分图匹配
"选/不选的相互依赖" 2-SAT 或网络流

19.21 LeetCode 1192:查找集群内的关键连接

题目:给定 n 个服务器和连接(无向图),找出所有"关键连接"——如果删除后有服务器断开。即求所有桥。

def criticalConnections(n: int, connections: List[List[int]]) -> List[List[int]]:
    """
    LeetCode 1192: Critical Connections in a Network
    
    直接用 Tarjan 求桥
    """
    graph = {i: [] for i in range(n)}
    for u, v in connections:
        graph[u].append(v)
        graph[v].append(u)
    
    dfn = [0] * n
    low = [0] * n
    timer = [0]
    bridges = []
    
    def dfs(u: int, parent: int):
        timer[0] += 1
        dfn[u] = low[u] = timer[0]
        
        for v in graph[u]:
            if v == parent:
                continue
            if dfn[v] == 0:
                dfs(v, u)
                low[u] = min(low[u], low[v])
                if low[v] > dfn[u]:
                    bridges.append([u, v])
            else:
                low[u] = min(low[u], dfn[v])
    
    dfs(0, -1)
    return bridges


# 测试
n = 4
connections = [[0,1],[1,2],[2,0],[1,3]]
print(criticalConnections(n, connections))  # [[1, 3]]
# 解释: 0-1-2 形成环,删除任一条都不影响连通性
# 但 1-3 是桥,删除后节点3孤立

面试注意:这道题可能遇到递归深度限制(n 可达 10⁵)。生产环境中应使用迭代版本或增大递归限制(sys.setrecursionlimit)。

19.22 实际工程中的应用场景

场景 1:微服务依赖分析(SCC)

def analyze_microservice_dependencies(services: List[str], calls: List[Tuple[str, str]]):
    """
    分析微服务之间的循环依赖
    如果一组服务形成 SCC(互相调用),它们应该被合并或解耦
    """
    # 建立编号映射
    name_to_id = {name: i for i, name in enumerate(services)}
    n = len(services)
    
    graph = {i: [] for i in range(n)}
    for caller, callee in calls:
        graph[name_to_id[caller]].append(name_to_id[callee])
    
    sccs = tarjan_scc(graph, n)
    
    # 找出有循环依赖的组(SCC 大小 > 1)
    circular_groups = []
    for scc in sccs:
        if len(scc) > 1:
            group_names = [services[i] for i in scc]
            circular_groups.append(group_names)
    
    return circular_groups

# 示例
services = ["auth", "user", "order", "payment", "notification"]
calls = [
    ("auth", "user"),      # auth 调用 user
    ("user", "auth"),      # user 调用 auth(循环!)
    ("order", "payment"),  # order 调用 payment
    ("payment", "notification"),
    ("notification", "order"),  # 循环: order→payment→notification→order
]

groups = analyze_microservice_dependencies(services, calls)
print("循环依赖组:", groups)
# [["order", "notification", "payment"], ["auth", "user"]]

场景 2:CDN 流量分配(网络流)

def cdn_bandwidth_allocation():
    """
    简化的 CDN 流量分配问题
    多个源服务器 → 多个 CDN 节点 → 用户群
    目标:最大化总带宽
    """
    # 节点: 0=超级源, 1-3=源服务器, 4-6=CDN节点, 7-9=用户群, 10=超级汇
    n = 11
    capacity = [[0] * n for _ in range(n)]
    
    # 源服务器容量
    capacity[0][1] = 100  # 服务器1 带宽 100Mbps
    capacity[0][2] = 80   # 服务器2 带宽 80Mbps
    capacity[0][3] = 60   # 服务器3 带宽 60Mbps
    
    # 服务器→CDN 链路容量
    capacity[1][4] = 50; capacity[1][5] = 70
    capacity[2][4] = 40; capacity[2][6] = 60
    capacity[3][5] = 30; capacity[3][6] = 50
    
    # CDN→用户群 链路容量
    capacity[4][7] = 60; capacity[4][8] = 30
    capacity[5][7] = 40; capacity[5][9] = 50
    capacity[6][8] = 45; capacity[6][9] = 35
    
    # 用户群需求
    capacity[7][10] = 80
    capacity[8][10] = 70
    capacity[9][10] = 60
    
    max_bw = edmonds_karp(capacity, 0, 10)
    print(f"最大可分配带宽: {max_bw} Mbps")

cdn_bandwidth_allocation()

19.23 常见陷阱与总结

陷阱 1:Tarjan 中的重边处理

# 无向图中,如果存在重边 (u,v) 出现两次
# 不能简单用 "if v == parent: continue" 跳过
# 因为只应跳过一次(来时的那条边),第二条平行边应该被考虑

# 正确做法:用边的编号来避免重复
def dfs_with_edge_id(u, parent_edge_id):
    for v, edge_id in graph[u]:
        if edge_id == parent_edge_id:
            continue  # 只跳过来时的那条边
        # ...

陷阱 2:递归深度限制

import sys
sys.setrecursionlimit(200000)  # 对于 n ≤ 10^5 的图

# 或者使用迭代实现(推荐用于生产环境)

陷阱 3:Edmonds-Karp 的邻接矩阵限制

# 邻接矩阵实现的 Edmonds-Karp 空间为 O(V²)
# 如果 V = 10^5,需要 10^10 字节内存 —— 不可行!
# 大规模网络流必须用邻接表实现

# 面试中如果节点数 ≤ 几百,邻接矩阵实现是最清晰的
# 如果节点数 ≤ 10^4-10^5,必须用邻接表 + 链式前向星

陷阱 4:混淆 SCC 与连通分量

# 无向图的连通分量:简单 BFS/DFS
# 有向图的强连通分量:需要 Tarjan/Kosaraju
# 有向图的弱连通分量:忽略边的方向后的连通分量

# 面试中要确认是有向图还是无向图!

算法速查表

算法 问题 时间 面试频率
Tarjan (SCC) 有向图强连通分量 O(V+E)
Tarjan (桥/割点) 无向图关键边/点 O(V+E) 中低
Kosaraju 有向图强连通分量 O(V+E)
Edmonds-Karp 最大流 O(VE²) 极低
Dinic 最大流 O(V²E) 极低
Hungarian 二分图最大匹配 O(VE)
Hopcroft-Karp 二分图最大匹配 O(E√V) 极低

核心要点

  1. SCC 是理解有向图结构的钥匙——缩点后变 DAG,问题简化。
  2. 网络流是组合优化的瑞士军刀——很多"看起来不像流"的问题可以归约为网络流。
  3. 最大流 = 最小割——这个对偶性是理解网络流应用的核心。
  4. 面试中这些算法考得少,但知道它们存在很重要——有些 hard 题只有建模为网络流才能高效解决。
  5. 实际工程中 SCC 最有用——循环依赖检测、死锁分析、编译器优化都用得到。
本章评分
4.6  / 5  (12 评分)

💬 留言讨论