手寫LRU緩存淘汰算法
背景
在我們這個(gè)日益追求高效的世界费薄,我們對(duì)任何事情的等待都顯得十分的浮躁硝全,網(wǎng)頁頁面刷新不出來,好煩楞抡,電腦打開運(yùn)行程序慢伟众,又是好煩!那怎么辦召廷,技術(shù)的產(chǎn)生不就是我們所服務(wù)么凳厢,今天我們就聊一聊緩存
這個(gè)技術(shù),并使用我們熟知的數(shù)據(jù)結(jié)構(gòu)--用鏈表實(shí)現(xiàn)LRU緩存淘汰算法
竞慢。
在學(xué)習(xí)如何使用鏈表實(shí)現(xiàn)LRU緩存淘汰算法前先紫,我們先提出幾個(gè)問題,大家好好思考下筹煮,問題如下:
- 什么是緩存遮精,緩存的作用?
- 緩存的淘汰策略有哪些败潦?
- 如何使用鏈表實(shí)現(xiàn)LRU緩存淘汰算法本冲,有什么特點(diǎn),如何優(yōu)化劫扒?
好了檬洞,我們帶著上面的問題來學(xué)進(jìn)行下面的學(xué)習(xí)。
1粟关、什么是緩存疮胖,緩存的作用是什么?
緩存
可以簡單的理解為保存數(shù)據(jù)的一個(gè)副本
,以便于后續(xù)能夠快速的進(jìn)行訪問闷板。以計(jì)算機(jī)的使用場景為例
澎灸,當(dāng)cpu要訪問內(nèi)存中的一條數(shù)據(jù)時(shí),它會(huì)先在緩存里查找遮晚,如果能夠找到則直接使用性昭,如果沒找到,則需要去內(nèi)存里查找县遣;
同樣的糜颠,在數(shù)據(jù)庫的訪問場景中
,當(dāng)項(xiàng)目系統(tǒng)需要查詢數(shù)據(jù)庫中的某條數(shù)據(jù)時(shí)萧求,可以先讓請(qǐng)求查詢緩存其兴,如果命中,就直接返回緩存的結(jié)果夸政,如果沒有命中元旬,就查詢數(shù)據(jù)庫, 并將查詢結(jié)果放入緩存,下次請(qǐng)求時(shí)查詢緩存命中匀归,直接返回結(jié)果坑资,就不用再次查詢數(shù)據(jù)庫。
通過以上兩個(gè)例子穆端,我們發(fā)現(xiàn)無論在哪種場景下袱贮,都存在這樣一個(gè)順序:先緩存,后內(nèi)存
体啰;先緩存攒巍,后數(shù)據(jù)庫
。但是緩存的存在也占用了一部分內(nèi)存空間狡赐,所以緩存是典型的以空間換時(shí)間
窑业,犧牲數(shù)據(jù)的實(shí)時(shí)性
,卻滿足計(jì)算機(jī)運(yùn)行的高效性
枕屉。
仔細(xì)想一下常柄,我們?nèi)粘i_發(fā)中遇到緩存的例子還挺多的。
- 操作系統(tǒng)的緩存
減少與磁盤的交互
- 數(shù)據(jù)庫緩存
減少對(duì)數(shù)據(jù)庫的查詢
- Web服務(wù)器緩存
減少對(duì)應(yīng)用服務(wù)器的請(qǐng)求
- 客戶瀏覽器的緩存
減少對(duì)網(wǎng)站的訪問
2搀擂、緩存有哪些淘汰策略?
緩存的本質(zhì)是以空間換時(shí)間
西潘,那么緩存的容量大小肯定是有限的,當(dāng)緩存被占滿時(shí)哨颂,緩存中的那些數(shù)據(jù)應(yīng)該被清理出去喷市,那些數(shù)據(jù)應(yīng)該被保留呢?這就需要緩存的淘汰策略來決定威恼。
事實(shí)上品姓,常用的緩存的淘汰策略有三種:先進(jìn)先出算法(First in First out FIFO
);淘汰一定時(shí)期內(nèi)被訪問次數(shù)最少的頁面(Least Frequently Used LFU
)箫措;淘汰最長時(shí)間未被使用的頁面(Least Recently Used LRU
)
這些算法在不同層次的緩存上執(zhí)行時(shí)具有不同的效率腹备,需要結(jié)合具體的場景來選擇。
2.1 FIFO算法
FIFO
算法即先進(jìn)先出算法
斤蔓,常采用隊(duì)列實(shí)現(xiàn)植酥。在緩存中,它的設(shè)計(jì)原則是:如果一個(gè)數(shù)據(jù)最先進(jìn)入緩存中弦牡,則應(yīng)該最早淘汰掉
友驮。
- 新訪問的數(shù)據(jù)插入FIFO隊(duì)列的尾部,隊(duì)列中數(shù)據(jù)由隊(duì)到隊(duì)頭按順序順序移動(dòng)
- 隊(duì)列滿時(shí)驾锰,刪除隊(duì)頭的數(shù)據(jù)
2.2 LRU算法
LRU算法
是根據(jù)對(duì)數(shù)據(jù)的歷史訪問次數(shù)來進(jìn)行淘汰數(shù)據(jù)的卸留,通常使用鏈表來實(shí)現(xiàn)。在緩存中椭豫,它的設(shè)計(jì)原則是:如果數(shù)據(jù)最近被訪問過耻瑟,那么將來它被訪問的幾率也很高买喧。
- 新加入的數(shù)據(jù)插入到鏈表的頭部
- 每當(dāng)緩存命中時(shí)(即緩存數(shù)據(jù)被訪問),則將數(shù)據(jù)移到鏈表頭部
- 當(dāng)來鏈表已滿的時(shí)候匆赃,將鏈表尾部的數(shù)據(jù)丟棄
2.3 LFU算法
LFU算法
是根據(jù)數(shù)據(jù)的歷史訪問頻率來淘汰數(shù)據(jù),因此今缚,LFU算法中的每個(gè)數(shù)據(jù)塊都有一個(gè)引用計(jì)數(shù)算柳,所有數(shù)據(jù)塊按照引用計(jì)數(shù)排序,具有相同引用計(jì)數(shù)的數(shù)據(jù)塊則按照時(shí)間排序姓言。在緩存中瞬项,它的設(shè)計(jì)原則是:如果數(shù)據(jù)被訪問多次,那么將來它的訪問頻率也會(huì)更高
何荚。
- 新加入數(shù)據(jù)插入到隊(duì)列尾部(引用計(jì)數(shù)為1囱淋;
- 隊(duì)列中的數(shù)據(jù)被訪問后,引用計(jì)數(shù)增加餐塘,隊(duì)列重新排序妥衣;
- 當(dāng)需要淘汰數(shù)據(jù)時(shí),將已經(jīng)排序的列表最后的數(shù)據(jù)塊刪除戒傻。
3税手、如何使用鏈表實(shí)現(xiàn)緩存淘汰,有什么特點(diǎn)需纳,如何優(yōu)化芦倒?
在上面的文章中我們理解了緩存的概念
及淘汰策略
,其中LRU算法
是筆試/面試中考察比較頻繁的不翩,我秋招的時(shí)候兵扬,很多公司都讓我手寫了這個(gè)算法,為了避免大家采坑口蝠,下面器钟,我們就手寫一個(gè)LRU緩存淘汰算法。
我們都知道鏈表的形式不止一種亚皂,我們應(yīng)該選擇哪一種呢俱箱?
思考三分鐘........
好了,公布答案灭必!
事實(shí)上狞谱,鏈表按照不同的連接結(jié)構(gòu)可以劃分為單鏈表
、循環(huán)鏈表
和雙向鏈表
禁漓。
- 單鏈表
- 每個(gè)節(jié)點(diǎn)只包含一個(gè)指針跟衅,即后繼指針。
- 單鏈表有兩個(gè)特殊的節(jié)點(diǎn)播歼,即首節(jié)點(diǎn)和尾節(jié)點(diǎn)伶跷,用首節(jié)點(diǎn)地址表示整條鏈表掰读,尾節(jié)點(diǎn)的后繼指針指向空地址null。
- 性能特點(diǎn):插入和刪除節(jié)點(diǎn)的時(shí)間復(fù)雜度為O(1)叭莫,查找的時(shí)間復(fù)雜度為O(n)蹈集。
- 循環(huán)鏈表
- 除了尾節(jié)點(diǎn)的后繼指針指向首節(jié)點(diǎn)的地址外均與單鏈表一致。
- 適用于存儲(chǔ)有循環(huán)特點(diǎn)的數(shù)據(jù)雇初,比如約瑟夫問題拢肆。
- 雙向鏈表
節(jié)點(diǎn)除了存儲(chǔ)數(shù)據(jù)外,還有兩個(gè)指針分別指向前一個(gè)節(jié)點(diǎn)地址(前驅(qū)指針prev)和下一個(gè)節(jié)點(diǎn)地址(后繼指針next)
首節(jié)點(diǎn)的前驅(qū)指針prev和尾節(jié)點(diǎn)的后繼指針均指向空地址靖诗。
雙向鏈表相較于單鏈表的一大優(yōu)勢在于:找到前驅(qū)節(jié)點(diǎn)的時(shí)間復(fù)雜度為O(1)郭怪,而單鏈表只能從頭節(jié)點(diǎn)慢慢往下找,所以仍然是O(n).而且刊橘,對(duì)于插入和刪除也是有優(yōu)化的鄙才。
我們可能會(huì)有問題:單鏈表的插入刪除不是O(1)嗎?
是的促绵,但是一般情況下攒庵,我們想要進(jìn)行插入刪除操作,很多時(shí)候還是得先進(jìn)行查找绞愚,再插入或者刪除叙甸,可見其實(shí)是先O(n),再O(1)。
不熟悉鏈表解題的同學(xué)可以先看看我的上一篇算法解析文章刷了LeetCode鏈表專題位衩,我發(fā)現(xiàn)了一個(gè)秘密裆蒸。
因?yàn)槲覀冃枰獎(jiǎng)h除操作,刪除一個(gè)節(jié)點(diǎn)不僅要得到該節(jié)點(diǎn)本身的指針糖驴,也需要操作其它前驅(qū)節(jié)點(diǎn)的指針僚祷,而雙向鏈表能夠直接找到前驅(qū),保證了操作時(shí)間復(fù)雜度為O(1)贮缕,因此使用雙向鏈表作為實(shí)現(xiàn)LRU緩存淘汰算法的結(jié)構(gòu)會(huì)更高效辙谜。
算法思路
維護(hù)一個(gè)雙向鏈表,保存所有緩存的值感昼,并且最老的值放在鏈表最后面装哆。
- 當(dāng)訪問的值在鏈表中時(shí): 將找到鏈表中值將其刪除,并重新在鏈表頭添加該值(保證鏈表中 數(shù)值的順序是從新到舊)
- 當(dāng)訪問的值不在鏈表中時(shí): 當(dāng)鏈表已滿:刪除鏈表最后一個(gè)值定嗓,將要添加的值放在鏈表頭 當(dāng)鏈表未滿:直接在鏈表頭添加
3.1 LRU緩存淘汰算法
極客時(shí)間王爭的《數(shù)據(jù)結(jié)構(gòu)與算法之美》給出了一個(gè)使用有序單鏈表實(shí)現(xiàn)LRU緩存淘汰算法蜕琴,代碼如下:
public class LRUBaseLinkedList<T> {
/**
* 默認(rèn)鏈表容量
*/
private final static Integer DEFAULT_CAPACITY = 10;
/**
* 頭結(jié)點(diǎn)
*/
private SNode<T> headNode;
/**
* 鏈表長度
*/
private Integer length;
/**
* 鏈表容量
*/
private Integer capacity;
public LRUBaseLinkedList() {
this.headNode = new SNode<>();
this.capacity = DEFAULT_CAPACITY;
this.length = 0;
}
public LRUBaseLinkedList(Integer capacity) {
this.headNode = new SNode<>();
this.capacity = capacity;
this.length = 0;
}
public void add(T data) {
SNode preNode = findPreNode(data);
// 鏈表中存在,刪除原數(shù)據(jù)宵溅,再插入到鏈表的頭部
if (preNode != null) {
deleteElemOptim(preNode);
intsertElemAtBegin(data);
} else {
if (length >= this.capacity) {
//刪除尾結(jié)點(diǎn)
deleteElemAtEnd();
}
intsertElemAtBegin(data);
}
}
/**
* 刪除preNode結(jié)點(diǎn)下一個(gè)元素
*
* @param preNode
*/
private void deleteElemOptim(SNode preNode) {
SNode temp = preNode.getNext();
preNode.setNext(temp.getNext());
temp = null;
length--;
}
/**
* 鏈表頭部插入節(jié)點(diǎn)
*
* @param data
*/
private void intsertElemAtBegin(T data) {
SNode next = headNode.getNext();
headNode.setNext(new SNode(data, next));
length++;
}
/**
* 獲取查找到元素的前一個(gè)結(jié)點(diǎn)
*
* @param data
* @return
*/
private SNode findPreNode(T data) {
SNode node = headNode;
while (node.getNext() != null) {
if (data.equals(node.getNext().getElement())) {
return node;
}
node = node.getNext();
}
return null;
}
/**
* 刪除尾結(jié)點(diǎn)
*/
private void deleteElemAtEnd() {
SNode ptr = headNode;
// 空鏈表直接返回
if (ptr.getNext() == null) {
return;
}
// 倒數(shù)第二個(gè)結(jié)點(diǎn)
while (ptr.getNext().getNext() != null) {
ptr = ptr.getNext();
}
SNode tmp = ptr.getNext();
ptr.setNext(null);
tmp = null;
length--;
}
private void printAll() {
SNode node = headNode.getNext();
while (node != null) {
System.out.print(node.getElement() + ",");
node = node.getNext();
}
System.out.println();
}
public class SNode<T> {
private T element;
private SNode next;
public SNode(T element) {
this.element = element;
}
public SNode(T element, SNode next) {
this.element = element;
this.next = next;
}
public SNode() {
this.next = null;
}
public T getElement() {
return element;
}
public void setElement(T element) {
this.element = element;
}
public SNode getNext() {
return next;
}
public void setNext(SNode next) {
this.next = next;
}
}
public static void main(String[] args) {
LRUBaseLinkedList list = new LRUBaseLinkedList();
Scanner sc = new Scanner(System.in);
while (true) {
list.add(sc.nextInt());
list.printAll();
}
}
}
這段代碼不管緩存有沒有滿凌简,都需要遍歷一遍鏈表,所以這種基于鏈表的實(shí)現(xiàn)思路恃逻,緩存訪問的時(shí)間復(fù)雜度為 O(n)雏搂。
3.2使用哈希表優(yōu)化LRU
事實(shí)上藕施,這個(gè)思路還可以繼續(xù)優(yōu)化,我們可以把單鏈表換成雙向鏈表
凸郑,并引入散列表
裳食。
- 雙向鏈表支持查找前驅(qū),保證操作的時(shí)間復(fù)雜度為O(1)
- 引入散列表記錄每個(gè)數(shù)據(jù)的位置芙沥,將緩存訪問的時(shí)間復(fù)雜度降到O(1)
哈希表查找較快胞谈,但是數(shù)據(jù)無固定的順序;鏈表倒是有順序之分憨愉。插入、刪除較快卿捎,但是查找較慢配紫。將它們結(jié)合,就可以形成一種新的數(shù)據(jù)結(jié)構(gòu)--哈希鏈表
(LinkedHashMap
)
力扣上146題-LRU緩存機(jī)制剛好可以拿來練手午阵,題圖如下:
題目:
運(yùn)用你所掌握的數(shù)據(jù)結(jié)構(gòu)躺孝,設(shè)計(jì)和實(shí)現(xiàn)一個(gè) LRU (最近最少使用) 緩存機(jī)制 。
- 實(shí)現(xiàn) LRUCache 類:
LRUCache(int capacity) 以正整數(shù)作為容量 capacity 初始化 LRU 緩存
int get(int key) 如果關(guān)鍵字 key 存在于緩存中底桂,則返回關(guān)鍵字的值植袍,否則返回 -1 。
void put(int key, int value) 如果關(guān)鍵字已經(jīng)存在籽懦,則變更其數(shù)據(jù)值于个;如果關(guān)鍵字不存在,則插入該組「關(guān)鍵字-值」暮顺。當(dāng)緩存容量達(dá)到上限時(shí)厅篓,它應(yīng)該在寫入新數(shù)據(jù)之前刪除最久未使用的數(shù)據(jù)值,從而為新的數(shù)據(jù)值留出空間捶码。
思路:
我們的思路就是哈希表+雙向鏈表
- 哈希表用于滿足題目時(shí)間復(fù)雜度O(1)的要求羽氮,雙向鏈表用于存儲(chǔ)順序
- 哈希表鍵值類型:<Integer, ListNode>,哈希表的鍵用于存儲(chǔ)輸入的key惫恼,哈希表的值用于存儲(chǔ)雙向鏈表的節(jié)點(diǎn)
- 雙向鏈表的節(jié)點(diǎn)中除了value外還需要包含key档押,因?yàn)樵趧h除最久未使用的數(shù)據(jù)時(shí),需要通過鏈表來定位hashmap中應(yīng)當(dāng)刪除的鍵值對(duì)
- 一些操作:雙向鏈表中祈纯,在后面的節(jié)點(diǎn)表示被最近訪問
- 新加入的節(jié)點(diǎn)放在鏈表末尾令宿,addNodeToLast(node)
- 若容量達(dá)到上限,去除最久未使用的數(shù)據(jù)盆繁,removeNode(head.next)
- 若數(shù)據(jù)新被訪問過掀淘,比如被get了或被put了新值,把該節(jié)點(diǎn)挪到鏈表末尾油昂,moveNodeToLast(node)
- 為了操作的方便革娄,在雙向鏈表頭和尾分別定義一個(gè)head和tail節(jié)點(diǎn)倾贰。
代碼
class LRUCache {
private int capacity;
private HashMap<Integer, ListNode> hashmap;
private ListNode head;
private ListNode tail;
private class ListNode{
int key;
int val;
ListNode prev;
ListNode next;
public ListNode(){
}
public ListNode(int key, int val){
this.key = key;
this.val = val;
}
}
public LRUCache(int capacity) {
this.capacity = capacity;
hashmap = new HashMap<>();
head = new ListNode();
tail = new ListNode();
head.next = tail;
tail.prev = head;
}
private void removeNode(ListNode node){
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void addNodeToLast(ListNode node){
node.prev = tail.prev;
node.prev.next = node;
node.next = tail;
tail.prev= node;
}
private void moveNodeToLast(ListNode node){
removeNode(node);
addNodeToLast(node);
}
public int get(int key) {
if(hashmap.containsKey(key)){
ListNode node = hashmap.get(key);
moveNodeToLast(node);
return node.val;
}else{
return -1;
}
}
public void put(int key, int value) {
if(hashmap.containsKey(key)){
ListNode node = hashmap.get(key);
node.val = value;
moveNodeToLast(node);
return;
}
if(hashmap.size() == capacity){
hashmap.remove(head.next.key);
removeNode(head.next);
}
ListNode node = new ListNode(key, value);
hashmap.put(key, node);
addNodeToLast(node);
}
}
巨人的肩膀
[1]數(shù)據(jù)結(jié)構(gòu)與算法之美-王爭
[2]力扣-LRU緩存機(jī)制(146題)
[3]https://blog.csdn.net/yangpl_tale/article/details/44998423
[4]https://leetcode-cn.com/problems/lru-cache/solution/146-lru-huan-cun-ji-zhi-ha-xi-biao-shuan-l3um/
本文由博客群發(fā)一文多發(fā)等運(yùn)營工具平臺(tái) OpenWrite 發(fā)布