本文将呈现如下内容:
- 一个在实际工作中遇到的问题
- 用 C++ 实现一个并查集
- 路径压缩
并查集和人脸聚类有啥关系
讲讲需求
以前在国内某家AI独角兽工作的时候,遇到过这样一个需求:需要对千万量级的人脸抓拍图进行聚类。
算法同事提供了一个过滤器:输入两张抓拍图,如果返回 true,则说明两张抓拍来自于同一个人;如果返回 false,则说明无法判定两者的关系。
说的不清楚?别着急,来看两个例子:
- 两张比较相似的抓拍,过滤器会返回True。
要是这都搞不定,算法同事可以提前退休了
- 两张差距较大的抓拍,过滤器搞不定了。
其实抓拍图集中,绝大多数都是第二种情况:来自同一个人的抓拍,但是由于角度,表情等问题,做不到精准识别。
如何解决这个问题的
由于抓拍图集中大都是时间间隔很短的抓拍,类似于下面这种:
工程师们灵机一动:如果把抓拍看做点,来自同一人的抓拍之间连一条边,那么就可以得到一张无向图,然后计算这张无向图的连通分量不就得了吗?
一共 N 张图片,任意两张之间都需判定是否来自同一人,总共需要判定
N
∗
(
N
−
1
)
2
\frac{N*(N-1)}{2}
2N∗(N−1)次。得到无向图后直接计算连通分量即可。
看上去还不错,但现在又有了新需求:查询抓拍集合中的两张抓拍是否来自同一人,可以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 5→2→1 的路径到达根节点,最终返回 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/