讀完本文,你可以去力扣拿下如下題目:
92.反轉連結串列II
-----------
反轉單鏈表的迭代實現不是一個困難的事情,但是遞迴實現就有點難度了,如果再加一點難度,讓你僅僅反轉單鏈表中的一部分,你是否能夠遞迴實現呢?
本文就來由淺入深,step by step 地解決這個問題。如果你還不會遞迴地反轉單鏈表也沒關係,本文會從遞迴反轉整個單鏈表開始拓展,只要你明白單鏈表的結構,相信你能夠有所收穫。
// 單鏈表節點的結構public class ListNode { int val; ListNode next; ListNode(int x) { val = x; }}
什麼叫反轉單鏈表的一部分呢,就是給你一個索引區間,讓你把單鏈表中這部分元素反轉,其他部分不變: e
注意這裡的索引是從 1 開始的。迭代的思路大概是:先用一個 for 迴圈找到第 m 個位置,然後再用一個 for 迴圈將 m 和 n 之間的元素反轉。但是我們的遞迴解法不用一個 for 迴圈,純遞迴實現反轉。
迭代實現思路看起來雖然簡單,但是細節問題很多的,反而不容易寫對。相反,遞迴實現就很簡潔優美,下面就由淺入深,先從反轉整個單鏈表說起。
一、遞迴反轉整個連結串列這個演算法可能很多讀者都聽說過,這裡詳細介紹一下,先直接看實現程式碼:
ListNode reverse(ListNode head) { if (head.next == null) return head; ListNode last = reverse(head.next); head.next.next = head; head.next = null; return last;}
看起來是不是感覺不知所云,完全不能理解這樣為什麼能夠反轉連結串列?這就對了,這個演算法常常拿來顯示遞迴的巧妙和優美,我們下面來詳細解釋一下這段程式碼。
對於遞迴演算法,最重要的就是明確遞迴函式的定義。具體來說,我們的 reverse 函式定義是這樣的:
輸入一個節點 head,將「以 head 為起點」的連結串列反轉,並返回反轉之後的頭結點。
明白了函式的定義,在來看這個問題。比如說我們想反轉這個連結串列:
那麼輸入 reverse(head) 後,會在這裡進行遞迴:
ListNode last = reverse(head.next);
不要跳進遞迴(你的腦袋能壓幾個棧呀?),而是要根據剛才的函式定義,來弄清楚這段程式碼會產生什麼結果:
這個 reverse(head.next) 執行完成後,整個連結串列就成了這樣:
並且根據函式定義,reverse 函式會返回反轉之後的頭結點,我們用變數 last 接收了。
現在再來看下面的程式碼:
head.next.next = head;
接下來:
head.next = null;return last;
神不神奇,這樣整個連結串列就反轉過來了!遞迴程式碼就是這麼簡潔優雅,不過其中有兩個地方需要注意:
1、遞迴函式要有 base case,也就是這句:
if (head.next == null) return head;
意思是如果連結串列只有一個節點的時候反轉也是它自己,直接返回即可。
2、當連結串列遞迴反轉之後,新的頭結點是 last,而之前的 head 變成了最後一個節點,別忘了連結串列的末尾要指向 null:
head.next = null;
理解了這兩點後,我們就可以進一步深入了,接下來的問題其實都是在這個演算法上的擴充套件。
二、反轉連結串列前 N 個節點這次我們實現一個這樣的函式:
// 將連結串列的前 n 個節點反轉(n <= 連結串列長度)ListNode reverseN(ListNode head, int n)
比如說對於下圖連結串列,執行 reverseN(head, 3):
解決思路和反轉整個連結串列差不多,只要稍加修改即可:
ListNode successor = null; // 後驅節點// 反轉以 head 為起點的 n 個節點,返回新的頭結點ListNode reverseN(ListNode head, int n) { if (n == 1) { // 記錄第 n + 1 個節點 successor = head.next; return head; } // 以 head.next 為起點,需要反轉前 n - 1 個節點 ListNode last = reverseN(head.next, n - 1); head.next.next = head; // 讓反轉之後的 head 節點和後面的節點連起來 head.next = successor; return last;}
具體的區別:
1、base case 變為 n == 1,反轉一個元素,就是它本身,同時要記錄後驅節點。
2、剛才我們直接把 head.next 設定為 null,因為整個連結串列反轉後原來的 head 變成了整個連結串列的最後一個節點。但現在 head 節點在遞迴反轉之後不一定是最後一個節點了,所以要記錄後驅 successor(第 n + 1 個節點),反轉之後將 head 連線上。
OK,如果這個函式你也能看懂,就離實現「反轉一部分連結串列」不遠了。
三、反轉連結串列的一部分現在解決我們最開始提出的問題,給一個索引區間 [m,n](索引從 1 開始),僅僅反轉區間中的連結串列元素。
ListNode reverseBetween(ListNode head, int m, int n)
首先,如果 m == 1,就相當於反轉連結串列開頭的 n 個元素嘛,也就是我們剛才實現的功能:
ListNode reverseBetween(ListNode head, int m, int n) { // base case if (m == 1) { // 相當於反轉前 n 個元素 return reverseN(head, n); } // ...}
如果 m != 1 怎麼辦?如果我們把 head 的索引視為 1,那麼我們是想從第 m 個元素開始反轉對吧;如果把 head.next 的索引視為 1 呢?那麼相對於 head.next,反轉的區間應該是從第 m - 1 個元素開始的;那麼對於 head.next.next 呢……
區別於迭代思想,這就是遞迴思想,所以我們可以完成程式碼:
ListNode reverseBetween(ListNode head, int m, int n) { // base case if (m == 1) { return reverseN(head, n); } // 前進到反轉的起點觸發 base case head.next = reverseBetween(head.next, m - 1, n - 1); return head;}
至此,我們的最終大 BOSS 就被解決了。
四、最後總結遞迴的思想相對迭代思想,稍微有點難以理解,處理的技巧是:不要跳進遞迴,而是利用明確的定義來實現演算法邏輯。
處理看起來比較困難的問題,可以嘗試化整為零,把一些簡單的解法進行修改,解決困難的問題。
值得一提的是,遞迴操作連結串列並不高效。和迭代解法相比,雖然時間複雜度都是 O(N),但是迭代解法的空間複雜度是 O(1),而遞迴解法需要堆疊,空間複雜度是 O(N)。所以遞迴操作連結串列可以作為對遞迴演算法的練習或者拿去和小夥伴裝逼,但是考慮效率的話還是使用迭代演算法更好。