概述
鏈表問題應該是數據結構中比較基礎的一類問題,但同時也是在面試中常考的一類問題。但是圍繞鏈表問題的一些基本方法或者處理思想,也無外乎那幾類,因此本文嘗試對鏈表常用的一些方法或者套路進行總結。
常用方法
1.頭結點
增加頭結點
或者說啞巴節點
這種方式,應該是我們在處理鏈表問題最常用的處理方式。簡單來說引入頭結點有兩個優點:
- 由於開始結點的位置被存放在頭結點的指針域中,所以在鏈表的第一個位置上的操作和在表的其他位置上的操作一致,無需進行特殊處理。
- 無論鏈表是否爲空,其頭指針是指向頭結點的非空指針(空表中頭結點的指針域爲空),因此空表和非控表的處理也就統一了。
總而言之,通過增加頭結點,減少了在鏈表處理過程中對邊界情況的判斷,大大簡化了程序的編寫。
下邊我們看一個例子:
給定一個排序鏈表,刪除所有含有重複數字的節點,只保留原始鏈表中 沒有重複出現 的數字。
示例 1:
輸入: 1->2->3->3->4->4->5
輸出: 1->2->5
示例 2:輸入: 1->1->1->2->3
輸出: 2->3來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/remove-duplicates-from-sorted-list-ii
著作權歸領釦網絡所有。商業轉載請聯繫官方授權,非商業轉載請註明出處。
這個問題,可能解決問題的思路比較容易想:
由於鏈表的節點是有序的,因此我們可以在對鏈表進行遍歷的過程中,可以比較當前遍歷的節點(current)和其下一個節點(current.next)是否相等,如果相等則刪除當前遍歷的節點(current),指針指向該節點的下一個節點,繼續進行該操作。
具體代碼如下所示:
public static ListNode deleteDuplicates(ListNode head) {
ListNode current = head;
current = current.next;
while (current != null) {
while (current.next != null && current.val == current.next.val) {
current.next = current.next.next;
}
current = current.next;
}
return head;
}
整個解法應該比較容易理解,但此時我們考慮如果此處不使用頭節點該如何解決該問題?
如果取消了頭結點,我們就需要考慮對鏈表第一個節點的處理,因爲在該問題上,鏈表的第一個節點也是有很大可能爲重複節點,因此我們此處顯然需要增加一個邊界情況的判斷,判斷頭結點是否爲重複節點。(head.val == head.next.val
)。並且需要針對其爲頭結點的情況單獨進行處理。
因此,此處我們可以簡單總結一下頭結點方法的使用場景:
只要是要處理的鏈表第一個節點本身會發生變化的情況都要考慮使用頭結點,因爲引入之後可能會極大的減少對邊界清理的處理。
2. 鏈表排序
鏈表排序,本身也可以是一個算法的題目,同時也是我們在解決鏈表問題時常用的中間手段。
下邊我們看一個題目:
148. 排序鏈表:
在 O(n log n) 時間複雜度和常數級空間複雜度下,對鏈表進行排序。
示例 1:
輸入: 4->2->1->3
輸出: 1->2->3->4
示例 2:輸入: -1->5->3->4->0
輸出: -1->0->3->4->5來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/sort-list
著作權歸領釦網絡所有。商業轉載請聯繫官方授權,非商業轉載請註明出處。
該問題明顯就是一個鏈表問題,但其難點可能在於對空間複雜度和時間複雜度的要求比較嚴苛。這就導致我們許多容易想到的方法都不能用,比如插入排序、存儲思想等。因此此處我們必須從複雜度爲O(nlogn)
的排序算法中尋找到一個能用的,並且空間複雜度只有常數級別的算法。
首先考慮,時間複雜度小於等於O(nlog(n))的算法有:
- 折半插入排序
- 希爾排序
- 快速排序
- 堆排序
- 歸併排序
- 基數排序
同時我們考慮到鏈表本身性能比較差,因此如果排序過程涉及大量的隨機訪問,大概率該算法不能用,比如快速排序、折半插入排序、希爾排序、堆排序(建堆的過程)。這些算法都不適用於鏈表。
基數排序空間複雜度比較大一般是O(r)
,r是排序隊列的個數。因此也不實用該題目。
最後我們只能考慮使用歸併排序。
整個過程可以分成如下步驟:
- 拆分:找到鏈表中間節點,獲取左半鏈表和右半鏈表
- 排序:分別對左側鏈表和右側鏈表進行歸併排序
- 合併:將排序後的左側鏈表和右側鏈表進行合併
其過程可以簡單用下圖來表示:
代碼實現如下:
// 使用歸併算法進行鏈表排序
public static ListNode mergesort(ListNode head) {
// 如果鏈表只有一個節點直接返回
if (head == null || head.next == null) {
return head;
}
// 找到鏈表的中間節點
ListNode middle = findMiddle(head);
// 獲取後半段的鏈表節點,同時與前半段節點斷開
ListNode tail = middle.next;
middle.next = null;
// 對左側進行排序
ListNode left = mergesort(head);
// 對右側進行排序
ListNode right = mergesort(tail);
// 合併兩條鏈,注意是將left和right進行合併
ListNode result = merge(left, right);
return result;
}
// 將兩個有序鏈表進行合併
private static ListNode merge(ListNode left, ListNode right) {
ListNode headNode = new ListNode(0);
ListNode tail = headNode;
headNode.next = left;
while (left != null && right != null) {
if (left.val < right.val) {
tail.next = left;
left = left.next;
} else {
tail.next = right;
right = right.next;
}
tail = tail.next;
}
// 將非空的節點直接鏈接到temp後邊
if (left != null) {
tail.next = left;
}
if (right != null) {
tail.next = right;
}
return headNode.next;
}
// 尋找鏈表的中間節點,可以使用快慢指針
private static ListNode findMiddle(ListNode head) {
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
// 快指針一次走兩步
fast = fast.next.next;
// 慢指針一次走一步
slow = slow.next;
}
return slow;
}
3. 鏈表插入與刪除
鏈表的插入與刪除操作應該是解決鏈表問題最常用的基礎手段。但關於鏈表的插入和刪除還是有若干學問的,比如對鏈表進行插入,就可以簡單分成頭插法和尾插法。鏈表刪除也是有若干邊界情況要考慮。但關於這兩個操作是比較基礎的,此處不進行詳述,我們此處就看幾個例題來回顧一下。
83. 刪除排序鏈表中的重複元素
給定一個排序鏈表,刪除所有重複的元素,使得每個元素只出現一次。
示例 1:
輸入: 1->1->2
輸出: 1->2
示例 2:輸入: 1->1->2->3->3
輸出: 1->2->3來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/remove-duplicates-from-sorted-list
著作權歸領釦網絡所有。商業轉載請聯繫官方授權,非商業轉載請註明出處。
這個問題比較容易,此處直接給出一種思路,通過雙指針來進行解決,一個指針指向當前遍歷節點,另一個指針指向當前遍歷節點的上一個節點,兩個節點值一直,則刪除當前遍歷節點,以此類推。
public ListNode deleteDuplicates(ListNode head) {
ListNode current = head;
while (current != null && current.next != null) {
if (current.next.val == current.val) {
current.next = current.next.next;
} else {
current = current.next;
}
}
return head;
}
4. 翻轉鏈表
鏈表翻轉應該是我們解決某些特殊問題的時候,比較有效的突破口,尤其是那些對鏈表按照指定規則進行重排序的問題,在無計可施的時候,通過翻轉有時候可以有效的找到突破口。
143.重排鏈表
給定一個單鏈表 L:L0→L1→…→Ln-1→Ln ,
將其重新排列後變爲: L0→Ln→L1→Ln-1→L2→Ln-2→…你不能只是單純的改變節點內部的值,而是需要實際的進行節點交換。
示例 1:
給定鏈表 1->2->3->4, 重新排列爲 1->4->2->3.
示例 2:給定鏈表 1->2->3->4->5, 重新排列爲 1->5->2->4->3.
來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/reorder-list
著作權歸領釦網絡所有。商業轉載請聯繫官方授權,非商業轉載請註明出處。
通過鏈表翻轉,我們可以迅速想到一種思路:
找到中點斷開,翻轉後面部分,然後合併前後兩個鏈表
具體代碼實現如下:
/** 按照要求重新對鏈表進行排序: 給定鏈表 1->2->3->4, 重新排列爲 1->4->2->3. */
public static void reorderList(ListNode head) {
// 邊界情況進行處理,如果只有一個節點或者節點爲空,直接返回
if (head == null || head.next == null) {
return;
}
// 如果只有一個節點直接反掌結果
if (head == null || head.next == null) {
return;
}
ListNode headNode = new ListNode(0);
headNode.next = head;
// 獲取中間鏈表節點
ListNode middle = getMiddle(head);
ListNode tail = middle.next;
middle.next = null;
// 對後一半的鏈表進行翻轉
tail = reverse(tail);
ListNode temp = headNode;
// 將兩段鏈表交替連接
while (head != null && tail != null) {
temp.next = head;
head = head.next;
temp = temp.next;
temp.next = tail;
tail = tail.next;
temp = temp.next;
}
// 非空節點連接到鏈表末尾
if (head != null) {
temp.next = head;
}
if (tail != null) {
temp.next = tail;
}
}
// 對鏈表進行翻轉
private static ListNode reverse(ListNode head) {
ListNode pre = null;
ListNode temp;
while (head != null) {
temp = head.next;
// 此處應該通過head的next來實現翻轉
head.next = pre;
pre = head;
head = temp;
}
return pre;
}
// 獲取鏈表的中間節點
private static ListNode getMiddle(ListNode head) {
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
return slow;
}
當然解決該問題的方法不知一種,比如我們也可以用存儲的思路的來解決,將鏈表轉成ArrayList來進行解決。具體代碼如下:
/**
* 使用存儲解決該問題
* 基本思路:將鏈表轉換乘ArraryList類型然後從後向前來進行遍歷
* @param head
*/
public static void reorderListByStorage(ListNode head) {
//邊界情況處理
if(head == null || head.next == null){
return;
}
//將鏈表轉換乘ArrayList進行操作
ArrayList<ListNode> list = new ArrayList<ListNode>();
while (head != null){
list.add(head);
head = head.next;
}
//通過雙指針連接乘新的鏈表
int i=0,j=list.size()-1;
while (i<j){
list.get(i).next = list.get(j);
i++;
//邊界情況處理 i == j
if(i == j){
break;
}
list.get(j).next=list.get(i);
j--;
}
//將最後一個節點的next置空
list.get(i).next = null;
}
5. 快慢指針
快慢指針或者說雙指針,毫無疑問是解決鏈表問題最常用的操作,比如在尋找鏈表中間節點的時候就很常用。
private static ListNode getMiddle(ListNode head) {
ListNode slow = head;
ListNode fast = head.next;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
return slow;
}
而且應用起來也比較靈活,比如可以用來判斷鏈表是否有環:
141. 環形鏈表
給定一個鏈表,判斷鏈表中是否有環。
如果鏈表中有某個節點,可以通過連續跟蹤 next 指針再次到達,則鏈表中存在環。 爲了表示給定鏈表中的環,我們使用整數 pos 來表示鏈表尾連接到鏈表中的位置(索引從 0 開始)。 如果 pos 是 -1,則在該鏈表中沒有環。注意:pos 不作爲參數進行傳遞,僅僅是爲了標識鏈表的實際情況。
如果鏈表中存在環,則返回 true 。 否則,返回 false 。
來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/linked-list-cycle
著作權歸領釦網絡所有。商業轉載請聯繫官方授權,非商業轉載請註明出處。
思路:快慢指針,快慢指針相同則有環,證明:如果有環每走一步快慢指針距離會減 1
代碼如下:
/**
* 使用快慢指針來解決鏈表是否有環的判斷
* @param head
* @return
*/
public static boolean hasCycle(ListNode head){
//邊界情況處理
if (head ==null || head.next ==null){
return false;
}
ListNode slow = head;
ListNode fast = head.next;
boolean hasCycle = false;
while (fast != null && fast.next != null){
//如果兩個指針重逢則證明一定有環
if (fast == slow){
hasCycle = true;
break;
}
//slow指針每次走一步,fast指針每次走兩步
slow = slow.next;
fast = fast.next.next;
}
return hasCycle;
}
總結
本文主要總結了解決鏈表問題時,常用的一些套路,比如增加頭結點、對鏈表進行排序、節點插入與刪除、鏈表翻轉以及快慢指針,希望能給讀者以幫助。