Union-Find算法應(yīng)用

讀完本文,你可以去力扣拿下如下題目:

130.被圍繞的區(qū)域

990.等式方程的可滿足性

-----------

希望你已經(jīng)讀了這篇題解 Union-Find 算法詳解

上篇文章很多讀者對(duì)于 Union-Find 算法的應(yīng)用表示很感興趣,這篇文章就拿幾道 LeetCode 題目來講講這個(gè)算法的巧妙用法。

首先,復(fù)習(xí)一下,Union-Find 算法解決的是圖的動(dòng)態(tài)連通性問題,這個(gè)算法本身不難,能不能應(yīng)用出來主要是看你抽象問題的能力,是否能夠把原始問題抽象成一個(gè)有關(guān)圖論的問題。

先復(fù)習(xí)一下上篇文章寫的算法代碼,回答讀者提出的幾個(gè)問題:

class UF {
    // 記錄連通分量個(gè)數(shù)
    private int count;
    // 存儲(chǔ)若干棵樹
    private int[] parent;
    // 記錄樹的“重量”
    private int[] size;

    public UF(int n) {
        this.count = n;
        parent = new int[n];
        size = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            size[i] = 1;
        }
    }
    
    /* 將 p 和 q 連通 */
    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        if (rootP == rootQ)
            return;
        
        // 小樹接到大樹下面,較平衡
        if (size[rootP] > size[rootQ]) {
            parent[rootQ] = rootP;
            size[rootP] += size[rootQ];
        } else {
            parent[rootP] = rootQ;
            size[rootQ] += size[rootP];
        }
        count--;
    }

    /* 判斷 p 和 q 是否互相連通 */
    public boolean connected(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        // 處于同一棵樹上的節(jié)點(diǎn),相互連通
        return rootP == rootQ;
    }

    /* 返回節(jié)點(diǎn) x 的根節(jié)點(diǎn) */
    private int find(int x) {
        while (parent[x] != x) {
            // 進(jìn)行路徑壓縮
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }
    
    public int count() {
        return count;
    }
}

算法的關(guān)鍵點(diǎn)有 3 個(gè):

1、用 parent 數(shù)組記錄每個(gè)節(jié)點(diǎn)的父節(jié)點(diǎn),相當(dāng)于指向父節(jié)點(diǎn)的指針,所以 parent 數(shù)組內(nèi)實(shí)際存儲(chǔ)著一個(gè)森林(若干棵多叉樹)。

2、用 size 數(shù)組記錄著每棵樹的重量,目的是讓 union 后樹依然擁有平衡性,而不會(huì)退化成鏈表,影響操作效率。

3、在 find 函數(shù)中進(jìn)行路徑壓縮,保證任意樹的高度保持在常數(shù),使得 unionconnected API 時(shí)間復(fù)雜度為 O(1)。

有的讀者問,既然有了路徑壓縮,size 數(shù)組的重量平衡還需要嗎?這個(gè)問題很有意思,因?yàn)槁窂綁嚎s保證了樹高為常數(shù)(不超過 3),那么樹就算不平衡,高度也是常數(shù),基本沒什么影響。

我認(rèn)為,論時(shí)間復(fù)雜度的話,確實(shí),不需要重量平衡也是 O(1)。但是如果加上 size 數(shù)組輔助,效率還是略微高一些,比如下面這種情況:

image

如果帶有重量平衡優(yōu)化,一定會(huì)得到情況一,而不帶重量?jī)?yōu)化,可能出現(xiàn)情況二。高度為 3 時(shí)才會(huì)觸發(fā)路徑壓縮那個(gè) while 循環(huán),所以情況一根本不會(huì)觸發(fā)路徑壓縮,而情況二會(huì)多執(zhí)行很多次路徑壓縮,將第三層節(jié)點(diǎn)壓縮到第二層。

也就是說,去掉重量平衡,雖然對(duì)于單個(gè)的 find 函數(shù)調(diào)用,時(shí)間復(fù)雜度依然是 O(1),但是對(duì)于 API 調(diào)用的整個(gè)過程,效率會(huì)有一定的下降。當(dāng)然,好處就是減少了一些空間,不過對(duì)于 Big O 表示法來說,時(shí)空復(fù)雜度都沒變。

下面言歸正傳,來看看這個(gè)算法有什么實(shí)際應(yīng)用。

PS:我認(rèn)真寫了 100 多篇原創(chuàng),手把手刷 200 道力扣題目,全部發(fā)布在 labuladong的算法小抄,持續(xù)更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

一、DFS 的替代方案

很多使用 DFS 深度優(yōu)先算法解決的問題,也可以用 Union-Find 算法解決。

比如第 130 題,被圍繞的區(qū)域:給你一個(gè) M×N 的二維矩陣,其中包含字符 XO,讓你找到矩陣中四面X 圍住的 O,并且把它們替換成 X。

void solve(char[][] board);

注意哦,必須是四面被圍的 O 才能被換成 X,也就是說邊角上的 O 一定不會(huì)被圍,進(jìn)一步,與邊角上的 O 相連的 O 也不會(huì)被 X 圍四面,也不會(huì)被替換。

image

PS:這讓我想起小時(shí)候玩的棋類游戲「黑白棋」,只要你用兩個(gè)棋子把對(duì)方的棋子夾在中間,對(duì)方的子就被替換成你的子??梢?,占據(jù)四角的棋子是無敵的,與其相連的邊棋子也是無敵的(無法被夾掉)。

解決這個(gè)問題的傳統(tǒng)方法也不困難,先用 for 循環(huán)遍歷棋盤的四邊,用 DFS 算法把那些與邊界相連的 O 換成一個(gè)特殊字符,比如 #;然后再遍歷整個(gè)棋盤,把剩下的 O 換成 X,把 # 恢復(fù)成 O。這樣就能完成題目的要求,時(shí)間復(fù)雜度 O(MN)。

這個(gè)問題也可以用 Union-Find 算法解決,雖然實(shí)現(xiàn)復(fù)雜一些,甚至效率也略低,但這是使用 Union-Find 算法的通用思想,值得一學(xué)。

你可以把那些不需要被替換的 O 看成一個(gè)擁有獨(dú)門絕技的門派,它們有一個(gè)共同祖師爺叫 dummy,這些 Odummy 互相連通,而那些需要被替換的 Odummy 不連通

image

這就是 Union-Find 的核心思路,明白這個(gè)圖,就很容易看懂代碼了。

首先要解決的是,根據(jù)我們的實(shí)現(xiàn),Union-Find 底層用的是一維數(shù)組,構(gòu)造函數(shù)需要傳入這個(gè)數(shù)組的大小,而題目給的是一個(gè)二維棋盤。

這個(gè)很簡(jiǎn)單,二維坐標(biāo) (x,y) 可以轉(zhuǎn)換成 x * n + y 這個(gè)數(shù)(m 是棋盤的行數(shù),n 是棋盤的列數(shù))。敲黑板,這是將二維坐標(biāo)映射到一維的常用技巧

其次,我們之前描述的「祖師爺」是虛構(gòu)的,需要給他老人家留個(gè)位置。索引 [0.. m*n-1] 都是棋盤內(nèi)坐標(biāo)的一維映射,那就讓這個(gè)虛擬的 dummy 節(jié)點(diǎn)占據(jù)索引 m * n 好了。

void solve(char[][] board) {
    if (board.length == 0) return;

    int m = board.length;
    int n = board[0].length;
    // 給 dummy 留一個(gè)額外位置
    UF uf = new UF(m * n + 1);
    int dummy = m * n;
    // 將首列和末列的 O 與 dummy 連通
    for (int i = 0; i < m; i++) {
        if (board[i][0] == 'O')
            uf.union(i * n, dummy);
        if (board[i][n - 1] == 'O')
            uf.union(i * n + n - 1, dummy);
    }
    // 將首行和末行的 O 與 dummy 連通
    for (int j = 0; j < n; j++) {
        if (board[0][j] == 'O')
            uf.union(j, dummy);
        if (board[m - 1][j] == 'O')
            uf.union(n * (m - 1) + j, dummy);
    }
    // 方向數(shù)組 d 是上下左右搜索的常用手法
    int[][] d = new int[][]{{1,0}, {0,1}, {0,-1}, {-1,0}};
    for (int i = 1; i < m - 1; i++) 
        for (int j = 1; j < n - 1; j++) 
            if (board[i][j] == 'O')
                // 將此 O 與上下左右的 O 連通
                for (int k = 0; k < 4; k++) {
                    int x = i + d[k][0];
                    int y = j + d[k][1];
                    if (board[x][y] == 'O')
                        uf.union(x * n + y, i * n + j);
                }
    // 所有不和 dummy 連通的 O,都要被替換
    for (int i = 1; i < m - 1; i++) 
        for (int j = 1; j < n - 1; j++) 
            if (!uf.connected(dummy, i * n + j))
                board[i][j] = 'X';
}

這段代碼很長(zhǎng),其實(shí)就是剛才的思路實(shí)現(xiàn),只有和邊界 O 相連的 O 才具有和 dummy 的連通性,他們不會(huì)被替換。

說實(shí)話,Union-Find 算法解決這個(gè)簡(jiǎn)單的問題有點(diǎn)殺雞用牛刀,它可以解決更復(fù)雜,更具有技巧性的問題,主要思路是適時(shí)增加虛擬節(jié)點(diǎn),想辦法讓元素「分門別類」,建立動(dòng)態(tài)連通關(guān)系。

PS:我認(rèn)真寫了 100 多篇原創(chuàng),手把手刷 200 道力扣題目,全部發(fā)布在 labuladong的算法小抄,持續(xù)更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

二、判定合法等式

這個(gè)問題用 Union-Find 算法就顯得十分優(yōu)美了。題目是這樣:

給你一個(gè)數(shù)組 equations,裝著若干字符串表示的算式。每個(gè)算式 equations[i] 長(zhǎng)度都是 4,而且只有這兩種情況:a==b 或者 a!=b,其中 a,b 可以是任意小寫字母。你寫一個(gè)算法,如果 equations 中所有算式都不會(huì)互相沖突,返回 true,否則返回 false。

比如說,輸入 ["a==b","b!=c","c==a"],算法返回 false,因?yàn)檫@三個(gè)算式不可能同時(shí)正確。

再比如,輸入 ["c==c","b==d","x!=z"],算法返回 true,因?yàn)檫@三個(gè)算式并不會(huì)造成邏輯沖突。

我們前文說過,動(dòng)態(tài)連通性其實(shí)就是一種等價(jià)關(guān)系,具有「自反性」「?jìng)鬟f性」和「對(duì)稱性」,其實(shí) == 關(guān)系也是一種等價(jià)關(guān)系,具有這些性質(zhì)。所以這個(gè)問題用 Union-Find 算法就很自然。

核心思想是,equations 中的算式根據(jù) ==!= 分成兩部分,先處理 == 算式,使得他們通過相等關(guān)系各自勾結(jié)成門派;然后處理 != 算式,檢查不等關(guān)系是否破壞了相等關(guān)系的連通性

boolean equationsPossible(String[] equations) {
    // 26 個(gè)英文字母
    UF uf = new UF(26);
    // 先讓相等的字母形成連通分量
    for (String eq : equations) {
        if (eq.charAt(1) == '=') {
            char x = eq.charAt(0);
            char y = eq.charAt(3);
            uf.union(x - 'a', y - 'a');
        }
    }
    // 檢查不等關(guān)系是否打破相等關(guān)系的連通性
    for (String eq : equations) {
        if (eq.charAt(1) == '!') {
            char x = eq.charAt(0);
            char y = eq.charAt(3);
            // 如果相等關(guān)系成立,就是邏輯沖突
            if (uf.connected(x - 'a', y - 'a'))
                return false;
        }
    }
    return true;
}

至此,這道判斷算式合法性的問題就解決了,借助 Union-Find 算法,是不是很簡(jiǎn)單呢?

PS:我認(rèn)真寫了 100 多篇原創(chuàng),手把手刷 200 道力扣題目,全部發(fā)布在 labuladong的算法小抄,持續(xù)更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

三、簡(jiǎn)單總結(jié)

使用 Union-Find 算法,主要是如何把原問題轉(zhuǎn)化成圖的動(dòng)態(tài)連通性問題。對(duì)于算式合法性問題,可以直接利用等價(jià)關(guān)系,對(duì)于棋盤包圍問題,則是利用一個(gè)虛擬節(jié)點(diǎn),營(yíng)造出動(dòng)態(tài)連通特性。

另外,將二維數(shù)組映射到一維數(shù)組,利用方向數(shù)組 d 來簡(jiǎn)化代碼量,都是在寫算法時(shí)常用的一些小技巧,如果沒見過可以注意一下。

很多更復(fù)雜的 DFS 算法問題,都可以利用 Union-Find 算法更漂亮的解決。LeetCode 上 Union-Find 相關(guān)的問題也就二十多道,有興趣的讀者可以去做一做。

_____________

我的 在線電子書 有 100 篇原創(chuàng)文章,手把手帶刷 200 道力扣題目,建議收藏!對(duì)應(yīng)的 GitHub 算法倉庫 已經(jīng)獲得了 70k star,歡迎標(biāo)星!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容