八皇后問題是一個以西洋棋為背景的問題:如何能夠在8×8的西洋棋棋盤上放置八個皇后,使得任何一個皇后都無法直接吃掉其他的皇后?為了達到此目的,任兩個皇后都不能處於同一條橫行、縱行或斜線上。八皇后問題可以推廣為更一般的n皇后擺放問題:這時棋盤的大小變為n×n,而皇后個數也變成n。若且唯若n = 1或n ≥ 4時問題有解。
八皇后問題最早是由西洋棋棋手馬克斯·貝瑟爾(Max Bezzel)於1848年提出。第一個解在1850年由弗朗茲·諾克(Franz Nauck)給出。並且將其推廣為更一般的n皇后擺放問題。諾克也是首先將問題推廣到更一般的n皇后擺放問題的人之一。
問題分析
我們先不看8皇后,我們先來看一下4皇后,其實原理都是一樣的,比如在下面的4*4的格子裡,如果我們在其中一個格子裡輸入了皇后,那麼在這一行這一列和這左右兩邊的對角線上都不能有皇后。
所以有一種方式就是我們一個個去試
第一行
比如我們在第一行第一列輸入了一個皇后
第二行
第二行我們就不能在第一列和第二列輸入皇后了,因為有衝突了。但我們可以在第3列輸入皇后
第三行
第三行我們發現在任何位置輸入都會有衝突。這說明我們之前選擇的是錯誤的,再回到上一步,我們發現第二步不光能選擇第3列,而且還能選擇第4列,既然選擇第3列不合適,那我們就選擇第4列吧
第二行(重新選擇)
第二行我們選擇第4列
第三行(重新選擇)
第3行我們只有選擇第2列不會有衝突
第四行
我們發現第4行又沒有可選擇的了。第一次重試失敗
第二次重試
到這裡我們只有重新回到第一步了,這說明我們之前第一行選擇第一列是無解的,所以我們第一行不應該選擇第一列,我們再來選擇第二列來試試
第一行
這一行我們選擇第2列
第二行
第二行我們前3個都不能選,只能選第4列
第三行
第三行我們只能選第1列
第四行
第四行我們只能選第3列
最後我們終於找到了一組解。除了這組解還有沒有其他解呢,肯定還是有的,因為4皇后是有兩組解的,這裡我們就不在一個個試了。
我們來看一下他查找的過程,就是先從第1行的第1列開始往下找,然後再從第1行的第2列……,一直到第1行的第n列。代碼該怎麼寫呢,看到這裡估計大家都已經想到了,這不就是一棵N叉樹的前序遍歷嗎,我們來看下,是不是下面這樣的。
我們先來看一下二叉樹的前序遍歷怎麼寫,不明白的可以參考下
public static void preOrder(TreeNode tree) { if (tree == null) return; System.out.printf(tree.val + &34;); preOrder(tree.left); preOrder(tree.right);}
二叉樹是有兩個子節點,那麼N叉樹當然是有N個子節點了,所以N叉樹的前序遍歷是這樣的
public static void preOrder(TreeNode tree) { if (tree == null) return; System.out.printf(tree.val + &34;); preOrder(&34;); preOrder(&34;); …… preOrder(&34;);}
如果N是一個很大的值,這樣寫要寫死人了,我們一般使用循環的方式
public static void preOrder(TreeNode tree) { if (tree == null) return; System.out.printf(tree.val + &34;); for (int i = 0; i <n ; i++) { preOrder(&34;); }}
搞懂了上面的分析過程,那麼這題的代碼輪廓就呼之欲出了
private void solve(char[][] chess, int row) { &34; return; for (int col = 0; col < chess.length; col++) { //判斷這個位置是否可以放皇后 if (valid(chess, row, col)) { //如果可以放,我們就把原來的數組chess複製一份, char[][] temp = copy(chess); //然後在這個位置放上皇后 temp[row][col] = &39;; //下一行 solve(temp, row + 1); } }}
我們來分析下上面的代碼,因為是遞歸所以必須要有終止條件,那麼這題的終止條件就是我們最後一行已經走完了,也就是
if (row == chess.length) { return;}
第7行就是判斷在這個位置我們能不能放皇后,如果不能放我們就判斷這一行的下一列能不能放……,如果能放我們就把原來數組chess複製一份,然後把皇后放到這個位置,然後再判斷下一行,這和我們上面畫圖的過程非常類似。注意這裡的第9行為什麼要複製一份,因為數組是引用傳遞,這涉及到遞歸的時候分支汙染問題(後面有時間我會專門寫一篇關於遞歸的時候分支汙染問題)。當然不複製一份也是可以的,我們下面再講。當我們把上面的問題都搞懂的時候,代碼也就很容易寫出來了,我們來看下N皇后的最終代碼
public void solveNQueens(int n) { char[][] chess = new char[n][n]; for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) chess[i][j] = &39;; solve(chess, 0);}private void solve(char[][] chess, int row) { if (row == chess.length) { //自己寫的一個公共方法,列印二維數組的, // 主要用於測試數據用的 Util.printTwoCharArrays(chess); System.out.println(); return; } for (int col = 0; col < chess.length; col++) { if (valid(chess, row, col)) { char[][] temp = copy(chess); temp[row][col] = &39;; solve(temp, row + 1); } }}//把二維數組chess中的數據測下copy一份private char[][] copy(char[][] chess) { char[][] temp = new char[chess.length][chess[0].length]; for (int i = 0; i < chess.length; i++) { for (int j = 0; j < chess[0].length; j++) { temp[i][j] = chess[i][j]; } } return temp;}//row表示第幾行,col表示第幾列private boolean valid(char[][] chess, int row, int col) { //判斷當前列有沒有皇后,因為他是一行一行往下走的, //我們只需要檢查走過的行數即可,通俗一點就是判斷當前 //坐標位置的上面有沒有皇后 for (int i = 0; i < row; i++) { if (chess[i][col] == &39;) { return false; } } //判斷當前坐標的右上角有沒有皇后 for (int i = row - 1, j = col + 1; i >= 0 && j < chess.length; i--, j++) { if (chess[i][j] == &39;) { return false; } } //判斷當前坐標的左上角有沒有皇后 for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { if (chess[i][j] == &39;) { return false; } } return true;}
代碼看起來比較多,我們主要看下solve方法即可,其他的方法不看也可以,知道有這個功能就行。solve代碼中其核心代碼是在17-23行,上面是終止條件的判斷,我們就用4皇后來測試一下
solveNQueens(4);
看一下列印的結果
我們看到4皇后的時候有兩組解,其中第一組和我們上面圖中分析的完全一樣。
4皇后解決了,那麼8皇后也一樣,我們只要在函數調用的時候傳入8就可以了。同理,要想計算10皇后,20皇后,100皇后……也都是可以的。
回溯解決
上面代碼中每次遇到能放皇后的時候,我們都會把原數組複製一份,這樣對新數據的修改就不會影響到原來的,也就是不會造成分支汙染。但這樣每次嘗試的時候都都把原數組複製一份,影響效率,有沒有其他的方法不複製呢,是有的。就是每次我們選擇把這個位置放置皇后的時候,如果最終不能成功,那麼返回的時候我們就還要把這個位置還原。這就是回溯算法,也是試探算法。我們來看下代碼
private void solve(char[][] chess, int row) { if (row == chess.length) { //自己寫的一個公共方法,列印二維數組的, // 主要用於測試數據用的 Util.printTwoCharArrays(chess); System.out.println(); return; } for (int col = 0; col < chess.length; col++) { if (valid(chess, row, col)) { chess[row][col] = &39;; solve(chess, row + 1); chess[row][col] = &39;; } }}
主要來看下11-13行,其他的都沒變,還和上面的一樣。這和我們之前講的391,回溯算法求組合問題很類似。他是先假設[row][col]這個位置可以放皇后,然後往下找,無論找到找不到最後都會回到這個地方,因為這裡是遞歸調用,回到這個地方的時候我們再把它復原,然後走下一個分支。最後我們再來看下使用回溯算法解N皇后的完整代碼
public void solveNQueens(int n) { char[][] chess = new char[n][n]; for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) chess[i][j] = &39;; solve(chess, 0);}private void solve(char[][] chess, int row) { if (row == chess.length) { //自己寫的一個公共方法,列印二維數組的, // 主要用於測試數據用的 Util.printTwoCharArrays(chess); System.out.println(); return; } for (int col = 0; col < chess.length; col++) { if (valid(chess, row, col)) { chess[row][col] = &39;; solve(chess, row + 1); chess[row][col] = &39;; } }}//row表示第幾行,col表示第幾列private boolean valid(char[][] chess, int row, int col) { //判斷當前列有沒有皇后,因為他是一行一行往下走的, //我們只需要檢查走過的行數即可,通俗一點就是判斷當前 //坐標位置的上面有沒有皇后 for (int i = 0; i < row; i++) { if (chess[i][col] == &39;) { return false; } } //判斷當前坐標的右上角有沒有皇后 for (int i = row - 1, j = col + 1; i >= 0 && j < chess.length; i--, j++) { if (chess[i][j] == &39;) { return false; } } //判斷當前坐標的左上角有沒有皇后 for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { if (chess[i][j] == &39;) { return false; } } return true;}
總結
8皇后問題其實是一道很經典的回溯算法題,我們這裡並沒有專門針對8皇后來講,我們這裡講的是N皇后,如果這道題搞懂了,那麼8皇后自然也就懂了,因為這裡的N可以是任何正整數。
遞歸一般可能會有多個分支,我們要保證每個分支的修改不會汙染其他分支,也就是不要對其他分支造成影響,這一點很重要。由於一些語言中除了基本類型以外,其他的大部分都是引用傳遞,所以我們在一個分支修改之後可能就會對其他分支產生一些我們不想要的垃圾數據,這個時候我們就有兩中解決方式,一種就是我們上面講到的第一種,複製一份新的數據,這樣每個分支都會產生一些新的數據,所以即使修改了也不會對其他分支有影響。還一種方式就是我們每次使用完之後再把它復原,一般的情況下我們都會選擇第二種,因為這種代碼更簡潔一些,也不會重複的複製數據,造成大量的垃圾數據。
如果喜歡這篇文章還可以關注微信公眾號「數據結構和算法」,查看更多的算法題