第 18 站 · 图论
生成树与树形图
Kruskal:按边权排序 + 并查集 O(E log E)
- 先学
- 学完去
- 没有后续站依赖它。
- 面试频次
- 面经里还没有对应到这一站的题。
先做这道
LC 1584连接所有点的最小费用卡住再看
将点间连接代价建为带权图,用最小生成树避免局部贪心错误。
要点
- 按边权从小到大看每条边:能连起两个不同连通块就选,否则跳过(选了会成环)。
- 选够 n-1 条边就是一棵生成树;最后不够 n-1 条,说明图不连通。
- 点少边多的稠密图(比如坐标点两两相连)用 O(n²) 的 Prim,往往比给所有边排序更快。
易错点
- 没检查是否连通,直接返回累加的边权。
- 坐标点两两连边有 n²/2 条,内存和排序时间都可能超限。
- 点的编号从 1 开始,并查集却只开了 n 个位置。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
Kruskal · 最小生成树
用最小的总权重把 6 个点连通
- 已选边
- 0 / 5
- 总权重
- 0
边按权重从小到大排好(下方格子)。一开始每个点自成一个连通块。
始终成立已选的边始终无环;按权重从小到大,每次选连接两个不同连通块的最轻的边。
模板
选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 1584。
Python
def mst_weight(n: int, edges: list[tuple[int, int, int]]) -> int:
"""Undirected edges (u, v, w). Weight of a minimum spanning tree, or -1 if disconnected."""
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
total = used = 0
for u, v, w in sorted(edges, key=lambda e: e[2]):
ru, rv = find(u), find(v)
if ru != rv: # the lightest edge joining two different components
parent[ru] = rv
total += w
used += 1
return total if used == n - 1 else -1C++
// Undirected edges {u, v, w}. Weight of a minimum spanning tree, or -1 if disconnected.
long long mstWeight(int n, vector<array<int, 3>> edges) {
sort(edges.begin(), edges.end(), [](auto& a, auto& b) { return a[2] < b[2]; });
vector<int> parent(n);
iota(parent.begin(), parent.end(), 0);
auto find = [&](int x) {
while (parent[x] != x) x = parent[x] = parent[parent[x]];
return x;
};
long long total = 0;
int used = 0;
for (auto& [u, v, w] : edges) {
int ru = find(u), rv = find(v);
if (ru == rv) continue; // would close a cycle
parent[ru] = rv;
total += w;
used++;
}
return used == n - 1 ? total : -1;
}Java
class Solution {
private int[] parent;
// Undirected edges {u, v, w}. Weight of a minimum spanning tree, or -1 if disconnected.
long mstWeight(int n, int[][] edges) {
Arrays.sort(edges, (a, b) -> Integer.compare(a[2], b[2]));
parent = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
long total = 0;
int used = 0;
for (int[] e : edges) {
int ru = find(e[0]), rv = find(e[1]);
if (ru == rv) continue; // would close a cycle
parent[ru] = rv;
total += e[2];
used++;
}
return used == n - 1 ? total : -1;
}
private int find(int x) {
while (parent[x] != x) x = parent[x] = parent[parent[x]];
return x;
}
}Go
import "sort"
// mstWeight takes undirected edges {u, v, w} and returns the weight of a minimum
// spanning tree, or -1 if the graph is disconnected.
func mstWeight(n int, edges [][3]int) int {
sort.Slice(edges, func(i, j int) bool { return edges[i][2] < edges[j][2] })
parent := make([]int, n)
for i := range parent {
parent[i] = i
}
find := func(x int) int {
for parent[x] != x {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
total, used := 0, 0
for _, e := range edges {
ru, rv := find(e[0]), find(e[1])
if ru == rv {
continue // would close a cycle
}
parent[ru] = rv
total += e[2]
used++
}
if used != n-1 {
return -1
}
return total
}常见变体
这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。
稠密图 Prim:O(n²) 不用堆时间 O(n²) · 空间 O(n)
完全图或边数接近 n² 时(LC 1584 连接所有点的最小费用),先列出所有边再给 Kruskal 排序要 O(n² log n);Prim 每次从树外挑距离最近的点加入、再用它更新其他点的距离,用数组扫一遍就是 O(n²),也不用把边存下来。
Python
def min_cost_connect(points: list[list[int]]) -> int:
"""Minimum spanning tree weight of all points under Manhattan distance (LC 1584)."""
n = len(points)
dist = [float("inf")] * n # dist[v]: cheapest edge from the tree to v so far
dist[0] = 0
in_tree = [False] * n
total = 0
for _ in range(n):
u = min((v for v in range(n) if not in_tree[v]), key=lambda v: dist[v]) # nearest outside node
in_tree[u] = True
total += dist[u]
for v in range(n):
if not in_tree[v]:
d = abs(points[u][0] - points[v][0]) + abs(points[u][1] - points[v][1])
dist[v] = min(dist[v], d)
return totalC++
// Minimum spanning tree weight of all points under Manhattan distance (LC 1584).
long long minCostConnect(const vector<pair<int, int>>& points) {
int n = points.size();
vector<long long> dist(n, LLONG_MAX); // dist[v]: cheapest edge from the tree to v so far
dist[0] = 0;
vector<bool> inTree(n, false);
long long total = 0;
for (int round = 0; round < n; round++) {
int u = -1;
for (int v = 0; v < n; v++) // nearest outside node
if (!inTree[v] && (u < 0 || dist[v] < dist[u])) u = v;
inTree[u] = true;
total += dist[u];
for (int v = 0; v < n; v++)
if (!inTree[v]) {
long long d = abs(points[u].first - points[v].first) + abs(points[u].second - points[v].second);
dist[v] = min(dist[v], d);
}
}
return total;
}Java
class Solution {
// Minimum spanning tree weight of all points under Manhattan distance (LC 1584).
long minCostConnect(int[][] points) {
int n = points.length;
long[] dist = new long[n]; // dist[v]: cheapest edge from the tree to v so far
Arrays.fill(dist, Long.MAX_VALUE);
dist[0] = 0;
boolean[] inTree = new boolean[n];
long total = 0;
for (int round = 0; round < n; round++) {
int u = -1;
for (int v = 0; v < n; v++) // nearest outside node
if (!inTree[v] && (u < 0 || dist[v] < dist[u])) u = v;
inTree[u] = true;
total += dist[u];
for (int v = 0; v < n; v++)
if (!inTree[v]) {
long d = Math.abs(points[u][0] - points[v][0]) + Math.abs(points[u][1] - points[v][1]);
dist[v] = Math.min(dist[v], d);
}
}
return total;
}
}Go
// minCostConnect returns the minimum spanning tree weight of all points under Manhattan distance.
func minCostConnect(points [][2]int) int {
n := len(points)
abs := func(x int) int {
if x < 0 {
return -x
}
return x
}
dist := make([]int, n) // dist[v]: cheapest edge from the tree to v so far
for v := 1; v < n; v++ {
dist[v] = 1 << 62
}
inTree := make([]bool, n)
total := 0
for round := 0; round < n; round++ {
u := -1
for v := 0; v < n; v++ { // nearest outside node
if !inTree[v] && (u < 0 || dist[v] < dist[u]) {
u = v
}
}
inTree[u] = true
total += dist[u]
for v := 0; v < n; v++ {
if !inTree[v] {
d := abs(points[u][0]-points[v][0]) + abs(points[u][1]-points[v][1])
dist[v] = min(dist[v], d)
}
}
}
return total
}一层层剥叶子找树的中心时间 O(n) · 空间 O(n)
问以哪个点为根树最矮(LC 310 最小高度树):把度为 1 的叶子一圈圈剥掉,像拓扑排序一样,剩下的最后 1 到 2 个点就是中心。每个点进出队列一次,O(n);不用从每个点都 BFS 一遍。
Python
def min_height_roots(n: int, edges: list[list[int]]) -> list[int]:
"""Roots giving the tree its minimum height (LC 310): the 1 or 2 nodes left after peeling leaves."""
if n <= 2:
return list(range(n))
graph = [[] for _ in range(n)]
deg = [0] * n
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
deg[u] += 1
deg[v] += 1
leaves = [u for u in range(n) if deg[u] == 1]
left = n
while left > 2: # peel one whole layer of leaves at a time
left -= len(leaves)
nxt = []
for leaf in leaves:
for nb in graph[leaf]:
deg[nb] -= 1
if deg[nb] == 1: # nb just became a leaf of the smaller tree
nxt.append(nb)
leaves = nxt
return sorted(leaves)C++
// Roots giving the tree its minimum height (LC 310): the 1 or 2 nodes left after peeling leaves.
vector<int> minHeightRoots(int n, const vector<pair<int, int>>& edges) {
if (n <= 2) {
vector<int> all(n);
iota(all.begin(), all.end(), 0);
return all;
}
vector<vector<int>> graph(n);
vector<int> deg(n, 0), leaves;
for (auto [u, v] : edges) {
graph[u].push_back(v);
graph[v].push_back(u);
deg[u]++;
deg[v]++;
}
for (int u = 0; u < n; u++)
if (deg[u] == 1) leaves.push_back(u);
for (int left = n; left > 2;) { // peel one whole layer of leaves at a time
left -= leaves.size();
vector<int> next;
for (int leaf : leaves)
for (int nb : graph[leaf])
if (--deg[nb] == 1) next.push_back(nb); // nb just became a leaf of the smaller tree
leaves = next;
}
sort(leaves.begin(), leaves.end());
return leaves;
}Java
class Solution {
// Roots giving the tree its minimum height (LC 310): the 1 or 2 nodes left after peeling leaves.
List<Integer> minHeightRoots(int n, int[][] edges) {
List<Integer> leaves = new ArrayList<>();
if (n <= 2) {
for (int u = 0; u < n; u++) leaves.add(u);
return leaves;
}
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
int[] deg = new int[n];
for (int[] e : edges) {
graph.get(e[0]).add(e[1]);
graph.get(e[1]).add(e[0]);
deg[e[0]]++;
deg[e[1]]++;
}
for (int u = 0; u < n; u++)
if (deg[u] == 1) leaves.add(u);
for (int left = n; left > 2;) { // peel one whole layer of leaves at a time
left -= leaves.size();
List<Integer> next = new ArrayList<>();
for (int leaf : leaves)
for (int nb : graph.get(leaf))
if (--deg[nb] == 1) next.add(nb); // nb just became a leaf of the smaller tree
leaves = next;
}
Collections.sort(leaves);
return leaves;
}
}Go
import "sort"
// minHeightRoots returns the roots giving the tree its minimum height (LC 310).
func minHeightRoots(n int, edges [][2]int) []int {
if n <= 2 {
all := make([]int, n)
for i := range all {
all[i] = i
}
return all
}
graph := make([][]int, n)
deg := make([]int, n)
for _, e := range edges {
graph[e[0]] = append(graph[e[0]], e[1])
graph[e[1]] = append(graph[e[1]], e[0])
deg[e[0]]++
deg[e[1]]++
}
var leaves []int
for u := 0; u < n; u++ {
if deg[u] == 1 {
leaves = append(leaves, u)
}
}
for left := n; left > 2; { // peel one whole layer of leaves at a time
left -= len(leaves)
var next []int
for _, leaf := range leaves {
for _, nb := range graph[leaf] {
deg[nb]--
if deg[nb] == 1 { // nb just became a leaf of the smaller tree
next = append(next, nb)
}
}
}
leaves = next
}
sort.Ints(leaves)
return leaves
}为什么对
- 切割性质:把点集任意分成两部分,横跨两部分的最轻边一定属于某棵最小生成树。若某棵树不含它,把它加进去会成环,环上必有另一条横跨边,换掉那条不会变重。
- Kruskal 接受的每条边,都是连接两个不同分量的边里最轻的,也就是「其中一个分量 vs 其余所有点」这个切割上的最轻边,所以每一步都安全。成环的边两端已在同一分量,永远用不上。
30 秒口述稿
面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。
最小生成树我用 Kruskal:边按权重从小到大排序,用并查集判断两端是否已连通,不连通就选上,连通就跳过,因为它会成环。正确性来自切割性质:每次选的边都是某个切割上最轻的横跨边,一定出现在某棵最小生成树里。选满 n−1 条就是生成树,不够说明图不连通。复杂度主要是排序,O(E log E)。For a minimum spanning tree I use Kruskal: sort the edges by weight, and take each edge whose endpoints union-find says are not yet connected, skipping the ones that would close a cycle. It is correct by the cut property: every accepted edge is the lightest edge across some cut, so it belongs to some minimum spanning tree. With n minus one edges chosen I have a spanning tree, and fewer means the graph is disconnected. Sorting dominates, so it is O(E log E).自评一下
对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
def mst_weight(n: int, edges: list[tuple[int, int, int]]) -> int:
"""Undirected edges (u, v, w). Weight of a minimum spanning tree, or -1 if disconnected."""
parent = list(range(n))
return total if used == n - 1 else -1分档练习
本站在 500 路里的 3 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
核心 2
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 1584 | 连接所有点的最小费用 | 将点间连接代价建为带权图,用最小生成树避免局部贪心错误。 | |
| 310 | 最小高度树 | 通过逐层剥离叶子定位树的中心,避免从每个节点重复求高度。 |
进阶 1
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 1168 | 水资源分配优化 🔒 | 增加虚拟水源节点,把打井和铺管统一为最小生成树。 |
本站 OA 真题 3
- Minimize Path ValueSalesforce
- Minimize Path ValuePalantir
- Min Cost to Connect All PointsNutanix