遞歸
本質上,將原來的問題,轉化為更小的同一個問題
生活中遞歸的例子
周末你帶着女朋友去電影院看電影,女朋友問你,咱們現在坐在第幾排啊?電影院里面太黑了,看不清,沒法數,現在你怎么辦?
別忘了你是程序員,這個可難不倒你,遞歸就開始排上用場了。於是你就問前面一排的人他是第幾排,你想只要在他的數字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也問他前面的人。就這樣一排一排往前問,直到問到第一排的人,說我在第一排,然后再這樣一排一排再把數字傳回來。直到你前面的人告訴你他在哪一排,於是你就知道答案了。
這就是一個非常標准的遞歸求解問題的分解過程,去的過程叫“遞”,回來的過程叫“歸”。基本上,所有的遞歸問題都可以用遞推公式來表示。剛剛這個生活中的例子,我們用遞推公式將它表示出來就是這樣的:
f(n)=f(n-1)+1
其中,f(1)=1
f(n) 表示你想知道自己在哪一排,f(n-1) 表示前面一排所在的排數,f(1)=1 表示第一排的人知道自己在第一排。有了這個遞推公式,我們就可以很輕松地將它改為遞歸代碼,如下:
int f(int n) {
if (n == 1) return 1;
return f(n-1) + 1;
}
遞歸需要滿足的三個條件
剛剛這個例子是非常典型的遞歸,那究竟什么樣的問題可以用遞歸來解決呢?我總結了三個條件,只要同時滿足以下三個條件,就可以用遞歸來解決。
- 一個問題的解可以分解為幾個子問題的解
何為子問題?子問題就是數據規模更小的問題。比如,前面講的電影院的例子,你要知道,“自己在哪一排”的問題,可以分解為“前一排的人在哪一排”這樣一個子問題。
- 這個問題與分解之后的子問題,除了數據規模不同,求解思路完全一樣
比如電影院那個例子,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一樣的。
- 存在遞歸終止條件
把問題分解為子問題,把子問題再分解為子子問題,一層一層分解下去,不能存在無限循環,這就需要有終止條件。
還是電影院的例子,第一排的人不需要再繼續詢問任何人,就知道自己在哪一排,也就是 f(1)=1,這就是遞歸的終止條件。
如何編寫遞歸代碼?
寫遞歸代碼最關鍵的是寫出遞推公式,找到終止條件,剩下將遞推公式轉化為代碼就很簡單了。
你先記住這個理論。我舉一個例子,帶你一步一步實現一個遞歸代碼,幫你理解。
假如這里有 n 個台階,每次你可以跨 1 個台階或者 2 個台階,請問走這 n 個台階有多少種走法?如果有 7 個台階,你可以 2,2,2,1 這樣子上去,也可以 1,2,1,1,2 這樣子上去,總之走法有很多,那如何用編程求得總共有多少種走法呢?
我們仔細想下,實際上,可以根據第一步的走法把所有走法分為兩類,第一類是第一步走了 1 個台階,另一類是第一步走了 2 個台階。所以 n 個台階的走法就等於先走 1 階后,n-1 個台階的走法 加上先走 2 階后,n-2 個台階的走法。用公式表示就是:
f(n) = f(n-1)+f(n-2)
有了遞推公式,遞歸代碼基本上就完成了一半。我們再來看下終止條件。當有一個台階時,我們不需要再繼續遞歸,就只有一種走法。所以 f(1)=1。這個遞歸終止條件足夠嗎?我們可以用 n=2,n=3 這樣比較小的數試驗一下。
n=2 時,f(2)=f(1)+f(0)。如果遞歸終止條件只有一個 f(1)=1,那 f(2) 就無法求解了。所以除了 f(1)=1 這一個遞歸終止條件外,還要有 f(0)=1,表示走 0 個台階有一種走法,不過這樣子看起來就不符合正常的邏輯思維了。所以,我們可以把 f(2)=2 作為一種終止條件,表示走 2 個台階,有兩種走法,一步走完或者分兩步來走。
所以,遞歸終止條件就是 f(1)=1,f(2)=2。這個時候,你可以再拿 n=3,n=4 來驗證一下,這個終止條件是否足夠並且正確。
我們把遞歸終止條件和剛剛得到的遞推公式放到一起就是這樣的:
f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2)
有了這個公式,我們轉化成遞歸代碼就簡單多了。最終的遞歸代碼是這樣的:
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
我總結一下,寫遞歸代碼的關鍵就是找到如何將大問題分解為小問題的規律,並且基於此寫出遞推公式,然后再推敲終止條件,最后將遞推公式和終止條件翻譯成代碼。
如果一個問題 A 可以分解為若干子問題 B、C、D,你可以假設子問題 B、C、D 已經解決,在此基礎上思考如何解決問題 A。而且,你只需要思考問題 A 與子問題 B、C、D 兩層之間的關系即可,不需要一層一層往下思考子問題與子子問題,子子問題與子子子問題之間的關系。屏蔽掉遞歸細節,這樣子理解起來就簡單多了。
因此,編寫遞歸代碼的關鍵是,只要遇到遞歸,我們就把它抽象成一個遞推公式,不用想一層層的調用關系,不要試圖用人腦去分解遞歸的每個步驟。
遞歸代碼要警惕堆棧溢出
在實際的軟件開發中,編寫遞歸代碼時,我們會遇到很多問題,比如堆棧溢出。而堆棧溢出會造成系統性崩潰,后果會非常嚴重。為什么遞歸代碼容易造成堆棧溢出呢?我們又該如何預防堆棧溢出呢?
我在“棧”那一節講過,函數調用會使用棧來保存臨時變量。每調用一個函數,都會將臨時變量封裝為棧幀壓入內存棧,等函數執行完成返回時,才出棧。系統棧或者虛擬機棧空間一般都不大。如果遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險。
比如前面的講到的電影院的例子,如果我們將系統棧或者 JVM 堆棧大小設置為 1KB,在求解 f(19999) 時便會出現如下堆棧報錯:
Exception in thread "main" java.lang.StackOverflowError
那么,如何避免出現堆棧溢出呢?
我們可以通過在代碼中限制遞歸調用的最大深度的方式來解決這個問題。遞歸調用超過一定深度(比如 1000)之后,我們就不繼續往下再遞歸了,直接返回報錯。還是電影院那個例子,我們可以改造成下面這樣子,就可以避免堆棧溢出了。不過,我寫的代碼是偽代碼,為了代碼簡潔,有些邊界條件沒有考慮,比如 x<=0。
// 全局變量,表示遞歸的深度。
int depth = 0;
int f(int n) {
++depth;
if (depth > 1000) throw exception;
if (n == 1) return 1;
return f(n-1) + 1;
}
但這種做法並不能完全解決問題,因為最大允許的遞歸深度跟當前線程剩余的棧空間大小有關,事先無法計算。如果實時計算,代碼過於復雜,就會影響代碼的可讀性。所以,如果最大深度比較小,比如 10、50,就可以用這種方法,否則這種方法並不是很實用。
遞歸代碼要警惕重復計算
除此之外,使用遞歸時還會出現重復計算的問題。剛才我講的第二個遞歸代碼的例子,如果我們把剛才我講的第二個遞歸代碼的例子,如果我們把整個遞歸過程分解一下的話,那就是這樣的:
從圖中,我們可以直觀地看到,想要計算 f(5),需要先計算 f(4) 和 f(3),而計算 f(4) 還需要計算 f(3),因此,f(3) 就被計算了很多次,這就是重復計算問題。
為了避免重復計算,我們可以通過一個數據結構(比如散列表)來保存已經求解過的 f(k)。當遞歸調用到 f(k) 時,先看下是否已經求解過了。如果是,則直接從散列表中取值返回,不需要重復計算,這樣就能避免剛講的問題了。
按照上面的思路,我們來改造一下剛才的代碼:
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// hasSolvedList 可以理解成一個 Map,key 是 n,value 是 f(n)
if (hasSolvedList.containsKey(n)) {
return hasSovledList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSovledList.put(n, ret);
return ret;
}
除了堆棧溢出、重復計算這兩個常見的問題。遞歸代碼還有很多別的問題。
在時間效率上,遞歸代碼里多了很多函數調用,當這些函數調用的數量較大時,就會積聚成一個可觀的時間成本。在空間復雜度上,因為遞歸調用一次就會在內存棧中保存一次現場數據,所以在分析遞歸代碼空間復雜度時,需要額外考慮這部分的開銷,比如我們前面講到的電影院遞歸代碼,空間復雜度並不是 O(1),而是 O(n)。
怎么將遞歸代碼改寫為非遞歸代碼?
我們剛說了,遞歸有利有弊,利是遞歸代碼的表達力很強,寫起來非常簡潔;而弊就是空間復雜度高、有堆棧溢出的風險、存在重復計算、過多的函數調用會耗時較多等問題。所以,在開發過程中,我們要根據實際情況來選擇是否需要用遞歸的方式來實現。
那我們是否可以把遞歸代碼改寫為非遞歸代碼呢?比如剛才那個電影院的例子,我們拋開場景,只看 f(x) =f(x-1)+1 這個遞推公式。我們這樣改寫看看:
int f(int n) {
int ret = 1;
for (int i = 2; i <= n; ++i) {
ret = ret + 1;
}
return ret;
}
同樣,第二個例子也可以改為非遞歸的實現方式。
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int ret = 0;
int pre = 2;
int prepre = 1;
for (int i = 3; i <= n; ++i) {
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
那是不是所有的遞歸代碼都可以改為這種迭代循環的非遞歸寫法呢?
籠統地講,是的。因為遞歸本身就是借助棧來實現的,只不過我們使用的棧是系統或者虛擬機本身提供的,我們沒有感知罷了。如果我們自己在內存堆上實現棧,手動模擬入棧、出棧過程,這樣任何遞歸代碼都可以改寫成看上去不是遞歸代碼的樣子。
但是這種思路實際上是將遞歸改為了“手動”遞歸,本質並沒有變,而且也並沒有解決前面講到的某些問題,徒增了實現的復雜度。
例子
// 求1-100的和
// fn(n) = fn(n-1) + n
function sum(n) {
if (n === 1) {
return 1;
}
return sum(n - 1) + n;
}
console.log(sum(100));
/**
* LeeCode 256
* @param {number} num
* @return {number}
*/
// var addDigits1 = function(num) {
// if (num < 9 && num > 0) {
// return num
// }
// const total = Array.from(`${num}`, (value) => Number(value)).reduce((acc, item) => {
// return acc + item;
// },)
// return addDigits(total);
// }
var addDigits = function name(num) {
var total = 0;
while (num) {
total += num % 10;
num = Math.floor(num / 10);
}
if (total.toString().length > 1) {
return total && addDigits(total)
}
return total;
};
console.log(addDigits(38));
翻轉鏈表
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var reverseList = function(head) {
var prev = null;
var cur = head;
var temp = null;
while (cur !== null) {
temp = cur.next;
cur.next = prev;
prev = cur;
cur = temp;
}
return prev;
}
// 鏈表的遞歸實現
var reverseList = function(H) {
if (H === null || H.next === null)
//鏈表為空直接返回,而H->next為空是遞歸基
return H
var newHead = reverseList(H.next) //一直循環到鏈尾
H.next.next = H //翻轉鏈表的指向
H.next = null //記得賦值NULL,防止鏈表錯亂
return newHead //新鏈表頭永遠指向的是原鏈表的鏈尾
}
斐波那契數列
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, ...
在數學上,斐波那契數列是以遞歸的方式定義:
f(0) = 0
f(1) = 1
f(n) = f(n - 1) + f(n - 2) // n >= 2
fabonacci的定義,我們可以很輕松的編寫出實現代碼:
function fibonacci(n) {
if (n === 0) {
return 0;
}
if (n === 1) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
怎么解決重復計算的問題?
既然是計算過的數字值被重復計算,那么我們可以使用緩存的方式,把計算過的結果保存起來,更大的數字計算直接從緩存中取,不就可以省去計算過程了嗎?
可以設定一個緩存對象:
var cache = {};
用來保存我們已經計算過的值,cache的使用過程將會是這樣的:
假設我們已經計算過了0~9的結果
cache = {
0: 0,
1: 1,
2: 1,
3: 2,
4: 3,
5: 5,
6: 8,
7: 13,
8: 21,
9: 34
};
當我們要計算fibonacci(5)的時候,我們就能夠1次從緩存中取出結果:
return cache[5];
性能高效的fabonacci程序
var cache = {
0: 0,
1: 1
};
function fibonacci(n) {
if (typeof cache[n] === 'number') {
return cache[n];
}
var result = cache[n] = fibonacci(n - 1) + fibonacci(n - 2);
return result;
}
函數化
function fibonacci() {
var cache = [0, 1];
return function __fibonacci(n) {
return typeof cache[n] === 'number'
? cache[n]
: cache[n] = __fibonacci(n - 1) + __fibonacci(n - 2);
};
}
面向對象的風格
function Fibonaccii() {
if (!(this instanceof Fibonacci)) {
return new Fabonacci();
}
this._cache = [0, 1];
}
Fibonacci.prototype.compute = function (n) {
return typeof this._cache[n] === 'number'
? this._cache[n]
: this._cache[n] = this.compute(n - 1) + this.compute(n - 2);
};
Fabonacci().compute(50);
for循環版本
function fibonacci(n) {
var curvalue = 1
var prevalue = 0
var nextvalue = curvalue;
for (var i = 1; i <= n; i++) {
prevalue = curvalue;
curvalue = nextvalue
nextvalue = curvalue + prevalue
}
return nextvalue
}
去除重復計算的遞歸版本
function fib(n){
function fib_(n,a,b){
if(n==0) return a
else return fib_(n-1,b,a+b)
}
return fib_(n,0,1)
}
使用記憶函數優化正常遞歸版本
function memozi(fn){
var r = {}
return function(n){
if(r[n] == null){
r[n] = fn(n)
return r[n]
}else{
return r[n]
}
}
}
var fibfn = memozi(function(n){
if(n==0){
return 0
}else if(n==1){
return 1
}else{
return fibfn(n-1) + fibfn(n-2)
}
})
純箭頭函數版本
let fib = n => n > 1 ? fib(n-1) + fib(n-2) : n
寫出一個fib數列
const fibonacci = n =>
Array.from({ length: n }).reduce(
(acc, val, i) => acc.concat(i > 1 ? acc[i - 1] + acc[i - 2] : i),
[]
);
尾調用優化
尾調用(Tail Call)是函數式編程的一個重要概念,本身非常簡單,一句話就能說清楚,就是指某個函數的最后一步是調用另一個函數
尾調用之所以與其他調用不同,就在於它的特殊的調用位置。
我們知道,函數調用會在內存形成一個“調用記錄”,又稱“調用幀”(call frame),保存調用位置和內部變量等信息。如果在函數A的內部調用函數B,那么在A的調用幀上方,還會形成一個B的調用幀。等到B運行結束,將結果返回到A,B的調用幀才會消失。如果函數B內部還調用函數C,那就還有一個C的調用幀,以此類推。所有的調用幀,就形成一個“調用棧”(call stack)。
尾調用由於是函數的最后一步操作,所以不需要保留外層函數的調用幀,因為調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用幀,取代外層函數的調用幀就可以了。
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();
// 等同於
function f() {
return g(3);
}
f();
// 等同於
g(3);
上面代碼中,如果函數g不是尾調用,函數f就需要保存內部變量m和n的值、g的調用位置等信息。但由於調用g之后,函數f就結束了,所以執行到最后一步,完全可以刪除f(x)的調用幀,只保留g(3)的調用幀。
這就叫做“尾調用優化”(Tail call optimization),即只保留內層函數的調用幀。如果所有函數都是尾調用,那么完全可以做到每次執行時,調用幀只有一項,這將大大節省內存。這就是“尾調用優化”的意義。
注意,只有不再用到外層函數的內部變量,內層函數的調用幀才會取代外層函數的調用幀,否則就無法進行“尾調用優化”。
function addOne(a){
var one = 1;
function inner(b){
return b + one;
}
return inner(a);
}
上面的函數不會進行尾調用優化,因為內層函數inner用到了外層函數addOne的內部變量one。
尾遞歸
函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。
遞歸非常耗費內存,因為需要同時保存成千上百個調用幀,很容易發生“棧溢出”錯誤(stack overflow)。但對於尾遞歸來說,由於只存在一個調用幀,所以永遠不會發生“棧溢出”錯誤。
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}
factorial(5) // 120
上面代碼是一個階乘函數,計算n的階乘,最多需要保存n個調用記錄,復雜度 O(n) 。
如果改寫成尾遞歸,只保留一個調用記錄,復雜度 O(1) 。
function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5, 1) // 120
還有一個比較著名的例子,就是計算 Fibonacci 數列,也能充分說明尾遞歸優化的重要性。
非尾遞歸的 Fibonacci 數列實現如下。
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10) // 89
Fibonacci(100) // 堆棧溢出
Fibonacci(500) // 堆棧溢出
尾遞歸優化過的 Fibonacci 數列實現如下。
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
if( n <= 1 ) {return ac2};
return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity
由此可見,“尾調用優化”對遞歸操作意義重大,所以一些函數式編程語言將其寫入了語言規格。ES6 是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署“尾調用優化”。這就是說,ES6 中只要使用尾遞歸,就不會發生棧溢出,相對節省內存。