一看就懂,一寫就懵?搞懂回溯演算法,一口氣刷了20多道題

叫我詹躲躲發表於2021-12-26

一、回溯演算法

1.1什麼是回溯?

回溯演算法實際上一個類似列舉的搜尋嘗試過程,主要是在搜尋嘗試過程中尋找問題的解,當發現已不滿足求解條件時,就“回溯”返回,嘗試別的路徑。——摘自《百度百科》

1.1 一般步驟:

  1. 針對所給問題,定義問題的解空間,它至少包含問題的一個(最優)解。
  2. 確定易於搜尋的解空間結構,使得能用回溯法方便地搜尋整個解空間 。
  3. 以深度優先的方式搜尋解空間,並且在搜尋過程中用剪枝函式避免無效搜尋。

1.2 如何理解回溯演算法?

  1. 為問題建立解空間結構
  2. 在解空間結構上進行DFS搜尋
  3. 設立回溯出口和剪枝點,減少無效搜尋,出口處儲存有效解.

1.3 解決那些問題?

  1. 組合問題:N個數⾥⾯按⼀定規則找出k個數的集合
  2. 切割問題:⼀個字串按⼀定規則有⼏種切割⽅式
  3. ⼦集問題:⼀個N個數的集合⾥有多少符合條件的⼦集
  4. 排列問題:N個數按⼀定規則全排列,有⼏種排列⽅式
  5. 棋盤問題:N皇后,解數獨等等。

1.4遞迴與回溯

首先先說明一下對遞迴 (Recursive)與回溯 (Backtrack)的理解。

1.4.1 遞迴 (Recursive)

程式呼叫自身的程式設計技巧稱為遞迴。
遞迴做為一種演算法在程式設計語言中廣泛應用。 一個過程或函式在其定義或說明中有直接或間接呼叫自身的一種方法,它通常把一個大型複雜的問題層層轉化為一個與原問題相似的規模較小的問題來求解,遞迴策略只需少量的程式就可描述出解題過程所需要的多次重複計算,大大地減少了程式的程式碼量。 ——摘自《百度百科》

通常來說,為了描述問題的某一狀態,必須用到該狀態的上一個狀態;而如果要描述上一個狀態,又必須用到上一個狀態的上一個狀態…… 這樣用自己來定義自己的方法就是遞迴。

1.4.2. 回溯 (Backtrack)

回溯演算法實際上一個類似列舉的搜尋嘗試過程,主要是在搜尋嘗試過程中尋找問題的解,當發現已不滿足求解條件時,就“回溯”返回,嘗試別的路徑。 ——摘自《百度百科》

在這種思想下,我們需要清晰的找出三個要素:選擇 (Options),限制 (Restraints),結束條件 (Termination)。

1.5.遞迴與回溯的區別

遞迴是一種演算法結構。遞迴會出現在子程式中,形式上表現為直接或間接的自己呼叫自己。典型的例子是階乘,計算規律為:n!=n×(n−1)!n!=n \times (n-1)!,基本如下所示:

let fac = (n)=> {
    if(n == 1){
       return n;
    }else{
      return (n*fac(n - 1)); 
    }    
}

回溯是一種演算法思想,它是用遞迴實現的。回溯的過程類似於窮舉法,但回溯有“剪枝”功能,即自我判斷過程。

二、Leetcode回溯題目

2.1- 22. 括號生成

數字 n 代表生成括號的對數,請你設計一個函式,用於能夠生成所有可能的並且 有效的 括號組合。

示例 1:

輸入:n = 3
輸出:["((()))","(()())","(())()","()(())","()()()"]

示例 2:

輸入:n = 1
輸出:["()"]

提示:
1 <= n <= 8

思路分析

  1. 判斷左右括號所剩的數量,最初始都是n;當左括號(()有剩餘,繼續做選擇;
  2. 判斷當右括號比左括號剩的多,才能選右括號;繼續遞迴做選擇
  3. 出口:構建的字串是 2n的時候,此時已經該分支已經構建完成,加入選項;

簡答繪製圖形

解題程式碼

var generateParenthesis = function (n) {
    const res = [];
    const backTracing = (lRemain, rRemain, str) => { // 左右括號所剩的數量,str是當前構建的字串
        if (str.length == 2 * n) { // 字串構建完成
            res.push(str);           // 加入解集
            return;                  // 結束當前遞迴分支
        }
        if (lRemain > 0) {         // 只要左括號有剩,就可以選它,然後繼續做選擇(遞迴)
            backTracing(lRemain - 1, rRemain, str + "(");
        }
        if (lRemain < rRemain) {   // 右括號比左括號剩的多,才能選右括號
            backTracing(lRemain, rRemain - 1, str + ")"); // 然後繼續做選擇(遞迴)
        }
    };
    backTracing(n, n, ""); // 遞迴的入口,剩餘數量都是n,初始字串是空串
    return res;
};

2.2 - 46. 全排列

給定一個不含重複數字的陣列 nums ,返回其 所有可能的全排列 。你可以 按任意順序 返回答案。

示例 1:

輸入:nums = [1,2,3]
輸出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

輸入:nums = [0,1]
輸出:[[0,1],[1,0]]

示例 3:

輸入:nums = [1]
輸出:[[1]]

提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整數 互不相同

解題思路

  1. 回溯終止條件:該條路徑長度與達到nums長度;
  2. 加入當前值到路徑,如果結果裡面已經包含這個路徑,則不加入結果裡面,否則繼續選擇這個選項;

解題程式碼

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function (nums) {
    if (!nums.length) return
    let res = []
    let backTrack = path => {
        //長度滿足條件,加入結果
        if (path.length === nums.length) {
            res.push(path)
            return
        }
        nums.forEach(item => {
            if (path.includes(item)) return //不包含重複的數字
            backTrack([...path, item]) //加入路徑,繼續遞迴選擇
        });
    }
    backTrack([])
    return res
};

[圖片上傳失敗...(image-40cdd5-1639281547994)]

2.3 - n 皇后問題

研究的是如何將 n 個皇后放置在 n × n 的棋盤上,並且使皇后彼此之間不能相互攻擊。

給你一個整數 n ,返回 n 皇后問題 不同的解決方案的數量。*

image

皇后走法規則

皇后的走法是:可以橫直斜走,格數不限。因此要求皇后彼此之間不能相互攻擊,等價於要求任何兩個皇后都不能在同一行、同一列以及同一條斜線上。

示例

示例 1:
image

輸入:n = 4
輸出:2

解釋:如上圖所示,4 皇后問題存在兩個不同的解法。

示例 2:

輸入:n = 1
輸出:1

提示:
1 <= n <= 9

解題思路

  1. 定義判斷當前位置的檢驗函式,約束條件包含 ,不能同行,不能同列,不能同對角線(45度和135度)
  2. 定義棋盤;標準回溯處理;

使用回溯的具體做法是:依次在每一行放置一個皇后,每次新放置的皇后都不能和已經放置的皇后之間有攻擊,即新放置的皇后不能和任何一個已經放置的皇后在同一列以及同一條斜線上。當 NNN 個皇后都放置完畢,則找到一個可能的解,將可能的解的數量加 111。

圖片來源

解題程式碼

var totalNQueens = function (n) {
    let count = 0; //皇后可放置的總數
    let isValid = (row, col, board, n) => {
        //所在行不用判斷,每次都會下移一行
        //判斷同一列的資料是否包含
        for (let i = 0; i < row; i++) {
            if (board[i][col] === 'Q') {
                return false
            }
        }
        //判斷45度對角線是否包含
        for (let i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
            if (board[i][j] === 'Q') {
                return false
            }
        }
        //判斷135度對角線是否包含
        for (let i = row - 1, j = col - 1; i >= 0 && j >= 0; j--, i--) {
            if (board[i][j] === 'Q') {
                return false
            }
        }
        return true
    }

    let backTracing = (row, board) => {
        //走到最後一行,統計次數
        if (row === n) {
            count++;
            return
        }

        for (let x = 0; x < n; x++) {
            //判斷該位置是否可以放置 皇后
            if (isValid(row, x, board, n)) {
                board[row][x] = 'Q'; //放置皇后
                backTracing(row + 1, board); //遞迴
                board[row][x] = '.'; //回溯,撤銷處理結果
            }
        }
    }
    backTracing(0, board)
    let board = [...Array(n)].map(v => v = ([...Array(n)]).fill('.')) //棋盤
    return count
};

2.4 - 78. 子集

給你一個整數陣列 nums ,陣列中的元素 互不相同 。返回該陣列所有可能的子集(冪集)。

解集 不能 包含重複的子集。你可以按 任意順序 返回解集。

示例 1:

輸入:nums = [1,2,3]
輸出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

輸入:nums = [0]
輸出:[[],[0]]

提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums 中的所有元素 互不相同

解題思路

  1. 列舉出所有可選的數;加入選項;
  2. 撤銷加入的選項,將選項加入結果

解題程式碼

const subsets = (nums) => {
    const res = [];
    const backTracing = (index, list) => {
        res.push(list.slice());     // 呼叫子遞迴前,加入解集
        for (let i = index; i < nums.length; i++) { // 列舉出所有可選的數
            list.push(nums[i]);       // 選這個數
            backTracing(i + 1, list);         // 基於選這個數,繼續遞迴
            list.pop();               // 撤銷選這個數
        }
    };
    backTracing(0, []);
    return res;
};

2.5 - 77. 組合

給定兩個整數 n 和 k,返回範圍 [1, n] 中所有可能的 k 個數的組合。

你可以按 任何順序 返回答案。

示例 1:

輸入:n = 4, k = 2
輸出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

示例 2:

輸入:n = 1, k = 1
輸出:[[1]]

提示:

1 <= n <= 20
1 <= k <= n

解題思路

  1. 列舉出所有可選的數;加入選項;
  2. 撤銷加入的選項,將選項加入結果
  3. 剪枝條件:選項的長度滿足條件;

解題程式碼

/**
 * @param {number} n
 * @param {number} k
 * @return {number[][]}
 */
var combine = function (n, k) {
    let result = [];
    let backTracing = (start, path) => {
        // 如果已經選滿了的話,加入結果集中
        if (path.length == k) {
            result.push(path.slice());
            return;
        }
        // 從開始的數字到末尾的數字
        for (let i = start; i <= n; i++) {
            // 加入選擇列表中
            path.push(i);
            // 繼續深度搜尋
            backTracing(i + 1, path);
            // 撤銷選擇
            path.pop(i);
        }
    };
    backTracing(1, []);
    return result;
};

2.6 - 081. 允許重複選擇元素的組合

給定一個無重複元素的正整數陣列 candidates 和一個正整數 target ,找出 candidates 中所有可以使數字和為目標數 target 的唯一組合。

candidates 中的數字可以無限制重複被選取。如果至少一個所選數字數量不同,則兩種組合是唯一的。
對於給定的輸入,保證和為 target 的唯一組合數少於 150 個。

示例 1:

輸入: candidates = [2,3,6,7], target = 7
輸出: [[7],[2,2,3]]

示例 2:

輸入: candidates = [2,3,5], target = 8
輸出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:

輸入: candidates = [2], target = 1
輸出: []

示例 4:

輸入: candidates = [1], target = 1
輸出: [[1]]

示例 5:

輸入: candidates = [1], target = 2
輸出: [[1,1]]

提示:
1 <= candidates.length <= 30
1 <= candidates[i] <= 200
candidate 中的每個元素都是獨一無二的。
1 <= target <= 500

解題思路

  1. 將當前元素加入到選項裡面,並將計算結果,傳到選項,繼續遞迴;
  2. 當選項和大於目標值時,結束這個選項,直到找到符合的選項,並將選項加入結果;

解題程式碼

var combinationSum = function (candidates, target) {
    const result = [], visited = [];
    backTracing(0, 0);
    return result;

    function backTracing(sum, cur) {
        if (target === sum) result.push(visited.slice());
        if (target <= sum) return;
        for (let i = cur; i < candidates.length; i++) {
            visited.push(candidates[i]); //加入到選項裡面
            backTracing(sum + candidates[i], i);//選擇這個選項,繼續遞迴
            visited.pop(); //插銷這個選擇
        }
    }
};

2.7 - 216. 組合總和 III

找出所有相加之和為 n 的 k 個數的組合。組合中只允許含有 1 - 9 的正整數,並且每種組合中不存在重複的數字。

說明:
所有數字都是正整數。
解集不能包含重複的組合。

示例 1:

輸入: k = 3, n = 7
輸出: [[1,2,4]]

示例 2:

輸入: k = 3, n = 9
輸出: [[1,2,6], [1,3,5], [2,3,4]]

解題思路

同組合1

解題程式碼

var combinationSum3 = function (k, n) {
    let ans = [];
    let backTracing = (start, path) => {
        if (path.length === k && path.reduce((acc, prev) => acc += prev) === n) {
            ans.push(path.slice())
            return
        }
        for (let i = start; i <= 9; i++) {
            path.push(i)
            backTracing(i + 1, path)
            path.pop(i)
        }
    }
    backTracing(1, [])
    return ans
};

2.8 - 17. 電話號碼的字母組合

給定一個僅包含數字 2-9 的字串,返回所有它能表示的字母組合。答案可以按 任意順序 返回。

給出數字到字母的對映如下(與電話按鍵相同)。注意 1 不對應任何字母。

示例 1:

輸入:digits = "23"
輸出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

輸入:digits = ""
輸出:[]

示例 3:

輸入:digits = "2"
輸出:["a","b","c"]

提示:
0 <= digits.length <= 4
digits[i] 是範圍 ['2', '9'] 的一個數字。

解題思路

  1. 找到當前按鈕對應的字母字串
  2. 拼接按鈕對應的字串組合
  3. 選項滿足長度,加入結果

解題程式碼

var letterCombinations = function (digits) {
    if(!digits.length) return []
    const dic = {
        2: 'abc',
        3: 'def',
        4: 'ghi',
        5: 'jkl',
        6: 'mno',
        7: 'pqrs',
        8: 'tuv',
        9: 'wxyz',
    }, ans = [];

    let backTracing = (cur, index) => {
        if (index > digits.length - 1) { //選項滿足長度,加入結果
            ans.push(cur)
            return
        }
        let curDic = dic[digits[index]] //找到當前按鈕對應的字母字串
        for (let prop of curDic) {
            backTracing(cur + prop,index+1) //拼接按鈕對應的字串組合
        }
    }
    backTracing('', 0)
    return ans
};

2.9 - 08.01. 三步問題

三步問題。有個小孩正在上樓梯,樓梯有n階臺階,小孩一次可以上1階、2階或3階。實現一種方法,計算小孩有多少種上樓梯的方式。結果可能很大,你需要對結果模1000000007。

示例1:

 輸入:n = 3 
 輸出:4
 說明: 有四種走法

示例2:

 輸入:n = 5
 輸出:13

提示:
n範圍在[1, 1000000]之間

解題程式碼(會超時)

 var waysToStep = function (n) {
    let ans = [], map = [1, 2, 3]
    let backTracing = (path, sum) => {
        if (sum >= n) {
            if (sum == n) {
                ans++;
            }
            return
        }
        for (let i = 0; i < 3; i++) {
            path.push(map[i]);
            backTracing(path, sum + map[i])
            path.pop(i)
        }
    }
    backTracing([], 0)
    return ans
};

動態規劃解法

/**
 * @param {number} n
 * @return {number}
 */
var waysToStep = function (n) {
    let dp =[],mod = 1000000007;
    dp[0]=0,dp[1]=1,dp[2]=2,dp[3]=4;
    for (let i = 4; i <= n; i++) {
        dp[i] = (dp[i - 1] + dp[i - 2] + dp[i - 3]) % mod
    }
    return dp[n]
};

2-10 - 40. 組合總和 II

給定一個陣列 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和為 target 的組合。

candidates 中的每個數字在每個組合中只能使用一次。
注意:解集不能包含重複的組合。

示例 1:

輸入: candidates = [10,1,2,7,6,1,5], target = 8,
輸出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

示例 2:

輸入: candidates = [2,5,2,1,2], target = 5,
輸出:
[
[1,2,2],
[5]
]

提示:
1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30

解題思路

思路同組合1

解題程式碼


/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
 var combinationSum2 = function (candidates, target) {
    candidates.sort((a,b)=>a-b)
    let ans = [];
    let backTracing = (start, path, sum) => {
        if (sum >= target) {
            if (sum === target) {
                ans.push(path.slice())
            }
            return
        }
        for (let i = start; i < candidates.length; i++) {
            if (i - 1 >= start && candidates[i - 1] == candidates[i]) {
                continue;
            }
            path.push(candidates[i])
            backTracing(i + 1, path, sum + candidates[i])
            path.pop()
        }
    }
    backTracing(0, [], 0)
    return ans
};

2-11 - 47. 全排列 II

給定一個可包含重複數字的序列 nums ,按任意順序 返回所有不重複的全排列。

示例 1:

輸入:nums = [1,1,2]
輸出:
[[1,1,2],
 [1,2,1],
 [2,1,1]]

示例 2:

輸入:nums = [1,2,3]
輸出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10

## 解題思路
同上全排列

## 解題程式碼

var permuteUnique = function (nums) {
   let ans = [];
   let used = Array(nums.length).fill(false)
   let backTracing = (start, path) => {
       if (start === nums.length) {
           ans.push(path.slice())
           return
       }
       for (let i = 0; i < nums.length; ++i) {
           if (used[i] || (i > 0 && nums[i] === nums[i - 1] && !used[i - 1])) {
               continue;
           }
           path.push(nums[i])
           used[i] = true
           backTracing(start + 1, path)
           used[i] = false
           path.pop()
       }
   }
   nums.sort((a, b) => a - b)
   backTracing(0, [])
   return ans

};

三、總結

主要運用了回溯演算法;而解決一個回溯問題,實際上就是一個決策樹的遍歷過程。

3.1 模板

let backtracking=(路徑,選擇列表) =>{
    if (滿足結束條件)) {
        存放路徑;
        return;
    }
    for (選擇:路徑,選擇列表) {
        做出選擇;
        backtracking(路徑,選擇列表); // 遞迴
        回溯,撤銷處理結果
    }
}

即:

  1. 1.路徑:也就是已經做出的選擇。
  2. 2.選擇列表:也就是你當前可以做的選擇。
  3. 3.結束條件:也就是到達決策樹底層,無法再做選擇的條件。

3.2 剪枝函式

  1. 1.用約束條件剪除得不到的可行解的子樹
  2. 2.用目標函式剪取得不到的最優解的子樹

3.3 回溯法的一般步驟:

  1. 1.設定初始化的方案(給變數賦初始值,讀入已知資料等)
  2. 2.變換方式去試探,若全部試完側轉(7)
  3. 3.判斷此法是否成功(通過約束函式),不成功則轉(2)
  4. 4.試探成功則前進一步再試探
  5. 5.正確方案還是未找到則轉(2)
  6. 6.以找到一種方案則記錄並列印
  7. 7.退回一步(回溯),若未退到頭則轉(2)
  8. 8.已退到頭則結束或列印無解

繼續加油!!!

# 四、參考文獻

  1. LeetCode 刷題筆記——遞迴與回溯的理解LeetCode 刷題筆記——遞迴與回溯的理解
  1. 圖解回溯演算法圖解回溯演算法
  2. 回溯演算法總結回溯演算法總結

相關文章