【超詳細】一文學會鏈表解題

前言

若是說數據結構是算法的基礎,那麼數組和鏈表就是數據結構的基礎。
由於像堆,棧,對,圖等比較複雜的數組結基本上均可以由數組和鏈表來表示,因此掌握數組和鏈表的基本操做十分重要。java

今天就來看看鏈表的基本操做及其在面試中的常看法題思路,本文將從如下幾個點來說解鏈表的核心知識node

  1. 什麼是鏈表,鏈表的優缺點
  2. 鏈表的表示及基本操做
  3. 面試中鏈表的常看法題思路---翻轉
  4. 面試中鏈表的常看法題思路---快慢指針

什麼是鏈表

相信你們已經開始火燒眉毛地想用鏈表解題了,不過在開始以前咱們仍是要先來溫習下鏈表的定義,以及它的優點與劣勢,磨刀不誤砍柴功!git

鏈表的定義

鏈表是物理存儲單元上非連續的、非順序的存儲結構,它是由一個個結點,經過指針來聯繫起來的,其中每一個結點包括數據和指針。github

鏈表的非連續非順序,對應數組的連續順序,咱們來看看整型數組 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 地址: https://github.com/allentofig... 下載運行(微信不支持外鏈,建議你們 copy 以後瀏覽器打開再下載運行),文中全部代碼均已用 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 = tmp.next;
            }
            tmp.next = new Node(val);
        }
    }
}

發現問題了嗎,注意看下面代碼

有兩個問題:

  1. 每插入一個元素都要對頭結點進行判空比較,若是一個鏈表有不少元素須要插入,就須要進行不少次的判空處理,不是那麼高效
  2. 頭結點與其餘結點插入邏輯不統一(一個須要判空後再插入,一個不須要判空直接插入),從程序邏輯性來講不是那麼合理(由於結點與結點是平級,添加邏輯理應相同)

若是定義了哨兵結點,以上兩個問題均可解決,來看下使用哨兵結點的鏈表定義

public class 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++
    }
}

能夠看到,定義了哨兵結點的鏈表邏輯上清楚了不少,不用每次插入元素都對頭結點進行判空,也統一了每個結點的添加邏輯。

因此以後的習題講解中咱們使用的鏈表都是使用定義了哨兵結點的形式。

作了這麼多前期的準備工做,終於要開始咱們的正餐了:鏈表解題經常使用套路--翻轉!

鏈表常看法題套路--翻轉

熱身賽

既然咱們要用鏈表解題,那咱們首先就構造一個鏈表吧
題目:給定數組 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 的後繼節點變了,須要從新設置!別忘了這一步)

一、定義遞歸函數,明確函數的功能
根據以上分析,這個遞歸函數的功能顯然是翻轉某個節點開始的鏈表,而後返回新的頭結點

/**
 * 翻轉結點 node 開始的鏈表
 */
public Node invertLinkedList(Node node) {
}

二、尋找遞推公式
上文中已經詳細畫出了翻轉鏈表的步驟,簡單總結一下遞推步驟以下

  • 針對結點 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

三、將遞推公式代入第一步定義好的函數中,以下 (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.next;
    Node cur = pre.next;
    pre.next = null;

    while (cur != null) {
        /**
         * 務必注意:在 cur 指向 pre 以前必定要先保留 cur 的後繼結點,否則 cur 指向 pre 後就再也找不到後繼結點了
         * 也就沒法對 cur 後繼以後的結點進行翻轉了
         */
        Node next = cur.next;
        cur.next = pre;
        pre = cur;
        cur = next;
    }
    // 此時 pre 爲頭結點的後繼結點
    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. 根據 from 和 to 找到 from-1, from, to, to+1 四個結點(注意臨界條件,若是 from 從頭結點開始,則 from-1 結點爲空, 翻轉後須要把 to 設置爲頭結點的後繼結點, from 和 to 結點也可能超過尾結點,這兩種狀況不符合條件不翻轉)。
  2. 對 from 到 to 的結點進行翻轉
  3. 將 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;
        } 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 個一組的鏈表(此例中 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++;
        } 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();
}

因而可知,掌握基本的鏈表翻轉很是重要!難題可能是在此基礎了作了相應的變形而已

鏈表解題利器---快慢指針

快慢指針在面試中出現的機率也很大,也是務必要掌握的一個要點,本文總結了市面上常見的快慢指針解題技巧,相信看完後此類問題能手到擒來。本文將詳細講述如何用快慢指針解決如下兩大類問題

  1. 尋找/刪除第 K 個結點
  2. 有關鏈表環問題的相關解法

尋找/刪除第 K 個結點

小試牛刀之一

LeetCode 876:給定一個帶有頭結點 head 的非空單鏈表,返回鏈表的中間結點。若是有兩個中間結點,則返回第二個中間結點。

解法一

要知道鏈表的中間結點,首先咱們須要知道鏈表的長度,說到鏈表長度你們想到了啥,還記得咱們在上文中說過哨兵結點能夠保存鏈表的長度嗎,這樣直接 從 head 的後繼結點 開始遍歷 鏈表長度 / 2 次便可找到中間結點。爲啥中間結點是 鏈表長度/2,咱們仔細分析一下

  1. 假如鏈表長度是奇數: head--->1--->2--->3--->4--->5, 從 1 開始遍歷 5/2 = 2 (取整)次,到達 3,3確實是中間結點
  2. 假如鏈表長度是偶數: 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;
}

解法三

解法二因爲要遍歷兩次鏈表,顯得不是那麼高效,那可否只遍歷一次鏈表就能拿到中間結點呢。

這裏就引入咱們的快慢指針了,主要有三步
一、 快慢指針同時指向 head 的後繼結點
二、 慢指針走一步,快指針走兩步
三、 不斷地重複步驟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;
}

有了上面的基礎,咱們如今再大一下難度,看下下面這道題

輸入一個鏈表,輸出該鏈表中的倒數第 k 個結點。好比鏈表爲 head-->1-->2-->3-->4-->5。求倒數第三個結點(即值爲 3 的節點)

分析:咱們知道若是要求順序的第 k 個結點仍是比較簡單的,從 head 開始遍歷 k 次便可,若是要求逆序的第 k 個結點,常規的作法是先順序遍歷一遍鏈表,拿到鏈表長度,而後再遍歷 鏈表長度-k 次便可,這樣要遍歷兩次鏈表,不是那麼高效,如何只遍歷一次呢,仍是用咱們的說的快慢指針解法

  1. 首先讓快慢指針同時指向 head 的後繼結點
  2. 快指針往前走 k- 1 步,先走到第 k 個結點
  3. 快慢指針同時日後走一步,不斷重複此步驟,直到快指針走到尾結點,此時的 slow 結點即爲咱們要找的倒序第 k 個結點

注:須要注意臨界狀況:k 大於鏈表的長度,這種異常狀況應該拋異常

public Node findKthToTail(int k) throws Exception {
    Node slow = head.next;
    Node fast = head.next;

    // 快指針先移到第k個結點
    int tmpK = k - 1;
    while (tmpK > 0 && fast != null) {
        fast = fast.next;
        tmpK--;
    }
    // 臨界條件:k大於鏈表長度
    if (fast == null) {
        throw new Exception("K結點不存在異常");
    }
    // slow 和 fast 同時日後移,直到 fast 走到尾結點
    while (fast.next != null) {
        slow = slow.next;
        fast = fast.next;
    }
    return slow;
}

知道了如何求倒序第 k 個結點,再來看看下面這道題

給定一個單鏈表,設計一個算法實現鏈表向右旋轉 K 個位置。舉例:
給定 head->1->2->3->4->5->NULL, K=3,右旋後即爲 head->3->4->5-->1->2->NULL

分析:這道題實際上是對求倒序第 K 個位置的的一個變形,主要思路以下

  • 先找到倒數第 K+1 個結點, 此結點的後繼結點即爲倒數第 K 個結點
  • 將倒數第 K+1 結點的的後繼結點設置爲 null
  • 將 head 的後繼結點設置爲以上所得的倒數第 K 個結點,將原尾結點的後繼結點設置爲原 head 的後繼結點

public void reversedKthToTail(int k) throws Exception {
    // 直接調已實現的 尋找倒序k個結點的方法,這裏是 k+1
    Node KPreNode = findKthToTail(k+1);
    // 倒數第 K 個結點
    Node kNode = KPreNode.next;
    Node headNext = head.next;

    KPreNode.next = null;
    head.next = kNode;

    // 尋找尾結點
    Node tmp = kNode;
    while (tmp.next != null) {
        tmp = tmp.next;
    }
    // 尾結點的後繼結點設置爲原 head 的後繼結點
    tmp.next = headNext;
}

有了上面兩道題的鋪墊,相信下面這道題不是什麼難事,限於篇幅關係,這裏不展開,你們能夠本身試試

輸入一個鏈表,刪除該鏈表中的倒數第 k 個結點

小試牛刀之二

判斷兩個單鏈表是否相交及找到第一個交點,要求空間複雜度 O(1)。
如圖示:若是兩個鏈表相交,5爲這兩個鏈表相交的第一個交點

畫外音:若是沒有空間複雜度O(1)的限制,其實有多種解法,一種是遍歷鏈表 1,將鏈表 1 的全部的結點都放到一個 set 中,再次遍歷鏈表 2,每遍歷一個結點,就判斷這個結點是否在 set,若是發現結點在這個 set 中,則這個結點就是鏈表第一個相交的結點

分析:首先咱們要明白,因爲鏈表自己的性質,若是有一個結點相交,那麼相交結點以後的全部結點都是這兩個鏈表共用的,也就是說兩個鏈表的長度主要相差在相交結點以前的結點長度,因而咱們有如下思路

一、若是鏈表沒有定義長度,則咱們先遍歷這兩個鏈表拿到兩個鏈表長度,假設分別爲 L1, L2 (L1 >= L2), 定義 p1, p2 指針分別指向各自鏈表 head 結點,而後 p1 先往前走 L1 - L2 步。這一步保證了 p1,p2 指向的指針與相交結點(若是有的話)同樣近。

二、 而後 p1,p2 不斷日後遍歷,每次走一步,邊遍歷邊判斷相應結點是否相等,若是相等即爲這兩個鏈表的相交結點

public static Node detectCommonNode(LinkedList list1, LinkedList list2) {
    int length1 = 0;        // 鏈表 list1 的長度
    int length2 = 0;        // 鏈表 list2 的長度

    Node p1 = list1.head;
    Node p2 = list2.head;

    while (p1.next != null) {
        length1++;
        p1 = p1.next;
    }

    while (p2.next != null) {
        length2++;
        p2 = p2.next;
    }

    p1 = list1.head;
    p2 = list2.head;

    // p1 或 p2 前進 |length1-length2| 步
    if (length1 >= length2) {
        int diffLength = length1-length2;
        while (diffLength > 0) {
            p1 = p1.next;
            diffLength--;
        }
    } else {
        int diffLength = length2-length1;
        while (diffLength > 0) {
            p2 = p2.next;
            diffLength--;
        }
    }
    // p1,p2分別日後遍歷,邊遍歷邊比較,若是相等,即爲第一個相交結點
    while (p1 != null && p2.next != null) {
        p1 = p1.next;
        p2 = p2.next;
        if (p1.data == p2.data) {
            // p1,p2 都爲相交結點,返回 p1 或 p2
            return p1;
        }
    }
    // 沒有相交結點,返回空指針
    return null;
}

進階

接下來咱們來看如何用快慢指針來判斷鏈表是否有環,這是快慢指針最多見的用法

判斷鏈表是否有環,若是有,找到環的入口位置(下圖中的 2),要求空間複雜度爲O(1)

首先咱們要看若是鏈表有環有什麼規律,若是從 head 結點開始遍歷,則這個遍歷指針必定會在以上的環中繞圈子,因此咱們能夠分別定義快慢指針,慢指針走一步,快指針走兩步, 因爲最後快慢指針在遍歷過程當中一直會在圈中裏繞,且快慢指針每次的遍歷步長不同,因此它們在裏面不斷繞圈子的過程必定會相遇,就像 5000 米長跑,一人跑的快,一人快的慢,跑得快的人必定會追上跑得慢的(即套圈)。

還不明白?那咱們簡單證實一下

一、 假如快指針離慢指針相差一個結點,則再一次遍歷,慢指針走一步,快指針走兩步,相遇

二、 假如快指針離慢指針相差兩個結點,則再一次遍歷,慢指針走一步,快指針走兩步,相差一個結點,轉成上述 1 的狀況

三、 假如快指針離慢指針相差 N 個結點(N大於2),則下一次遍歷因爲慢指針走一步,快指針走兩步,因此相差 N+1-2 = N-1 個結點,發現了嗎,相差的結點從 N 變成了 N-1,縮小了!不斷地遍歷,相差的結點會不斷地縮小,當 N 縮小爲 2 時,即轉爲上述步驟 2 的狀況,由此得證,若是有環,快慢指針必定會相遇!

畫外音:若是慢指針走一步,快指針走的不是兩步,而是大於兩步,會有什麼問題,你們能夠考慮一下

/**
 * 判斷是否有環,返回快慢指針相遇結點,不然返回空指針
 */
public Node detectCrossNode() {
    Node slow = head;
    Node fast = head;

    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
        
        if (fast == null) {
            return null;
        }

        if (slow.data == fast.data) {
            return slow;
        }
    }
    return null;
}

判斷有環爲啥要返回相遇的結點,而不是返回 true 或 false 呢。
由於題目中還有一個要求,判斷環的入口位置,就是爲了這個作鋪墊的,一塊兒來看看怎麼找環的入口,須要一些分析的技巧

假設上圖中的 7 爲快慢指針相遇的結點,不難看出慢指針走了 L + S 步,快指針走得比慢指針更快,它除了走了 L + S 步外,還額外在環裏繞了 n 圈,因此快指針走了 L+S+nR 步(R爲圖中環的長度),另外咱們知道每遍歷一次,慢指針走了一步,快指針走了兩步,因此快指針走的路程是慢指針的兩倍,即
2 (L+S) = L+S+nR,即 L+S = nR

  • 當 n = 1 時,則 L+S = R 時,則從相遇點 7 開始遍歷走到環入口點 2 的距離爲 R - S = L,恰好是環的入口結點,而 head 與環入口點 2 的距離剛好也爲 L,因此只要在頭結點定義一個指針,在相遇點(7)定義另一個指針,兩個指針同時遍歷,每次走一步,必然在環的入口位置 2 相遇
  • 當 n > 1 時,L + S = nR,即 L = nR - S, nR-S 怎麼理解?能夠看做是指針從結點 7 出發,走了 n 圈後,回退 S 步,此時恰好指向環入口位置,也就是說若是設置一個指針指向 head(定義爲p1), 另設一個指針指向 7(定義爲p2),不斷遍歷,p2 走了 nR-S 時(即環的入口位置),p1也恰好走到這裏(此時 p1 走了 nR-S = L步,恰好是環入口位置),即二者相遇!

綜上所述,要找到入口結點,只需定義兩個指針,一個指針指向head, 一個指針指向快慢指向的相遇點,而後這兩個指針不斷遍歷(同時走一步),當它們指向同一個結點時便是環的入口結點

public Node getRingEntryNode() {
        // 獲取快慢指針相遇結點
        Node crossNode = detectCrossNode();

        // 若是沒有相遇點,則沒有環
        if (crossNode == null) {
            return null;
        }

        // 分別定義兩個指針,一個指向頭結點,一個指向相交結點
        Node tmp1 = head;
        Node tmp2 = crossNode;

        // 二者相遇點即爲環的入口結點
        while (tmp1.data != tmp2.data) {
            tmp1 = tmp1.next;
            tmp2 = tmp2.next;
        }
        return tmp1;
    }

思考題:知道了環的入口結點,怎麼求環的長度?

總結

本文詳細講解了鏈表與數組的本質區別,相信你們對二者的區別應該有了比較深入的認識,尤爲是程序局部性原理,相信你們看了應該會眼前一亮,以後經過對鏈表的翻轉由淺入深地介紹,相信以後的鏈表翻轉對你們應該不是什麼難事了,以後再介紹了鏈表的另外一個重要的解題技巧:快慢指針,這兩大類是面試的高頻題,你們必定要掌握!建議你們親自實現一遍文中的代碼哦,這樣印象會更深入一些!有一些看起來思路是這麼一回事,但真正操做起來仍是會有很多坑,紙上得來終覺淺,絕知此事要躬行!

文中的全部代碼均已更新在個人 github 地址上: https://github.com/allentofig...,能夠下載運行

若有幫助,有勞轉發+在看,多謝啦!

相關文章
相關標籤/搜索