「演算法之美系列」遞迴與回溯(JS版)

wuwhs發表於2021-11-16

遞迴(Recursion)

遞迴和回溯的關係密不可分:
遞迴的基本性質就是函式呼叫,在處理問題的時候,遞迴往往是把一個大規模的問題不斷地變小然後進行推導的過程。
回溯則是利用遞迴的性質,從問題的起始點出發,不斷地進行嘗試,回頭一步甚至多步再做選擇,直到最終抵達終點的過程。

遞迴演算法思想

遞迴演算法是一種呼叫自身函式的演算法(二叉樹的許多性質在定義上就滿足遞迴)。

漢諾塔問題

有三個塔 ABC,一開始的時候,在塔 A 上放著 n 個盤子,它們自底向上按照從大到小的順序疊放。現在要求將塔 A 中所有的盤子搬到塔 C 上,讓你列印出搬運的步驟。在搬運的過程中,每次只能搬運一個盤子,另外,任何時候,無論在哪個塔上,大盤子不能放在小盤子的上面。

hano

漢諾塔問題解法

  • 從最終的結果出發,要把 n 個盤子按照大小順序疊放在塔 C 上,就需要將塔 A 的底部最大的盤子搬到塔 C
  • 為了實現步驟 1,需要將除了這個最大盤子之外的其餘盤子都放到塔 B 上。

由上可知,將原來的問題規模從 n 個盤子變成了 n-1 個盤子,即將 n-1 個盤子轉移到塔 B 上。

如果一個函式,能將 n 個盤子從塔 A,藉助塔 B,搬到塔 C。那麼,也可以利用該函式將 n-1 個盤子從塔 A,藉助塔 C,搬到塔 B。同理,不斷地把問題規模變小,當 n1,也就是隻有 1 個盤子的時候,直接列印出步驟。

漢諾塔問題程式碼示例

// 漢諾塔問題
const hano = function (A, B, C, n) {
  if (n > 0) {
    hano(A, C, B, n - 1)
    move(A, C)
    hano(B, A, C, n - 1)
  }
}

const move = function (p, c) {
  const temp = p.pop()
  c.push(temp)
}
const a = [1, 2, 3, 4, 5]
const b = []
const c = []
hano(a, b, c, a.length)

console.log('----after----')
console.log('a: ', String(a)) //
console.log('b: ', String(b)) //
console.log('c: ', String(c)) // 1, 2, 3, 4, 5

由上述總結出遞迴的演算法思想,將一個問題的規模變小,然後再利用從小規模問題中得出的結果,結合當前的值或者情況,得出最終的結果。

通俗來說,把要實現的遞迴函式看成是已經實現好的, 直接利用解決一些子問題,然後需要考慮的就是如何根據子問題的解以及當前面對的情況得出答案。這種演算法也被稱為自頂向下(Top-Down)的演算法。

數字解碼問題

LeetCode 第 91 題,解碼的方法。
一條包含字母  A-Z  的訊息通過以下方式進行了編碼:
'A' -> 1
'B' -> 2

'Z' -> 26
給定一個只包含數字的非空字串,請計算解碼方法的總數。

數字解碼解題思路

  • 就例題中的第二個例子,給定編碼後的訊息是字串“226”,如果對其中“22”的解碼有 m 種可能,那麼,加多一個“6”在最後,相當於在最終解密出來的字串裡多了一個“F”字元而已,總體的解碼還是隻有 m 種。
  • 對於“6”而言,如果它的前面是”1”或者“2”,那麼它就有可能是“16”,“26”,所以還可以再往前看一個字元,發現它是“26”。而前面的解碼組合是 k 個,那麼在這 k 個解出的編碼裡,新增一個“Z”,所以總的解碼個數就是 m+k

數字解碼程式碼實現

const numDecoding = function (str) {
  if (str.charAt(0) === '0') return 0
  const chars = [...str]
  return decode(chars, chars.length - 1)
}

// 字串轉化成字元組,利用遞迴函式 decode,從最後一個字串向前遞迴
const decode = function (chars, index) {
  if (index <= 0) return 1
  let count = 0
  let curr = chars[index]
  let prev = chars[index - 1]

  // 當前字元比 `0` 大,則直接利用它之前的字串所求得結果
  if (curr > '0') {
    count = decode(chars, index - 1)
  }

  // 由前一個字元和當前字元構成的數字,值必須要在1和26之間,否則無法編碼
  if (prev === '1' || (prev == '2' && curr <= '6')) {
    count += decode(chars, index - 2)
  }

  return count
}

console.log('count: ', numDecoding('1213')) // count: 5

遞迴問題解題模板

通過上述例題,來歸納總結一下遞迴函式的解題模版。

解題步驟

  • 判斷當前情況是否非法,如果非法就立即返回,這一步也被稱為完整性檢查(Sanity Check)。例如,看看當前處理的情況是否越界,是否出現了不滿足條件的情況。通常,這一部分程式碼都是寫在最前面的。
  • 判斷是否滿足結束遞迴的條件。在這一步當中,處理的基本上都是一些推導過程當中所定義的初始情況。
  • 將問題的規模縮小,遞迴呼叫。在歸併排序和快速排序中,我們將問題的規模縮小了一半,而在漢諾塔和解碼的例子中,我們將問題的規模縮小了一個。
  • 利用在小規模問題中的答案,結合當前的資料進行整合,得出最終的答案。

遞迴問題解題模板程式碼實現

function fn(n) {
    // 第一步:判斷輸入或者狀態是否非法?
    if (input/state is invalid) {
        return;
    }

    // 第二步:判讀遞迴是否應當結束?
    if (match condition) {
        return some value;
    }

    // 第三步:縮小問題規模
    result1 = fn(n1)
    result2 = fn(n2)
    ...

    // 第四步: 整合結果
    return combine(result1, result2)
}

中心對稱數問題

LeetCode 第 247 題:找到所有長度為 n 的中心對稱數。

示例
輸入:  n = 2
輸出: ["11","69","88","96"]

中心對稱數問題解題思路

symmetric-number

  • n=0 的時候,應該輸出空字串:“ ”
  • n=1 的時候,也就是長度為 1 的中心對稱數有:0,1,8
  • n=2 的時候,長度為 2 的中心對稱數有:11, 69,88,96。注意:00 並不是一個合法的結果。
  • n=3 的時候,只需要在長度為 1 的合法中心對稱數的基礎上,不斷地在兩邊新增 11,69,88,96 就可以了。

[101, 609, 808, 906, 111, 619, 818, 916, 181, 689, 888, 986]

隨著 n 不斷地增長,我們只需要在長度為 n-2 的中心對稱數兩邊新增 11,69,88,96 即可。

中心對稱數問題程式碼實現

const helper = function (n) {
  debugger
  // 第一步:判斷輸入或狀態是否非法
  if (n < 0) {
    throw new Error('illegal argument')
  }

  // 第二步:判讀遞迴是否應當結束
  if (n === 0) {
    return ['']
  }
  if (n === 1) {
    return ['0', '1', '8']
  }

  // 第三步:縮小問題規模
  const list = helper(n - 2)

  // 第四步:整合結果
  const res = []

  for (let i = 0; i < list.length; i++) {
    let s = list[i]
    res.push('1' + s + '1')
    res.push('6' + s + '9')
    res.push('8' + s + '8')
    res.push('9' + s + '6')
  }
  return res
}
console.log(helper(2)) // [ '11', '69', '88', '96' ]

回溯(Backtracking)

回溯演算法思想

回溯實際上是一種試探演算法,這種演算法跟暴力搜尋最大的不同在於,在回溯演算法裡,是一步一步地小心翼翼地進行向前試探,會對每一步探測到的情況進行評估,如果當前的情況已經無法滿足要求,那麼就沒有必要繼續進行下去,也就是說,它可以幫助我們避免走很多的彎路。

回溯演算法的特點在於,當出現非法的情況時,演算法可以回退到之前的情景,可以是返回一步,有時候甚至可以返回多步,然後再去嘗試別的路徑和辦法。這也就意味著,想要採用回溯演算法,就必須保證,每次都有多種嘗試的可能。

回溯演算法解題模板

解題步驟:

  • 判斷當前情況是否非法,如果非法就立即返回;
  • 當前情況是否已經滿足遞迴結束條件,如果是就將當前結果儲存起來並返回;
  • 當前情況下,遍歷所有可能出現的情況並進行下一步的嘗試;
  • 遞迴完畢後,立即回溯,回溯的方法就是取消前一步進行的嘗試。

回溯演算法解題程式碼

function fn(n) {
  // 第一步:判斷輸入或者狀態是否非法?
  if (input/state is invalid) {
    return;
  }
  
  // 第二步:判讀遞迴是否應當結束?
  if (match condition) {
    return some value;
  }

  // 遍歷所有可能出現的情況
  for (all possible cases) {  
    // 第三步: 嘗試下一步的可能性
    solution.push(case)
    // 遞迴
    result = fn(m)

    // 第四步:回溯到上一步
    solution.pop(case)
  }
}

湊整數問題

LeetCode 第 39 題:給定一個無重複元素的陣列 candidates 和一個目標數  target ,找出 candidates 中所有可以使數字和為 target 的組合。candidates  中的數字可以無限制重複被選取。

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

湊整數問題解題思路

題目要求的是所有不重複的子集,而且子集裡的元素的值的總和等於一個給定的目標。

思路 1:暴力法。

羅列出所有的子集組合,然後逐個判斷它們的總和是否為給定的目標值。解法非常慢。

思路 2:回溯法。

從一個空的集合開始,小心翼翼地往裡面新增元素。

每次新增,檢查一下當前的總和是否等於給定的目標。

如果總和已經超出了目標,說明沒有必要再嘗試其他的元素了,返回並嘗試其他的元素;

如果總和等於目標,就把當前的組合新增到結果當中,表明我們找到了一種滿足要求的組合,同時返回,並試圖尋找其他的集合。

湊整數問題程式碼實現

const combinationSum = function (candidates, target) {
  const results = []
  backtracking(candidates, target, 0, [], results)
  return results
}

const backtracking = function (candidates, target, start, solution, results) {
  if (target < 0) {
    return false
  }

  if (target === 0) {
    results.push([...solution])
    return true
  }

  for (let i = start; i < candidates.length; i++) {
    solution.push(candidates[i])
    backtracking(candidates, target - candidates[i], i, solution, results)
    solution.pop()
  }
}

console.log(combinationSum([1, 2, 3], 5))
// [ [ 1, 1, 1, 1, 1 ],
//   [ 1, 1, 1, 2 ],
//   [ 1, 1, 3 ],
//   [ 1, 2, 2 ],
//   [ 2, 3 ] ]

在主函式裡:

  • 定義一個 results 陣列用來儲存最終的結果;
  • 呼叫函式 backtracking,並將初始的情況以及 results 傳遞進去,這裡的初始情況就是從第一個元素開始嘗試,而且初始的子集為空。

backtracking 函式裡:

  • 檢查當前的元素總和是否已經超出了目標給定的值,每新增進一個新的元素時,就將它從目標總和中減去;
  • 如果總和已經超出了目標給定值,就立即返回,去嘗試其他的數值;
  • 如果總和剛好等於目標值,就把當前的子集新增到結果中。

在迴圈體內:

  • 每次新增了一個新的元素,立即遞迴呼叫 backtracking,看是否找到了合適的子集
  • 遞迴完畢後,要把上次嘗試的元素從子集裡刪除,這是最重要的。

以上,就完成了回溯。

提示:這是一個最經典的回溯的題目,麻雀雖小,但五臟俱全。它完整地體現了回溯演算法的各個階段。

N 皇后問題

LeetCode 第 51 題, 在一個 N×N 的國際象棋棋盤上放置 N 個皇后,每行一個並使她們不能互相攻擊。給定一個整數 N,返回 N 皇后不同的的解決方案的數量。

N 皇后問題解題思路

解決 N 皇后問題的關鍵就是如何判斷當前各個皇后的擺放是否合法。

nqueen

利用一個陣列 columns[] 來記錄每一行裡皇后所在的列。例如,第一行的皇后如果放置在第 5 列的位置上,那麼 columns[0] = 6。從第一行開始放置皇后,每行只放置一個,假設之前的擺放都不會產生衝突,現在將皇后放在第 row 行第 col 列上,檢查一下這樣的擺放是否合理。

方法就是沿著兩個方向檢查是否存在衝突就可以了。

N 皇后問題程式碼實現

首先,從第一行開始直到第 row 行的前一行為止,看那一行所放置的皇后是否在 col 列上,或者是不是在它的對角線上,程式碼如下。

const check = function (row, col, columns) {
  for (let r = 0; r < row; r++) {
    // 其他皇后是否在當前放置皇后的列和對角線上
    if ((columns[r] = col || row - r == Math.abs(columns[r] - col))) {
      return false
    }
  }
  return true
}

然後進行回溯的操作,程式碼如下。

const totalNQueens = function (n) {
  const results = []
  backtracking(n, 0, [], [], results)
  console.log('results: ', results) // results: [ [ [ 0, 0 ], [ 1, 0 ], [ 2, 0 ] ],[ [ 0, 2 ], [ 1, 0 ], [ 2, 0 ] ] ]
  console.log('count: ', results.length) // count: 2
}

const backtracking = function (n, row, columns, solution, results) {
  // 是否在所有 n 行裡都擺好了皇后
  if (row === n) {
    results.push([...solution])
    return
  }

  // 嘗試將皇后放置到當前行中的每一列
  for (let col = 0; col < n; col++) {
    columns[row] = col
    solution.push([row, col])

    // 檢查是否合法,如果合法就繼續到下一行
    if (check(row, col, columns)) {
      backtracking(n, row + 1, columns, solution, results)
    }
    solution.pop()
    // 如果不合法,就不要把皇后放在這列中
    columns[row] = -1
  }
}

totalNQueens(3)

相關文章