搞懂單連結串列常見面試題

像一隻狗發表於2018-03-09

搞懂單連結串列常見面試題

Hello 繼上次的 搞懂基本排序演演算法,這個一星期,我總結了,我所學習和思考的單連結串列基礎知識和常見面試題,這些題有的來自 《劍指 offer》 ,有的來自《程式設計師程式碼面試指南》,有的來自 leetCode,不是很全面,但都具有一定代表性,相信大家看完以後一定跟我一樣,對面試的時候演演算法題又多了一份自信。不過文章仍然是又臭又長,希望大家備好咖啡,火腿腸,方便麵之類的,慢慢看,如果我有哪些理解不對的地方,也希望大家能在評論區為我指出,也算是對我碼這麼多字的認可吧。

什麼是單連結串列

連結串列(Linked list)是一種常見的基礎資料結構,是一種線性表,但是並不會按線性的順序儲存資料,而是在每一個節點裡存到下一個節點的指標(Pointer),簡單來說連結串列並不像陣列那樣將陣列儲存在一個連續的記憶體地址空間裡,它們可以不是連續的因為他們每個節點儲存著下一個節點的引用(地址),所以較之陣列來說這是一個優勢。

對於單連結串列的一個節點我們經常使用下邊這種程式碼表示:

public class Node{
    //節點的值
    int value;
    //指向下一個節點的指標(java 中表現為下一個節點的引用)
    Node next;
    
    public void Node(int value){
        this.value = value;
    }
}
複製程式碼

單連結串列的特點

  1. 連結串列增刪元素的時間複雜度為O(1),查詢一個元素的時間複雜度為 O(n);
  2. 單連結串列不用像陣列那樣預先分配儲存空間的大小,避免了空間浪費
  3. 單連結串列不能進行回溯操作,如:只知道連結串列的頭節點的時候無法快讀快速連結串列的倒數第幾個節點的值。

單連結串列的基本操作

上一節我們說了什麼是單連結串列,那麼我們都知道一個陣列它具有增刪改查的基本操作,那麼我們單連結串列作為一種常見的資料結構型別也是具有這些操作的那麼我們就來看下對於單連結串列有哪些基本操作:

獲取單連結串列的長度

由於單連結串列的儲存地址不是連續的,連結串列並不具有直接獲取連結串列長度的功能,對於一個連結串列的長度我們只能一次去遍歷連結串列的節點,直到找到某個節點的下一個節點為空的時候得到連結串列的總長度,注意這裡的出發點並不是一個空連結串列然後依次新增節點後,然後去讀取已經記錄的節點個數,而是已知一個連結串列的頭結點然後去獲取這個連結串列的長度:

public int getLength(Node head){
    
    if(head == null){
        return 0;
    }
    
    int len = 0;
    while(head != null){
        len++;
        head = head.next;
    }  
    return len;  
}
複製程式碼

查詢指定索引的節點值或指定值得節點值的索引

由於連結串列是一種非連續性的儲存結構,節點的記憶體地址不是連續的,也就是說連結串列不能像陣列那樣可以透過索引值獲取索引位置的元素。所以連結串列的查詢的時間複雜度要是O(n)級別的,這點和陣列查詢指定值得元素位置是相同的,因為你要查詢的東西在記憶體中的儲存地址都是不一定的。

    /** 獲取指定角標的節點值 */
    public int getValueOfIndex(Node head, int index) throws Exception {

        if (index < 0 || index >= getLength(head)) {
            throw new Exception("角標越界!");
        }

        if (head == null) {
            throw new Exception("當前連結串列為空!");
        }

        Node dummyHead = head;

        while (dummyHead.next != null && index > 0) {
            dummyHead = dummyHead.next;
            index--;
        }

        return dummyHead.value;
    }

    
    /** 獲取節點值等於 value 的第一個元素角標 */
    public int getNodeIndex(Node head, int value) {
    
            int index = -1;
            Node dummyHead = head;
    
            while (dummyHead != null) {
                index++;
                if (dummyHead.value == value) {
                    return index;
                }
                dummyHead = dummyHead.next;
            }
    
            return -1;
    }

複製程式碼

連結串列新增一個元素

學過資料結構的朋友一定知道連結串列的插入操作,分為頭插法,尾插法,隨機節點插入法,當然資料結構講得時候也是針對一個已經構造好的(儲存了連結串列頭部節點和尾部節點引用)的情況下去插入一個元素,這看上去很簡單,如果我們在只知道一個連結串列的頭節點的情況下去插入一個元素,就不是那麼簡單了,就對於頭插入法我們只需要構造一個新的節點,然後將這個節點的 next 指標指向已知連結串列的頭節點就可以了。

1、 在已有連結串列頭部插入一個節點

public Node addAtHead(Node head, int value){
     Node newHead = new Node(value);
     newHead.next = head;
     return newHead;
}
複製程式碼

2、在已有連結串列的尾部插入一個節點:

public void addAtTail(Node head, int value){
     Node node = new Node(value);
     Node dummyHead = head;
     
     //找到未節點 注意這裡是當元素的下一個元素為空的時候這個節點即為未節點
     while( dummyHead.next != null){
        dummyHead = dummyHead.next;
     }
     
     dummyHead.next = node;   
}
複製程式碼

3、在指定位置新增一個節點

// 注意這裡 index 從 0 開始
 public Node insertElement(Node head, int value, int index) throws Exception {
   //為了方便這裡我們假設知道連結串列的長度
   int length = getLength(head);
   if (index < 0 || index >= length) {
       throw new Exception("角標越界!");
   }

   if (index == 0) {
       return addAtHead(head, value);
   } else if (index == length - 1) {
       addAtTail(head, value);
   } else {

       Node pre = head;
       Node cur = head.next;
       //
       while (pre != null && index > 1) {
           pre = pre.next;
           cur = cur.next;
           index--;
       }

       //迴圈結束後 pre 儲存的是索引的上一個節點 而 cur 儲存的是索引值當前的節點
       Node node = new Node(value);
       pre.next = node;
       node.next = cur;
   }
   return head;
}

複製程式碼

在指定位置新增一個節點,首先我們應該找到這個索引所在的節點的前一個,以及該節點,分別記錄這兩個節點,然後將索引所在節點的前一個節點的 next 指標指向新節點,然後將新節點的 next 指標指向插入節點即可。與其他元素並沒有什麼關係,所以單連結串列插入一個節點時間複雜度為 O(1),而陣列插入元素就不一樣瞭如果將一個元素插入陣列的指定索引位置,那麼該索引位置以後元素的索引位置(記憶體地址)都將發生變化,所以一個陣列的插入一個元素的時間複雜度為 O(n);所以連結串列相對於陣列插入的效率要高一些,刪除同理。

連結串列刪除一個元素

由於上邊介紹了連結串列新增元素的方法這裡對於連結串列刪除節點的方法不在詳細介紹直接給出程式碼:

1、 刪除頭部節點 也就是刪除索引為 0 的節點:

    public Node deleteHead(Node head) throws Exception {
        if (head == null) {
            throw new Exception("當前連結串列為空!");
        }
        return head.next;
    }
複製程式碼

2、 刪除尾節點

    public void deleteTail(Node head) throws Exception {

        if (head == null) {
            throw new Exception("當前連結串列為空!");
        }

        Node dummyHead = head;
        while (dummyHead.next != null && dummyHead.next.next != null) {
            dummyHead = dummyHead.next;
        }
        dummyHead.next = null;
    }

複製程式碼

3、 刪除指定索引的節點:

public Node deleteElement(Node head, int index) throws Exception {

   int size = getLength(head);
   
   if (index < 0 || index >= size) {
       throw new Exception("角標越界!");
   }

   if (index == 0) {
       return deleteHead(head);
   } else if (index == size - 1) {
       deleteTail(head);
   } else {
       Node pre = head;

       while (pre.next != null && index > 1) {
           pre = pre.next;
           index--;
       }

       //迴圈結束後 pre 儲存的是索引的上一個節點 將其指向索引的下一個元素
       if (pre.next != null) {
           pre.next = pre.next.next;
       }
   }

   return head;
}
複製程式碼

由單連結串列的增加刪除可以看出,連結串列的想要對指定索引進行操作(增加,刪除),的時候必須獲取該索引的前一個元素。記住這句話,對連結串列演演算法題很有用。

單連結串列常見面試題

介紹了連結串列的常見操作以後,我們的目標是學習連結串列常見的面試題目,不然我們學他幹嘛呢,哈哈~ 開個玩笑那麼我們就先從簡單的面試題開始:

尋找單連結串列的中間元素

同學們可能看到這道面試題笑了,咋這麼簡單,拿起筆來就開始寫,遍歷整個連結串列,拿到連結串列的長度len,再次遍歷連結串列那麼位於 len/2 位置的元素就是連結串列的中間元素。

我們也不能說這種方法不對,想想一下一個騰訊的面試官坐在對面問這個問題,這個回答顯然連自己這一關都很難過去。那麼更漸快的方法是什麼呢?或者說時間複雜度更小的方法如何實現這次查詢?這裡引出一個很關鍵的概念就是 快慢指標法,這也是面試官想考察的。

假如我們設定 兩個指標 slow、fast 起始都指向單連結串列的頭節點。其中 fast 的移動速度是 slow 的2倍。當 fast 指向末尾節點的時候,slow 正好就在中間了。想想一下是不是這樣假設一個連結串列長度為 6 , slow 每次一個節點位置, fast 每次移動兩個節點位置,那麼當fast = 5的時候 slow = 2 正好移動到 2 的節點的位置。

所以求解連結串列中間元素的解題思路是:

    public Node getMid(Node head){
      if(head == null){
         return null;
      }
      
      Node slow = head;
      Node fast = head;
      
      // fast.next = null 表示 fast 是連結串列的尾節點
      while(fast != null && fast.next != null){
         fast = fast.next.next;
         slow = slow.next;
      }
      return slow;
    }

複製程式碼

判斷一個連結串列是否是迴圈連結串列

首先此題也是也是考察快慢指標的一個題,也是快慢指標的第二個應用。先簡單說一下什麼迴圈連結串列,迴圈連結串列其實就是單連結串列的尾部指標指向頭指標,構建成一個環形的連結串列,叫做迴圈連結串列。 如 1 -> 2 - > 3 -> 1 -> 2 .....。為什麼快慢指標再迴圈連結串列中總能相遇呢?你可以想象兩個人在賽跑,A的速度快,B的速度慢,經過一定時間後,A總是會和B相遇,且相遇時A跑過的總距離減去B跑過的總距離一定是圈長的n倍。這也就是 Floyd判環(圈)演演算法

那麼如何使用快慢指標去判斷一個連結串列是否為環形連結串列呢:


private static boolean isLoopList(Node head){

        if (head == null){
            return false;
        }

        
        Node slow = head;
        Node fast = head.next;
        
        //如果不是迴圈連結串列那麼一定有尾部節點 此節點 node.next = null
        while(slow != null && fast != null && fast.next != null){
            if (fast == slow || fast.next == slow){
                return true;
            }
            // fast 每次走兩步  slow 每次走一步
            fast =fast.next.next;
            slow = slow.next;
        }
        //如果不是迴圈連結串列返回 false
        return false;
    }

複製程式碼

已知一個單連結串列求倒數第 N 個節點

為什麼這個題要放在快慢指標的後邊呢,因為這個題的解題思想和快慢指標相似,我們可以想一下:如果我們讓快指標先走 n-1 步後,然後讓慢指標出發。快慢指標每次都只移動一個位置,當快指標移動到連結串列末尾的時候,慢指標是否就正處於倒數第 N 個節點的位置呢。

是這裡把這兩個指標稱之為快慢指標是不正確的,因為快慢指標是指一個指標移動的快一個指標移動的慢,而此題中 快指標只是比慢指標先移動了 n-1 個位置而已,移動速度是相同的。

如果上邊的講解不好理解,這裡提供另外一種思路,就是想象一下,上述快慢指標的移動過程,是否就相當於一個固定視窗大小為 n 的滑動視窗:

  1. n = 1 fast 指標不移動 fast 到達最後一個節點 即 fast.next 的時候 slow 也到達尾部節點滿條件
  2. n = len fast 指標移動 n-1(len -1 ) 次 fast 到達最後一個節點 slow 位於頭節點不變 滿足條件 兩個臨界值均滿足我們這種假設。
  3. 1< n < len 的時候我們假設 n = 2 ,那麼 fast 比 slow 先移動一步,也就是視窗大小為 2, 那麼當 fast.next = null 即 fast 已經指向連結串列最後一個節點的時候,slow 就指向了 倒數第二個節點。

下面我們來看下函式實現:

     /**
     * 注意我們一般說倒數第 n 個元素 n 是從 1 開始的
     */
    private Node getLastIndexNode(Node head, int n) {

        // 輸入的連結串列不能為空,並且 n 大於0
        if (n < 1 || head == null) {
            return null;
        }

        n = 10;
        // 指向頭結點
        Node fast = head;
        // 倒數第k個結點與倒數第一個結點相隔 n-1 個位置
        // fast 先走 n-1 個位置
        for (int i = 1; i < n; i++) {
            // 說明還有結點
            if (fast.next != null) {
                fast = fast.next;
            }else {
                // 已經沒有節點了,但是i還沒有到達k-1說明k太大,連結串列中沒有那麼多的元素
                return null;
            }
        }

        Node slow = head;
        // fast 還沒有走到連結串列的末尾,那麼 fast 和 slow 一起走,
        // 當 fast 走到最後一個結點即,fast.next=null 時,slow 就是倒數第 n 個結點
        while (fast.next != null) {
            slow = slow.next;
            fast = fast.next;
        }
        // 返回結果
        return slow;
}
複製程式碼

刪除單連結串列的倒數第 n 個節點

看到這個題時候樂了,這考察的知識點不就是一道求解倒數第 n 個節點的進化版麼。但是我們也說過,如果想操作連結串列的某個節點(新增,刪除)還必須知道這個節點的前一個節點。所以我們刪除倒數第 n 個元素就要找到倒數第 n + 1 個元素。然後將倒數第 n + 1個元素 p 的 next 指標 p.next 指向 p.next.next

我們找到倒數第 n 個節點的時候,先讓 fast 先走了 n-1 步,那麼我們刪除倒數第 n 個節點的時候就需要 讓 fast 先走 n 步,構建一個 n+1 大小的視窗,然後 fast 和 slow 整體平移到連結串列尾部,slow 指向的節點就是 倒數第 n+1 個節點。

這裡我們還可以使用滑動視窗的思想來考慮臨界值:

  1. n = 1 的時候我們需要構建的視窗為 2,也就是當 fast.next = null 的時候 slow 在的倒數第二個節點上,那麼可想而知是滿足我們的條件的。

  2. 當 1 < n < len 的時候我們總是能構建出這樣的一個 len + 1大小的視窗,n 最大為 len -1 的時候,slow 位於頭節點,fast 位於未節點,刪除倒數第 n 個元素,即刪除正數第二個節點,slow.next = slow.next.next 即可。

  3. 當 n > len 的時候可想而知,我們要找的倒數第 n 個元素不存在,此時返回 頭節點就好了

  4. n = len 的時候比較特殊,迴圈並沒有因為倒數第 len 個元素不存在而終止,並進行了 fast = fast.next; 迴圈結束後 fast 指向 null , 且此時 slow 位於頭節點,所以我們要刪除的節點是頭節點,只需要在迴圈結束後判斷 如果 fast == null 返回 head.next 即可

下面我們來看解法:

/**
 * 刪除倒是第 n 個節點 我們就要找到倒數第 n + 1 個節點, 如果 n > len 則返回原列表
 */
private Node deleteLastNNode(Node head, int n) {

   if (head == null || n < 1) {
       return head;
   }

   Node fast = head;
   
   //注意 我們要構建長度為 n + 1 的視窗 所以 i 從 0 開始
   for (int i = 0; i < n; i++) {
       //fast 指標指向倒數第一個節點的時候,就是要刪除頭節點
       if (fast == null) {
           return head;
       } else {
           fast = fast.next;
       }
   }

   // 由於 n = len 再迴圈內部沒有判斷直接前進了一個節點,臨界值 n = len 的時候 迴圈完成或 fast = null
   if (fast == null){
       return head.next;
   }

   //此時 n 一定是小於 len 的 且 fast 先走了 n 步
   Node pre = head;

   while (fast.next != null) {
       fast = fast.next;
       pre = pre.next;
   }

   pre.next = pre.next.next;

   return head;
}
複製程式碼

旋轉單連結串列

題目:給定一個連結串列,旋轉連結串列,使得每個節點向右移動k個位置,其中k是一個非負數。 如給出連結串列為 1->2->3->4->5->NULL and k = 2, return 4->5->1->2->3->NULL.

做完,刪除倒數第 n 個節點的題,我們在看著道題是不是很簡單了,這道題的本質就是,找到 k 位置節點 將其變成尾節點,然後原來連結串列的尾節點指向原來的頭節點

private Node rotateList(Node head, int n) {

   int start = 1;

   Node fast = head;

   //先讓快指標走 n 給個位置
   while (start < n && fast.next != null) {
       fast = fast.next;
       start++;
   }


   //迴圈結束後如果 start < n 表示 n 整個連結串列還要長 旋轉後還是原連結串列
   //如果 fast.next = null 表示 n 正好等於原連結串列的長度此時也不需要旋轉
   if (fast.next == null || start < n) {
       return head;
   }

   //倒數第 n + 1個節點
   Node pre = fast;
   //旋轉後的頭節點
   Node newHead = fast.next;

   while (fast.next != null) {
       fast = fast.next;
   }
   //原連結串列的最後一個節點指向原來的頭節點
   fast.next = head;
   //將旋轉的節點的上一個節點變為尾節點
   pre.next = null;

   return newHead;
}

複製程式碼

翻轉單連結串列

翻轉一個單連結串列,要求額外的空間複雜度為 O(1)

翻轉單連結串列是我感覺比較難的基礎題,那麼先來屢一下思路:一個節點包含指向下一節點的引用,翻轉的意思就是對要原來指向下一個節點引用指向上一個節點

  1. 找到當前要反轉的節點的下一個節點並用變數儲存因為下一次要反轉的是它
  2. 然後讓當前節點的 next 指向上一個節點, 上一個節點初始 null 因為頭結點的翻轉後變為尾節點
  3. 當前要反轉的節點變成了下一個要比較元素的上一個節點,用變數儲存
  4. 當前要比較的節點賦值為之前儲存的未翻轉前的下一個節點
  5. 當前反轉的節點為 null 的時候,儲存的上一個節點即翻轉後的連結串列頭結點

ok,不知道按照上邊我寫的步驟能否理解一個連結串列的翻轉過程。如果不理解自己動手畫一下可能更好理解哈,注意在畫的時候一次只考慮一個節點,且不要考慮已經翻轉完的連結串列部分。

下面我們來看下實現過程:

public Node  reverseList(Node head){
   //頭節點的上一個節點為 null
   Node pre = null;
   Node next = null;
   
   while(head != null){
       next = head.next;
       head.next = pre;
       pre = head;
       head = next;
   }
}
複製程式碼

翻轉部分單連結串列

題目要求:要求 0 < from < to < len 如果不滿足則不翻轉

這類題還有一類進階題型,就是翻轉連結串列 from 位置到 to 位置的節點,其實翻轉過程是相似的,只是我們需要找到位於 from 的前一個節點,和 to 的下一個節點 翻轉完 from 和 to 部分後將 from 的上一個節點的 next 指標指向翻轉後的to,將翻轉後 from 節點的 next 指標指向 to 節點下一個節點。

  1. 遍歷整個連結串列 遍歷過程需要統計連結串列的長度 len ,from 節點的前一個節點 fPosPre , 翻轉開始的節點 from ,翻轉結束的節點 to ,節點to 節點的後一個節點 tPosNext 。
  2. 迴圈後判斷條件 0 < from < to < len 的條件是否滿足,如果不滿足返回 head
  3. 進行 from 到 to 節點翻轉
  4. 翻轉完後判斷 如果翻轉的起點不是 head 則返回 head,如果反轉的連結串列是起點,那麼翻轉後 toPos 就是頭結點。

下面我們開看程式碼(你可能有更簡便的解法,省去幾個變數,但是下面的解法應該是最好理解的);


    private Node reversePartList(Node head, int from, int to) {
        
        Node dummyHead = head;

        int len = 0;

        Node fPosPre = null;
        Node tPosNext = null;
        Node toPos = null;
        Node fromPos = null;

        while (dummyHead != null) {
            //因為 len = 0 開始的所以 len 先做自增一
            len++;

            if (len == from) {
                fromPos = dummyHead;
            } else if (len == from - 1) {
                fPosPre = dummyHead;
            } else if (len == to + 1) {
                tPosNext = dummyHead;
            } else if (len == to) {
                toPos = dummyHead;
            }

            dummyHead = dummyHead.next;
        }

        //不滿足條件不翻轉連結串列
        if (from > to || from < 0 || to > len || from > len) {
            return head;
        }


        Node cur = fromPos;
        Node pre = tPosNext;
        Node next = null;

        while (cur != null && cur != tPosNext) {
            next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = next;
        }
        // 如果翻轉的起點不是 head 則返回 head
        if (fPosPre != null) {
            fPosPre.next = pre;
            return head;
        }
        // 如果反轉的連結串列是起點,那麼翻轉後 toPos 就是頭結點
        return toPos;
    }
複製程式碼

單連結串列排序

在我的上一篇文章中說到了陣列基本的排序方法 搞懂基本排序方法,對於連結串列來說也有上述幾種排序方法,如果感興趣的朋友也可以使用氣泡排序,選擇排序,快速排序去實現單連結串列的排序,由於連結串列的不可回溯行,對於連結串列來說歸併排序是個不錯的排序方法。我們知道歸併透過遞迴,可以實現,那麼對於單連結串列來說也是可以的。

單連結串列的歸併排序

歸併的中心思想在於在於已知兩個連結串列的時候,如果按順序歸併這兩個連結串列。其實這也是一道面試題按照元素的大小合併兩個連結串列那麼我們就先看下如何合併兩個連結串列 我們稱這個過程為 merge 。

private Node merge(Node l, Node r) {

   //建立臨時空間
   Node aux = new Node();
   Node cur = aux;

   //由於連結串列不能方便的拿到連結串列長度 所以一般使用 while l == null 表示連結串列遍歷到尾部
   while (l != null && r != null) {
       if (l.value < r.value) {
           cur.next = l;
           cur = cur.next;
           l = l.next;
       } else {
           cur.next = r;
           cur = cur.next;
           r = r.next;
       }
   }
   //當有一半連結串列遍歷完成後 另外一個連結串列一定只剩下最後一個元素(連結串列為基數)
   if (l != null) {
       cur.next = l;
   } else if (r != null) {
       cur.next = r;
   }

   return aux.next;
}
複製程式碼

返回的 Node 節點為歸併完成後的連結串列頭節點。那麼歸併排序的核心過程也完成了,想想我們想要歸併一個陣列還需要一個劃分操作 中心節點 mid 是誰,看到這裡是不是笑了,之前我們已經講過如何尋找一個連結串列的中間元素,那麼是不是萬事具備了,ok 我們來實現連結串列的歸併排序:

private Node mergeSort(Node head) {

   //遞迴退出的條件 當歸並的元素為1個的時候 即 head.next 退出遞迴
   if (head == null || head.next == null) {
       return head;
   }

   Node slow = head;
   Node fast = head;

   //尋找 mid 值
   while (fast.next != null && fast.next.next != null) {
       slow = slow.next;
       fast = fast.next.next;
   }

   Node left = head;
   Node right = slow.next;

   //拆分兩個連結串列 如果設定連結串列的最後一個元素指向 null 那麼 left 永遠等於 head 這連結串列 也就無法排序
   slow.next = null;
   
   //遞迴的劃分連結串列
   left = mergeSort(left);
   right = mergeSort(right);

   return merge(left, right);
}
複製程式碼

單連結串列的插入排序

回想一下陣列的插入排序,我們從第二個數開始遍歷陣列,如果當前考察的元素值比下一個元素的值要大,則下一個元素應該排列排列在當前考察的元素之前,所以我們從已經排序的元素序列中從後向前掃描,如果該元素(已排序)大於新元素,將該元素移到下一位置(賦值也好,交換位置也好)。但是由於連結串列的不可回溯性,我們只能從連結串列的頭節點開始找,這個元素應該要在的位置。

我們來看下程式碼實現:

    public Node insertionSortList(Node head) {
        if (head == null || head.next == null) return head;

        Node dummyHead = new Node(0);
        Node p = head;
        dummyHead.next = head;
      //p 的值不小於下一節點元素考察下一節點
        while (p.next != null) {
            if (p.value <= p.next.value) { 
                p = p.next;
            } else {
                //p 指向 4
                Node temp = p.next;
                Node q = dummyHead;
                p.next = p.next.next;

                //從頭遍歷連結串列找到比當前 temp 值小的第一個元素插入其後邊 整個位置一定在 頭節點與 q 節點之間
                while (q.next.value < temp.value && q.next != q)
                    q = q.next;

                temp.next = q.next;
                //重新連線連結串列 注意 else 的過程並沒有改變 p 指標的位置
                q.next = temp;
            }
        }
        return dummyHead.next;
    }
複製程式碼

劃分連結串列

題目 : 按某個給定值將連結串列劃分為左邊小於這個值,右邊大於這個值的新連結串列 如一個連結串列 為 1 -> 4 -> 5 -> 2 給定一個數 3 則劃分後的連結串列為 1-> 2 -> 4 -> 5

此題不是很難,就是遍歷一遍連結串列,就可以完成,我們新建一兩個連結串列,如果遍歷過程中,節點值比給定值小則劃在左連結串列中,反之放在右連結串列中,遍歷完成後拼接兩個連結串列就好。不做過多解釋直接看程式碼。

 private Node partition(Node head , int x){
    if(head == null){
        return = null;
    }
    
    Node left = new Node(0);
    Node right = new Node(0);
    
    Node dummyLeft = left;
    Node dummyRight = right;
    
    while(head != null){
        if(head.value < x){
            dummyLeft.next = head;
            dummyLeft = dummyLeft.next;
        }else{
            dummyRight.next = head;
            dummyRight = dummyRight.next;
        }
        head = head.next;
    }
    
    dummyLeft.next = right.next;
    right.next = null;
    
    return left.next;
 }
複製程式碼

連結串列相加求和

題目: 假設連結串列中每一個節點的值都在 0-9 之間,那麼連結串列整體可以代表一個整數。 例如: 9->3->7 可以代表 937 給定兩個這樣的連結串列,頭節點為 head1 head2 生成連結串列相加的新連結串列。 如 9->3->7 和 6 -> 3 生成的新連結串列應為 1 -> 0 -> 0 -> 0

此題如果明白題意的情況並不難解決,首先理解怎麼取加兩個連結串列,即連結串列按照,尾節點往前的順序每一位相加,如果有進位則在下一個節點相加的時候算上,每一位加和為新連結串列的一個結點。這看上去跟數學加法一樣。所以我們的解題思路為:

  1. 翻轉要相加的兩個連結串列,這樣就可以從原連結串列的尾節點開始相加。
  2. 同步遍歷兩個逆序連結串列,每一個節點的值相加,透過是要使用變數記錄是否進位。
  3. 當連結串列遍歷完成後 判斷是否還有進位 如果有再新增一個結點,
  4. 再次翻轉兩個連結串列使其復原,並翻轉新連結串列,則得到的題解。

private Node addLists(Node head1, Node head2) {
        head1 = reverseList(head1);
        head2 = reverseList(head2);
        //進位標識
        int ca = 0;
        int n1 = 0;
        int n2 = 0;
        int sum = 0;

        Node addHead = new Node(0);
        Node dummyHead = addHead;

        Node cur1 = head1;
        Node cur2 = head2;

        while (cur1 != null || cur2 != null) {
            n1 = cur1 == null ? 0 : cur1.value;
            n2 = cur2 == null ? 0 : cur2.value;

            sum = n1 + n2 + ca;

            Node node = new Node(sum % 10);
            System.out.println( sum % 10);
            ca = sum / 10;

            dummyHead.next = node;

            dummyHead = dummyHead.next;

            cur1 = cur1 == null ? null : cur1.next;
            cur2 = cur2 == null ? null : cur2.next;
        }

        if (ca > 0) {
            dummyHead.next = new Node(ca);
        }

        head1 = reverseList(head1);
        head2 = reverseList(head2);

        addHead = addHead.next;
        return reverseList(addHead);
    }
    
    private  Node reverseList(Node head) {
        Node cur = head;
        Node pre = null;
        Node next = null;

        while (cur != null) {
            next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = next;
        }
        //注意這裡返回的是賦值當前比較元素
        return pre;
    }

複製程式碼

刪除有序/無序連結串列中重複的元素

刪除有序連結串列中的重複元素

刪除有序連結串列中的重複元素比較簡單,因為連結串列本身有序,所以如果元素值重複,那麼必定相鄰,所以刪除重複元素的方法為:

如一個連結串列為 36 -> 37 -> 65 -> 76 -> 97 -> 98 -> 98 -> 98 -> 98 -> 98 刪除重複元素後為: 36 -> 37 -> 65 -> 76 -> 97 -> 98

 private void delSortSame(Node head) {
        if (head == null || head.next == null) {
            return;
        }

        Node dummy = head;
        while (dummy.next != null) {
            if (dummy.value == dummy.next.value) {
                dummy.next = dummy.next.next;
            } else {
                dummy = dummy.next;
            }
        }
    }
複製程式碼

刪除無序連結串列中的重複元素

刪除無序連結串列中的重複元素,就要求我們必須使用一個指標記住當前考察元素 cur 的上一個元素 pre ,並以此遍歷考察元素之後的所有節點,如果有重複則將 pre 指標的 next 指標指向當 cur.next; 重複遍歷每個節點,直至連結串列結尾。

如一個連結串列刪除重複元素前為: 0 -> 0 -> 3 -> 5 -> 3 -> 0 -> 1 -> 4 -> 5 -> 7 刪除重複元素後為: 0 -> 3 -> 5 -> 1 -> 4 -> 7

private void delSame(Node head) {

   if (head == null || head.next == null) {
       return;
   }
   
   Node pre = null;
   Node next = null;
   Node cur = head;

   while (cur != null) {
       //當前考察的元素的前一個節點
       pre = cur;
       //當前考察元素
       next = cur.next;
       //從遍歷剩餘連結串列刪除重複元素
       while (next != null) {
           if (cur.value == next.value) {
               //刪除相同元素
               pre.next = next.next;
           }else {
               //移動指標
               pre = next;
           }
           //移動指標
           next = next.next;
       }
       //考察下一個元素
       cur = cur.next;
   }
}

複製程式碼

重排連結串列

其實這也是一系列的題目,主要考察了我們對於額外空間複雜度為O(1) 的連結串列操作。我們先看第一道題:

按照左右半區的方式重新排列組合單連結串列

題目 給定一個單連結串列L: L0→L1→…→Ln-1→Ln, 重新排列後為 L0→Ln→L1→Ln-1→L2→Ln-2→… 要求必須在不改變節點值的情況下進行原地操作。

我們先來分析一下題目,要想重排連結串列,必須先找到連結串列的中間節點,然後分離左右兩部連結串列,然後按左邊一個,右邊一個的順序排列連結串列。我們假設連結串列為基數的時候, N/2 位置的節點算左半連結串列, 那麼右半連結串列就會比左半連結串列多一個節點。當左半連結串列為最後一個節點的時候我們只需要將剩餘的右半連結串列設為其下一個節點即可。 N 為偶數的時候就好說了,N/2 + 1 為右半連結串列的開始,重拍最後只需要將左半連結串列為最後一個節點指向 null,恰巧此時右半連結串列為 null 所以重拍最後一步就是 left.next = right 下面我們來看題解:


private void relocate1(Node head) {
   //如果連結串列長度小於2 則不需要重新操作
   if (head == null || head.next == null) {
       return;
   }

   //使用快慢指標 遍歷連結串列找到連結串列的中點
   Node mid = head;
   Node right = head.next;

   while (right.next != null && right.next.next != null) {
       mid = mid.next;
       right = right.next.next;
   }

   //拆分左右半區連結串列
   right = mid.next;
   mid.next = null;

   //按要求合併
   mergeLR(head, right);

}

private void mergeLR(Node left, Node right) {
   Node temp = null;
   while (left.next != null) {
       temp = right.next;

       right.next = left.next;
       left.next = right;

       //這裡每次向後移動兩個位置 也就是原來的 left.next
       left = right.next;
       right = temp;
   }
   left.next = right;
}
複製程式碼

今日頭條的一個重排連結串列題目

給定一個連結串列 1 -> 92 -> 8 -> 86 -> 9 -> 43 -> 20 連結串列的特徵是奇數位升序,偶數位為降序,要求重新排列連結串列並保持連結串列整體為升序

這道題和左右半區重排連結串列類似,其實這可以理解為一個已經進行重排後的連結串列,現在要執行上一道重排的逆過程。要滿足這個條件,我們必須假設偶數位最小的節點大於奇數位最大的元素。我想出題人也是這意思。如果不是的話也不麻煩上邊我們也講了歸併排序的方法,只是一次歸併而已。下面來看滿足數位最小的節點大於奇數位最大的元素的解法:

此題考察了面試者對連結串列的基本操作以及如何翻轉一個連結串列

 private Node relocate2(Node head) {

        //新建一個左右連個連結串列的頭指標
        Node left = new Node();
        Node right = new Node();


        Node dummyLeft = left;
        Node dummyRight = right;

        int i = 0;
        while (head != null) {
            //因為 i 從0 開始 連結串列的頭節點算是奇數位所以 i 先自增 再比較
            i++;
            if (i % 2 == 0) {
                dummyRight.next = head;
                dummyRight = dummyRight.next;
            } else {
                dummyLeft.next = head;
                dummyLeft = dummyLeft.next;
            }
            //每次賦值後記得將下一個節點置位 null
            Node next = head.next;
            head.next = null;
            head = next;
        }

        right = reverseList(right.next);
        dummyLeft.next = right;

        return left.next;
    }
複製程式碼

判斷兩個單連結串列(無環)是相交

題目: 判斷兩個無環連結串列是否相交,如果相交則返回第一個相交節點,如果不想交返回 null 。

我們來分析一下這道題,我們假設兩個單連結串列相交,那從相交的節點開始到結束,一直到兩個連結串列都結束,那麼後邊這段連結串列相當於是共享的。我們還可以知道如果將這兩個連結串列的末尾對齊,這兩個連結串列的尾節點一定是相等的,所以我們的解題思路如下:

  1. 想讓一個連結串列遍歷一遍,並記錄其長度
  2. 在遍歷另一個連結串列,遍歷過程中 n 每次自減一
  3. 遍歷結束後,指標 cur1 指向連結串列 head1 的最後一個節點,同理指標 cur2 指向 head2 的最後一個節點,如果此時 cur1 != cur2 那麼根據題意這兩個連結串列不想交。
  4. 遍歷結束後,我們假設 hea1 要比 head2 長,那麼 n 一定為正數,代表了 head1 頭節點指標如果向右移動 n 個數 剩餘連結串列的長度將和 head2 一樣長
  5. 此後 point1 和 point2 一起走那麼這兩個 point 指向的節點總會相等,第一次相等的點即為兩個連結串列相交的點。
 private Node intersect(Node head1, Node head2) {
        if (head1 == null || head2 == null) {
            return null;
        }

        Node cur1 = head1;
        Node cur2 = head2;

        int n = 0;

        while (cur1.next != null) {
            n++;
            cur1 = cur1.next;
        }

        while (cur2.next != null) {
            n--;
            cur2 = cur2.next;
        }

        if (cur1 != cur2) {
            return null;
        }

        //令 cur1 指向 較長的連結串列,cur2 指向較短的連結串列
        if (n > 0) {
            cur1 = head1;
            cur2 = head2;
        } else {
            cur1 = head2;
            cur2 = head1;
        }

        n = Math.abs(n);

        //較長的連結串列先走 n 步
        while (n != 0) {
            cur1 = cur1.next;
        }

        //兩個連結串列一起走 第一次相等節點即為相交的第一個節點
        while (cur1 != cur2) {
            cur1 = cur1.next;
            cur2 = cur2.next;
        }
        
        return cur1;
    }
複製程式碼

總結

上篇文章搞懂排序演演算法評論有人說,文章太長了。沒想到這篇文章寫著寫著又這麼長了。還請大家耐下心來看,每到題自己耐下心來做一遍。等大家都搞懂以後,相信大家也就差不多無所畏懼單連結串列的面試題了。

歡迎大家關注我的個人部落格地址,本文演演算法題也上傳到我的 github上了。NodePractice 後續我將開始學習陣列,和字串的演演算法題。相信不久將來又能見到我的又臭又長的文章了。

最後 願天不負有心人。

相關文章