以前我很少寫遞歸,因為感覺寫遞歸需要靈感,很難複製。學了點函數式後,我發現寫遞歸其實是有套路的。 ...
以前我很少寫遞歸,因為感覺寫遞歸需要靈感,很難複製。
學了點函數式後,我發現寫遞歸其實是有套路的。
遞歸只需要想清楚 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 Style
cont
代替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
。
CPS
可以把遞歸變成尾遞歸,但並不是用了 CPS
的遞歸就是尾遞歸。
像這麼寫,就不是尾遞歸:
const fibonacci = (n, cont) => {
if (n === 1) return cont(1);
else if (n === 2) return cont(1);
else
return fibonacci(n - 1, x =>
cont(fibonacci(n - 2, y => x + y))
);
};
註意這段代碼:
x => cont(fibonacci(n - 2, y => x + y));
fibonacci
的調用不是函數的最後一步,cont
的調用才是最後一步。
最後想說的
不管按以前的方式寫遞歸 還是 用 CPS
寫遞歸,思想都是一樣的,都是要搞清:
- 什麼情況不需要計算
- 大問題怎麼變成小問題