1. <strong id="7actg"></strong>
    2. <table id="7actg"></table>

    3. <address id="7actg"></address>
      <address id="7actg"></address>
      1. <object id="7actg"><tt id="7actg"></tt></object>

        回溯算法詳解(修訂版)

        共 10325字,需瀏覽 21分鐘

         ·

        2021-03-29 12:17

        預(yù)計(jì)閱讀時(shí)間:10 分鐘

        這篇文章是很久之前的一篇《回溯算法詳解》的進(jìn)階版,之前那篇不夠清楚,就不必看了,看這篇就行。把框架給你講清楚,你會(huì)發(fā)現(xiàn)回溯算法問題都是一個(gè)套路。

        廢話不多說,直接上回溯算法框架。解決一個(gè)回溯問題,實(shí)際上就是一個(gè)決策樹的遍歷過程。你只需要思考 3 個(gè)問題:

        1、路徑:也就是已經(jīng)做出的選擇。

        2、選擇列表:也就是你當(dāng)前可以做的選擇。

        3、結(jié)束條件:也就是到達(dá)決策樹底層,無法再做選擇的條件。

        如果你不理解這三個(gè)詞語的解釋,沒關(guān)系,我們后面會(huì)用「全排列」和「N 皇后問題」這兩個(gè)經(jīng)典的回溯算法問題來幫你理解這些詞語是什么意思,現(xiàn)在你先留著印象。

        代碼方面,回溯算法的框架:

        result = []
        def backtrack(路徑, 選擇列表):
            if 滿足結(jié)束條件:
                result.add(路徑)
                return

            for 選擇 in 選擇列表:
                做選擇
                backtrack(路徑, 選擇列表)
                撤銷選擇

        其核心就是 for 循環(huán)里面的遞歸,在遞歸調(diào)用之前「做選擇」,在遞歸調(diào)用之后「撤銷選擇」,特別簡(jiǎn)單。

        什么叫做選擇和撤銷選擇呢,這個(gè)框架的底層原理是什么呢?下面我們就通過「全排列」這個(gè)問題來解開之前的疑惑,詳細(xì)探究一下其中的奧妙!

        一、全排列問題

        我們?cè)诟咧械臅r(shí)候就做過排列組合的數(shù)學(xué)題,我們也知道n個(gè)不重復(fù)的數(shù),全排列共有 n! 個(gè)。

        PS:為了簡(jiǎn)單清晰起見,我們這次討論的全排列問題不包含重復(fù)的數(shù)字。

        那么我們當(dāng)時(shí)是怎么窮舉全排列的呢?比方說給三個(gè)數(shù)[1,2,3],你肯定不會(huì)無規(guī)律地亂窮舉,一般是這樣:

        先固定第一位為 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位變成 3,第三位就只能是 2 了;然后就只能變化第一位,變成 2,然后再窮舉后兩位……

        其實(shí)這就是回溯算法,我們高中無師自通就會(huì)用,或者有的同學(xué)直接畫出如下這棵回溯樹:

        只要從根遍歷這棵樹,記錄路徑上的數(shù)字,其實(shí)就是所有的全排列。我們不妨把這棵樹稱為回溯算法的「決策樹」。

        為啥說這是決策樹呢,因?yàn)槟阍诿總€(gè)節(jié)點(diǎn)上其實(shí)都在做決策。比如說你站在下圖的紅色節(jié)點(diǎn)上:

        你現(xiàn)在就在做決策,可以選擇 1 那條樹枝,也可以選擇 3 那條樹枝。為啥只能在 1 和 3 之中選擇呢?因?yàn)?2 這個(gè)樹枝在你身后,這個(gè)選擇你之前做過了,而全排列是不允許重復(fù)使用數(shù)字的。

        現(xiàn)在可以解答開頭的幾個(gè)名詞:[2]就是「路徑」,記錄你已經(jīng)做過的選擇;[1,3]就是「選擇列表」,表示你當(dāng)前可以做出的選擇;「結(jié)束條件」就是遍歷到樹的底層,在這里就是選擇列表為空的時(shí)候。

        如果明白了這幾個(gè)名詞,可以把「路徑」和「選擇列表」作為決策樹上每個(gè)節(jié)點(diǎn)的屬性,比如下圖列出了幾個(gè)節(jié)點(diǎn)的屬性:

        我們定義的backtrack函數(shù)其實(shí)就像一個(gè)指針,在這棵樹上游走,同時(shí)要正確維護(hù)每個(gè)節(jié)點(diǎn)的屬性,每當(dāng)走到樹的底層,其「路徑」就是一個(gè)全排列。

        再進(jìn)一步,如何遍歷一棵樹?這個(gè)應(yīng)該不難吧?;貞浺幌轮?nbsp;學(xué)習(xí)數(shù)據(jù)結(jié)構(gòu)的框架思維 寫過,各種搜索問題其實(shí)都是樹的遍歷問題,而多叉樹的遍歷框架就是這樣:

        void traverse(TreeNode root) {
            for (TreeNode child : root.childern)
                // 前序遍歷需要的操作
                traverse(child);
                // 后序遍歷需要的操作
        }

        而所謂的前序遍歷和后序遍歷,他們只是兩個(gè)很有用的時(shí)間點(diǎn),我給你畫張圖你就明白了:

        前序遍歷的代碼在進(jìn)入某一個(gè)節(jié)點(diǎn)之前的那個(gè)時(shí)間點(diǎn)執(zhí)行,后序遍歷代碼在離開某個(gè)節(jié)點(diǎn)之后的那個(gè)時(shí)間點(diǎn)執(zhí)行。

        回想我們剛才說的,「路徑」和「選擇」是每個(gè)節(jié)點(diǎn)的屬性,函數(shù)在樹上游走要正確維護(hù)節(jié)點(diǎn)的屬性,那么就要在這兩個(gè)特殊時(shí)間點(diǎn)搞點(diǎn)動(dòng)作:

        現(xiàn)在,你是否理解了回溯算法的這段核心框架?

        for 選擇 in 選擇列表:
            # 做選擇
            將該選擇從選擇列表移除
            路徑.add(選擇)
            backtrack(路徑, 選擇列表)
            # 撤銷選擇
            路徑.remove(選擇)
            將該選擇再加入選擇列表

        我們只要在遞歸之前做出選擇,在遞歸之后撤銷剛才的選擇,就能正確得到每個(gè)節(jié)點(diǎn)的選擇列表和路徑。

        下面,直接看全排列代碼:

        List<List<Integer>> res = new LinkedList<>();

        /* 主函數(shù),輸入一組不重復(fù)的數(shù)字,返回它們的全排列 */
        List<List<Integer>> permute(int[] nums) {
            // 記錄「路徑」
            LinkedList<Integer> track = new LinkedList<>();
            backtrack(nums, track);
            return res;
        }

        // 路徑:記錄在 track 中
        // 選擇列表:nums 中不存在于 track 的那些元素
        // 結(jié)束條件:nums 中的元素全都在 track 中出現(xiàn)
        void backtrack(int[] nums, LinkedList<Integer> track) {
            // 觸發(fā)結(jié)束條件
            if (track.size() == nums.length) {
                res.add(new LinkedList(track));
                return;
            }

            for (int i = 0; i < nums.length; i++) {
                // 排除不合法的選擇
                if (track.contains(nums[i]))
                    continue;
                // 做選擇
                track.add(nums[i]);
                // 進(jìn)入下一層決策樹
                backtrack(nums, track);
                // 取消選擇
                track.removeLast();
            }
        }

        我們這里稍微做了些變通,沒有顯式記錄「選擇列表」,而是通過numstrack推導(dǎo)出當(dāng)前的選擇列表:

        至此,我們就通過全排列問題詳解了回溯算法的底層原理。當(dāng)然,這個(gè)算法解決全排列不是很高效,因?yàn)閷?duì)鏈表使用contains方法需要 O(N) 的時(shí)間復(fù)雜度。有更好的方法通過交換元素達(dá)到目的,但是難理解一些,這里就不寫了,有興趣可以自行搜索一下。

        但是必須說明的是,不管怎么優(yōu)化,都符合回溯框架,而且時(shí)間復(fù)雜度都不可能低于 O(N!),因?yàn)楦F舉整棵決策樹是無法避免的。這也是回溯算法的一個(gè)特點(diǎn),不像動(dòng)態(tài)規(guī)劃存在重疊子問題可以優(yōu)化,回溯算法就是純暴力窮舉,復(fù)雜度一般都很高。

        明白了全排列問題,就可以直接套回溯算法框架了,下面簡(jiǎn)單看看 N 皇后問題。

        二、N 皇后問題

        這個(gè)問題很經(jīng)典了,簡(jiǎn)單解釋一下:給你一個(gè) N×N 的棋盤,讓你放置 N 個(gè)皇后,使得它們不能互相攻擊。

        PS:皇后可以攻擊同一行、同一列、左上左下右上右下四個(gè)方向的任意單位。

        這是 N = 8 的一種放置方法:

        圖片來自 LeetCode

        這個(gè)問題本質(zhì)上跟全排列問題差不多,決策樹的每一層表示棋盤上的每一行;每個(gè)節(jié)點(diǎn)可以做出的選擇是,在該行的任意一列放置一個(gè)皇后。

        直接套用框架:

        vector<vector<string>> res;

        /* 輸入棋盤邊長(zhǎng) n,返回所有合法的放置 */
        vector<vector<string>> solveNQueens(int n) {
            // '.' 表示空,'Q' 表示皇后,初始化空棋盤。
            vector<string> board(n, string(n, '.'));
            backtrack(board, 0);
            return res;
        }

        // 路徑:board 中小于 row 的那些行都已經(jīng)成功放置了皇后
        // 選擇列表:第 row 行的所有列都是放置皇后的選擇
        // 結(jié)束條件:row 超過 board 的最后一行
        void backtrack(vector<string>& board, int row) {
            // 觸發(fā)結(jié)束條件
            if (row == board.size()) {
                res.push_back(board);
                return;
            }

            int n = board[row].size();
            for (int col = 0; col < n; col++) {
                // 排除不合法選擇
                if (!isValid(board, row, col)) 
                    continue;
                // 做選擇
                board[row][col] = 'Q';
                // 進(jìn)入下一行決策
                backtrack(board, row + 1);
                // 撤銷選擇
                board[row][col] = '.';
            }
        }

        這部分主要代碼,跟全排列問題差不多。isValid函數(shù)的實(shí)現(xiàn)也很簡(jiǎn)單:

        /* 是否可以在 board[row][col] 放置皇后? */
        bool isValid(vector<string>& board, int row, int col) {
            int n = board.size();
            // 檢查列是否有皇后互相沖突
            for (int i = 0; i < n; i++) {
                if (board[i][col] == 'Q')
                    return false;
            }
            // 檢查右上方是否有皇后互相沖突
            for (int i = row - 1, j = col + 1
                    i >= 0 && j < n; i--, j++) {
                if (board[i][j] == 'Q')
                    return false;
            }
            // 檢查左上方是否有皇后互相沖突
            for (int i = row - 1, j = col - 1;
                    i >= 0 && j >= 0; i--, j--) {
                if (board[i][j] == 'Q')
                    return false;
            }
            return true;
        }

        函數(shù)backtrack依然像個(gè)在決策樹上游走的指針,每個(gè)節(jié)點(diǎn)就表示在board[row][col]上放置皇后,通過isValid函數(shù)可以將不符合條件的情況剪枝

        如果直接給你這么一大段解法代碼,可能是懵逼的。但是現(xiàn)在明白了回溯算法的框架套路,還有啥難理解的呢?無非是改改做選擇的方式,排除不合法選擇的方式而已,只要框架存于心,你面對(duì)的只剩下小問題了。

        當(dāng)N = 8時(shí),就是八皇后問題,數(shù)學(xué)大佬高斯窮盡一生都沒有數(shù)清楚八皇后問題到底有幾種可能的放置方法,但是我們的算法只需要一秒就可以算出來所有可能的結(jié)果。

        不過真的不怪高斯。這個(gè)問題的復(fù)雜度確實(shí)非常高,看看我們的決策樹,雖然有isValid函數(shù)剪枝,但是最壞時(shí)間復(fù)雜度仍然是 O(N^(N+1)),而且無法優(yōu)化。如果N = 10的時(shí)候,計(jì)算就已經(jīng)很耗時(shí)了。

        有的時(shí)候,我們并不想得到所有合法的答案,只想要一個(gè)答案,怎么辦呢?比如解數(shù)獨(dú)的算法,找所有解法復(fù)雜度太高,只要找到一種解法就可以。

        其實(shí)特別簡(jiǎn)單,只要稍微修改一下回溯算法的代碼即可:

        // 函數(shù)找到一個(gè)答案后就返回 true
        bool backtrack(vector<string>& board, int row) {
            // 觸發(fā)結(jié)束條件
            if (row == board.size()) {
                res.push_back(board);
                return true;
            }
            ...
            for (int col = 0; col < n; col++) {
                ...
                board[row][col] = 'Q';

                if (backtrack(board, row + 1))
                    return true;

                board[row][col] = '.';
            }

            return false;
        }

        這樣修改后,只要找到一個(gè)答案,for 循環(huán)的后續(xù)遞歸窮舉都會(huì)被阻斷。也許你可以在 N 皇后問題的代碼框架上,稍加修改,寫一個(gè)解數(shù)獨(dú)的算法?

        三、最后總結(jié)

        回溯算法就是個(gè)多叉樹的遍歷問題,關(guān)鍵就是在前序遍歷和后序遍歷的位置做一些操作,算法框架如下:

        def backtrack(...):
            for 選擇 in 選擇列表:
                做選擇
                backtrack(...)
                撤銷選擇

        backtrack函數(shù)時(shí),需要維護(hù)走過的「路徑」和當(dāng)前可以做的「選擇列表」,當(dāng)觸發(fā)「結(jié)束條件」時(shí),將「路徑」記入結(jié)果集

        其實(shí)想想看,回溯算法和動(dòng)態(tài)規(guī)劃是不是有點(diǎn)像呢?我們?cè)趧?dòng)態(tài)規(guī)劃系列文章中多次強(qiáng)調(diào),動(dòng)態(tài)規(guī)劃的三個(gè)需要明確的點(diǎn)就是「狀態(tài)」「選擇」和「base case」,是不是就對(duì)應(yīng)著走過的「路徑」,當(dāng)前的「選擇列表」和「結(jié)束條件」?

        某種程度上說,動(dòng)態(tài)規(guī)劃的暴力求解階段就是回溯算法。只是有的問題具有重疊子問題性質(zhì),可以用 dp table 或者備忘錄優(yōu)化,將遞歸樹大幅剪枝,這就變成了動(dòng)態(tài)規(guī)劃。而今天的兩個(gè)問題,都沒有重疊子問題,也就是回溯算法問題了,復(fù)雜度非常高是不可避免的。

        本文終。如果覺得本文不錯(cuò)的話,不妨分享給你的朋友。另外,幾篇?jiǎng)討B(tài)規(guī)劃和框架思維的相關(guān)文章正在更新和修訂,敬請(qǐng)期待。

        推薦閱讀:

        完全整理 | 365篇高質(zhì)技術(shù)文章目錄整理

        算法之美 : 棧和隊(duì)列

        主宰這個(gè)世界的10大算法

        徹底理解cookie、session、token

        淺談什么是遞歸算法

        專注服務(wù)器后臺(tái)技術(shù)棧知識(shí)總結(jié)分享

        歡迎關(guān)注交流共同進(jìn)步

        瀏覽 61
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        1. <strong id="7actg"></strong>
        2. <table id="7actg"></table>

        3. <address id="7actg"></address>
          <address id="7actg"></address>
          1. <object id="7actg"><tt id="7actg"></tt></object>
            麻豆被操 | 日日夜夜尻 | 中国大陆黄色视频 | 国产aa精品 | 外国性视频 | 你的欲梦裸身视频在线观看 | jZZijZZij亚洲日本少妇 | 免费看av的网址 成人免费视频 网址 | 日本在线黄色视频 | 囯产精品久久久久久久免牛肉蒲团 |