雙向鏈表與LRU算法實現


雙向鏈表與LRU算法

各位好久不見啊,由於疫情原因筆者一直宅在家中做考研復習。俗語雲:積少成多,跬步千里。於是我在此做一個簡單分享,一步步記錄我的學習歷程。

先從單鏈表談起

道家有言:一生二,二生三,三生萬物 ,萬物皆有源頭,在說雙向鏈表之前讓我們先看看單鏈表吧。

我們在學習計算機編程語言時,最先接觸的數據結構線性表,線性表是邏輯結構,其根據存儲方式的不同,又分為 順序表鏈表。而 單鏈表是鏈表中最基礎的結構。

如下圖所示,

其中,我們有兩個節點,第一個節點的值為10,並擁有一個指針指向下一個節點15。

可能的類代碼:

public class SLList {
   private IntNode first;
   public SLList() {
      first = null;
   }

   public SLList(int x) {
      first = new IntNode(x, null);
   }
 
   public void addFirst(int x) {
      first = new IntNode(x, first);
   }
   public int getFirst() {
  	  return first.item;
   }
}

規范——哨兵節點的誕生

在上面的單鏈表中,我們實現了從頭結點插入的功能,如果我們要實現從鏈表的尾部插入的功能呢?

我們可能會這樣寫:

public void addLast(int x) {
      size += 1;
      IntNode p = first;
      while (p.next != null) {
         p = p.next;
      }
      p.next = new IntNode(x, null);
   }

但是,如果我們要插入到一個空鏈表時,因為 first本身是 null ,當我們運行到 while(p.next != null)時,程序會發生錯誤!

有的同學就會想到,那我們加一個 if 處理不就行了。

if (first == null) {
    first = new IntNode(x, null);
    return;
  }
while(p.next != null){
    p = p.next;
}
p.next = new IntNode(x,null);

但是,這樣處理問題會顯得不美觀。而且當你處理的特殊情況越來越多的時候,你的代碼會越來越長,導致難以閱讀和維護,並破壞了簡單設計的原則。

這個時候我們的大救星,哨兵節點,閃亮登場。

如上圖所示,我們在初始化空鏈表時,會創建一個哨兵節點,他不存儲值,只是提供了一個守門員的角色,幫助你看看門外有沒有人並幫助你尋找后面的節點。我們把它叫做 sentinel

這樣我們就不用擔心會遇到空節點的情況,萬歲。事情變得簡單規范化了,沒有特殊例子!

我們可以這樣寫代碼了,去掉了 if語句:

IntNode p = sentinel;
  while (p.next != null) {
    p = p.next;
  }
p.next = new IntNode(x,null);

夠不着怎么辦

我們解決了從頭部插入和從尾部插入的問題,但是如果我們要刪除最后一個節點呢?時間復雜度是多少?

顯然,我們要從頭節點,一直找下去,直到導數第二個節點,時間復雜度為 O(n)。有沒有辦法縮短時間呢?

終極進化

如果我們想要刪除最末尾的節點,顯然我們要找到最后的節點和倒數第二個節點,所以我們可以添加一個指向上一個節點的指針。並添加指向最末尾的指針,一直指向最后一個節點。

這樣的結構夠好么?別忘了還有我們的哨兵朋友們!

最后綜合上述原因,我們造出了帶有哨兵節點的雙向鏈表!如下圖所示:

雙向鏈表的實現

上面我們講了雙向鏈表的由來,這里我們正式實現雙向鏈表:

API:

  • addFirst : 頭插入
  • removeFirst: 刪除頭節點
  • addLast: 尾插入
  • removeLast: 刪除尾節點
public class DLList<T> {
    // 使用了泛型實現雙向鏈表
    private TNode sentinel;
    private int size;

    // 新建內部類,節點
    public class TNode{
        TNode prev;
        TNode next;
        T item;
        public TNode(T item,TNode prev,TNode next){
            this.item = item;
            this.prev = prev;
            this.next = next;
        }
    }
	// 新建空鏈表
    public DLList(){
        sentinel = new TNode(null,null,null);
        sentinel.prev = sentinel.next = sentinel;
        size = 0;
    }

    public void addFirst(T item){
        TNode newNode = new TNode(item,sentinel,sentinel.next);
        sentinel.next.prev = newNode;
        sentinel.next = newNode;
        size+=1;

    }
    public boolean validateIndex(int index){
        if(index<0||index>=size){
            return false;
        }
        return true;
    }
    /*
     * helper method to get the node we need
     * */
    private TNode getNode(int index){
        TNode res;
        if(index<size/2){
            res = sentinel.next;
            for (int i=0;i<index;i++){
                res = res.next;
            }
            return res;
        }
        res = sentinel.prev;
        int newIndex = size - index -1;
        for (int i = 0 ;i<newIndex;i++){
            res = res.prev;
        }
        return res;
    }

    public T get(int index){
        if(!validateIndex(index)) return null;
        return getNode(index).item;
    }


    public int size(){
        return size;
    }
    public boolean isEmpty(){
        return size==0;
    }
    public void addLast(T item){
        TNode newNode = new TNode(item,sentinel.prev,sentinel);
        sentinel.prev.next = newNode;
        sentinel.prev = newNode;
        size+=1;
    }
    /*
     * helper method to delete the node we want
     * */
    private T delete(int index){
        if(!validateIndex(index)) throw new IndexOutOfBoundsException();
        TNode cur = getNode(index);
        T res = cur.item;
        cur.prev.next = cur.next;
        cur.next.prev = cur.prev;
        cur = null;
        size--;
        return res;
    }
    public T removeLast(){
        return delete(size-1);
    }

    public T removeFirst(){
        return  delete(0);
    }
}

LRU算法

學習過計算機操作系統的小伙伴,一定知道我們管理內存時需要頁面置換算法。其中一種經典的算法就是LRU算法(最近最久未使用算法)。

利用雙向鏈表,我們可以軟件模擬這種操作。每次使用數據,或者插入新數據的時候,我們把它移動到頭部。

這樣越靠近頭部的就是我們經常使用的數據。而當數據滿了的時候,我們只要刪除尾部的節點就好了,因為他是最久未使用的數據。

眾所周知,鏈表的遍歷是線性的,當我們要查詢數據的時候,速度並不理想。於是我們引入哈希表加速查找。


具體實現

LRU 緩存機制可以通過哈希表輔以雙向鏈表實現,我們用一個哈希表和一個雙向鏈表維護所有在緩存中的鍵值對。

雙向鏈表按照被使用的順序存儲了這些鍵值對,靠近頭部的鍵值對是最近使用的,而靠近尾部的鍵值對是最久未使用的。

哈希表即為普通的哈希映射(HashMap),通過緩存數據的鍵映射到其在雙向鏈表中的位置。

這樣一來,我們首先使用哈希表進行定位,找出緩存項在雙向鏈表中的位置,隨后將其移動到雙向鏈表的頭部,即可在 O(1), O(1) 的時間內完成 get 或者 put 操作。具體的方法如下:

對於 get 操作,首先判斷 key 是否存在:

如果 key 不存在,則返回 -1−1;

如果 key 存在,則 key 對應的節點是最近被使用的節點。通過哈希表定位到該節點在雙向鏈表中的位置,並將其移動到雙向鏈表的頭部,最后返回該節點的值。

對於 put 操作,首先判斷 key 是否存在:

如果 key 不存在,使用 key 和 value 創建一個新的節點,在雙向鏈表的頭部添加該節點,並將 key 和該節點添加進哈希表中。然后判斷雙向鏈表的節點數是否超出容量,如果超出容量,則刪除雙向鏈表的尾部節點,並刪除哈希表中對應的項;

如果 key 存在,則與 get 操作類似,先通過哈希表定位,再將對應的節點的值更新為 value,並將該節點移到雙向鏈表的頭部。

上述各項操作中,訪問哈希表的時間復雜度為 O(1)O(1),在雙向鏈表的頭部添加節點、在雙向鏈表的尾部刪除節點的復雜度也為 O(1)O(1)。而將一個節點移到雙向鏈表的頭部,可以分成「刪除該節點」和「在雙向鏈表的頭部添加節點」兩步操作,都可以在 O(1)O(1) 時間內完成。

代碼如下:

public class LRUCache {
    class DLinkedNode {
        int key;
        int value;
        DLinkedNode prev;
        DLinkedNode next;
        public DLinkedNode() {}
        public DLinkedNode(int _key, int _value) {key = _key; value = _value;}
    }

    private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>();
    private int size;
    private int capacity;
    private DLinkedNode head, tail;

    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        // 使用偽頭部和偽尾部節點
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 如果 key 存在,先通過哈希表定位,再移到頭部
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            // 如果 key 不存在,創建一個新的節點
            DLinkedNode newNode = new DLinkedNode(key, value);
            // 添加進哈希表
            cache.put(key, newNode);
            // 添加至雙向鏈表的頭部
            addToHead(newNode);
            ++size;
            if (size > capacity) {
                // 如果超出容量,刪除雙向鏈表的尾部節點
                DLinkedNode tail = removeTail();
                // 刪除哈希表中對應的項
                cache.remove(tail.key);
                --size;
            }
        }
        else {
            // 如果 key 存在,先通過哈希表定位,再修改 value,並移到頭部
            node.value = value;
            moveToHead(node);
        }
    }

    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }

    private DLinkedNode removeTail() {
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }
}

更多

引用:

^1鏈表定義

^2緩存文件置換機制

^3leetcode


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM