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 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 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 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 visited = new HashSet<>();
                if (!solveHelper(i, j, board, visited, memoization)) {
                    board[i][j] = 'X';
                }
            }
        }
    }
}

private boolean solveHelper(int row, int col, char[][] board, HashSet visited,
                            HashSet 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 。

你可能感兴趣的:(LeetCode 力扣 130. 被围绕的区域)