【算法专项】扩展域并查集:原理详解及解决大部分种类并查集问题(洛谷P5937 P2024 C++代码)

我们平时经常能碰到要处理很多个对象之间的关系,而且这种关系往往带有可传递性,比如朋友的朋友是朋友,朋友的敌人也是敌人,而敌人的敌人就是朋友。而这就是属于种类并查集 的问题了,我们可以用带权并查集 取模来解决。但是我们其实还能用另一个更加好理解的办法来解决,那就是扩展域并查集

带权并查集是通过对不同数字的定义来解决这个问题,但是定义起来有点麻烦,还要注意能维护到"敌人的敌人就是朋友"类似这种的传递关系才能取模。比如解决刚刚提出的问题可以定义 0 是朋友,1 是敌人,那取模后自然就完成了传递关系,但是反过来定义就不行了,不能套用取模而是其他方法。

而扩展域并查集,不同于带权并查集稍微有点绕的定义,定义比较简洁。扩展域并查集直接开出一个新的空间,比如朋友和敌人的二元关系,我们只需要开 2n 的空间,三元关系那就是 3n 空间,哪个 n 长度区间的定义随便,只要修改的时候符合自己的逻辑就行。用一点内存消耗换来了我们逻辑上的简洁。

至于扩展域并查集是如何做到维护对象直接的关系,下面通过两道题目讲解一下。

洛谷 P1892 二元关系

这题就是比较经典的,也就是刚刚提到的"敌人的敌人是朋友"的这种关系题目。如果我们打算用扩展域并查集解决这道题,我们需要开 2n 大小的数组来作为并查集数组才行。

对于题目给出的 n 个人,定义一个 2n 大小的数组作为并查集数组 parents,1 - n 这个区间我们表示他们的朋友(或者他自己),n+1 - 2n 这个区间代表了他们的敌人

这样之后又要怎么做才能符合我们的定义逻辑呢?

事先说明,扩展域并查集的 find 查询和维护逻辑其实很简单。假设对于 A ,find(A) 就是这题之下的朋友区,find 的值就是 A 的朋友或者它自己;find(A+n) 找的是敌人区,那么 find 的值就是 A 的敌人。 而维护我们通过统一的式子 **parentsfind() = find()**来维护即可。比如我要让 A 是 B 的敌人。

了解了这些,现在我们的扩展域并查集操作逻辑其实就如下。比如我要操作 A 是 B 的朋友 ,我们就要在朋友区把他们连接起来,parentsfind(A) = find(B) ,A 和 B 成为朋友。事实上,为了逻辑的稳固,我们最好还是要执行 parentsfind(A+n) = find(B+n) ,即在敌人区把他们连接起来。因为 A 和 B 是朋友,那么也就表明 A 和 B 的敌人就是朋友了

但是这道题目很特殊,他要求我们贪心地分出尽可能多的团体数,所以如果我们一旦让 A 和 B 的敌人成为朋友(即分在同一个团体,实际上他们可以分开的不在一个团体,有点阴),那我们后续的分组中就很难分出来。所以这里我们只用简单让 A B 成为朋友就行了。

而如果 A 和 B 是敌人 呢?毫无疑问,我们的 A+n 这个点正是代表 A 的敌人,按照逻辑,必须是执行 parentsfind(A+n) = find(B) 了。随后,既然 A 的敌人是 B ,那相对的,B 的敌人也就是 A 了。时刻要记得我们数组不同区域的定义,B+n 这个点代表的就是 B 的敌人,即执行 parentsfind(B+n) = find(A)

最后,用一个 st 数组处理一下分组就可以了。为了这个 st 数组有效进行分组,即只要这个点 find 不属于一个已经分好的一个组,那么以他自己作为一个新组,我们刚刚才没有进行在两个人是朋友的时候,让他们的敌人成为朋友,因为这样也意味着我们把他们分到一个团体去了。

最终代码如下。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using ll=long long;
const int maxn=1e4+5;
int parents[maxn<<1];//两倍
bool st[maxn<<1];
int find(int x){
    return parents[x]==x?x:parents[x]=find(parents[x]);
}
void solve(){
    int n,m;
    cin>>n>>m;
    int ans=0;
    for(int i=1;i<=(n<<1);++i) parents[i]=i;//初始化
    for(int i=1;i<=m;++i){
        int p,q;
        char op;
        cin>>op>>p>>q;
        if(op=='F'){
            parents[find(p)]=find(q);//朋友
        }else{
            parents[find(p)]=find(q+n);//敌人
            parents[find(q)]=find(p+n);
        }
    }
    for(int i=1;i<=n;++i){
        if(st[find(i)]) continue;
        st[find(i)]=true;
        ++ans;
    }
    cout<<ans<<'\n';
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    int T=1;
    //cin>>T;
    while(T--){
        solve();
    }
}

实际上二元关系思考起来还是比较简单的,只要不遗漏逻辑链就不会出错。一般来讲,二元关系下的维护一般一次都要进行两次的逻辑维护,进而,三元关系一般也要进行三次逻辑维护 ,比如我们能看到无论是朋友还是敌人,我们维护的时候都操作了两次,只是因为这题的特殊性导致朋友位置少了一次。所以对于一般题目,只要发现我们维护的操作次数少了一次,我们就需要担心我们是否有逻辑遗漏了。用这个办法还是很好找遗漏的,毕竟一旦遗漏逻辑肉眼很难看出来而且也很难发现是因为什么原因出错。

洛谷 P2024 三元关系

这题相比于 P1892 难度稍微大一点,它要求我们维护同类,猎物,天敌的三元关系,同样他是可传递的关系,可以用扩展域并查集。但其实和刚刚的逻辑一样,我们直接开一个 3n 的并查集数组,剩下的逻辑我们慢慢梳理。

我们开一个 3n 大小的并查集数组,1 - n 是同类或者是自己,n+1 - 2n 是猎物(我吃的),2n+1 - 3n 是天敌(吃我的)。

和刚刚那道题的推理其实差不多,只不过要注意的是,既然是三元关系,那我们至少就要进行三次修改才能完整我们的逻辑链。

还是一样去做,假设我们现在要让 A 和 B 是同类 ,那该怎么办?首先,既然 A 和 B 是同类,那显然第一步是要 parentsfind(A) = find(B) 了,同类区进行连接。但是还不够,我们继续思考,既然 A 和 B 是同类,那么也就意味着,他们的猎物必然是同类了,而且他们的天敌也必然是同类了!所以猎物区和天敌区也需要连接起来保持逻辑完整。即

parentsfind(A+n) = find(B+n) 猎物是同类

parentsfind(A+2n) = find(B+2n) 天敌是同类

然后就是题目给的A 吃 B ,意思也就是说,A 的猎物是 B 。所以第一步,我们就按照题目意思翻译即可,即parentsfind(A+n) = find(B) 。下一步我们反过来想,那不就是 B 的天敌是 A 吗?所以可以得到 parentsfind(B+2n) = find(A) 。那么目前,我们离逻辑完备还差一次操作才行。我们可以发现,按照题意的食物链,在 A 的猎物是 B 条件下, A 的天敌就是 B 的猎物 !所以最终的一次操作就是parentsfind(A+2n) = find(B+n) 。(如果觉得结论推导难可以看后续总结)

所以总结起来也不是很复杂了,只要梳理好逻辑关系,保证自己的操作不是重复的,都是有效的逻辑,且这题的操作数达到三个一般都不会错。唯一不同的是这题要判定是否为假话,这就不算难了。只用 find 来比对就好了。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using ll=long long;
const int maxn=1e5+5;
int parents[maxn*3];
int find(int x){
    return x==parents[x]?x:parents[x]=find(parents[x]);
}
void solve(){
    int n,k;
    int ans=0;
    cin>>n>>k;
    for(int i=1;i<=n*3;++i) parents[i]=i;//初始化
    for(int i=1;i<=k;++i){
        int op,x,y;
        cin>>op>>x>>y;
        if(x>n||y>n){//大于n是假话
            ++ans;
            continue;
        }
        if(op==1){
            //x和y是同类,那如果发现是猎物或者天敌关系则必为假话
            if(find(x)==find(y+n)||find(x)==find(y+(n<<1))){
                ++ans;
                continue;
            }
            parents[find(x)]=find(y);
            parents[find(x+n)]=find(y+n);
            parents[find(x+(n<<1))]=find(y+(n<<1));
        }else{
            //x吃y,如果发现x和y是同类或者y吃x则是假话
            if(find(x)==find(y)||find(x)==find(y+n)){
                ++ans;
                continue;
            }
            parents[find(x+n)]=find(y);
            parents[find(y+n)]=find(x+(n<<1));
            parents[find(y+(n<<1))]=find(x);
        }
    }
    cout<<ans<<'\n';
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    int T=1;
    //cin>>T;
    while(T--){
        solve();
    }
}

总结推导方法

其实扩展域并查集作为带权并查集在解决种类并查集问题时候的一个平替,思考逻辑来说确实是没这么难的,但我们也不能轻视逻辑混乱的问题。就像刚刚我提出的,一般维护 n 元关系那么操作并查集的时候肯定是至少有 n 次操作才能逻辑完备的 。这个原则可以让我们检查明显的逻辑遗漏。但是有时候我们的逻辑不止是遗漏,还有冗余。比如同样的一个意思 A 吃 B ,我们会有很多的操作方式来表达,但我们如何判断这个操作是否有效地对逻辑做出贡献而不是和另一个操作一模一样而冗余呢?

我们可以换个方式思考。我们利用扩展域并查集要维护的逻辑本身是带权并查集所维护的,所以我们操作的出发点其实都是:还原类似带权并查集那样的逻辑链 。比如,对于刚刚的 A 吃 B ,B 吃 C,C 吃 A 的食物链关系,其实就是 A -> B -> C -> A 的逻辑链

我们从左往右看,首先建立的逻辑链是 A -> B ,也就是 A 吃 B ,我们用 parentsfind(A+n) = find(B) 解决;下一步是 B 吃 C 的逻辑,我们可以看到,我们建立的是 A -> B -> C ,在这个链条里,C 是 A 的天敌,同时 C 是 B 的猎物 ,所以自然而然就推导出刚刚的结论:A 的天敌是 B 的猎物!下一步是建立 C -> A 逻辑完成一个环状的闭合逻辑。我们可以看到, A 也是 C 的猎物,而 C 也是 B 的猎物,可推导出 A 就是 B 的天敌!从而建立完整个 A -> B -> C -> A 的闭合环状逻辑 ,我们可以保证每一次推导出来的结论不会有逻辑冗余且也是完美符合我们的操作次数的!总结下来就是:利用每次的并查集操作来模拟环状逻辑链条的建立,保证不产生逻辑冗余和遗漏

还有就是特别提醒一下大家并查集一定要记得初始化!!!不要忘记了!!!

根据以上的方法去推导,其实扩展域并查集几乎就没有难度了。只要逻辑正确,答案就绝对不会错。

相关推荐
兰令水2 小时前
leecodecode【单调栈】【2026.6.12打卡-java版本】
java·开发语言·算法
TMT星球2 小时前
魔法原子上交会首秀VLA K02大模型,完成具身智能从“执行”到“理解”的能力跃迁
人工智能·算法·机器学习
2301_764441332 小时前
番茄钟+AI:高效专注的秘密武器
人工智能·算法·数学建模·动态规划·交互
影寂ldy2 小时前
C# 泛型委托
java·算法·c#
星马梦缘2 小时前
算法设计与分析 作业三 纯答案
算法
吴阿福|一人公司2 小时前
深度解析 Python 类变量修改的命名空间隔离
java·服务器·数据结构
雾沉川3 小时前
Visual C++ 运行库合集 v105.0 部署与故障排查技术指南
开发语言·c++·dll
不知名的老吴3 小时前
经典算法题之行星碰撞
数据结构·算法
丘山望岳3 小时前
剑起霜华——平衡二叉树(AVL树 )精讲
开发语言·数据结构·c++