【算法】鏈表的基本操作和高頻算法題

鏈表的基本操作

鏈表的基礎操作有查找、刪除、添加。

查找

先定義一下鏈表的數據結構:

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表也不想存,想優化空間複雜度,一般的做法是定義指針。單向鏈表一般要定義雙指針,一個指向當前,一個指向前一個,如果是雙向鏈表,只用定義一個。但是在算法題中,單純只考鏈表的題目比較少,很多都會帶一些其他知識點。比如鏈表的排序、鏈表的二分查找等。只要熟練掌握鏈表的插入、刪除,只用考慮排序、查找的邏輯就行了,跟數組的排序、二分查找沒啥區別。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章