并查集+树上差分是我遇到这样一道题后,发现有必要记录下来作为对并查集进一步的理解。
题目编号
acwing.2069.网络分析
题目描述:
小明正在做一个网络实验。
他设置了 n 台电脑,称为节点,用于收发和存储数据。
初始时,所有节点都是独立的,不存在任何连接。
小明可以通过网线将两个节点连接起来,连接后两个节点就可以互相通信了。
两个节点如果存在网线连接,称为相邻。
小明有时会测试当时的网络,他会在某个节点发送一条信息,信息会发送到每个相邻的节点,之后这些节点又会转发到自己相邻的节点,直到所有直接或间接相邻的节点都收到了信息。
所有发送和接收的节点都会将信息存储下来。
一条信息只存储一次。
给出小明连接和测试的过程,请计算出每个节点存储信息的大小。
输入格式:
输入的第一行包含两个整数 n,m,分别表示节点数量和操作数量。
节点从 1 至 n 编号。
接下来 m 行,每行三个整数,表示一个操作。
- 如果操作为 1 a b,表示将节点 a 和节点 b 通过网线连接起来。当 a = b 时,表示连接了一个自环,对网络没有实质影响。
- 如果操作为 2 p t,表示在节点 p上发送一条大小为 t 的信息。
输出格式:
输出一行,包含 n 个整数,相邻整数之间用一个空格分割,依次表示进行完上述操作后节点 1至节点 n上存储信息的大小。
数据范围:
1 ≤ n ≤ 10000,
1 ≤ m ≤ 10^5,
1 ≤ t ≤ 100
输入样例:
4 8
1 1 2
2 1 10
2 3 5
1 4 1
2 2 2
1 1 2
1 2 4
2 2 1
输出样例:
13 13 5 3
题目分析:
这道题的大意就是对网络中的结点支持这样的操作:
- 操作1:加一条边。
- 操作2:对某个连通块的所有结点都统一加上t。
这样一看其实就知道这很符合并查集的特征,让我们回忆一下并查集支持的两个操作:
- 操作1:支持两个集合的合并。
- 操作2:询问两个元素是否在一个集合中。
第一个要求维护集合的连通性是并查集的基本操作,第二个要求我们找到属于同一个集合的所有元素,对它们的值统一加上一个数即可。但是我们分析一下时间复杂度,一个有1e5个操作,每次遍历所有结点用来确定哪些结点属于一个集合,数据的范围是1e4,两层一乘就是1e9,这显然会超时,经过实验,暴力+并查集只能拿到70%的分数,
最外层的1e5次操作肯定不能再优化了,所以我们把注意力集中在对连通块中每个元素都加上t的优化上,能不能对连通块中的代表元素加上t来替代对连通块中每个元素都加上t呢?这其实就是树上差分的雏形了,我们都知道差分可以在O(1)的时间复杂度内完成对某段区间或者某块区域值的统一修改,那树上差分是什么意思呢?
并查集是以树的形式维护一个集合的,我们在查找某个结点的根节点时务必会走完该结点到根节点的路径,那么如果我们只对根节点加上t,那么凡是通往根节点的所有路径上的结点的值都会受到影响 (看这个描述是不是很像差分),计算某个结点的值就可以在路径压缩的过程中通过父节点值的不断累加得到。
简而言之,对根节点加上t就可以实现连通块中所有结点都加上t,每个结点的真实值=该点到根节点的路径的权值和。
我们不光要看树上差分可以满足题目要求的操作2:对某个连通块的所有结点都统一加上t,还需要注意这种方式对操作1:加一条边的影响,如果我们直接将两个连通块合并,就是说一个连通块的根节点附加在另一个连通块的根节点上,像这样:
将结点5作为新的根节点,结点5的统一加3会让之前连通块中的所有结点1、2、3、4都加上3,而这个加3操作是在合并前完成的,按理说不应该让加3影响到后来合并进来的连通块,结点3的值就变成了15而不是原来的12,这显然是错的,那这个问题如何解决呢?
有两种处理方式:
第一种:在合并的时候建立一个新的根节点,作为两个连通块的根节点。
这样的话两个连通块中的值都不会受到影响了。
第二种:在合并的时候修改被归入的连通块的根节点的值。
只要将结点1的+5更新为+2,就可以保证原来连通块1中所有结点的值不受影响。
现在就剩下最后一个问题:
我之前使用的并查集在寻找某个结点的根节点时,采用的是路径压缩的方式,那么在路径压缩的时候如何维护这个信息?
情况1和2都好说,因为在路径压缩的过程中并没有改变树的形态,对于情况3,在状态压缩后,该结点的父节点会直接指向根节点,那么我们如何保存该结点的真实值?
经过这样的处理,题目的时间复杂度就会降低为O(nlogn),因为没有考虑按秩合并,但是实际上可以粗略的将时间复杂度当做O(n),按秩合并的时间优化并不大。
代码实现:
1.暴力+并查集:
def init():
global p
for i in range(1,n+1):
p[i]=i
def find(x):
global p
if x!=p[x]:
p[x]=find(p[x])
return p[x]
def unit(x,y):
global p
a,b=find(x),find(y)
if a!=b:
p[a]=b
n,m=map(int,input().split())
p=[0 for i in range(n+1)]
num=[0 for i in range(n+1)]
init()
while m:
op,a,b=map(int,input().split())
if op==1:
unit(a,b)
elif op==2:
tmp=find(a)
for i in range(1,n+1):
if tmp==find(i):
num[i]+=b
m-=1
for i in range(1,n+1):
print(num[i],end=' ')
print('')
2.树上差分+并查集:
- 方式一:更新根节点
p=[i for i in range(10001)]
d=[0 for i in range(10001)]
def find(x):
#当前结点就是根节点或者当前结点的父节点就是根节点时,树的形状不会发生变化
global p,d
#情况1和2
if p[x]==x or p[p[x]]==p[x]:
return p[x]
#情况3
r=find(p[x])
#在递归的过程中记录了路径上所有点的权值和
d[x]+=d[p[x]]
p[x]=r
return r
def unit(x,y):
global p,d
x,y=find(x),find(y)
if x!=y:
#更新被归入的连通块的根节点的值
d[x]-=d[y]
p[x]=y
n,m=map(int,input().split())
for i in range(m):
op,x,y=map(int,input().split())
if op==1:
unit(x,y)
elif op==2:
#直接根节点加上该数,这样的话,凡是通往根节点的路径都会被影响到,这应该就是树上差分的体现
x=find(x)
d[x]+=y
for i in range(1,n+1):
#如果是根节点,直接输出即可
if i==find(i):
#这里为什么是i==find(i) 而不是i==p[i]
#因为有可能该结点未进行过路径压缩 d[i]里边存的就不是最新的值(没更新过的)
print(d[i],end=' ')
#如果不是根节点,那就要输出该结点到根节点路径上所有值的和
else:
#为什么在i!=find(i)的条件下,不能d[i]=d[i]+d[find(i)],然后输出d[i]
#因为在find(i)中已经将该结点进行路径压缩过了,只剩下该结点和根节点两个结点,该结点的值就是最新的值,不需要再更新了
print(d[i]+d[find(i)],end=' ')
- 方式二:增加新的根节点
待续~~
原题链接:link