若是說數據結構是算法的基礎,那麼數組和鏈表就是數據結構的基礎。 由於像堆,棧,對,圖等比較複雜的數組結基本上均可以由數組和鏈表來表示,因此掌握數組和鏈表的基本操做十分重要。java
鏈表的知識點蠻多的,因此分紅上下兩篇,這篇主要講解鏈表翻轉的解題技巧,下篇主要講關於鏈表快慢指針的知識點,乾貨不少,建議先收藏再看。看完保證收穫滿滿!node
今天就來看看鏈表的基本操做及其在面試中的常看法題思路,本文將從如下幾個點來說解鏈表的核心知識git
相信你們已經開始火燒眉毛地想用鏈表解題了,不過在開始以前咱們仍是要先來溫習下鏈表的定義,以及它的優點與劣勢,磨刀不誤砍柴功!github
鏈表是物理存儲單元上非連續的、非順序的存儲結構,它是由一個個結點,經過指針來聯繫起來的,其中每一個結點包括數據和指針。面試
鏈表的非連續,非順序,對應數組的連續,順序,咱們來看看整型數組 1,2,3,4 在內存中是如何表示的算法
那鏈表在內存中是怎麼表示的呢數組
能夠看到每一個結點都分配在非連續的位置,結點與結點之間經過指針連在了一塊兒,因此若是咱們要找好比值爲 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 的空間必須是連續的,未使用的,因此在內存空間的分配上數組的要求會比較嚴格,若是內存碎片太多,分配連續的大空間極可能致使失敗。而鏈表因爲是非連續的,因此這種狀況下選擇鏈表更合適。
若是涉及到元素的頻繁刪除和插入,用鏈表就會高效不少,對於數組來講,若是要在元素間插入一個元素,須要把其他元素一個個日後移(如圖示),覺得新元素騰空間(同理,若是是刪除則須要把被刪除元素以後的元素一個個往前移),效率上無疑是比較低的。
而鏈表的插入刪除相對來講就比較簡單了,修改指針位置便可,其餘元素無需作任何移動操做(如圖示:以插入爲例)
綜上所述:若是數據以查爲主,不多涉及到增和刪,選擇數組,若是數據涉及到頻繁的插入和刪除,或元素所需分配空間過大,傾向於選擇鏈表。
說了這麼多理論,相信讀者對數組和鏈表的區別應該有了更深入地認識了,尤爲是 程序局部性原理,是否是開了很多眼界^_^,若是面試中問到數組和鏈表的區別能回答到程序局部性原理,會是一個很是大的亮點!
接下來咱們來看看鏈表的表現形式和解題技巧
須要說明的是有些代碼像打印鏈表等限於篇幅的關係沒有在文中展現,我把文中全部相關代碼都放到 github 中了,你們若是須要,能夠訪問個人 github 地址 下載運行,文中全部代碼均已用 Java 實現並運行經過
因爲鏈表的特色(查詢或刪除元素都要從頭結點開始),因此咱們只要在鏈表中定義頭結點便可,另外若是要頻繁用到鏈表的長度,還能夠額外定義一個變量來表示。
須要注意的是這個頭結點的定義是有講究的,通常來講頭結點有兩種定義形式,一種是直接以某個元素結點爲頭結點,以下
一種是以一個虛擬的節點做爲頭結點,即咱們常說的哨兵,以下
定義這個哨兵有啥好處呢,假設咱們不定義這個哨兵,來看看鏈表及添加元素的基本操做怎麼定義的
/** * 鏈表中的結點,data表明節點的值,next是指向下一個節點的引用 */
class Node {
int data;// 結點的數組域,值
Node next = null;// 節點的引用,指向下一個節點
public Node(int data) {
this.data = data;
}
}
/** * 鏈表 */
public class 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.next = tmp;
}
tmp.next = new Node(val);
}
}
}
複製代碼
發現問題了嗎,注意看下面代碼
有兩個問題:
若是定義了哨兵結點,以上兩個問題均可解決,來看下使用哨兵結點的鏈表定義
public class LinkedList {
int length = 0; // 鏈表長度,非必須,可不加
Node head = new Node(0); // 哨兵結點
public void addNode(int val) {
Node tmp = head;
while (tmp.next != null) {
tmp.next = tmp;
}
tmp.next = new Node(val);
}
}
複製代碼
能夠看到,定義了哨兵結點的鏈表邏輯上清楚了不少,不用每次插入元素都對頭結點進行判空,也統一了每個結點的添加邏輯。
因此以後的習題講解中咱們使用的鏈表都是使用定義了哨兵結點的形式。
作了這麼多前期的準備工做,終於要開始咱們的正餐了:鏈表解題經常使用套路--翻轉!
既然咱們要用鏈表解題,那咱們首先就構造一個鏈表吧 題目:給定數組 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;
}
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,刪除它的後繼結點 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 的後繼節點變了,須要從新設置!別忘了這一步)
一、定義遞歸函數,明確函數的功能 根據以上分析,這個遞歸函數的功能顯然是翻轉某個節點開始的鏈表,而後返回新的頭結點
/** * 翻轉結點 node 開始的鏈表 */
public Node invertLinkedList(Node node) {
}
複製代碼
二、尋找遞推公式 上文中已經詳細畫出了翻轉鏈表的步驟,簡單總結一下遞推步驟以下
三、將遞推公式代入第一步定義好的函數中,以下 (invertLinkedList)
/** * 遞歸翻轉結點 node 開始的鏈表 */
public Node invertLinkedList(Node node) {
if (node.next == null) {
return node;
}
// 步驟 1: 先翻轉 node 以後的鏈表
Node newHead = invertLinkedList(node.next);
// 步驟 2: 再把原 node 節點後繼結點的後繼結點指向 node (4),node 的後繼節點設置爲空(防止造成環)
node.next.next = 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 的後繼結點變了,別忘了從新設置哦!
四、計算時間/空間複雜度 因爲遞歸調用了 n 次 invertLinkedList 函數,因此時間複雜度顯然是 O(n), 空間複雜度呢,沒有用到額外的空間,可是因爲遞歸調用了 n 次 invertLinkedList 函數,壓了 n 次棧,因此空間複雜度也是 O(n)。
遞歸必定要從函數的功能去理解,從函數的功能看,定義的遞歸函數清晰易懂,定義好了以後,因爲問題與被拆分的子問題具備相同的解決思路,因此子問題只要持續調用定義好的功能函數便可,切勿層層展開子問題,此乃遞歸常見的陷阱!仔細看函數的功能,其實就是按照下圖實現的。(對照着代碼看,是否是清晰易懂^_^)
咱們知道遞歸比較容易形成棧溢出,因此若是有其餘時間/空間複雜度相近或更好的算法,應該優先選擇非遞歸的解法,那咱們看看如何用迭代來翻轉鏈表,主要思路以下
步驟 1: 定義兩個節點:pre, cur ,其中 cur 是 pre 的後繼結點,若是是首次定義, 須要把 pre 指向 cur 的指針去掉,不然因爲以後鏈表翻轉,cur 會指向 pre, 就進行了一個環(以下),這一點須要注意
步驟2:知道了 cur 和 pre,翻轉就容易了,把 cur 指向 pre 便可,以後把 cur 設置爲 pre ,cur 的後繼結點設置爲 cur 一直往前重複此步驟便可,完整動圖以下
注意:同遞歸翻轉同樣,迭代翻轉完了以後 head 的後繼結點從 4 變成了 1,記得從新設置一下。
知道了解題思路,實現代碼就容易多了,直接上代碼
/** * 迭代翻轉 */
public void iterationInvertLinkedList() {
// 步驟 1
Node pre = head;
Node cur = pre.getNext();
pre.setNext(null); // pre 是頭結點,避免翻轉鏈表後造成環
// 步驟 2
while (cur != null) {
/** * 務必注意!!!:在 cur 指向 pre 以前必定要先保留 cur 的後繼結點,否則若是 cur 先指向 pre,以後就再也找不到後繼結點了 */
Node next = cur.getNext();
cur.setNext(pre);
pre = cur;
cur = next;
}
// 此時 pre 指向的是原鏈表的尾結點,翻轉後即爲鏈表 head 的後繼結點
head.next = pre;
}
複製代碼
用迭代的思路來作因爲循環了 n 次,顯然時間複雜度爲 O(n),另外因爲沒有額外的空間使用,也未像遞歸那樣調用遞歸函數不斷壓棧,因此空間複雜度是 O(1),對比遞歸,顯然應該使用迭代的方式來處理!
花了這麼大的精力咱們總算把翻轉鏈表給搞懂了,若是你們看了以後幾道翻轉鏈表的變形,會發現咱們花了這麼大篇幅講解翻轉鏈表是值得的。
接下來咱們來看看鏈表翻轉的變形
變形題 1: 給定一個鏈表的頭結點 head,以及兩個整數 from 和 to ,在鏈表上把第 from 個節點和第 to 個節點這一部分進行翻轉。 例如:給定以下鏈表,from = 2, to = 4 head-->5-->4-->3-->2-->1 將其翻轉後,鏈表變成 head-->5--->2-->3-->4-->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;
} else if (curIndex == fromIndex) {
from = tmp;
} else if (curIndex == toIndex) {
to = tmp;
} else if (curIndex == toIndex+1) {
toNext = tmp;
}
tmp = tmp.next;
curIndex++;
}
if (from == null || to == null) {
// from 或 to 都超過尾結點不翻轉
throw new 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 個一組這一段鏈表的前繼結點,定義爲 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++;
} else if (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();
}
複製代碼
因而可知,掌握基本的鏈表翻轉很是重要!難題可能是在此基礎了作了相應的變形而已
本文詳細講解了鏈表與數組的本質區別,相信你們對二者的區別應該有了比較深入的認識,尤爲是程序局部性原理,相信你們看了應該會眼前一亮,以後經過對鏈表的翻轉由淺入深地介紹,相信以後的鏈表翻轉對你們應該不是什麼難事了,不過建議你們親自實現一遍文中的代碼哦,這樣印象會更深入一些!有一些看起來思路是這麼一回事,但真正操做起來仍是會有很多坑,紙上得來終覺淺,絕知此事要躬行!
文中的全部代碼均已更新在個人 github 地址上 ,你們若是須要,能夠下載運行
下一篇,咱們未來看看鏈表解題的另外一個關鍵的點:快慢指針。敬請期待!
若有幫助,有勞轉發+在看,多謝啦!