以前我很少寫遞歸,因為感覺寫遞歸需要靈感,很難復制。看了《The Little Schemer》后,我發現寫遞歸其實是有套路的。遞歸只需要想清楚 2 個問題:
- 什么情況不需要計算
- 大問題怎么變成小問題
舉例
1. 判斷數組是否包含某元素
const has = (element, arr) => {};
-
什么情況不需要計算?
數組為空時不需要計算,一定不包含。const has = (element, arr) => { if (arr.length === 0) return false; }; -
怎么把大問題變成小問題?
把arr的長度減小,向數組為空的情況逼近。
從arr中取出第一個元素和element比較:- 相同:返回
true。 - 不相同:求解更小的問題。
const has = (element, arr) => { if (arr.length === 0) return false; else if (arr[0] === element) return true; else return has(element, arr.slice(1)); }; - 相同:返回
2. 刪除數組的某個元素
const del = (element, arr) => {};
-
什么情況不需要計算?
數組為空時不需要計算,返回空數組。const del = (element, arr) => { if (arr.length === 0) return []; }; -
怎么把大問題變成小問題?
把arr的長度減小,向空數組的情況逼近。
從arr中取出第一個元素和element比較:- 相同:返回數組余下元素。
- 不相同:留下該元素,再求解更小的問題。
const del = (element, arr) => { if (arr.length === 0) return []; else if (arr[0] === element) return arr.slice(1); else return [ arr[0], ...del(element, arr.slice(1)) ]; };
3. 階乘、斐波那契
階乘、斐波那契用遞歸來寫也是這個套路,代碼結構都是一樣的。
先列出不需要計算的情況,再寫大問題和小問題的轉換關系。
const factorial = n => {
if (n === 1) return 1;
else return n * factorial(n - 1);
};
const fibonacci = n => {
if (n === 1) return 1;
else if (n === 2) return 1;
else return fibonacci(n - 1) + fibonacci(n - 2);
};
4. 小孩子的加法
小孩子用數數的方式做加法,過程是這樣的:
3 顆糖 加 2 顆糖 是幾顆糖?
小孩子會把 3 顆糖放左邊,2 顆糖放右邊。
從右邊拿 1 顆糖到左邊,數 4,
再從右邊拿 1 顆糖到左邊,數 5,
這時候右邊沒了,得出有 5 顆糖。
這也是遞歸的思路。
const add = (m, n) => {};
-
當
n = 0時,不需要計算,結果就是m。const add = (m, n) => { if (n === 0) return m; }; -
把問題向
n = 0逼近:const add = (m, n) => { if (n === 0) return m; else return add(m + 1, n - 1); };
當然
m = 0也是不需要計算的情況。
選擇m = 0還是n = 0作為不需要計算的情況 決定了 大問題轉成小問題的方向。
Continuation Passing Style
const add1 = m => m + 1;
把 add1 的返回結果乘 2,通常這么寫:
add1(5) * 2;
用 Continuation Passing Style 來實現是這樣的:
const add1 = (m, continuation) =>
continuation(m + 1);
add1(5, x => x * 2);
add1 加一個參數 continuation,它是一個函數,表示對結果的后續操作。
我們用 Continuation Passing Style 來寫寫遞歸。
以下用
CPS代替Continuation Passing Stylecont代替continuation
1. 階乘
const factorial = (n, cont) => {
if (n === 1) return cont(1);
else return factorial(n - 1, x => cont(n * x));
};
- 如果
n === 1,把結果1交給cont; - 如果
n > 1,計算n - 1的階乘,
把n - 1階乘的結果x乘n,交給cont。
這個
factorial函數該怎么調用呢?
cont可以傳x => x,這個函數傳入什么就返回什么。factorial(5, x => x);
-
之前的寫法:
const factorial = n => { if (n === 1) return 1; else return n * factorial(n - 1); };遞歸調用
factorial不是函數的最后一步,還需要乘n。
因此編譯器必須保留堆棧。 -
新寫法:
const factorial = (n, cont) => { if (n === 1) return cont(1); else return factorial(n - 1, x => cont(n * x)); };遞歸調用
factorial是函數的最后一步。
做了尾遞歸優化的編譯器將不保留堆棧,從而不怕堆棧深度的限制。
也就是說:可以通過 CPS 把遞歸變成尾遞歸。
2. 斐波那契
const fibonacci = (n, cont) => {
if (n === 1) return cont(1);
else if (n === 2) return cont(1);
else
return fibonacci(n - 1, x =>
fibonacci(n - 2, y => cont(x + y))
);
};
- 如果
n === 1,把結果1交給cont; - 如果
n === 2,把結果1交給cont; - 如果
n > 2,
計算n - 1的結果x,
計算n - 2的結果y,
把x + y交給cont。
3. CPS 尾遞歸使用誤區
-
cont傳入的參數不是最終結果。CPS中cont是對結果的后續操作。
也就是說cont傳入的參數需要是最終結果。
不滿足這一點,就不能叫做CPS。錯誤代碼示例:
const factorial = (n, cont) => { if (n === 1) return cont(1); else return factorial(n - 1, x => cont(n) * x); };上述代碼和之前代碼的區別如下:
x => cont(n) * x; // 現在 錯誤的寫法 x => cont(n * x); // 之前 正確的寫法錯誤的寫法中,
cont傳入的參數不再是最終結果了,如果這么調用:factorial(5, console.log);期望控制台打印
120,
實際控制台打印5。 -
是
CPS,卻不是尾遞歸。const factorial = (n, cont) => { if (n === 1) return cont(1); else return cont(factorial(n - 1, x => n * x)); };以上寫法稱得上是
CPS了,但卻不是尾遞歸。注意這段代碼:
cont(factorial(n - 1, x => n * x));factorial的遞歸調用不是函數的最后一步,cont的調用才是最后一步。
4. 驗證 CPS 尾遞歸優化
截止到 2019 年 11 月,只有 Safari 瀏覽器宣稱支持尾遞歸優化。
用從 1 加到 N 的例子試驗了一下,Safari 13.0.3:
-
一般遞歸:堆棧溢出。
"use strict"; const sum = n => { if (n === 1) return 1; else return n + sum(n - 1); }; sum(100000); -
CPS尾遞歸:正常算出結果。"use strict"; const sum = (n, cont) => { if (n === 1) return cont(1); else return sum(n - 1, x => cont(n + x)); }; sum(1000000, x => x);
最后想說的
用以前的方式寫遞歸 還是 用 CPS 寫遞歸,只是寫法上不同,思想都是一樣的,都是要搞清:
- 什么情況不需要計算
- 大問題怎么變成小問題
