LeetCode 力扣 130. 被圍繞的區域

題目描述(中等難度)

有一點點像圍棋,把被 X 圍起來的 O 變成 X,邊界的 O 一定不會被圍起來。如果 O 和邊界的 O 連通起來,那麼這些 O 就都算作不被圍起來,比如下邊的例子。

X X X X X
O O O X X
X X X O X
X O X X X

上邊的例子就只需要變化 1O

X X X X X
O O O X X
X X X X X
X O X X X

解法一

把相鄰的O 看作是連通的圖,然後從每一個 O 開始做 DFS

如果遍歷完成後沒有到達邊界的 O ,我們就把當前 O 改成 X

如果遍歷過程中到達了邊界的 O ,直接結束 DFS,當前的 O 就不用操作。

然後繼續考慮下一個 O,繼續做一次 DFS

public void solve(char[][] board) {
    int rows = board.length;
    if (rows == 0) {
        return;
    }
    int cols = board[0].length;
    //考慮除去邊界以外的所有 O
    for (int i = 1; i < rows - 1; i++) {
        for (int j = 1; j < cols - 1; j++) {
            if (board[i][j] == 'O') {
                //visited 用於記錄 DFS 過程中已經訪問過的節點
                HashSet<String> visited = new HashSet<>();
                //如果沒有到達邊界,就把當前 O 置爲 X
                if (!solveHelper(i, j, board, visited)) {
                    board[i][j] = 'X';
                }
            }
        }
    }
}

private boolean solveHelper(int row, int col, char[][] board, HashSet<String> visited) {
    //是否訪問過
    if (visited.contains(row + "@" + col)) {
        return false;
    }
    visited.add(row + "@" + col);
    
    //到達了 X 直接返回 false
    if (board[row][col] == 'X') {
        return false;
    }

    if (row == 0 || row == board.length - 1 || col == 0 || col == board[0].length - 1)     {
        return true;
    }
    
    //分別嘗試四個方向
    if (solveHelper(row - 1, col, board, visited)
        || solveHelper(row, col - 1, board, visited)
        || solveHelper(row + 1, col, board, visited)
        || solveHelper(row, col + 1, board, visited)) {
        return true;
    } else {
        return false;
    }
}

遺憾的是,到最後兩個 test 的時候超時了。

優化的的話,我嘗試了在每次 DFS 過程中,返回 true 之前,把當前的 rowcol 記錄下來,然後第二次遇到這些點的時候,就直接跳過 。

public void solve(char[][] board) {
    int rows = board.length;
    if (rows == 0) {
        return;
    }
    //記錄可以連通到邊界的 O
    HashSet<String> memoization = new HashSet<>();
    int cols = board[0].length;
    for (int i = 1; i < rows - 1; i++) {
        for (int j = 1; j < cols - 1; j++) {
            if (board[i][j] == 'O') {
                //如果當前位置的 O 被記錄過了,直接跳過
                if (memoization.contains(i + "@" + j)) {
                    continue;
                }
                HashSet<String> visited = new HashSet<>();
                if (!solveHelper(i, j, board, visited, memoization)) {
                    board[i][j] = 'X';
                }
            }
        }
    }
}

private boolean solveHelper(int row, int col, char[][] board, HashSet<String> visited,
                            HashSet<String> memoization) {
    if (visited.contains(row + "@" + col)) {
        return false;
    }
    visited.add(row + "@" + col);

    if (board[row][col] == 'X') {
        return false;
    }
    //當前位置可以連通到邊界,返回 true
    if (memoization.contains(row + "@" +col)) {
         return true;
    }
    if (row == 0 || row == board.length - 1 || col == 0 || col == board[0].length - 1)     {
        //當前位置可以連通道邊界,記錄下來
        memoization.add(row + "@" + col);
        return true;
    }

    if (solveHelper(row - 1, col, board, visited, memoization)
        || solveHelper(row, col - 1, board, visited, memoization)
        || solveHelper(row + 1, col, board, visited, memoization)
        || solveHelper(row, col + 1, board, visited, memoization)) {
         //當前位置可以連通道邊界,記錄下來
        memoization.add(row + "@" + col);
        return true;
    } else {
        return false;
    }

}

但沒什麼效果,依舊還是超時。

之前還考慮過能不能在遍歷過程中,返回 false 之前,直接把 O 改成 X。最後發現是不可以的,比如下邊的例子。

如果我們從橙色的 ODFS,然後沿着箭頭方向,到達最後一個 O 的時候,它的左邊上邊右邊都是 X ,根據代碼它就返回 false,此外它下邊是訪問過的節點也會返回 false,所以四個方向都返回 false,如果把它改成 X明顯是不對的。

解法二

解法一是從當前節點做 DFS ,然後看它是否能到達邊界的 O。那麼我們能不能把思路逆轉過來呢?

從邊界的 ODFS,然後把遇到的 O 都標記一下,這些 O 就是可以連通到邊界的。然後把邊界的所有的 O 都做一次 DFS ,把 DFS 過程的中的 O 做一下標記。最後我們只需要遍歷節點,把沒有標記過的 O 改成 X 就可以了。

標記的話,我們可以用一個 visited 二維數組,把訪問過的置爲 true

public void solve(char[][] board) {
    int rows = board.length;
    if (rows == 0) {
        return;
    }
    int cols = board[0].length;
    boolean[][] visited = new boolean[rows][cols];
    for (int i = 0; i < cols; i++) {
        //最上邊一行的所有 O 做 DFS
        if (board[0][i] == 'O') {
            dfs(0, i, board, visited);
        }
        //最下邊一行的所有 O 做 DFS
        if (board[board.length - 1][i] == 'O') {
            dfs(board.length - 1, i, board, visited);
        }

    }
    for (int i = 1; i < rows - 1; i++) {
        //最左邊一列的所有 O 做 DFS
        if (board[i][0] == 'O') {
            dfs(i, 0, board, visited);
        }
        //最右邊一列的所有 O 做 DFS
        if (board[i][board[0].length - 1] == 'O') {
            dfs(i, board[0].length - 1, board, visited);
        }
    }
    //把所有沒有標記過的 O 改爲 X
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            //省略了對 X 的判斷,把 X 變成 X 不影響結果
            if (!visited[i][j]) {
                board[i][j] = 'X';
            }
        }
    }
}

private void dfs(int i, int j, char[][] board, boolean[][] visited) {
    if (i < 0 || j < 0 || i == board.length || j == board[0].length) {
        return;
    }
    if (visited[i][j]) {
        return;
    }
    if (board[i][j] == 'O') {
        visited[i][j] = true;
        dfs(i + 1, j, board, visited);
        dfs(i, j + 1, board, visited);
        dfs(i, j - 1, board, visited);
        dfs(i - 1, j, board, visited);
    }

}

然後這個解法 AC 了,但空間複雜度可以優化一下,這個思想很多題用過了,比如 79 題

這裏的 visited 的二維數組我們可以不需要。我們可以先把連通的 O 改成 *,或者其他的字符。最後遍歷整個 board,遇到 * 就把它還原到 O 。遇到 O,因爲它沒有被修改成*,也就意味着它沒有連到邊界,就把它改成 X

public void solve(char[][] board) {
    int rows = board.length;
    if (rows == 0) {
        return;
    }
    int cols = board[0].length;
    for (int i = 0; i < cols; i++) {
        //最上邊一行的所有 O 做 DFS
        if (board[0][i] == 'O') {
            dfs(0, i, board);
        }
        //最下邊一行的所有 O 做 DFS
        if (board[board.length - 1][i] == 'O') {
            dfs(board.length - 1, i, board);
        }

    }
    for (int i = 1; i < rows - 1; i++) {
        //最左邊一列的所有 O 做 DFS
        if (board[i][0] == 'O') {
            dfs(i, 0, board);
        }
        //最右邊一列的所有 O 做 DFS
        if (board[i][board[0].length - 1] == 'O') {
            dfs(i, board[0].length - 1, board);
        }
    }
    //把所有沒有標記過的 O 改爲 X,標記過的還原
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            if (board[i][j] == '*') {
                board[i][j] = 'O';
            }else if(board[i][j] == 'O'){
                board[i][j] = 'X';
            }
        }
    }
}

private void dfs(int i, int j, char[][] board) {
    if (i < 0 || j < 0 || i == board.length || j == board[0].length) {
        return;
    }
    if (board[i][j] == '*') {
        return;
    }
    if (board[i][j] == 'O') {
        board[i][j] = '*';
        dfs(i + 1, j, board);
        dfs(i, j + 1, board);
        dfs(i, j - 1, board);
        dfs(i - 1, j, board);
    }

}

但是在逛 Disscuss 的時候發現有人提出來說,DFS 的解法可能導致棧溢出。

這個 解法 下的第一個評論,我把原文貼過來。

This is a DFS solution, but it may causes a stack overflow issue.

When you use DFS, it is tricky to use:

if(i>1)
check(vec,i-1,j,row,col);
if(j>1)
check(vec,i,j-1,row,col);

because it is more common to write like this:

if(i>=1)
check(vec,i-1,j,row,col);
if(j>=1)
check(vec,i,j-1,row,col);

Then I’ll explain it.

There is a test case like this:

OOOOOOOOOO
XXXXXXXXXO
OOOOOOOOOO
OXXXXXXXXX
OOOOOOOOOO
XXXXXXXXXO
OOOOOOOOOO
OXXXXXXXXX
OOOOOOOOOO
XXXXXXXXXO

To make it clear, I draw a 10x10 board, but it is actually a 250x250 board like this one.

When dfs function visit board[0][0], it ought to visit every grid marked ‘O’, thus lead to stack overflow(runtime error).

After you change “if(j>=1)” to “if(j>1)”, the DFS function won’t check board[i][0] (0<=i<=row-1), and then not all the grids marked ‘O’ will be visited when you dfs(board[0][0]).
Your code won’t cause stack overflow in this test case, but if we change the test case a little, it won’t work well.

Consider a test case like this:

OOOOOOOOOOOX
XXXXXXXXXXOX
XOOOOOOOOOOX
XOXXXXXXXXXX
XOOOOOOOOOOX
XXXXXXXXXXOX
XOOOOOOOOOOX
XOXXXXXXXXXX
XOOOOOOOOOOX
XXXXXXXXXXOX

I draw a 10x12 board, but it may be as large as the 250x250 board.

I believe that your code will get “runtime error” in this test case when tested in Leetcode system.

他的意思就是說,比如下邊的例子類型,假如是 250 × 250 大小的話,因爲我們做的是 DFS,一直壓棧的話就會造成溢出。

OOOOOOOOOOOX
XXXXXXXXXXOX
XOOOOOOOOOOX
XOXXXXXXXXXX
XOOOOOOOOOOX
XXXXXXXXXXOX
XOOOOOOOOOOX
XOXXXXXXXXXX
XOOOOOOOOOOX
XXXXXXXXXXOX

但是我的代碼已經通過了呀,一個可能的原因就是 leetcode 升級了,因爲這是 2015 年的評論,現在是 2019 年,壓棧的大小足夠大了,只要有遞歸出口,就不用擔心壓棧放不下了。我就好奇的想測一下 leetcode 的壓棧到底有多大。寫了一個簡單的遞歸代碼。

public void solve(char[][] board) {
    dfs(2677574);
}

private int dfs(int count) {
    if (count == 0) {
        return 1;
    }
    return dfs(count - 1);

}

然後一開始傳一個較大的數字,然後利用二分法,開始不停試探那個溢出的臨界點是多少。經過多次嘗試,發現 2677574 的話就會造成溢出。2677573 就不會造成溢出。本以爲這樣就結束了,然後準備截圖總結的時候發現。取 2677574 竟然不溢出了,2677573 反而溢出了。

同一個數字,一會兒溢出一會兒不溢出,那就沒辦法得出結論了。那可能棧的大小和它服務器當前的承載的能力有關了,不過一般情況的棧的大小肯定足夠解決題目了。

那麼退一步講,如果它的棧的限定很小,這裏的 DFS 行不通,我們有什麼解決方案嗎?

這裏我想到兩種,一種就是用棧去模擬遞歸,這裏的棧當然就是對象了,存在堆裏,就不用擔心函數棧溢出了。

另一種,利用一個隊列,去實現 BFS,首先把四個邊界的 O 加到隊列中,然後按照正常的 BFS 和之前一樣訪問連通的 O 並且進行標記。最後再把沒有標記的 O 改成 X 就可以了。

解法三

這裏再介紹另外一種思想,參考 這裏,就是並查集,其實本質上和上邊的解法是一樣的,只是抽象出了一種數據結構,在很多地方都有應用。

看下維基百科對 並查集 的定義。

計算機科學中,並查集是一種樹型的數據結構,用於處理一些不交集(Disjoint Sets)的合併及查詢問題。有一個聯合-查找算法union-find algorithm)定義了兩個用於此數據結構的操作:

  • Find:確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬於同一子集。
  • Union:將兩個子集合併成同一個集合。

由於支持這兩種操作,一個不相交集也常被稱爲聯合-查找數據結構(union-find data structure)或合併-查找集合(merge-find set)。其他的重要方法,MakeSet,用於創建單元素集合。有了這些方法,許多經典的劃分問題可以被解決。

爲了更加精確的定義這些方法,需要定義如何表示集合。一種常用的策略是爲每個集合選定一個固定的元素,稱爲代表,以表示整個集合。接着,Find(x) 返回 x 所屬集合的代表,而 Union 使用兩個集合的代表作爲參數。

網上很多講並查集的文章了,這裏推薦 一篇

知道了並查集,下邊就很好解決了,因爲你會發現,我們做的就是分類的問題,O 其實最終就是兩大類,一種能連通到邊界,一種不能連通到邊界。

首先我們把每個節點各作爲一類,用它的行數和列數生成一個 id 標識該類。

int node(int i, int j) {
    return i * cols + j;
}

然後遍歷每個 O節點,和它上下左右的節點進行合併即可。

如果當前節點是邊界的 O,就把它和 dummy 節點(一個在所有節點外的節點)合併。最後就會把所有連通到邊界的 o 節點和 dummy 節點合爲了一類。

最後我們只需要判斷,每一個 o 節點是否和 dummy 節點是不是一類即可。

public class Solution {
    int rows, cols;
    
    public void solve(char[][] board) {
        if(board == null || board.length == 0) return;
        
        rows = board.length;
        cols = board[0].length;
        
        //多申請一個空間
        UnionFind uf = new UnionFind(rows * cols + 1);
        //所有邊界的 O 節點都和 dummy 節點合併
        int dummyNode = rows * cols;
        
        for(int i = 0; i < rows; i++) {
            for(int j = 0; j < cols; j++) {
                if(board[i][j] == 'O') {
                    //當前節點在邊界就和 dummy 合併
                    if(i == 0 || i == rows-1 || j == 0 || j == cols-1) {
                        uf.union( dummyNode,node(i,j));
                    }
                    else {
                        //將上下左右的 O 節點和當前節點合併
                        if(board[i-1][j] == 'O')  uf.union(node(i,j), node(i-1,j));
                        if(board[i+1][j] == 'O')  uf.union(node(i,j), node(i+1,j));
                        if(board[i][j-1] == 'O')  uf.union(node(i,j), node(i, j-1));
                        if( board[i][j+1] == 'O')  uf.union(node(i,j), node(i, j+1));
                    }
                }
            }
        }
        
        for(int i = 0; i < rows; i++) {
            for(int j = 0; j < cols; j++) {
                //判斷是否和 dummy 節點是一類
                if(uf.isConnected(node(i,j), dummyNode)) {
                    board[i][j] = 'O';
                }
                else {
                    board[i][j] = 'X';
                }
            }
        }
    }
    
    int node(int i, int j) {
        return i * cols + j;
    }
}

class UnionFind {
    int [] parents;
    public UnionFind(int totalNodes) {
        parents = new int[totalNodes];
        for(int i = 0; i < totalNodes; i++) {
            parents[i] = i;
        }
    }
    
    void union(int node1, int node2) {
        int root1 = find(node1);
        int root2 = find(node2);
        if(root1 != root2) {
            parents[root2] = root1;
        }
    }
    
    int find(int node) {
        while(parents[node] != node) {
            parents[node] = parents[parents[node]];
            node = parents[node];
        }
        return node;
    }
    
    boolean isConnected(int node1, int node2) {
        return find(node1) == find(node2);
    }
}

解法一到解法二僅僅是思路的一個逆轉,速度卻帶來了質的提升。所以有時候走到了死衚衕,可以試試往回走。

刷這麼多題第一次應用到了並查集,並查集說簡單點,就是每一類選一個代表,然後類中的其他元素最終都可以找到這個代表。然後遍歷其他元素,將它合併到某個類中。

更多詳細通俗題解詳見 leetcode.wang

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章