1. 程式人生 > 其它 >區域性搜尋解千萬級別N皇后問題

區域性搜尋解千萬級別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 n n的陣列。

指標函式

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]); } } }

值得一提的是,表示狀態的陣列最好採用全域性變數,這樣不僅可以定義更大的陣列,而且相比於在程式中newdelete陣列,速度快很多倍。v0版本如果改成在程式中new陣列,則解 n = 1000 n=1000 n=1000需要17分鐘。

實驗v1

優化區域性搜尋演算法主要有3種思路:

  1. 引入選擇概率
  2. 改變步長
  3. 多次生成初始解

v0版本中已經採用了思路3來幫助找到全域性最優解。如果採用思路1的話,計算選擇概率需要用到鄰域內所有鄰居的指標函式值,而鄰域大小是 n ( n − 1 ) / 2 n(n - 1) / 2 n(n1)/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 2n1條。將這些對角線按規則編號, 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 n1,但我們的優化目標都是衝突數為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演算法的效能。

初始化的思想為:

  1. 一行行選取皇后放置的列位置,以放置好的就不再移動
  2. 先放置 n − c n-c nc個皇后,它們不應該產生衝突,因此需要為它們隨機尋找空閒的列位置
  3. 再放置 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