並查集(Union-Find) 應用舉例 --- 基礎篇

本文是作爲上一篇文章 《並查集算法原理和改進》 的後續,焦點主要集中在一些並查集的應用上。材料主要是取自POJ,HDOJ上的一些算法練習題。

 

首先還是回顧和總結一下關於並查集的幾個關鍵點:

1.以樹作爲節點的組織結構,結構的形態很是否採取優化策略有很大關係,未進行優化的樹結構可能會是“畸形”樹(嚴重不平衡,頭重腳輕,退化成鏈表等),按尺寸(正規說法叫做秩,後文全部用秩來表示)進行平衡,同時輔以路徑壓縮後,樹結構會高度扁平化。

2.雖然組織結構比較複雜,數據表示方式卻十分簡潔,主要採用數組作爲其底層數據結構。一般會使用兩個數組(parent-link array and size array),分別用來保存當前節點的父親節點以及當前節點所代表子樹的秩。第一個數組(parent-link array)無論是否優化,都需要使用,而第二個數組(sizearray),在不需要按秩合併優化或者不需要保存子樹的秩時,可以不使用。根據應用的不同,可能需要第三個數組來保存其它相關信息,比如HDU-3635中提到的“轉移次數”。

3.主要操作包括兩部分,union以及find。union負責對兩顆樹進行合併,合併的過程中可以根據具體應用的性質選擇是否按秩優化。需要注意的是,執行合併操作之前,需要檢查待合併的兩個節點是否已經存在於同一顆樹中,如果兩個節點已經在一棵樹中了,就沒有合併的必要了。這是通過比較兩個節點所在樹的根節點來實現的,而尋找根節點的功能,自然是由find來完成了。find通過parent-link數組中的信息來找到指定節點的根節點,同樣地,也可以根據應用的具體特徵,選擇是否採用路徑壓縮這一優化手段。然而在需要保存每個節點代表子樹的秩的時候,則無法採用路徑壓縮,因爲這樣會破壞掉非根節點的尺寸信息(注意這裏的“每個”,一般而言,在按秩合併的時候,需要的信息僅僅是根節點的秩,這時,路徑壓縮並無影響,路徑壓縮影響的只是非根節點的秩信息)。

以上就是我認爲並查集中存在的幾個關鍵點。關於並查集更詳盡的演化過程,可以參考上一篇關於並查集的文章:《並查集算法原理和改進

言歸正傳,來看幾個利用並查集來解決問題的例子:

(說明:除了第一個問題貼了完整的代碼,後面的問題都只會貼出關鍵部分的代碼)

HDU-1213 How many tables

問題的描述是這樣的:

Today is Ignatius' birthday. He invitesa lot of friends. Now it's dinner time. Ignatius wants to know how many tableshe 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 Bknows 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 canstay in one table, and D, E have to stay in the other one. So Ignatius needs 2tables at least.

對這個問題抽象之後,就是要求進行若干次union操作之後,還會剩下多少顆樹(或者說還剩下多少Connected Components)。反映到這個例子中,就是要求有多少“圈子”。其實,這也是社交網絡中的最基本的功能,每次系統向你推薦的那些好友一般而言,會跟你在一個“圈子”裏面,換言之,也就是你可能認識的人,以並查集的視角來看這層關係,就是你們掛在同一顆樹上。

給出實現代碼如下:

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)

 

在這道題中,對子樹進行合併時,就不能按秩進行合併,因爲合併是有先後關係的。

我們重點關注一下要回答的問題是什麼,比如QA代表的問題就是:

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。按照這種想法,實現代碼爲:

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];

一般化後,實現代碼如下所示:

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

這道題目的目的是想知道經過一系列的合併操作之後,查詢在所有的子樹中,秩的最大值是多少,簡而言之,就是最大的那顆子樹包含了多少個節點。

很顯然,這個問題也能夠同時使用兩種優化策略,只不過因爲要求最大秩的值,需要有一個變量來記錄。那麼在哪個地方來更新它是最好的呢?我們知道,在按秩進行合併的時候,需要比較兩顆待合併子樹的秩,因此可以順帶的將對秩的最大值的更新也放在這裏進行,實現代碼如下:

    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 atree ?

http://acm.hdu.edu.cn/showproblem.php?pid=1272

http://acm.hdu.edu.cn/showproblem.php?pid=1325

這兩個問題都是判斷是否合併後的結構是一棵樹,即結構中應該沒有環路,除此之外,還有邊數和頂點數量的之間的關係,應該滿足edges + 1 = nodes。

對於並查集,後者可以通過檢查最後的connectedcomponents的數量是否爲1來確定。

當然,兩者在題目描述上還是有一定的區別,前者是無向圖,後者是有向圖。但是對於使用並查集來實現時,這一點的區別僅僅體現在合併過程無法按秩優化了。其實,如果能夠採用路徑壓縮,按秩優化的效果就不那麼明顯了,因爲每次進行查詢操作的時候,會對被查詢的節點進行路徑壓縮(參見find方法),可以說這是一種“懶優化”,或者叫做“按需優化”。而按秩合併則是一個主動優化的過程,每次進行合併的時候都會進行。而採用按秩合併優化,需要額外一個保存size信息的數組,在一些應用場景中,對size信息並不在意,因此爲了實現可選的優化方法而增加空間複雜度,就有一些得不償失了。並且,對於按秩合併以及路徑壓縮到底能夠提高多少效率,我們目前也並不清楚,這裏做個記號,以後有空了寫一篇相關的文章。

扯遠了,回到正題。前面提到了判斷一張圖是否是一顆樹的兩個關鍵點:

1.  不存在環路(對於有向圖,不存在環路也就意味着不存在強連通子圖)

2.  滿足邊數加一等於頂點數的規律(不考慮重邊和指向自身的邊)

第一條,在並查集中應該如何實現呢?

現在我們對並查集也有一定的認識了,其實很容易我們就能夠想出,當兩個頂點的根節點相同時,就代表添加了這一條邊後會出現環路。這很好解釋,如果兩個頂點的根節點是相同的,代表這兩個頂點已經是連通的了,對於已經連通的兩個頂點,再添加一條邊,必然會產生環路。

第二條呢?

圖中的邊數,我們可以在每次進行真正合並操作之前(也就是,在確認兩個待合併的頂點的根節點不相同時)進行記錄。然後頂點數,也就是整個合併過程中參與進來的頂點個數了,可以使用一個布爾數組來進行記錄,出現後將相應位置設爲true,最後進行一輪統計即可。

相關實現:

   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做文章,根據具體應用,增加一些信息,增加一些邏輯,例如上題中的轉移次數,或者是根據問題特徵選擇使用合適的優化策略,按秩合併以及路徑壓縮。

 

發佈了31 篇原創文章 · 獲贊 21 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章