10 | 递归:如何用三行代码找到“最终推荐人”?
什么是遞歸?
遞歸是一種應用非常廣泛的算法(或者編程技巧)。很多數據結構和算法的編碼實現都要用到遞歸,比如 DFS 深度優先搜索、前中后序二叉樹遍歷等等。
去的過程叫“遞”,回來的過程叫“歸”
場景
周末你帶著女朋友去電影院看電影,女朋友問你,咱們現在坐在第幾排啊?電影院里面太黑了,看不清,沒法數,現在你怎么辦?別忘了你是程序員,這個可難不倒你,遞歸就開始排上用場了。于是你就問前面一排的人他是第幾排,你想只要在他的數字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也問他前面的人。就這樣一排一排往前問,直到問到第一排的人,說我在第一排,然后再這樣一排一排再把數字傳回來。直到你前面的人告訴你他在哪一排,于是你就知道答案了。這就是一個非常標準的遞歸求解問題的分解過程,去的過程叫“遞”,回來的過程叫“歸”?;旧?#xff0c;所有的遞歸問題都可以用遞推公式來表示。剛剛這個生活中的例子,我們用遞推公式將它表示出來就是這樣的:
f(n)=f(n-1)+1 其中,f(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,f(2)=2
將遞推公式和終止條件整合到一起:
/** 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 兩層之間的關系即可,不需要一層一層往下思考子問題與子子問題,子子問題與子子子問題之間的關系。屏蔽掉遞歸細節,這樣子理解起來就簡單多了
遞歸的弊端
遞歸代碼雖然簡潔高效,但是也有不少的弊端
1、遞歸代碼要警惕堆棧溢出
- 函數調用會使用棧來保存臨時變量。每調用一個函數,都會將臨時變量封裝為棧幀壓入內存棧,等函數執行完成返回時,才出棧。系統?;蛘咛摂M機??臻g一般都不大。如果遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險。
如何避免堆棧溢出
- 限制調用深度:遞歸調用超過一定深度(比如 1000)之后,我們就不繼續往下再遞歸了,直接返回報錯
2、遞歸代碼要警惕重復計算
觀察如下示意圖,其中遞歸涉及到多處重復計算
優化方案
為了避免重復計算,我們可以通過一個數據結構(比如散列表)來保存已經求解過的 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 hasSolvedList.get(n);}int ret = f(n-1) + f(n-2);hasSolvedList.put(n, ret);return ret; }3、函數調用耗時多
4、空間復雜度高
遞歸代碼改寫為非遞歸代碼
遞歸有利有弊,利是遞歸代碼的表達力很強,寫起來非常簡潔;而弊就是空間復雜度高、有堆棧溢出的風險、存在重復計算、過多的函數調用會耗時較多等問題。
那是不是所有的遞歸代碼都可以改為這種迭代循環的非遞歸寫法呢?籠統地講,是的。因為遞歸本身就是借助棧來實現的,只不過我們使用的棧是系統或者虛擬機本身提供的,我們沒有感知罷了。如果我們自己在內存堆上實現棧,手動模擬入棧、出棧過程,這樣任何遞歸代碼都可以改寫成看上去不是遞歸代碼的樣子。但是這種思路實際上是將遞歸改為了“手動”遞歸,本質并沒有變,而且也并沒有解決前面講到的某些問題,徒增了實現的復雜度。
如何找到“最終推薦人”
偽代碼實現
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,這樣就會發生死循環。
?
總結
以上是生活随笔為你收集整理的10 | 递归:如何用三行代码找到“最终推荐人”?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: java多元一次方程组求解_java 怎
- 下一篇: python爬虫实例之一