【八皇後問題】
問題: 國際象棋棋盤是8 * 8的方格,每個方格裡放一個棋子。皇後這種棋子可以攻擊同一行或者同一列或者斜線(左上左下右上右下四個方向)上的棋子。在一個棋盤上如果要放八個皇後,使得她們互相之間不能攻擊(即任意兩兩之間都不同行不同列不同斜線),求出一種(進一步的,所有)布局方式。
■ 描述 & 實作
之前的Python基礎那本書上介紹遞歸和生成器的一張有解過這個問題。書本中對于此問題的解可能更偏重于對于Python語言的應用。然而果然我也是早就忘光了。下面再來從頭看看這個問題。
首先,我們想到遞歸和非遞歸兩類算法來解決這個問題。首先說說遞歸地算法。
很自然的,我們可以基于行來做判斷标準。八個皇後都不同行這是肯定的,也就說每行有且僅有一個皇後,問題就在于皇後要放在哪個列。當然八個列下标也都不能有相同,除此之外還要保證斜線上不能有重疊的皇後。
第一個需要解決的小問題就是,如何用數學的語言來表述斜線上重疊的皇後。其實我們可以看到,對于位于(i,j)位置的皇後而言,其四個方向斜線上的格子下标分别是 (i-n,j+n), (i-n,j-n), (i+n,j-n), (i+n,j+n)。當然i和j的±n都要在[0,7]的範圍内,保持不越界。暫時抛開越界限制不管,這個關系其實就是: 目标格子(a,b)和本格子(i,j)在同一條斜線上 等價于 |a - i| == |b - j| 。
然後,從遞歸的思想來看,我們在從第一行開始給每一行的皇後确定一個位置。每來到新的一行時,對本行的所有可能位置(皇後放在這個位置和前面所有已放置的皇後無沖突)分别進行遞歸地深入;若某一行可能的位置數為0,則表明這是一條死路,傳回上一層遞歸尋找其他辦法;若來到的這一行是第九行(不存在第九行,隻不過是說明前八行都已經正确配置,已經得到一個解決方案),這說明得到解決方案。
可以看到,尋找一行内皇後應該擺放的位置這是個遞歸過程,并且在進入遞歸時,應該要告訴這個過程的東西包括兩個: 1. 之前皇後放置的狀态, 2. 現在是第幾行。
是以,遞歸主體函數可以設計為 EightQueen(board, row),其中board表示的是目前棋盤的狀态(比如一個二維數組,0表示未放置,1表示放有皇後的狀态)。另外還可以有一個check(board,pos),pos可以是一個(x,y)元組,check函數用來傳回以目前的board棋盤狀态,如果在pos再放置一個皇後是否會有沖突。
基于上面的想法,初步實作如下:
def check(board,pos):
# check函數暫時先不實作
pass
def EightQueen(board,row):
blen = len(board)
if row == blen: # 來到不存在的第九行了
print board
return True # 一定要return一個True,理由在下面
for possibleY in range(blen):
if check(board,(row,possibleY)):
board[row][possibleY] = 1 # 放置一個Queen
if not EightQueen(board,row+1): # 這裡其實是本行下面所有行放置皇後的遞歸入口。但是如果最終這條路沒有找到一個解,那麼
# 此時應該将剛才放置的皇後收回,再去尋找下一個可能的解
board[row][possibleY] = 0
else:
return True
return False
最開始,可能在回歸傳回條件那裡面不會想到要return True,而隻是return。對應的,下面主循環中放置完Queen之後也隻是簡單地遞歸調用EightQueen,不會做邏輯判斷。但是很快可以發現這樣做存在一個問題,即當某一層遞歸中for possibleY這個循環走完卻沒有找到一個合适的解(即本行無合适位置),此時傳回上一行,上一行的possibleY右移一格,此時之前放在這一行的Queen的位置仍然是1。這樣之後本行的所有check肯定都是通不過的。是以我們需要設計一個機制,使得第一個possibleY沒有找到合理的最終解決方案(這裡就加上了一個判斷條件),要右移一格到下一個possibleY時将本格的Queen收回。
這個判斷條件就是如果某層遞歸for possibleY循環整個走完未找到結果傳回False(EightQueen整個函數最後的傳回),上一層根據這個False回報把前一個Queen拿掉;如果找到了某個結果那麼就可以一路return True回來,結束函數的運作。
另外,如果隻是擷取一個解的話,可以考慮在if row == blen的時候,列印出board,然後直接sys.exit(0)。此時就隻需要for possibleY循環完了之後return一個False就可以了。當然主循環中對于遞歸的傳回的判斷 if not EightQueen還是需要的。
■ 優化
● check函數怎麼搞
上面沒有實作check函數。其實仔細想一下,如果按照上面的設想來實作check函數還是有點困難的。比如令 x,y = pos,盡管此時我們隻需要去檢查那些行下标小于x的board中的行,但是對于每一行中我們還是要一個個去周遊,找到相關行中值是1的那個格子(突然發現這個是one-hot模式诶哈哈),然後将它再和x,y這個位置做沖突判斷。是以但是這個check函數複雜度就可能會達到O(n^2),再套上外面的循環,複雜度蹭蹭往上漲。下面是check函數的一個可能的實作:
def check(board,pos):
x,y = pos
blen = len(board)
for i in range(x):
for j in range(blen):
if board[i][j] == 1:
if j == y or abs(j-y) == abs(i-x):
return False
return True
其實可以看到,我們花了一層循環在尋找某行中的one-hot,那些大量的0值元素是我們根本不關心的。換句話說,對于board這個二維數組,其實我們真正關心的是每行中one-hot值的下标值。自然我們就可以想到,能不能将board轉化為一個一維數組,下标本身就代表了board中的某一行,然後值是指這一行中皇後放在第幾列。
如果是這樣的話,那麼程式就需要改造,首先是check函數要根據新的board資料結構做一些調整:
def check(board,row,col):
i = 0
while i < row:
if abs(col-board[i]) in (0,abs(row-i)):
return False
i += 1
return True
可以看到,改變二維數組board變為一維數組之後,我們可以在O(1)的時間就确定row行之前每一行擺放的位置,并将其作為參考進行每一行的沖突判斷。
然後是主函數的修改:
def EightQueen(board,row):
blen = len(board)
if row == blen: # 來到不存在的第九行了
print board
return True
col = 0
while col < blen:
if check(board,row,col):
board[row] = col
if EightQueen(board,row+1):
return True
col += 1
return False
def printBoard(board):
'''為了更友好地展示結果 友善觀察'''
import sys
for i,col in enumerate(board):
sys.stdout.write('□ ' * col + '■ ' + '□ ' * (len(board) - 1 - col))
print ''
總的結構,和沒修改之前是類似的,隻不過在主循環中,從上面的possibleY作為遊标去設定 - 去除 一個位置的放置狀态,這種方式改為了簡單的col += 1。改成col+=1的好處就是當某輪遞歸以失敗告終,傳回上層遞歸之後,就不用再去特地收回之前放置好的Queen,而是可以直接讓col += 1,。
printBoard函數可以将一維數組的board狀态很直覺地展現出來:
■ □ □ □ □ □ □ □
□ □ □ □ ■ □ □ □
□ □ □ □ □ □ □ ■
□ □ □ □ □ ■ □ □
□ □ ■ □ □ □ □ □
□ □ □ □ □ □ ■ □
□ ■ □ □ □ □ □ □
□ □ □ ■ □ □ □ □
■ 所有結果?
上面的程式多隻是生成了一個結果,而實際上八皇後可以有很多種可能的布局。如何才能求得所有結果?其實隻要小小地修改一下上面的程式就可以了。
以上面修改過後一維數組維護棋盤狀态為例。程式在碰到一次row == blen的情況之後就傳回了True,然後遞歸一層層地傳回True直到最上層。是以找到一個解決方案之後,程式就會退出了。
反過來,如果獲得一個解決方案之後,不判斷EightQueen函數的傳回,此時函數會繼續執行col += 1,将狀态搜尋繼續下去,如此收集狀态的任務在row == blen的判斷中,(注意這裡的return可不能删,這裡需要一個return來提示遞歸的終結條件),而對于每條遞歸路徑總是窮盡所有可能再回頭,這樣就可以獲得到所有可能了:
def EightQueen(board,row):
blen = len(board)
if row == blen: # 來到不存在的第九行了
print board
return True
col = 0
while col < blen:
if check(board,row,col):
board[row] = col
if EightQueen(board,row+1):
# return True 去掉這裡即可,或者直接删除掉整個判斷,隻留下單一個EightQueen(board,row+1)
pass
col += 1
return False
示例結果:
[0, 4, 7, 5, 2, 6, 1, 3]
[0, 5, 7, 2, 6, 3, 1, 4]
[0, 6, 3, 5, 7, 1, 4, 2]
[0, 6, 4, 7, 1, 3, 5, 2]
[1, 3, 5, 7, 2, 0, 6, 4]
[1, 4, 6, 0, 2, 7, 5, 3]
[1, 4, 6, 3, 0, 7, 5, 2]
[1, 5, 0, 6, 3, 7, 2, 4]
[1, 5, 7, 2, 0, 3, 6, 4]
…… 總共有92種布局方案
■ 非遞歸
非遞歸解這個問題,很顯然是要去維護一個stack來儲存一個路徑了。簡單來說,這個棧中維護的應該是“尚未嘗試去探索的可能”,當我開始檢查一個特定的位置,如果檢查通過,那麼應該做的是首先将本位置右邊一格加入棧,然後再把下一行的第一個格子加入棧。注意前半個操作很容易被忽視,但是如果不将本位置右邊一格入棧,那麼如果基于本格有皇後的情況進行的遞歸最終沒有傳回一個結果的話,接下來就不知道往哪走了。如果使用了棧,那麼用于掃描棋盤的遊标就不用自己在循環裡+=1了,循環中遊标的移動全權交給棧去維護。
代碼如下:
def EightQueen(board):
blen = len(board)
stack = Queue.LifoQueue()
stack.put((0,0)) # 為了自洽的初始化
while not stack.empty():
i,j = stack.get()
if check(board,i,j): # 當檢查通過
board[i] = j # 别忘了放Queen
if i >= blen - 1:
print board # i到達最後一行表明已經有了結果
break
else:
if j < blen - 1: # 雖然說要把本位置右邊一格入棧,但是如果本位置已經是行末尾,那就沒必要了
stack.put((i,j+1))
stack.put((i+1,0)) # 下一行第一個位置入棧,準備開始下一行的掃描
elif j < blen - 1:
stack.put((i,j+1)) # 對于未通過檢驗的情況,自然右移一格即可
顯然,把break去掉就是求所有解了
■ 用C語言寫了一版
#include <stdio.h>
static int board[8] = {};
int board_size = sizeof(board)/sizeof(int);
int check(int *board,int row){
int i = 0;
while(i < row){
if(board[i] == board[row] || row - i == board[row] - board[i] || row - i == board[i] - board[row]){
return 0;
}
i++;
}
// printf("board[%d]: %d\n",row,board[row]);
return 1;
}
void print_board(int *board){
int i;
int size = board_size;
for(i=0;i<size;i++){
printf("%d,",board[i]);
}
printf("\n");
i = 0;
while (i < size){
int j;
for (j=0;j<size;j++){
if(j == board[i]){
printf("%s ","■ ");
}
else{
printf("%s ","□ ");
}
}
printf("\n");
i++;
}
}
int eight_queen(int *board,int row){
if (row == 8){
print_board(board);
return 1;
}
board[row] = 0;
while (1){
if (check(board,row) && eight_queen(board,row+1)){
return 1;
}
else{
if(++board[row] >= 8){
break;
}
}
}
return 0;
}
int main(){
eight_queen(board,0);
// print_board(board);
return 0;
}