cswork / 模板打开 500 路题单

第 15 站 · 图论

图连通性与并查集

并查集:路径压缩 + 按大小合并 每次操作约 O(α(n))

周赛难度分 难度分 1412–难度分 2085· 中位 难度分 1715(9/16 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 15 站 · 16 题
先学
学完去
面试频次
7 家公司考过 · 面经出现 10 次TikTok / ByteDance 3、Amazon 2、Apple 1、Electronic Arts 1、Mercor 1

先做这道

LC 1971寻找图中是否存在路径简单
卡住再看

由边表构建邻接结构,判断两节点是否连通。

要点

  1. find 时做路径压缩,union 时把小树挂到大树下;两者一起用,单次操作几乎是常数时间。
  2. union 返回 false 说明两点本来就连通:无向图判环、找冗余连接都靠它。
  3. 连通块个数、块的大小这类信息只记在根上,合并时累加。

易错点

  • 用 parent[a] == parent[b] 判断连通是错的,必须比较 find(a) == find(b)。
  • 合并时写成 parent[a] = b,而不是把两个根连起来,会破坏树的结构。
  • 结点是字符串或坐标时,先映射成 0..n-1 的编号,或者用哈希表存 parent。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

并查集 · 按大小合并 + 路径压缩

8 个元素,依次执行 7 次 union

01234567
0p[0]
1p[1]
2p[2]
3p[3]
4p[4]
5p[5]
6p[6]
7p[7]
当前处理已发现 / 已计算答案
连通分量
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 True
C++
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 True
C++
// 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 res
C++
// 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
}

为什么对

  1. 每个集合是一棵树,树根是代表元;find 返回根,所以两点同根 ⇔ 属于同一连通分量。union 只是把一个根挂到另一个根下,不会拆开已有的连通关系。
  2. 按大小合并保证树高 O(log n):一个节点所在的树每变深一次,它所在集合的大小至少翻倍,最多翻 log n 次。再加上路径压缩,均摊接近常数(反阿克曼函数 α(n))。
  3. union 返回 false 说明两端已经连通,再加这条边就会成环,这就是判冗余边的依据。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
动态连通性我用并查集。每个集合是一棵树,根是代表元,两点同根就说明连通。find 时做路径压缩,union 时把小树挂到大树下,按大小合并保证树高 O(log n),两者一起均摊接近常数。如果 union 发现两点已经同根,说明这条边会成环,可以直接用来判冗余边。
English
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 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

5 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

5 道题
  1. 并查集同时使用路径压缩和按秩(或按大小)合并后,每次操作的均摊时间是?
  2. 用并查集判断无向图有没有环:依次处理每条边 (u, v),什么时候说明出现了环?
  3. n 个顶点、处理完 m 条边后,并查集中成功合并(两端原本不在同一集合)的次数为 s。连通分量有几个?
  4. n 个元素上做 m 次 find 或 union(m ≥ n),总时间是?
    def find(p, x):
        while p[x] != x:
            p[x] = p[p[x]]
            x = p[x]
        return x
    
    def union(p, size, a, b):
        a, b = find(p, a), find(p, b)
        if a == b:
            return False
        if size[a] < size[b]:
            a, b = b, a
        p[b] = a
        size[a] += size[b]
        return True
  5. n 个元素上做 m 次 find 或 union,最坏情况下总时间是?
    def find(p, x):
        while p[x] != x:
            x = p[x]
        return x
    
    def union(p, a, b):
        p[find(p, a)] = find(p, b)

作答记录只保存在这台设备的浏览器里;登录后打开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钥匙和房间将房间钥匙抽象为有向边,验证从起点可达全部节点。难度分 1412
547省份数量从邻接矩阵统计连通分量,比较遍历与并查集。
323无向图中连通分量的数目 🔒从边列表统计无向图连通块,覆盖孤立节点。

核心 10

#题目一句话思路难度分
261以图判树 🔒同时检查连通性与无环性,判断无向图是否是一棵树。
684冗余连接按边顺序合并集合,识别首次连接已连通节点的冗余边。
1319连通网络的操作次数把多余边与连通块数联系起来,判断连接全图的最低操作数。难度分 1633
785判断二分图对每个连通分量二染色,发现奇环冲突。难度分 1625
886可能的二分法将互斥关系转为二分图约束,练习题意建模。难度分 1795
721账户合并以共享邮箱建立等价关系,合并账户并整理去重后的输出。
1202交换字符串中的元素用交换关系划分连通分量,在每个分量内排序重排字符。难度分 1855
2101引爆最多的炸弹根据爆炸半径建立有向图,逐起点搜索最大可达范围。难度分 1880
1466重新规划路线在无向遍历中保留原边方向,统计需要反转的边。难度分 1634
2359找到离给定两个节点最近的节点利用每个节点至多一个后继的结构,比较两起点的可达距离。难度分 1715

进阶 2

#题目一句话思路难度分
399除法求值将比值建为带权边,沿路径组合关系并处理未知变量。
1192查找集群内的关键连接用DFS时间戳和low-link识别桥,理解返祖边对连通性的影响。难度分 2085

Core Skills

不调库,亲手实现这一站用到的数据结构或算法,四种语言都能判题。