本文是作為上一篇文章 《並查集演算法原理和改進》 的後續,焦點主要集中在一些並查集的應用上。材料主要是取自POJ,HDOJ上的一些演算法練習題。
首先還是回顧和總結一下關於並查集的幾個關鍵點:
1.以樹作為節點的組織結構,結構的形態很是否採取優化策略有很大關係,未進行優化的樹結構可能會是“畸形”樹(嚴重不平衡,頭重腳輕,退化成連結串列等),按尺寸(正規說法叫做秩,後文全部用秩來表示)進行平衡,同時輔以路徑壓縮後,樹結構會高度扁平化。
2.雖然組織結構比較複雜,資料表示方式卻十分簡潔,主要採用陣列作為其底層資料結構。一般會使用兩個陣列(parent-link array and size array),分別用來儲存當前節點的父親節點以及當前節點所代表子樹的秩。第一個陣列(parent-link array)無論是否優化,都需要使用,而第二個陣列(size array),在不需要按秩合併優化或者不需要儲存子樹的秩時,可以不使用。根據應用的不同,可能需要第三個陣列來儲存其它相關資訊,比如HDU-3635中提到的“轉移次數”。
3.主要操作包括兩部分,union以及find。union負責對兩顆樹進行合併,合併的過程中可以根據具體應用的性質選擇是否按秩優化。需要注意的是,執行合併操作之前,需要檢查待合併的兩個節點是否已經存在於同一顆樹中,如果兩個節點已經在一棵樹中了,就沒有合併的必要了。這是通過比較兩個節點所在樹的根節點來實現的,而尋找根節點的功能,自然是由find來完成了。find通過parent-link陣列中的資訊來找到指定節點的根節點,同樣地,也可以根據應用的具體特徵,選擇是否採用路徑壓縮這一優化手段。然而在需要儲存每個節點代表子樹的秩的時候,則無法採用路徑壓縮,因為這樣會破壞掉非根節點的尺寸資訊(注意這裡的“每個”,一般而言,在按秩合併的時候,需要的資訊僅僅是根節點的秩,這時,路徑壓縮並無影響,路徑壓縮影響的只是非根節點的秩資訊)。
以上就是我認為並查集中存在的幾個關鍵點。關於並查集更詳盡的演化過程,可以參考上一篇關於並查集的文章:《並查集演算法原理和改進》
言歸正傳,來看幾個利用並查集來解決問題的例子:
(說明:除了第一個問題貼了完整的程式碼,後面的問題都只會貼出關鍵部分的程式碼)
HDU-1213 How many tables
問題的描述是這樣的:
Today is Ignatius’ birthday. He invites a lot of friends. Now it’s dinner time. Ignatius wants to know how many tables he needs at least. You have to notice that not all the friends know each other, and all the friends do not want to stay with strangers.
One important rule for this problem is that if I tell you A knows B, and B knows C, that means A, B, C know each other, so they can stay in one table.
For example: If I tell you A knows B, B knows C, and D knows E, so A, B, C can stay in one table, and D, E have to stay in the other one. So Ignatius needs 2 tables at least.
對這個問題抽象之後,就是要求進行若干次union操作之後,還會剩下多少顆樹(或者說還剩下多少Connected Components)。反映到這個例子中,就是要求有多少“圈子”。其實,這也是社交網路中的最基本的功能,每次系統向你推薦的那些好友一般而言,會跟你在一個“圈子”裡面,換言之,也就是你可能認識的人,以並查集的視角來看這層關係,就是你們掛在同一顆樹上。
給出實現程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; public class Main { public static void main(String[] args) throws NumberFormatException, IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); PrintWriter out = new PrintWriter(System.out); int totalCases = Integer.parseInt(br.readLine()); WeightedQUWithPathCompression uf; String[] parts; while (totalCases > 0) { parts = br.readLine().split(" "); // based on 1, not 0 uf = new WeightedQUWithPathCompression( Integer.parseInt(parts[0]) + 1); // construct the uf int tuples = Integer.parseInt(parts[1]); while (tuples > 0) { parts = br.readLine().split(" "); uf.union(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); tuples--; } out.println(uf.count() - 1); br.readLine(); totalCases--; } out.flush(); } } class WeightedQUWithPathCompression { private int count; private int[] id; private int[] size; public WeightedQUWithPathCompression(int N) { this.count = N; this.id = new int[N]; this.size = new int[N]; for (int i = 0; i < this.count; i++) { id[i] = i; size[i] = 1; } } private int find(int p) { while (p != id[p]) { id[p] = id[id[p]]; // 路徑壓縮,會破壞掉當前節點的父節點的尺寸資訊,因為壓縮後,當前節點的父節點已經變了 p = id[p]; } return p; } public void union(int p, int q) { int pCom = this.find(p); int qCom = this.find(q); if (pCom == qCom) { return; } // 按秩進行合併 if (size[pCom] > size[qCom]) { id[qCom] = pCom; size[pCom] += size[qCom]; } else { id[pCom] = qCom; size[qCom] += size[pCom]; } // 每次合併之後,樹的數量減1 count--; } public int count() { return this.count; } } |
最後,通過呼叫count方法獲取的返回值就是樹的數量,也就是“圈子”的數量。
根據問題的具體特性,上面同時採用了兩種優化策略,即按秩合併以及路徑壓縮。因為問題本身對合並的先後關係以及子樹的秩這類資訊不敏感。然而,並不是所有的問題都這樣,比如下面這一道題目,他對合並的先後順序就有要求:
HDU-3635 Dragon Balls:
http://acm.hdu.edu.cn/showproblem.php?pid=3635
題意:起初球i是被放在i號城市的,在年代更迭,世事變遷的情況下,球被轉移了,而且轉移的時候,連帶該城市的所有球都被移動了:T A B(A球所在的城市的所有球都被移動到了B球所在的城市),Q A(問:A球在那城市?A球所在城市有多少個球呢?A球被轉移了多少次呢?)
(上面題意的描述摘自:http://www.cnblogs.com/Shirlies/archive/2012/03/06/2382118.html)
在這道題中,對子樹進行合併時,就不能按秩進行合併,因為合併是有先後關係的。
我們重點關注一下要回答的問題是什麼,比如Q A代表的問題就是:
A球在哪裡? — 這個問題好回答,A球所在的城市就是該子樹的根節點,即find方法的返回值。
A球所在的城市有多少個球? — 同樣地,這個問題的答案就是size陣列中對應位置的資訊,雖然本題不能按秩進行合併優化,但是秩還是需要被儲存下來的。
A球被轉移了多少次呢? — 這個問題畫張圖,就比較好理解了:
首先將球1所在城市的所有球轉移到球2所在的城市中,即城市2,然後將球1所在城市的所有球轉移到球3所在的城市中,即城市3。顯然,在第二步中,1球已經不在城市1中,因為其在第一步中已經轉移到城市2了。然後第二步實際就是將城市2中的所有球(包括球1和球2)都轉移到城市3中。
緊接著,將1球所在城市的球全部轉移(包括球1,2,3)到球4所在的城市中,即是將3和4進行合併。這個時候如果直接進行合併的話,會得到一個連結串列狀的結構,這種結構使我們一直都力求避免的,所以可以採用前面使用的路徑壓縮排行優化。路徑壓縮的具體做法就不贅述了。現在需要考慮的是,經過這3輪合併,球1到底移動了多少次?如果從最後的結果圖來看,球1最後到城市4,應該移動了2次,即1->3, 3->4。但是,仔細想想就會發現,這是不正確的。
因為在T1 2中球1首先移動到了城市2,然後T 1 3,表示1球所在的城市中的所有球被移動到了城市3中,即城市2中的球移動到城市3中,這會對1球進行一次移動。以此類推,最後在T 1 4中,1球從城市3中移動到了城市4中,又發生了一次移動,因此,1球一共移動了3次,1->2, 2->3, 3->4。那麼這就存在問題了,至少在最後的圖中,這一點很不直觀,因為從1到4的路徑上,已經沒有2的蹤跡了。
顯然,這是路徑壓縮帶來的副作用。因為採用了路徑壓縮,所以對樹結構造成了一些破壞,具體而言,是能夠推匯出球的轉移次數的資訊被破壞了。試想一下,如果沒有進行路徑壓縮,轉移次數實際上是很直觀的,從待求節點到根節點走過的路徑數,就是轉移次數。
所以為了解決引入路徑壓縮帶來的問題,需要引入第三個陣列來儲存每個球的轉移次數。結合題意,每次在進行轉移的時候,是轉移該球所在城市中所有的球到目標球所在的城市,把這句話抽象一下,就是隻有根節點才能夠進行合併。因此,現有的union方法還是適用的,因為它在進行真正的合併之前,還是需要首先找到兩個待合併節點的根節點。然後合併的時候,將第一個球所在城市的的號碼的轉移次數加1。按照這種想法,實現程式碼為:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private static void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 不能進行按秩合併,且在合併時,對第一個球的轉移次數進行遞增 id[pRoot] = qRoot; trans[pRoot]++; size[qRoot] += size[pRoot]; } |
但是跟蹤一下以上程式碼的呼叫過程不難發現,最後的球1,2,3,4的轉移次數分別為1,1,1,0(唯一對trans陣列進行影響的操作目前只存在於union方法中,見上)。顯然,這是不正確的,正確的轉移次數應該是3,2,1,0。那麼是什麼地方出了岔子呢,還是看看路徑壓縮就明白了,在路徑壓縮的時候,只顧著壓縮,而沒有對轉移次數進行更新。
那麼如何進行更新呢?看看上圖,1本來是2的孩子,現在卻成了3的孩子,跳過了2,因此可以看成,1->2->3的路徑被壓縮成了1->3,即2->3的這條路徑被壓縮了。被壓縮在了1->3中,因此更新的操作也就有了基本的想法,我們可以講被壓縮的那條路徑中的資訊增加到壓縮後的結果路徑中,對應前面的例子,我們需要把2->3的資訊給新增到1->3,用程式碼來表示的話,就是:
trans[1] += trans[2];
一般化後,實現程式碼如下所示:
1 2 3 4 5 6 7 |
private static int find(int q) { while (id[q] != id[id[q]]) { //如果q不是其所在子樹的根節點的直接孩子 trans[q] += trans[id[q]]; //更新trans陣列,將q的父節點的轉移數新增到q的轉移數中 id[q] = id[id[q]]; //對其父節點到其爺爺節點之間的路徑進行壓縮 } return id[q]; } |
最後,如果需要獲得球A的轉移次數,直接獲取trans[A]就OK了。
HDU-1856 More is better
這道題目的目的是想知道經過一系列的合併操作之後,查詢在所有的子樹中,秩的最大值是多少,簡而言之,就是最大的那顆子樹包含了多少個節點。
很顯然,這個問題也能夠同時使用兩種優化策略,只不過因為要求最大秩的值,需要有一個變數來記錄。那麼在哪個地方來更新它是最好的呢?我們知道,在按秩進行合併的時候,需要比較兩顆待合併子樹的秩,因此可以順帶的將對秩的最大值的更新也放在這裡進行,實現程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
private static void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } if (sz[pRoot] > sz[qRoot]) { id[qRoot] = pRoot; sz[pRoot] += sz[qRoot]; if (sz[pRoot] > max) { // 如果合併後的樹的秩比當前最大秩還要大,替換之 max = sz[pRoot]; } } else { id[pRoot] = qRoot; sz[qRoot] += sz[pRoot]; if (sz[qRoot] > max) { // 如果合併後的樹的秩比當前最大秩還要大,替換之 max = sz[qRoot]; } } } |
這樣,在完成了所有的合併操作之後,max中儲存的即為所需要的資訊。
HDU-1272 | HDU-1325 小希的迷宮 | Is it a tree ?
http://acm.hdu.edu.cn/showproblem.php?pid=1272
http://acm.hdu.edu.cn/showproblem.php?pid=1325
這兩個問題都是判斷是否合併後的結構是一棵樹,即結構中應該沒有環路,除此之外,還有邊數和頂點數量的之間的關係,應該滿足edges + 1 = nodes。
對於並查集,後者可以通過檢查最後的connected components的數量是否為1來確定。
當然,兩者在題目描述上還是有一定的區別,前者是無向圖,後者是有向圖。但是對於使用並查集來實現時,這一點的區別僅僅體現在合併過程無法按秩優化了。其實,如果能夠採用路徑壓縮,按秩優化的效果就不那麼明顯了,因為每次進行查詢操作的時候,會對被查詢的節點進行路徑壓縮(參見find方法),可以說這是一種“懶優化”,或者叫做“按需優化”。
而按秩合併則是一個主動優化的過程,每次進行合併的時候都會進行。而採用按秩合併優化,需要額外一個儲存size資訊的陣列,在一些應用場景中,對size資訊並不在意,因此為了實現可選的優化方法而增加空間複雜度,就有一些得不償失了。並且,對於按秩合併以及路徑壓縮到底能夠提高多少效率,我們目前也並不清楚,這裡做個記號,以後有空了寫一篇相關的文章。
扯遠了,回到正題。前面提到了判斷一張圖是否是一顆樹的兩個關鍵點:
- 不存在環路(對於有向圖,不存在環路也就意味著不存在強連通子圖)
- 滿足邊數加一等於頂點數的規律(不考慮重邊和指向自身的邊)
第一條,在並查集中應該如何實現呢?
現在我們對並查集也有一定的認識了,其實很容易我們就能夠想出,當兩個頂點的根節點相同時,就代表新增了這一條邊後會出現環路。這很好解釋,如果兩個頂點的根節點是相同的,代表這兩個頂點已經是連通的了,對於已經連通的兩個頂點,再新增一條邊,必然會產生環路。
第二條呢?
圖中的邊數,我們可以在每次進行真正合並操作之前(也就是,在確認兩個待合併的頂點的根節點不相同時)進行記錄。然後頂點數,也就是整個合併過程中參與進來的頂點個數了,可以使用一個布林陣列來進行記錄,出現後將相應位置設為true,最後進行一輪統計即可。
相關實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private static void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { valid = false; // 此處的valid是一個boolean變數,置為false表示改圖不是一顆樹 return; } mark[p] = true; mark[q] = true; // p和q參與到最後的頂點數量的統計 edges++; // 在合併之前,將邊的數量遞增 id[qRoot] = pRoot; } |
就目前看來,一般問題都是圍繞著並查集的兩個主要操作,union和find做文章,根據具體應用,增加一些資訊,增加一些邏輯,例如上題中的轉移次數,或者是根據問題特徵選擇使用合適的優化策略,按秩合併以及路徑壓縮。
並查集是我比較喜歡的一種資料結構和演算法,很多實際問題都能夠利用它給出高效而簡潔的解決方案。後續還會陸續介紹一些有代表性的,同時難度也更大的題目,敬請關注。