回溯演算法 | 追憶那些年曾難倒我們的八皇后問題

  • 2020 年 10 月 18 日
  • 筆記

文章收錄在公眾號:bigsai 更多精彩乾貨敬請關注!

前言

說起八皇后問題,它是一道回溯演算法類的經典問題,也可能是我們大部分人在上數據結構或者演算法課上遇到過的最難的一道題……
在這裡插入圖片描述

第一次遇到它的時候應該是大一下或者大二這個期間,這個時間對啥都懵懵懂懂,啥都想學卻發現好像啥都挺難的,八皇后同樣把那個時候的我阻攔在外,我記得很清楚當時大二初我們學業導師給我們開班會時候講到的一句話很清晰:「如果沒有認真的學習演算法他怎麼可能解出八皇后的程式碼呢」。

確實,那個時候的我搞不懂遞歸,回溯也沒聽過,連Java的集合都沒用明白,毫無邏輯可言,八皇后對我來說確實就是無從下手。

但今天,我可以吊打八皇后了,和你們一起白銀萬兩,佳麗三十。在這裡插入圖片描述

淺談遞歸

對於遞歸演算法,我覺得掌握遞歸是入門數據結構與演算法的關鍵,因為後面學習很多操作涉及到遞歸,例如鏈表的一些操作、樹的遍歷和一些操作、圖的dfs、快排、歸併排序等等。
在這裡插入圖片描述

遞歸的實質還是藉助棧實現一些操作,利用遞歸能夠完成的操作使用棧都能夠完成,並且利用棧的話可以很好的控制停止,效率更高(遞歸是一個來回的過程回來的時候需要特判)。

遞歸實現和棧實現操作的區別,遞歸對我們來說更簡潔巧妙,並且用多了會發現很多問題的處理上遞歸的思考方式更偏向人的思考方式,而棧的話就是老老實實用電腦(數據結構特性)的思維去思考問題。這個你可以參考二叉樹的遍歷方式遞歸和非遞歸版本,複雜性一目了然。

從遞歸演算法的特徵上來看,遞歸演算法的問題都是父問題可以用過一定關係轉化為子問題。即從後往前推導的過程,一般通過一個參數來表示當前的層級。

而遞歸的主要特點如下:

  • 自己調用自己
  • 遞歸通常不在意具體操作,只關心初始條件和上下層的變化關係。
  • 遞歸函數需要有臨界停止點,即遞歸不能無限制的執行下去。通常這個點為必須經過的一個數。
  • 遞歸可以被棧替代。有些遞歸可以優化。比如遇到重複性的可以藉助空間記憶體記錄而減少遞歸的次數。

在這裡插入圖片描述

而通常遞歸演算法的一個流程大致為:

定義遞歸演算法及參數
- 停止遞歸演算法條件
- (可存在)其他邏輯
- 遞歸調用(參數需要改變)
- (可存在)其他邏輯

如果還是不理解的話就要看我的另一篇文章了:數據結構與演算法—遞歸演算法(從階乘、斐波那契到漢諾塔的遞歸圖解),寫的是真的好!

回溯演算法

談完遞歸,你可能明白有這麼一種方法可以使用,但你可能感覺單單的遞歸和八皇后還是很難扯上關係,是的沒錯,所以我來講回溯演算法了。

這裡插個小插曲。前天(真的前天)有個舍友我們宿舍一起聊天的時候談到回溯演算法,他說回shuo(朔)演算法,我們差異的糾正了一下是回su(素)演算法,他竟然讀錯了四年……不知道路過的你們有沒有讀錯的。
在這裡插入圖片描述
咱們言歸正傳,演算法界中,有五大常用演算法:貪心演算法、分治演算法、動態規劃演算法、回溯演算法、分支界限演算法。咱們回溯演算法就是五大之一,因為回溯演算法能夠解決很多實際的問題,儘管很多時候複雜度可能不太小,但大部分情況都能得到一個不錯的結果。

對於回溯法的定義,百度百科是這麼定義的:

回溯演算法實際上一個類似枚舉的搜索嘗試過程,主要是在搜索嘗試過程中尋找問題的解,當發現已不滿足求解條件時,就「回溯」返回,嘗試別的路徑。回溯法是一種選優搜索法,按選優條件向前搜索,以達到目標。但當探索到某一步時,發現原先選擇並不優或達不到目標,就退回一步重新選擇,這種走不通就退回再走的技術為回溯法,而滿足回溯條件的某個狀態的點稱為「回溯點」。許多複雜的,規模較大的問題都可以使用回溯法,有「通用解題方法」的美稱。

對於回溯法,它的核心就是試探和復原。這個自動化的過程就是利用遞歸去執行,在遞歸函數執行前去修改嘗試,滿足條件後向下遞歸試探,試探完畢後需要將數值復原。在這個試探的過程中找到我們所需要的一個或者所有解。這個我們也俗稱暴力。

在這裡插入圖片描述
啥?沒聽懂?好,那我就再講講,你應該知道深度優先搜索(dfs)吧?其實回溯演算法就是一種特殊的dfs。之所以叫回溯,就是因為這類演算法在運用遞歸都有個復原的過程,所以前面的操作就相當於試探一樣。而這類演算法一般常常配對一個或多個boolean類型的數組用來標記試探途中用過的點。

舉個例子,我們知道回溯演算法用來求所有數字的排列順序。我們分析其中一個順序。比如數列6 8 9這個序列的話,我們用來求它的排列順序。

對於程式碼塊來說,這可能很容易實現:

import java.util.Arrays;

public class test {
    public static void main(String[] args) {
        int arr[]={6,8,9};//需要排列組合的數組
        int val[]={0,0,0};//臨時儲存的數組
        boolean jud[] = new boolean[arr.length];// 判斷是否被用
        dfs(arr,val, jud,  0,"");//用一個字元串長度更直觀看結果
    }

    private static void dfs(int[] arr, int val[],boolean[] jud, int index,String s) {
        System.out.println(s+Arrays.toString(val));
        if (index == arr.length){ }//停止遞歸條件
        else{
            for (int i = 0; i < arr.length; i++) {
                if (!jud[i]) {//當前不能用的
                    int team=val[index];
                    val[index] = arr[i];
                    jud[i] = true;// 下層不能在用
                    dfs(arr, val, jud, index + 1,s+"  _  ");
                    jud[i] = false;// 還原
                    val[index]=team;
                }
            }
        }
    }
}

 

而執行的結果為:
在這裡插入圖片描述
這裡再配張圖理解:
在這裡插入圖片描述
而通常回溯演算法的一個流程大致為:

定義回溯演算法及參數
- (符合條件)跳出
- (不符合)不跳出:
- - 遍歷需要操作的列表&&該元素可操作&&可以繼續試探
- - - 標記該元素已使用以及其他操作
- - - 遞歸調用(參數改變)
- - - 清除該元素標記以及其他操作

也就是在使用數組進行回溯的時候,使用過的時候需要標記子遞歸不能再使用防止死循環,而當回來的時候需要解封該位置,以便該編號位置被其他兄弟使用之後這個數值在後面能夠再次使用!而如果使用List或者StringBuilder等動態空間用來進行回溯的時候記得同樣的復原,刪了要記得增,減了要記得加。搞明白這些,我想回溯演算法也應該難不倒你了吧。

八皇后問題

掌握了回溯演算法的關鍵,八皇后問題多思考就可以想的出來了。前面的講解都是為了解決八皇后問題做鋪墊。首先,我們認真的看下八皇后問題描述。

八皇后問題(英文:Eight queens),是由國際西洋棋棋手馬克斯·貝瑟爾於1848年提出的問題,是回溯演算法的典型案例。

問題表述為:在8×8格的國際象棋上擺放8個皇后,使其不能互相攻擊,即任意兩個皇后都不能處於同一行、同一列或同一斜線上,問有多少種擺法。高斯認為有76種方案。1854年在柏林的象棋雜誌上不同的作者發表了40種不同的解,後來有人用圖論的方法解出92種結果。如果經過±90度、±180度旋轉,和對角線對稱變換的擺法看成一類,共有42類。電腦發明後,有多種電腦語言可以編程解決此問題。
在這裡插入圖片描述

我們該怎麼思考這種問題呢?也就是從何入手呢?

  • 從限制條件入手

八皇后問題有以下限制條件:

  • 8 x 8的方格
  • 每行一個,共八行(0-7)
  • 每列一個,共八列(0-7)
  • 每左斜杠一個,共十五左斜杠(0-14)
  • 每右斜杠一個,共十五右斜杠(0-14)

當看到這些限制條件,肯定想到這麼多限制條件需要判斷。判斷的話當然就是藉助boolean數組啦。還是一維的8個大小,所以我們首先用4個boolean數組用來判斷各自的條件是否被滿足。

表示這個圖的話我們可以使用一個int類型數組表示,0表示沒有,1表示有皇后。

那麼如何去設計這個演算法呢?這個並不是每個格子都有數字,所以在進行回溯的時候不應該每個格子每個格子進行向下遞歸(同行互斥),也就是遞歸到當前層的時候,循環遍歷該層的八種情況進行試探(每個都試探),如果不滿足條件的就不操作而被終止掉,但該行每個滿足條件的需要遞歸的時候需要進入到下一行。

當然你需要提前知道當前位置橫縱坐標怎們知道對應的boolean位置(位置從0號開始計算)。例如位置p(x,y)中對應的位置為:

  • hang[] : x 每一行就是對應的i。
  • lie[] : y 每一列就是對應的j。
  • zuoxie[] : x+y 規定順序為左上到右下
  • youxie[] : x+(7-y) 規定順序為右上到左下(個人習慣)

在這裡插入圖片描述

好啦,該演算法的實現程式碼為:

import java.util.Arrays;

public class EightQueens {
    static  int allnum=0;
    public static void main(String[] args) {
        boolean hang[]=new boolean[8];//
        boolean lie[]=new boolean[8];//
        boolean zuoxie[]=new boolean[15];//左斜杠
        boolean youxie[]=new boolean[15];//右斜杠
        int map[][]=new int[8][8];//地圖
        dfs(0,hang,lie,zuoxie,youxie,map);//進行下去
    }

    private static void dfs(int hindex, boolean[] hang, boolean[] lie, boolean[] zuoxie, boolean[] youxie, int[][] map) {
        if(hindex==8){
            allnum++;
            printmap(map);//輸出map
        }
        else{
            //hindex為行  i為具體的某一列
            for(int i=0;i<8;i++)
            {
                if(!hang[hindex]&&!lie[i]&&!zuoxie[hindex+i]&&!youxie[hindex+(7-i)])
                {
                    hang[hindex]=true;//試探
                    lie[i]=true;
                    zuoxie[hindex+i]=true;
                    youxie[hindex+(7-i)]=true;
                    map[hindex][i]=1;
                    dfs(hindex+1,hang,lie,zuoxie,youxie,map);//dfs
                    hang[hindex]=false;//還原
                    lie[i]=false;
                    zuoxie[hindex+i]=false;
                    youxie[hindex+(7-i)]=false;
                    map[hindex][i]=0;
                }
            }
        }
    }
    //輸出地圖
    private static void printmap(int[][] map) {
        System.out.println("第"+allnum+"個排列為");
        for(int a[]:map)
        {
            System.out.println(Arrays.toString(a));
        }
    }
}

 

跑一邊就知道到底有多少種皇后,最終是92種皇后排列方式,不得不說能用數學方法接出來的是真的牛叉。
在這裡插入圖片描述

八皇后變種

此時我想八皇后問題已經搞得明明白白了,但是智慧的人們總是想出各種方法變化題目想難到我們,這種八皇后問題有很多變種,例如n皇后,數獨等問題。

這裡就簡單講講兩數獨問題的變種。

力扣36 有效的數獨
在這裡插入圖片描述
像這種題需要考慮和八皇后還是很像,改成9*9,只不過在具體處理需要考慮3x3小方格

當然這題比較簡單,還有一題就比較麻煩了 力扣37解數獨。

在這裡插入圖片描述
在這裡插入圖片描述
這一題有難度的就是需要我們每個位置都有數據都要去試探。

這種二維的回溯需要考慮一些問題,我們對於每一行每一行考慮。 每一行已經預有一些數據事先標記,在從開始試探放值,滿足條件後向下遞歸試探。一直到結束如果都滿足那麼就可以結束返回數組值。

這裡的話有兩點需要注意的在這裡提一下:

  • 用二維兩個參數進行遞歸回溯判斷起來誰加誰減比較麻煩,所以我們用一個參數index用它來計算橫縱坐標進行轉換,這樣就減少二維遞歸的一些麻煩。
  • 回溯是一個來回的過程,在回來的過程正常情況需要將數據改回去,但是如果已經知道結果就沒必要再該回去可以直接停止放置回溯造成值的修改(這裡我用了一個isfinish的boolean類型進行判斷)。

程式碼可以參考為:
在這裡插入圖片描述

結語

好啦,不知道這個專題結束之後能否能夠掌握這個八皇后的回溯演算法以及思想,能否理清遞歸,回溯,深搜以及八皇后為關係?

總的來說

  • 遞歸更注重一種方式,自己調用自己。
  • 回溯更注重試探和復原,這個過程一般藉助遞歸。
  • dfs深度優先搜素,一般用棧或者遞歸去實現,如果用遞歸可能會復原也可能不復原數據,所以回溯是深搜的一種。
  • 八皇后是經典回溯演算法解決的問題,你說深度優先搜素其實也沒問題,但回溯更能精準的描述演算法特徵。

好啦,不說啦,我bigsai去領取佳麗30和白銀萬兩啦!(不錯的話記得一鍵三聯,微信搜索bigsai,回復bigsai 下次迎娶美杜莎女王!)

在這裡插入圖片描述