算法优化思路~以并查集为例

最近,笔者在阅读《算法》一书,它在基础篇中以并查集作为样例,介绍了一个算法是怎样一步步优化,提升性能的。笔者看后非常有感觉,故以本文进行了一个实践。

本文中的例子都将以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的耗时上涨,这里的核心思路是FindAPI调用时压缩访问路径的一半,核心代码修改如下:

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/并查集

posted @   code_wk  阅读(210)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?
点击右上角即可分享
微信分享提示