1. 程式人生 > >資料結構與演算法之遞迴篇

資料結構與演算法之遞迴篇

1、背景

現在很多App都有這個功能。使用者A來推薦使用者B來註冊,使用者B又推薦了使用者C來註冊,我們可以說,使用者C的“最終推薦人”為使用者A,使用者B的"最終推薦按人”也為使用者A,而使用者A沒有"最終推薦人"。

一般來說,我們會通過資料庫來記錄這種推薦關係。在資料庫表中,我們可以記錄兩行資料,其中actor_id表示使用者id,referrer_id表示推薦人id.

那麼給定一個使用者ID,如何查詢這個使用者的"最終推薦人"?

 

2、如何理解"遞迴"?

遞迴是一種應用非常廣泛的演算法(或者程式設計技巧)。之後我們要講的很多資料結構和演算法的編碼實現都要用到遞迴,比如DFS深度優先搜尋、前後中序二叉樹遍歷等等。所以搞懂遞迴非常重要,否則後面複雜的一些資料結構和演算法學起來比較吃力。

首先,舉個例子:

週末你帶女朋友去看電影,女朋友問你,咱們現在坐第幾排啊?電影院裡面太黑,看不清,沒法數,現在你怎麼辦?

別忘了你是程式設計師,這個可難不倒你,遞迴開始排上用場。於是你就問前面一排的人他是第幾排,你想只要在他的數字加一,就知道自己在哪一排了。但是前面的人也看不清啊,所以他也問前面的人。就這樣一排一排往前問,直到問道第一排的人,說我在第一排,然後在這樣一排一排再把數字傳回來。直到你前面的人告訴你他在哪一排,於是你就知道答案了。

這就是一個非常標準的遞迴求解問題的分解過程,取得過程叫"遞”,回的過程叫"歸”。基本上所有的遞迴過程都可以用遞推公式來表示。例如剛才的例子,我們用遞推公式將它表示出來就是這樣的:

f(n)表示你想知道自己在哪一排,f(n-1)表示前面一排所在的排數,f(1)=1表示第一排的人知道自己在第一排。有了這個遞推公式,我們就可以輕鬆地將它改為遞迴程式碼,如下:

 

3、遞迴需要滿足的三個條件:

剛剛上面的例子是非常典型的遞迴,那究竟什麼樣的問題可以用遞迴來解決呢?需要同時滿足以下三個條件:

(1)、一個人問題可以分解為幾個子問題的解

何為子問題?子問題就是資料規模更小的問題。比如:前面講的電影院的例子,你要知道,"自己在哪一排"的問題,可以分解為"前面的人在哪一排"這樣一個子問題。

(2)、這個問題與分解之後的子問題,除了資料規模不同,求解思路完全不一樣

比如上面的例子,你求解"自己在哪一排"的思路,和前面一排人求解"自己在哪一排"的思路,是一模一樣的。

(3)、存在遞迴終止條件

把問題分解為子問題,把子問題再分解為子子問題,一層一層分解下去,不能存在無限迴圈,這就需要終止條件。

 

4、如何編寫遞迴程式碼?

剛剛鋪墊了這麼多,現在我們看來,如何來寫遞迴程式碼?寫出遞迴程式碼最關鍵的是寫出遞推公式,找到終止條件,剩下將遞推公式轉化為程式碼就很簡單了。

舉個例子:

假如這裡有n個臺階,每次你可以跨1個臺階或者2個臺階,請問走這個臺階有多少種走法?

如果有7個臺階,你可以2,2,2,1這樣子上去,也可以1,2,1,1,2這樣子上去,總之走法有很多,那如何程式設計求得總共有多少種走法?

我們仔細想一下,實際上,可以根據第一步的走法把所有走法分為兩類,第一類是第一步走了1個臺階,另一類是第一步走了2個臺階。所以n個臺階的走法就等於先走1階後,n-1個臺階的走法加上先走2階後,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來驗證一下,這個終止條件是否滿足並且正確。

我們把遞迴終止條件和剛剛得到的遞推公式放到一起就是這樣的:

 

有了這個公式,我們轉化成遞迴程式碼,就簡單多了。最終的遞迴程式碼是這樣子的:

因此,總結一下:寫遞迴程式碼的關鍵就是找到如何將大問題轉化成小問題的規律,並且基於此寫出遞推公式,然後再推敲終止條件,最後遞推公式和終止條件翻譯成程式碼。

剛講的電影院的例子,我們用遞迴呼叫只有一個分支,也就是說"一個問題只需要分解為一個子問題",我們很容易能夠想清楚"遞"和"歸"的每一個步驟,所以寫起來、理解起來都不難。

但是,當我們面對是一個問題要分解為多個子問題的情況,遞迴程式碼就沒有那麼好理解了。

像剛才的第二個例子,人腦幾乎沒有辦法把整個"遞"和"歸"的過程一步一步都想清楚。

計算機擅長做重複的事情,所以遞迴正和它的胃口。而我們人腦更喜歡平鋪直敘的思維方式。當我們看到遞迴時,我們總想著把遞迴平鋪開來,腦子裡就會迴圈,一層一層往下調,然後再一層一層返回,試圖搞清楚計算機每一步執行都是怎麼執行的,這樣就很容易被繞進去。

對於遞迴程式碼,這種試圖想清楚整個遞和歸過程的做法,實際上進入一種思維誤區。很多時候我們理解起來比較吃力,主要因為就是自己給自己製造這種理解障礙。那正確的思維方式應該是怎樣的?

如果一個問題A可以分解成若干子問題B、C、D,你可以假設B、C、D已經解決,在此基礎上思考如何解決問題A。而且,你只需要思考問題A與問題B、C、D兩層之間的關係即可,不需要一層一層往下思考子問題與子子問題之間的關係。遮蔽掉遞迴細節,這樣子理解起來就簡單多了。

因此,編寫遞迴程式碼的關鍵是,只要遇到遞迴,我們就把它抽象成一個遞推公式,不用想一層層呼叫關係,不要試圖用人腦去分解遞迴的每一個步驟。

 

5、遞迴程式碼要警惕棧溢位

比如前面的講到的電影院的例子,如果我們將系統棧或者JVM堆疊大小設定為1KB,在求解f(1999)時便會出現如下堆疊報錯:

 

那麼,如何避免出現堆疊溢位呢?

 

我們可以通過在程式碼中限制遞迴呼叫的最大深度方式來解決這個問題。遞迴呼叫超過一定的深度(比如1000)之後,我們就不繼續往下在遞迴了,直接返回報錯。還是電影院那個例子,我們可以改造成下面這樣子,就可以避免堆疊溢位。不過,下買你的程式碼是虛擬碼,為了簡潔,有些邊界條件沒有考慮,比如x<=0.

 

但是這種做法並不能完全解決問題,因為最大允許的遞迴深度跟當前現成的剩餘的棧空間大小有關,事先無法計算。如果實時計算,程式碼過於複雜,就會影響程式碼的可讀性。所以,如果最大深度比較小,比如10、50,就可以用這種方法,否則這種方法並不是很實用。

6、遞迴程式碼要警惕重複計算

除此之外,使用遞迴時,還會出現重複計算的問題。剛才我講的第二個遞迴程式碼的例子,如果我們把整個遞迴過程分解一下的話,那就是這樣:

 

從此圖中,我們可以直觀地看到,想要計算f(5),需要計算f(4)和f(3),而計算f(4)還需要計算f(3),因此,f(3)就被計算了很多次,這就是重複計算問題。

為避免重複計算,我們可以通過一個數據結構(比如散列表)來儲存已經求解過的f(k)。當遞迴呼叫到f(k)時,先看下是否已經求解過了。如果是,則直接從散例表中取值返回,不需要重複計算,這樣就能避免剛講的問題了。

按照上面的思路,我們來改造剛才的程式碼:

除了堆疊溢位、重複計算這兩個常見的問題,遞迴程式碼還有很多別的問題。

在時間效率上,遞迴程式碼裡多了很多函式呼叫,但這些函式呼叫的數量較大時,就會聚整合一個可觀的時間成本。在空間複雜度上,因為遞迴呼叫一次就會在記憶體棧中儲存一次現場資料,所以在分析遞迴程式碼空間複雜度時,需要額外考慮在這部分的開銷,比如我們前面講到的電影院遞迴程式碼,空間複雜度並不是O(1),而是O(n)。

7、怎麼將遞迴程式碼改為非遞迴程式碼?

剛才說過,遞迴有利有弊,利是遞迴程式碼的表達力很強,寫起來非常簡潔;而弊就是空間複雜度高,有堆疊溢位的風險、存在重複計算、過多的函式呼叫會耗時較多等問題。所以,在開發過程中,我們要根據實際情況來選擇是否需要用遞迴的方式來實現。

那我們是否可以把遞迴程式碼改寫成非遞迴程式碼呢?比如剛才那個電影院的例子,我們拋開場景,只看f(x)=f(x-1)+1這個遞推公式。我們這樣寫看看:

 

同樣,第二個例子也可以改為非遞迴的實現方式。

那是不是所有的遞迴程式碼都可以改為這種迭代迴圈的非遞迴寫法呢?

籠統地講,是的。因為遞迴本身就是藉助棧來實現,只不過我們使用的棧是系統或者虛擬機器本身提供的,我們沒有感知罷了。如果我們自己在記憶體上實現棧,手動模擬入棧、出棧過程,這樣任何遞迴程式碼都可以改寫成看上去不是遞迴程式碼的樣子。

但是這種思路實際上是將遞迴改為"手動"遞迴,本質沒有變,而且也並沒有解決前面講到的某些問題,徒增了實現的複雜度。

8、解答開篇

有了以上的知識,咱們來看一下開篇的問題;如何找到"最終推薦的人"?我的解決方案是這樣的:

 

是不是很簡潔?用三行程式碼就能搞定了,不過在實際專案中,上面的程式碼並不能工作,為什麼呢?這裡有兩個問題。

第一,如果遞迴很深,可能會有堆疊溢位的問題。

第二,如果資料庫裡存在髒資料,我們還需要處理由此產生的無限遞迴問題。比如demo環境下資料庫中,測試工程師為了方便測試,會人為地插入一些資料,就會出現髒資料。如果A的推薦人是B,B的推薦人是C,C的推薦人A,這樣會就會發生死迴圈。

第一個問題,我們前面已經解答過了,可以用限制遞迴的深度來解決。第二個問題,也可以用限制遞迴深度來解決。

 


歡迎大家掃碼關注微信公眾號,其中含有大量免費的人工智慧、影象處理、IT資料: