1. 程式人生 > 實用技巧 >遞迴———從leetcode 138 談起

遞迴———從leetcode 138 談起

  各位好,對於遞迴相信大家都覺得熟悉而陌生。估計絕大部分人都能說出遞迴最本質的特徵,那就是所謂的“自己呼叫自己”,然而即使如此,在實際寫程式碼的過程中,很多人還是被遞迴的具體實現搞懵了,彷彿遞迴是一種還沒實現就已經存在的邏輯實體,我們需要拿著將來才會實現的東西現在就用,這種反思維的特徵我想是困擾很多人的難點。今天我剛好刷到了一道leetcode 題目,裡面給出了遞迴解法,我結合之前看部落格和自己思考的經驗,就打算寫一篇部落格來介紹一下遞迴怎麼寫。

  首先我們先看一下這道題:

  A linked list is given such that each node contains an additional random pointer which could point to any node in the list or null.

  Return adeep copyof the list.

  The Linked List is represented in the input/output as a list ofnnodes. Each node is represented as a pair of[val, random_index]where:

  • val: an integer representingNode.val
  • random_index: the index of the node (range from0ton-1) where random pointer points to, ornullif it does not point to any node.

  Example 1:

1 Input: head = [[7,null],[13,0],[11,4],[10,2],[1,0]]
2 Output: [[7,null],[13,0],[11,4],[10,2],[1,0]]

  這個題是說,有一個連結串列,和傳統的單鏈表不一樣的是,每個連結串列結點中除了指向下一個連結串列結點的next指標,還有一個random隨機指標。這個random指標可以指向這個連結串列中隨便一個連結串列結點或者null。讓我們返回這種形式的連結串列的深拷貝。注意是深拷貝,不能直接建立一個連結串列,和輸入的連結串列一一對應,然後拷貝結點的random的值用輸入的連結串列的各結點的random指標值。這樣得到的連結串列各個結點的random指標指向的是原連結串列中的結點,不符合深拷貝的要求。

  本題的方法其實蠻簡單,先定義一個Map<Node,Node> ,在建立新連結串列的過程中,每次建立一個連結串列結點,就把原來的連結串列當前遍歷的結點cur和新建的cur的拷貝結點t存進map中,這樣下一次我們可以通過原來連結串列的結點找到新建立的連結串列中對應的拷貝結點。程式碼如下:

/*
// Definition for a Node.
class Node {
    int val;
    Node next;
    Node random;

    public Node(int val) {
        this.val = val;
        this.next = null;
        this.random = null;
    }
}
*/

class Solution {
    public Node copyRandomList(Node head) {
        if(head == null){
            return null;
        }
        Map<Node,Node> map = new HashMap<>();
        Node cur = head;
        Node chead = new Node(cur.val);
        map.put(head,chead);
        Node ccur = chead;
        cur = cur.next;
        while(cur != null){
            Node t = new Node(cur.val);
            map.put(cur,t);
            ccur.next = t;
            ccur = ccur.next;
            cur = cur.next;
        }
        
        //給random賦值
        ccur = chead;
        cur = head;
        while(ccur != null){
            ccur.random = map.get(cur.random); //可以通過cur結點的random,找到當前ccur.random應該指向的那個結點
            ccur = ccur.next;
            cur = cur.next;
        }
        
        return chead;
    }
}

  作為leetcode 中的一道連結串列題目,自然很大概率可以用遞迴來寫。遞迴,正如上面所說,就是自己呼叫自己,每次呼叫相對於上一次呼叫,問題規模都有所減小,從而最後達到求解的目的。常見的求斐波那契數列的遞迴寫法我相信大家都可以回憶得起來。作為經驗而談,遞迴的求解最重要的應該是三要素的確定:

  1. 遞迴函式的功能是什麼

  2. 結束條件是什麼

  3. 前後狀態之間的聯絡怎麼寫出來

  我覺得,在寫遞迴的時候按順序考慮上面三點是比較符合邏輯的。另外當你需要除了遞迴之外的一些輔助物件的時候,可以考慮將它嵌入到上面的過程中來,本題的遞迴寫法中也同樣需要藉助一個map,一會兒會講到。

  那麼,我們要是遞迴求解的話,首先應該考慮一下遞迴函式的功能是什麼,那有人說,這不簡單,題目要啥我給啥唄,沒錯,本題給的題目要求很明確,我們不妨直接給出我們的遞迴函式:

1 //這個recurse函式,返回以head為頭結點的連結串列的深拷貝,其中形參中
2 //map和前面的功能一樣,是存待拷貝結點和拷貝結點的對映的。
3 private Node recurse(Node head,Map<Node,Node> map){
4         //具體實現程式碼寫這裡  
5 }

  上面程式碼塊中已經給出了遞迴函式的功能了:返回以head為頭結點的連結串列的深拷貝,返回的結點就是這個深拷貝的頭結點。 這樣我們第一步就完成了。

  下面我們考慮第二步,因為遞迴是自己呼叫自己,所以不能一直呼叫,否則很快記憶體就會爆掉,然後程式結束,所以一定要給一個終止條件。其實也就是當問題求解規模非常小的情況,比如head如果就是null,那麼我們可以放心地直接返回null就行,因為null的深拷貝肯定也就是null。所以我們得到第一個終止條件:

1 if(head == null){
2       return null;
3 }

  一般而言,遞迴的結束條件都比較簡單,可能就是問題規模等於空啊或者等於1啊的時候該返回什麼,很好考慮。而且,其實多一個少一個結束條件在很多時候其實沒有太多影響,多一個結束條件可能會讓遞迴函式更快地結束掉。這裡我們想一想,還有沒有一些其他的情況咱們也可以直接返回。注意啊,我們可是用了map儲存了待拷貝結點和拷貝結點的對映哎,這個對映是在新連結串列建立的過程中不斷put出來的。所以如果head已經在map中的話,那說明深拷貝連結串列已經建立起來了,所以我們直接返回這個深拷貝連結串列的頭結點就好,頭結點是誰呢?哈哈,因為head是原連結串列的頭結點,map又是儲存head和它的拷貝結點(也就是深拷貝連結串列的頭結點)的對映的,所以map.get(head)就是深拷貝連結串列的頭結點。這個結束條件如下:

1 if(map.containsKey(head)){
2       return map.get(head);
3 }

  這樣,我們暫時想不到其他的結束條件了,那我們就考慮第三步吧。所謂的前後狀態之間的聯絡,就是你已經拿到子問題的解了(其實實際執行中還沒有求出來,但是我們可以這麼想)。比如本題中,你自然很想得到以head.next為頭結點的連結串列的深拷貝。有了head之後的連結串列的深拷貝,我們就可以想辦法把它和當前head的拷貝結點(也就是深拷貝連結串列的頭結點)給合在一起,這樣連起來的結果不就是我們想要的以head為頭結點的連結串列的深拷貝麼。

  好,你說你想要以head.next為頭結點的連結串列的深拷貝是吧,哎,正好啊,我這個函式功能不就是幹這個的麼,那自然,我們很容易可以得到:

1 Node a = recurse(head.next,map)

  你看,這個a結點是不是就是指向以head.next為頭結點的連結串列的深拷貝連結串列的第一個結點了。好了,那麼怎麼把head的拷貝結點t和這個a連起來呢?其實只要處理好t的next和random就可以了,t.next 很明顯應該指向a,這樣就連在一起了,那random應該指向誰呢?別忘了map的功能,再說一次,map是在拷貝連結串列建立結點的過程中,存放原連結串列待拷貝結點和新連結串列的拷貝結點的對映的。既然我們已經得到a了,那我們完全可以理解成,以head.next為頭結點的連結串列中各個結點和對應的拷貝結點都已經存在了map中了,那麼t.random 就顯然應該等於 map.get(head.random) 。最後我們返回t 就是需要的以head為頭結點的連結串列的深拷貝啦。這一部分完整的程式碼如下:

1 Node t = new Node(head.val);         //t指向head結點的拷貝結點
2 map.put(head,t);                            //把head和t的對映關係存入map中
3 t.next = recurse(head.next,map);    //把t和以head.next 為頭結點的連結串列的深拷貝連線起來,注意這個深拷貝是通過遞迴函式得來的
4 t.random = map.get(head.random); // 處理t的random指標
5 return t;                                         //返回t 就是我們所求的以head為頭結點的連結串列的深拷貝

  最後附上完整程式碼:

 1 /*
 2 // Definition for a Node.
 3 class Node {
 4     int val;
 5     Node next;
 6     Node random;
 7 
 8     public Node(int val) {
 9         this.val = val;
10         this.next = null;
11         this.random = null;
12     }
13 }
14 */
15 
16 class Solution {
17     public Node copyRandomList(Node head) {
18         //2020.9.19 第三次複習第二次提交,遞迴
19         if(head == null){
20             return null;
21         }
22         Map<Node,Node> map = new HashMap<>();
23         return recurse(head,map);
24     }
25     
26     private Node recurse(Node head,Map<Node,Node> map){
27         if(head == null){
28             return null;
29         }
30         if(map.containsKey(head)){
31             return map.get(head);
32         }
33         
34         Node t = new Node(head.val);
35         map.put(head,t);
36         t.next = recurse(head.next,map);
37         t.random = map.get(head.random);
38         
39         return t;
40     }
41 }

  在後來做題過程中,特別是做leetcode連結串列那部分的題目時,遞迴幾乎可以用到每個題目中,思考步驟一般都是上面所提的三步。大家多多練習多多思考,應該很快就可以熟練了。