遞歸法的理解——以反轉鏈表為例


2020-01-07

遞歸是什么:

遞歸,從定義上說,指的是某個函數直接或者間接調用自己時,則發生了遞歸。

 

比如說著名的斐波拉契數列的實現方法之一:

1 public static int f(int n){ 2 
3     if(n == 1 || n == 2) return 1; 4 
5     return f(n-1) + f(n-2); 6 
7 }

 

在這個例子中,對於n大於2的情況,我們都直接調用f自身來遞歸解決了這個問題。

 

從底層的情況來思考,實際上計算機將相關的函數先壓入stack中,然后再pop出來,由此要使用額外的空間與時間,所以當相關的算法設計的不夠精巧時,可能會帶來額外的開支。

 

這個算法的數學本質其實並不神秘,就是普通的數學歸納法而已:為了解決問題p(n),首先解決基礎情況p(1),然后假定p(n-1)已被完成,在此條件下,若能解出p(n),那么這一問題可解。(我們中學學習時,往往是用於證明一些問題,這里需要把它遷移到編程解決一個特定問題,略有區別)

 

從其數學本質上看似乎不難,但是,實際編程時,遞歸的思考實際上是“違反直覺的”(英文就是counter-intuitive),想一步步理解清楚遞歸函數究竟做了什么,即使對於很有經驗的程序員來說,也是很困難的。這也是遞歸類問題一直給初學者帶來困擾的主要原因。

 

如果姿勢水平不夠,那就得再學習一個。參考[1]的說法,理解遞歸時,只需要“明白每個函數能做的事,並相信它們能夠完成”就可以了,拆解也是一件較為模塊化的事情,理解只需要達到“這個部分能完成xx功能”即可,過度的拆解實際上不利於編程。

 

基於這樣的思想,我們可以引入所謂“遞歸三要素”來思考遞歸相關的問題。[2](在九章算法的相關課程中第一回見到這個說法,至於課程本身,大家見仁見智吧)

 

遞歸三要素:

遞歸的定義:遞歸函數接受什么參數、返回什么值、代表什么意思。當函數直接或間接調用自己時,也就發生了遞歸。

簡而言之,就是由於在編程過程中,會重復地運用這個函數,所以這個函數的可復用性應當會很強,一般而言要從問題中抽象出較為通用的求解范式。

 

遞歸的拆解:每次的遞歸都要讓問題的規模變小。

比如說,在斐波拉契數列問題中,我們每一步至少向前兩步逼近一些問題;在分治法相關的遞歸設計中,往往可以將問題分解為左右兩個對稱的部分,先拆解,再綜合結果進行比較或其他運算。

 

遞歸的出口:遞歸必須有一個明確的結束條件。

如果說前面都是在“遞”,那么這一步就是確定何時“歸”。如果久遞不歸,那就頗有點“濁酒一杯家萬里,燕然未勒歸無計”的味道了,對計算機而言,最后的結果自然是內存溢出。

在編程時,需要注意給定一個限制條件讓函數return值。

  

LeetCode 206 Reverse Linked List:

題目不難理解,就是將一個線性鏈表反轉里面的數據,如果原本是1->2->3->4->5->null,反轉完成后則變為5->4->3->2->1->null。(null是空指針,代表鏈表的結束)

 當然,本題自然也可以不用遞歸,直接使用迭代法(iterative)來解決,對於每一個節點,都保存其前驅(pre)以及后繼(next)兩個節點,不斷進行原地(in-place)逆序即可。

 

 此處還是以遞歸法來說明所謂“遞歸三要素”的理解應用:

對於這樣一個鏈表,實際上用於保存它的方法是很簡單的,它只保存了一個頭節點,而每一個節點定義如下:

 

class ListNode{
    int data;
    Node next;    
}

 

每一個節點只保存了自己的數據(此處是int)以及下一個節點的引用(或者說是地址,但是java沒有指針,所以其實是下一個節點的引用)。

因此,這個問題天然地具有一種類似於數學上自同構(auto-morphism)的感覺,也就是問題可以被分解為對於每一個節點進行處理。

 

 

1.遞歸的定義:我們可以試着定義一個遞歸函數,它只處理一個給定節點,返回的是已經被處理好的鏈表的第一個節點。(比如說,對於1-2-3-4-5,如果輸入一個3,返回的是5,對應的其實就是5-4這樣一個被處理好的部分,隨后將3再接到5-4之后,形成1-2-3以及5-4-3的情況)

2.遞歸的拆解:由於我們一開始只知道頭節點head,所以比較合理的遞歸/前進方式是,每次輸入一個head.next,也就是向后一次遍歷一個引用,這也是合理的,因為從數據結構上來看,我們也只能作這樣的訪問。

3.遞歸的出口:到什么情況我們可以返回一個處理好的鏈表呢?其實這時對應的往往都是基礎/平凡(trivial)的情況。對於本題,就是返回空指針、單個節點的情況(因為這樣的情況不需要再反轉了)。

由此我們可以給出代碼:

 

 1 // 遞歸的定義:下面的函數返回的是,將給定節點之后(包括這一節點)所有的節點反轉之后的鏈表的頭節點
 2 // 輸入:一個給定的節點
 3 // 輸出:包含本節點在內的反轉鏈表的頭節點
 4 public ListNode reverseList(ListNode head){
 5     // 遞歸的出口:當是空指針或者單個節點時,返回其本身
 6     if(head == null || head.next == null) return head;
 7     
 8     // 遞歸的拆解:一個新的反轉鏈表 = 當前節點之后的反轉鏈表 + 將當前節點移動到已有的反轉鏈表之后
 9     ListNode next = reverseList(head.next);
10     head.next.next = head; // 注意,在修改head.next之前,head.next指向的依舊是原來的后續節點
11     head.next = null;
12     return next; // 返回新的反轉鏈表 
13 }

 

 應該說,這個代碼基本體現出了遞歸的三要素,在之后的練習中,也應該多思考遞歸函數的設計,而不是湊對了、看懂了就草草帶過去,相關的設計思想往往就被遺漏了。

 

對於這一話題,下一步的計划:

1.練習更多、難度更大的題目

2.閱讀一些算法教材,從更底層和本質的角度思考遞歸問題

 

 Reference:

[1] https://coding.oi-wiki.org/basic/divide-and-conquer/

[2] https://v2ex.com/t/628435


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM