前言
遞迴函式是一個很有趣的東西
也常在一些特殊的資料結構上會使用到
而遞迴最主要的構成因素就是在函式裡面呼叫自己,大概長得像是下面的結構:
function recursion(param) {
recursion(param);
}
// initial call
recursion(initialParam);
但若像上面的結構,不斷地呼叫自己,沒有終止的機制,就會像無窮迴圈一樣,讓電腦記憶體爆掉
因此,遞迴函式裡面必須有一個刹車的機制
流程控制:繼續走?還是回頭?
在流程控制(control flow)上,可以分成兩條路:
- 符合終止條件:停止
- 不符合終止條件:呼叫自己
流程圖大概如下:
夢境
我很喜歡用一個比喻去詮釋遞迴的概念,那就是:全面啟動
電影中的主角柯伯如何進入夢境的?
沒錯,睡著就行了!
而在夢中的他,有兩個選擇:
- 醒來,回到現實世界
- 再睡著一次,進入「夢中夢」,也就是第二層夢境
但不幸的是,隊員的其中一人在出任務的時候受傷而進入昏迷狀態,柯伯必須去下一層救他
原本計畫順利的話,應該就此打道回府才是
然而面對這樣情況,他也無法執行了
任務達成的條件尚未成立
於是他說:「我們必須前往更深處」
他只好再睡著一次,去下一層夢境了
然後他終於找回隊員了
該怎麼回到現實世界呢?
沒錯!就是 kick!
執行環境與堆疊
在理解遞迴的同時,也需要先了解這兩個關鍵字作為基礎知識
一個是執行環境(execution context),另一個則是堆疊(stack)
這裡就直接用英文稱呼,比較直覺一點
在 JavaScript 的世界裡,其實有兩種 execution context:
- 全域執行環境(Global Execution Context)
- 函式執行環境(Function Execution Context)
而我們這裡討論的,主要是函式執行環境,而所有函式執行環境的最外面,即是所謂的全域執行環境
context 就是記住當下的時空背景:當下的有哪些變數、其值又是什麼
每一次的函式呼叫,就會創造一個新的 context,以便記憶此次呼叫的環境狀態
創造出來 context 則會存放在稱為 stack 的地方
新的 context 建立了之後,就丟進去這個 stack,
因此,stack 裡的順序就會是:舊的在下,新的在上
下一次要去拿 context 的時候,就會先拿到比較新的 context
簡單範例
大致了解 context 與 stack 的概念之後,我們來看一個簡單的範例:階乘的計算
同時,我們把全面啟動的故事也放在手邊(到底是有多愛全面啟動
階乘的數學定義如下:
$$ n! = n \times (n - 1) \times (n - 2) \times \ldots \times 3 \times 2 \times 1 $$
我們要拿指定的初始值 n
去做階乘計算
function factorial(n) {
// 終止條件
if (n === 0) {
return 1;
} else {
return n * factorial(n - 1); // 呼叫自己
}
}
factorial(4); // result: 24
factorial(2); // result: 2
factorial(0); // result: 1
終止條件是,當 n = 0 時,return 1
因為階乘的定義上:
$$ 0! = 1 $$
假設我們的初始值 n = 3,於是我們執行第一次的呼叫:
factorial(3);
柯伯坐在飛機頭等艙舒適的座椅上睡著了,進入了夢境
第一次的呼叫,建立的一個新的 context,裡面有我們帶進去的 n = 3
所以我們可以這樣表示:
Context { n: 3 }
並且將這個 context 丟進 stack 中儲存,可以這樣表示:
Stack = [
{ n: 3 }
];
然而我們的 n
沒有符合終止條件,於是,進行了第二次的呼叫 factorial
柯伯為了解任務,再次睡著進入了夢中夢
此時,我們帶入 factorial
的參數是 n - 1
,所以是 3 - 1 = 2
,帶入 2
第二次的呼叫,再次建立一個屬於它的 context:
Context { n: 2 }
於是 stack 變成:
Stack = [
{ n: 2 },
{ n: 3 }
];
我們又回到了函式的第二行,再次判斷終止與否,但仍未滿足條件,於是進行第三次的呼叫:
柯伯達成任務了,不料,隊員昏迷不醒,於是他躍身進入下一層夢境
然後再重複了一次遞迴
OK,這次 n = 0,所以要 return 1
回去,至此,不再遞迴下去
柯伯找回遺失的隊員要打道回府了
return
代表結束這個函式,並回傳值
但別忘了,我們現在在第幾層夢境?
柯伯說:「我是誰?我為什麼在這裡?」
啊,我是指在哪一個 context 裡?
最後那一個對吧,也就是 stack 最上面那個:Context: { n: 0 }
所以當我們 return
之後,回到上一次層是:Context: { n: 1 }
這個 context,而不是現實世界(aka Global 的 context)
柯伯醒來一次,回到上一層夢境
然後我們看到上一層這樣寫:return n * factorial(n - 1)
於是,1
跟上一層的 n
相乘之後,又繼續 return
更上一層,一連串的 return
柯伯一行人,藉由一連串的 kick,一路回到現實世界
整個過程像是這樣:
factorial(3);
3 * factorial(2);
2 * factorial(1);
1 * factorial(0);
return 1;
return 1 * 1;
return 2 * 1;
return 3 * 2;
於是最後我們得到結果就是:
$$ 3! = 6 $$
我們總共呼叫了 factorial
這個函式四次,稱之為遞迴的深度(depth)
如果我們想知道每一次呼叫時的 depth,可以這樣寫:
function factorial(n, depth) {
console.log("depth: ", depth); // 現在的遞迴深度
console.log("context: n =", n); // 當前的 n 值,也就是目前的 context
if (n === 0) {
return 1;
} else {
return n * factorial(n - 1, depth + 1);
}
}
factorial(2, 1); // 第一次呼叫,所以深度從 1 開始
這樣就可以看到每一個 context 及對應的 depth 了:
depth: 1
context: n = 2
depth: 2
context: n = 1
depth: 3
context: n = 0
於是柯伯轉動手上的陀螺,以避免迷失自我
而這裡的 console.log
則成為各個 context 裡的指標(電影裡稱之為「圖騰」totem)
但柯伯的陀螺只能判斷是在夢境(aka Function Execution Context)或是現實(aka Global Execution Context)
還是不太方便呢(重點錯
效能考量
由上述的範例可知,每一次的呼叫,就會創造一個 context 並存到 stack 之中
所以遞迴的深度越深,context 也會越多,電腦需要花更多的記憶體去存放這個龐大的 stack
到達極限的時候,就會造成「堆疊溢位」(stack overflow)
遞迴函式自有它簡潔優雅的優點,但優點是用吃重記憶體這個缺點換來的
就像我們以前會說:「Chrome 瀏覽器的速度好快啊!」但它是吃記憶體的大怪獸啊
基於效能的考量,就會衍生出迭代(Iterate)與遞迴(Recursion)的優缺點比較了
有機會再聊這塊
後記
其實一直想要寫關於遞迴的文章
但又不知道從何寫起
而每當在用遞迴函式的時候,總想到全面啟動這部電影
為了寫這篇,我還重新看了一次電影呢 🙈
參考網路上很多關於遞迴的範例
其他常見的遞迴範例還有,像是費式數列、巴斯卡三角、巢狀資料結構的處理……等
他們都寫得很好,我也把連結列在參考資料
或許我並不是想要寫遞迴,而是全面啟動吧 🙃
參考資料
- JavaScript 學演算法(二十二)- 遞迴 Recursion – 竹白記事本
- Recursion and stack
- Recursion - MDN Web Docs Glossary: Definitions of Web-related terms | MDN
- JavaScript Recursion (with Examples)
- JavaScript Recursive Function By Examples
- Recursive Functions in JavaScript: 10 Examples - Software Development
- Difference between Recursion and Iteration - Interview Kickstart
- [JavaScript] Javascript 的執行環境 (Execution context) 與堆疊 (Stack) | by itsems | itsems_frontend | Medium
- Factorial - Wikipedia