cswork / 模板打开 500 路题单

第 18 站 · 图论

生成树与树形图

Kruskal:按边权排序 + 并查集 O(E log E)

周赛难度分 难度分 1858–难度分 2069· 中位 难度分 1964(2/3 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 18 站 · 3 题
先学
学完去
没有后续站依赖它。
面试频次
面经里还没有对应到这一站的题。

先做这道

LC 1584连接所有点的最小费用中等
卡住再看

将点间连接代价建为带权图,用最小生成树避免局部贪心错误。

要点

  1. 按边权从小到大看每条边:能连起两个不同连通块就选,否则跳过(选了会成环)。
  2. 选够 n-1 条边就是一棵生成树;最后不够 n-1 条,说明图不连通。
  3. 点少边多的稠密图(比如坐标点两两相连)用 O(n²) 的 Prim,往往比给所有边排序更快。

易错点

  • 没检查是否连通,直接返回累加的边权。
  • 坐标点两两连边有 n²/2 条,内存和排序时间都可能超限。
  • 点的编号从 1 开始,并查集却只开了 n 个位置。

看动画

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

交互演示 · 逐步观察

Kruskal · 最小生成树

用最小的总权重把 6 个点连通

122345610ABCDEF
1C–B
2A–C
2D–E
3E–F
4A–B
5B–D
6D–F
10C–E
当前处理已发现 / 已计算答案
连通块
{A}{B}{C}{D}{E}{F}
已选边
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 -1
C++
// 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 total
C++
// 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
}

为什么对

  1. 切割性质:把点集任意分成两部分,横跨两部分的最轻边一定属于某棵最小生成树。若某棵树不含它,把它加进去会成环,环上必有另一条横跨边,换掉那条不会变重。
  2. Kruskal 接受的每条边,都是连接两个不同分量的边里最轻的,也就是「其中一个分量 vs 其余所有点」这个切割上的最轻边,所以每一步都安全。成环的边两端已在同一分量,永远用不上。

30 秒口述稿

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

中文
最小生成树我用 Kruskal:边按权重从小到大排序,用并查集判断两端是否已连通,不连通就选上,连通就跳过,因为它会成环。正确性来自切割性质:每次选的边都是某个切割上最轻的横跨边,一定出现在某棵最小生成树里。选满 n−1 条就是生成树,不够说明图不连通。复杂度主要是排序,O(E log E)。
English
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 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

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

小测

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

3 道题
  1. Kruskal 算法按权值从小到大考虑每条边,什么情况下跳过这条边?
  2. 一个有 V 个顶点的连通无向带权图,下面哪些说法正确?(多选)
  3. V = n 个顶点,E = len(edges) 条边,图连通。时间复杂度是?
    def kruskal(n, edges):
        p = list(range(n))
    
        def find(x):
            while p[x] != x:
                p[x] = p[p[x]]
                x = p[x]
            return x
    
        total = 0
        for w, u, v in sorted(edges):
            ru, rv = find(u), find(v)
            if ru != rv:
                p[ru] = rv
                total += w
        return total

作答记录只保存在这台设备的浏览器里;登录后打开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连接所有点的最小费用将点间连接代价建为带权图,用最小生成树避免局部贪心错误。难度分 1858
310最小高度树通过逐层剥离叶子定位树的中心,避免从每个节点重复求高度。

进阶 1

#题目一句话思路难度分
1168水资源分配优化 🔒增加虚拟水源节点,把打井和铺管统一为最小生成树。难度分 2069

Core Skills

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