遞歸:如何用三行代碼找到“最終推薦人”
推薦注冊返傭金的這個功能我想你應該不陌生吧?現在很多 App 都有這個功能。這個功能中,用戶 A 推薦用戶 B 來注冊,用戶 B 又推薦了用戶 C 來注冊。我們可以說,用戶 C 的“最終推薦人”為用戶 A,用戶 B 的“最終推薦人”也為用戶 A,而用戶 A 沒有“最終推薦人”。
一般來說,我們會通過數據庫來記錄這種推薦關系。在數據庫表中,我們可以記錄兩行數據,其中 actor_id 表示用戶 id,referrer_id 表示推薦人 id。
基于這個背景,我的問題是,給定一個用戶 ID,如何查找這個用戶的“最終推薦人”? 帶著這個問題,我們來學習今天的內容,遞歸(Recursion)!
如何理解“遞歸”?
從我自己學習數據結構和算法的經歷來看,我個人覺得,有兩個最難理解的知識點,一個是動態規劃,另一個就是遞歸。
遞歸是一種應用非常廣泛的算法(或者編程技巧)。之后我們要講的很多數據結構和算法的編碼實現都要用到遞歸,比如 DFS 深度優先搜索、前中后序二叉樹遍歷等等。所以,搞懂遞歸非常重要,否則,后面復雜一些的數據結構和算法學起來就會比較吃力。
不過,別看我說了這么多,遞歸本身可是一點兒都不“高冷”,咱們生活中就有很多用到遞歸的例子。
周末你帶著女朋友去電影院看電影,女朋友問你,咱們現在坐在第幾排啊?電影院里面太黑了,看不清,沒法數,現在你怎么辦?
別忘了你是程序員,這個可難不倒你,遞歸就開始排上用場了。于是你就問前面一排的人他是第幾排,你想只要在他的數字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也問他前面的人。就這樣一排一排往前問,直到問到第一排的人,說我在第一排,然后再這樣一排一排再把數字傳回來。直到你前面的人告訴你他在哪一排,于是你就知道答案了。
這就是一個非常標準的遞歸求解問題的分解過程,去的過程叫“遞”,回來的過程叫“歸”。基本上,所有的遞歸問題都可以用遞推公式來表示。剛剛這個生活中的例子,我們用遞推公式將它表示出來就是這樣的:
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;
}
遞歸需要滿足的三個條件
剛剛這個例子是非常典型的遞歸,那究竟什么樣的問題可以用遞歸來解決呢?我總結了三個條件,只要同時滿足以下三個條件,就可以用遞歸來解決。
1. 一個問題的解可以分解為幾個子問題的解
何為子問題?子問題就是數據規模更小的問題。比如,前面講的電影院的例子,你要知道,“自己在哪一排”的問題,可以分解為“前一排的人在哪一排”這樣一個子問題。
2. 這個問題與分解之后的子問題,除了數據規模不同,求解思路完全一樣
比如電影院那個例子,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一樣的。
3. 存在遞歸終止條件
把問題分解為子問題,把子問題再分解為子子問題,一層一層分解下去,不能存在無限循環,這就需要有終止條件。
還是電影院的例子,第一排的人不需要再繼續詢問任何人,就知道自己在哪一排,也就是 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)
有了這個公式,我們轉化成遞歸代碼就簡單多了。最終的遞歸代碼是這樣的:
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;
}
那是不是所有的遞歸代碼都可以改為這種迭代循環的非遞歸寫法呢?
籠統地講,是的。因為遞歸本身就是借助棧來實現的,只不過我們使用的棧是系統或者虛擬機本身提供的,我們沒有感知罷了。如果我們自己在內存堆上實現棧,手動模擬入棧、出棧過程,這樣任何遞歸代碼都可以改寫成看上去不是遞歸代碼的樣子。
但是這種思路實際上是將遞歸改為了“手動”遞歸,本質并沒有變,而且也并沒有解決前面講到的某些問題,徒增了實現的復雜度。
解答開篇
到此為止,遞歸相關的基礎知識已經講完了,咱們來看一下開篇的問題:如何找到“最終推薦人”?我的解決方案是這樣的:
long findRootReferrerId(long actorId) {
Long referrerId = select referrer_id from [table] where actor_id = actorId;
if (referrerId == null) return actorId;
return findRootReferrerId(referrerId);
}
是不是非常簡潔?用三行代碼就能搞定了,不過在實際項目中,上面的代碼并不能工作,為什么呢?這里面有兩個問題。
第一,如果遞歸很深,可能會有堆棧溢出的問題。
第二,如果數據庫里存在臟數據,我們還需要處理由此產生的無限遞歸問題。比如 demo 環境下數據庫中,測試工程師為了方便測試,會人為地插入一些數據,就會出現臟數據。如果 A 的推薦人是 B,B 的推薦人是 C,C 的推薦人是 A,這樣就會發生死循環。
第一個問題,我前面已經解答過了,可以用限制遞歸深度來解決。第二個問題,也可以用限制遞歸深度來解決。不過,還有一個更高級的處理方法,就是自動檢測 A-B-C-A 這種“環”的存在。如何來檢測環的存在呢?這個我暫時不細說,你可以自己思考下,后面的章節我們還會講。
內容小結
關于遞歸的知識,到這里就算全部講完了。我來總結一下。
遞歸是一種非常高效、簡潔的編碼技巧。只要是滿足“三個條件”的問題就可以通過遞歸代碼來解決。
不過遞歸代碼也比較難寫、難理解。編寫遞歸代碼的關鍵就是不要把自己繞進去,正確姿勢是寫出遞推公式,找出終止條件,然后再翻譯成遞歸代碼。
遞歸代碼雖然簡潔高效,但是,遞歸代碼也有很多弊端。比如,堆棧溢出、重復計算、函數調用耗時多、空間復雜度高等,所以,在編寫遞歸代碼的時候,一定要控制好這些副作用。
課后思考
我們平時調試代碼喜歡使用 IDE 的單步跟蹤功能,像規模比較大、遞歸層次很深的遞歸代碼,幾乎無法使用這種調試方式。對于遞歸代碼,你有什么好的調試方法呢?
評論精選
調試遞歸:
1.打印日志發現,遞歸值。
2.結合條件斷點進行調試。檢測環可以構造一個set集合或者散列表(下面都叫散列表吧,為了方便)。每次獲取到上層推薦人就去散列表里先查,沒有查到的話就加入,如果存在則表示存在環了。當然,每一次查詢都是一個自己的散列表,不能共用。實際情況內存不會耗費太多。
界定問題能否用遞歸解決
1.一個問題的解可以分解為幾個子問題的解;
2.這個問題與分解子問題的求解思路完全相同;
3.存在終止條件
編寫遞歸代碼的技巧
1.終止條件
2.遞推公式
3.清理現場
編寫遞歸的關鍵是思考終止條件,把問題抽象成一個遞推公式,并信任它一定能幫我們完成任務,不用想一層層的調用關系,試圖用人腦分解遞歸是反人類的,最多只能想兩三層。
遞歸的缺點
遞歸會利用棧保存臨時變量,如果遞歸過深,會造成棧溢出。解決方案是控制遞歸的深度。
遞歸要警惕重復計算,遞歸分解的子問題、子子問題可能存在相同的情況,如果都一一計算的話,就會發生重復計算。解決方案是使用散列表來保存結算結果,每次開始計算前檢查散列表是否已經有結算結果。
籠統地講,遞歸代碼都能用迭代循環來替換。
總結
一、什么是遞歸?
1.遞歸是一種非常高效、簡潔的編碼技巧,一種應用非常廣泛的算法,比如DFS深度優先搜索、前中后序二叉樹遍歷等都是使用遞歸。
2.方法或函數調用自身的方式稱為遞歸調用,調用稱為遞,返回稱為歸。
3.基本上,所有的遞歸問題都可以用遞推公式來表示,比如
f(n) = f(n-1) + 1;
f(n) = f(n-1) + f(n-2);
f(n)=n*f(n-1);
二、為什么使用遞歸?遞歸的優缺點?
1.優點:代碼的表達力很強,寫起來簡潔。
2.缺點:空間復雜度高、有堆棧溢出風險、存在重復計算、過多的函數調用會耗時較多等問題。
三、什么樣的問題可以用遞歸解決呢?
一個問題只要同時滿足以下3個條件,就可以用遞歸來解決:
1.問題的解可以分解為幾個子問題的解。何為子問題?就是數據規模更小的問題。
2.問題與子問題,除了數據規模不同,求解思路完全一樣
3.存在遞歸終止條件
四、如何實現遞歸?
1.遞歸代碼編寫
寫遞歸代碼的關鍵就是找到如何將大問題分解為小問題的規律,并且基于此寫出遞推公式,然后再推敲終止條件,最后將遞推公式和終止條件翻譯成代碼。
2.遞歸代碼理解
對于遞歸代碼,若試圖想清楚整個遞和歸的過程,實際上是進入了一個思維誤區。
那該如何理解遞歸代碼呢?如果一個問題A可以分解為若干個子問題B、C、D,你可以假設子問題B、C、D已經解決。而且,你只需要思考問題A與子問題B、C、D兩層之間的關系即可,不需要一層層往下思考子問題與子子問題,子子問題與子子子問題之間的關系。屏蔽掉遞歸細節,這樣子理解起來就簡單多了。
因此,理解遞歸代碼,就把它抽象成一個遞推公式,不用想一層層的調用關系,不要試圖用人腦去分解遞歸的每個步驟。
五、遞歸常見問題及解決方案
1.警惕堆棧溢出:可以聲明一個全局變量來控制遞歸的深度,從而避免堆棧溢出。
2.警惕重復計算:通過某種數據結構來保存已經求解過的值,從而避免重復計算。
六、如何將遞歸改寫為非遞歸代碼?
籠統的講,所有的遞歸代碼都可以改寫為迭代循環的非遞歸寫法。如何做?抽象出遞推公式、初始值和邊界條件,然后用迭代循環實現。