目录

题目描述

1489. 找到最小生成树里的关键边和伪关键边

题意分析

给一张 n 个点、带权无向连通图,要把每条边分成三类并输出前两类的原始下标

  • 关键边:删掉它之后,图的最小生成树权重会变大(或者图直接不连通)。等价说法是「它出现在每一棵最小生成树里」。
  • 伪关键边:不是关键边,但至少存在一棵最小生成树包含它。
  • 剩下的边:任何一棵最小生成树都不会用到它。

这两个定义本身就把解法写出来了——它们都是「反事实」的判断,一个问「拿掉会怎样」,一个问「强行加上会怎样」。只要能反复计算最小生成树的权重,两个判定就都是一次比较

约束是本题最重要的信号:n <= 100,边数 edges.length <= 200。这个规模小得反常。$E$ 只有 200,意味着「对每条边各跑两次 Kruskal」的 $O(E^2 \alpha)$ 也不过 $200 \times 200 \times 2 = 8 \times 10^4$ 次并查集操作,毫无压力。出题人给出这个范围,就是在明示「暴力枚举每条边 + 重跑 MST」是期望解法;真正需要证明的是「删边变大 ⇒ 关键边」和「强制加入仍等于基准 ⇒ 伪关键边」这两条判据的正确性,而不是把 Tarjan 求桥那套搬出来。

有一个陷阱必须提前处理:题目要返回原始下标,而 Kruskal 必须先按权重排序。排序会打乱下标,所以排序前必须把原始下标随边一起带上。

边界:可能存在权重相同的多条边(这正是「伪关键」这个概念存在的根本原因——权重相同的边可以互换,谁进 MST 都行);可能存在重边和权重相同的平行结构;删掉某条边后图可能不连通,此时该边必然是关键边。

解法:Kruskal 枚举每条边

核心思路

数据规模允许对每条边重新运行 Kruskal。先用全部边求最小生成树权重 base,再通过“排除”和“强制”两个反事实实验分类。

  • 排除边 e 后,若图不连通或最小权重大于 base,说明每棵 MST 都必须包含 e,它是关键边。
  • 对非关键边,先强制加入 e,再用 Kruskal 补齐;若总权重仍等于 base,说明至少存在一棵包含 e 的 MST,它是伪关键边。

必须先判关键边,因为关键边被强制加入时也会得到 base。排序前给每条边附加原始下标,避免 Kruskal 排序后丢失题目要求的编号。

buildMst(skip, force) 每次使用全新的并查集。强制边先合并并计入权重;随后按权重升序扫描其余边,只在连接两个不同分量时选择。若最终选择边数不足 n - 1,返回无穷大表示不连通。

正确性说明:Kruskal 的割性质保证 buildMst 分别得到无约束、排除某边、包含某边这三种约束下的最小权重。因此排除后变差恰好等价于“所有 MST 都含该边”;排除不变而强制后仍为 base,恰好等价于“存在但并非所有 MST 含该边”。两类互斥且与定义一致。

解题步骤

  • 将每条边扩展为 [u,v,weight,originalIndex],按权重排序一次。
  • 实现受 skipforce 约束的 Kruskal,并用 n - 1 次成功合并判断连通。
  • 无约束运行一次得到 base
  • 对每条排序后的边,先排除运行;结果大于 base 就加入关键边。
  • 否则强制运行;结果等于 base 就加入伪关键边。
  • 输出两个原始下标列表。

官方样例的基准权重为 7;排除原始边 0、1 后最优权重升为 8,所以它们关键。边 2、3、4、5 都可被强制加入且仍得到 7,因此是伪关键边。权重相同、平行边以及排除后断图都由同一流程处理。

代码实现

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class Solution {
    private static final long INF = Long.MAX_VALUE / 4;

    public List<List<Integer>> findCriticalAndPseudoCriticalEdges(
            int n, int[][] edges) {
        int[][] sorted = new int[edges.length][4];
        for (int i = 0; i < edges.length; i++) {
            sorted[i] = new int[] {
                edges[i][0], edges[i][1], edges[i][2], i
            };
        }
        Arrays.sort(sorted, (a, b) -> Integer.compare(a[2], b[2]));

        long base = buildMst(n, sorted, -1, -1);
        List<Integer> critical = new ArrayList<>();
        List<Integer> pseudoCritical = new ArrayList<>();

        for (int i = 0; i < sorted.length; i++) {
            if (buildMst(n, sorted, i, -1) > base) {
                critical.add(sorted[i][3]);
            } else if (buildMst(n, sorted, -1, i) == base) {
                pseudoCritical.add(sorted[i][3]);
            }
        }
        return Arrays.asList(critical, pseudoCritical);
    }

    private long buildMst(int n, int[][] edges, int skip, int force) {
        UnionFind unionFind = new UnionFind(n);
        long weight = 0;
        int used = 0;

        if (force >= 0) {
            int[] edge = edges[force];
            if (!unionFind.union(edge[0], edge[1])) {
                return INF;
            }
            weight += edge[2];
            used++;
        }

        for (int i = 0; i < edges.length && used < n - 1; i++) {
            if (i == skip || i == force) {
                continue;
            }
            int[] edge = edges[i];
            if (unionFind.union(edge[0], edge[1])) {
                weight += edge[2];
                used++;
            }
        }
        return used == n - 1 ? weight : INF;
    }

    private static class UnionFind {
        private final int[] parent;
        private final int[] rank;

        UnionFind(int n) {
            parent = new int[n];
            rank = new int[n];
            for (int i = 0; i < n; i++) {
                parent[i] = i;
            }
        }

        int find(int x) {
            while (x != parent[x]) {
                parent[x] = parent[parent[x]];
                x = parent[x];
            }
            return 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;
        }
    }
}
import "sort"

func findCriticalAndPseudoCriticalEdges(n int, edges [][]int) [][]int {
	sorted := make([][]int, len(edges))
	for i, edge := range edges {
		sorted[i] = []int{edge[0], edge[1], edge[2], i}
	}
	sort.Slice(sorted, func(i, j int) bool {
		return sorted[i][2] < sorted[j][2]
	})

	const inf int64 = 1 << 60
	buildMst := func(skip, force int) int64 {
		parent := make([]int, n)
		rank := make([]int, n)
		for i := range parent {
			parent[i] = i
		}

		find := func(x int) int {
			for x != parent[x] {
				parent[x] = parent[parent[x]]
				x = parent[x]
			}
			return x
		}
		union := func(x, y int) bool {
			rootX, rootY := find(x), 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
		}

		var weight int64
		used := 0
		if force >= 0 {
			edge := sorted[force]
			if !union(edge[0], edge[1]) {
				return inf
			}
			weight += int64(edge[2])
			used++
		}

		for i, edge := range sorted {
			if used == n-1 {
				break
			}
			if i == skip || i == force {
				continue
			}
			if union(edge[0], edge[1]) {
				weight += int64(edge[2])
				used++
			}
		}
		if used != n-1 {
			return inf
		}
		return weight
	}

	base := buildMst(-1, -1)
	critical := make([]int, 0)
	pseudoCritical := make([]int, 0)
	for i, edge := range sorted {
		if buildMst(i, -1) > base {
			critical = append(critical, edge[3])
		} else if buildMst(-1, i) == base {
			pseudoCritical = append(pseudoCritical, edge[3])
		}
	}
	return [][]int{critical, pseudoCritical}
}

复杂度分析

  • 时间复杂度:$O(E \log E + E^2 \alpha(V))$。排序一次;每条边最多触发两次扫描全部边的 Kruskal。
  • 空间复杂度:$O(E + V)$。排序副本占 E,并查集占 V;输出不计入额外空间。

关键点总结

  • 关键边用“排除后是否变差”判定,伪关键边用“强制后能否仍最优”判定。
  • 必须先关键、后伪关键,保证两个输出列表互斥。
  • 每轮 Kruskal 都要重建并查集,避免状态泄漏。
  • 排序前绑定原始下标;所有分类结果都返回原始编号。
  • 不连通统一编码为无穷大,使断图和权重上升共用同一比较。

易错点总结

  • 先判伪关键:关键边强制加入也等于 base,会同时出现在两个列表。
  • 排除后不检查连通性:桥边被删后只得到一片森林,其较小权重不能与 MST 比较。
  • 强制加入但漏加边权或边数:受约束 Kruskal 的结果会系统性偏小或被误判为不连通。
  • 主循环再次选择强制边:若实现不依赖并查集自动拒绝,会重复计数;代码显式跳过 force
  • 复用并查集:上一轮连通状态会污染下一条边的分类。
  • 丢失原始下标:排序位置不是题目要求的边编号。
  • 关键判断使用 >=:排除后权重相等代表存在不含该边的 MST,它不是关键边。

相似题目

题目 难度 考察点
1584. 连接所有点的最小费用 中等 边由坐标两两生成的稠密图,考察 Kruskal 与 Prim 在稠密图上的取舍
1135. 最低成本连通所有城市 中等 最裸的 MST 模板,重点在判断图是否连通、不连通时返回 -1
1168. 水资源分配优化 困难 需要引入虚拟节点把「打井成本」建模成边,考察建图技巧而非算法本身
1319. 连通网络的操作次数 中等 只用并查集数连通块与冗余边,不涉及权重,是本题并查集部分的简化版
684. 冗余连接 中等 用并查集在加边过程中检测出第一条成环的边,考察 union 返回值的用法
721. 账户合并 中等 并查集配合哈希映射把字符串归组,重点在如何把非整数实体映射成并查集下标