第 15 站 · 图论
图连通性与并查集
并查集:路径压缩 + 按大小合并 每次操作约 O(α(n))
先做这道
LC 1971寻找图中是否存在路径卡住再看
由边表构建邻接结构,判断两节点是否连通。
要点
- find 时做路径压缩,union 时把小树挂到大树下;两者一起用,单次操作几乎是常数时间。
- union 返回 false 说明两点本来就连通:无向图判环、找冗余连接都靠它。
- 连通块个数、块的大小这类信息只记在根上,合并时累加。
易错点
- 用 parent[a] == parent[b] 判断连通是错的,必须比较 find(a) == find(b)。
- 合并时写成 parent[a] = b,而不是把两个根连起来,会破坏树的结构。
- 结点是字符串或坐标时,先映射成 0..n-1 的编号,或者用哈希表存 parent。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
并查集 · 按大小合并 + 路径压缩
8 个元素,依次执行 7 次 union
- 连通分量
- 8
- 操作
- —
每个元素自成一组:parent[i] = i。箭头从孩子指向父亲,没有箭头的是根。
始终成立同一组的元素沿 parent 走到同一个根;树高保持在 O(log n),压缩后几乎是常数。
模板
选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 1971。
Python
class DSU:
def __init__(self, n: int):
self.parent = list(range(n))
self.size = [1] * n
self.count = n # number of components
def find(self, x: int) -> int:
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a: int, b: int) -> bool:
a, b = self.find(a), self.find(b)
if a == b:
return False # already connected: this edge would close a cycle
if self.size[a] < self.size[b]:
a, b = b, a
self.parent[b] = a # hang the smaller tree under the larger
self.size[a] += self.size[b]
self.count -= 1
return TrueC++
struct DSU {
vector<int> parent, size;
int count; // number of components
DSU(int n) : parent(n), size(n, 1), count(n) { iota(parent.begin(), parent.end(), 0); }
int find(int x) {
while (parent[x] != x) x = parent[x] = parent[parent[x]]; // path halving
return x;
}
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) return false; // already connected: this edge would close a cycle
if (size[a] < size[b]) swap(a, b);
parent[b] = a; // hang the smaller tree under the larger
size[a] += size[b];
count--;
return true;
}
};Java
class DSU {
private final int[] parent, size;
int count; // number of components
DSU(int n) {
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
count = n;
}
int find(int x) {
while (parent[x] != x) x = parent[x] = parent[parent[x]]; // path halving
return x;
}
boolean union(int a, int b) {
a = find(a);
b = find(b);
if (a == b) return false; // already connected: this edge would close a cycle
if (size[a] < size[b]) {
int t = a;
a = b;
b = t;
}
parent[b] = a; // hang the smaller tree under the larger
size[a] += size[b];
count--;
return true;
}
}Go
type DSU struct {
parent, size []int
count int // number of components
}
func NewDSU(n int) *DSU {
d := &DSU{parent: make([]int, n), size: make([]int, n), count: n}
for i := range d.parent {
d.parent[i], d.size[i] = i, 1
}
return d
}
func (d *DSU) Find(x int) int {
for d.parent[x] != x {
d.parent[x] = d.parent[d.parent[x]] // path halving
x = d.parent[x]
}
return x
}
func (d *DSU) Union(a, b int) bool {
a, b = d.Find(a), d.Find(b)
if a == b {
return false // already connected: this edge would close a cycle
}
if d.size[a] < d.size[b] {
a, b = b, a
}
d.parent[b] = a // hang the smaller tree under the larger
d.size[a] += d.size[b]
d.count--
return true
}常见变体
这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。
BFS 二染色判二分图时间 O(V + E) · 空间 O(V)
要把点分成两组、每条边都跨组(二分图、「不喜欢」的人分开):BFS 给相邻点涂相反的颜色,遇到同色的邻居就是奇环,答案为否。图可能不连通,每个没染色的点都要当一次起点。LC 785 判断二分图、LC 886 可能的二分法。
Python
from collections import deque
def is_bipartite(graph: list[list[int]]) -> bool:
"""Can the nodes be 2-coloured so every edge joins different colours? (LC 785)"""
color = [-1] * len(graph)
for s in range(len(graph)): # every component, not only the one holding node 0
if color[s] != -1:
continue
color[s] = 0
q = deque([s])
while q:
u = q.popleft()
for v in graph[u]:
if color[v] == -1:
color[v] = color[u] ^ 1 # the other colour
q.append(v)
elif color[v] == color[u]:
return False # an odd cycle
return TrueC++
// Can the nodes be 2-coloured so every edge joins different colours? (LC 785)
bool isBipartite(const vector<vector<int>>& graph) {
vector<int> color(graph.size(), -1);
for (int s = 0; s < (int)graph.size(); s++) { // every component, not only the one holding node 0
if (color[s] != -1) continue;
color[s] = 0;
queue<int> q;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : graph[u]) {
if (color[v] == -1) {
color[v] = color[u] ^ 1; // the other colour
q.push(v);
} else if (color[v] == color[u]) {
return false; // an odd cycle
}
}
}
}
return true;
}Java
class Solution {
// Can the nodes be 2-coloured so every edge joins different colours? (LC 785)
boolean isBipartite(int[][] graph) {
int[] color = new int[graph.length];
Arrays.fill(color, -1);
for (int s = 0; s < graph.length; s++) { // every component, not only the one holding node 0
if (color[s] != -1) continue;
color[s] = 0;
Deque<Integer> q = new ArrayDeque<>(List.of(s));
while (!q.isEmpty()) {
int u = q.poll();
for (int v : graph[u]) {
if (color[v] == -1) {
color[v] = color[u] ^ 1; // the other colour
q.offer(v);
} else if (color[v] == color[u]) {
return false; // an odd cycle
}
}
}
}
return true;
}
}Go
// isBipartite reports whether the nodes can be 2-coloured so every edge joins different colours.
func isBipartite(graph [][]int) bool {
color := make([]int, len(graph))
for i := range color {
color[i] = -1
}
for s := range graph { // every component, not only the one holding node 0
if color[s] != -1 {
continue
}
color[s] = 0
q := []int{s}
for len(q) > 0 {
u := q[0]
q = q[1:]
for _, v := range graph[u] {
if color[v] == -1 {
color[v] = color[u] ^ 1 // the other colour
q = append(q, v)
} else if color[v] == color[u] {
return false // an odd cycle
}
}
}
}
return true
}Tarjan 求桥:low 值越不过父结点时间 O(V + E) · 空间 O(V + E)
找「删掉就断开」的边或点(关键连接、割点):DFS 记每个点的发现时间 disc,low 是子树经一条回边能到的最早发现时间;子结点 v 的 low[v] > disc[u] 时,边 u-v 就是桥。LC 1192 查找集群内的关键连接。递归很深时 Python 要调大递归上限。
Python
def critical_connections(n: int, connections: list[list[int]]) -> list[list[int]]:
"""Every bridge, an edge whose removal disconnects the graph (LC 1192)."""
graph = [[] for _ in range(n)]
for u, v in connections:
graph[u].append(v)
graph[v].append(u)
disc = [0] * n # discovery time; 0 = not visited yet
low = [0] * n # earliest discovery time the subtree reaches with one back edge
timer = 1
res = []
def dfs(u: int, parent: int) -> None:
nonlocal timer
disc[u] = low[u] = timer
timer += 1
for v in graph[u]:
if v == parent:
continue # assumes no parallel edges
if disc[v]:
low[u] = min(low[u], disc[v]) # a back edge
else:
dfs(v, u)
low[u] = min(low[u], low[v])
if low[v] > disc[u]: # v's subtree cannot climb above u without this edge
res.append([u, v])
for s in range(n):
if not disc[s]:
dfs(s, -1)
return resC++
// Every bridge, an edge whose removal disconnects the graph (LC 1192).
vector<pair<int, int>> criticalConnections(int n, const vector<pair<int, int>>& connections) {
vector<vector<int>> graph(n);
for (auto [u, v] : connections) {
graph[u].push_back(v);
graph[v].push_back(u);
}
vector<int> disc(n, 0), low(n, 0); // disc 0 = not visited; low: earliest time reached with one back edge
int timer = 1;
vector<pair<int, int>> res;
function<void(int, int)> dfs = [&](int u, int parent) {
disc[u] = low[u] = timer++;
for (int v : graph[u]) {
if (v == parent) continue; // assumes no parallel edges
if (disc[v]) {
low[u] = min(low[u], disc[v]); // a back edge
} else {
dfs(v, u);
low[u] = min(low[u], low[v]);
if (low[v] > disc[u]) res.push_back({u, v}); // v's subtree cannot climb above u without this edge
}
}
};
for (int s = 0; s < n; s++)
if (!disc[s]) dfs(s, -1);
return res;
}Java
class Solution {
private List<List<Integer>> graph;
private int[] disc, low; // disc 0 = not visited; low: earliest time reached with one back edge
private int timer;
private List<int[]> res;
// Every bridge, an edge whose removal disconnects the graph (LC 1192).
List<int[]> criticalConnections(int n, int[][] connections) {
graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
for (int[] e : connections) {
graph.get(e[0]).add(e[1]);
graph.get(e[1]).add(e[0]);
}
disc = new int[n];
low = new int[n];
timer = 1;
res = new ArrayList<>();
for (int s = 0; s < n; s++)
if (disc[s] == 0) dfs(s, -1);
return res;
}
private void dfs(int u, int parent) {
disc[u] = low[u] = timer++;
for (int v : graph.get(u)) {
if (v == parent) continue; // assumes no parallel edges
if (disc[v] != 0) {
low[u] = Math.min(low[u], disc[v]); // a back edge
} else {
dfs(v, u);
low[u] = Math.min(low[u], low[v]);
if (low[v] > disc[u]) res.add(new int[] {u, v}); // v's subtree cannot climb above u without this edge
}
}
}
}Go
// criticalConnections returns every bridge, an edge whose removal disconnects the graph (LC 1192).
func criticalConnections(n int, connections [][2]int) [][2]int {
graph := make([][]int, n)
for _, e := range connections {
graph[e[0]] = append(graph[e[0]], e[1])
graph[e[1]] = append(graph[e[1]], e[0])
}
disc, low := make([]int, n), make([]int, n) // disc 0 = not visited; low: earliest time reached with one back edge
timer := 1
var res [][2]int
var dfs func(u, parent int)
dfs = func(u, parent int) {
disc[u], low[u] = timer, timer
timer++
for _, v := range graph[u] {
if v == parent {
continue // assumes no parallel edges
}
if disc[v] != 0 {
low[u] = min(low[u], disc[v]) // a back edge
} else {
dfs(v, u)
low[u] = min(low[u], low[v])
if low[v] > disc[u] { // v's subtree cannot climb above u without this edge
res = append(res, [2]int{u, v})
}
}
}
}
for s := 0; s < n; s++ {
if disc[s] == 0 {
dfs(s, -1)
}
}
return res
}为什么对
- 每个集合是一棵树,树根是代表元;find 返回根,所以两点同根 ⇔ 属于同一连通分量。union 只是把一个根挂到另一个根下,不会拆开已有的连通关系。
- 按大小合并保证树高 O(log n):一个节点所在的树每变深一次,它所在集合的大小至少翻倍,最多翻 log n 次。再加上路径压缩,均摊接近常数(反阿克曼函数 α(n))。
- union 返回 false 说明两端已经连通,再加这条边就会成环,这就是判冗余边的依据。
30 秒口述稿
面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。
动态连通性我用并查集。每个集合是一棵树,根是代表元,两点同根就说明连通。find 时做路径压缩,union 时把小树挂到大树下,按大小合并保证树高 O(log n),两者一起均摊接近常数。如果 union 发现两点已经同根,说明这条边会成环,可以直接用来判冗余边。For dynamic connectivity I use union-find. Each set is a tree whose root represents it, and two nodes are connected exactly when they share a root. find compresses paths and union hangs the smaller tree under the larger, which keeps trees logarithmic, and together they cost nearly constant time amortised. If union finds both ends already share a root, the edge would close a cycle, which detects redundant edges directly.自评一下
对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
5 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
class DSU:
def __init__(self, n: int):
self.parent = list(range(n))
self.size = [1] * n
self.count = n
self.size[a] += self.size[b]
self.count -= 1
return True分档练习
本站在 500 路里的 16 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
基础 4
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 1971 | 寻找图中是否存在路径 | 由边表构建邻接结构,判断两节点是否连通。 | |
| 841 | 钥匙和房间 | 将房间钥匙抽象为有向边,验证从起点可达全部节点。 | |
| 547 | 省份数量 | 从邻接矩阵统计连通分量,比较遍历与并查集。 | |
| 323 | 无向图中连通分量的数目 🔒 | 从边列表统计无向图连通块,覆盖孤立节点。 |
核心 10
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 261 | 以图判树 🔒 | 同时检查连通性与无环性,判断无向图是否是一棵树。 | |
| 684 | 冗余连接 | 按边顺序合并集合,识别首次连接已连通节点的冗余边。 | |
| 1319 | 连通网络的操作次数 | 把多余边与连通块数联系起来,判断连接全图的最低操作数。 | |
| 785 | 判断二分图 | 对每个连通分量二染色,发现奇环冲突。 | |
| 886 | 可能的二分法 | 将互斥关系转为二分图约束,练习题意建模。 | |
| 721 | 账户合并 | 以共享邮箱建立等价关系,合并账户并整理去重后的输出。 | |
| 1202 | 交换字符串中的元素 | 用交换关系划分连通分量,在每个分量内排序重排字符。 | |
| 2101 | 引爆最多的炸弹 | 根据爆炸半径建立有向图,逐起点搜索最大可达范围。 | |
| 1466 | 重新规划路线 | 在无向遍历中保留原边方向,统计需要反转的边。 | |
| 2359 | 找到离给定两个节点最近的节点 | 利用每个节点至多一个后继的结构,比较两起点的可达距离。 |
进阶 2
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 399 | 除法求值 | 将比值建为带权边,沿路径组合关系并处理未知变量。 | |
| 1192 | 查找集群内的关键连接 | 用DFS时间戳和low-link识别桥,理解返祖边对连通性的影响。 |
本站 OA 真题 34
- Longest Contiguous-House Segment After Each BuildMeta
- Can Sort Permutation in Given MovesAmazon
- Count Connected ComponentsAmazon
- Make Value Groups ContiguousAmazon
- Product Category Group SizesAmazon
- Minimum Rooks in a Peaceful State (SDE 2, Phone Interview)Amazon
- Reorder Routes to Make All Paths Lead to the City ZeroMicrosoft
- Radio WavesSnowflake
- Visible Profiles CountIBM
- Get Node To RemoveSalesforce