並查集經典題目
還是先看兩道題:
試題描述 |
俗話說得好,敵人的敵人就是朋友。 現在有n個人,編號1至n,初始互不相識。接下來有m個操作,操作分為兩種: (1)檢查x號和y號是否是朋友,若不是,則變成敵人(2)詢問x號的朋友有多少個 請你針對每個操作中的詢問給出回答。 |
輸入 |
第一行兩個正整數n、m,表示人的數量和操作的數量。 接下來m行,依次描述輸入。每行的第一個整數為1或2表示操作種類。對於操作(1),隨後有兩個正整數x,y。對於操作(2),隨後一個正整數x。 |
輸出 |
輸出包含m行,對於操作(1),輸入'N'或"Y",'N'表示x和y之前不是朋友,'Y'表示是朋友。對於操作(2),輸出x的朋友數量。 |
輸入示例 |
5 8 1 1 2 1 1 3 1 2 3 2 3 1 4 5 2 3 1 1 4 2 3 |
輸出示例 |
N N Y 1 N 1 N 2 |
其他說明 |
n,m<=300000。 對於80%的資料,不包含操作2。 |
試題描述 |
有N只動物分別編號為1,2,……,N。所有動物都屬於A,B,C中的一類。已知A能吃掉B,B能吃掉C,C能吃掉A。按順序給出下面的兩種資訊共K條: |
輸入 |
第一行兩個自然數,兩數間用一個空格分隔,分別表示N和K,接下來的K行,每行有三個數,第一個數為0或1,分別對應第一種或第二種,接著的兩個數,分別為該條資訊的x和y,三個數兩兩之間用一個空格分隔。 |
輸出 |
一個自然數,表示錯誤資訊的條數。 |
輸入示例 |
100 7 0 101 1 1 1 2 1 2 3 1 3 3 0 1 3 1 3 1 0 5 5 |
輸出示例 |
3 |
其他說明 |
資料範圍:1<=N<=50000,0<=K<=100000,其它說有輸入都不會超過100000. |
兩道並查集的經典題目,現有兩種做法可供參考:
先以第一題(名為敵人)為例, 這題和那種並查集的模板題有一點區別,那就是這裡多了一種關係:叫做朋友與敵人,如果只有一種關係那就好辦了。那怎麼處理這多出來的一種關係呢?這裡我們先介紹第一種方法,我們管它叫精神分裂法(又稱分身術)
現在我們有三個小朋友,由於本題的關係只有2種,於是我們只需召喚一個分身即可
好的我們成功召喚了這三個小朋友的三個分身,我們可以把一個分身a'當做a自己的敵人(上圖中連了他們的三條邊,但是這並沒有什麼用。)
上圖中的連邊表示這1,2是敵人關係,2,3是敵人關係,這樣1,3就藉著2的分身成為朋友了。但是這樣連邊並不完備
這樣的連邊才是比較完備的。
知道這些就可以寫程式了:
按 Ctrl+C 複製程式碼 按 Ctrl+C 複製程式碼其中還有一些細節,第一個細節就是分身的儲存,分身也應該存在f陣列,為了使下標不重複,所以a的分身存在a+n,b的分身存在b+n,以此類推。第二個細節就是並查集合並:在我寫的merge函式中,合併的是a所在的集合和b所在的集合,其中合併過程是f[x]=y;(x,y分別是a,b所在集合的標識元素),也就是把b合併到a裡,因為題目中會隨時詢問朋友的個數,但是每一個集合中都包含敵人與朋友,不好處理。所以你可以看到:在第32行程式碼並查集初始化,每一個集合容量初始為1時,是從1到n,並沒有算分身的初始化。因為分身是虛的,所在集合元素個數應該是0.這樣的設定帶來的好處就是:在合併敵人關係時,更新size陣列,敵人不會被算在內。(這個有點難想,需要讀者仔細思考,認真體會)。
我們可以再通過食物鏈這種擁有三種關係的並查集習題來理解一下所謂的“分身術”,下面是程式碼:
1 #include<iostream> 2 #include<algorithm> 3 #include<cmath> 4 #include<queue> 5 using namespace std; 6 const int maxn=150000+10; 7 int n,k,t[maxn],a[maxn],b[maxn],ans; 8 int f[maxn]; 9 int getf(int x){return x==f[x] ? x : f[x]=getf(f[x]);} 10 void unite(int x,int y) 11 { 12 int a=getf(x),b=getf(y); 13 if(a!=b)f[b]=a; 14 return; 15 } 16 int main() 17 { 18 scanf("%d%d",&n,&k); 19 for(int i=0;i<k;i++) 20 scanf("%d%d%d",&t[i],&a[i],&b[i]); 21 for(int i=0;i<3*n;i++)f[i]=i; 22 for(int i=0;i<k;i++) 23 { 24 int tp=t[i]; 25 int x=a[i]-1,y=b[i]-1; 26 if(x<0 || x>=n || y<0 || y>=n)//輸入不合法 27 { 28 ans++; 29 continue; 30 } 31 if(tp==0) 32 { 33 if(getf(x)==getf(y+n) || getf(x)==getf(y+2*n) ) ans++;//屬於吃或被吃關係 34 else 35 { 36 unite(x,y); 37 unite(x+n,y+n); 38 unite(x+n*2,y+n*2);//三個分身都合併 39 } 40 } 41 else 42 { 43 if(getf(x)==getf(y) || getf(x)==getf(y+2*n) ) ans++;//同類或被吃關係 44 else 45 { 46 unite(x,y+n); 47 unite(x+n,y+2*n); 48 unite(x+2*n,y);//三個分身交叉合併,表示吃的關係 49 } 50 } 51 } 52 printf("%d",ans); 53 return 0; 54 }
下面以食物鏈為例,我們介紹第二種方法:帶權並查集
食物鏈一題一共有三種關係:同類,吃,被吃
同類權值為0,就像上圖
上圖中1到2邊的權值1,2到1的權值是2,分別表示1吃2,2被1吃
知道這些基礎的後,我們就可以通過點點之間的邊權的值來弄明白他們之間的關係了,我們用一個r陣列來存這種關係,其中r[x]表示從x點指出去邊的權值,指向的是f[x]
首先是並查集的基礎:查詢一個元素所在集合的標識元素
對n做一遍getf函式,順帶路徑壓縮(過程中順帶更新r[n]):起初n的祖先是f[n],所以節點n到節點f[n]存在一條邊權值為r[n],要想求得n到根的邊權,需要知道r[f[n]]的值,然後(r[n]+r[f[n]])%3就可算出n到根的關係了(要模3的原因是因為只有三種關係),r[f[n]]的值嗎。。就交給偉大的遞迴了:
1 int getf(int n) 2 { 3 if(f[n]==n)return n; 4 int tmp=f[n];//提前存下f[n]的值,因為程式執行完下一行後f[n]直接就變成n所在集合的表示元素了 5 f[n]=getf(f[n]);//路徑壓縮 6 r[n]=(r[tmp]+r[n])%3;//公式計算 7 return f[n]; 8 }
會寫getf函式後我們再來想想如何寫並查集合並的程式,在此題中針對兩個動物a,b我們是先知道他們之間的關係,才進行合併的,所以
圖中x,y分別是a,b所在集合的標識元素,在merge過程中,我們遵循y連到x的規則(其實都一樣),因為在未合併前,集合x和集合y是兩個沒有任何關係的集合,多了a到b的關係後,才建立聯絡的。這裡有一個非常非常重要的結論:一個並查集中從一個點出發,沿著它所指向的邊一直走,並累加下路上的權值(如果邊是反的那麼就取權值的負數),走一圈回來,累加的和模3結果一定是0.這個我也不怎麼會證明,但是讀者可以通過類似向量的東西來理解這個結論,應該不算太難吧。根據這個結論和上圖,我們能得到一個等式:(r[b]+r[y]+k-r[a])%3=0,其中k是a到b的關係,這個邊其實在並查集中是不存在的,但是加上這條邊能更好的解釋。在這個等式裡面,有哪些是我們不知道的呢?只有r[y],所以在合併時我們只需算出r[y]的值即可。r[y]=(r[a]-k-r[b]+3)%3,這個是由剛才的等式推出的,其中有一個+3是為了防負數的。
知道這些其實就可以做題了,本題的意思是找出所有不合法資訊的個數,不合法資訊包括數字問題,還有的就是並查集之間的衝突。對於輸入的兩個數a,b,如果是兩個未知關係的元素,我們就進行正常的並查集合並就可以了,如果他們之前有關係(即在一個集合之中)我們就需要分析他們的關係是否正確了。
由於r[a],r[b]儲存的都是a,b與他們的標識元素之間的關係,無法直接得出a和b的關係,但是假設a,b之間存在一個關係k,如果等式(k+r[b]-r[a]+3)%3=0的話,那麼關係就就是正確的了,反之,則不對。
下面貼程式碼:
1 #include<iostream> 2 #include<algorithm> 3 #include<cmath> 4 #include<cstring> 5 using namespace std; 6 int f[50010],r[50010],cnt,n,k,tp,a,b,x,y; 7 int getf(int n) 8 { 9 if(f[n]==n)return n; 10 int tmp=f[n]; 11 f[n]=getf(f[n]); 12 r[n]=(r[tmp]+r[n])%3; 13 return f[n]; 14 } 15 int main() 16 { 17 scanf("%d%d",&n,&k); 18 for(int i=1;i<=n;i++)f[i]=i; 19 while(k--) 20 { 21 scanf("%d%d%d",&tp,&a,&b); 22 if(a<1 || a>n || b<1 || b>n){cnt++;continue;}//數字非法 23 x=getf(a);y=getf(b);//找根 24 if(x!=y) 25 { 26 f[x]=y;//這裡的合併與上面不同,是把x合併到y; 27 r[x]=(r[b]-r[a]+tp+999)%3;//我這裡寫了999,並沒有寫3,其實都一樣 28 } 29 else if((tp+r[b]-r[a])%3) cnt++;//判斷關係是否正確 30 } 31 printf("%d",cnt); 32 return 0; 33 }
然後我們再來看看敵人這道題,用帶權並查集做
1 #include<iostream> 2 #include<algorithm> 3 #include<cmath> 4 #include<cstring> 5 using namespace std; 6 const int maxn=300000+10; 7 int f[maxn],r[maxn],size1[maxn],size2[maxn],n,k,tp,a,b,x,y; 8 int read() 9 { 10 int f=1,x=0; 11 char ch=getchar(); 12 if(ch=='-') f=-1; 13 while(ch<'0'||ch>'9') 14 { 15 if(ch=='-')f=-1; 16 ch=getchar(); 17 } 18 while(ch>='0'&& ch<='9') { x=x*10+ch-'0'; ch=getchar(); } 19 return x*f; 20 } 21 int getf(int n) 22 { 23 if(f[n]==n)return n; 24 int tmp=f[n]; 25 f[n]=getf(f[n]); 26 r[n]=(r[tmp]+r[n])%2; 27 return f[n]; 28 } 29 int main() 30 { 31 n=read();k=read(); 32 for(int i=1;i<=n;i++) 33 { 34 f[i]=i; 35 size1[i]=1; 36 } 37 while(k--) 38 { 39 tp=read(); 40 if(tp==1) 41 { 42 a=read();b=read(); 43 x=getf(a),y=getf(b); 44 if(x==y) 45 { 46 if((r[a]-r[b]+2)%2==0) printf("Y\n");//是朋友關係 47 else printf("N\n"); 48 } 49 else 50 { 51 printf("N\n"); 52 f[x]=y; 53 r[x]=(r[b]-r[a]+132245)%2;//這裡的132245也是我閒的,只要是一個大於等於3的奇數即可(1+2,其中1為a與b的關係,2為防止負數) 54 if(r[x]==0) 55 { 56 size1[y]+=size1[x]; 57 size2[y]+=size2[x]; 58 } 59 else 60 { 61 size1[y]+=size2[x]; 62 size2[y]+=size1[x]; 63 } 64 } 65 } 66 else 67 { 68 a=read(); 69 x=getf(a); 70 if(r[a]==1) printf("%d\n",size2[x]-1); 71 else printf("%d\n",size1[x]-1); 72 } 73 } 74 return 0; 75 }
這道題多了一個隨時詢問並查集中朋友的個數,我們用兩個陣列來維護,一個是size1,一個是size2,size1[x]表示在以X為標識元素的集合中x朋友的個數,size2相反,便是x的敵人了。在合併時X到y時,需要判斷一下,如果r[x]=0即X與y是朋友關係,那x的朋友也是y的朋友,x的敵人也是y的敵人。合併執行size1[y]+=size1[x];size2[y]+=size2[x];即可,反之如果r[x]=1即X與y是敵人關係,那x的朋友是y的敵人,x的敵人是y的朋友。合併執行size1[y]+=size2[x];size2[y]+=size1[x];在
在最後輸出時,也要判斷一下,根據所求元素a與a的標識元素x的關係來確定,究竟是輸出size1還是size2(勿忘減一,不算自己)
最後再提一句,食物鏈還有高階版本:就是不止有三種動物了,有n種,在這個題目下精神分裂法就顯得不是那麼厲害了,需要兩兩合併,十分麻煩,用帶權並查集就快多了,只需在原題中改幾個數就可以了,幸虧在n<=10.哈哈哈哈