n皇后問題的三種解法
N皇后問題是一個經典的問題,在一個N*N的棋盤上放置N個皇后,每行一個並使其不能互相攻擊(同一行、同一列、同一斜線上的皇后都會自動攻擊)。
n皇后問題不算是陳詞濫調,也是老生常談了,作為回溯的經典案例,有遞迴和非遞迴兩種實現方式,同時,除了回溯演算法,最近我在網上查了下看到還可以用位運算來解決這個問題。本次趁著本學期演算法課程的期末要求,我將對兩種演算法的實驗效果進行分析。
1、使用回溯解決n皇后問題
首先,我們來看一下回溯演算法的基本思想:從一條路往前走,能進則進,不能進則退回來,換一條路再試。正如前所述,使用回溯演算法解決n皇后問題分為遞迴和非遞迴兩種形式。本文都將進行分析。
1.1 使用遞迴的回溯法解決
下面是用遞迴的回溯法求解n皇后問題的演算法描述,這裡用一個N*N的矩陣來儲存棋盤:
1) 演算法開始, 清空棋盤,當前行設為第一行,當前列設為第一列
2) 在當前行,當前列的位置上判斷是否滿足條件(即保證經過這一點的行,列與斜線上都沒有兩個皇后),若不滿足,跳到第4步
3) 在當前位置上滿足條件的情形:
在當前位置放一個皇后,若當前行是最後一行,記錄一個解;
若當前行不是最後一行,當前行設為下一行, 當前列設為當前行的第一個待測位置;
若當前行是最後一行,當前列不是最後一列,當前列設為下一列;
若當前行是最後一行,當前列是最後一列,回溯,即清空當前行及以下各行的棋盤,然後,當前行設為上一行,當前列設為當前行的下一個待測位置;
以上返回到第2步
4) 在當前位置上不滿足條件的情形:
若當前列不是最後一列,當前列設為下一列,返回到第2步;
若當前列是最後一列了,回溯,即,若當前行已經是第一行了,演算法退出,否則,清空當前行及以下各行的棋盤,然後,當前行設為上一行,當前列設為當前行的下一個待測位置,返回到第2步;
演算法的基本原理是上面這個樣子,但不同的是用的資料結構不同,檢查某個位置是否滿足條件的方法也不同。為了提高效率,有各種優化策略,如多執行緒,多分配記憶體表示棋盤等。
在具體解決該問題時,可以將其拆分為幾個小問題。首先就是在棋盤上如何判斷兩個皇后是否能夠相互攻擊,在最初接觸這個問題時,首先想到的方法就是把棋盤儲存為一個二維陣列,然後在需要在第i行第j列放置皇后時,根據問題的描述,首先判斷是在第i行是否有皇后,由於每行只有一個皇后,這個判斷也可以省略,然後判斷第j列是否有皇后,這個也很簡單,最後需要判斷在同一斜線上是否有皇后,按照該方法需要判斷兩次,正對角線方向和負對角線方向,總體來說也不難。但是寫完之後,總感覺很笨,因為在N皇后問題中這個函式的使用次數太多了,而這樣做效率較差,個人感覺很不爽。上網查看了別人的實現之後大吃一驚,大牛們都是使用一個一維陣列來儲存棋盤,在某個位置上是否有皇后可以相互攻擊的判斷也很簡單。具體細節如下:
把棋盤儲存為一個N維陣列a[N],陣列中第i個元素的值代表第i行的皇后位置,這樣便可以把問題的空間規模壓縮為一維O(N),在判斷是否衝突時也很簡單,首先每行只有一個皇后,且在陣列中只佔據一個元素的位置,行衝突就不存在了,其次是列衝突,判斷一下是否有a[i]與當前要放置皇后的列j相等即可。至於斜線衝突,通過觀察可以發現所有在斜線上衝突的皇后的位置都有規律即它們所在的行列互減的絕對值相等,即| row – i | = | col – a[i] | 。這樣某個位置是否可以放置皇后的問題已經解決。
下面要解決的是使用何種方法來找到所有的N皇后的解。上面說過該問題是回溯法的經典應用,所以可以使用回溯法來解決該問題,具體實現也有兩個途徑,遞迴和非遞迴。遞迴方法較為簡單,大致思想如下:
void queen(int row) {
if (n == row) //如果已經找到結果,則列印結果
print_result();
else {
for (k=0 to N) { //試探第row行每一個列
if (can_place(row,k) {
place(row,k); //放置皇后
queen(row+ 1); //繼續探測下一行
}
}
}
}
該方法由於在探測第i行後,如果找到一個可以放置皇后的位置j後,則會遞迴探測下一行,結束後則會繼續探測i行j+1列,故可以找到所有的N皇后的解。完整的程式碼如下:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
const int N=20; //最多放皇后的個數
int q[N]; //各皇后所在的行號
int cont = 0; //統計解得個數
//輸出一個解
void print(int n)
{
int i,j;
cont++;
printf("第%d個解:",cont);
for(i=1;i<=n;i++)
printf("(%d,%d)",i,q[i]);
printf("\n");
for(i=1;i<=n;i++) //行
{
for(j=1;j<=n;j++) //列
{
if(q[i]!=j)
printf("x");
else
printf("Q");
}
printf("\n");
}
}
//檢驗第i行的k列上是否可以擺放皇后
int find(int i,int k)
{
int j=1;
while(j<i) //j=1~i-1是已經放置了皇后的行
{
//第j行的皇后是否在k列或(j,q[j])與(i,k)是否在斜線上
if(q[j]==k ||abs(j-i)==abs(q[j]-k))
return0;
j++;
}
return 1;
}
//放置皇后到棋盤上
void place(int k,int n)
{
int j;
if(k>n)
print(n);
else
{
for(j=1;j<=n;j++) //試探第k行的每一個列
{
if(find(k,j))
{
q[k] = j;
place(k+1,n); //遞迴總是在成功完成了上次的任務的時候才做下一個任務
}
}
}
}
int main(void)
{
clock_t start,finish;
int n;
printf("請輸入皇后個數:");
scanf("%d",&n);
start = clock();
printf("\n使用非遞歸回溯法解決 %d 皇后問題時的執行情況\n",n);
place(1,n);
finish = clock();
printf("共有 %d 種不同的排列 計算用時%.2fms \n",count, (double) (finish - start));
system("pause");
return 0;
}
1.2 使用非遞迴的回溯法解決n皇后問題
非遞迴方法的一個重要問題時何時回溯及如何回溯的問題。程式首先對N行中的每一行進行探測,尋找該行中可以放置皇后的位置,具體方法是對該行的每一列進行探測,看是否可以放置皇后,如果可以,則在該列放置一個皇后,然後繼續探測下一行的皇后位置。如果已經探測完所有的列都沒有找到可以放置皇后的列,此時就應該回溯,把上一行皇后的位置往後移一列,如果上一行皇后移動後也找不到位置,則繼續回溯直至某一行找到皇后的位置或回溯到第一行,如果第一行皇后也無法找到可以放置皇后的位置,則說明已經找到所有的解程式終止。如果該行已經是最後一行,則探測完該行後,如果找到放置皇后的位置,則說明找到一個結果,打印出來。但是此時並不能再此處結束程式,因為我們要找的是所有N皇后問題所有的解,此時應該清除該行的皇后,從當前放置皇后列數的下一列繼續探測。
完整的程式碼如下:
#include "iostream"
#include "cmath"
using namespace std;
#define Max 20 //定義棋盤的最大值
int a[Max];
int show(int S) //定義輸出函式
{
int i,p,q;
intb[Max][Max]={0}; //定義並初始化b[][]輸出陣列
for(i=1;i<=S;i++) //按橫列i順序輸出a[i]陣列座標
{
b[i][a[i]]=1;
printf("(%d,%d)\t",i,a[i]);
}
printf("\n");
for(p=1;p<=S;p++) //按棋盤的橫列p順序標明皇后的位置
{
for(q=1;q<=S;q++)
{
if(b[p][q]==1) //在第p行第q列放置一個皇后棋子
printf("●");
else
printf("○");
}
printf("\n");
}
return 0;
}
int check(int k) //定義check函式
{
int i;
for(i=1;i<k;i++) //將第k行與前面的第1~~k-1行進行判斷
{
if((a[i]==a[k]) || (a[i]-a[k]==k-i) || (a[i]-a[k]==i-k)) //檢查是否有多個皇后在同一條直線上
{
return0;
}
}
return 1;
}
void check_m(int num) //定義函式
{
int k=1,count=0;
printf("Thepossible configuration of N queens are:\n");
a[k]=1;
while(k>0)
{
if(k<=num && a[k]<=num)//從第k行第一列的位置開始,為後續棋子選擇合適位子
{
if(check(k)==0) //第k行的a[k]列不能放置皇后
{
a[k]++; //繼續探測當前行的下一列:a[k]+1
}
else
{
k++; //第K行的位置已經確定了,繼續尋找第k+1行皇后的位置
a[k]=1; //從第一列開始查詢
}
}
else
{
if(k>num) //若滿足輸出陣列的要求則輸出該陣列
{
count++;
printf("[%d]: ",count);
show(num); //呼叫輸出函式show()
}
//如果k>num會執行下面兩行程式碼,因為雖然找到了N皇后問題的一個解,但是要找的是所有解,需要回溯,從當前放置皇后的下一列繼續探測
//如果a[k]>num也會執行下面兩行程式碼,就是說在當前行沒有找到可以放置皇后的位置,於是回溯,從上一行皇后位置的下一列繼續探測
k--; //棋子位置不符合要求,則退回前一步
a[k]++; //繼續試探下一列位置
}
}
printf("The countis: %d \n",count);
}
int main(void)
{
clock_t start,finish;
int n;
printf("請輸入皇后個數:");
scanf("%d",&n);
start = clock();
printf("\n使用非遞歸回溯法解決 %d 皇后問題時的執行情況\n",n);
check_m(n);
finish = clock();
printf(" 計算時間%.2fms \n", (double) (finish - start));
system("pause");
return 0;
}
2、 使用位運算求解n皇后問題
核心程式碼如下:
void test(int row, int ld, int rd)
{
intpos, p;
if( row != upperlim )
{
pos= upperlim & (~(row | ld | rd ));
while( pos )
{
p= pos & (~pos + 1);
pos= pos - p;
test(row| p, (ld | p) << 1, (rd | p) >> 1);
}
}
else
++Ans;
}
初始化: upperlim = (1 << n)-1;Ans = 0;
呼叫引數:test(0, 0, 0);
和普通演算法一樣,這是一個遞迴函式,程式一行一行地尋找可以放皇后的地方。函式帶三個引數row、ld和rd,分別表示在縱列和兩個對角線方向的限制條件下這一行的哪些地方不能放。位於該行上的衝突位置就用row、ld和rd中的1來表示。把它們三個並起來,得到該行所有的禁位,取反後就得到所有可以放的位置(用pos來表示)。
p = pos &(~pos + 1)其結果是取出最右邊的那個1。這樣,p就表示該行的某個可以放子的位置,把它從pos中移除並遞迴呼叫test過程。
注意遞迴呼叫時三個引數的變化,每個引數都加上了一個禁位,但兩個對角線方向的禁位對下一行的影響需要平移一位。最後,如果遞迴到某個時候發現row=upperlim了,說明n個皇后全放進去了,找到的解的個數加一。
注:
upperlime:=(1 << n)-1 就生成了n個1組成的二進位制數。
這個程式是從上向下搜尋的。
pos & -pos 的意思就是取最右邊的 1 再組成二進位制數,相當於 pos &(~pos +1),因為取反以後剛好所有數都是相反的(怎麼聽著像廢話),再加1 ,就是改變最低位,如果低位的幾個數都是1,加的這個 1 就會進上去,一直進到 0 ,在做與運算就和原數對應的 1 重合了。舉例可以說明:
原數 0 0 0 0 1 0 0 0 原數 0 1 0 1 00 1 1
取反 1 1 1 1 0 1 1 1 取反 1 0 1 0 11 0 0
加1 1 1 1 1 1 0 0 0 加1 1 0 1 0 1 1 0 1
與運算 0 0 0 0 1 0 0 0 and 00 0 0 0 0 0 1 其中,這個取反再加 1 就是補碼,and 運算與負數,就是按位和補碼與運算。
(ld | p)<< 1 是因為由ld造成的佔位在下一行要右移一下;
(rd| p)>> 1 是因為由rd造成的佔位在下一行要左移一下。
ld rd row 還要和upperlime 與運算一下,這樣做的結果就是從最低位數起取n個數為有效位置,原因是在上一次的運算中ld發生了右移,如果不and的話,就會誤把n以外的位置當做有效位。
在進行到某一層的搜尋時,pos中儲存了所有的可放位置,為了求出所有解,必須遍歷所有可放的位置,而每走過一個點必須要刪掉它,否則就成死迴圈了。
這個是目前公認N皇后的最高效演算法。
完整的程式碼如下:
#include "iostream"
using namespace std;
#include "time.h"
// sum用來記錄皇后放置成功的不同佈局數;upperlim用來標記所有列都已經放置好了皇后。
long sum = 0, upperlim = 1;
// 試探演算法從最右邊的列開始。
void test(long row, long ld, long rd)
{
if(row != upperlim)
{
//row,ld,rd進行“或”運算,求得所有可以放置皇后的列,對應位為0,
// 然後再取反後“與”上全1的數,來求得當前所有可以放置皇后的位置,對應列改為1
//也就是求取當前哪些列可以放置皇后
longpos = upperlim & ~(row | ld | rd);
while(pos) // 0 -- 皇后沒有地方可放,回溯
{
//拷貝pos最右邊為1的bit,其餘bit置0
//也就是取得可以放皇后的最右邊的列
longp = pos & -pos;
//將pos最右邊為1的bit清零
//也就是為獲取下一次的最右可用列使用做準備,
//程式將來會回溯到這個位置繼續試探
pos-= p;
//row + p,將當前列置1,表示記錄這次皇后放置的列。
//(ld + p) << 1,標記當前皇后左邊相鄰的列不允許下一個皇后放置。
//(ld + p) >> 1,標記當前皇后右邊相鄰的列不允許下一個皇后放置。
//此處的移位操作實際上是記錄對角線上的限制,只是因為問題都化歸
//到一行網格上來解決,所以表示為列的限制就可以了。顯然,隨著移位
//在每次選擇列之前進行,原來N×N網格中某個已放置的皇后針對其對角線
//上產生的限制都被記錄下來了
test(row+ p, (ld + p) << 1, (rd + p) >> 1);
}
}
else
{
//row的所有位都為1,即找到了一個成功的佈局,回溯
sum++;
}
}
int main(int argc, char *argv[])
{
clock_tstart,finish;
intn;
printf("請輸入皇后個數:");
scanf("%d",&n);
start= clock();
printf("\n使用位運算 %d 皇后問題的執行情況\n",n);
test(0, 0, 0);
finish= clock();
printf("共有%ld種排列, 計算時間%.2fms\n", sum, (double) (finish - start));
system("pause");
return0;
}
上述程式碼還是比較容易理解的,但我覺得核心的是在針對試探-回溯演算法所用的資料結構的設計上。程式採用了遞迴,也就是借用了編譯系統提供的自動回溯功能。
演算法的核心:使用bit陣列來代替以前由int或者bool陣列來儲存當前格子被佔用或者說可用資訊,從這可以看出N個皇后對應需要N位表示。
巧妙之處在於:以前我們需要在一個N*N正方形的網格中挪動皇后來進行試探回溯,每走一步都要觀察和記錄一個格子前後左右對角線上格子的資訊;採用bit位進行資訊儲存的話,就可以只在一行格子也就是(1行×N列)個格子中進行試探回溯即可,對角線上的限制被化歸為列上的限制。
程式中主要需要下面三個bit陣列,每位對應網格的一列,在C中就是取一個整形數的某部分連續位即可。 row用來記錄當前哪些列上的位置不可用,也就是哪些列被皇后佔用,對應為1。ld,rd同樣也是記錄當前哪些列位置不可用,但是不表示被皇后佔用,而是表示會被已有皇后在對角線上吃掉的位置。這三個位陣列進行“或”操作後就是表示當前還有哪些位置可以放置新的皇后,對應0的位置可放新的皇后。如下圖所示的8皇后問題求解得第一步:
row: [ ][ ][ ][ ][ ][ ][ ][*]
ld: [ ][ ][ ][ ][ ][ ][*][ ]
rd: [ ][ ][ ][ ][ ][ ][ ][ ]
--------------------------------------
row|ld|rd: [ ][ ][ ][ ][ ][][*][*]
所有下一個位置的試探過程都是通過位操作來實現的,這是借用了C語言的好處輔來實現的。
關於此演算法,如果考慮N×N棋盤的對稱性,對於大N來說仍能較大地提升效率!
位操作--對優化演算法有了個新的認識
該演算法的巧妙之處我認為有2個:
1、以前都是用陣列來描述狀態,而這演算法採用是的位來描述,運算速度可以大大提升,以後寫程式對於描述狀態的變數大家可以借鑑這個例子,會讓你的程式跑得更快。
2、描述每行可放置的位置都是隻用row,ld,rd這3個變數來描述,這樣使得程式看起來挺簡潔的。
關於n皇后問題不同演算法的效果分析:
三種演算法的執行結果如下(以10皇后問題為例),為了能準確分析三種演算法的效率,每一種排列情況就不打印出來了:
(1) 使用遞迴的回溯法時的執行情況
(2) 使用非遞迴的回溯法時的執行情況
(3) 使用位運算時的執行情況
由以上的執行結果可以看到,用位運算計算n皇后問題最快,其次是非遞迴的回溯法,處理得最慢的是遞迴的回溯法。