1.初探并查集
例题1:AcWing 237.程序自动分析
这题的思路其实比较好想,只要先考虑所有等于的情况并合并,然后再看不等于的情况,看是否出现矛盾即可。但是这题显然是需要离散化的,但是离散化也很简单,只要排序+二分即可。
代码较易,不予展示。
总结:并查集擅于动态维护具有传递性的关系,像例题1中的等于关系显然具有传递性,而不等于显然不具有。假如我们把这些关系看作边,把元素看作点,那么就会形成一张无向图,而并查集就能够维护这张无向图中节点的连通关系。
例题2:AcWing 145.超市
这题除了可以用贪心来做,还可以按利润对商品排序,每次都尝试将其安排在它自身过期时间前的第一个空闲时间段。之前利用的是暴力枚举法,不能通过。那么我们能否对这个过程进行优化呢?我们想到使用并查集来维护这些“位置”的占用情况。每个点所在连通块的代表(也就是祖先)就是它的过期时间前的第一个空闲位置。安排好了之后要再向前继续更新位置,将其置为祖先。
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=1e4+5;
struct Node{
int p,d;
}a[N];
bool cmp(Node x,Node y){
return x.p>y.p;
}
bool st[N];
int fa[N];
int find(int x){
if(fa[x]==x) return x;
return fa[x]=find(fa[x]);
}
int main(){
int n;
while(cin>>n){
memset(st,false,sizeof st);
int d=-1;
for(int i=1;i<=n;i++){
scanf("%d%d",&a[i].p,&a[i].d);
d=max(d,a[i].d);
}
sort(a+1,a+n+1,cmp);
int ans=0;
for(int i=1;i<=d;i++) fa[i]=i;
for(int i=1;i<=n;i++){
int r=find(a[i].d);
if(find(r)>0){
fa[r]=r-1;
ans+=a[i].p;
}
}
printf("%d\n",ans);
}
return 0;
}
2.“边带权”与“扩展域”的并查集
“边带权”,顾名思义是连接每两个节点的边上附带有权值的并查集。那么这个怎么用呢?
我们用d[x]保存x到fa[x]之间的边权,然后通过路径压缩,可以直接求出x到find(x)的路径上的边权之和。
例题1:AcWing 238.银河英雄传说
这个题是“边带权”并查集的裸题,可以直接套用下面的模板水掉:
(2)维护size的并查集:
int fa[N], size[N];
//fa[]存储每个点的祖宗节点, size[]只有祖宗节点的有意义,表示祖宗节点所在集合中的点的数量
// 返回x的祖宗节点
int find(int x)
{
if (fa[x] != x) fa[x] = find(fa[x]);
return fa[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ )
{
fa[i] = i;
size[i] = 1;
}
// 合并a和b所在的两个集合:
size[find(b)] += size[find(a)];
fa[find(a)] = find(b);
(3)维护到祖宗节点距离的并查集:
int fa[N], d[N];
//fa[]存储每个点的祖宗节点, d[x]存储x到fa[x]的距离
// 返回x的祖宗节点
int find(int x)
{
if (fa[x] != x)
{
int root = find(fa[x]);
d[x] += d[fa[x]];
fa[x] = root;
}
return fa[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ )
{
fa[i] = i;
d[i] = 0;
}
// 合并a和b所在的两个集合:
fa[find(a)] = find(b);
d[find(a)] = distance; // 根据具体问题,初始化find(a)的偏移量
那么显然在这里,我们就只要在合并时将d[find(a)]赋值为size[find(b)],然后直接套用模板即可。查询时直接输出|d[find(a)]-d[find(b)]|-1即可。
例题2:AcWing 239.奇偶游戏
对于这题,给定区间[l,r]中有偶数个1表示sum[l-1]与sum[r]的奇偶性相同,反之则表示不同(sum数组是序列S的前缀和数组)。这里sum数组并不是已知量,而是视作变量来处理。我们发现这题与程序自动分析很相似,都是已知一些变量和它们之间的关系,然后提供一些询问来查询某些变量之间的关系。但是程序自动分析一题中的不等关系并不是可传递的,所以只存在一种传递性关系。但是这题的奇偶性相同与不同之间却是都存在传递性并且是可以相互导出的:
1.若x1与x2的奇偶性相同,且x2与x3的奇偶性相同,那么x1与x3的奇偶性相同
2.若x1与x2的奇偶性相同,且x2与x3的奇偶性不同,那么x1与x3的奇偶性不同
3.若x1与x2的奇偶性不同,且x2与x3的奇偶性不同,那么x1与x3的奇偶性相同
那么接下来就要考虑如何通过“边带权”的方法来导出每一对元素之间的关系了:我们通过观察上面3条传递性质,发现就只存在相同与不同两类关系,想到用二进制来做。用0和1来做边权。d[x]=0表示x与fa[x]的奇偶性相同,d[x]=1表示x与fa[x]的奇偶性不同。那么通过做异或运算就可以得出x与祖先之间的奇偶性关系了。具体实现请看代码:
#include<iostream>
#include<algorithm>
using namespace std;
const int N=5005;
struct Node{
int l,r;
bool f;
}q[N];
int form[2*N],fcnt;
int n,m;
int get(int x){
int l=1,r=fcnt;
while(l<r){
int mid=(l+r)>>1;
if(form[mid]>=x) r=mid;
else l=mid+1;
}
return l;
}
int fa[2*N];
bool d[2*N];
int find(int x){
if(fa[x]==x) return x;
int root=find(fa[x]);
d[x]^=d[fa[x]];
return fa[x]=root;
}
int main(){
cin>>n>>m;
for(int i=1;i<=m;i++){
string type;
cin>>q[i].l>>q[i].r>>type;
if(type=="even") q[i].f=false;
else q[i].f=true;
form[++fcnt]=q[i].l-1;//离散化
form[++fcnt]=q[i].r;
}
sort(form+1,form+fcnt+1);
for(int i=1;i<=fcnt;i++) fa[i]=i;
for(int i=1;i<=m;i++){
int x=get(q[i].l-1),y=get(q[i].r);
int a=find(x),b=find(y);
if(a==b){
if(d[x]^d[y]!=q[i].f){//x,y之间的奇偶性与要求矛盾
cout<<i-1<<endl;
return 0;
}
}
else{//合并操作(具体推导见蓝书P199)
fa[a]=b;
d[a]=d[x]^d[y]^q[i].f;
}
}
cout<<m<<endl;
return 0;
}
总结:这题告诉我们:对于多关系且关系直接可以相互导出的并查集题目,可以尝试采用“边带权”的算法解决。这种算法的难度在于:用恰当的边权表示方法与计算方法,完美地导出各个点之间存在的关系。(所以难度在于设计)
例题3:AcWing 240.食物链
这题的思路与上题大同小异,关键在于设计边权的表达与计算方法。我们注意到:**若x1吃x2,x2吃x3,那么x3吃x1。并且节点之间的关系具有方向性。**所以要用一种怎样的妙招来表示呢?对3取模。 设d[x]为x到fa[x]的距离,那么不妨 设fa[x] mod 3 == 0表示x与fa[x]是同类;==1表示x可以吃fa[x];==2表示x被fa[x]吃。 有了这个思路,我们就可以对题目中的操作进行分析。当执行操作1时,需要先判断x与y是否在同一集合中,若在,则应满足d[y]%3 == d[x]%3,为了防止出现负数,可以移项变成(d[y]-d[x])%3 == 0。那么若不在一个集合之中,那么就要合并。由于x的祖先的父节点要变成y的祖先,x的祖先rx到其父节点的距离d[rx],加上之前x到rx的距离d[x]就是x到新的祖先节点的距离,那么显然要满足(d[rx]+d[x]-d[y])%3==0。显然d[rx]有一解为d[y]-d[x]。那么同理可以处理掉操作2的情况。
#include<iostream>
using namespace std;
const int N = 50010;
int n, k, res;
int p[N]; //父节点
int d[N]; //到父节点的距离
int find(int x)
{
if(p[x] != x){
int t = find(p[x]); //先把父节点及以上压缩到树根
d[x] += d[p[x]]; //更新边权
p[x] = t; //x节点也压缩到树根
}
return p[x];
}
int main()
{
cin >> n >> k;
for(int i = 1; i <= n; i++) p[i] = i;
while(k--){
int v, x, y;
cin >> v >> x >> y;
if(x > n || y > n) res++;
else{
int rx = find(x), ry = find(y);
if(v == 1){
//假话
if(rx == ry && (d[y] - d[x]) % 3) res++;
//真话
else if(rx != ry){ //当前不在同一集合中,无法判定为假。故为真,应加入同一集合表示存在同类关系
p[rx] = ry;
d[rx] = d[y] - d[x]; //(d[x]+d[rx]-d[y])%3 = 0,由于判断时都针对mod 3,故3可省略
}
}
else{ //x吃y
if(x == y) res++;
else if(rx == ry && (d[x] - d[y] - 1) % 3) res++; //C++中负数取模得非正数,需要注意别写错
else if(rx != ry){
p[rx] = ry;
d[rx] = d[y] + 1 - d[x];
}
}
}
}
cout << res << endl;
}
“扩展域”,顾名思义是存在多个域来维护并查集,这种算法适用于存在多种传递关系的题目,且这些关系可以相互导出。
例题1:AcWing 257.关押罪犯
这题比较简单,拿来练一下手。很明显,两所监狱就是扩展域。若两个犯人存在仇恨,我们要尽量将他们分开,那么就要建立一个仇敌域,将a与仇敌域中的b连起来,再将b与仇敌域中的a连起来。那么就只要按照c来排序,每次进行上述操作。但当发现a与b处于同一域中,则矛盾必然爆发,直接输出即可。代码如下:
#include<iostream>
#include<cstdio>
#include<algorithm>
using namespace std;
const int N=4e4+5,M=1e5+5;
int fa[N];
struct Edge{
int x,y,z;
}edge[M];
bool cmp(Edge x,Edge y){
return x.z>y.z;
}
int find(int x){
if(x==fa[x]) return x;
return fa[x]=find(fa[x]);
}
int main(){
int n,m;
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
int a,b,c;
scanf("%d%d%d",&a,&b,&c);
edge[i]={a,b,c};
}
sort(edge+1,edge+m+1,cmp);
for(int i=1;i<=2*n;i++) fa[i]=i;
for(int i=1;i<=m;i++){
int a=find(edge[i].x),b=find(edge[i].y);
int va=find(edge[i].x+n),vb=find(edge[i].y+n);
if(a==b){
printf("%d",edge[i].z);
return 0;
}
fa[a]=vb;
fa[b]=va;
}
puts("0");
return 0;
}
例题2:AcWing 239.奇偶游戏
这题毫无疑问可以建立一个奇数域、一个偶数域。具体方法可见蓝书P200-201。
例题3:AcWing 240.食物链
具体方法分析见蓝书P202。
代码如下:
#include<iostream>
using namespace std;
const int N=5e4+5;
int fa[N*3];//fa[i]表示同类域,fa[i+n]表示捕食域,fa[i+2n]表示天敌域
int find(int x){
if(fa[x]==x) return x;
return fa[x]=find(fa[x]);
}
int main(){
int n,k,ans=0;
cin>>n>>k;
for(int i=1;i<=3*n;i++) fa[i]=i;
while(k--){
int x,y,d;
cin>>d>>x>>y;
if((x==y&&d==2) || x>n || y>n){
ans++;
continue;
}
if(d==1){
if(find(x+n)==find(y) || find(x+2*n)==find(y)){
ans++;
}
else{
fa[find(x)]=find(y);
fa[find(x+n)]=find(y+n);
fa[find(x+2*n)]=find(y+2*n);
}
}
else{//x吃y
if(find(x)==find(y) || find(x+2*n)==find(y)){
ans++;
}
else{
fa[find(x+n)]=find(y);
fa[find(y+2*n)]=find(x);
fa[find(x+2*n)]=find(y+n);
}
}
}
cout<<ans<<endl;
}
总结:“扩展域”并查集本质上是一种“拆点”的方法,将一个节点所拥有的与其他节点各种不同的关系(如奇偶性的异同、捕食与被捕食)拆分成多个“域”。然后在此基础上,根据题意给出的节点之间的关系合并它们对应的“域”。这种方法的优点很明显,就是通解性很强,比较容易想到。相比之下,“边带权”并查集则更注重设计的巧妙,不容易想到。