算法优化思路~以并查集为例
最近,笔者在阅读《算法》一书,它在基础篇中以并查集作为样例,介绍了一个算法是怎样一步步优化,提升性能的。笔者看后非常有感觉,故以本文进行了一个实践。
本文中的例子都将以golang
实现,详细代码见代码仓库
并查集
并查集(Disjoint-set data struct、Union-find data struct)是一种用于处理不相交集合的合并以及查询的数据结构,优化后可以以接近常数的复杂度完成集合查找、集合合并的操作,是常见最高效的数据结构之一。
并查集的核心在于,根据数据构建集合,合并集合,最终产生以若干不相交的集合。在图论中有个很相似的概念,即连通分量,Kruskal算法在求取最小生成树的过程中,应用了并查集作为连通分量的载体。它的API非常简单明了:
- 查询:查询元素的集合序号。
- 合并:合并不相交的集合。
- 添加:新增集合对象。
这些能力映射到抽象层面上,可以通过一个golang接口来框定这个能力范围,代码如下:
type UnionFind interface {
// 新增集合
Add(p int32)
// 查询集合编号,不存在则返回-1
Find(p int32) int32
// 集合是否相交
Connected(p int32, q int32) bool
// 合并集合
Union(p int32, q int32)
// 集合数量
Count() int32
}
并查集是描述集合以及集合元素关系的数据结构,因此需要一个元素以及其归属集合的符号映射表。映射表存储(元素序号, 集合序号)
的符号关系,最简单的实现是例如诸如Hash Table
等常见的符号表作为容器。但是这种方式显然并不最优,并查集时描述确定元素以及元素对应集合的关系的, 可以通过特殊编码来省略额外的集合序号,存储(元素序号, 特定集合元素序号)
,特定元素序号是集合中某个特殊元素的序号(最常见的就是首个集合元素序号)(元素序号, 特定集合元素序号)
可以用一个简单的数组来存储。
此外,整个并查集API,其实就是查询/修改某个元素对应的集合首个元素的序号(常见的实现是quick-find
并查集),具体golang代码如下(源代码):
type QuickFindUnionFind struct {
ids []int32
count int32
}
func (uf *QuickFindUnionFind) Add(p int32) {
if p < int32(len(uf.ids)) {
return
}
for p >= int32(len(uf.ids)) {
uf.ids = append(uf.ids, int32(len(uf.ids)))
uf.count += 1
}
}
func (uf *QuickFindUnionFind) Count() int32 {
return uf.count
}
func (uf *QuickFindUnionFind) Find(p int32) int32 {
return uf.ids[p]
}
func (uf *QuickFindUnionFind) Connected(p int32, q int32) bool {
return uf.Find(p) == uf.Find(q)
}
func (uf *QuickFindUnionFind) Union(p int32, q int32) {
pi := uf.Find(p)
qi := uf.Find(q)
if pi == qi {
return
}
for i, id := range uf.ids {
if id == qi {
uf.ids[i] = pi
}
}
uf.count -= 1
}
quick-union
并查集
quick-find
的并查集结构的并查集搜索性能极高,时间复杂度为O(1)
。算法的核心思想是,通过合并集合的时候,将被合并集合的各个节点的父节点调整为合并集合的根节点,构造出一根扁平的树,提高查询效率。查询效率非常高,但是,合并操作是一个O(n)
的开销。完成一个最终的集合构建,需要O(n2)的时间复杂度。
并查集核心作用点,可不仅仅是查询,它在整个生命周期,可能会随着数据,不停产生合并,直到达到最终的形态,高昂的合并开销,将直接作用于调用方。要解决这个问题,首先自然是需要降低Union
的开销。最简单的思路,就是不再构建扁平的树形结构,而是简单得将集合合并,构成不规则的多叉树结构。落到具体代码层面,即符号表含义从原来的(元素序号, 特定集合元素序号)
变为(元素序号, 同集合优先进入集合的元素序号)
,其核心代码变动如下(源码):
func (uf *QuickUnionUnionFind) Find(p int32) int32 {
for p != uf.ids[p] {
p = uf.ids[p]
}
return p
}
func (uf *QuickUnionUnionFind) Union(p int32, q int32) {
pi := uf.Find(p)
qi := uf.Find(q)
if pi == qi {
return
}
uf.ids[qi] = pi
uf.count -= 1
}
加权quick-union
并查集
quick-union
并查集的查询操作不再是一个常数级的开销,而是与树的高度相关的。最差达到时间复杂度O(n)
级别的消耗(假使树的本身形状是极端的链表形状),这显然非常低效。因此这种算法的性能不稳定,最差情况下比前一种算法还差很多。
并查集quick-union
的问题在于树高决定了它的时间开销,那么只需要控制树高就可以了,常用的方式其实就是让这棵树平衡或者是限定树高。算法思路非常简单,合并的时候,小集合向大集合合并,核心代码的修改如下(源码):
type WeightQuickUnionUnionFind struct {
ids []int32
weights []int32 // 节点权重
count int32
}
func (uf *WeightQuickUnionUnionFind) Union(p int32, q int32) {
pi := uf.Find(p)
qi := uf.Find(q)
if pi == qi {
return
}
// p所属的树是一颗更大的树
if uf.weights[pi] >= uf.weights[qi] {
uf.ids[qi] = pi
uf.weights[pi] = uf.weights[qi] + uf.weights[pi]
uf.weights[qi] = 0
} else {
// q所属的树是一颗更大的树
uf.ids[pi] = qi
uf.weights[qi] = uf.weights[qi] + uf.weights[pi]
uf.weights[pi] = 0
}
uf.count -= 1
}
路径压缩的加权quick-union
并查集
加权quick-union
并查集引入了节点权重的概念,通过权重确定树的规模,将小树向大树合并。算法最差的情况下,树高是logn
,这样搜索和合并的的时间复杂度控制在了O(logn)
以下,提供了一种高效的查询和合并能力。
算法优化到加权quick-union
并查集,此时Union
的操作开销已经可控了,此时还可以继续优化访问效率,思路就是最早的quick-find
算法,根据并查集本身的特性,压缩访问路径。即生成路径,进一步压缩,将节点的父节点直设置为根节点,压缩路径(代码)后,访问的开销基本就是常数级别的一次数组查找。算法的核心在于压缩路径的时机和规模,规模太大会导致访问时间存在毛刺,时机则导致压缩相关api的耗时上涨,这里的核心思路是Find
API调用时压缩访问路径的一半,核心代码修改如下:
func (uf *CompressWeightQuickUnionUnionFind) Find(p int32) int32 {
// 对半压缩路径
for p != uf.ids[p] {
uf.ids[p] = uf.ids[uf.ids[p]]
p = uf.ids[p]
}
return p
}
后记
《算法》中提到,优化到路径压缩的加权quick-union
并查集,基本已经达到一个很高的性能水准,后序的优化很难达到普适情况下性能的数量级提升。再优化肯定得依据于真实的应用环境,针对性得设计。
整个数据结构的设计过程,其实就是 1. 确定API 2. 性能分析 3. 性能优化 4. 重复步骤2直到符合目标 的过程。
- 一个能够不断演进的优秀设计,需要在一开始就确定和核心API,API的作用是约束使用者和设计者,对他俩进行解耦,API要细致而确定,不暴露细节。
- API确定后,需要按需演进算法,这个需要从数学上(理论复杂度)和工程上(分摊复杂度)进行优化。
- 数学上的优化,是数理层面的分析,时间复杂度、空间复杂度、最坏时间复杂度……
- 工程上的优化,是通过编程技巧,将复杂的计算平均分配到普通操作上,进而达到一个工程上非常优秀的表现,没有尖峰,没有长尾。
- 解决方案基本藏在已经存在的树、符号表、图的理论里。
推荐大家看《算法》~
参考文献
[1] 算法:https://item.jd.com/11098789.html
[2] 算法课后详解:https://algs4.cs.princeton.edu/home/
[3] 并查集:https://zh.m.wikipedia.org/zh/并查集
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?