原文轉載自微信公衆號【碼海】,文章加入了自己的見解和內容
文章目錄
1. 前言
如果說數據結構是算法的基礎,那麼數組和鏈表就是數據結構的基礎。 因爲像堆,棧,隊列,圖等比較複雜的數組結基本上都可以由數組和鏈表來表示,所以掌握數組和鏈表的基本操作十分重要。
今天就來看看鏈表的基本操作及其在面試中的常見解題思路,本文將從以下幾個點來講解鏈表的核心知識
-
什麼是鏈表,鏈表的優缺點
-
鏈表的表示及基本操作
-
鏈表常見解題思路—翻轉
-
鏈表常見解題思路—快慢指針
2. 什麼是鏈表
相信大家已經開始迫不及待地想用鏈表解題了,不過在開始之前我們還是要先來溫習下鏈表的定義,以及它的優勢與劣勢,磨刀不誤砍柴功!
鏈表的定義
鏈表是物理存儲單元上非連續的、非順序的存儲結構,它是由一個個結點,通過指針來聯繫起來的,其中每個結點包括數據和指針。
鏈表的非連續,非順序,對應數組的連續,順序,我們來看看整型數組 1,2,3,4 在內存中是如何表示的
可以看到數組的每個元素都是連續緊鄰分配的,這叫連續性,同時由於數組的元素佔用的大小是一樣的,在 Java 中 int 型大小固定爲 4 個字節,所以如果數組的起始地址是 100, 由於這些元素在內存中都是連續緊鄰分配的,大小也一樣,可以很容易地找出數組中任意一個元素的位置,比如數組中的第三個元素起始地址爲 100 + 2 * 4 = 108,這就叫順序性。查找的時間複雜度是O(1),效率很高!
那鏈表在內存中是怎麼表示的呢
可以看到每個結點都分配在非連續的位置,結點與結點之間通過指針連在了一起,所以如果我們要找比如值爲 3 的結點時,只能通過結點 1 從頭到尾遍歷尋找,如果元素少還好,如果元素太多(比如超過一萬個),每個元素的查找都要從頭開始查找,時間複雜度是O(n),比起數組的 O(1),差距不小。
除了查找性能鏈表不如數組外,還有一個優勢讓數組的性能高於鏈表,這裏引入程序局部性原理,啥叫程序局部性原理。
我們知道 CPU 運行速度是非常快的,如果 CPU 每次運算都要到內存裏去取數據無疑是很耗時的,所以在 CPU 與內存之間往往集成了挺多層級的緩存,這些緩存越接近CPU,速度越快,所以如果能提前把內存中的數據加載到如下圖中的 L1, L2, L3 緩存中,那麼下一次 CPU 取數的話直接從這些緩存裏取即可,能讓CPU執行速度加快,那什麼情況下內存中的數據會被提前加載到 L1,L2,L3 緩存中呢,答案是當某個元素被用到的時候,那麼這個元素地址附近的的元素會被提前加載到緩存中
以上文整型數組 1,2,3,4爲例,當程序用到了數組中的第一個元素(即 1)時,由於 CPU 認爲既然 1 被用到了,那麼緊鄰它的元素 2,3,4 被用到的概率會很大,所以會提前把 2,3,4 加到 L1,L2,L3 緩存中去,這樣 CPU 再次執行的時候如果用到 2,3,4,直接L1、L2、L3 緩存裏取就行了,能提升不少性能
畫外音:如果把CPU的一個時種看成一秒,則從 L1 讀取數據需要 3 秒,從 L2 讀取需要 11 秒,L3讀取需要 25秒,而從內存讀取呢,需要 1 分 40 秒,所以程序局部性原理能對 CPU 執行性能有很大的提升
而鏈表呢,由於鏈表的每個結點在內存裏都是隨機分佈的,只是通過指針聯繫在一起,所以這些結點的地址並不相鄰,自然無法利用 程序局部性原理 來提前加載到 L1、L2、L3 緩存中來提升程序性能。
畫外音:程序局部性原理是計算機中非常重要的原理,這裏不做展開,建議大家查閱相關資料詳細瞭解一下
如上所述,相比數組,鏈表的非連續,非順序確實讓它在性能上處於劣勢,那什麼情況下該使用鏈表呢?考慮以下情況
- 大內存空間分配
由於數組空間的連續性,如果要爲數組分配 500M 的空間,這 500M 的空間必須是連續的,未使用的,所以在內存空間的分配上數組的要求會比較嚴格,如果內存碎片太多,分配連續的大空間很可能導致失敗。而鏈表由於是非連續的,所以這種情況下選擇鏈表更合適。
- 元素頻繁刪除和插入
如果涉及到元素的頻繁刪除和插入,用鏈表就會高效很多,對於數組來說,如果要在元素間插入一個元素,需要把其餘元素一個個往後移(如圖示),以爲新元素騰空間(同理,如果是刪除則需要把被刪除元素之後的元素一個個往前移),效率上無疑是比較低的。
(在 1,2 間插入 5,需要把2,3,4 同時往後移一位)
而鏈表的插入刪除相對來說就比較簡單了,修改指針位置即可,其他元素無需做任何移動操作(如圖示:以插入爲例)
綜上所述:如果數據以查爲主,很少涉及到增和刪,選擇數組,如果數據涉及到頻繁的插入和刪除,或元素所需分配空間過大,傾向於選擇鏈表。
說了這麼多理論,相信讀者對數組和鏈表的區別應該有了更深刻地認識了,尤其是 程序局部性原理,是不是開了不少眼界_,如果面試中問到數組和鏈表的區別能回答到程序局部性原理,會是一個非常大的亮點!
接下來我們來看看鏈表的表現形式和解題技巧
需要說明的是有些代碼像打印鏈表等限於篇幅的關係沒有在文中展示,我把文中所有相關代碼都放到 github 中了,大家如果需要,可以訪問我的 Github 地址: Code-Java/e-DataStructure/1-Linear Table下載運行
3. 鏈表的表示
由於鏈表的特點(查詢或刪除元素都要從頭結點開始),所以我們只要在鏈表中定義頭結點即可,另外如果要頻繁用到鏈表的長度,還可以額外定義一個變量來表示。
需要注意的是這個頭結點的定義是有講究的,一般來說頭結點有兩種定義形式,一種是直接以某個元素結點爲頭結點,如下
一種是以一個虛擬的節點作爲頭結點,即我們常說的哨兵,如下
定義這個哨兵有啥好處呢,假設我們不定義這個哨兵,來看看鏈表及添加元素的基本操作怎麼定義的:
/**
* 鏈表中的結點,data代表節點的值,next是指向下一個節點的引用
*/
class Node {
int data;// 結點的數組域,值
Node next = null;// 節點的引用,指向下一個節點
public Node(int data) {
this.data = data;
}
}
/**
* 鏈表
*/
publicclass LinkedList {
int length = 0; // 鏈表長度,非必須,可不加
Node head = null; // 頭結點
public void addNode(int val) {
if (head == null) {
head = new Node(val);
} else {
Node tmp = head;
while (tmp.next != null) {
tmp = tmp.next;
}
tmp.next = new Node(val);
}
}
}
發現問題了嗎,注意看下面代碼:
有兩個問題:
-
每插入一個元素都要對頭結點進行判空比較,如果一個鏈表有很多元素需要插入,就需要進行很多次的判空處理,不是那麼高效
-
頭結點與其他結點插入邏輯不統一(一個需要判空後再插入,一個不需要判空直接插入),從程序邏輯性來說不是那麼合理(因爲結點與結點是平級,添加邏輯理應相同)
如果定義了哨兵結點,以上兩個問題都可解決,來看下使用哨兵結點的鏈表定義
publicclass LinkedList {
int length = 0; // 鏈表長度,非必須,可不加
Node head = new Node(0); // 哨兵結點
public void addNode(int val) {
Node tmp = head;
while (tmp.next != null) {
tmp = tmp.next;
}
tmp.next = new Node(val);
length++;
}
}
可以看到,定義了哨兵結點的鏈表邏輯上清楚了很多,不用每次插入元素都對頭結點進行判空,也統一了每一個結點的添加邏輯。
所以之後的習題講解中我們使用的鏈表都是使用定義了哨兵結點的形式。
做了這麼多前期的準備工作,終於要開始我們的正餐了:鏈表解題常用套路–翻轉!
4. 鏈表常見解題套路–翻轉
熱身賽
既然我們要用鏈表解題,那我們首先就構造一個鏈表吧 題目:給定數組 1,2,3,4 構造成如下鏈表 head–>4---->3---->2---->1
看清楚了,是逆序構造鏈表!順序構造我們都知道怎麼構造,對每個元素持續調用上文代碼定義的 addNode 方法即可(即尾插法),與尾插法對應的,是頭插法,即把每一個元素插到頭節點後面即可,這樣就能做到逆序構造鏈表,如圖示(以插入1,2 爲例)
頭插法比較簡單,直接上代碼,直接按以上動圖的步驟來完成邏輯,如下
public class LinkedList {
int length = 0; // 鏈表長度,非必須,可不加
Node head = new Node(0); // 哨兵節點
// 頭插法
public void headInsert(int val) {
// 1.構造新結點
Node newNode = new Node(val);
// 2.新結點指向頭結點之後的結點
newNode.next = head.next;
// 3.頭結點指向新結點
head.next = newNode;
length++;
}
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
int[] arr = {1,2,3,4};
// 頭插法構造鏈表
for (int i = 0; i < arr.length; i++) {
linkedList.headInsert(arr[i]);
}
// 打印鏈表,將打印 4-->3-->2-->1
linkedList.printList();
}
}
小試牛刀
現在我們加大一下難度,來看下曾經的 Google 面試題: 給定單向鏈表的頭指針和一個節點指針,定義一個函數在 O(1) 內刪除這個節點。
如圖示:即給定值爲 2 的結點,如何把 2 給刪了。
我們知道,如果給定一個結點要刪除它的後繼結點是很簡單的,只要把這個結點的指針指向後繼結點的後繼結點即可
如圖示:
給定結點 2,刪除它的後繼結點 3, 把結點 2 的 next 指針指向 3 的後繼結點 4 即可。
但給定結點 2,該怎麼刪除結點 2 本身呢,注意題目沒有規定說不能改變結點中的值,所以有一種很巧妙的方法,狸貓換太子!
我們先通過結點 2 找到結點 3,再把節點 3 的值賦給結點 2,此時結點 2 的值變成了 3,這時候問題就轉化成了上圖這種比較簡單的需求,即根據結點 2 把結點 3 移除即可,看圖
不過需要注意的是這種解題技巧只適用於被刪除的指定結點是中間結點的情況,如果指定結點是尾結點,還是要老老實實地找到尾結點的前繼結點,再把尾結點刪除,代碼如下:
/**
* 刪除指定的結點
* @param deletedNode
*/
public void removeSelectedNode(Node deletedNode) {
// 如果此結點是尾結點我們還是要從頭遍歷到尾結點的前繼結點,再將尾結點刪除
if (deletedNode.next == null) {
Node tmp = head;
//找到尾結點的前繼結點
while (tmp.next != deletedNode) {
tmp = tmp.next;
}
//把尾結點刪除
tmp.next = null;
} else {
Node nextNode = deletedNode.next;
// 將刪除結點的後繼結點的值賦給被刪除結點
deletedNode.data = nextNode.data;
// 將nextNode 結點刪除
deletedNode.next = nextNode.next;
nextNode.next = null;
}
}
入門到進階:鏈表翻轉
接下來我們會重點看一下鏈表的翻轉,鏈表的翻轉可以衍生出很多的變形,是面試中非常熱門的考點,基本上考鏈表必考翻轉!所以掌握鏈表的翻轉是必修課!
【什麼是鏈表的翻轉】
給定鏈表 head–>4—>3–>2–>1,將其翻轉成 head–>1–>2–>3–>4 ,由於翻轉鏈表是如此常見,如此重要,所以我們分別詳細講解下如何用遞歸和非遞歸這兩種方式來解題
遞歸翻轉
關於遞歸的文章,強烈建議點擊這裏查看,總結了遞歸的常見解題套路,給出了遞歸解題的常見四步曲,如果看完對以下遞歸的解題套路會更加深刻,這裏不做贅述了,我們直接套遞歸的解題思路:
首先我們要查看翻轉鏈表是否符合遞歸規律:問題可以分解成具有相同解決思路的子問題,子子問題…,直到最終的子問題再也無法分解。
要翻轉 head —> 4 —> 3—> 2—> 1 鏈表,不考慮 head 結點,分析 4—> 3 —> 2 —> 1,仔細觀察我們發現只要先把 3 —> 2 —> 1 翻轉成 3 <— 2 <— 1,之後再把 3 指向 4 即可(如下圖示)
只要按以上步驟定義好這個翻轉函數的功能即可, 這樣由於子問題與最初的問題具有相同的解決思路,拆分後的子問題持續調用這個翻轉函數即可達到目的。
注意看上面的步驟1,問題的規模是不是縮小了,從翻轉整個鏈表變成了只翻轉部分鏈表!問題與子問題都是從某個結點開始翻轉,具有相同的解決思路,另外當縮小到只翻轉一個結點時,顯然是終止條件,符合遞歸的條件!之後的翻轉 3–>2–>1, 2–>1 持續調用這個定義好的遞歸函數即可!
既然符合遞歸的條件,那我們就可以套用遞歸四步曲來解題了(注意翻轉之後 head 的後繼節點變了,需要重新設置!別忘了這一步)
【遞歸四部曲】
經過判斷可以用遞歸後,接下來我們就來看看用遞歸解題的基本套路(四步曲):
-
先定義一個函數,明確這個函數的功能,由於遞歸的特點是問題和子問題都會調用函數自身,所以這個函數的功能一旦確定了, 之後只要找尋問題與子問題的遞歸關係即可
-
接下來尋找問題與子問題間的關係(即遞推公式),這樣由於問題與子問題具有相同解決思路,只要子問題調用步驟 1 定義好的函數,問題即可解決。所謂的關係最好能用一個公式表示出來,比如 f(n) = n * f(n-) 這樣,如果暫時無法得出明確的公式,用僞代碼表示也是可以的, 發現遞推關係後,要尋找最終不可再分解的子問題的解,即(臨界條件),確保子問題不會無限分解下去。由於第一步我們已經定義了這個函數的功能,所以當問題拆分成子問題時,子問題可以調用步驟 1 定義的函數,符合遞歸的條件(函數裏調用自身)
-
將第二步的遞推公式用代碼表示出來補充到步驟 1 定義的函數中
-
最後也是很關鍵的一步,根據問題與子問題的關係,推導出時間複雜度,如果發現遞歸時間複雜度不可接受,則需轉換思路對其進行改造,看下是否有更靠譜的解法
1、定義遞歸函數,明確函數的功能 根據以上分析,這個遞歸函數的功能顯然是翻轉某個節點開始的鏈表,然後返回新的頭結點
//翻轉結點 node 開始的鏈表
public Node invertLinkedList(Node node) { }
2、尋找遞推公式 上文中已經詳細畫出了翻轉鏈表的步驟,簡單總結一下遞推步驟如下
-
針對結點 node (值爲 4), 先翻轉 node 之後的結點 invert(node->next) ,翻轉之後 4—>3—>2—>1 變成了 4—>3<—2<—1
-
再把 node 節點的下個節點(3)指向 node,node 的後繼節點設置爲空(避免形成環),此時變成了 4<—3<—2<—1
-
返回新的頭結點,因爲此時新的頭節點從原來的 4 變成了 1,需要重新設置一下 head
3、將遞推公式代入第一步定義好的函數中,如下 (invertLinkedList)
這種遞歸的寫法很巧妙,在後面我會詳細分析每一步操作
public Node invertLinkedList(Node node) {
if (node.next == null) {
return node;
}
// 步驟 1: 先翻轉node之後的鏈表
Node newHead = invertLinkedList(node.next);
// 步驟 2: 再把原node節點後繼結點的後繼結點指向node
node.next.next = node;
// node的後繼節點設置爲空(防止形成環)
node.next = null;
// 步驟 3: 返回翻轉後的頭結點
return newHead;
}
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
int[] arr = {4,3,2,1};
for (int i = 0; i < arr.length; i++) {
linkedList.addNode(arr[i]);
}
Node newHead = linkedList.invertLinkedList(linkedList.head.next);
// 翻轉後別忘了設置頭結點的後繼結點!
linkedList.head.next = newHead;
linkedList.printList(); // 打印 1,2,3,4
}
畫外音:翻轉後由於 head 的後繼結點變了,別忘了重新設置哦!
4、計算時間/空間複雜度 由於遞歸調用了 n 次 invertLinkedList 函數,所以時間複雜度顯然是 O(n), 空間複雜度呢,沒有用到額外的空間,但是由於遞歸調用了 n 次 invertLinkedList 函數,壓了 n 次棧,所以空間複雜度也是 O(n)。
遞歸一定要從函數的功能去理解,從函數的功能看,定義的遞歸函數清晰易懂,定義好了之後,由於問題與被拆分的子問題具有相同的解決思路,所以子問題只要持續調用定義好的功能函數即可,切勿層層展開子問題,此乃遞歸常見的陷阱!仔細看函數的功能,其實就是按照下圖實現的。(對照着代碼看,是不是清晰易懂😊)
【代碼詳解分析】
我們通過力扣上的題來再對遞歸思想和代碼有個深入的瞭解
遞歸的兩個條件:
-
終止條件是當前節點或者下一個節點爲
null
-
在函數內部,改變節點的指向,也就是 head 的下一個節點指向 head 遞歸函數
head.next.next = head;
自己在理解這句語義卡了很長時間不得其解,其實就是遞歸的思想,層層劃分爲子問題再利用子問題求解。
先看這個動畫,來理解遞歸是怎麼解決這個問題的
然後我們再看遞歸到底是怎麼調用的,每一行代碼怎麼理解
1. 首先找到遞歸的出口
就是找到鏈表最後一個節點(尾結點),我們要反轉當前的鏈表,就必須從尾結點開始,因爲鏈表的性質就是:通過頭結點來找到後面的節點進行操作,CRUD都需要從頭結點開始找。
找到當前鏈表的尾結點,就是反轉鏈表的頭結點(先不考慮頭結點是否設立標兵)。
if(head == null || head.next == null)
這個條件對應兩種情況:
當鏈表只有空結點時:
鏈表到尾結點時:
以上這兩種情況都歸納爲尋找鏈表的尾結點
2. 遞和歸
爲了形象的讓大家理解這個過程,我們先以求階乘爲例:
遞就是拆分子問題
遞操作:
那麼針對此題中問題的拆分,我們可以這樣:
那最後剩餘這個節點怎麼辦呀?
1
這個節點是尾結點,它反轉只要讓它指向前驅節點即可
ListNode cur = reverseList(head.next);
cur
是不變的,始終都等於1
真正完成節點反轉的(就是節點間的指向發生改變的)是這句話:
head.next.next = head;
誰寫的這句話,真的是太妙了😘
-
如果鏈表是 1 -> 2 -> 3 -> 4 -> 5,那麼此時的
cur
就是5 -
而head是4,head的下一個是5,下下一個是空
-
所以head.next.next 就是4 -> 5
有一個小疑問就是:
head怎麼就是4
了?head作爲頭結點,一開始從尾結點反轉時,應該是5
呀!並且是怎麼一步步來反轉每一個節點呢?
這就是遞歸解法的精髓所在了。
首先經過上面的遞操作之後,我們此時應該是這樣的
此時head確實是5,滿足了出口條件,變返回到了上一層,即有兩個節點的情況。
由於是遞歸調用,head就向前走了一步,此時head==4
此時,head=4
,head.next
是cur = 5
,即head.next = cur = 5
我們要讓cur
指向head來反轉,而不再指向爲空
就是讓cur.next = head = 4
消除中間等價量cur
那麼就等價於head.next.next = head
此時我們發現,鏈表有環了,這是不可以的,我們只要斷開即可
head.next = null;
每層遞歸函數都返回cur
,也就是最後一個節點,即逆置新鏈表的頭結點cur
你看,當有兩個節點時,我們就逆置完成了
同樣的操作,再回上一層歸的操作時
這樣,直到歸到第一次調用時,完成逆置。
非遞歸翻轉鏈表(迭代解法)
我們知道遞歸比較容易造成棧溢出,所以如果有其他時間/空間複雜度相近或更好的算法,應該優先選擇非遞歸的解法,那我們看看如何用迭代來翻轉鏈表,主要思路如下
步驟 1: 定義兩個節點:pre, cur ,其中 cur 是 pre 的後繼結點,如果是首次定義, 需要把 pre 指向 cur 的指針去掉,否則由於之後鏈表翻轉,cur 會指向 pre, 就進行了一個環(如下),這一點需要注意
步驟2:知道了 cur 和 pre,翻轉就容易了,把 cur 指向 pre 即可,之後把 cur 設置爲 pre ,cur 的後繼結點設置爲 cur 一直往前重複此步驟即可,完整動圖如下
注意:同遞歸翻轉一樣,迭代翻轉完了之後 head 的後繼結點從 4 變成了 1,記得重新設置一下。
【代碼詳解分析】
我們可以申請兩個指針:
-
第一個指針叫 pre,最初是指向 null 的
-
第二個指針 cur 指向 head,然後不斷遍歷 cur
每次迭代到 cur,都將 cur 的 next 指向 pre,然後 pre 和 cur 前進一位。
都迭代完了(cur 變成 null 了),pre 就是最後一個節點了。
動畫演示如下(動圖來自@wang_ni_ma):
爲了簡化說明,取三個節點:
對照着代碼來理解圖,自己再畫一畫寫一寫,容易懂的哈😊
【代碼詳解分析】
同樣還是剛纔力扣的題目,我們再用指針解法分析遞歸思想和詳解代碼:
我們可以申請兩個指針:
-
第一個指針叫 pre,最初是指向 null 的
-
第二個指針 cur 指向 head,然後不斷遍歷 cur
每次迭代到 cur,都將 cur 的 next 指向 pre,然後 pre 和 cur 前進一位。
都迭代完了(cur 變成 null 了),pre 就是最後一個節點了。
動畫演示如下:
爲了簡化說明,取三個節點:
對照着代碼來理解圖,自己再畫一畫寫一寫,容易懂的哈😊
接下來我們來看看鏈表翻轉的變形
變形題1
給定一個鏈表的頭結點 head,以及兩個整數 from 和 to ,在鏈表上把第 from 個節點和第 to 個節點這一部分進行翻轉。 例如:給定如下鏈表,from = 2, to = 4 head–>5–>4–>3–>2–>1 將其翻轉後,鏈表變成 head–>5—>2–>3–>4–>1
有了之前翻轉整個鏈表的解題思路,現在要翻轉部分鏈表就相對簡單多了,主要步驟如下:
-
根據 from 和 to 找到 from-1, from, to, to+1 四個結點(注意臨界條件,如果 from 從頭結點開始,則 from-1 結點爲空, 翻轉後需要把 to 設置爲頭結點的後繼結點, from 和 to 結點也可能超過尾結點,這兩種情況不符合條件不翻轉)。
-
對 from 到 to 的結點進行翻轉
-
將 from-1 節點指向 to 結點,將 from 結點指向 to + 1 結點
知道了以上的思路,代碼就簡單了,按上面的步驟1,2,3 實現,註釋也寫得很詳細,看以下代碼(對 from 到 to 結點的翻轉我們使用迭代翻轉,當然使用遞歸也是可以的,限於篇幅關係不展開,大家可以嘗試一下)。
/**
* 迭代翻轉 from 到 to 的結點
*/
public void iterationInvertLinkedList(int fromIndex, int toIndex) throws Exception {
Node fromPre = null; // from-1結點
Node from = null; // from 結點
Node to = null; // to 結點
Node toNext = null; // to+1 結點
// 步驟 1:找到 from-1, from, to, to+1 這四個結點
Node tmp = head.next;
int curIndex = 1; // 頭結點的index爲1
while (tmp != null) {
if (curIndex == fromIndex-1) {
fromPre = tmp;
} elseif (curIndex == fromIndex) {
from = tmp;
} elseif (curIndex == toIndex) {
to = tmp;
} elseif (curIndex == toIndex+1) {
toNext = tmp;
}
tmp = tmp.next;
curIndex++;
}
if (from == null || to == null) {
// from 或 to 都超過尾結點不翻轉
thrownew Exception("不符合條件");
}
// 步驟2:以下使用循環迭代法翻轉從 from 到 to 的結點
Node pre = from;
Node cur = pre.next;
while (cur != toNext) {
Node next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 步驟3:將from-1節點指向 to 結點
// 如果從 head 的後繼結點開始翻轉,則需要重新設置 head 的後繼結點)
// 將 from 結點指向 to + 1 結點
if (fromPre != null) {
fromPre.next = to;
} else {
head.next = to;
}
from.next = toNext;
}
變形題2
給出一個鏈表,每 k 個節點一組進行翻轉,並返回翻轉後的鏈表。k 是一個正整數,它的值小於或等於鏈表的長度。如果節點總數不是 k 的整數倍,那麼將最後剩餘節點保持原有順序。
示例 : 給定這個鏈表:head–>1->2->3->4->5 當 k = 2 時,應當返回: head–>2->1->4->3->5 當 k = 3 時,應當返回: head–>3->2->1->4->5 說明 :
-
你的算法只能使用常數的額外空間。
-
你不能只是單純的改變節點內部的值,而是需要實際的進行節點交換。
這道題是 LeetCode 的原題,屬於 hard 級別,如果這一題你懂了,那對鏈表的翻轉應該基本沒問題了,有了之前的翻轉鏈表基礎,相信這題不難。
只要我們能找到翻一組 k 個結點的方法,問題就解決了(之後只要重複對 k 個結點一組的鏈表進行翻轉即可)。
接下來,我們以以下鏈表爲例,來看看怎麼翻轉 3 個一組的鏈表(此例中 k = 3)
-
首先,我們要記錄 3 個一組這一段鏈表的前繼結點,定義爲 startKPre,然後再定義一個 step, 從這一段的頭結點 (1)開始遍歷 2 次,找出這段鏈表的起始和終止結點,如下圖示
-
找到 startK 和 endK 之後,根據之前的迭代翻轉法對 startK 和 endK 的這段鏈表進行翻轉
-
然後將 startKPre 指向 endK,將 startK 指向 endKNext,即完成了對 k 個一組結點的翻轉
知道了一組 k 個怎麼翻轉,之後只要重複對 k 個結點一組的鏈表進行翻轉即可,對照圖示看如下代碼應該還是比較容易理解的
/**
* 每 k 個一組翻轉鏈表
* @param k
*/
public void iterationInvertLinkedListEveryK(int k) {
Node tmp = head.next;
int step = 0; // 計數,用來找出首結點和尾結點
Node startK = null; // k個一組鏈表中的頭結點
Node startKPre = head; // k個一組鏈表頭結點的前置結點
Node endK; // k個一組鏈表中的尾結點
while (tmp != null) {
// tmp 的下一個節點,因爲由於翻轉,tmp 的後繼結點會變,要提前保存
Node tmpNext = tmp.next;
if (step == 0) {
// k 個一組鏈表區間的頭結點
startK = tmp;
step++;
} elseif (step == k-1) {
// 此時找到了 k 個一組鏈表區間的尾結點(endK),對這段鏈表用迭代進行翻轉
endK = tmp;
Node pre = startK;
Node cur = startK.next;
if (cur == null) {
break;
}
Node endKNext = endK.next;
while (cur != endKNext) {
Node next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 翻轉後此時 endK 和 startK 分別是是 k 個一組鏈表中的首尾結點
startKPre.next = endK;
startK.next = endKNext;
// 當前的 k 個一組翻轉完了,開始下一個 k 個一組的翻轉
startKPre = startK;
step = 0;
} else {
step++;
}
tmp = tmpNext;
}
}
時間複雜度是多少呢,對鏈表從頭到尾循環了 n 次,同時每 k 個結點翻轉一次,可以認爲總共翻轉了 n 次,所以時間複雜度是O(2n),去掉常數項,即爲 O(n)。 注:這題時間複雜度比較誤認爲是O(k * n),實際上並不是每一次鏈表的循環都會翻轉鏈表,只是在循環鏈表元素每 k 個結點的時候纔會翻轉
變形3
變形 2 針對的是順序的 k 個一組翻轉,那如何逆序 k 個一組進行翻轉呢
例如:給定如下鏈表, head–>1–>2–>3–>4–>5 逆序 k 個一組翻轉後,鏈表變成(k = 2 時) head–>1—>3–>2–>5–>4
這道題是字節跳動的面試題,確實夠變態的,順序 k 個一組翻轉都已經屬於 hard 級別了,逆序 k 個一組翻轉更是屬於 super hard 級別了,不過其實有了之前知識的鋪墊,應該不難,只是稍微變形了一下,只要對鏈表做如下變形即可
代碼的每一步其實都是用了我們之前實現好的函數,所以我們之前做的每一步都是有伏筆的哦!就是爲了解決字節跳動這道終極面試題!
/**
* 逆序每 k 個一組翻轉鏈表
* @param k
*/
public void reverseIterationInvertLinkedListEveryK(int k) {
// 先翻轉鏈表
iterationInvertLinkedList();
// k 個一組翻轉鏈表
iterationInvertLinkedListEveryK(k);
// 再次翻轉鏈表
iterationInvertLinkedList();
}
由此可見,掌握基本的鏈表翻轉非常重要!難題多是在此基礎了做了相應的變形而已
5. 鏈表解題利器—快慢指針
快慢指針在面試中出現的概率也很大,也是務必要掌握的一個要點,本文總結了市面上常見的快慢指針解題技巧,相信看完後此類問題能手到擒來。本文將詳細講述如何用快慢指針解決以下兩大類問題
-
尋找/刪除第 K 個結點
-
有關鏈表環問題的相關解法
尋找/刪除第 K 個結點
刪除第 K 個結點
👉leetcode:刪除鏈表的倒數第N個節點
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
//添加一個啞結點作爲輔助,該結點位於列表頭部
//啞結點用來簡化某些極端情況,例如列表中只含有一個結點,或需要刪除列表的頭部
ListNode dummy = new ListNode(0);
dummy.next = head;
//設置一個指向啞結點的指針,並移動它遍歷列表
ListNode node = head;
int listLength = 0; //鏈表長度
while(node != null) {
node = node.next;
listLength++;
}
//node歸位,重新遍歷鏈表
node = dummy;
//找到要刪除節點的前一個節點
for (int i = 0; i < listLength - n; i++) {
node = node.next;
}
node.next = node.next.next;
return dummy.next;
}
}
小試牛刀之一
LeetCode 876:給定一個帶有頭結點 head 的非空單鏈表,返回鏈表的中間結點。如果有兩個中間結點,則返回第二個中間結點。
解法一
要知道鏈表的中間結點,首先我們需要知道鏈表的長度,說到鏈表長度大家想到了啥,還記得我們在上文中說過哨兵結點可以保存鏈表的長度嗎,這樣直接 從 head 的後繼結點 開始遍歷 鏈表長度 / 2 次即可找到中間結點。爲啥中間結點是 鏈表長度/2,我們仔細分析一下
-
假如鏈表長度是奇數: head—>1—>2—>3—>4—>5, 從 1 開始遍歷 5/2 = 2 (取整)次,到達 3,3確實是中間結點
-
假如鏈表長度是偶數: head—>1—>2—>3—>4—>5—>6, 從 1 開始遍歷 6/2 = 3次,到達 4,4 確實是中間結點的第二個結點
畫外音:多畫畫圖,舉舉例,能看清事情的本質!
哨後結點的長度派上用場了,這種方式最簡單,直接上代碼
public Node findMiddleNode() {
Node tmp = head.next;
int middleLength = length / 2;
while (middleLength > 0) {
tmp = tmp.next;
middleLength--;
}
return tmp;
}
解法二
如果哨兵結點裏沒有定義長度呢,那就要遍歷一遍鏈表拿到鏈表長度(定義爲 length)了,然後再從頭結點開始遍歷 length / 2 次即爲中間結點
public Node findMiddleNodeWithoutHead() {
Node tmp = head.next;
int length = 1;
// 選遍歷一遍拿到鏈表長度
while (tmp.next != null) {
tmp = tmp.next;
length++;
}
// 再遍歷一遍拿到鏈表中間結點
tmp = head.next;
int middleLength = length / 2;
while (middleLength > 0) {
tmp = tmp.next;
middleLength--;
}
return tmp;
}
解法三
解法二由於要遍歷兩次鏈表,顯得不是那麼高效,那能否只遍歷一次鏈表就能拿到中間結點呢。
這裏就引入我們的快慢指針了,主要有三步 1、 快慢指針同時指向 head 的後繼結點 2、 慢指針走一步,快指針走兩步 3、 不斷地重複步驟2,什麼時候停下來呢,這取決於鏈表的長度是奇數還是偶數
-
如果鏈表長度爲奇數,當 fast.next = null 時,slow 爲中間結點
-
如果鏈表長度爲偶數,當 fast = null 時,slow 爲中間結點
分析可知:當 fast = null 或者 fast.next = null 時,此時的 slow 結點即爲我們要求的中間結點,否則不斷地重複步驟 2, 知道了思路,代碼實現就簡單了
/**
* 使用快慢指針查找找到中間結點
* @return
*/
public Node findMiddleNodeWithSlowFastPointer() {
Node slow = head.next;
Node fast = head.next;
while (fast != null && fast.next != null) {
// 快指針走兩步
fast = fast.next.next;
// 慢指針走一步
slow = slow.next;
}
// 此時的 slow 結點即爲哨兵結點
return slow;
}
總結
本文詳細講解了鏈表與數組的本質區別,相信大家對兩者的區別應該有了比較深刻的認識,尤其是程序局部性原理,這樣印象會更深刻一些!有一些看起來思路是這麼一回事,但真正操作起來還是會有不少坑,紙上得來終覺淺,絕知此事要躬行!