這兩天在寫AI的課程實驗,趁剛剛完結實驗代碼,腦海中還有些思路,在此簡單總結一下。
目錄
問題描述
2N皇后問題:給定一個n*n的棋盤。現要向棋盤中放入n個黑皇后和n個白皇后,使任意的兩個黑皇后都不在同一行、同一列或同一條對角線上,任意的兩個白皇后都不在同一行、同一列或同一條對角線上。請盡量快地給出一組可行解。
關于N皇后問題
N皇后問題是一個很經典的問題,在大家最初學習算法的時候都有討論過。回溯法是經典的解法,但是隨著N的增大,其復雜度的增加呈指數增長。哪怕N只是為100,使用回溯解法的話,運行也要相當久的時間。
簡單分析
對于2N皇后問題,很多同學的第一想法可能是兩次求解N皇后問題,且第二次求解時為擺放位置設限。然而,這種做法不免顯得過于繁瑣。
我們在這里,不妨先簡單地分析了一下幾種情況:
1. 當N為偶數時:其實只要求得一組可行解即可,另外一組可行解可以由當前解沿N*N矩陣的中軸線作對稱變換得到。因為N為偶數,所以不會存在黑白皇后位置沖突的情況。
例如:N=4時,求得一組可行解為:3 1 4 2,按著這個思路,變換得到另一組可行解為:2 4 1 3。可以判斷,這樣的兩組解合并起來是符合題目要求的。
2. 當N為奇數時:沿中軸線對稱的方式不再可行,因為肯定有一個皇后會落在中軸線上。此時,可以考慮通過中心點作點對稱的情況。
這里要注意,如果求得的可行解存在關于中心點對稱的皇后擺放(或有皇后位于中心點),那么此解不合要求,需要重新再求;直到沒有兩個皇后的位置是關于中心點對稱的,另一組解可以通過對當前解關于中心點作點對稱變換得到。
例如,N=5時,求得一組可行解為:2 4 1 3 5,按著這個思路,變換得到另一組可行解為:1 3 5 2 4。可以判斷,這樣的兩組解合并起來是符合題目要求的。
從上可以看出,其實2N皇后問題并不需要二次求解N皇后,在大多數情況下只需求得一組可行解即可。
爬山算法
在介紹爬山算法之前,我覺得很有必要先弄清楚什么是局部搜索。
局部搜索
從數學層面來理解,局部搜索是一種解決最優化問題的啟發式算法。
對于某些計算起來非常復雜的最優化問題,比如各種 NP 完全問題,要找到最優解所需要的時間會隨問題規模的增大呈指數增長,因此誕生了各種啟發式算法來退而求次地尋找局部最優解,而局部搜索算法就是其中的一種算法。
局部搜索算法從某一狀態(而不是多條路徑)出發,通常只移動到與當前狀態相鄰的狀態。而在典型情況下,搜索的路徑是不保留的。盡管局部搜索算法不是系統化的求解方法,但是它有幾個關鍵的優點:
1. 占用很少的內存,通常情況下容量是常數級別的。
2. 經常能在不適合系統化算法的很大或者無限的(連續的)狀態空間中快速找出合理的解。
爬山算法簡介
爬山算法屬于局部搜索算法的一份子,因此是一種解決最優化問題的啟發式算法。
在實際運用中,爬山算法不會前瞻與當前狀態不直接相鄰的狀態,只會選擇比當前狀態價值更好的相鄰狀態,所以簡單來說,爬山算法是向價值增長方向持續移動的循環過程。
由于它的貪婪特性,使得在解決問題中容易陷入局部極大值(Local maxima,指一個比所有鄰居狀態價值要高但是比全局最大值要低的狀態),我們能采取隨機重啟(Random restart)以及模擬退火(Simulated annealing)的方法來改進。本文的主要涉及的就是這兩種算法。
先在這里簡單地說一下它們之間的區別,主要在于如何選擇下一狀態以及如何有效地得到全局最優解:
1. 隨機重啟爬山算法:
求解過程中,當得到了局部極大值時,如果不是全局最優解,則隨機生成初始狀態,重新求解,直到得到全局最優解。
2. 模擬退火爬山算法:
基于隨機爬山算法,允許在隨機選擇相鄰狀態的時候有概率地選擇價值更小的狀態。在初期,向低價值狀態移動的概率高,隨著時間流逝該概率會越來越低。(溫度逐漸降低,即"退火"。)
算法實現與關鍵優化
初始化
不同于一般的隨機初始化。我實現時采用的初始化方式為:先依次為每一行的對應列擺上皇后,如第i 行,那么皇后就擺在第i 列,之后再隨機選擇交換皇后所在列。
這樣做的優點是可以保證在任一時刻,每一行每一列都只有一個皇后,大大縮小了搜索范圍,節省了程序運行時間。(從我的程序運行耗時可以明顯看出這一策略的優越性。)
具體的實驗代碼如下:
/*初始化函數:在每行每列放置一個皇后*/
void generate_status(int* status) {
for (int i = 0; i < N; i++) {
status[i] = i;
}
/*隨機交換*/
srand((unsigned)time(NULL));
for (int i = 0; i < N; i++) {
int r = rand();
r = r % N;
swap(status[r], status[N - r - 1]);
}
}
評價函數
對于每一個狀態,需要有一個評價函數對其進行估值評價。在此,我選用沖突數作為評價指標,存在沖突數越多的狀態,其評價就越差。顯然,最優解的評價結果為0,即不存在沖突。
為了進一步優化實驗運行效果,此函數我設置為內聯函數。
具體的實驗代碼如下:
/*評價函數:返回擺放狀態的沖突數*/
inline int evaluate(int* status, CollisionList& collision_list) {
collision_list.clear();
int num = 0;
for (int i = 0; i < N; i++) {
for (int j = i+1; j < N; j++) {
int offset = j - i;
if (abs(status[i] - status[j]) == offset) {
collision_list.push_back(j);
num += 1;
}
}
}
return num;
}
嘗試交換函數
對傳入的狀態進行交換嘗試,如果交換后的狀態評價結果小于當前傳入狀態,就進行交換,將新狀態返回;否則,不交換,直接返回原先的狀態。
對于模擬退火算法,這里就需要加上一個temperature變量,當鄰居狀態不是更優,但是溫度夠高,達到了振蕩指標時,也可以進行狀態轉換。同時,temperature值是在不斷減小的。
尋找下一個更優狀態的函數
對于傳入的狀態,不斷調用嘗試交換函數,直到獲得了沖突數更小的新狀態,即為一個更優狀態,將此狀態的沖突數作為返回值返回。
N皇后解法的主函數
這個函數的主要實現的就是調用初始化函數進行初始化,然后持續迭代調用尋找下一個更優狀態函數,直到返回的沖突數指標為0時,可以將此狀態作為求得的一組可行解再交還給main函數。
算法效率比較
下面就是見證奇跡的時刻啦~~~
筆者特意寫了一份回溯法求解N皇后問題的程序,作為對比參照。
N=10時:
額,好像N設的有點小了。。。。來個大點的。。。。
N=20吧:
什么?回溯法?這。尷尬了。。等了好久都沒跑出來結果。。。
退而求其次,我跑了個N=16的回溯法給大家瞧瞧,不過也是讓我足足等了5分多鐘:
可見當N>10以后,爬山算法的優越性盡顯無疑,我于是又給它上了幾個更大的數,具體的運行效果如下:
爬山算法 N=1000的耗時也不過是回溯法 N=16情況運行耗時的一半!
AI的強大之處可以略窺一二了吧~