冗余连接:两端已连通,这条边就是多余的
n 个点的树只有 n−1 条边。按顺序 union,第一次发现两端根相同,这条边加进去会成环,它就是答案。
并查集维护连通块。逐条读边 (a,b):find(a)==find(b) 说明两点已被前面的边连在一起,当前边是冗余边,按题意直接返回;否则把两个根 union。输入顺序扫描,返回的就是那条「最后出现、删掉后仍是树」的边。近似 O(n α(n))。
题目给的是一棵 n 个节点的树,又多加了一条边,现在有 n 条边。图仍然连通,但有且仅有一个环。找出可以删掉的那条边,使剩下的图变回一棵树。若有多个答案,返回输入里最后出现的那一条。
主例 n=5,边是 [1,2]、[2,3]、[3,4]、[1,4]、[1,5]。前三条把 1-2-3-4 连成一条链。第四条 [1,4] 把链的两头接上,成环。删掉它,再接上 [1,5],仍是树。答案是 [1,4]。
本文要回答:为什么不必真的去找环上的全部边,以及主例里 find(1) 和 find(4) 第一次相等,发生在扫到哪一条的时候。
逐条加边,抓第一个环
树的充要条件之一:连通,并且边数是 n−1。现在多一条,连通性还在,多出来的那条一定落在唯一的环上。DFS 找环能做,但要记路径、还要按输入顺序挑「最后出现」的那条。缺的是一种只回答「这两点现在通不通」的结构:通,这条边就是多余的;不通,把它接上。
并查集一开始每个节点自成一块,parent[i]=i。读到边 (a,b),先 find 两边的根。根不同,说明还分属两块,union 把一块挂到另一块下面,这条边是树边。根相同,说明从 a 到 b 已经有一条由前面的边组成的路,再加 (a,b) 就成环。题目要的正是这条边。
用手走主例。parent 初始 1,2,3,4,5 各成一块。(1,2) 根不同,2 挂到 1 下。(2,3) 里 2 的根是 1,3 的根是 3,3 挂到 1 下。(3,4) 里 3 的根是 1,4 仍是 4,4 挂到 1 下。现在 1、2、3、4 一块,5 还单独。(1,4) 两边 find 都是 1,根相同,这条边多余。演示场景前三步 connected 在长,第四步 parent 不再变,标出冗余 [1,4]。
[1,5] 还没读到。已经可以返回了:多出来的边只有一条,按输入顺序第一次成环的,就是题面要求的「最后那条可删边」——因为更早的边都还是树边,删它们里的任何一条都会把图弄断,只剩这条可删。路径压缩让 find 沿着 parent 往上走时把途经节点直接挂到根上,之后的比较近乎常数。
两条边就成环的自环或双边,第一次根相同就会被抓住。星形树再加一条叶子之间的边,同样是扫到那条叶子边时两端已通过中心连通。节点编号从 1 到 n,parent 开 n+1 格,0 闲置。这些情况都不用另写。
并查集「两点是否已经连通」被压成一次根比较。路径压缩之后 find 接近 O(1)。本题不需要按秩合并也能过,因为 n 不大,正确性只依赖根是否相同。
Go:并查集
func findRedundantConnection(edges [][]int) []int {parent := make([]int, len(edges)+1)for i := range parent { parent[i] = i }var find func(int) intfind = func(x int) int {if parent[x] != x { parent[x] = find(parent[x]) }return parent[x]}for _, e := range edges {a, b := find(e[0]), find(e[1])if a == b { return e }parent[a] = b}return nil}
1n 条边对应 n 个节点,parent 开 n+1,下标 1..n 各自为根。
2find 带路径压缩:沿途节点直接挂到根上。
3a==b 就是成环。主例扫到 [1,4] 时两边根都是 1,立刻返回这条边。
4否则 parent[a]=b 合并。主例前三条边走的都是这一支。
总结
按边 union,第一次两端已连通的就是冗余边。主例是 [1,4]。
- n 点 n 边的连通图恰有一个环;按输入顺序第一次成环的边可删。
- 根相同 = 已经有路,再加就成环。不必把环上的边都找出来。
- 路径压缩只加速 find,不改变「是否同根」的判定。