連結串列的基本操作和高頻演算法題

語言: CN / TW / HK

連結串列的基本操作

連結串列的基礎操作有查詢、刪除、新增。

查詢

先定義一下連結串列的資料結構:

class DataNode{
    int key;
    int value;
    
    DataNode pre;
    DataNode next;
    
    public DataNode(){};
    
    public DataNode (int key,int value){
        this.key = key;
        this.value = value;
    }
}

​ 其中的key和value就是節點實際儲存的值。pre和next分別指向前一個節點和下一個節點。一般的單向連結串列只有next,我這裡定義的是雙向連結串列。查詢操作就是從頭節點,一直遍歷next,直到找到目標節點為止。可以用while迴圈或者遞迴實現,找到目標節點就跳出或返回即可,時間複雜度為O(n)。

刪除

以上面的雙向連結串列為例,演示一下刪除操作。我們假設有三個節點A、B、C,現要刪除B節點。把A.next指向C,C.pre指向A。中間的B節點就不在鏈路上了,會被垃圾收回器給回收掉。

public void delNode(DataNode node){
    node.pre.next = node.next;
    node.next.pre = node.pre;
}

上面的程式碼初看可能有點繞,其實連結串列除了查詢操作,都有點繞,建議畫圖理解。其中 node.pre.next 就是上一個節點的下一個節點,把它改成 node.next ,就相當於讓上一個節點指向自己的下一個節點。第二行程式碼就是讓下一個節點的pre指向自己的上一個節點。刪除操作的時間複雜度為O(1)。

新增

假設有A、C兩個節點,現要往中間新增一個B節點。思路看圖都能想到,你的寫法不一定要和我一樣,只是注意別丟失節點了,程式碼如下:

//寫法1
public void addNode(DataNode pre,DataNode node){
    //先記錄一下pre.next節點,否則下一步會丟失C節點
    DataNode next = pre.next; // 記錄C
    pre.next = node; //A->B
    node.next = next; //B->C
    next.pre = node; // B<-C 
    node.pre = pre; // A<-B
}
//寫法2,不用臨時變數
public void addNode(DataNode pre,DataNode node){
    node.next = pre.next; //B->C
    node.pre = pre; //A<-B
    pre.next = node; // A->B
    node.next.pre = node; //C<-B
}

演算法題

LRU快取

關於連結串列的演算法題中,我覺得最能訓練連結串列操作的就是LRU快取。即給出已給固定容量的容器,往裡put元素時,如果容量到達最大, 就刪除最久未使用的元素

題目描述:

實現 LRUCache 類:

LRUCache(int capacity) 以 正整數 作為容量 capacity 初始化 LRU 快取

int get(int key) 如果關鍵字 key 存在於快取中,則返回關鍵字的值,否則返回 -1 。

void put(int key, int value) 如果關鍵字 key 已經存在,則變更其資料值 value ;如果不存在,則向快取中插入該組 key-value 。如果插入操作導致關鍵字數量超過 capacity ,則應該 逐出 最久未使用的關鍵字。

函式 get 和 put 必須以 O(1) 的平均時間複雜度執行。

思路:根據key找到value,所以肯定要一個Hash表儲存值。元素數量超過capacity就要刪除最久未使用的關鍵字。我們就設計一個連結串列,每次get元素A時,就把A移到連結串列頭部。需要刪除元素時,直接刪除連結串列尾部的元素,尾部的就是最久沒使用的。

每次get時要把對應的元素移至頭部,為了避免遍歷連結串列,設計Hash表的value型別可以設定成DataNode,這樣就免去的連結串列查詢的時間。DataNode就複用開頭定義的資料結構。

class LRUCache {
    int capacity;
    HashMap<Integer,DataNode> map;
    
    //定義一個虛擬的頭節點和尾節點,方便刪除尾節點和往頭節點新增元素
    DataNode head;
    DataNode tail;

    //1.初始化相關屬性
    public LRUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap<>();
        head = new DataNode();
        tail = new DataNode();
        head.next = tail;
        tail.pre = head;
    }
    //2.實現get邏輯,裡面的moveToHead可以先不實現
    public int get(int key) {
        DataNode node = map.get(key);
        if(node==null){
            return -1;
        }
        //把node節點移至頭部
        moveToHead(node);
        return node.value;
    }
    
    //3.實現put邏輯
    public void put(int key, int value) {
        if(map.containsKey(key)){
            //如果當前key已經存在
            DataNode node = map.get(key);
            moveToHead(node);
            node.value = value;
        }else{
            //不存在就新建一個node,如果超過capacity就刪除尾部節點
            DataNode node = new DataNode(key,value);
            map.put(key,node);
            if(map.size()>capacity){
                //因為還要從map中刪除元素,所以removeTail要有返回值
                DataNode delNode = removeTail();
                map.remove(delNode.key);
            }
            addHead(node);
        }
    }
    
    //4.最後一步,實現上面所需的連結串列操作方法
    private void moveToHead(DataNode node){
        //先刪除,再移至頭部
        removeNode(node);
        addHead(node);
    }
    
    private void addHead(DataNode node){
        node.next = head.next;
        node.pre = head;
        head.next = node;
        node.next.pre = node;
    }
    
    private DataNode removeTail(){
        DataNode delNode = tail.pre;
        removeNode(delNode);
        return delNode;
    }
    
    //removeTail和moveToHead都有刪除元素的操作,所以再提取一個刪除方法
    private void removeNode(DataNode node){
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }
}

反轉連結串列

反轉連結串列也是面試中出現頻率比較高的,反轉連結串列是個單向連結串列,它的操作比雙向連結串列更簡單。

題目描述:

給你單鏈表的頭節點 head ,請你反轉連結串列,並返回反轉後的連結串列。

思路:定義兩個指標(變數),一個指向當前節點,一個指向前一個節點。每次反轉指標指向的兩個節點,然後指標往後移一位。

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode reverseList(ListNode head) {
        if(head==null||head.next==null){
            return head;
        }
        ListNode pre = null;
        ListNode node = head;
        while(node!=null){
            ListNode temp = node.next;
            //反轉node和pre
            node.next = pre;
            //node和pre往後移一位
            pre = node;
            node = temp;
        }
        //因為node最終會移到尾節點的next上,也就是null
        //所以pre才是真正的尾節點,也就是反轉後的頭節點
        return pre;
    }
}

環形連結串列

題目描述:

出一個連結串列的head,判斷該連結串列是否是環形連結串列。如果是,就返回環形的入口。如果不是,就返回null。

如上圖,入口節點就是2。

思路1:要做出這個題不難,第一下就能想到: 邊遍歷連結串列,邊往Hash表儲存節點 ,每次遍歷前判斷Hash表是否存在當前節點,如果存在,這個節點就是環形入口。如果遍歷完了,還沒有重複節點,就說明沒有環形。時間複雜度:O(n),空間複雜度:O(n)。

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public ListNode detectCycle(ListNode head) {
        HashSet<ListNode> set = new HashSet<>();
        while(head!=null){
            if(set.contains(head)){
                return head;
            }
            set.add(head);
            head = head.next;
        }
        return null;
    }
}

思路2:優化連結串列的空間複雜度常用手段就是用指標,思路2就是定義兩個快慢指標,屬於數學邏輯範疇了。我們定義一個慢指標slow,一個快指標fast。slow一次移動一位,fast一次移動兩位。

  • 如果fast移到了null節點,說明連結串列無環,直接返回null

  • fast和slow相遇

    • 此時fast和slow一定在環形內,否則不可能相遇。 我們假設head到環形入口(不含入口)的長度為x,環形長度為y
    • 然後假設slow走了s步,則fast走了2s步(fast是slow的兩倍速)
    • fast和slow相遇時,fast 在環內 比slow 多走 了ny步(關鍵點,可以畫圖理解一下)
      • 所以fast=2s=s+ny(s是slow走的步數,ny是fast比slow多走的步數)
      • 所以s=ny
    • 根據上面的推測s=ny,接著可以推算出, 入口點就是x+ny 。因為y是環形長度,n是正整數,所以ny實際上和y沒區別,無非就是多繞了幾圈。(關鍵點,也可以畫圖理解一下)。
    • 此時slow已經走了ny步,所以再走x步就是入口點了。但是我們不知道x等於多少,那我們就讓一個指標從head再走一遍,一次走一步,和slow相遇點就是入口點。為了少建立一個變數,可以讓fast指標回到head節點重新走。
public class Solution {
    public ListNode detectCycle(ListNode head) {
        if(head==null||head.next==null){
            return null;
        }
        ListNode slow = head;
        ListNode fast = head;
        while(true){
            if(fast == null||fast.next == null){
                return null;
            }
            slow = slow.next;
            fast = fast.next.next;
            //第一次相遇
            if(fast==slow){
                break;
            }
        }
        fast = head;
        while(fast!=slow){
            fast = fast.next;
            slow = slow.next;
        }
        return slow;
    }
}

總結

連結串列必須要掌握它的刪除、新增、查詢三個基礎操作。連結串列的型別還分為:單向連結串列、迴圈連結串列(頭尾相連,或者帶環的)、雙向連結串列。只要掌握了雙向連結串列的基礎操作,其他連結串列都不在話下。

關於連結串列的演算法中,因為不能像陣列那樣,通過下標隨機訪問,所以一般會把節點存進Hash表。如果Hash表也不想存,想優化空間複雜度,一般的做法是定義指標。單向連結串列一般要定義雙指標,一個指向當前,一個指向前一個,如果是雙向連結串列,只用定義一個。但是在演算法題中,單純只考連結串列的題目比較少,很多都會帶一些其他知識點。比如連結串列的排序、連結串列的二分查詢等。只要熟練掌握連結串列的插入、刪除,只用考慮排序、查詢的邏輯就行了,跟陣列的排序、二分查詢沒啥區別。