LeetCode(一)——連結串列1
iwehdio的部落格園:https://www.cnblogs.com/iwehdio/
學習自:
本文包括連結串列leetcode相關題目:
- 92、25、234.
- 2、19、21、23、24、25、61、82、83、86.
1、資料結構的基本知識
資料結構的儲存方式
- 資料結構的儲存方式只有兩種:陣列(順序儲存)和連結串列(鏈式儲存)。
- 陣列由於是緊湊連續儲存,可以隨機訪問,通過索引快速找到對應元素,而且相對節約儲存空間。但正因為連續儲存,記憶體空間必須一次性分配夠,所以說陣列如果要擴容,需要重新分配一塊更大的空間,再把資料全部複製過去,時間複雜度 O(N);而且你如果想在陣列中間進行插入和刪除,每次必須搬移後面的所有資料以保持連續,時間複雜度 O(N)。
- 連結串列因為元素不連續,而是靠指標指向下一個元素的位置,所以不存在陣列的擴容問題;如果知道某一元素的前驅和後驅,操作指標即可刪除該元素或者插入新元素,時間複雜度 O(1)。但是正因為儲存空間不連續,你無法根據一個索引算出對應元素的地址,所以不能隨機訪問;而且由於每個元素必須儲存指向前後元素位置的指標,會消耗相對更多的儲存空間。
- 「佇列」、「棧」這兩種資料結構既可以使用連結串列也可以使用陣列實現。用陣列實現,就要處理擴容縮容的問題;用連結串列實現,沒有這個問題,但需要更多的記憶體空間儲存節點指標。
- 「圖」的兩種表示方法,鄰接表就是連結串列,鄰接矩陣就是二維陣列。鄰接矩陣判斷連通性迅速,並可以進行矩陣運算解決一些問題,但是如果圖比較稀疏的話很耗費空間。鄰接表比較節省空間,但是很多操作的效率上肯定比不過鄰接矩陣。
- 「散列表」就是通過雜湊函式把鍵對映到一個大數組裡。而且對於解決雜湊衝突的方法,拉鍊法需要連結串列特性,操作簡單,但需要額外的空間儲存指標;線性探查法就需要陣列特性,以便連續定址,不需要指標的儲存空間,但操作稍微複雜些。
- 「樹」,用陣列實現就是「堆」,因為「堆」是一個完全二叉樹,用陣列儲存不需要節點指標,操作也比較簡單;用連結串列實現就是很常見的那種「樹」,因為不一定是完全二叉樹,所以不適合用陣列儲存。為此,在這種連結串列「樹」結構之上,又衍生出各種巧妙的設計,比如二叉搜尋樹、AVL 樹、紅黑樹、區間樹、B 樹等等,以應對不同的問題。
資料結構的基本操作
-
對於任何資料結構,其基本操作無非遍歷 + 訪問,再具體一點就是:增刪查改。
-
資料結構種類很多,但它們存在的目的都是在不同的應用場景,儘可能高效地增刪查改。
-
如何遍歷 + 訪問?從最高層來看,各種資料結構的遍歷 + 訪問無非兩種形式:線性的和非線性的。
-
線性就是 for/while 迭代為代表,非線性就是遞迴為代表。
-
陣列遍歷框架,典型的線性迭代結構:
void traverse(int[] arr) { for (int i = 0; i < arr.length; i++) { // 迭代訪問 arr[i] } }
-
連結串列遍歷框架,兼具迭代和遞迴結構:
/* 基本的單鏈表節點 */ class ListNode { int val; ListNode next; } void traverse(ListNode head) { for (ListNode p = head; p != null; p = p.next) { // 迭代訪問 p.val } } void traverse(ListNode head) { // 遞迴訪問 head.val traverse(head.next); }
-
二叉樹遍歷框架,典型的非線性遞迴遍歷結構:
/* 基本的二叉樹節點 */ class TreeNode { int val; TreeNode left, right; } void traverse(TreeNode root) { // 前序遍歷 traverse(root.left) // 中序遍歷 traverse(root.right) // 後序遍歷 }
-
二叉樹框架可以擴充套件為 N 叉樹的遍歷框架:
/* 基本的 N 叉樹節點 */ class TreeNode { int val; TreeNode[] children; } void traverse(TreeNode root) { // 遞迴訪問 root.val for (TreeNode child : root.children) traverse(child); }
-
N 叉樹的遍歷又可以擴充套件為圖的遍歷,因為圖就是好幾 N 叉棵樹的結合體。圖是可能出現環的,用個布林陣列 visited 做標記就行了。
-
2、連結串列
- 連結串列題中的常用命名:
- pred:前驅;
- succ:後繼;
- cur:當前;
- nxt:下一個;
- head:頭;
- tail:尾。
遞迴反轉連結串列的一部分
-
單鏈表節點結構:
// 單鏈表節點的結構 public class ListNode { int val; ListNode next; ListNode(int x) { val = x; } }
-
什麼叫反轉單鏈表的一部分呢,就是給你一個索引區間,讓你把單鏈表中這部分元素反轉,其他部分不變:
- 注意這裡的索引是從 1 開始的。
-
迭代的思路大概是:
-
先用一個 for 迴圈找到第 m 個位置,然後再用一個 for 迴圈將 m 和 n 之間的元素反轉。
-
具體的,用兩個指標指向要反轉的兩個節點,用第三個指標指向下一個要反轉的節點:
third = cur.next cur.next = prev prev = cur cur = third
-
迭代實現思路看起來雖然簡單,但是細節問題很多的,反而不容易寫對。相反,遞迴實現就很簡潔優美。
-
-
遞迴思路:
-
實現程式碼:
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;
-
有兩個地方需要注意:
-
遞迴函式要有 base case,也就是這句:
if (head.next == null) return head;
意思是如果連結串列只有一個節點的時候反轉也是它自己,直接返回即可。
-
當連結串列遞迴反轉之後,新的頭結點是
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; }
-
具體的區別:
- base case 變為
n == 1
,反轉一個元素,就是它本身,同時要記錄後驅節點。 - 剛才我們直接把
head.next
設定為 null,因為整個連結串列反轉後原來的head
變成了整個連結串列的最後一個節點。但現在head
節點在遞迴反轉之後不一定是最後一個節點了,所以要記錄後驅successor
(第 n + 1 個節點),反轉之後將head
連線上。
- base case 變為
-
-
反轉列表的一部分:
-
現在解決我們最開始提出的問題,給一個索引區間
[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; }
-
-
總結:
- 遞迴的思想處理的技巧是:不要跳進遞迴,而是利用明確的定義來實現演算法邏輯。這裡的定義指的是,遞迴函式的返回值和遞迴函式中return的值。
- 處理看起來比較困難的問題,可以嘗試化整為零,把一些簡單的解法進行修改,解決困難的問題。
- 值得一提的是,遞迴操作連結串列並不高效。和迭代解法相比,雖然時間複雜度都是 O(N),但是迭代解法的空間複雜度是 O(1),而遞迴解法需要堆疊,空間複雜度是 O(N)。所以考慮效率的話還是使用迭代演算法更好。
-
92題 反轉列表 Ⅱ:
-
遞迴思路:
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ class Solution { public ListNode reverseBetween(ListNode head, int m, int n) { if (m==1) return reverseStart(head, n); head.next=reverseBetween(head.next, m-1,n-1); return head; } ListNode succ = null; ListNode reverseStart(ListNode head, int n) { if (n==1) { succ=head.next; return head; } ListNode last = reverseStart(head.next, n-1); head.next.next = head; head.next=succ; return last; } }
-
迭代思路:
class Solution { public ListNode reverseBetween(ListNode head, int m, int n) { if(head==null) return null; ListNode pred = null; ListNode cur = head; //移動到反轉位置 while(m-1>0){ pred = cur; cur = cur.next; m--; n--; } //con是前一段連結串列尾,tail是反轉部分的連結串列尾 ListNode con = pred, tail = cur, temp = null; while(n>0){ temp = cur.next; cur.next = pred; pred = cur; cur = temp; n--; } //結束時,pred是反轉部分的連結串列頭,cur是下一段連結串列頭 if(con!=null){ con.next=pred; } else{ head=pred; } tail.next = cur; return head; } }
-
K個一組反轉連結串列
-
子問題:
- 這個問題的子問題其實就是將k個長度的連結串列反轉。
- 還需要考慮的是,反轉後,與原連結串列的連線問題,所以需要維護指向前一段連結串列的尾和後一段連結串列的頭的指標。
- 最後,遞迴或迭代的終止條件,就是當不足k個,則不反轉。
- 例:k=2,前兩個反轉後,指向後續連結串列呼叫反轉函式。
-
遞歸向後,迭代反轉:
class Solution { public ListNode reverseKGroup(ListNode head, int k) { //儲存下一段連結串列的頭 ListNode tail = head; for(int i=0; i<k; i++){ if(tail==null) return head; tail = tail.next; } //返回的是反轉部分的頭 ListNode newHead = reverse(head, k); //head是反轉部分的尾 head.next = reverseKGroup(tail,k); return newHead; } ListNode reverse(ListNode head, int num){ //因為在上一層判斷過了,所以head必然不為null ListNode cur = head, pred = null, temp = null; while(num-->0){ temp = cur.next; cur.next = pred; pred = cur; cur = temp; } return pred; } }
迴文連結串列
-
這道題的關鍵在於,單鏈表無法倒著遍歷,無法使用雙指標技巧。那麼最簡單的辦法就是,把原始連結串列反轉存入一條新的連結串列,然後比較這兩條連結串列是否相同。
-
其實,藉助二叉樹後序遍歷的思路,不需要顯式反轉原始連結串列也可以倒序遍歷連結串列。
-
樹結構不過是連結串列的衍生。那麼,連結串列其實也可以有前序遍歷和後序遍歷:
void traverse(ListNode head) { // 前序遍歷程式碼 traverse(head.next); // 後序遍歷程式碼 }
-
如果想倒序遍歷連結串列,就可以在後序遍歷位置操作:
/* 倒序列印單鏈表中的元素值 */ void traverse(ListNode head) { if (head == null) return; traverse(head.next); // 後序遍歷程式碼 print(head.val); }
-
模仿雙指標實現迴文判斷:
class Solution { ListNode left = null; public boolean isPalindrome(ListNode head) { left = head; return traverse(head); } boolean traverse(ListNode right){ if(right==null) return true; boolean res = traverse(right.next); res = res &&(left.val==right.val); left = left.next; return res; } }
-
這麼做的核心邏輯是什麼呢?實際上就是把連結串列節點放入一個棧,然後再拿出來,這時候元素順序就是反的,只不過我們利用的是遞迴函式的堆疊而已。
-
當然,無論造一條反轉連結串列還是利用後序遍歷,演算法的時間和空間複雜度都是 O(N)。下面我們想想,能不能不用額外的空間,解決這個問題。
-
優化思路:
- 通過「雙指標技巧」中的快慢指標來找到連結串列的中點。
- 如果fast指標沒有指向null,說明連結串列長度為奇數,slow還要再前進一步。
- 總之,就是讓slow指向後半個需要反轉的連結串列頭節點。
- 最後反轉連結串列後,比較值。
class Solution { public boolean isPalindrome(ListNode head) { //設定快慢指標 ListNode fast = head, slow = head; while(fast!=null && fast.next!=null){ fast = fast.next.next; slow = slow.next; } if(fast!=null){ slow = slow.next; } ListNode left = head; ListNode right = reverse(slow); while(right!=null){ if(left.val!=right.val) return false; left = left.next; right = right.next; } return true; } //反轉從head開始的節點,返回反轉後的頭結點 ListNode reverse(ListNode head){ ListNode cur = head, pred = null, temp = null; while(cur!=null){ temp = cur.next; cur.next = pred; pred = cur; cur = temp; } return pred; } }
-
但是,反轉連結串列會破壞原來連結串列的結構。
- 可以通過記錄反轉部分的頭結點,和前半部分的尾節點,通過再進行一次反轉恢復結構。
p.next = reverse(q);
3、其他連結串列題目()
兩數相加
-
直觀解法非常簡單,就是從後往前對連結串列中的值相加。
- 需要考慮的一個是隻要連結串列向後遍歷的過程中有一個不是null,或者進位不是0,就說明需要新建一個連結串列節點。
- 優化的話,如果其中一個連結串列為空並且進位為0了,就可以直接把另一個連結串列接到新連結串列後面。
class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { ListNode head = new ListNode(0); int i = 0, add = 0, sum = 0; int v1 = 0, v2 = 0; ListNode curr = head; while(l1!=null || l2!=null || add>0){ if(l1==null){ v1 = 0; } else { v1 = l1.val; l1 = l1.next; } if(l2==null){ v2 = 0; } else { v2 = l2.val; l2 = l2.next; } sum = v1 + v2 + add; add = sum / 10; curr.next = new ListNode(sum % 10); curr = curr.next; } return head.next; } }
刪除連結串列的倒數第N個節點
-
首先n是有效的,也就是說對n的合法性不需要做判斷。
-
一趟掃描就要完成,需要用到多個指標,慢指標del與快指標last相距n-1。
- 因為當n=1時,所要刪除的就是最後一個節點。而要刪除一個節點,最好使能獲取其前一個節點的位置,直接把前一個節點的next指向next.next。
- 然後快慢指標向後遍歷,直到快指標的下一個為null,則刪除此時慢指標指向的下一個元素。
- 為了好處理快慢指標的初始化和刪除連結串列第一個節點的問題,引入頭哨兵header。
class Solution { public ListNode removeNthFromEnd(ListNode head, int n) { ListNode header = new ListNode(0, head); ListNode del = header, last = header; while(n-->0) { last = last.next; } while(last.next!=null){ last = last.next; del = del.next; } del.next = del.next.next; return header.next; } }
合併兩個有序連結串列
-
迭代思路:
- 首先同樣構造一個頭哨兵,從頭哨兵開始,最後返回頭哨兵的next。
- 當l1和l2都不為空的時候向後遍歷,比較值的大小。cur指向新連結串列的尾,l1和l2指向舊連結串列的頭。
- 當有一個為null時,直接將另一個接上就行了。
class Solution { public ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode header = new ListNode(0); ListNode cur = header; while(l1!=null && l2!=null){ if(l1.val <= l2.val) { cur.next = l1; l1 = l1.next; } else { cur.next = l2; l2 = l2.next; } cur = cur.next; } if(l1!=null) { cur.next = l1; } else { cur.next = l2; } return header.next; } }
-
遞迴思路:
- 遞迴基是為null時直接返回。但是因為用到了棧,空間複雜度不如迭代。
class Solution { public ListNode mergeTwoLists(ListNode l1, ListNode l2) { if (l1 == null) { return l2; } else if (l2 == null) { return l1; } else if (l1.val < l2.val) { l1.next = mergeTwoLists(l1.next, l2); return l1; } else { l2.next = mergeTwoLists(l1, l2.next); return l2; } } }
合併K個升序連結串列
-
常規想法,每次比較k個連結串列頭的最小值,複雜度等於順序合併:
class Solution { public ListNode mergeKLists(ListNode[] lists) { ListNode header = new ListNode(0); ListNode cur = header; while(true) { ListNode min = null; int temp = -1; for(int i=0; i<lists.length; i++) { if(lists[i]==null) { continue; } else { if(min==null || lists[i].val<min.val) { min = lists[i]; temp = i; } } } if(temp == -1) { break; } cur.next = min; cur = cur.next; lists[temp] = cur.next; } return header.next; } }
-
相比於順序合併兩個相鄰的連結串列,分治合併的效率更高。
-
順序合併:
-
分治合併:
-
分治時要注意,這裡的遞迴基有兩個,一個是如果區間lo和hi相等,就返回這個連結串列的指標。另一個是,如果輸入陣列為空就返回null。
class Solution { public ListNode mergeKLists(ListNode[] lists) { return merge(lists, 0, lists.length-1); } ListNode merge(ListNode[] lists, int lo, int hi) { if(lo==hi) { return lists[lo]; } if(lo>hi) { return null; } int mid = (lo+hi)/2; return mergeTwoLists(merge(lists, lo, mid), merge(lists, mid+1, hi)); } ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode header = new ListNode(0); ListNode cur = header; while(l1!=null && l2!=null){ if(l1.val <= l2.val) { cur.next = l1; l1 = l1.next; } else { cur.next = l2; l2 = l2.next; } cur = cur.next; } if(l1!=null) { cur.next = l1; } else { cur.next = l2; } return header.next; } }
-
兩兩交換連結串列中的節點
-
遞迴形式,就是反轉連結串列的變種:
- 遞迴基是如果傳入的連結串列只有一個節點或為null,就直接返回。
- 否則,交換並連結頭尾。
class Solution { public ListNode swapPairs(ListNode head) { if(head==null || head.next==null){ return head; } else { ListNode temp = head.next.next, ans = head.next; head.next.next = head; head.next = swapPairs(temp); return ans; } } }
-
迭代形式:
- 迭代比較麻煩的就是處理null的問題。
class Solution { public ListNode swapPairs(ListNode head) { if(head==null || head.next==null) return head; ListNode header = new ListNode(0, head), curr = head, tail = head.next.next, temp = null, ans=header; while(true) { temp = curr.next; temp.next = curr; curr.next = tail; header.next = temp; if(tail==null || tail.next==null){ return ans.next; } else { header = header.next.next; curr = tail; tail = tail.next.next; } } } }
旋轉連結串列
-
最需要注意的一點就是,先首尾相連把連結串列變成環,再移動ans指標。不然的話需要單獨處理shift為0的情況:
class Solution { public ListNode rotateRight(ListNode head, int k) { if(head==null || head.next==null) return head; ListNode tail=head, ans=head, temp=null; int len = 1; while(tail.next!=null){ tail = tail.next; len++; } tail.next = head; int shift = len - k % len; while(--shift>0){ ans = ans.next; } temp = ans; ans = ans.next; temp.next = null; return ans; } }
刪除連結串列中的重複元素
-
用遞迴的方式,每次傳入處理好的連結串列尾和需要刪除的連結串列頭。
- 檢查連結串列頭是否有重複的,有就刪除。如果有的話需要特別注意null值的判定和刪除後的指向。
class Solution { public ListNode deleteDuplicates(ListNode head) { ListNode header = new ListNode(0, head); deleteD(header, head); return header.next; } void deleteD(ListNode header, ListNode head) { if(head==null || head.next==null) return; if(head.next!=null && head.val == head.next.val) { while(head.next!=null && head.val == head.next.val) { head = head.next; } header.next = head.next; head = header; } deleteD(head, head.next); } }
刪除排序連結串列中的重複元素
-
與前一道題不同的是,重複的需要留下一個,其實就是把上一道題的header.next = head.next改為header.next = head。
class Solution { public ListNode deleteDuplicates(ListNode head) { ListNode cur = head; while(cur!=null && cur.next!=null) { if(cur.val==cur.next.val) { cur.next = cur.next.next; } else { cur = cur.next; } } return head; } }
分隔連結串列
-
分別用哨兵引出小於x和大於x的鏈:
class Solution { public ListNode partition(ListNode head, int x) { ListNode header1 = new ListNode(0), header2 = new ListNode(0), cur1 = header1, cur2 = header2; while(head!=null) { if(head.val<x){ cur1.next = head; cur1 = cur1.next; } else { cur2.next = head; cur2 = cur2.next; } head = head.next; } cur1.next = header2.next; cur2.next = null; return header1.next; } }
iwehdio的部落格園:https://www.cnblogs.com/iwehdio/