區域性搜尋解千萬級別N皇后問題
技術標籤:演算法
文章目錄
區域性搜尋解n皇后,並測試n的極限
實驗v0
v0版本我實現了最基本的區域性搜尋演算法,該版本解 n = 1000 n=1000 n=1000需要超過 7 7 7分鐘, n = 10000 n=10000 n=10000時間太長,超過極限。
狀態的表示
首先,使用哪種資料結構表示n皇后問題的一個狀態是很重要的,直接影響到演算法的效率。我使用一個一維陣列來表示狀態,i, board[i]
表示一個皇后的行、列位置。
初始時要隨機生成一個狀態,此時就可以解決掉所有行和列上的衝突,即保證每一行、每一列都只有一個皇后,那麼這個過程就可以編碼成隨機生成一個元素值在[0, n - 1]
指標函式
n皇后問題的指標可以用皇后間的衝突數來表示,顯然我們的目標是最小化衝突數到0,即找到n皇后的一個解。v0版本中我採用暴力的計算衝突數方法,即遍歷所有的皇后對,判斷它們是否在彼此的對角線上:
int get_conflicts(int n) {
int ans = 0;
for (int i = 0; i < n - 1; ++i) {
for (int j = i + 1; j < n; ++j) {
if (j - i == abs(board[i] - board[j])) ans++ ;
}
}
return ans;
}
該指標函式時間複雜度 O ( n 2 ) O(n^2) O(n2)
狀態選擇
通過交換兩個皇后的列位置,可以得到當前狀態的鄰域P,我們按照貪心策略,從P中隨機選取一個元素作為P’,那麼P’的最優解就只有這個隨機選取的元素。狀態選擇就變成了交換任意兩個皇后的列位置,若交換後衝突數減少,則選擇這個新狀態。
跳出區域性最優
當所有交換都不會導致衝突數減少,即鄰域P為空時,我們就進入了局部最優狀態,但求解n皇后問題我們是得找到全域性最優,解決方案是隨機重啟,即重新生成初始狀態並計算衝突數。
總體流程程式碼
bool restart = true;
int curr;
while (true) {
if (restart) {
random_start(n);
curr = get_conflicts(n);
}
if (curr == 0) break;
restart = true;
for (int i = 0; i < n - 1; ++i) {
for (int j = i + 1; j < n; ++j) {
swap(board[i], board[j]);
int tmp = get_conflicts(n);
if (tmp < curr) {
curr = tmp;
restart = false;
}
else swap(board[i], board[j]);
}
}
}
值得一提的是,表示狀態的陣列最好採用全域性變數,這樣不僅可以定義更大的陣列,而且相比於在程式中new
、delete
陣列,速度快很多倍。v0版本如果改成在程式中new
陣列,則解
n
=
1000
n=1000
n=1000需要17分鐘。
實驗v1
優化區域性搜尋演算法主要有3種思路:
- 引入選擇概率
- 改變步長
- 多次生成初始解
v0版本中已經採用了思路3來幫助找到全域性最優解。如果採用思路1的話,計算選擇概率需要用到鄰域內所有鄰居的指標函式值,而鄰域大小是 n ( n − 1 ) / 2 n(n - 1) / 2 n(n−1)/2,這導致選擇過程會是 O ( n 2 ) O(n^2) O(n2)的,因此基本上不採用這種優化思路。對於思路2,經過測試,改變步長為3(選取3個皇后參與交換)也不會改進演算法效率,反而導致演算法收斂更加不穩定,如果在執行過程中動態調整步長的話,何時調整也是需要探究的問題。總之,這兩者都不是好的優化思路。
衝突表
v0版本存在的一個明顯的問題就是計算衝突數的方法,暴力的方法複雜度 O ( n 2 ) O(n^2) O(n2),每一次狀態選擇時都需要這樣一個 O ( n 2 ) O(n^2) O(n2)的過程是我們無法接受的。通過引入衝突表,我們只需要 O ( 1 ) O(1) O(1)的時間就能夠計算出新狀態的衝突數。
我們知道衝突只會出現在對角線上,一個n x n
的矩陣有多少條對角線呢?根據定義有主對角線(左上->右下)和反對角線(左下->右上),每種都有
2
n
−
1
2n - 1
2n−1條。將這些對角線按規則編號,
n
=
6
n=6
n=6時的一個示例如圖:
主對角線 | 反對角線 |
因此,對於一個位置在i, board[i]
( board[i]
為列號)的皇后,她相應地也在編號為i - board[i] + n - 1
的主對角線上,也在編號為i + board[i]
的反對角線上。
這樣,我們就可以把皇后的位置對映成對角線上的皇后個數,從而算出衝突數並放入衝突表中:
int pos_diag[2 * MAX - 1]; // 主對角線上的皇后個數
int neg_diag[2 * MAX - 1]; // 反對角線上的皇后個數
int get_conflicts(int n) {
int ans = 0;
memset(pos_diag, 0, sizeof(int) * (2 * n - 1));
memset(neg_diag, 0, sizeof(int) * (2 * n - 1));
for (int i = 0; i < n; ++i) {
pos_diag[i - board[i] + n - 1]++;
neg_diag[i + board[i]]++;
}
for (int i = 0; i < 2 * n - 1; ++i) {
ans += pos_diag[i] > 1 ? pos_diag[i] - 1 : 0;
ans += neg_diag[i] > 1 ? neg_diag[i] - 1 : 0;
}
return ans;
}
注意,此時我們計算出的狀態數跟v0版本的是不同的,如果一條對角線上有 n n n個元素,那麼此時的衝突數是 n − 1 n-1 n−1,但我們的優化目標都是衝突數為0,所以不影響。
更新衝突表
狀態選擇時我們需要計算新狀態的衝突數,get_conflicts
函式此時複雜度為
O
(
n
)
O(n)
O(n),似乎已得到了不錯的優化,但通過維護衝突表,可以進一步把這個過程優化到
O
(
1
)
O(1)
O(1)。
交換位於i, board[i]
和位於j, board[j]
的兩個皇后會對衝突數造成什麼影響?
首先我們會把皇后從原始位置挪開,那麼原始位置對應的對角線上的皇后個數自然減1,如果這些對角線上原本就有衝突,那麼按照衝突數的定義,皇后個數減1會導致衝突減1;我們會把皇后挪到目標位置,那麼目標位置對應的對角線上的皇后個數加1,如果這些對角線上原本就有皇后,那麼衝突數自然也會加1。
int swap_gain(int i, int j, int n) {
int gain = 0;
if (neg_diag[i + board[i]] > 1) gain--;
if (neg_diag[j + board[j]] > 1) gain--;
if (pos_diag[i - board[i] + n - 1] > 1) gain--;
if (pos_diag[j - board[j] + n - 1] > 1) gain--;
if (neg_diag[i + board[j]] > 0) gain++;
if (neg_diag[j + board[i]] > 0) gain++;
if (pos_diag[i - board[j] + n - 1] > 0) gain++;
if (pos_diag[j - board[i] + n - 1] > 0) gain++;
return gain;
}
當gain < 0
時,說明新狀態的衝突數更少。
選擇新狀態後,需要更新狀態表以及交換皇后的列位置:
void update_state(int i, int j, int n) {
neg_diag[i + board[i]]--;
neg_diag[j + board[j]]--;
pos_diag[i - board[i] + n - 1]--;
pos_diag[j - board[j] + n - 1]--;
neg_diag[i + board[j]]++;
neg_diag[j + board[i]]++;
pos_diag[i - board[j] + n - 1]++;
pos_diag[j - board[i] + n - 1]++;
swap(board[i], board[j]);
}
狀態選擇
在v0版本中還存在的一個問題就是新狀態的選擇,思路是隨機選取兩個皇后,但v0版本的實現卻沒有引入隨機因素,而是按一定順序選取的。這同樣會極大地影響演算法效率。經過測試,引入這個隨機因素使得程式求解 n = 100000 n=100000 n=100000的時間從 120 120 120秒顯著降到了 6 6 6秒左右。
總體流程程式碼
bool restart = true;
int curr;
clock_t start = clock();
while (true) {
if (restart) {
random_start(n);
curr = get_conflicts(n);
}
if (curr == 0) break;
restart = true;
// 隨機交換兩個皇后,嘗試次數不超過鄰域大小
long long max_iteration = (long long)n * (long long)n / 2;
for (long long i = 0; i < max_iteration; ++i) {
int i_index = get_randindex(n), j_index = get_randindex(n);
int gain = swap_gain(i_index, j_index, n);
if (gain < 0) {
update_state(i_index, j_index, n);
curr += gain;
restart = false;
break;
}
}
}
實驗結果
實驗結果如下所示,v1版本很快解決了 n = 1000 n=1000 n=1000,解 n = 10000 n=10000 n=10000平均時間 0.36 0.36 0.36秒,解 n = 100000 n=100000 n=100000平均時間 5.76 5.76 5.76秒,解 n = 200000 n=200000 n=200000平均時間 13.33 13.33 13.33秒,解 n = 500000 n=500000 n=500000平均時間 43.23 43.23 43.23秒,解 n = 1000000 n=1000000 n=1000000平均時間約兩分鐘。
實驗v2
查閱相關文獻,我發現在Rok Sosic發表的3,000,000 Queens in Less Than One Minute這篇論文中,他們提出了QS4演算法,而我實現的v1版本實際上是QS1演算法。相比於QS1演算法,QS4演算法對“隨機初始化”這個過程進行了優化,最終速度要快上幾百倍。
隨機初始化
Rok Sosic等人提出,完全隨機的初始化會導致初始狀態存在的衝突數約為 0.53 n 0.53n 0.53n,如果限制這個衝突數的大小為 c c c,那麼會極大地提升QS1演算法的效能。
初始化的思想為:
- 一行行選取皇后放置的列位置,以放置好的就不再移動
- 先放置 n − c n-c n−c個皇后,它們不應該產生衝突,因此需要為它們隨機尋找空閒的列位置
- 再放置 c c c個皇后,此時可以完全隨機放置
int random_start_qs4(int n, int c) {
int m = n - c;
for (int i = 0; i < n; ++i) board[i] = i;
memset(pos_diag, 0, sizeof(int) * (2 * n - 1));
memset(neg_diag, 0, sizeof(int) * (2 * n - 1));
for (int i = 0, last = n; i < m; ++i, --last) {
int j = i + get_randindex(last);
while (pos_diag[i - board[j] + n - 1] > 0 || neg_diag[i + board[j]] > 0) j = i + get_randindex(last);
swap(board[i], board[j]);
pos_diag[i - board[i] + n - 1]++;
neg_diag[i + board[i]]++;
}
for (int i = m, last = c; i < n; ++i, --last) {
int j = i + get_randindex(last);
swap(board[i], board[j]);
pos_diag[i - board[i] + n - 1]++;
neg_diag[i + board[i]]++;
}
}
狀態選擇
狀態選擇的過程與QS1演算法類似,只不過此時限定了第一個選取的皇后必須是會產生衝突的,第二個選取的皇后則可以完全隨機選取,也可以按順序選取。
總體流程程式碼
bool restart = true;
int curr;
int m = n - c;
while (true) {
if (restart) curr = random_start_qs4(n, c);
if (curr == 0) break;
restart = true;
int gain = 0;
for (int i = m; i < n; ++i) {
if (pos_diag[i - board[i] + n - 1] > 1 || neg_diag[i + board[i]] > 1) {
for (int j = 0; j < n; ++j) {
if (i != j) {
gain = swap_gain(i, j, n);
if (gain < 0) {
update_state(i, j, n);
curr += gain;
restart = false;
break;
}
}
}
if (gain < 0) break;
}
}
}
實驗結果
最終結果如下圖所示,解 n = 1000000 n=1000000 n=1000000僅需 0.4 0.4 0.4秒,解 n = 5000000 n=5000000 n=5000000平均時間 5.5 5.5 5.5秒,解 n = 10000000 n=10000000 n=10000000平均時間 9.84 9.84 9.84秒,解 n = 20000000 n=20000000 n=20000000平均時間 20.392 20.392 20.392秒,v2版本可以快速解決千萬級別的n皇后問題。
程式碼
完整程式碼地址:https://github.com/chenf99/AI/blob/master/NQueen/localSearch_NQueen.cpp