记录下在LeetCode刷题时遇到的并查集的路径压缩实现上的部分问题;
题目本身很简单,并查集也无甚难点,但是有一些容易忽略的glitch容易WA,还是值得记录下来注意一下
顺便吐个槽,为什么LeetCode上这么多并查集的题啊…NOI根本不会这么频繁地考并查集啊…难道是因为难度原因吗…
构造中的并查集结构一般类似于这样:
假如要判断节点F和节点H是否属于同一集合,那么调用getParent()
时就会依次递归:
getParent(F)->getParent(E)->getParent(B)->getParent(A)->A
getParent(H)->getParent(C)->getParent(A)->A
在数据关系较为随机的情况下,树的层数增长得很慢;然而如果题目专门准备了卡数据的情况下搜索的时间复杂度是会退化到O(n)的;因此了解一下路径压缩就显得很有必要了。
上面的图可以看成是如下两个集合的并:
执行Union(H,F)
即会出现如第一幅图所示的情况。既然Union(H,F)
本质上是使得C-->A
成立,那么所谓的路径压缩就是为了减少搜索时的递归次数,把搜索途中的节点全部挂在最后的根节点上;即,Union(H,F)
会把上图变为:
在getParent(H)
和getParent(F)
中经过的节点以圆形表示,路径压缩即是将这些节点直接挂在根节点上以减少树的深度。
反映到代码实现上:
#原本的Union():
def Union(a,b):
nonlocal p#记录父节点的列表
pa,pb=getParent(a),getParent(b)
if pa!=pb:
p[pb]=p[pb]
#数据压缩PressedUnion():
def PressedUnion(a,b):
nonlocal p
#因为需要记录途径的节点所以需要修改getParent()
r=[b]#途径节点列表,一定包含要被挂到新根节点的b
while p[b]!=b:#一路找到b原来的根节点,记录途径的所有节点
b=p[b]
r.append(b)
while p[a]!=a:#对a如法炮制
r.append(a)#注意迭代(=)和添加(append)的顺序
a=p[a]
for node in r:#将途径的所有节点挂到遍历到根节点的a上
p[node]=a
但!是!
很多教程会说“路径压缩并查集最终会挂成一个大菊花的形状”
…
我不知道是不是只有我一个人有这个误解
但是实际上不!是!的!
如果你错误的认为路径压缩并查集内的节点要么是根节点,要么是直接挂在根节点上的叶子节点,那就大错特错了…
由于只有Union()
过程中的节点被压缩,因此其他部分还是照原样挂着(第二幅图也能看出这一点)
你当然可以修改getParent()
以在每次搜索时压缩一次路径,不过这仍然不能保证节点的叶子性…
纪念我第一次提交WA掉的#1319
20210228更新
来记录一下路径压缩下并查集的时间复杂度。
(之前尝试计算,但是对着草稿纸一顿狂写之后发现这个难度超乎了我的想象,遂放弃;后来才知道不是我这数学能力能算出来的 :sad: )
路径压缩并查集的时间复杂度是反阿克曼函数
O
(
A
c
k
−
1
(
n
)
)
O(Ack^{-1}(n))
O(Ack−1(n))。1
阿克曼函数 A c k ( n , m ) Ack(n,m) Ack(n,m)
全称为
A
c
k
e
r
m
a
n
n
(
n
,
m
)
Ackermann(n,m)
Ackermann(n,m),定义为:2
A
c
k
(
n
,
m
)
=
{
n
+
1
,
i
f
m
=
0
A
c
k
(
m
−
1
,
1
)
,
i
f
m
>
0
a
n
d
n
=
0
A
c
k
(
m
−
1
,
A
c
k
(
m
,
n
−
1
)
)
,
i
f
m
>
0
a
n
d
n
>
0
Ack(n,m)=\left\{ \\ \begin{matrix} n+1 &,&\mathrm{if\space\space m=0} \\ Ack(m-1,1)&,&\mathrm{if\space\space m>0\space and\space n=0}\\ Ack(m-1,Ack(m,n-1))&,&\mathrm{if\space\space m>0\space and\space n>0}\\ \end{matrix}\\ \right.
Ack(n,m)=⎩
⎨
⎧n+1Ack(m−1,1)Ack(m−1,Ack(m,n−1)),,,if m=0if m>0 and n=0if m>0 and n>0
这是一个增长速度非常快的函数。
A
c
k
(
1
,
n
)
Ack(1,n)
Ack(1,n)相当于
n
+
1
n+1
n+1,
A
c
k
(
2
,
n
)
Ack(2,n)
Ack(2,n)相当于
n
+
2
n+2
n+2,
A
c
k
(
3
,
n
)
Ack(3,n)
Ack(3,n)相当于
2
n
+
3
2n+3
2n+3,看起来并没有什么威力,然而
A
c
k
(
4
,
n
)
=
2
n
+
3
−
3
Ack(4,n)=2^{n+3}-3
Ack(4,n)=2n+3−3
A
c
k
(
5
,
n
)
=
2
2
2
.
.
.
2
(
n
+
3
层
2
)
−
3
=
n
+
3
2
−
3
Ack(5,n)=2^{2^{2^{...^{2}}}}(n+3层2)-3=^{n+3}2-3
Ack(5,n)=222...2(n+3层2)−3=n+32−3
…
所以
A
c
k
(
n
,
n
)
Ack(n,n)
Ack(n,n)的增长速度是相当不做人的…
那么,反阿克曼函数
A
c
k
−
1
(
x
)
Ack^{-1}(x)
Ack−1(x)又如何定义?
A
c
k
−
1
(
x
)
=
m
a
x
(
n
)
,
i
f
A
c
k
(
n
,
n
)
≤
x
Ack^{-1}(x)=max(n)\space\space, \mathrm{if\space\space Ack(n,n)\le x}
Ack−1(x)=max(n) ,if Ack(n,n)≤x
即当
A
c
k
(
n
,
n
)
≤
x
Ack(n,n)\le x
Ack(n,n)≤x时能取到的最大的
n
n
n。看到上面这恐怖的阿克曼函数的增长速度,在绝大多数情况下
n
n
n甚至不会大于6,这说明平均条件下路径压缩并查集是极为优秀的,可以当做
O
(
1
)
O(1)
O(1)看待。3