八皇后||演算法

夕陽下飛奔的豬發表於2020-07-21

一、背景

八皇后問題是一個以國際象棋為背景的問題:如何能夠在8×8個格子的國際象棋棋盤上放置八個皇后,使得任何一個皇后都無法直接吃掉其他的皇后, 為了達到此目的,任兩個皇后都不能處於同一條橫行、縱行或斜線上(中國象棋,車可以走橫線,縱線),問有多少種擺法,高斯認為有76種方案。1854年在柏林的象棋雜誌上不同的作者發表了40種不同的解,後來有人用圖論的方法解出92種結果。計算機發明後,有多種計算機語言可以程式設計解決此問題。

八皇后問題可以推廣為更一般的n皇后擺放問題:這時棋盤的大小變為n×n,而皇后個數也變成n。

當且僅當 n = 1n ≥ 4時問題有解.

皇后可攻擊範圍如圖所示:圖中藍色區塊不允許放其他皇后

圖片名稱

力扣(leetcode)原題連結:https://leetcode-cn.com/problems/n-queens-ii/

該題官方提供了2種方式,但是官方講解的並不容易理解,且在主對角線和次對角線描述反了,利用位運算的方法只提供了方法,我通過自己的理解畫了圖形,所謂有圖有真相,方便理解一些,在參考其他題解後,我再新增了一種利用斜率判斷安全位置的解法。

二、思路

1.窮舉法
時間複雜度:O(n^n)
如八皇后:8^8 = 16777216
進行暴力窮舉: n個for迴圈巢狀遍歷,找出所有合適的擺放方式,效率低下,不推薦!

2.遞迴和回溯法
時間複雜度: O(n!) =n*(n-2)(n-4)...
空間複雜度: O(n),儲存對角線是否被佔用以及列的資訊

  • 約束程式設計概念:
    在放置每個皇后以後會增加限制。當在棋盤上放置了一個皇后後,立即排除當前行,列和對應的兩個對角線。該過程傳遞了約束從而有助於減少需要考慮情況數。如下圖所示:
圖片名稱
  • 回溯演算法:
    解決一個回溯問題,實際上就是一個決策樹的遍歷過程。
    1、路徑:也就是已經做出的選擇。
    2、選擇列表:也就是你當前可以做的選擇。
    3、結束條件:也就是到達決策樹底層,無法再做選擇的條件。

    DFS(深度優先搜尋演算法) 也採用了回溯源節點方式直到遍歷完所有節點。

  • n皇后問題特點:
    1.一行一列只能有一個皇后 (橫線,縱線攻擊), 且每行、每列必須有一個皇后 (n*n 棋盤 要擺放n個皇后)
    2.皇后佔據的每條斜線有規律,可據此判斷當前位置是否安全(主對角線和次對角線的規律 )

擺放要求:任兩個皇后都不能處於同一條橫行、縱行或斜線上,問有多少種擺法
棋盤可用一個二維陣列表示

1.不能在同一行,每個皇后放置在一行後,保證下一個皇后在下一行擺放。

2.不能在同一列,用一維陣列記錄每列是否被皇后佔據,1:被佔用; 0: 未被佔用

3.如何確定是否在同一條斜線上?
有2種方式

  • 方法一:主對角線和次對角線上的常數規律

    相同主對角線上: row -col = 固定常數
    而且每條對角線的常數值不同,因此用來標識該對角線是否已經被皇后佔用

    如下圖所示:第一條主對角線上,row= col , row =col = 0 ,由右上數第二條 為-1,-2 ... 直到-7。

    注:

圖片名稱

注: 如果在主對角線右上側, col> row , 因此 const是負數,如果用陣列下標索引儲存會導致越界異常,因此可加一個固定正整數(大於等於n)避免 (下見程式碼可理解)。

相同次對角線上:row+col = const,每條次對角線的值也不相同。從上到下對角線的值從0到14依次遞增

圖片名稱
  • 方法二:根據斜率確定在同一條斜線上

    利用兩點之間斜率絕對值為 1,即夾角的正切值為1,夾角為 45度或者 135度,據此判斷是否在其他皇后的攻擊斜線上。

    公式: |y2-y1| = |x2-x1|

    斜率如圖所示:

    在放置第三個皇后時,排除已有皇后佔據的列,再剩下位置中與已經放置皇后的座標計算斜率,如果斜率絕對值為1,即不是安全位置。

圖片名稱

動態展示圖:

三、程式碼展示

方法一:官方解法,利用陣列記錄被佔據的列資訊,以及根據每條斜線被佔據記錄。

  • 初始化操作
 public void initQueen(int n) {
        /**
         * 一行一列只能有一個queen,記錄某列是否被佔用,int陣列記錄,陣列下標表示列;1表示佔用,0表示未被佔用
         */
        int[] cols = new int[n];
        /**主對角線特點: row -col = const, 對角線的個數是 2*n-1
         * 因為考慮row-col為負數情況(右上三角),會發生越界異常,加一個固定常數n,所以這裡陣列長度也變為 3*n-1,
         * 官方程式碼長度延長了2n, 即4*n-1。
         */
        int[] zhuDiagonal = new int[3*n - 1];
        /**
         * 次對角線特點: row+col =const,次對角線不用延長陣列長度,對角線的條數即為 2*n-1
         */
        int[] ciDiagonal = new int[ 2*n - 1];
        // 呼叫遞迴回溯方法,下見
        int count = back2Track(0, 0, n, cols, zhuDiagonal, ciDiagonal);
        System.out.printf("共有%d 種方法排列",count);
    }
  • 判斷當前位置是否安全
 public boolean isSafe(int row,int col,int n,int[] cols,int[] zhu, int[] ci) {
        int res = cols[col] + zhu[row-col+  n] + ci[row+col];
        return res ==0 ? true:false;
    }
//如果當前位置列,主對角線,次對線均為0,表示當前位置安全,可以擺放皇后。
  • 遞迴回溯
  public int back2Track(int row,int count, int n ,int[] cols,int[] zhu, int[] ci) {

        for(int col= 0; col< n; col++) {
            if(isSafe(row,col,n,cols,zhu,ci)) {
                /**
                 * 在安全位置,佔用
                 */
                //當前列,主對角線,列都佔用標誌:1
                cols[col] =1;
                zhu[row-col + n] =1;
                ci[row +col] =1;
                //遍歷到最後一行了,返回成功解一個
                if(row +1 ==n ) {count ++;}
                else {
                    //遍歷下一行,此時 row+1
                  count = back2Track(row+1,count,n,cols,zhu,ci);
                }
                //如果某行所有列都不安全,遞迴回退,將原來置為1的位置清除,繼續遍歷下一列
                cols[col] = 0;
                zhu[row-col+ n] =0;
                ci[row+col] =0;
            }
        }
        return count;
    }

方法二

關鍵方法:

1.利用一維陣列表示二維空間的棋盤中皇后的位置

2.利用兩點之間斜率絕對值為 1,即夾角的正切值為1,夾角為 45度或者 135度,據此判斷是否在其他皇后的攻擊斜線上。

程式碼如下:

  • 初始化
   /**
     * 表示n*n位棋盤,也表示n位皇后
     */
    int  n ;
    /**
     * 一維陣列儲存皇后位置,陣列下標代表行,陣列值代表列;例如: locations[0]= 0: 表示 第一行第一列有一位皇后
     */
    int[] locations;
    /**
     * 記錄可排列種類有多少
      */
    static int maxCount;
  • 判斷是否安全
 /**
     * 判斷第k個皇后在第k行某列是否安全
     */
    private boolean isSafe(int k) {
        //與前n-1個皇后比較
        for(int i = 0 ; i< k; i++) {
            //這裡較難理解
            //1, 因為locations中記錄前k-1行皇后的列擺放位置,陣列下標代表行,陣列值代表列
            //如果locations[i] == locations[k],則表示這一列已經有皇后佔據了,衝突不安全
            //2,Math.abs(k- i) == Math.abs(locations[k] - locations[i]) 利用的是|y2-y1| = |x2-x1| 公式,斜率為1,也不安全
            if(locations[i] == locations[k] || Math.abs(k- i) == Math.abs(locations[k] - locations[i]))  {
                return false;
            }
        }
        return true;
    }
  • 遞迴回溯方法
//k表示第k行,也表示第k個皇后,  
private void  check(int k) {
        if(k ==n) {
            print();
            //成功計數器+1
            maxCount ++;
            return;
        }
        for (int i = 0; i<n; i++) {
            //遍歷列,首先就將當前列設值,在判斷安全時會進行比較
            locations[k] =i;
            if(isSafe(k)) {
                //如果安全,在遞迴k+1行
                check(k+1);
            }
        }
    }
  • 列印每種解的皇后擺放
  /**
     *  列印皇后可行排列順序
     */
    private void  print() {
        for (int col: locations) {
            System.out.print(col + " ");
        }
        System.out.println();
    }
圖片名稱

一行代表一種擺放方式
輸出結果可以用遊戲驗證:8皇后遊戲:http://360.6822.com/www1.9/play_76277.html

方法三:利用位運算實現

計算機對位運算計算更快,這個方法實現很巧妙,對位運算會有更深的理解。

在看該方法前,先複習一下位運算的基本運算,後面會用上。

  • 位運算的基本運算
1.按位與 & : 有0則為0,  只有當兩位都是 1 時結果才是 1,否則為0。
2.按位或 | : 有1則為1,即兩位中只要有 1 位為 1 結果就是 1,兩位都為 0 則結果為 0。
3. 取反 ~  : 0 則變為 1,1 則變為 0。
4.左位移 << :向左進行移位操作,高位丟棄,低位補 0
              如 1<<3   1向左位移3位  000000001    -->  00001000  = 2^3 =8 (10)
5.右位移 >> :向右進行移位操作,對無符號數,高位補 0,對於有符號數,高位補符號位
            如 00001011 向右位移2位,00001011>>2  = 00000010, 左邊2位丟棄,高位補0

示例圖如下:

與運算

或運算

1.原碼:是最簡單的機器數表示法。用最高位表示符號位,‘1’表示負號,‘0’表示正號。其他位存放該數的二進位制的絕對值。
2.反碼:正數的反碼還是等於原碼,負數的反碼就是他的原碼除符號位外,按位取反。
3.補碼: 正數的補碼等於他的原碼,負數的補碼等於反碼+1。

注:正數的原碼,反碼,補碼相同,計算機中運算是以補碼的形式儲存計算的。

本次演算法相關:

  1. x & -x : 該運算只保留x最右邊的第一個1 ,其餘置為0。
    例如:x= 00110110, -x = 10110110 (負值是符號位取反,其他位不變), x的補碼還是其原碼,-x的補碼是反碼+1
    即 01001001 (反碼) +1 = 01001010,兩數按位與計算結果
    00110110
    01001010
    =00000010
  2. x&(x-1): 該運算清除x最右邊的第一個1為0,其餘位值不變 。
    可以證明:1,最低位為1,直接清0;2,最低位為0,中間某個位置為1,-1會向前借位,導致最右邊第一個1,被借位為0 .

提示: 該演算法遍歷是從低位開始,是從右到左的遍歷,上2個方法是從左到右的方式,這個方法巧妙在於之前的方法是用陣列儲存被佔用的位置,這個就是用3個int型別值,int 4個位元組,32位來替代陣列表示。

下面我們來看如何使用3個int型別的值 表示列,主對角線,次對角線佔用資訊的

int column // 位元位記錄列被佔用資訊,如下圖: 1001000表示 第1列和第4列被佔用

圖片名稱

int pie //表示左斜線,即次對角線的佔用資訊, 0010000,傳遞到第3行時,表示第3行3列位置不安全。

圖片名稱

int na // 表示右斜線,即主對角線的佔用資訊,00101000

圖片名稱

利用位運算 或,即可求得下一行的所有被佔用的情況,運算結果如下圖:

10111000 ,三個變數進行或運算,求出 1,3,4,5位置會被攻擊,為0的位置是可以擺放皇后的

左斜線(pie)傳遞到下一行的約束推導,如下圖, pie 與 當前行放置皇后位置求並集,再向左位移1位,即傳遞到下一行的左斜線約束

,同理,右斜線(na)求並集向右位移1位

所以公式如下:

p: 代表該行皇后擺放的位置,如 00000001

  1. 確定下一行可以哪些位置可以擺放皇后: cloumn | p

  2. 獲取下一行左下斜被佔用的情況: (pie | p) << 1

  3. 獲取下一行右下斜被佔用的情況: (na | p) >> 1

  4. 清除該行擺放皇后的位置: bits = bits & (bits - 1)

程式碼如下:

int totalNQueens(int n) {
      return backTrack(0,0,0,0,n);
    }
   //用於記錄一共有多少種方式
    private int count;

    public int backTrack(int row, int column, int pie, int na,int n) {
        if(row == n) {
            count++;
            return count;
        }
        /**下一行的可擺放的位置,1:代表可以擺放;0:不可以
         * (column | pie | na),表示該行可以擺放的位置,此時 0 代表可以擺放,~ 取反方便下一步操作,1代表可以擺放
         * 但是int型別 32 位,取反高位為1了,不需要,因此 (1<<n -1)按位與,抹去高位為0,只留下需要的n個低位
         * 這裡為什麼要取反,1表示可以擺放的位置了,是為了方便bits與0比較 
         */
        int bits = ~(column | pie | na) & ((1 << n) - 1);
        /**
         * 大於0,表示存在1,有可以擺放皇后的位置
         */
        while (bits > 0) {
            /**
             * 1,取出該行最右邊的為1的那一位,表示可以擺放
             * 涉及到計算機儲存的是補碼問題
             */
            int p = bits & -bits;

            backTrack(row + 1, column | p, (pie | p) << 1, (na | p) >> 1, n);
            /**
             * 抹去最右邊為1的那位,將1變為0,繼續從右遍歷第二位為1的
             */
            bits = bits & (bits - 1);
        }
        return count;
    }

相關文章