引言 (Introduction)
前四篇文章覆盖了图论的基础设施(遍历、最短路、MST、拓扑排序)。本篇是本系列的收官之作,将聚焦于三个进阶主题:二分图判定、桥与割点(Tarjan 算法) 和带权并查集。这三个主题代表图论在不同方向上的延伸——着色问题、连通性分析、关系代数。
本文是本系列第五篇,将覆盖 LeetCode 785、1192、399 三道题,并在结尾给出全系列的知识复盘对照表。
1. 二分图判定:LeetCode 785
1.1 定义
二分图(Bipartite Graph)是指可以将图中所有节点分成两个集合 $A$ 和 $B$,使得每条边的两端分别属于两个不同的集合。
等价定义:二分图是可以二染色且没有奇数长度环的图。
1.2 染色法
用 0/1(或两种颜色)给每个节点染色。从任意未染色节点开始 BFS/DFS,将其染为颜色 0,其所有邻居染为颜色 1,邻居的邻居染为颜色 0,依此类推。若某个节点已经被染色,且其颜色与当前期望的颜色不同,说明图不是二分图。
1.3 完整代码
from typing import List
from collections import deque
class Solution:
def isBipartite(self, graph: List[List[int]]) -> bool:
n = len(graph)
color: List[int] = [-1] * n # -1 = 未染色, 0/1 = 两种颜色
for start in range(n):
if color[start] != -1:
continue
color[start] = 0
q: deque[int] = deque([start])
while q:
u = q.popleft()
for v in graph[u]:
if color[v] == -1: # 未染色 → 染相反色
color[v] = color[u] ^ 1
q.append(v)
elif color[v] == color[u]: # 同色相邻 → 不是二分图
return False
return True
复杂度:时间 $O(V + E)$,空间 $O(V)$。
DFS 版同理——将 BFS 的队列替换为递归即可,核心逻辑不变。
1.4 二分图的实际应用
二分图匹配是图论的独立子领域(匈牙利算法、KM 算法),在 Assignment Problem(任务分配)、推荐系统中广泛应用。染色法是整个二分图算法的入口。
2. Tarjan 算法求桥:LeetCode 1192
2.1 什么是桥?
在无向连通图中,一条边是桥(Bridge / Critical Connection),当且仅当删除它后,图的连通分量数增加。换言之,桥是那些「一旦断了整个网络就分裂」的关键链路。
2.2 Tarjan 算法思想
Tarjan 算法的核心是时间戳和追溯值:
- $dfn[u]$:节点 $u$ 被 DFS 首次访问的时间(发现时间)。
- $low[u]$:从节点 $u$ 出发,通过 DFS 树上的边以及最多一条回边,能到达的节点的最小 $dfn$。
判据:对于 DFS 树上的边 $(u, v)$($u$ 是 $v$ 的父节点),若 $low[v] > dfn[u]$,则 $(u, v)$ 是桥。
直觉:如果 $v$ 及其子树中所有节点都无法通过回边到达 $u$ 或 $u$ 的祖先($low[v] \le dfn[u]$),那么 $(u, v)$ 是把 $v$ 的子树与图其余部分连接起来的唯一通道——它就是桥。
2.3 完整代码
from typing import List, Dict
class Solution:
def criticalConnections(self, n: int,
connections: List[List[int]]) -> List[List[int]]:
# 建无向图
graph: Dict[int, List[int]] = {i: [] for i in range(n)}
for u, v in connections:
graph[u].append(v)
graph[v].append(u)
dfn: List[int] = [-1] * n
low: List[int] = [-1] * n
bridges: List[List[int]] = []
time = 0
def dfs(u: int, parent: int) -> None:
nonlocal time
dfn[u] = low[u] = time
time += 1
for v in graph[u]:
if v == parent:
continue # 不往回走到父节点
if dfn[v] == -1: # 未访问 → 树边
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
复杂度:时间 $O(V + E)$,空间 $O(V)$。
2.4 Tarjan 算法的直觉
在 DFS 树上:
- 树边(Tree Edge):DFS 向前探索形成的边。
- 回边(Back Edge):连向 DFS 树上祖先的非树边。
$low[v]$ 记录的是「从 $v$ 的子树出发,不经过 $(parent, v)$ 这条树边,能到达的最早($dfn$ 最小)的节点」。如果 $low[v] > dfn[u]$,说明 $v$ 子树中所有节点都无法回到 $u$ 或其祖先——那么 $(u, v)$ 就是把子树和其余图连在一起的唯一通道。
2.5 扩展:割点
割点(Articulation Point)是删除后导致连通分量数增加的节点。Tarjan 算法同样可以求割点,判据有所调整:
- 对于根节点:若其在 DFS 树中有 $\ge 2$ 个子树,则根是割点。
- 对于非根节点 $u$:若存在子节点 $v$ 满足 $low[v] \ge dfn[u]$,则 $u$ 是割点。
掌握桥的 Tarjan 后,割点的判据只需一个等号的差异。
3. 带权并查集:LeetCode 399
题目:给出若干形如 a / b = value 的方程式,对于查询 c / d,返回计算结果。若无法推导则返回 -1.0。
3.1 问题建模
变量之间的倍数关系形成一张带权图:节点是变量,边权是两变量之比。a / b = 2.0 意味着 $a$ 到 $b$ 有一条权重为 $2.0$ 的有向边。
带权并查集可以在常规并查集的基础上维护节点到父节点的权重。查询 a / b 时,如果 $a$ 和 $b$ 在同一个连通分量中,结果 = $weight[a] / weight[b]$——即它们各自到根节点的权重之比。
3.2 完整代码
from typing import List, Dict
class WeightedUnionFind:
def __init__(self):
self.parent: Dict[str, str] = {}
self.weight: Dict[str, float] = {} # weight[x] = x / parent[x]
def find(self, x: str) -> str:
if x not in self.parent:
self.parent[x] = x
self.weight[x] = 1.0
if self.parent[x] != x:
root = self.find(self.parent[x])
# 路径压缩的同时更新权重
self.weight[x] *= self.weight[self.parent[x]]
self.parent[x] = root
return self.parent[x]
def union(self, a: str, b: str, value: float) -> None:
"""a / b = value"""
ra, rb = self.find(a), self.find(b)
if ra == rb:
return
# 将 ra 挂到 rb 下
# weight[a] = a / ra, weight[b] = b / rb
# 需要: weight[ra] = ra / rb
# ra / rb = (a / rb) / (a / ra)
# = (value * b / rb) / weight[a]
# = value * weight[b] / weight[a]
self.parent[ra] = rb
self.weight[ra] = value * self.weight[b] / self.weight[a]
def query(self, a: str, b: str) -> float:
if a not in self.parent or b not in self.parent:
return -1.0
ra, rb = self.find(a), self.find(b)
if ra != rb:
return -1.0
return self.weight[a] / self.weight[b]
class Solution:
def calcEquation(self, equations: List[List[str]],
values: List[float],
queries: List[List[str]]) -> List[float]:
uf = WeightedUnionFind()
for (a, b), val in zip(equations, values):
uf.union(a, b, val)
return [uf.query(a, b) for a, b in queries]
复杂度:时间 $O((E + Q) \cdot \alpha(V))$,空间 $O(V)$。
3.3 带权并查集的核心公式
在路径压缩和合并操作中,权重的更新遵循代数量规则:
- 路径压缩:$weight[x] \gets weight[x] \times weight[parent[x]]$,因为 $x / root = (x / oldParent) \times (oldParent / root)$。
- 合并:当 $ra$ 挂到 $rb$ 下时,$weight[ra] = value \times weight[b] / weight[a]$。推导:$ra / rb = (a / ra)^{-1} \times (a / b) \times (b / rb)$。
带权并查集的关键在于:将「比例关系」转化为森林中子节点到父节点的边权,使得任意两个节点的比值 = 它们各自到根节点的权重之比。这需要清晰的代数推导,但只要公式正确,代码非常简洁。
4. 全系列复盘对照表
本系列共五篇文章,覆盖了 15 道 LeetCode 题目和 6 个经典图论算法:
| 篇目 | 主题 | 核心算法/数据结构 | LeetCode 题目 | 关键技巧 | 典型复杂度 |
|---|---|---|---|---|---|
| 第一篇 | 图的表示与遍历 | 邻接表/矩阵、BFS、DFS | 200 岛屿数量695 最大岛屿面积994 腐烂的橘子 | 沉岛法、多源 BFS入队即标记方向向量 | $O(V+E)$ / $O(mn)$ |
| 第二篇 | 最短路径 | Dijkstra、Bellman-Ford、Floyd-Warshall | 743 网络延迟787 最便宜航班1334 阈值距离 | 惰性删除、松弛轮次控制上一轮副本保留$k$ 循环必须在最外层 | $O((V+E)\log V)$ / $O(VE)$ / $O(V^3)$ |
| 第三篇 | 最小生成树 | Kruskal、Prim、并查集 | 1584 连接所有点684 冗余连接 | 路径压缩 + 按秩合并边排序 + 连通性检查完全图的建图优化 | $O(E \log E)$ |
| 第四篇 | 拓扑排序与 DAG | Kahn (BFS)、DFS 后序 | 207 课程表210 课程表 II802 安全状态 | 入度表 + 队列三色标记环检测逆向拓扑排序 | $O(V+E)$ |
| 第五篇 | 进阶图论 | 染色法、Tarjan 桥、带权并查集 | 785 二分图1192 关键连接399 除法求值 | BFS/DFS 二染色$low[v] > dfn[u]$ 判桥权重积累公式 | $O(V+E)$ |
全系列方法论总结
-
建图是第一步:LeetCode 上的图问题几乎从来不直接给你邻接表。边列表、矩阵、方程式——你需要将题目语言翻译成图的语言。一旦图建好,算法就是套路。
-
BFS 和 DFS 是所有图算法的基础:Dijkstra 是 BFS + 优先队列,拓扑排序是 BFS + 入度表,Tarjan 是 DFS + 时间戳。理解遍历,才能理解建立在遍历之上的高级算法。
-
选择正确的算法:最短路选 Dijkstra/Bellman-Ford/Floyd-Warshall,MST 选 Kruskal/Prim——选择本身是图论的核心能力。本系列提供的决策树和对比表可作为快速参考。
-
图论与已有系列的衔接:Dijkstra 的贪心本质(贪心系列)、DAG 上的 DP(DP 系列)、Bellman-Ford 的 DP 视角——图论并非孤立的算法领域,而是与贪心、DP 深刻交织。
-
从模板到变形:LeetCode 的图论题大多数是经典算法的变体。掌握了 15 道题的核心模式(多源 BFS、惰性删除、松弛轮次控制、逆向拓扑排序、带权并查集),其余题目的区别只在于建图和细微的条件调整。
结论 (Conclusion)
五篇文章走完了图论从基础遍历到进阶算法的完整路径。15 道 LeetCode 题目覆盖了图的遍历、最短路、MST、拓扑排序、二分图、桥和带权并查集七大板块。
图论的学习曲线是先陡后平的——前两篇(遍历和最短路)的知识点密度最高,因为它们定义了整个领域的基本语言(邻接表、队列、优先队列、松弛、贪心选择)。一旦掌握了这些基础,后续的 MST、拓扑排序、Tarjan 桥都是在同一套语言上的延伸。
图论是所有算法领域中与真实世界映射最直接的一个——从地图导航到网络路由,从任务调度到社交分析。这个系列建立的不仅是 LeetCode 的解题能力,更是用计算机科学语言描述和优化现实世界系统的建模能力。