1. 程式人生 > 其它 >2022年3月25日的學習記錄

2022年3月25日的學習記錄

回溯演算法解題套路框架

其實回溯演算法其實就是我們常說的 DFS 演算法,本質上就是一種暴力窮舉演算法。

解決一個回溯問題,實際上就是一個決策樹的遍歷過程

站在回溯樹的一個節點上,你只需要思考 3 個問題:

1、路徑:也就是已經做出的選擇。

2、選擇列表:也就是你當前可以做的選擇。

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

回溯演算法框架:

result = []
def backtrack(路徑, 選擇列表):
    if 滿足結束條件:
        result.add(路徑)
        return
    
    for 選擇 in 選擇列表:
        做選擇
        backtrack(路徑, 選擇列表)
        撤銷選擇

其核心就是 for 迴圈裡面的遞迴,在遞迴呼叫之前「做選擇」,在遞迴呼叫之後「撤銷選擇」

一、全排列問題

力扣第 46 題「 全排列」就是給你輸入一個數組 nums,讓你返回這些數字的全排列。

解題程式碼:

class Solution {
public:
    //回溯        
    vector<vector<int>>res;//儲存所有可能排列
    vector<vector<int>> permute(vector<int>& nums) {
        vector<bool>used(nums.size(),false);//記錄是否走過這個數
        vector<int>track;//記錄路徑
        backtrack(nums,track,used);
        return res;
    }
    void backtrack(vector<int>nums,vector<int>&track,vector<bool>&used){
        //結束條件
        if(track.size()==nums.size()){
            res.push_back(track);
            return;
        }
        //遍歷
        for(int i=0;i<nums.size();i++){
            if(used[i]==true){//如果這個數已經被選擇過
                continue;
            }
            used[i]=true;//選擇這個數
            track.push_back(nums[i]);//記入路徑
            backtrack(nums,track,used);//對其他數進行遍歷
            used[i]=false;//撤銷選擇
            track.pop_back();
        }
    }
};

本質上就是暴力窮舉,要深入理解【決策樹】這一概念。在這裡不做深入解釋,想要了解可以去回溯演算法解題套路框架 :: labuladong的演算法小抄 (gitee.io)自行了解。

注意:

但是必須說明的是,不管怎麼優化,都符合回溯框架,而且時間複雜度都不可能低於 O(N!),因為窮舉整棵決策樹是無法避免的。這也是回溯演算法的一個特點,不像動態規劃存在重疊子問題可以優化,回溯演算法就是純暴力窮舉,複雜度一般都很高

二、N 皇后問題

力扣第 51 題「 N 皇后」就是這個經典問題,簡單解釋一下:給你一個 N×N 的棋盤,讓你放置 N 個皇后,使得它們不能互相攻擊。

class Solution {
public:
    vector<vector<string>>res;
    vector<vector<string>> solveNQueens(int n) {
        
        vector<string>board(n,string(n,'.'));
        backtrack(0,board);
        return res;
    }
    void backtrack(int row,vector<string>&board){
        //結束條件
        if(row==board.size()){//當選擇完的行數等於棋盤的行數時
            res.push_back(board);
            return;
        }
        //遍歷 一行中的每個數也就是改變列即可
        for(int col=0;col<board.size();col++){
            //除去不合法的選擇 
            if(!isValid(board,row,col)){
                //不符合遊戲規則 直接跳過
                continue;
            } 
            //符合規則選擇
            board[row][col]='Q';
            //對下一行進行選擇
            backtrack(row+1,board);
            //撤銷選擇
            board[row][col]='.';
        }
    }
    bool isValid(vector<string>&board,int row,int col){
        int n=board.size();
        //檢查一列中是否有衝突
        for(int i=0;i<row;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;
    }
};

PS:肯定有讀者問,按照 N 皇后問題的描述,我們為什麼不檢查左下角,右下角和下方的格子,只檢查了左上角,右上角和上方的格子呢?

因為皇后是一行一行從上往下放的,所以左下方,右下方和正下方不用檢查(還沒放皇后);因為一行只會放一個皇后,所以每行不用檢查。也就是最後只用檢查上面,左上,右上三個方向。

三、最後總結

回溯演算法就是個多叉樹的遍歷問題,關鍵就是在前序遍歷和後序遍歷的位置做一些操作,演算法框架如下:

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

backtrack 函式時,需要維護走過的「路徑」和當前可以做的「選擇列表」,當觸發「結束條件」時,將「路徑」記入結果集

其實想想看,回溯演算法和動態規劃是不是有點像呢?我們在動態規劃系列文章中多次強調,動態規劃的三個需要明確的點就是「狀態」「選擇」和「base case」,是不是就對應著走過的「路徑」,當前的「選擇列表」和「結束條件」?

某種程度上說,動態規劃的暴力求解階段就是回溯演算法。只是有的問題具有重疊子問題性質,可以用 dp table 或者備忘錄優化,將遞迴樹大幅剪枝,這就變成了動態規劃。而今天的兩個問題,都沒有重疊子問題,也就是回溯演算法問題了,複雜度非常高是不可避免的。