N皇后問題——通俗易懂地講解(C++)

注:參考程序猿小灰hackbuteer1

八皇后問題,是一個古老而著名的問題,是回溯算法的典型例題。該問題是十九世紀著名的數學家高斯1850年提出:

在8X8格的國際象棋上擺放八個皇后,使其不能互相攻擊,即任意兩個皇后都不能處於同一行、同一列或同一斜線上(斜率爲1),問有多少種擺法。高斯認爲有76種方案。

讓我們來舉個栗子,下圖的綠色格子是一個皇后在棋盤上的“封鎖範圍”,其他皇后不得放置在這些格子:
在這裏插入圖片描述
下圖的綠色格子是兩個皇后在棋盤上的“封鎖範圍”,其他皇后不得放置在這些格子:
在這裏插入圖片描述
在這裏插入圖片描述
如何解決八皇后問題?

所謂遞歸回溯,本質上是一種枚舉法。這種方法從棋盤的第一行開始嘗試擺放第一個皇后,擺放成功後,遞歸一層,再遵循規則在棋盤第二行來擺放第二個皇后。如果當前位置無法擺放,則向右移動一格再次嘗試,如果擺放成功,則繼續遞歸一層,擺放第三個皇后…

如果某一層看遍了所有格子,都無法成功擺放,則回溯到上一個皇后,讓上一個皇后右移一格,再進行遞歸。如果八個皇后都擺放完畢且符合規則,那麼就得到了其中一種正確的解法。

說起來有些抽象,我們來看一看遞歸回溯的詳細過程。

1.第一層遞歸,嘗試在第一行擺放第一個皇后:
在這裏插入圖片描述
2.第二層遞歸,嘗試在第二行擺放第二個皇后(前兩格被第一個皇后封鎖,只能落在第三格):

在這裏插入圖片描述
3.第三層遞歸,嘗試在第三行擺放第三個皇后(前四格被第一第二個皇后封鎖,只能落在第五格):

在這裏插入圖片描述
4.第四層遞歸,嘗試在第四行擺放第四個皇后(第一格被第二個皇后封鎖,只能落在第二格):

在這裏插入圖片描述
5.第五層遞歸,嘗試在第五行擺放第五個皇后(前三格被前面的皇后封鎖,只能落在第四格):
在這裏插入圖片描述
6.由於所有格子都“綠了”,第六行已經沒辦法擺放皇后,於是進行回溯,重新擺放第五個皇后到第八格。:
在這裏插入圖片描述
7.第六行仍然沒有辦法擺放皇后,第五行也已經嘗試遍了,於是回溯到第四行,重新擺放第四個皇后到第七格。:
在這裏插入圖片描述
8.繼續擺放第五個皇后,以此類推…

八皇后問題的代碼實現?

非遞歸解法
非遞歸方法的一個重要問題時何時回溯及如何回溯的問題。程序首先對N行中的每一行進行探測,尋找該行中可以放置皇后的位置,具體方法是對該行的每一列進行探測,看是否可以放置皇后,如果可以,則在該列放置一個皇后,然後繼續探測下一行的皇后位置。

如果已經探測完所有的列都沒有找到可以放置皇后的列,此時就應該回溯,把上一行皇后的位置往後移一列,如果上一行皇后移動後也找不到位置,則繼續回溯直至某一行找到皇后的位置或回溯到第一行,如果第一行皇后也無法找到可以放置皇后的位置,則說明已經找到所有的解程序終止。

如果該行已經是最後一行,則探測完該行後,如果找到放置皇后的位置,則說明找到一個結果,打印出來。但是此時並不能再此處結束程序,因爲我們要找的是所有N皇后問題所有的解,此時應該清除該行的皇后,從當前放置皇后列數的下一列繼續探測。

/**
* 回溯法解N皇后問題
* 使用一個一維數組表示皇后的位置
* 其中數組的下標表示皇后所在的行
* 數組元素的值表示皇后所在的列
* 這樣設計的棋盤,所有皇后必定不在同一行,於是行衝突就不存在了
* date  : 2011-08-03 
* author: liuzhiwei
**/
 
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
 
#define QUEEN 8     //皇后的數目
#define INITIAL -10000   //棋盤的初始值
 
int a[QUEEN];    //一維數組表示棋盤
 
void init()  //對棋盤進行初始化
{
	int *p;
	for (p = a; p < a + QUEEN; ++p) 
	{
		*p = INITIAL;
	}
} 
 
int valid(int row, int col)    //判斷第row行第col列是否可以放置皇后
{
	int i;
	for (i = 0; i < QUEEN; ++i)   //對棋盤進行掃描
	{
		if (a[i] == col || abs(i - row) == abs(a[i] - col))   //判斷列衝突與斜線上的衝突
			return 0;
	}
	return 1;
} 
 
void print()    //打印輸出N皇后的一組解
{
	int i, j;
	for (i = 0; i < QUEEN; ++i)
	{
		for (j = 0; j < QUEEN; ++j)
		{
			if (a[i] != j)      //a[i]爲初始值
				printf("%c ", '.');
			else                //a[i]表示在第i行的第a[i]列可以放置皇后
				printf("%c ", '#');
		}
		printf("\n");
	}
	for (i = 0; i < QUEEN; ++i)
		printf("%d ", a[i]);
	printf("\n");
	printf("--------------------------------\n");
}
 
void queen()      //N皇后程序
{
	int n = 0;
	int i = 0, j = 0;
	while (i < QUEEN)
	{
		while (j < QUEEN)        //對i行的每一列進行探測,看是否可以放置皇后
		{
			if(valid(i, j))      //該位置可以放置皇后
			{
				a[i] = j;        //第i行放置皇后
				j = 0;           //第i行放置皇后以後,需要繼續探測下一行的皇后位置,所以此處將j清零,從下一行的第0列開始逐列探測
				break;
			}
			else
			{
				++j;             //繼續探測下一列
			}
		}
		if(a[i] == INITIAL)         //第i行沒有找到可以放置皇后的位置
		{
			if (i == 0)             //回溯到第一行,仍然無法找到可以放置皇后的位置,則說明已經找到所有的解,程序終止
				break;
			else                    //沒有找到可以放置皇后的列,此時就應該回溯
			{
				--i;
				j = a[i] + 1;        //把上一行皇后的位置往後移一列
				a[i] = INITIAL;      //把上一行皇后的位置清除,重新探測
				continue;
			}
		}
		if (i == QUEEN - 1)          //最後一行找到了一個皇后位置,說明找到一個結果,打印出來
		{
			printf("answer %d : \n", ++n);
			print();
			//不能在此處結束程序,因爲我們要找的是N皇后問題的所有解,此時應該清除該行的皇后,從當前放置皇后列數的下一列繼續探測。
			//_sleep(600);
			j = a[i] + 1;             //從最後一行放置皇后列數的下一列繼續探測
			a[i] = INITIAL;           //清除最後一行的皇后位置
			continue;
		}
		++i;              //繼續探測下一行的皇后位置
	}
}
 
int main(void)
{
	init();
	queen();
	system("pause");
	return 0;
}
   下面的代碼跟上面的代碼差不多,只是稍微做了一些變化。。上面函數判斷棋盤某個位置合法性的時候,valid函數裏面的QUEEN可以修改爲row的,只需要將前面row行與第row行進行比較就可以了,不需要將所有行都與第row進行比較的。。。下面的代碼中的check函數中循環次數是k而不是皇后的個數就是這個原因。。。
#include "iostream"
#include "cmath"
using namespace std;
 
#define Max 20      //定義棋盤的最大值
int a[Max];
int show(int S)    //定義輸出函數
{
	int i,p,q;
	int b[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))    //檢查是否有多個皇后在同一條直線上
		{
			return 0;
		}
	}
	return 1;
}
 
void check_m(int num)    //定義函數
{
	int k=1,count=0;
	printf("The possible 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 count is: %d \n",count);
}
 
int main(void)
{
	int N,d;
	//system("color 2a");
	do
	{
		printf("********************N皇后問題系統*********************\n\n");
		printf("                  1. 四皇后問題                        \n");
		printf("                  2. 八皇后問題                        \n");
		printf("                  3. N 皇后問題(N<20)                  \n");
		printf("                  4. 退出                              \n");
		printf("******************************************************\n");
		printf("\n    從數字1-4之間的數選擇需要的操作\n\n"); /*提示輸入選項*/
		printf("      請輸入你要選擇的功能選項:__\n");
		scanf("%d",&d); 
		switch(d)
		{
		case 1:
			check_m(4);      //4皇后問題
			break; 
		case 2:
			check_m(8);     //8皇后問題
			break; 
		case 3:
			printf("請輸入N的值:_");
			fflush(stdin);      //清除緩衝
			scanf("%d",&N);
			printf("\n");
			if(N>0&&N<20)
			{
				check_m(N);    //N皇后問題
				break;
			}
			else
			{
				printf("輸入錯誤,請從新輸入:");
				printf("\n\n");
				break; 
			}
		case 4:
			exit(0);     //程序結束
		}
	}while(1);
	system("pause");
	return 0;
}

遞歸解法

#include <stdio.h>
#include <stdlib.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)) 
			return 0;
		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)
{
	int n;
	printf("請輸入皇后的個數(n<=20),n=:");
	scanf("%d",&n);
	if(n>20)
		printf("n值太大,不能求解!\n");
	else
	{
		printf("%d皇后問題求解如下(每列的皇后所在的行數):\n",n);
		place(1,n);        //問題從最初狀態解起
		printf("\n");
	}
	system("pause");
	return 0;
}

使用位運算來求解N皇后的高效算法
核心代碼如下:

void test(int row, int ld, int rd)
{
	int pos, 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 0 0 1 1
 取反 1 1 1 1 0 1 1 1    取反 1 0 1 0 1 1 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  0 0 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 已經完成任務了還要減去p 是因爲?
while 循環是因爲?
在進行到某一層的搜索時,pos中存儲了所有的可放位置,爲了求出所有解,必須遍歷所有可放的位置,而每走過一個點必須要刪掉它,否則就成死循環啦!

     這個是目前公認N皇后的最高效算法。

完整的代碼如下:

/*
** 目前最快的N皇后遞歸解決方法
** N Queens Problem
** 試探-回溯算法,遞歸實現
*/
#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
		// 也就是求取當前哪些列可以放置皇后
		long pos = upperlim & ~(row | ld | rd); 
		while (pos)    // 0 -- 皇后沒有地方可放,回溯
		{
			// 拷貝pos最右邊爲1的bit,其餘bit置0
			// 也就是取得可以放皇后的最右邊的列
			long p = 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[])
{
	time_t tm;
	int n = 16;
 
	if (argc != 1)
		n = atoi(argv[1]);
	tm = time(0);
 
	// 因爲整型數的限制,最大隻能32位,
	// 如果想處理N大於32的皇后問題,需要
	// 用bitset數據結構進行存儲
	if ((n < 1) || (n > 32))                 
	{
		printf(" 只能計算1-32之間\n");
		exit(-1);
	}
	printf("%d 皇后\n", n);
 
	// N個皇后只需N位存儲,N列中某列有皇后則對應bit置1。
	upperlim = (upperlim << n) - 1;         
 
	test(0, 0, 0);
	printf("共有%ld種排列, 計算時間%d秒 \n", sum, (int) (time(0) - tm));
	system("pause");
	return 0;
}

上述代碼還是比較容易看懂的,但我覺得核心的是在針對試探-回溯算法所用的數據結構的設計上。
程序採用了遞歸,也就是借用了編譯系統提供的自動回溯功能。

算法的核心:使用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來說仍能較大地提升效率!
   位操作--對優化算法有了個新的認識

這個是在csdn找到的一個N皇后問題最快的算法,看了好一會才明白,這算法巧妙之處我認爲有2個:

   1、以前都是用數組來描述狀態,而這算法採用是的位來描述,運算速度可以大大提升,以後寫程序對於描述狀態的變量大家可以借鑑這個例子,會讓你的程序跑得更快                        

   2、描述每行可放置的位置都是隻用row,ld,rd這3個變量來描述,這樣使得程序看起來挺簡潔的。

回溯法

去判斷出這個節點以及這個節點向下派生出的所有節點就不再有必要進行遍歷了,這樣就會避免4+4×4次的完全無用功的遍歷,就會大大的節省時間,再去探索第二行的第二個節點……其他的同理。

這樣,如果能夠成功遍歷到葉子節點,並且判斷該葉子節點的局面就是符合4皇后問題的,那麼這個節點局面就代表一個合法的四皇后問題的解。下面的圖片就代表找到的一個合法的解的過程(注意圖片中,虛線代表排除,黑實線代表繼續向下探索)
在這裏插入圖片描述
以上圖爲例,當在第i層出現非法的棋盤局面時,就跳回第i-1層,繼續探索第i-1層的那個節點的下一個分支;或者在第4層探索到合法的局面就進行記錄並跳回上一層,繼續探索下一個分支。其他三個解空間樹同理。

 以上圖爲例,就單看探索的第四層節點的個數。使用回溯法,就只需探索第4層中的4個節點,而如果使用窮舉法,就要探索玩第4層的所有64個節點,顯而易見,哪一個方法更有效。

其實在解決四皇后問題的時候,並不一定要真的構建出這樣的一棵解空間樹,它完全可以通過一個遞歸回溯來模擬。所謂的解空間樹只是一個邏輯上的抽象。當然也可以用樹結構來真實的創建出一棵解空間樹,不過那樣會比較浪費空間資源,也沒有那個必要

代碼

#include<stdio.h>
 
int count = 0;
int isCorrect(int i, int j, int (*Q)[4])
{
    int s, t;
    for(s=i,t=0; t<4; t++)
        if(Q[s][t]==1 && t!=j)
            return 0;//判斷行
    for(t=j,s=0; s<4; s++)
        if(Q[s][t]==1 && s!=i)
            return 0;//判斷列
    for(s=i-1,t=j-1; s>=0&&t>=0; s--,t--)
        if(Q[s][t]==1)
            return 0;//判斷左上方
    for(s=i+1,t=j+1; s<4&&t<4;s++,t++)
        if(Q[s][t]==1)
            return 0;//判斷右下方
    for(s=i-1,t=j+1; s>=0&&t<4; s--,t++)
        if(Q[s][t]==1)
            return 0;//判斷右上方
    for(s=i+1,t=j-1; s<4&&t>=0; s++,t--)
        if(Q[s][t]==1)
            return 0;//判斷左下方
 
    return 1;//否則返回
}
 
void Queue(int j, int (*Q)[4])
{
    int i,k;
    if(j==4){//遞歸結束條件
        for(i=0; i<4; i++){
                //得到一個解,在屏幕上顯示
            for(k=0; k<4; k++)
                printf("%d ", Q[i][k]);
            printf("\n");
        }
        printf("\n");
        count++;
        return ;
    }
    for(i=0; i<4; i++){
        if(isCorrect(i, j, Q)){//如果Q[i][j]可以放置皇后
            Q[i][j]=1;//放置皇后
            Queue(j+1, Q);//遞歸深度優先搜索解空間樹
            Q[i][j]=0;//這句代碼就是實現回溯到上一層
        }
    }
}
 
int main()
{
    int Q[4][4];
    int i, j;
    for(i=0; i<4; i++)
        for(j=0; j<4; j++)
            Q[i][j] = 0;
    Queue(0, Q);
    printf("The number of the answers are %d\n", count);
    return 0;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章