LeetCode 1135. 最低成本连通所有城市
题目描述
题意分析
给定 n 座编号从 1 到 n 的城市,以及一批「连接城市 a 和 b 需要花费 cost」的候选连接,要求挑出一个花费总和最小的子集,使得任意两座城市之间都能互相到达。注意连接是双向的,且题目只关心连通性而不关心具体路径长度,这两点决定了它是一个无向图上的选边问题,而不是最短路问题。
「让所有点连通」和「总权重最小」这两个要求同时出现,是一个非常明确的信号。要连通 n 个点至少需要 n - 1 条边,而只要选出的边数恰好是 n - 1 且不成环,图就必然连通;反过来,任何多余的边都只会白白增加成本。所以答案一定是一棵包含全部 n 个点、边数为 n - 1 的树,问题就变成在所有这样的树里挑权重和最小的那一棵。
输入里的城市编号从 1 开始而不是从 0 开始,这个细节会直接影响辅助数组的开辟大小,是本题最常见的翻车点。另外候选连接可能包含权重不同的重复边,也可能整体就不足以连通所有城市。
边界要单独想:候选边数少于 n - 1 时无论怎么选都不可能连通,必须返回 -1;即使边数够多,也可能全部集中在某几座城市之间,导致剩下的城市成为孤岛,同样返回 -1;多条边成本相同时任选其一都不影响最优总成本,不需要额外的打破平局规则。
解法:Kruskal + 并查集
核心思路
暴力做法是枚举所有 n - 1 条边的组合,检查是否连通并取最小总成本,组合数是指数级的,完全不可行。稍微聪明一点的做法是随便先造一棵生成树再尝试换边优化,但「换哪条边能变小」本身又是一个需要遍历环上所有边的子问题,瓶颈在于每次判断都要重新分析全图结构。
关键观察来自一条可以直接证明的性质:把所有边按成本从小到大排序后,依次考察每条边,如果它连接的两个端点当前还不连通,那么这条边一定属于某棵最小生成树。直觉上讲,此时这两个连通块之间迟早要用一条边接上,而所有还没被考察的边成本都不小于它,用它接上不会比用任何别的边更差。这条性质把全局最优化问题拆成了一串独立的局部决策,贪心因此成立。
于是维护的不变量是:已选边的集合在任何时刻都是一个无环子图(即若干棵树构成的森林),并且它是「只使用已考察过的这些边」时能达到的最优森林。要维持这条不变量,每一步只需要回答一个问题——当前边的两个端点是否已经在同一棵树里。
回答这个问题的数据结构就是并查集:每个连通块用一个代表元标识,find 沿父指针一路上溯找到代表元,union 把两个代表元挂在一起。端点代表元相同说明加入这条边会成环,直接跳过;不同则合并并累加成本。同时用 edgeCount 记录已选边数,一旦达到 n - 1 就说明森林已经收缩成一棵覆盖全部城市的树,可以立即返回;循环自然结束时仍未达到,说明图本身不连通,返回 -1。
解题步骤
第一步,按第三个分量对 connections 升序排序。这是整个贪心的前提:只有从小到大考察,才能保证「第一条能连上两个不同连通块的边」就是这两块之间的最优选择。用
Integer.compare而不是相减做比较,是为了避免成本差值在极端取值下发生整型溢出导致比较结果反号。
第二步,初始化容量为 n + 1 的并查集,并把答案 answer 和已选边数 edgeCount 清零。容量必须是 n + 1 而不是 n,因为城市编号是 1 到 n,下标 n 必须是合法的;多出来的 0 号位置永远不会被访问,浪费一个槽位换来编号无需平移,代码更不容易出错。
第三步,顺序遍历排好序的边,取出两端城市和成本,调用 union。这里把「判环」和「合并」合并进同一个方法并让它返回布尔值,是一个值得养成的习惯:返回 false 表示两点本就连通,这条边被丢弃;返回 true 表示合并成功,此时才累加成本并把边数加一。分成 find 两次再比较再 union 的写法会重复走一遍路径,逻辑也更容易漏掉某个分支。
第四步,每次成功合并后检查 edgeCount 是否等于 n - 1,成立就立刻返回 answer。提前返回的意义不只是省时间,更是表达「树已经建成」这个语义;如果一直没有触发,循环结束后返回 -1,说明所有候选边都考察完了仍有城市没被并进来。
并查集内部两处细节值得说明:find 里的
parent[x] = find(parent[x])是路径压缩,它在回溯时把整条查询路径上的节点全部直接挂到根上,让后续查询几乎是常数时间;union 里按 rank 决定谁挂谁,是为了避免树高线性增长,两者配合才能得到近乎常数的均摊复杂度。注意 rank 只在两棵树高度相等时才加一,因为只有这种情况下合并才真的让树长高了一层。
以
n = 3, connections = [[1,2,5],[1,3,6],[2,3,1]]走一遍:排序后边的顺序变成 [2,3,1]、[1,2,5]、[1,3,6]。第一条边连接 2 和 3,此时 find(2) = 2、find(3) = 3 不相等,合并成功(rank 相等,3 挂到 2 下面且 rank[2] 变 1),answer = 1,edgeCount = 1,还不等于 n - 1 = 2。第二条边连接 1 和 2,find(1) = 1、find(2) = 2 不相等,合并成功(rank[1] 为 0 小于 rank[2] 为 1,所以 1 挂到 2 下面),answer = 1 + 5 = 6,edgeCount = 2,等于 n - 1,立即返回 6。第三条边 [1,3,6] 根本没被考察到——即使考察,find(1) 和 find(3) 都会得到 2,被判成环丢弃。最终答案 6,对应选择 2-3 和 1-2 这两条连接,比选 1-2 和 1-3 的 11 更省。
代码实现
class Solution {
// Kruskal 按边权从小到大选边,并查集用于判断加入该边是否会成环。
public int minimumCost(int n, int[][] connections) {
Arrays.sort(connections, (x, y) -> Integer.compare(x[2], y[2]));
UnionFind uf = new UnionFind(n + 1);
int answer = 0;
int edgeCount = 0;
for (int[] edge : connections) {
int cityA = edge[0];
int cityB = edge[1];
int cost = edge[2];
if (!uf.union(cityA, cityB)) {
continue;
}
answer += cost;
edgeCount++;
if (edgeCount == n - 1) {
return answer;
}
}
return -1;
}
private static class UnionFind {
private final int[] parent;
private final int[] rank;
UnionFind(int size) {
parent = new int[size];
rank = new int[size];
for (int idx = 0; idx < size; idx++) {
parent[idx] = idx;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
boolean union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) {
return false;
}
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
return true;
}
}
}
func minimumCost(n int, connections [][]int) int {
// Kruskal 按边权从小到大选边,并查集用于判断加入该边是否会成环。
sort.Slice(connections, func(i int, j int) bool {
return connections[i][2] < connections[j][2]
})
uf := newUnionFind(n + 1)
answer := 0
edgeCount := 0
for _, edge := range connections {
cityA, cityB, cost := edge[0], edge[1], edge[2]
if !uf.union(cityA, cityB) {
continue
}
answer += cost
edgeCount++
if edgeCount == n-1 {
return answer
}
}
return -1
}
type unionFind struct {
parent []int
rank []int
}
func newUnionFind(size int) *unionFind {
parent := make([]int, size)
rank := make([]int, size)
for idx := 0; idx < size; idx++ {
parent[idx] = idx
}
return &unionFind{parent: parent, rank: rank}
}
func (uf *unionFind) find(x int) int {
if uf.parent[x] != x {
uf.parent[x] = uf.find(uf.parent[x])
}
return uf.parent[x]
}
func (uf *unionFind) union(x int, y int) bool {
rootX := uf.find(x)
rootY := uf.find(y)
if rootX == rootY {
return false
}
if uf.rank[rootX] < uf.rank[rootY] {
uf.parent[rootX] = rootY
} else if uf.rank[rootX] > uf.rank[rootY] {
uf.parent[rootY] = rootX
} else {
uf.parent[rootY] = rootX
uf.rank[rootX]++
}
return true
}
复杂度分析
- 时间复杂度:$O(m \log m)$,其中 m 表示候选连接数。排序占据主导,之后对每条边只做一次 union,在路径压缩加按秩合并下单次操作的均摊代价是反阿克曼函数级别,可视为常数,合计 $O(m \log m + m \alpha(n))$ 由排序项主导。
- 空间复杂度:$O(n)$,并查集的 parent 与 rank 两个数组各占 n + 1 个整数;排序若采用原地实现则不额外占用,find 的递归深度在路径压缩下被压得很浅。
关键点总结
- 看到「让所有点连通」加「总代价最小」就应当条件反射地想到最小生成树,并立刻把「答案恰好用 n - 1 条边」这个结构性结论说出来。这个结论既是算法的终止条件,也是判定无解的唯一依据。
- Kruskal 的正确性靠的是排序后的贪心,而贪心之所以成立是因为「当前最小的跨连通块边一定在某棵最优树里」。面试时把这句话讲清楚,比背出算法名字有用得多。
- 让 union 返回布尔值,把「是否成环」和「执行合并」统一进一次调用,是并查集题的通用工程习惯。它天然避免了先判后并之间状态被改动的隐患,也让主循环读起来就是算法本身的描述。
- 路径压缩与按秩合并是两个独立的优化,可以只用其一。面试中若时间紧张,只写路径压缩的版本通常也会被接受,但要能说清两者各自解决什么问题:压缩让查询变浅,按秩让合并不制造高树。
- 面试视角上,被问到「Kruskal 还是 Prim」时的标准答法是看图的稠密程度:边数远小于点数平方的稀疏图用 Kruskal,因为主要开销在排序 m 条边;接近完全图的稠密图用 Prim 配堆或朴素 $O(n^2)$ 版本更划算。本题给的是显式边列表,Kruskal 是更自然的选择。
- 编号从 1 开始时把并查集开成 n + 1 是最省心的做法。与其在每次访问时做减一平移,不如多开一个槽位,减少心智负担也减少出错面。
易错点总结
- 错误写法:并查集容量写成 n → 对用例
n = 3, connections = [[1,2,5],[1,3,6],[2,3,1]],访问下标 3 时直接数组越界抛异常,因为城市编号最大就是 n 本身。
- 错误写法:排序比较器写成
(x, y) -> x[2] - y[2]→ 当成本取到接近整型上下界的极端值时相减溢出,比较结果反号导致边序错乱,选出的生成树不是最小的。
- 错误写法:忘记排序直接遍历 connections → 对用例
n = 3, connections = [[1,2,5],[1,3,6],[2,3,1]],会先选 1-2 花费 5 再选 1-3 花费 6 得到 11,而正确答案是 6。
- 错误写法:不判断是否成环,见边就累加成本 → 同一用例会把三条边全部加上得到 12,且边数超过 n - 1,返回值完全失去意义。
- 错误写法:union 里直接写
parent[x] = y而不是先 find 到根再挂 → 对用例n = 4, connections = [[1,2,1],[2,3,1],[3,4,1]],第二次合并时把已经有父节点的 2 的指针改掉,1 所在的整块被从树上摘下来,最终 edgeCount 计数正确但连通关系已经错乱。
- 错误写法:合并成功后忘记 edgeCount 自增,只靠成本累加判断 → 对同一用例循环结束时 edgeCount 恒为 0,永远不等于 n - 1,函数返回 -1 而不是 3。
- 错误写法:终止条件写成
edgeCount == n→ 对用例n = 2, connections = [[1,2,7]],唯一一条边选完后 edgeCount 是 1 永远到不了 2,返回 -1 而不是 7。
- 错误写法:union 在两根相同时也返回 true → 成环的边被计入 edgeCount,对用例
n = 3, connections = [[1,2,1],[1,2,2],[2,3,3]]会在选完前两条边后就以为树建成,返回 3 而不是 4。
- 错误写法:按秩合并时每次合并都执行
rank[rootX]++→ 秩失去了「树高上界」的含义,退化成合并次数计数,挂接方向随之变得随意,极端链式输入下树高退化,find 变成线性。
- 错误写法:路径压缩写成
find(parent[x])而不把结果赋回parent[x]→ 压缩完全没有生效,对用例中先把 1 到 n 依次两两合并再反复查询的场景,单次 find 退化到 $O(n)$,整体明显变慢。
- 错误写法:认为「候选边数大于等于 n - 1 就一定连通」,于是省掉最后的 -1 分支 → 对用例
n = 4, connections = [[1,2,1],[1,2,2],[3,4,3]],边数够但城市 1、2 与 3、4 分成两块,函数会走完循环却没有返回值可给。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1584. 连接所有点的最小费用 | 中等 | 边未给出,需先由坐标两两生成曼哈顿距离,稠密图更适合 Prim |
| 1168. 水资源分配优化 | 困难 | 打井成本要建模成到虚拟 0 号节点的边,再套同一套 Kruskal |
| 1489. 找到最小生成树里的关键边和伪关键边 | 困难 | 需反复做「删边」「强制加边」的 MST 对比,考察对 MST 权值唯一性的理解 |
| 1319. 连通网络的操作次数 | 中等 | 边无权重,答案退化为连通块数减一,还要先判断线缆是否够用 |
| 547. 省份数量 | 中等 | 只数连通块个数,不需要排序也不需要累加权重 |
| 323. 无向图中连通分量的数目 | 中等 | 并查集的最小骨架,可与 DFS 计数解法直接对照 |
| 684. 冗余连接 | 中等 | 反过来利用 union 返回 false 的时刻定位成环的那条边 |
| 721. 账户合并 | 中等 | 元素是字符串,需要先做映射再并查集,最后还要按根收集分组 |
| 399. 除法求值 | 中等 | 带权并查集,find 时要同步维护到根的比值 |
| 305. 岛屿数量 II | 困难 | 动态加点,连通块计数需随每次插入实时更新 |
| 743. 网络延迟时间 | 中等 | 目标是单源最短路而非连通总代价,需用 Dijkstra 而非 MST |