并查集能干点啥?

本文将呈现如下内容:

  • 一个在实际工作中遇到的问题
  • 用 C++ 实现一个并查集
  • 路径压缩

并查集和人脸聚类有啥关系

讲讲需求

以前在国内某家AI独角兽工作的时候,遇到过这样一个需求:需要对千万量级的人脸抓拍图进行聚类。

算法同事提供了一个过滤器:输入两张抓拍图,如果返回 true,则说明两张抓拍来自于同一个人;如果返回 false,则说明无法判定两者的关系。

说的不清楚?别着急,来看两个例子:

  • 两张比较相似的抓拍,过滤器会返回True。要是这都搞不定,算法同事可以提前退休了
  • 两张差距较大的抓拍,过滤器搞不定了。

其实抓拍图集中,绝大多数都是第二种情况:来自同一个人的抓拍,但是由于角度,表情等问题,做不到精准识别。

如何解决这个问题的

由于抓拍图集中大都是时间间隔很短的抓拍,类似于下面这种:

工程师们灵机一动:如果把抓拍看做点,来自同一人的抓拍之间连一条边,那么就可以得到一张无向图,然后计算这张无向图的连通分量不就得了吗?

一共 N 张图片,任意两张之间都需判定是否来自同一人,总共需要判定 N ∗ ( N − 1 ) 2 \frac{N*(N-1)}{2} 2N(N1)次。得到无向图后直接计算连通分量即可。

看上去还不错,但现在又有了新需求:查询抓拍集合中的两张抓拍是否来自同一人,可以O(1)的给出答案吗?

基于连通分量的做法也可以:

  • 为每个连通分量分配一个 ID。
  • 每个抓拍记录所属连通分量的ID。
  • 对于一次查询,直接比较两张抓拍的连通分量的ID即可。

但是有点啰嗦,接下来看一种更简单优雅的做法。

「人脸聚类」抽象为「集合合并」

上面「构造无向图+连通分量」的做法,本质上是在构造M个集合,每个集合由一个整数标识(即连通分量的ID)。归属于同一集合的点,代表其对应的抓拍来自于同一个人

既然是构造集合,那把人脸聚类的过程抽象成集合合并的过程应该很合适吧。

初始时,每张抓拍都构成了一个独立的集合。ABCDE代表图片的编号,12345代表集合的编号,箭头代表归属关系。

接下来,判断任意两张抓拍会否来自同一人。第一轮,依次判断 ( A , B ) , ( A , C ) , ( A , D ) , ( A , E ) (A,B), (A,C), (A,D), (A,E) (A,B),(A,C),(A,D),(A,E)

因为 ( A , B ) , ( A , C ) (A,B),(A,C) (A,B),(A,C)被过滤器识别为 True,所以合并集合1,2集合1,3

删除或保留哪个集合并不重要,因为集合的ID无意义,我们只是要求出哪些抓拍在同一集合。

接下来,依次判断 ( B , C ) , ( B , D ) , ( B , E ) (B,C),(B,D),(B,E) (B,C)(B,D)(B,E)。因为只有 ( B , C ) (B,C) (B,C)返回了True,所以没有发生合并。

再接下来,依次判断 ( C , D ) , ( C , E ) (C,D),(C,E) (C,D)(C,E)。因为都返回了True,所以依次合并集合1,4集合1,5

至此,聚类的过程就结束了。那如何快速查询两张抓拍是否来自同一人呢?这个太简单了,直接比较所属集合ID是否相同即可。

其实,上述过程就是并查集的两个最重要的操作:

  • merge(u,v):合并 u ,v 所在的集合。
  • find(x):返回 x 所属集合的ID。

实现一个并查集

如何存储数据

要实现 merge 和 find 函数,就要先想好如何存储数据。

不加任何优化,最朴素的并查集使用**「森林」**存储数据。森林,即若干棵树组成。每棵树代表一个集合,树中的每个节点代表一个元素

如果用连续的整数对元素进行编号。比如有N个元素,则依次分配ID为 0,1,2…N-1。

为了方便实现,我们将 「根节点的ID」 作为 「集合的ID」。现在可这样实现两个函数:

  • find(x) :通过 x 的父节点,父节点的父节点 … …,一直找到根节点并返回其ID。
  • merge(u,v):通过 find 函数找到 u,v 的根节点 root_u, root_v。如果两者的根节点不相同,则将 root_u 的父节点设为 root_v。如果相同,则无需任何操作。

如上所述,可以发现并查集不关心节点有哪些儿子只关心节点的父亲是谁,所以并查集只需要一个数组:

std::vector<int> fa;

fa[i] 记录节点 i 的父节点; 特殊的,当 i 是根节点时,fa[i] 的值为 i 。初始时,每个节点都构成了一棵树,即每个节点都是一个根节点,所以初始化需进行如下操作:

void init(int N) {
	fa.resize(N);
	for (int i = 0; i < N; i++) {
		fa[i] = i;
	}
}
find 函数

基于 fa 数组,很容易实现 find 函数,一个 while 搞定

int find(int x) {
	while(fa[x] != x) {
		x = fa[x];
	}
	return x;
}


如上图所示,箭头代表 fa 数组,比如 f a 5 = 2 , f a 2 = 1 , f a 1 = 1 fa_5=2,fa_2=1,fa_1=1 fa5=2,fa2=1,fa1=1

当调用 find(5) 时,按照 5 → 2 → 1 5→2→1 521 的路径到达根节点,最终返回 1。

merge 函数

基于 find(x) 函数,实现 merge(u,v) 也很简单:通过 find 函数找到 u,v 的根节点 root_u, root_v。如果两者的根节点不相同,则将 root_u 的父节点设为 root_v。如果相同,则无需任何操作。

void merge(int u, int v) {
	int ru = find(u);
	int rv = find(v);
	fa[ru] = rv;
}

举个例子,有如下两棵树,调用 merge(8, 5) 时:

  • 先通过 find(8), find(5) 找到对应的根节点 7 和 1
  • 再将 fa[7] 修改为 1。


    至此,简版的并查集的所有代码都搞完了。

并查集的进化:路径压缩

上述代码看似简练优雅,但性能极不稳定。考虑这样一种情况:

int N = 1000;
init(N);
for (int i = 1; i < N; i++) {
	merge(i-1, i);
}


此时,fa 会退化成一个长长的链表,find(x) 的时间复杂度为 O(n)

为了避免出现深度过大的树,稳定 find(x) 的时间复杂,大佬们提出了 「路径压缩」的方案。

再来分析下 find 和 merge 这两个函数:

  • find(x):借助 fa 数组,找到 x 的根节点
  • merge(u, v):找到 u,v 的根节点 ru,rv,然后执行 fa[ru] = rv。

不难发现,并查集其实也不关心节点的父亲是谁,它真正关心的是 「节点的根是谁」。既然这样,fa[i] 直接记录节点 i 的根 不就得了嘛。这就是「路径压缩」的核心思想。

接下来,实现一下「路径压缩版」的 find 函数,两个 while 就能搞定

int find(int x) {
	int r = x;
	while(fa[r] != r) {
		r = fa[r];
	}
	while(fa[x] != x) {
		int t = fa[x];
		fa[x] = r;
		x = t;
	}
	return x;
}

解释一下两个 while:

  • 第一个 while:找到 x 所在树的根节点 r 。
  • 第二个 while:将 x → r 路径上的所有节点的 fa 更新为 r。

有如上左图所示的一棵树,调用 find(5):

  • 第一个 while:先找到节点 5 所在树的根节点 1。
  • 第二个 while:将 5 → 1路径上的所有节点的 fa 更新为 1,如上右图所示。

其实,以上就是路径压缩的全部了。merge 函数完全不用修改。

随着 find 函数的不断调用,所有树的深度都将趋近于 2,即所有的 fa[i] 的值都将变为其所在树的根节点的ID

复杂度分析

空间复杂度

因为并查集只有一个 fa 数组,所以空间复杂度为 O(n)。

时间复杂度

因为并查集使用树表示节点之间的关系,所以并查集最多有 N-1 条边。

随着「路径压缩」的引入,所有 「没有直连到根节点的边」第一次 被 find 访问的时候就会被压缩

所以,随着 find 不断被调用,每次调用都 「均摊了路径压缩的时间成本」,最终,find 的时间复杂度会稳定在 O(1)。

因为 merge 执行了两次 find, 一次赋值,所以时间复杂度最终也会稳定在 O(1)。

几个例题

  • https://leetcode-cn.com/problems/friend-circles/
  • https://leetcode-cn.com/problems/number-of-operations-to-make-network-connected/
  • https://leetcode-cn.com/problems/number-of-islands/
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值