一、引文
上一篇博客——並查集(入門)寫完后,我對並查集有了基本的了解。
- 並查集可以判斷一幅無向圖中有幾個連通分量
- 並查集的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; }
上面那道題只是帶權並查集中的種類並查集的一道開胃小菜,接下來我們來談poj1182食物鏈這道題。
啊!!!我又做到一條和上面poj1073類似的題目,一塊講了,再講poj1182吧。
題意:給定n只蟲子,不同性別的可以在一起,相同性別的不能在一起。給你m對蟲子,判斷中間有沒有同性別在一起的。
因為剛剛做完了上道題,這道題一看到,就有了思路。沒得到一對蟲子,我們就判斷它們是否屬於同一連通分支,如果是,我們再判斷它們各自與根結點的性別是否相同,如果兩個性別都與根結點的性別相同,說明這一對蟲子的性別相同,答案就出來了。
具體程序只要稍稍修改一下上面的代碼即可。
說是如此說,我看到一個人的題解上寫的r[x]表示的是x和父節點的關系(而不是和根結點),具體為r[x] = 0表示x和父節點關系為同性,r[x] = 1表示x和父節點關系為異性。
但是我測試了一下,僅改動這個定義而不該懂其他代碼仍然AC。這說明或者我們前面的定義有問題或者這個定義有問題。話不多說,慢慢斟酌,先上這個代碼。
#include <cstdio> #include <cstring> #include <cstdlib> using namespace std; #define maxn 5005 int T,n,m; int pre[maxn]; //記錄父節點 int r[maxn]; //r[x]記錄x和父節點之間的關系 //其中r[x] = 0表示x和父節點關系為同性, r[x] = 1表示x和父節點關系為異性 void init() { for(int i=1;i<=n;i++){ pre[i] = i; r[i] = 0; } } //集合查找 int find(int x) { if(pre[x] == x) return x; int t = pre[x]; pre[x] = find(t); r[x] = (r[x] + r[t]) % 2; //根據老的父節點和新父節點關系,修改r[x]值 return pre[x]; } //合並集合 void unite(int x, int y) { int fx = find(x); int fy = find(y); pre[fx] = fy; r[fx] = ((r[x] - r[y] + 1) % 2); //唯一一處不同的換成 r[fx] = ((r[x] + r[y] + 1) % 2); 還是AC } int main() { scanf("%d", &T); int kase = 1; while(T--) { scanf("%d%d", &n, &m); init(); int a,b,flag = 0; while(m--) { scanf("%d%d", &a, &b); if(find(a) == find(b)) { if(r[a] == r[b]) //如果不滿足異性關系,有矛盾 flag = 1; } else { unite(a, b); } } printf("Scenario #%d:\n",kase++); if(flag==1) printf("Suspicious bugs found!\n\n"); else printf("No suspicious bugs found!\n\n"); } return 0; }
好啦好啦,不管是與父親結點還是祖先結點的關系,咱們都直接進入poj1182食物鏈吧。
題意:三類動物A、B、C構成食物鏈循環,告訴兩個動物的關系(同類或天敵),判斷有多少個關系是和正確的沖突。
哈哈,看到這道題,一下子就注意到三類動物,之前我們研究的是兩類,現在只是多了一類而已,真的是而已嗎?
顯然不是,如果我們依照上面的想法做,每來一對動物a b就將他們歸於同一個祖先下,最后,只剩下一個連通分支。如果我們仍然設r[x]表示x與根結點的關系,那么就有r[x]=0表示與根結點同類,r[x]=1表示與根結點異類(???貌似有三個類啊??這樣無法區分另外兩個類啊)。
既然之前的想法不能解決問題,我們回本溯源,帶權並查集的本質不就是多了各結點之間的關系嗎?不妨大膽設一些關系。
想着想着,因為每來一句話都要判斷其真假,所以必須要讓所有結點在一個連通分支內(如果分3個分支,來了新的一對動物讓你判斷,如果發生前后矛盾,你將不知道是之前是錯的還是現在的是錯的),所以數組r[]要保留,既然是食物鏈,不妨設r[x]=0表示x與根結點同類,r[x]=1表示x吃根結點,r[x]=-1表示x被根結點吃。這樣如果命令是1 a b時,我們便可以先判斷它們是否是同一連通分支的,如果是,就判斷它們各自與根結點的關系(即r[a]==r[b]),如果r[a]==r[b](即表示它們是同類),命令正確,反之說明這個命令是假話。當然,如果它們不是同一連通分支的,它們之間的關系就不好判定,命令真假也就無法確定,我們只能將兩個點連入同一連通分支,即直接調用unite函數。如果命令是2 a b時(即a吃b),我們仍然先判斷它們是否是同一連通分支的,如果是,我們可以窮舉r[a]和r[b]的值來判斷此話是否正確(若想命令正確只能是:r[a]=1,r[b]=0或r[a]=0,r[b]=-1或r[a]=-1,r[b]=1)。當然,如果不是同一連通分支的,同上,直接調用unite函數。
於是,我們可以得出下面的代碼:
cin>>D>>a>>b;
if(D==1){
if(find(a) == find(b)) //a和b屬於同一連通分量
if(r[a] != r[b]) //a和祖先的關系 與 b和祖先的關系 不一致
假話數量++; //即a和b的種類不一樣
else
unite(a,b);
}
if(D==2){
if(find(a)==find(b))
if(不是正確的對應關系)
假話數量++;
else
unite(a,b);
}
看到這,我們目標就有啦啦啦!!至於關系的得到就鎖定在find函數和unite函數內了,詳細的推導日后再說。
我懷着自信滿滿的心去編程,卻發現有個地方忽視了。。。當命令D a b過來時,如果a和b屬於同一連通分支還好說,如果不是,我們就要unite(a,b),但是unite的具體內容決定r[]的更新,所以與當前傳入的D有關。
於是,得到下面的代碼:
cin>>D>>a>>b;
int sum = 0; //假話數量
if(a>n || b>n || (D==2&&x==y)) //如果節點編號大於最大編號,或者自己吃自己,說謊
sum++;
else if(find(a) == find(b)){
if(D==1 && r[a]!=r[b]) //如果 x 和 y 不屬於同一類
sum++;
if(D==2 && (r[a]+1)%3!=r[b]) //如果a沒有吃b(注意要對應unite(x,y)的情況,否則一路WA到死啊!!!)
sum++;
}
else{
unite(a,b,d); //如果開始沒有關系,則建立關系
}
於是相應的代碼就有了:
#include<cstdio> #include<iostream> using namespace std; const int maxn = 50000+10; int pre[maxn]; //存父親節點 int r[maxn]; //存與根節點的關系 //r[x]==0表示x與根結點同種類 r[x]==1代表x會被根結點吃 r[x]==2代表x會吃根結點 int n,k; 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])%3; //此處模3 return pre[x]; } void unite(int x,int y,int d) { int fx = find(x); int fy = find(y); pre[fy] = fx; //合並樹 注意:被x吃,所以以 x的根為父 r[fy] = (r[x]-r[y]+3+(d-1))%3; //對應更新與父節點的關系 } int main() { cin>>n>>k; init(); int sum = 0,D,a,b; while(k--){ scanf("%d%d%d",&D,&a,&b); if(a>n || b>n || (a==b&&D==2)){ sum++; continue; } else if(find(a) == find(b)) //如果原來有關系,也就是在同一棵樹中,那么直接判斷是否說謊 { if(D == 1 && r[a] != r[b]) sum++; //如果a和b不屬於同一類 if(D == 2 && (r[a]+1)%3 != r[b]) sum++; // 如果a沒有吃b (注意要對應unite(a,b)的情況,否則一路WA到死啊!!!) } else unite(a,b,D); //如果開始沒有關系,則建立關系 } printf("%d\n",sum); return 0; }
寫到這,只缺一些關系的證明了。我也不想證,怎么辦??搬點別人的貨吧。
—————————————————————————————食物鏈別人講解————————————————————————————————
思路:把確定了相對關系的節點放在同一棵樹中
每個節點對應的 r[]值記錄他與根節點的關系:
0:同類,
1:被父親節點吃,
2: 吃父親節點
每次輸入一組數據 d, x, y判斷是否超過 N 后,先通過find()函數找他們的根節點從而判斷他們是否在同一棵樹中。(也就是是否有確定的關系)
1.如果在同一棵樹中find(x) == find(y):直接判斷是否說謊。
1)如果 d ==1,那么 x 與 y 應該是同類,他們的r[]應該相等
如果不相等,則說謊數 +1
2)如果 d==2,那么 x 應該吃了 y,也就是 (r[x]+1)%3 == r[y]
如果不滿足,則說謊數 +1
如何判斷 x 吃了 y 是 (r[x]+1)%3 == r[y],請看下圖:(PS:箭頭方向指向被吃方)

2.如果不在同一棵樹中:那么合並 x 與 y 分別所在的樹。
合並樹時要注意順序,我這里是把 x 樹的根當做主根,否則會
WA的很慘
注意:找父親節點時,要不斷更新 r[]的值。
這里有一個關系:如果 x 和y 為關系 r1, y 和 z 為關系 r2
那么 x 和z的關系就是 (r1+r2)%3
如何證明?
無非是3*3 = 9種情況而已
(a, b) 0:同類 、 1:a被b吃 、 2:a吃b
| (x, y) |
(y, z) |
(x,z) |
如何判斷 |
| 0 |
0 |
0 |
0+0 = 0 |
| 0 |
1 |
1 |
0+1 = 1 |
| 0 |
2 |
2 |
0+2 = 2 |
| 1 |
0 |
1 |
1+0 = 1 |
| 1 |
1 |
2 |
1+1 = 2 |
| 1 |
2 |
0 |
(1+2)%3 = 0 |
| 2 |
0 |
2 |
2+0 = 2 |
| 2 |
1 |
0 |
(2+1)%3 = 0 |
| 2 |
2 |
1 |
(2+2)%3 = 1 |
關於合並時r[]值的更新:
如果 d == 1則 x和y 是同類 ,那么 y 對 x 的關系是 0
如果 d == 2 則 x 吃了 y, 那么 y 對 x 的關系是 1, x 對 y 的關系是 2.
綜上所述 ,無論 d為1 或者是為 2, y 對 x 的關系都是 d-1
定義 :fx 為 x 的根點, fy 為 y 的根節點
合並時,如果把 y 樹合並到 x 樹中
如何求 fy 對 fx 的r[]關系?
fy 對 y 的關系為 3-r[y]
y 對 x 的關系為 d-1
x 對 fx 的關系為 r[x]
所以 fy 對 fx 的關系是(3-r[y] + d-1 + r[x])%3

到這,帶權並查集也告一段落了。並查集的學習給我的學習方法帶來了不小的收獲,一個解題過程的形成至關重要,先確定目標模板,然后思路就有了。
