前端學習 資料結構與演算法 快速入門 系列 —— 遞迴

彭加李發表於2021-11-23

遞迴

遞迴的概念

遞迴是一種解決問題的方法,它從解決問題的各個小部分開始,直到解決最初的大問題。

遞迴通常涉及呼叫函式本身,直接呼叫自身,亦或者間接呼叫自身,都是遞迴函式。就像這樣:

// 直接呼叫自身
function fn1(){
    fn1()
}
// 間接呼叫自身
function fn2(){
    fn3()
}

function fn3(){
    fn2()
}

現在執行 fn1() 會一直執行下去,所以每個遞迴函式都必須有一個不在遞迴呼叫的條件(即基線條件),以防止無限遞迴。

有句名言:要理解遞迴,首先要理解遞迴。我們將其翻譯成 javascript 程式碼:

<script>
    function 理解遞迴() {
        const answer = confirm('你理解遞迴了嗎?')
        if (answer) {
            return
        }
        理解遞迴()
    }
    理解遞迴()
</script>

將這段程式碼在瀏覽器中執行,會不斷詢問 你理解遞迴了嗎?,直到你點選 確認 才會終止。

計算一個數的階乘

一個正整數的 階乘(factorial)是所有小於及等於該數的正整數的積,並且0的階乘為1。自然數n的階乘寫作n!

亦即n!=1×2×3×...×(n-1)×n

5 的階乘表示為 5!,等於 1*2*3*4*5,即 120

請看遞迴實現:

// 預設 n 是大於等於0的正整數
function factorial(n) {
  // 基線條件
  if (n <= 1) {
    return 1
  }
  // 遞迴呼叫
  return n * factorial(n - 1)
}

console.log(factorial(5)) // 120

超出最大呼叫堆疊大小

如果忘記給遞迴函式新增停止的條件,會發生什麼?就像這樣:

<script>
    let i = 0
    function fn4() {
        i++
        return fn4()
    }

    try {
        fn4()
    } catch (e) {
        console.log(`i : ${i}   error : ${e}`)
    }
</script>

測試:

// Google Chrome v95
i : 13955   error : RangeError: Maximum call stack size exceeded

// Microsoft Edge v95
i : 13948   error : RangeError: Maximum call stack size exceeded

在 chrome v95 中,該函式執行了 13955 次,最後丟擲錯誤:RangeError:超出最大呼叫堆疊大小,因此,具有停止遞迴的基線條件非常重要。

Tip:es6 有尾呼叫優化,也就是說這段程式碼會一直執行下去。檢視 相容表 你會發現絕大多數瀏覽器都不支援尾呼叫(proper tail calls (tail call optimisation)),故不在展開。

斐波那契數

斐波那契數列(Fibonacci sequence)指的是這樣一個數列:0、1、1、2、3、5、8、13、21、34、……

數 2 由 1 + 1 得到,數 3 由 2 + 1 得到,數 5 由 3 + 2 得到,以此類推。

斐波拉契數列定義如下:

  • 位置 0 的斐波拉契數是 0
  • 位置 1 和 2 的斐波拉契數為 1
  • 位置 n(此處 n > 2)的斐波拉契數是 (n - 1) 的斐波拉契數加上 (n - 2) 的斐波拉契數。

請看遞迴實現:

function fibonacci(val) {
  if (val <= 1) {
    return val
  }

  return fibonacci(val - 1) + fibonacci(val - 2)
}

// 0 1 1 2 3 5
for (let i = 0; i <= 5; i++) {
  console.log(fibonacci(i))
}

遞迴更快嗎

我們使用 console.time() 來檢測兩個版本的 fibonacci 函式(迭代實現 vs 遞迴實現):

// 迭代求斐波拉契數
function fibonacciIterative(n) {
  let pre = 1
  let prePre = 0
  let result = n

  for (let i = 2; i <= n; i++) {
    result = pre + prePre;
    [prePre, pre] = [pre, pre + prePre]
  }
  return result
}

測試:

console.time('fibonacciIterative()')
console.log(fibonacciIterative(45))
console.timeEnd('fibonacciIterative()')

console.time('fibonacci()')
console.log(fibonacci(45))
console.timeEnd('fibonacci()')

// 1134903170
// fibonacciIterative(): 0.579ms
// 1134903170
// fibonacci(): 8.260s

測試表明迭代版本比遞迴版本要快很多。

但是迭代版本更容易理解,所需的程式碼也更少,此外,對於某些演算法,迭代的解法可能不可用。

記憶化的優化技術

執行 fibonacci(45) 既然花費了 8 秒,時間花在哪裡?

假如我們要計算 fibonacci(5),呼叫情況如下:

graph TD a["fibonacci(5)"] --> b["fibonacci(4)"] a["fibonacci(5)"] --> c["fibonacci(3)"] b["fibonacci(4)"] --> d["fibonacci(3)"] b["fibonacci(4)"] --> e["fibonacci(2)"] c["fibonacci(3)"] --> f["fibonacci(2)"] c["fibonacci(3)"] --> g["fibonacci(1)"] d["fibonacci(3)"] --> h["fibonacci(2)"] d["fibonacci(3)"] --> i["fibonacci(1)"]

fibonacci(3) 被呼叫 2 次,fibonacci(2) 被呼叫 3 次,fibonacci(1) 呼叫了 5 次。

我們可以將結果存下來,當需要再次計算它的時候,我們就無需重複計算,直接返回結果即可。重寫 fibonacci() 如下:

const fibonacciMemoization = (function () {
  const mem = [0, 1]
  function fibonacci(val) {
    // 在快取中則直接返回
    if (mem[val]) {
      return mem[val]
    }
    if (val <= 1) {
      return val
    }
    const result = fibonacci(val - 1) + fibonacci(val - 2)
    // 存入快取中
    mem.push(result)
    return result
  }
  return fibonacci
}())

測試:

let num = 45
console.time('fibonacci()')
console.log(fibonacci(num))
console.timeEnd('fibonacci()')

console.time('fibonacciIterative()')
console.log(fibonacciIterative(num))
console.timeEnd('fibonacciIterative()')

console.time('fibonacciMemoization()')
console.log(fibonacciMemoization(num))
console.timeEnd('fibonacciMemoization()')

// 1134903170
// fibonacci(): 10.590s
// 1134903170
// fibonacciIterative(): 0.513ms
// 1134903170
// fibonacciMemoization(): 0.506ms

雖然遞迴版本花費 10s,但是記憶化優化版本只花費 0.5ms,和迭代版本所花時間幾乎相同。

相關文章