并查集能干点啥?

本文将呈现如下内容:

  • 一个在实际工作中遇到的问题
  • 用 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/
  • 6
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
内容简介 《SQL Cookbook中文版》中的SQL 是计算机世界的语言,在用关系数据库开发报表时,将数据放入数据库以及从数据库中取出来,都需要SQL 的知识。很多人以一种马马虎虎的态度在使用SQL,根本没有意识到自己掌握着多么强大的武器。《SQL Cookbook中文版》的目的是打开读者的视野,看看SQL 究竟能干什么,以改变这种状况。 在这《SQL Cookbook中文版》中有150 多个小节,这还仅仅是SQL 所能做的事情的一鳞半爪。解决日常编程问题的解决方案的数量仅取决于需要解决的问题的数量,《SQL Cookbook中文版》没有覆盖所有问题,事实上也不可能覆盖;然而从中可以找到许多共同的问题及其解决方案,这些解决方案中用到许多技巧,读者学到这些技巧就可以将它们扩展并应用到《SQL Cookbook中文版》不可能覆盖的其他新问题上。 毫无疑问,《SQL Cookbook中文版》的目标是让读者看到,SQL 能够做多少一般认为是SQL 问题范围之外的事情。在过去的10 年间,SQL 走过了很长的路,许多过去只能用C 和JAVA等过程化语言解决的典型问题现在都可以直接用SQL 解决了,但是很多开发人员并没有意识到这一事实。《SQL Cookbook中文版》就是要帮助大家认识到这一。 -------------------------------------------------------------------------------- 作者简介 Anthony Molinaro是wireless Generation公司的数据库开发人员。他多年从事帮助开发人员改进其sQL查询的工作,具有丰富的实践经验。Anthony酷爱sQL,在相关领域,他小有名气,客户在遇到困难的sQL查询问题时,就会想到他,他总能起到关键作用。他博学多才,对关系理论有深入的理解,有9年解决复杂sQL问题的实战经验。Anthony通晓新的和功能强大的sQL功能,比如,添加到最新sQL标准中的窗口函数语法等。 -------------------------------------------------------------------------------- 编辑推荐 《SQL Cookbook中文版》是一本指南,其中包含了一系列SQL 的常用问题以及它们的解决方案,希望能对读者的日常工作有所帮助。《SQL Cookbook中文版》将相关主题的小节归成章,如果读者遇到不能解决的SQL 新问题,可以先找到最可能适用的章,浏览其中各小节的标题,希望读者能从中找到解决方案,至少可以找到灵感。 -------------------------------------------------------------------------------- 目录 第1章 检索记录 1.1从表中检索所有行和列 1.2从表中检索部分行 1.3查找满足多个条件的行 1.4从表中检索部分列 1.5为列取有意义的名称 1.6在WHERE子句中引用取别名的列 1.7连接列值 1.8在SELECT语句中使用条件逻辑 1.9限制返回的行数 1.10从表中随机返回n条记录 1.11查找空值 1.12将空值转换为实际值 1.13按模式搜索 第2章 查询结果排序 2.1以指定的次序返回查询结果 2.2按多个字段排序 2.3按子串排序 2.4对字母数字混合的数据排序 2.5处理排序空值 2.6根据数据项的键排序 第3章 操作多个表 3.1记录集的叠加 3.2组合相关的行 3.3在两个表中查找共同行 3.4从一个表中查找另一个表没有的值 3.5在一个表中查找与其他表不匹配的记录 3.6向查询中增加联接而不影响其他联接 3.7检测两个表中是否有相同的数据 3.8识别和消除笛卡儿积 3.9聚集与联接 3.10聚集与外联接 3.11从多个表中返回丢失的数据 3.12在运算和比较时使用NULL值 第4章 插入、更新与删除 4.1插入新记录 4.2插入默认值 4.3使用NULL代替默认值 4.4从一个表向另外的表中复制行 4.5复制表定义 4.6一次向多个表中插入记录 4.7阻止对某几列插入 4.8在表中编辑记录 4.9当相应行存在时更新 4.10用其他表中的值更新 4.11合并记录 4.12从表中删除所有记录 4.13删除指定记录 4.14删除单个记录 4.15删除违反参照完整性的记录 4.16删除重复记录 4.17删除从其他表引用的记录 第5章 元数据查询 第6章 使用字符串 第7章 使用数字 第8章 日期运算 第9章 日期操作 第10章 范围处理 第11章 高级查找 第12章 报表和数据仓库运算 第13章 分层查询 第14章 若干另类目标 …… 附录A 窗口函数补充 附录B 回顾Rozenshtein ……
根据提供的引用内容,Prim算法确实使用了并查集。Prim算法是一种用于构建最小生成树的贪心算法,它的基本思想是从一个开始,每次选择一个与当前生成树距离最近的加入生成树中,直到所有的都被加入生成树为止。在Prim算法中,我们需要维护一个集合S,表示已经加入生成树的的集合,以及一个数组d,表示每个到集合S的距离。每次选择距离集合S最近的加入集合S中,并更新其他到集合S的距离。这个过程可以使用堆优化的Dijkstra算法来实现,也可以使用并查集来实现。具体来说,我们可以将所有分为两个集合,一个集合表示已经加入生成树的,一个集合表示还没有加入生成树的。每次选择距离已经加入生成树的最近的未加入生成树的加入生成树中,并将这个加入已经加入生成树的的集合中。这个过程可以使用并查集来实现,具体来说,我们可以将已经加入生成树的的集合看作一个并查集,每次加入一个新的时,我们将这个加入并查集中,并将这个与已经加入生成树的的集合中的进行合并。这样,我们就可以快速地判断一个是否已经加入生成树中,以及两个是否在同一个连通块中。因此,Prim算法使用并查集来维护已经加入生成树的的集合,以及判断两个是否在同一个连通块中。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值