題目描述(中等難度)
有一點點像圍棋,把被 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);
}
}
總
解法一到解法二僅僅是思路的一個逆轉,速度卻帶來了質的提升。所以有時候走到了死衚衕,可以試試往回走。
刷這麼多題第一次應用到了並查集,並查集說簡單點,就是每一類選一個代表,然後類中的其他元素最終都可以找到這個代表。然後遍歷其他元素,將它合併到某個類中。
更多詳細通俗題解詳見 leetcode.wang 。