一、引文
上一篇博客——并查集(入门)写完后,我对并查集有了基本的了解。
- 并查集可以判断一幅无向图中有几个连通分量
- 并查集的find、join函数都是必不可少的
- 路径压缩算法对于并查集的优化也很关键
有了这些知识,我成功AC了hdu1232畅通工程,总觉得并查集不应该这么简单(套模板,修改一点点就AC),后来,遇到了poj1182食物链这道题,发现只学习了上篇博客的知识是无法解决它的,是时候增加自己的知识量了。
二、正文
- 并查集的进阶主要内容是解决带权并查集的相关问题。
- 在原有并查集的基础上,加入集合内部元素和其父节点之间的关系,这样的拓展,可以解决更多问题
- 带权并查集和普通并查集最大的区别在于带权并查集合并的是可以推算关系的点的集合(可以通过集合中的一个已知值推算这个集合中其他元素的值)。而一般并查集合并的意图在于这个元素属于这个集合。带权并查集相当于把“属于”的定义拓展了一下,拓展为有关系的集合。
来看这么一道题目:警察抓获N个罪犯,这些罪犯只可能属于两个黑帮团伙中的一个,现在给出M个条件(D a b表示a和b不在同一团伙),对于每一个询问(A a b)确定a,b是不是属于同一黑帮团伙或者不能确定。
之前的解题观点:D a b表示a和b不在同一个团伙??我们平时碰到的不都是两人在同一个团伙,然后用unite函数将两人放入同一个连通分支内。所以这道题给我的第一印象是将两个团伙看作两个连通分支,然后将相应的罪犯加入到相应的连通分支中,最后要询问时,只要判断两个罪犯的祖先是否一致(如果一致,那么两人是同一团伙)。
不可行的地方:D a b只表明a和b不属于同一个连通分支,并没有明确说明a和b属于哪个团伙(设想一下,如果说明了,那岂不是so easy),那么该怎么办呢?
新思路:仔细想想,我们是否可以存储每个节点与其祖先的关系。拿这道题来说,我们用一维数组r[]存储每个节点与其祖先是否属于同一团伙(例如r[x]=0表示结点x与其祖先属于同一团伙,而r[x]=1则表示结点x与其祖先不属于同一团伙)。有了这个关系尚且不够,我还有一些疑惑,这个祖先是谁哇?这个关系是怎么得出的哇?
继续前进:对于这题每次输入的D a b,我们都知道a和b不属于同一团伙,也就是a和b不在同一连通分支。而我们要做的是将a和b归于同一个祖先之下(即连接a和b所在的连通分支),伴随这一操作的还有更新r[a]、r[b](怎么更新稍后谈),为何要将a和b归于同一个连通分支呢?因为后面的查询A a b,我们通过判断find(a)==find(b)是否成立来确定我们是否知道他们的关系(成立就说明他们属于同一连通分支,说明他们已经D a b过了,不成立就可以输出"Not sure yet."啦),在find(a)==find(b)成立的情况下,我们就可以通过判断r[a]==r[b]是否成立来确定他们的具体关系(成立就说明他们属于同一团伙,就可以输出"In the same gang.",不成立就说明他们不属于同一个团伙,就可以输出"In different gangs.")。我们应该要知道上面那个式子r[a]==r[b]等价于r[a]==r[b]==0或r[a]==r[b]==1,即a和b如果属于同一团伙,暗含着他们与他们的祖先在同一个团伙或不在同一个团伙。读到这,我们并没有理清祖先是谁,关系怎么得出,但我们知晓了我们努力的目标。
于是,我们敲出了下面的代码:
if(a和b属于同一连通分支){
if(a和祖先的关系==b和祖先的关系){
cout<<"a和b属于同一团伙"<<endl;
}
else{
cout<<"a和b不属于同一团伙"<<endl;
}
}
else{
cout<<"还没有确定a和b的关系"<<endl;
}
再往前迈进:既然每次D a b都将a和b连起来,而且最后形成的那有且仅有一个的连通分支是由每次D a b的a和b结点组成,于是我们就可以确定祖先结点就是第一次D a b的a或b(具体是a还是b要看你的unite函数怎么写的了),后面我们才慢慢这个连通分支上再添加结点的。祖先节点我们搞懂了,再来搞懂关系即可。
最后一根稻草:我们前面说过数组r[x]表示节点x与根节点的关系,我们知道初始的时候,每个点都是一个连通分支(都有pre[i]=i,r[i]=0),而我们在构建一个由多个节点组成的连通分支时,我们新加入的节点的祖先在此刻发生变化,那么他们的r[]也要变化,当然,这是每次D a b出现后,将a和b所在的连通分支连起来时对r[]的更新,也就是在unite函数内的更新。此外,在find函数寻找根结点的时候也要不断更新r[](为啥啊为啥啊),因为unite函数内的r[]更新是在联合两棵树的时候进行的更新两棵树的根的关系,而其中相关子结点却未曾更新,所以要在find函数内进行更新(这样才能判断r[a]==r[b]是否成立)。下面,我们先解释find函数内r[]是怎么更新的,再解释unite函数内r[]是怎么更新的。
我们先解释:根据子节点a与父亲节点b的关系r1和父节点b与爷爷节点c的关系r2推导子节点a与爷爷节点c的关系r3
很容易通过穷举发现其关系式:a 和 b 的关系为 r1, b 和 c 的关系为r2,则 a 和 c 的关系r3为: r3 = ( r1 + r2) % 2; //(PS:因为只用两种情况所以对 2 取模)
于是find函数变为:
int find(int x) //找根节点
{
if(x == pre[x]) return x;
int t = pre[x]; //记录父亲节点 方便下面更新r[]
pre[x] = find(pre[x]);
r[x] = (r[x]+r[t])%2; //根据子节点与父亲节点的关系和父节点与爷爷节点的关系,推导子节点与爷爷节点的关系
return pre[x]; //容易忘记
}
在find函数内,若我们如此调用find(a),那么find函数除了返回a的祖先,还会在这过程中确定r[a]的值(即a与祖先结点的关系)。
最后,我们再来解释unite函数内的r[]更新:
定义:fx 为 x的根节点, fy 为 y 的根节点,联合时,使得 pre[fx] = fy (即fy也变为x和fx的祖先)
同时也要寻找 fx 与 fy 的关系(此时fy是fx的祖先),于是有 r[fx] = (r[x]+r[y]+1)%2
证明过程:fx 与 x 的关系是 r[x], x 与 y 的关系是 1 (因为确定是不同类,才联合的),y与 fy 关系是 r[y],模 2 是因为只有两种关系,所以又上面的一点所推出的定理可以证明 fx 与 fy 的关系是: (r[x]+r[y]+1)%2
于是unite函数变为:
void unite(int x, int y)
{
int fx = find(x); //x所在集合的根节点
int fy = find(y);
pre[fx] = fy; //合并
r[fx] = (r[x]+1+r[y])%2; //fx与x关系 + x与y的关系 + y与fy的关系 = fx与fy的关系
}
有了以上的详细分析,代码怎么敲不用讲了吧。哈哈。还是贴上一个AC代码,仅供参考。
#include<cstdio> #include<iostream> using namespace std; const int maxn = 100000+10; int pre[maxn]; //存父亲节点 int r[maxn]; //存与根节点的关系,0 代表同类, 1代表不同类 int T,n,m; void init() { for(int i=1;i<=n;i++){ pre[i] = i; r[i] = 0; } } int find(int x) { if(x == pre[x]) return x; int t = pre[x]; pre[x] = find(pre[x]); r[x] = (r[x]+r[t])%2; return pre[x]; } void unite(int x,int y) { int fx = find(x); int fy = find(y); pre[fx] = fy; r[fx] = (r[x]+1+r[y])%2; } int main() { scanf("%d",&T); while(T--){ scanf("%d%d",&n,&m); init(); int a,b; char ch; while(m--){ getchar(); scanf("%c%d%d",&ch,&a,&b); if(ch == 'D'){ unite(a,b); } else{ if(find(a) == find(b)){ if(r[a] == r[b]){ cout<<"In the same gang.\n"; } else{ cout<<"In different gangs.\n"; } } else{ cout<<"Not sure yet.\n"; } } } } return 0; }