并查集概念
并查集是一种用于管理 不相交集合 的数据结构,支持以下两种核心操作:
查找(Find):确定元素属于哪个集合(常用于判断两个元素是否属于同一集合)。
合并(Union):将两个集合合并为一个集合。
核心思想:通过树形结构组织集合,每个集合的根节点作为唯一标识。
算法步骤
初始化
每个元素初始化为独立的集合,父节点指向自己。
int parent[N];
void init() {
for (int i = 0; i < N; i++) parent[i] = i;
}
查找(Find)
找到元素的根节点(代表元)。
普通版本:
int find(int x) {
if (parent[x] != x) return find(parent[x]);
return x;
}
合并(Union)
将两个元素所属的集合合并。
普通版本:
void unionSet(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX != rootY) parent[rootX] = rootY;
}
优化:
(1) 路径压缩(Path Compression)
目标:缩短查找路径,使树更扁平。
实现:在查找过程中,将路径上的所有节点直接指向根。
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // 递归压缩
return parent[x];
}
(2) 按秩合并(Union by Rank)
目标:合并时避免树的高度过高。
实现:总是将较矮的树合并到较高的树中。
int rank[N]; // 初始化 rank[i] = 0
void unionSet(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
if (rank[rootX] == rank[rootY]) rank[rootX]++;
}
}
拓展运用:
(1) 统计集合大小(size[])
记录每个集合的元素数量,仅根节点的值有效。
int size[N]; // 初始化 size[i] = 1
void unionSet(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
if (size[rootX] < size[rootY]) {
parent[rootX] = rootY;
size[rootY] += size[rootX];
} else {
parent[rootY] = rootX;
size[rootX] += size[rootY];
}
}
应用场景:统计朋友圈人数、岛屿面积等。
(2) 带权并查集(dis[])
记录节点到根节点的距离(权值),每个节点的值有效。
int parent[N], dis[N]; // dis[i] 表示 i 到 parent[i] 的权值
int find(int x) {
if (parent[x] != x) {
int root = find(parent[x]);
dis[x] += dis[parent[x]]; // 路径压缩时更新权值
parent[x] = root;
}
return parent[x];}
void unionSet(int x, int y, int w) { // w 是 x 到 y 的权值
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
parent[rootX] = rootY;
dis[rootX] = w + dis[y] - dis[x]; // 根据向量关系更新权值
}
应用场景:
食物链问题(判断矛盾关系)。
网络连通性(带距离约束)。
(3)拓展域并查集:用于多分类问题有很好的效果
拓展域并查集是一种用于解决 多分类问题 的高级并查集技术。它通过将每个元素拆分为多个域(或状态),从而处理元素之间的复杂关系(如对立、矛盾等)。
1. 核心思想
将每个元素 x拆分为多个域,通常表示不同的状态或类别。
通过并查集维护这些域之间的关系。
适用于需要处理 对立关系 或 多类别关系 的问题。
2. 典型应用场景
食物链问题:
描述动物之间的捕食关系(A 吃 B,B 吃 C,C 吃 A)。
需要处理三种类别(同类、捕食者、被捕食者)。
二分图检测:
判断图是否可以二分(分为两个集合,集合内无边)。
矛盾关系判断:
判断一组关系是否存在矛盾(如 A 和 B 不能同时为真)。
3. 实现方法
假设每个元素 xx 有 kk 个域,通常用以下方式表示:
x:表示元素 xx 的默认域。
x+n:表示元素 xx 的第二个域。
x+2n:表示元素 xx 的第三个域。
以此类推。
核心操作:
合并(Union):将两个域合并为同一集合。
查找(Find):找到某个域的根节点。
矛盾检测:通过检查两个域是否在同一集合中,判断是否存在矛盾。
class ExtendedUnionFind {private:
vector<int> parent;
int n; // 元素个数
int k; // 域的个数
public:
ExtendedUnionFind(int n, int k) : n(n), k(k) {
parent.resize(n * k);
for (int i = 0; i < n * k; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]);
return parent[x];
}
void unite(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX != rootY) parent[rootX] = rootY;
}
bool isConnected(int x, int y) {
return find(x) == find(y);
}
};
典型问题
基础应用:判断图中两个节点是否连通。
集合大小:统计每个连通块的大小。
带权问题:处理元素间的相对关系(如距离、类别差异)。
动态连通性:处理离线查询的连通性问题。
习题:
搭配购买:
题目描述
明天就是母亲节了,电脑组的小朋友们在忙碌的课业之余挖空心思想着该送什么礼物来表达自己的心意呢?听说在某个网站上有卖云朵的,小朋友们决定一同前往去看看这种神奇的商品,这个店里有 n 朵云,云朵已经被老板编号为 1,2,3,...,n,并且每朵云都有一个价值,但是商店的老板是个很奇怪的人,他会告诉你一些云朵要搭配起来买才卖,也就是说买一朵云则与这朵云有搭配的云都要买,电脑组的你觉得这礼物实在是太新奇了,但是你的钱是有限的,所以你肯定是想用现有的钱买到尽量多价值的云。
输入格式
第一行输入三个整数,n,m,w,表示有 n 朵云,m 个搭配和你现有的钱的数目。
第二行至 n+1 行,每行有两个整数, ci,di,表示第 i 朵云的价钱和价值。
第 n+2 至 n+1+m 行 ,每行有两个整数 ui,vi。表示买第 ui 朵云就必须买第 vi 朵云,同理,如果买第 vi 朵就必须买第 ui 朵。
输出格式
一行,表示可以获得的最大价值。
分析:
背包+并查集:
并查集处理搭配关系:
初始化并查集,每个云朵的父节点初始化为自身。
遍历输入的搭配关系,对于每对 (u_i, v_i),使用并查集的合并操作将它们所在的集合合并,这样所有有搭配关系的云朵最终都会在同一个集合中。
遍历所有云朵,将每个云朵所在集合的总花费(所有同集合云朵的花费之和)和总价值(所有同集合云朵的价值之和)分别统计出来。
01 背包求解:
定义一个数组 dp,dp[j] 表示在预算为 j 时能够获得的最大价值。
遍历每个集合,对于每个集合,将其总花费看作背包问题中的物品重量,总价值看作物品价值。
按照 01 背包的经典思路,从后往前遍历背包容量(预算),对于每个容量 j,如果 j 大于等于当前集合的总花费,就更新 dp[j] 为 dp[j] 和 dp[j - 集合总花费] + 集合总价值 中的较大值。
遍历结束后,dp[w] 即为在给定预算 w 下能够获得的最大价值。
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=10010;
int n,m,vol;
int v[N],w[N];
int p[N];
int f[N];
int find(int x){
if(x!=p[x]){
p[x]=find(p[x]);
}return p[x];
}
int main(){
cin>>n>>m>>vol;
for(int i=1;i<=n;i++)cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)p[i]=i;
for(int i=1;i<=m;i++){
int from,to;
cin>>from>>to;
int pu=find(from),pv=find(to);
if(pv!=pu){
p[pv]=pu;
v[pu]+=v[pv];
w[pu]+=w[pv];
}
}
for(int i=1;i<=n;i++){
if(i!=p[i])continue;
for(int j=vol;j>=v[i];j--)
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
cout<<f[vol];
}
程序自动化分析:
题目描述
在实现程序自动分析的过程中,常常需要判定一些约束条件是否能被同时满足。
考虑一个约束满足问题的简化版本:假设 x1,x2,x3,⋯ 代表程序中出现的变量,给定 n 个形如 xi=xj 或 xi=xj 的变量相等/不等的约束条件,请判定是否可以分别为每一个变量赋予恰当的值,使得上述所有约束条件同时被满足。例如,一个问题中的约束条件为:x1=x2,x2=x3,x3=x4,x4=x1,这些约束条件显然是不可能同时被满足的,因此这个问题应判定为不可被满足。
现在给出一些约束满足问题,请分别对它们进行判定。
输入格式
输入的第一行包含一个正整数 t,表示需要判定的问题个数。注意这些问题之间是相互独立的。
对于每个问题,包含若干行:
第一行包含一个正整数 n,表示该问题中需要被满足的约束条件个数。接下来 n 行,每行包括三个整数 i,j,e,描述一个相等/不等的约束条件,相邻整数之间用单个空格隔开。若 e=1,则该约束条件为 xi=xj。若e=0,则该约束条件为 xi=xj。
输出格式
输出包括 t 行。
输出文件的第 k 行输出一个字符串 YES 或者 NO(字母全部大写),YES 表示输入中的第 k 个问题判定为可以被满足,NO 表示不可被满足。
分析:
离散化+离线查询+并查集
代码:
#include <bits/stdc++.h>using namespace std;
const int N=200010;int p[N];int t;int n;pair<int,int> q[N];int tmp=0;unordered_map<int,int> m;int cnt=0;int find(int x){
if(x!=p[x])p[x]=find(p[x]);
return p[x];
}void merge(int x,int y){
int px=find(x),py=find(y);
if(px!=py)p[px]=py;
}int main(){
scanf("%d",&t);
while(t--){
scanf("%d",&n);
tmp=0;
cnt=0;
m.clear();
for(int i=1;i<=2*n;i++)p[i]=i;
int flag=1;
for(int i=0;i<n;i++){
int u,v,c;
scanf("%d%d%d",&u,&v,&c);
if(m[u]==0)m[u]=++cnt;
if(m[v]==0)m[v]=++cnt;
if(c==1){
merge(m[u],m[v]);
}
else {
q[tmp++]={m[u],m[v]};
}
}
for(int i=0;i<tmp;i++){
int u=q[i].first,v=q[i].second;
if(find(u)==find(v)){
flag=0;
break;
}
}
if(!flag)puts("NO");
else puts("YES");
}
}
银河英雄传说:
题目背景
公元 5801 年,地球居民迁至金牛座 α 第二行星,在那里发表银河联邦创立宣言,同年改元为宇宙历元年,并开始向银河系深处拓展。
宇宙历 799 年,银河系的两大军事集团在巴米利恩星域爆发战争。泰山压顶集团派宇宙舰队司令莱因哈特率领十万余艘战舰出征,气吞山河集团点名将杨威利组织麾下三万艘战舰迎敌。
题目描述
杨威利擅长排兵布阵,巧妙运用各种战术屡次以少胜多,难免恣生骄气。在这次决战中,他将巴米利恩星域战场划分成 30000 列,每列依次编号为 1,2,…,30000。之后,他把自己的战舰也依次编号为 1,2,…,30000,让第 i 号战舰处于第 i 列,形成“一字长蛇阵”,诱敌深入。这是初始阵形。当进犯之敌到达时,杨威利会多次发布合并指令,将大部分战舰集中在某几列上,实施密集攻击。合并指令为 M i j,含义为第 i 号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第 j 号战舰所在的战舰队列的尾部。显然战舰队列是由处于同一列的一个或多个战舰组成的。合并指令的执行结果会使队列增大。
然而,老谋深算的莱因哈特早已在战略上取得了主动。在交战中,他可以通过庞大的情报网络随时监听杨威利的舰队调动指令。
在杨威利发布指令调动舰队的同时,莱因哈特为了及时了解当前杨威利的战舰分布情况,也会发出一些询问指令:C i j。该指令意思是,询问电脑,杨威利的第 i 号战舰与第 j 号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。
作为一个资深的高级程序设计员,你被要求编写程序分析杨威利的指令,以及回答莱因哈特的询问。
输入格式
第一行有一个整数 T(1≤T≤5×105),表示总共有 T 条指令。
以下有 T 行,每行有一条指令。指令有两种格式:
M i j:i 和 j 是两个整数(1≤i,j≤30000),表示指令涉及的战舰编号。该指令是莱因哈特窃听到的杨威利发布的舰队调动指令,并且保证第 i 号战舰与第 j 号战舰不在同一列。
C i j:i 和 j 是两个整数(1≤i,j≤30000),表示指令涉及的战舰编号。该指令是莱因哈特发布的询问指令。
输出格式
依次对输入的每一条指令进行分析和处理:
如果是杨威利发布的舰队调动指令,则表示舰队排列发生了变化,你的程序要注意到这一点,但是不要输出任何信息。
如果是莱因哈特发布的询问指令,你的程序要输出一行,仅包含一个整数,表示在同一列上,第 i 号战舰与第 j 号战舰之间布置的战舰数目。如果第 i 号战舰与第 j 号战舰当前不在同一列上,则输出 −1。
分析:
并查集+dis[]+sz[]:
本题可以使用并查集来维护战舰之间的列关系,同时引入两个额外的数组 dis[] 和 sz[] 来辅助解决问题。
dis[i]:表示第 i 号战舰到其所在列根节点战舰的距离。
sz[i]:表示以第 i 号战舰为根节点的战舰队列的长度。
对于 M i j 指令,将第 i 号战舰所在的队列合并到第 j 号战舰所在队列的尾部,需要更新 dis 和 sz 数组。
对于 C i j 指令,判断第 i 号战舰和第 j 号战舰是否在同一列,如果在同一列,计算它们之间的战舰数量;如果不在同一列,输出 -1。
代码:
#include <bits/stdc++.h>
using namespace std;
const int N=500010;
int p[N];
int n;
int sz[N],d[N];
int find(int x){
if(x!=p[x]){
int root=find(p[x]);
d[x]+=d[p[x]];//每时每刻的d[x],都是存储的到p[x]的值,比如1->2.合并的时候并没有更新d[1],而是在下次用到1这个节点的时候现场更新。
p[x]=root;
}
return p[x];
}int main(){
cin>>n;
for(int i=1;i<=n;i++)p[i]=i;
for(int i=1;i<=n;i++)sz[i]=1;
for(int i=0;i<n;i++){
char c;int a,b;
cin>>c>>a>>b;
if(c=='M'){
int pb=find(b);
int pa=find(a);
d[pa]=sz[pb];
sz[pb]+=sz[pa];
p[pa]=pb;
}else{
int pb=find(b);
int pa=find(a);
if(pa!=pb)puts("-1");
else cout<<max(0,abs(d[a]-d[b])-1)<<endl;
}
}
}
奇偶游戏:
小 A 和小 B 在玩一个游戏。
首先,小 A写了一个由 0 和 1 组成的序列 S,长度为 N。
然后,小 B 向小 A 提出了 M 个问题。
在每个问题中,小 B 指定两个数 l 和 r,小 AA 回答 S[l∼r] 中有奇数个 1 还是偶数个 1。
机智的小 BB 发现小 AA 有可能在撒谎。
例如,小 AA 曾经回答过 S[1∼3] 中有奇数个 1,S[4∼6]中有偶数个 11,现在又回答 S[1∼6] 中有偶数个 1,显然这是自相矛盾的。
请你帮助小 B 检查这 M 个答案,并指出在至少多少个回答之后可以确定小 A 一定在撒谎。
即求出一个最小的 k,使得 0101 序列 SS 满足第 1∼k 个回答,但不满足第1∼k+1 个回答。
输入格式
第一行包含一个整数 N,表示 01 序列长度。
第二行包含一个整数 M,表示问题数量。
接下来 M 行,每行包含一组问答:两个整数 l 和 r,以及回答 even 或 odd,用以描述 S[l∼r] 中有偶数个 1 还是奇数个 1。
输出格式
输出一个整数 k,表示 01 序列满足第 1∼k 个回答,但不满足第1∼k+1 个回答,如果 01序列满足所有回答,则输出问题总数量。
N有点大,需要离散化
分析:
这是一道结合了前缀和思想与扩展域并查集的逻辑推理题。解题的关键在于利用前缀和将区间问题转化为两个端点前缀和的奇偶性比较问题,再通过扩展域并查集来维护这些奇偶关系,判断是否存在矛盾。以下是详细的思路分析:
前缀和的应用:
对于由0和1组成的序列S,定义pre[i]表示S的前i项中1的个数,即前缀和。
那么区间S[l∼r]中1的个数就等于pre[r]−pre[l−1]。所以判断S[l∼r]中有奇数个1还是偶数个1,实际上就是判断pre[r]和pre[l−1]的奇偶性是否相同。若相同,则S[l∼r]中有偶数个1;若不同,则S[l∼r]中有奇数个1。
扩展域并查集的构建:
因为需要判断前缀和的奇偶性,所以使用扩展域并查集,为每个前缀和维护两个域,一个表示奇数域,一个表示偶数域。
对于每个前缀和pre[i],在并查集中有两个节点,分别表示pre[i]为奇数和pre[i]为偶数的情况。例如,对于pre[i],设其奇数域节点为i,偶数域节点为i+N(N为序列长度,这样可以保证奇数域和偶数域的节点编号不冲突)。
离散化处理:
由于N可能很大,但实际涉及的区间端点数量相对较少,所以需要对区间端点进行离散化处理。
收集所有问题中的区间端点l和r,将它们去重并排序,然后为每个不同的端点分配一个新的连续编号。这样可以减少并查集的空间开销。
并查集的操作:
初始化并查集,每个节点的父节点都是其自身。
对于每个问题,根据回答是 “even” 还是 “odd” 来进行并查集的合并操作。
若回答是 “even”,表示pre[l−1]和pre[r]的奇偶性相同,那么将pre[l−1]的奇数域节点和pre[r]的奇数域节点合并,pre[l−1]的偶数域节点和pre[r]的偶数域节点合并。
若回答是 “odd”,表示pre[l−1]和pre[r]的奇偶性不同,那么将pre[l−1]的奇数域节点和pre[r]的偶数域节点合并,pre[l−1]的偶数域节点和pre[r]的奇数域节点合并。
在合并操作之前,先检查当前的合并操作是否会导致矛盾。例如,若pre[l−1]的奇数域节点和pre[r]的奇数域节点已经在同一个集合中,而当前回答是 “odd”,就说明出现了矛盾。
确定撒谎的位置:
按照上述步骤依次处理每个问题,一旦发现矛盾,就可以确定小A在撒谎,此时的问题编号k就是所求的最小的k,使得01序列满足第1∼k个回答,但不满足第1∼k+1个回答。
如果处理完所有M个问题都没有发现矛盾,则输出问题总数量M。
通过以上步骤,利用前缀和将区间问题转化为奇偶性比较问题,再通过扩展域并查集维护这些关系,就可以有效地判断小A是否在撒谎以及确定撒谎的位置。
代码:
#include <iostream>
#include <unordered_map>
using namespace std;
const int N=100000;
int n,e;
unordered_map<int,int> m;
int cnt=0;
int p[N*2];
int find(int x){
if(x!=p[x]){
p[x]=find(p[x]);
}return p[x];
}
int main(){
cin>>n;
for(int i=1;i<=N*2;i++)p[i]=i;
cin>>e;
for(int i=1;i<=e;i++){
int u,v;
string s;
cin>>u>>v>>s;
u--;
if(m[u]==0)m[u]=++cnt;
if(m[v]==0)m[v]=++cnt;
if(s[0]=='e'){
if(find(m[u]+N)==find(m[v])){
cout<<i-1<<endl;
return 0;
}
p[find(m[u])]=p[find(m[v])];
p[find(m[u]+N)]=p[find(m[v]+N)];
}else{
if(find(m[v])==find(m[u])){
cout<<i-1<<endl;
return 0;
}
p[find(m[u])]=p[find(m[v]+N)];
p[find(m[u]+N)]=p[find(m[v])];
}
}
cout<<e;
}