彻底搞懂带权并查集:从“找祖先”到解密“除法求值”

在算法题或者系统连通性图论设计中,并查集(Disjoint Set Union,简称 DSU) 是一种出场率极高、代码异常精简,而且非常好上手的数据结构。如果说普通的并查集是告诉你“这堆东西都在同一个门派里”,那今天我们要讲的 带权并查集(Weighted Union-Find) 则进一步解决了“同一个门派里大家互相欠了多少钱”这样的比例/距离换算问题。

本文将借助一道非常经典的题目 —— 除法求值(LeetCode 399) 的 Go 语言规范实现,带你摸透带权并查集的底牌。


一、什么是带权并查集?为什么要“带权”?

普通并查集的核心有两个动作:

  1. find(x):找祖先(门派掌门人),顺带着把沿路所有小弟直接“挂接”给掌门人(也就是大名鼎鼎的“路径压缩”)。
  2. merge(x, y):合并连通域,让其中一个门派的掌门人,去给另一个门派的掌门人当小弟。

但是,普通的集合只能告诉你 AB 是否是连通的。假设我们有一组数据条件:

  • 已知 \(A / B = 2.0\)
  • 且已知 \(B / C = 3.0\)

此时如果我要查询 \(A / C\) 的值是多少?答案显而易见是 \(2.0 \times 3.0 = 6.0\)。但假如这种方程链条达到成百上千个呢?我们可以使用图论 DFS 或者带权并查集。

带权并查集不仅维护父节点指针 fa[],还会额外维护一个“除开父节点等于多少”的自身权重数组 mul[](针对这道除法题是倍数权重,如果是做距离题那可能是加减法权重)。在任何时刻:

\[\text{节点 } i \text{ 到根节点的值} = mul[i] \]

当我们把所有相连通的变量挂接至同一个根节点下时,只要做一次除法,答案就呼之欲出了。


二、带权并查集的核心机制与源码拆解

这段带权并查集的规范 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]
}

举个形象的例子:
假设 AB 的 2 倍,BC 的 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]。每个 AiBi 是一个表示单个变量的字符串。
另有一些以数组 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
}

四、总结

判断一个图有没有环、有没有连通区域?用朴素并查集。
如果这个图的边不仅仅表示“有红线相牵”,还表示“大你多少岁、快你多少秒、是你的多少倍”,就务必掏出“带权并查集”。

你只需要:

  1. 额外带上一条表征比例差额的数组。
  2. find() 路径压缩时,根据向量首尾相挂的原则对权重进行叠加(如果是差值就是相加,倍数就是相乘)。
  3. merge() 连接两根树时解一笔非常简单的小学方程式。

搞定这三步,带权图的联通推导问题,就会完全变成默写代码的送分题。

posted @ 2026-04-07 00:21  ousenseiryuo  阅读(38)  评论(0)    收藏  举报