遞迴
遞迴的概念
遞迴是一種解決問題的方法,它從解決問題的各個小部分開始,直到解決最初的大問題。
遞迴通常涉及呼叫函式本身,直接呼叫自身,亦或者間接呼叫自身,都是遞迴函式。就像這樣:
// 直接呼叫自身
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)
,呼叫情況如下:
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,和迭代版本所花時間幾乎相同。