【大爽python演算法】遞迴演算法進化之回溯演算法(backtracking)

大爽歌python程式設計輔導發表於2021-11-15

作者自我介紹:大爽歌, b站小UP主
python1對1輔導老師
時常直播程式設計,直播時免費回答簡單問題。

前置知識: 遞迴演算法(recursion algorithm)。
我的遞迴教程: 【教程】python遞迴三部曲(基於turtle實現視覺化)
回溯與遞迴的關係:
回溯是一種演算法思想,遞迴是實現方式。

回溯法經典問題:
八皇后問題、數獨問題。

(其實兩個很像)

八皇后問題

八皇后問題是一個以國際象棋為背景的問題:

如何在8×8的國際象棋棋盤上放置八個皇后,使其不互相攻擊。
即任兩個皇后都不能處於同一條橫行、縱行或斜線上。

n皇后問題

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

(當且僅當n = 1 或 n ≥ 4時問題有解)

4皇后問題!

八皇后討論起來比較麻煩,先討論四皇后情況(n=4)

首先展示下錯誤的情況:

如上圖所示,三個圖的錯誤分別是

  1. 第一行有重複了
  2. 對角線有重複了。(注意有兩個對角線)
  3. 第一列有重複

想要正確,則每一行每一列,每個對角線(對角線有兩個方向)都不能有重複項。

正確的情況示例如下:

回溯法

回溯法(backtracking)是暴力搜尋法中的一種。

其核心思想就是不斷嘗試,不行就後退再試其他的。

關於這一思想,我之前有個視訊,感覺能比較形象地展示,感興趣可以看看:

迷宮探索動畫

接下來我們用回溯法探究下剛才的4皇后問題。

回溯法過程展示

個人感覺用行列座標表示不夠直觀,所以給每個格子從前往後依次編號。
後面用編號來稱呼位置(無特殊說明的話)
如下圖

同時四個皇后從前往後按次序編為
\(Q_1\)\(Q_2\)\(Q_3\)\(Q_4\)

原始的回溯法
每次會從前往後依次嘗試每個編號的位置。

為了簡化談論,以下先進行了一定的優化。

由於每行不能重複,n個皇后必須分別放在n行上。
當有一行放不下了時。也就失敗了。

所以
\(Q_1\)必須放在第一行(行索引為0)
\(Q_2\)必須放在第二行(行索引為1)
\(Q_3\)必須放在第三行(行索引為2)
\(Q_4\)必須放在第四行(行索引為3)

1 \(Q_1\)放位置0

使用回溯法,\(Q_2\)仍然會從0開始嘗試,發現放不了,就往後走。

由於\(Q_1\)放位置0。所以
0、1、2、3、
4、8、12、
5、10、15都放不了

\(Q_2\)從第二行開頭試。
即從4、5開始試,一直試到6才能夠放下,那麼就先放在這裡。

\(Q_2\)放位置6

那麼接下來繼續嘗試\(Q_3\)
會發現第三行(行索引為2)已經放不了了。
如下圖

這說明

\(Q_2\)放位置6失敗

回來重新放\(Q_2\),放位置7

\(Q_2\)放位置7

此時\(Q_3\)唯一能放的位置只有9。
之後,\(Q_4\)已經無處可放。

如下圖

這說明

\(Q_2\)放位置7失敗

\(Q_2\)無位置可放

\(Q_2\)無位置可放,
說明\(Q_1\)放在位置0失敗。
\(Q_1\)需要嘗試其他位置,即嘗試先放在位置1。

到這裡回溯法的特點其實就已經展現的比較夠了:
即不斷向下嘗試,如果所有嘗試都失敗,那就後退一步,重新嘗試。

2 \(Q_1\)放位置1

此時\(Q_2\)只能放在位置7,
之後\(Q_3\)只能放在位置8,
最後\(Q_3\)只能放在位置14,

即如下圖所示

到這裡,如果只要求找到一個解法,問題就已經結束了,如果要找到所有解法,那就是繼續往後不斷嘗試。

程式碼實現

原始回溯法程式碼

class NQueens:
    def __init__(self, n):
        self.n = n
        # 儲存每個皇后的座標, (ci, ri)
        # 第一行第一列的皇后座標為(0, 0)
        self.one_solution = []

    def check_can_place(self, ri, ci):
        for pos in self.one_solution:
            pc, pr = pos
            if pc == ci:  # 行檢測
                return False

            if pr == ri:  # 列檢測
                return False

            if pr - pc == ri - ci:  # 對角線檢測 1
                return False

            if pr + pc == ri + ci:  # 對角線檢測 2
                return False

        return True

    def solve(self):
        for ri in range(self.n):
            for ci in range(self.n):
                if self.check_can_place(ri, ci):
                    pos = (ci, ri)
                    self.one_solution.append(pos)

                    if len(self.one_solution) == self.n:
                        return True

                    res = self.solve()
                    if res:
                        return True
                    else:
                        self.one_solution.pop()

        return False

    def show_in_board(self):
        board = [
            ["-" for i in range(self.n)] for j in range(self.n)
        ]
        for pos in self.one_solution:
            pc, pr = pos
            board[pr][pc] = "Q"

        for row in board:
            print(" ".join(row))


nq = NQueens(8)
res = nq.solve()
if res:
    print("Queens positions:")
    print(nq.one_solution)
    print("Queens in board:")
    nq.show_in_board()

輸出結果

Queens positions:
[(0, 0), (4, 1), (7, 2), (5, 3), (2, 4), (6, 5), (1, 6), (3, 7)]
Queens in board:
Q - - - - - - -
- - - - Q - - -
- - - - - - - Q
- - - - - Q - -
- - Q - - - - -
- - - - - - Q -
- Q - - - - - -
- - - Q - - - -

check_can_place方法

該方法,用於檢查指定的橫縱座標,是否還能防止皇后(不與已經放置的皇后衝突)

檢查是否能放置
行和列好分析,對角線情況則比較麻煩。
兩種對角線圖示如下

第一種對角線(紅色對角線)
每一條對角線上格子,\(r-c\)都是相同的值。
可以通過這個值來判斷是否在同一條對角線上。

第二種對角線(綠色對角線)
每一條對角線上格子,\(r+c\)都是相同的值。
可以通過這個值來判斷是否在同一條對角線上。

solve方法解析

def solve(self):
    for ri in range(self.n):
        for ci in range(self.n):
            # 從前往後嘗試所有的位置,看是否能放皇后
            if self.check_can_place(ri, ci):
                # 成功則新增
                pos = (ci, ri)
                self.one_solution.append(pos)

                if len(self.one_solution) == self.n:
                    # 皇后數量已到達n,問題解決,返回解決成功
                    return True

                # 走到這裡,說明還沒解決

                # 遞迴呼叫自身,看當前情況往後是否能夠解決成功
                res = self.solve()
                if res:
                    # 成功,就繼續返回解決成功
                    return True
                else:
                    # 失敗,之前新增的pos方法,是不成功的,將其彈出,之後繼續嘗試
                    self.one_solution.pop()

    return False

程式碼優化與擴充

優化:一行一試

上面的原始回溯法的程式碼。
每一次放皇后都是從前往後一個一個試,效率很低。

這裡按照上文討論中的思路進行優化,
即每一行放一個皇后。

那麼程式碼裡面就是每一行,從第一列開始一直嘗試到最後一列。
一行放好後,就往下一行進行嘗試。

這裡只需要給NQueens類新增一個新方法solve_advanced即可

def solve_advanced(self, ri=0):
    for ci in range(self.n):
        if self.check_can_place(ri, ci):
            pos = (ci, ri)
            self.one_solution.append(pos)

            if ri == self.n - 1:
                return True

            res = self.solve_advanced(ri+1)
            if res:
                return True
            else:
                self.one_solution.pop()

    return False

呼叫時的res = nq.solve()改成res = nq.solve_advanced()即可。

輸出和原始回溯法時的輸出是一樣的。
不過程式碼執行的速度會得到很大提升。

不僅如此,優化後的程式碼在去求所有解時,不會求出重複情況。

擴充:獲得所有解(不重複)

求所有解的程式碼在優化後的方法上,簡單調整以下就好

  • 不再返回(即不會試到一個成功的就退出)
  • 成功後將結果記錄,記錄時要使用切片進行拷貝。

首先,先在NQueens__init__方法中新增新的屬性,用於記錄解決方法。

self.solutions = []

然後給NQueens類新增新方法solve_all

def solve_all(self, ri=0):
    for ci in range(self.n):
        if self.check_can_place(ri, ci):
            pos = (ci, ri)
            self.one_solution.append(pos)

            if ri == self.n - 1:
                self.solutions.append(self.one_solution[:])
            else:
                self.solve_all(ri+1)

            self.one_solution.pop()

然後修改下show_in_board方法。
因為原來的方法只能展示self.one_solution
這裡希望也能夠展示別的solution

修改後的show_in_board如下

def show_in_board(self, sol=None):
    board = [
        ["-" for i in range(self.n)] for j in range(self.n)
    ]
    if sol is None:
        sol = self.one_solution

    for pos in sol:
        pc, pr = pos
        board[pr][pc] = "Q"

    for row in board:
        print(" ".join(row))

總程式碼

一個NQueens的例項,只能呼叫三個方法中的一個(一次)

  • solve
  • solve_advanced
  • solve_all

重複呼叫可能會出問題(需要再呼叫,建議新建NQueens例項)

以下總程式碼中只展示solve_all的呼叫結果。
且由於八皇后問題的解太多(有92個),
以下只展示下六皇后問題的呼叫求解

class NQueens:
    def __init__(self, n):
        self.n = n
        # 儲存每個皇后的座標, (ci, ri)
        # 第一行第一列的皇后座標為(0, 0)
        self.one_solution = []

        self.solutions = [

        ]

    def check_can_place(self, ri, ci):
        for pos in self.one_solution:
            pc, pr = pos
            if pc == ci:  # 行檢測
                return False

            if pr == ri:  # 列檢測
                return False

            if pr - pc == ri - ci:  # 對角線檢測 1
                return False

            if pr + pc == ri + ci:  # 對角線檢測 2
                return False

        return True

    def solve(self):
        for ri in range(self.n):
            for ci in range(self.n):
                if self.check_can_place(ri, ci):
                    pos = (ci, ri)
                    self.one_solution.append(pos)

                    if len(self.one_solution) == self.n:
                        return True

                    res = self.solve()
                    if res:
                        return True
                    else:
                        self.one_solution.pop()

        return False

    def solve_advanced(self, ri=0):
        for ci in range(self.n):
            if self.check_can_place(ri, ci):
                pos = (ci, ri)
                self.one_solution.append(pos)

                if ri == self.n - 1:
                    return True

                res = self.solve_advanced(ri+1)
                if res:
                    return True
                else:
                    self.one_solution.pop()

        return False

    def solve_all(self, ri=0):
        for ci in range(self.n):
            if self.check_can_place(ri, ci):
                pos = (ci, ri)
                self.one_solution.append(pos)

                if ri == self.n - 1:
                    self.solutions.append(self.one_solution[:])
                else:
                    self.solve_all(ri+1)

                self.one_solution.pop()

    def show_in_board(self, sol=None):
        board = [
            ["-" for i in range(self.n)] for j in range(self.n)
        ]
        if sol is None:
            sol = self.one_solution

        for pos in sol:
            pc, pr = pos
            board[pr][pc] = "Q"

        for row in board:
            print(" ".join(row))


nq = NQueens(6)

solutions = nq.solve_all()
for si in range(len(nq.solutions)):
    sol = nq.solutions[si]
    print("=== Solution %s ===" % si)
    print("Queens positions:")
    print(sol)
    print("Queens in board:")
    nq.show_in_board(sol)

輸出

總程式碼的輸出如下

=== Solution 0 ===
Queens positions:
[(1, 0), (3, 1), (5, 2), (0, 3), (2, 4), (4, 5)]
Queens in board:
- Q - - - -
- - - Q - -
- - - - - Q
Q - - - - -
- - Q - - -
- - - - Q -
=== Solution 1 ===
Queens positions:
[(2, 0), (5, 1), (1, 2), (4, 3), (0, 4), (3, 5)]
Queens in board:
- - Q - - -
- - - - - Q
- Q - - - -
- - - - Q -
Q - - - - -
- - - Q - -
=== Solution 2 ===
Queens positions:
[(3, 0), (0, 1), (4, 2), (1, 3), (5, 4), (2, 5)]
Queens in board:
- - - Q - -
Q - - - - -
- - - - Q -
- Q - - - -
- - - - - Q
- - Q - - -
=== Solution 3 ===
Queens positions:
[(4, 0), (2, 1), (0, 2), (5, 3), (3, 4), (1, 5)]
Queens in board:
- - - - Q -
- - Q - - -
Q - - - - -
- - - - - Q
- - - Q - -
- Q - - - -

參考文件

相關文章