题目描述(中等难度)
有一点点像围棋,把被 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
上边的例子就只需要变化 1
个 O
。
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
之前,把当前的 row
和 col
记录下来,然后第二次遇到这些点的时候,就直接跳过 。
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
。最后发现是不可以的,比如下边的例子。
如果我们从橙色的 O
做 DFS
,然后沿着箭头方向,到达最后一个 O
的时候,它的左边上边右边都是 X
,根据代码它就返回 false
,此外它下边是访问过的节点也会返回 false
,所以四个方向都返回 false
,如果把它改成 X
明显是不对的。
解法二
解法一是从当前节点做 DFS
,然后看它是否能到达边界的 O
。那么我们能不能把思路逆转过来呢?
从边界的 O
做 DFS
,然后把遇到的 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); } }
总
解法一到解法二仅仅是思路的一个逆转,速度却带来了质的提升。所以有时候走到了死胡同,可以试试往回走。
刷这么多题第一次应用到了并查集,并查集说简单点,就是每一类选一个代表,然后类中的其他元素最终都可以找到这个代表。然后遍历其他元素,将它合并到某个类中。