雙向鏈表與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
