彻底搞懂带权并查集:从“找祖先”到解密“除法求值”
在算法题或者系统连通性图论设计中,并查集(Disjoint Set Union,简称 DSU) 是一种出场率极高、代码异常精简,而且非常好上手的数据结构。如果说普通的并查集是告诉你“这堆东西都在同一个门派里”,那今天我们要讲的 带权并查集(Weighted Union-Find) 则进一步解决了“同一个门派里大家互相欠了多少钱”这样的比例/距离换算问题。
本文将借助一道非常经典的题目 —— 除法求值(LeetCode 399) 的 Go 语言规范实现,带你摸透带权并查集的底牌。
一、什么是带权并查集?为什么要“带权”?
普通并查集的核心有两个动作:
find(x):找祖先(门派掌门人),顺带着把沿路所有小弟直接“挂接”给掌门人(也就是大名鼎鼎的“路径压缩”)。merge(x, y):合并连通域,让其中一个门派的掌门人,去给另一个门派的掌门人当小弟。
但是,普通的集合只能告诉你 A 和 B 是否是连通的。假设我们有一组数据条件:
- 已知 \(A / B = 2.0\)
- 且已知 \(B / C = 3.0\)
此时如果我要查询 \(A / C\) 的值是多少?答案显而易见是 \(2.0 \times 3.0 = 6.0\)。但假如这种方程链条达到成百上千个呢?我们可以使用图论 DFS 或者带权并查集。
带权并查集不仅维护父节点指针 fa[],还会额外维护一个“除开父节点等于多少”的自身权重数组 mul[](针对这道除法题是倍数权重,如果是做距离题那可能是加减法权重)。在任何时刻:
当我们把所有相连通的变量挂接至同一个根节点下时,只要做一次除法,答案就呼之欲出了。
二、带权并查集的核心机制与源码拆解
这段带权并查集的规范 Go 实现中,所有奥秘都在这三个操作里。
1. 数据结构初始化
type UnionFind struct {
fa []int // fa[i] 表示父节点是谁
mul []float64 // mul[i] 表示节点 i 与其父亲相互作用的权值
}
func NewUnionFind(n int) UnionFind {
fa := make([]int, n)
mul := make([]float64, n)
for i := range fa {
fa[i] = i
mul[i] = 1.0 // 最初都是孤证,自己相当于自己,所以默认比例 1.0
}
return UnionFind{fa: fa, mul: mul}
}
2. Find与带权路径压缩:一棒子捅到底
这是整个架构中最优美的一处逻辑。平时没有权重时,u.fa[x] = root 就完事了。但加入了倍数后,随着爷爷变成了爸爸,中间错过的父辈倍数要乘回到自身。
func (u *UnionFind) find(x int) int {
if u.fa[x] != x {
// 先递归:向深层走,拿到终极老祖宗
root := u.find(u.fa[x])
// 回来的时候,自己的权重 = 曾经对原父亲的权重 * 原父亲对老祖宗的权重
u.mul[x] *= u.mul[u.fa[x]]
// 直接跨越认祖:将当前节点直接挂载到终极老祖宗身上(路径压缩)
u.fa[x] = root
}
return u.fa[x]
}
举个形象的例子:
假设 A 是 B 的 2 倍,B 是 C 的 3 倍。我们在查询 A 也就是执行 find(A) 时,原本结构是 \(A \rightarrow B \rightarrow C\)。
回溯阶段:A 直接认 C 做父亲,此时要把自己 2倍 的光环乘上父亲 B 身上 3倍 的光环,变成 \(A \rightarrow C\),mul[A] 被更新为了 6.0。
3. Merge:平衡并查集宇宙的秤杆
如果有新的公式传来,比如把分支一(树 \(X\))接进分支二(树 \(Y\)),那这根嫁接过去的“接枝”应该赋予多少权值呢?
func (u *UnionFind) merge(from, to int, value float64) {
x, y := u.find(from), u.find(to)
if x == y {
return // 本来就是同一家人!
}
// X门派带着小弟投靠Y门派。
// 已知方程 from / to = value,结合我们有的到根节点的从属关系,反推两边掌门之间的新比率
u.mul[x] = u.mul[to] * value / u.mul[from]
u.fa[x] = y
}
三、实战演练:LeetCode 399 除法求值
为了更好地将上述理论落地,我们来看具体的例题与完整代码。
题目描述(简化版):
给你一个变量对数组 equations 和一个实数值数组 values 作为已知条件,其中 equations[i] = [Ai, Bi] 和 values[i] 共同表示等式 Ai / Bi = values[i]。每个 Ai 或 Bi 是一个表示单个变量的字符串。
另有一些以数组 queries 表示的问题,其中 queries[j] = [Cj, Dj] 表示第 j 个问题,请你根据已知条件求出 Cj / Dj 的答案。
如果无法确定答案(例如变量不在已知条件中出现,或者它们之间没有连通关系),则返回 -1.0。
完整解题代码:
我们把前面讲到的带权并查集封装好后,主业务逻辑其实就是建图查询的过程:
package main
// UnionFind 结构体定义了一个带权值的并查集
type UnionFind struct {
fa []int // fa[i] 表示节点 i 的父节点
mul []float64 // mul[i] 表示当前节点到其父节点的权重比例
}
// NewUnionFind 初始化大小为 n 的带权并查集
func NewUnionFind(n int) UnionFind {
fa := make([]int, n)
mul := make([]float64, n)
for i := range fa {
fa[i] = i
mul[i] = 1.0
}
return UnionFind{fa: fa, mul: mul}
}
// find 查询元素所属门派的根节点,伴随“路径压缩”与“权值更新”
func (u *UnionFind) find(x int) int {
if u.fa[x] != x {
root := u.find(u.fa[x])
u.mul[x] *= u.mul[u.fa[x]]
u.fa[x] = root
}
return u.fa[x]
}
// same 判断两个变量是否同属一个图结构(拥有共同祖先)
func (u *UnionFind) same(x, y int) bool {
return u.find(x) == u.find(y)
}
// merge 合并两个节点
func (u *UnionFind) merge(from, to int, value float64) {
x, y := u.find(from), u.find(to)
if x == y {
return
}
u.mul[x] = u.mul[to] * value / u.mul[from]
u.fa[x] = y
}
func calcEquation(equations [][]string, values []float64, queries [][]string) []float64 {
// 1. 哈希表将字符变量映射到连续的整数ID,方便数组操作
varToId := map[string]int{}
for _, equation := range equations {
for _, s := range equation {
if _, ok := varToId[s]; !ok {
varToId[s] = len(varToId)
}
}
}
// 2. 基于映射总数初始化并查集,并根据方程式建图
unionFind := NewUnionFind(len(varToId))
for i, equation := range equations {
// 建图:将 equation[1] 和 equation[0] 以 values[i] 的比率关系挂接相连
unionFind.merge(varToId[equation[1]], varToId[equation[0]], values[i])
}
// 3. 处理查询
ans := make([]float64, len(queries))
for i, q := range queries {
a, okA := varToId[q[0]]
b, okB := varToId[q[1]]
// 变量未出现过,或者不属于一个连通域,则绝对无法算出
if !okA || !okB || !unionFind.same(a, b) {
ans[i] = -1.0
continue
}
// 两者属于同一个树结构,直接将相对根节点的权重做商相除得出答案
ans[i] = unionFind.mul[b] / unionFind.mul[a]
}
return ans
}
四、总结
判断一个图有没有环、有没有连通区域?用朴素并查集。
如果这个图的边不仅仅表示“有红线相牵”,还表示“大你多少岁、快你多少秒、是你的多少倍”,就务必掏出“带权并查集”。
你只需要:
- 额外带上一条表征比例差额的数组。
- 在
find()路径压缩时,根据向量首尾相挂的原则对权重进行叠加(如果是差值就是相加,倍数就是相乘)。 - 在
merge()连接两根树时解一笔非常简单的小学方程式。
搞定这三步,带权图的联通推导问题,就会完全变成默写代码的送分题。

浙公网安备 33010602011771号