1. 程式人生 > >小朋友學經典演算法(14):回溯法和八皇后問題

小朋友學經典演算法(14):回溯法和八皇后問題

一、回溯法

回溯法(探索與回溯法)是一種選優搜尋法,又稱為試探法,按選優條件向前搜尋,以達到目標。但當探索到某一步時,發現原先選擇並不優或達不到目標,就退回一步重新選擇,這種走不通就退回再走的技術為回溯法,而滿足回溯條件的某個狀態的點稱為“回溯點”。

二、八皇后問題

(一)問題描述

1.png

在國際象棋中,皇后是最強大的一枚棋子,可以吃掉與其在同一行、列和斜線的敵方棋子。比中國象棋裡的車強幾百倍,比她那沒用的老公更是強的飛起(國王只能前後左右斜線走一格)。
八皇后問題是這樣一個問題:將八個皇后擺在一張8*8的國際象棋棋盤上,使每個皇后都無法吃掉別的皇后,一共有多少種擺法?
八皇后問題,是一個古老而著名的問題,是回溯演算法的典型案例。該問題是國際西洋棋棋手馬克斯·貝瑟爾於1848年提出。高斯認為有76種方案。1854年在柏林的象棋雜誌上不同的作者發表了40種不同的解,後來有人用圖論的方法解出92種結果。計算機發明後,有多種計算機語言可以解決此問題。

(二)分析過程

為了使問題簡化,假定國王與四位皇后離了婚,那麼只剩下四位皇后了。八皇后問題就變成了四皇后問題。

2.png

在第一行放1號皇后。第一行的四個格子都可以放。按列舉的習慣,先放在第一個格子。如下圖所示。黑色的格子不能放其他的皇后。

3.png

在第二行放2號皇后,只能放在第三個或第四個格子。按列舉的習慣,先放在第三個格子,如下圖所示。

4.png

不好了,前兩位皇后沆瀣一氣,已經把第三行全部鎖死了,第三位皇后無論放哪裡都難逃被吃掉的厄運。於是在第一個皇后位於1號,第二個皇后位於3號的情況下問題無解。我們只能返回上一步來,給2號皇后換個位置,挪到第四個格子上。

5.png

顯然,第三個皇后只有一個位置可選。當第三個皇后佔據第三行藍色空位時,第四行皇后無路可走,於是發生錯誤,返回上層挪動3號皇后,而3號也別無可去,繼續返回上層挪動2號皇后,2號已然無路可去,繼續返回上層挪動1號皇后。於是1號皇后改變位置如下,繼續搜尋。

6.png

分析到這裡,想必小朋友們對“回溯法”已經有了基本概念。下面要將演算法實現出來。

(三)程式碼實現

###1 queen()函式

void queen(int row)
{
    if(row == n)
    {
        // 從0到n-1行,全部都已經放上皇后了,所以答案+1
        total++;

        // 打印出n個皇后具體放在0~n-1行的第幾列
        for(int i = 0; i<n; i++)
        {
            cout << c[i] << " ";
        }
        cout << endl;
    }
    else
    {
        for(int col = 0; col != n; col++)
        {
            c[row] = col;
            if(check(row))
            {
                queen(row + 1);
            }
        }
    }
}

演算法是逐行安排皇后的,其引數row為現在正執行到第幾行。n是皇后數,在八皇后問題裡當然就是8啦。
if(row == n)這句程式碼好理解,如果程式執行了row == n,說明從0到n-1的位置都放上了皇后,那自然是找到了一種解法,於是八皇后問題解法數加1。
否則進入else語句。遍歷所有列col,將當前col儲存在陣列c裡,然後使用check()檢查row行col列能不能擺皇后,若能擺皇后,則遞迴呼叫queen去安排下一列擺皇后的問題。

還不太清楚?再慢點來,剛開始的時候row = 0,意思是要對第0行擺皇后了。
If判斷失敗,進入else,進入for迴圈,col初始化為0
顯然,0行0列的位置一定可以擺皇后的,因為這是第一個皇后啊,後宮空蕩她想怎麼折騰就怎麼折騰,於是check(0)測試成功,遞迴呼叫queen(1)安排第1行的皇后問題。

皇后放在第1行時即row=1,進來if依然測試失敗,進入for迴圈,col初始化為0。1行0列顯然是不能擺皇后的,因為0行0列已經有一個聖母皇太后在那擱著了,於是check()測試失敗,迴圈什麼也不做空轉一圈,col變為1。1行1列依然check()測試失敗,一直到1行2列,發現可以擺皇后,於是繼續遞迴queen(2)去安排第二個皇后位置。

如果在某種情況下問題無解呢?例如前面在4皇后問題中,0行0列擺皇后是無解的。假設前面遞迴到queen(2)時候,發現第2行沒有地方可以擺皇后,那怎麼辦呢?要注意queen(2)的呼叫是在queen(1)的for迴圈框架內的,queen(2)若無解,則自然而然queen(1)的for迴圈col自加1,即將第1行的皇后從1行2列改為1行3列的位置,檢查可否放皇后後繼續安排下一行的皇后。如此遞迴,當queen(0)的col自加到n-1,說明第一列的皇后已經遍歷了從0行1列到0行n-1列,此時for迴圈結束,程式退出。

在主函式中呼叫queen(0),得到正確結果,8皇后問題一共有92種解法。

###2 check函式

bool check(int curRow)
{
    //放當前行的皇后時,只需要檢查跟前面那些行的皇后有沒有衝突
    //不需要考慮後幾行,因為後幾行的皇后還沒放上去呢
    for(int preRow = 0; preRow != curRow; preRow++)
    {
        if(c[curRow] == c[preRow] ||
           curRow - c[curRow] == preRow - c[preRow] ||
           curRow + c[curRow] == preRow + c[preRow])
        {
            return false;
        }
    }

    return true;
}

這裡curRow表示當前的行。假定當前的行為第3行(從0開始計數)。那麼for迴圈裡,preRow = 0表示第0行,preRow = 1表示第1行,preRow = 2表示第2行。
c[curRow]表示第curRow行所在的列。比如c[3] = 2表示第三行第2列。c[0] = 2表示第0行第2列等。

####(1) c[curRow] == c[preRow]
表示第row行和第preRow行的列一樣,這樣兩個皇后就衝突了,所以返回false。

####(2) curRow - c[curRow] == preRow - c[preRow]
表示curRow行c[curRow]列與preRow 行c[preRow]列,在同一條斜率為負的斜線上。這樣兩個皇后也衝突了。以下圖為例

7.png

#####例1
A格子,preRow = 0, c[preRow] = c[0] = 0,即第0行第0列。C格子,curRow = 2, c[curRow] = c[2] = 2, 即第2行第2列。curRow - c[curRow] == preRow - c[preRow],表示這兩個格子在一條斜線上,返回false。
#####例2
B格子,preRow = 1, c[preRow] = c[1] = 0,即第1行第0列。D格子,curRow= 3, c[curRow] = c[3] = 2, 即第3行第2列。curRow - c[curRow] == preRow - c[preRow],表示這兩個格子在一條斜線上,返回false。

#####(3) curRow + c[curRow] == preRow + c[preRow]
表示curRow行c[curRow]列與preRow行c[preRow]列,在同一條斜率為正的斜線上。這樣兩個皇后也衝突了。如下圖所示:

8.png

####例3
A格子,preRow = 0, c[preRow] = c[0] = 1,即第0行第1列。B格子,curRow = 1, c[curRow] = c[1] = 0, 即第1行第0列。curRow + c[curRow] == preRow + c[preRow],表示這兩個格子在一條斜線上,返回false。
####例4
C格子,preRow = 0, c[preRow] = c[0] = 3,即第0行第3列。D格子,curRow= 3, c[curRow] = c[3] = 0, 即第3行第0列。curRow+ c[curRow] == preRow + c[preRow],表示這兩個格子在一條斜線上,返回false。

注意:上面表示兩種斜線的情況,一種用的是“-”,另一種用的是“+”,其實是因為這兩種線的斜率分別為-1和1的緣故。

###3 完整程式碼

#include<iostream>
#include<math.h>
using namespace std;

int n = 8;
int total = 0;
int *c = new int(n); //也可以寫為int c[n];表示皇后放在第幾列

bool check(int curRow)
{
    //放當前行的皇后時,只需要檢查跟前面那些行的皇后有沒有衝突
    //不需要考慮後幾行,因為後幾行的皇后還沒放上去呢
    for(int preRow = 0; preRow != curRow; preRow++)
    {
        if(c[curRow] == c[preRow] ||
           curRow - c[curRow] == preRow - c[preRow] ||
           curRow + c[curRow] == preRow + c[preRow])
        {
            return false;
        }
    }

    return true;
}

void queen(int row)
{
    if(row == n)
    {
        // 從0到n-1行,全部都已經放上皇后了,所以答案+1
        total++;

        // 打印出n個皇后具體放在0~n-1行的第幾列
        for(int i = 0; i<n; i++)
        {
            cout << c[i] << " ";
        }
        cout << endl;
    }
    else
    {
        for(int col = 0; col != n; col++)
        {
            c[row] = col;
            if(check(row))
            {
                queen(row + 1);
            }
        }
    }
}

int main()
{
    queen(0);
    cout << total << endl;
    
    return 0;
}

執行結果:

……
6 2 0 5 7 4 1 3
6 2 7 1 4 0 5 3
6 3 1 4 7 0 2 5
6 3 1 7 5 0 2 4
6 4 2 0 5 7 1 3
7 1 3 0 6 4 2 5
7 1 4 2 0 6 3 5
7 2 0 5 1 4 6 3
7 3 0 2 5 1 6 4
92

三、回溯法和列舉法的區別

回溯法與窮舉法有某些聯絡,它們都是基於試探的。
窮舉法要將一個解的各個部分全部生成後,才檢查是否滿足條件,若不滿足,則直接放棄該完整解,然後再嘗試另一個可能的完整解,它並沒有沿著一個可能的完整解的各個部分逐步回退生成解的過程。
而對於回溯法,一個解的各個部分是逐步生成的,當發現當前生成的某部分不滿足約束條件時,就放棄該步所做的工作,退到上一步進行新的嘗試,而不是放棄整個解重來。

少兒程式設計QQ群:581357582
少兒英語QQ群:952399366
公眾號.jpg