各位很久不見啊,因爲疫情緣由筆者一直宅在家中作考研複習。俗語云:聚沙成塔,跬步千里。因而我在此作一個簡單分享,一步步記錄個人學習歷程。java
道家有言:一輩子二,二生三,三生萬物
,萬物皆有源頭,在說雙向鏈表以前讓咱們先看看單鏈表
吧。node
咱們在學習計算機編程語言時,最早接觸的數據結構是線性表
,線性表是邏輯結構,其根據存儲方式的不一樣,又分爲 順序表
,鏈表
。而 單鏈表
是鏈表中最基礎的結構。算法
以下圖所示,編程
其中,咱們有兩個節點,第一個節點的值爲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)
時,程序會發生錯誤!this
有的同窗就會想到,那咱們加一個 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:
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 緩存機制能夠經過哈希表輔以雙向鏈表實現,咱們用一個哈希表和一個雙向鏈表維護全部在緩存中的鍵值對。
雙向鏈表按照被使用的順序存儲了這些鍵值對,靠近頭部的鍵值對是最近使用的,而靠近尾部的鍵值對是最久未使用的。
哈希表即爲普通的哈希映射(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