如何设计LRU Cache算法
前言
- 相信有的伙伴在面試的過程中,或多或少的會(huì)被問到redis的內(nèi)存淘汰策略,可能大部分人都知道都有哪些對(duì)應(yīng)的策略,畢竟對(duì)于八股文的套路大家肯定早已銘記于心。但是當(dāng)面試官問你如何實(shí)現(xiàn)或者讓你去寫一個(gè)對(duì)應(yīng)策略的算法時(shí),可能就頓時(shí)一臉蒙蔽了:不對(duì)啊,套路不是這樣的啊!!如果單純的讓你直接寫對(duì)應(yīng)的算法還好,要是再更深入一點(diǎn)讓你說一下你的思考過程或者說如果讓你來設(shè)計(jì)你會(huì)怎么去做,這個(gè)可能就上升到了一個(gè)架構(gòu)的思維(如果你打算面試架構(gòu)死,不妨提前鍛煉一下這樣的思維),對(duì)于平時(shí)沒有這方面準(zhǔn)備的伙伴來講,那無疑就是當(dāng)頭一棒:與自己心儀的offer就要失之交臂了。
- 這篇文章我將從如何設(shè)計(jì)LRU Cache算法,跟大家一起來看一下整體的設(shè)計(jì)過程,包括我們應(yīng)該如何去思考,如何去解決,提前鍛煉一下自己初步的架構(gòu)設(shè)計(jì)能力。當(dāng)然在這里只是淺談一下我的思考過程,為大家拋磚引玉,歡迎在留言區(qū)分享你的想法。目前博主個(gè)人博客已經(jīng)搭建發(fā)布,后期相關(guān)文章也會(huì)發(fā)布在上面,大家有興趣可以去上面學(xué)習(xí),點(diǎn)擊即可前往文青樂園
LRU Cache基本概述
LRU 是什么
- 基本概述
LRU(Least Recently Used) ,即最近最少使用,它是一種緩存逐出策略 cache eviction policies。LRU 算法是假設(shè)最近最少使用的那些信息,將來被使用的概率也不大,所以在容量有限的情況下,就可以把這些不常用的信息清理掉。 - 使用場(chǎng)景
比如有熱點(diǎn)新聞時(shí),所有人都在搜索這個(gè)信息,那剛被一個(gè)人搜過的信息接下來被其他人搜索的概率也大,之前的新聞被搜索的概率就會(huì)小,所以我們把很久沒有用過的信息清理掉,也就是把 Least Recently Used 的信息清理掉。
我們舉個(gè)例子,假設(shè)內(nèi)存容量為 5,現(xiàn)在有 1-5 五個(gè)數(shù),存儲(chǔ)順序如下:
數(shù)據(jù)從左到右邊其使用最新程度逐漸增加,比如1就是最早使用的數(shù)據(jù),5就是最近使用的數(shù)據(jù),我們現(xiàn)在想加入一個(gè)新的數(shù)6,可是容量已經(jīng)滿了,所以需要清理其中的某一個(gè),那按照什么規(guī)則清理呢?目前主要有如下緩存逐出策略:
- LFU (Least Frequently Used) :這個(gè)是計(jì)算每個(gè)信息的訪問次數(shù),清除掉訪問次數(shù)最少的那個(gè);如果訪問次數(shù)一樣,就清除掉好久沒用過的那個(gè)。這個(gè)算法其實(shí)很高效,但是耗資源,所以一般不用。
- LRU (Least Recently Used) :這是目前最常用了,把很長時(shí)間沒有用過的清除掉,那它的隱含假設(shè)就是,認(rèn)為最近用到的信息以后用到的概率會(huì)更大。
那我們這個(gè)例子中就是把最老的 1 清理掉,變成:
如此不斷地進(jìn)行迭代。
Cache 是什么
- 基本概述
- 簡單理解就是:把一些可以重復(fù)使用的信息存起來,以便之后需要時(shí)可以快速拿到。至于它存在哪里就不一定了,最常見的是存在內(nèi)存里,也就是 memory cache,但也可以不存在內(nèi)存里。
- 使用場(chǎng)景
- Spring 中有 @Cacheable 等支持 Cache 的一系列注解,使用它大大減少了 call 某服務(wù)器的次數(shù),解決了一個(gè)性能上的問題。
- 在進(jìn)行數(shù)據(jù)庫查詢的時(shí)候,不想每次請(qǐng)求都去 call 數(shù)據(jù)庫,那我們就在內(nèi)存里存一些常用的數(shù)據(jù),來提高訪問性能。這種設(shè)計(jì)思想其實(shí)是遵循了著名的“二八定律”。在讀寫數(shù)據(jù)庫時(shí),每次的 I/O 過程消耗很大,但其實(shí) 80% 的 request 都是在用那 20% 的數(shù)據(jù),所以把這 20% 的數(shù)據(jù)放在內(nèi)存里,就能夠極大的提高整體的效率。
總之,Cache 的目的是存一些可以復(fù)用的信息,方便將來的請(qǐng)求快速獲得。
那我們知道了 LRU,了解了 Cache,合起來就是 LRU Cache 了:當(dāng) Cache 儲(chǔ)存滿了的時(shí)候,使用 LRU 算法把老數(shù)據(jù)清理出去。接下來我們看看具體的實(shí)現(xiàn)思路。
LRU Cache設(shè)計(jì)思路詳解
其實(shí)很多伙伴都知道設(shè)計(jì)這個(gè)算法需要使用 HashMap + Doubly Linked List,或者說用 Java 中現(xiàn)成的 LinkedHashMap,但是,你有思考過下面的問題么?
- 為什么是使用上面的數(shù)據(jù)結(jié)構(gòu)?
- 你是怎么想到用這兩個(gè)數(shù)據(jù)結(jié)構(gòu)的?
如果真的問到這個(gè),面試的時(shí)候不講清楚這個(gè),不說清楚思考過程,代碼寫對(duì)了也沒用。其實(shí)這個(gè)和在工作中的設(shè)計(jì)思路類似,沒有人會(huì)告訴我們要用什么數(shù)據(jù)結(jié)構(gòu),一般的思路是:
接下來我們就從上面的思路出發(fā)進(jìn)行設(shè)計(jì)。
分析 Operations
對(duì)于這個(gè) LRU Cache 需要有哪些操作呢?我們來分析一下:
找尋數(shù)據(jù)結(jié)構(gòu)
第一個(gè)操作很明顯,我們需要一個(gè)能夠快速查找的數(shù)據(jù)結(jié)構(gòu),非 HashMap 莫屬,還不了解 HashMap 原理和設(shè)計(jì)規(guī)則的大家可以去百度查查,這里不做贅述??墒前l(fā)現(xiàn)后面的操作 HashMap 就不頂用了。這里我們先來數(shù)一遍基本的數(shù)據(jù)結(jié)構(gòu):Array, LinkedList, Stack, Queue, Tree, BST, Heap, HashMap。在做這種數(shù)據(jù)結(jié)構(gòu)的題目時(shí),就這樣把所有的數(shù)據(jù)結(jié)構(gòu)列出來,一個(gè)個(gè)來分析,有時(shí)候不是因?yàn)檫@個(gè)數(shù)據(jù)結(jié)構(gòu)不行,而是因?yàn)槠渌臄?shù)據(jù)結(jié)構(gòu)更好。我們做出如下的分析:
- Array, Stack, Queue :這三種本質(zhì)上都是 Array 實(shí)現(xiàn)的(當(dāng)然 Stack, Queue 也可以用 LinkedList 來實(shí)現(xiàn)。。),一會(huì)插入新的,一會(huì)刪除老的,一會(huì)調(diào)整下順序,array 不是不能做,時(shí)間復(fù)雜度 O(n) 啊,不可行;
- BST :同理,時(shí)間復(fù)雜度是 O(logn);
- Heap: 即便可以,也是 O(logn);
- LinkedList:有點(diǎn)可以哦,按照從老到新的順序,排排站,刪除、插入、移動(dòng),都可以是 O(1) 。但是刪除時(shí)我還需要一個(gè) previous pointer 才能刪掉,所以我需要一個(gè) Doubly LinkedList。
最后我們數(shù)據(jù)結(jié)構(gòu)選定為HashMap + Double LinkedList。
定義數(shù)據(jù)結(jié)構(gòu)的內(nèi)容
選好了數(shù)據(jù)結(jié)構(gòu)之后,還需要定義清楚每個(gè)數(shù)據(jù)結(jié)構(gòu)具體存儲(chǔ)的是是什么,HashMap + Doubly LinkedList兩個(gè)數(shù)據(jù)結(jié)構(gòu)是如何聯(lián)系的,這才是核心問題。我們先想個(gè)場(chǎng)景,在搜索引擎里,你輸入問題 Questions,谷歌給你返回答案 Answer。那我們就先假設(shè)這兩個(gè)數(shù)據(jù)結(jié)構(gòu)存的都是 <Q, A>,現(xiàn)在我們的 HashMap 和 LinkedList 長這樣:
然后我們進(jìn)行以下操作:
- 直接從 HashMap 里讀取 Answer 即可,O(1),沒問題;
- 新加入一組 Q&A,兩個(gè)數(shù)據(jù)結(jié)構(gòu)都得加,那先要判斷一下當(dāng)前的緩存里有沒有這個(gè) Q,那我們用 HashMap 判斷,如果沒有這個(gè) Q,加進(jìn)來,都沒問題;
- 如果已經(jīng)有這個(gè) Q,HashMap 這里要更新一下 Answer,然后我們還要把 LinkedList 的那個(gè) node 移動(dòng)到最后或者最前,因?yàn)樗兂闪俗钚卤皇褂玫牧寺铩?/li>
可是,怎么找 LinkedList 的這個(gè) node 呢?一個(gè)個(gè)遍歷去找并不是我們想要的,因?yàn)橐?O(n) 的時(shí)間嘛,我們想用 O(1) 的時(shí)間操作。那也就是說這樣記錄是不行的,還需要記錄 LinkedList 中每個(gè) ListNode 的位置,這就是設(shè)計(jì)的關(guān)鍵所在。怎么設(shè)計(jì)呢?自然是在 HashMap 里記錄 ListNode 的位置這個(gè)信息了,也就是存一下每個(gè) ListNode 的 reference。想想其實(shí)也是,HashMap 里沒有必要記錄 Answer,Answer 只需要在 LinkedList 里記錄就可以了。之后我們更新、移動(dòng)每個(gè) node 時(shí),它的 reference 也不需要變,所以 HashMap 也不用改動(dòng),動(dòng)的只是 previous, next pointer。那再一想,其實(shí) LinkedList 里也沒必要記錄 Question,反正 HashMap 里有。這兩個(gè)數(shù)據(jù)結(jié)構(gòu)是相互配合來用的,不需要記錄一樣的信息。更新后的數(shù)據(jù)結(jié)構(gòu)如下:
這樣,我們才分析出來用什么數(shù)據(jù)結(jié)構(gòu),每個(gè)數(shù)據(jù)結(jié)構(gòu)里存的是什么,物理意義是什么。
那我們?cè)儆脠D來總結(jié)一下:
畫圖的時(shí)候邊講邊寫,每一步都從 high level 到 detail 再到代碼,把代碼模塊化。
- 比如“Welcome”是要把這個(gè)新的信息加入到 HashMap 和 LinkedList 里,那我會(huì)用一個(gè)單獨(dú)的 add() method 來寫這塊內(nèi)容,那在下面的代碼里我取名為 appendHead(),更精準(zhǔn);
- “踢走老的”這里我也是用一個(gè)單獨(dú)的 remove() method 來寫的。
有了上面的分析,接下里直接給出設(shè)計(jì)的代碼。
LRU Cache算法實(shí)現(xiàn)
class LRUCache {// HashMap: <key = Question, value = ListNode>// LinkedList: <Answer>public static class Node {int key;int val;Node next;Node prev;public Node(int key, int val) {this.key = key;this.val = val;}}Map<Integer, Node> map = new HashMap<>();private Node head;private Node tail;private int cap;public LRUCache(int capacity) {cap = capacity;}public int get(int key) {Node node = map.get(key);if(node == null) {return -1;} else {int res = node.val;remove(node);appendHead(node);return res;}}public void put(int key, int value) {// 先 check 有沒有這個(gè) keyNode node = map.get(key);if(node != null) {node.val = value;// 把這個(gè)node放在最前面去remove(node);appendHead(node);} else {node = new Node(key, value);if(map.size() < cap) {appendHead(node);map.put(key, node);} else {// 踢走老的map.remove(tail.key);remove(tail);appendHead(node);map.put(key, node);}}}private void appendHead(Node node) {if(head == null) {head = tail = node;} else {node.next = head;head.prev = node;head = node;}}private void remove(Node node) {if(head == tail) {head = tail = null;} else {if(head == node) {head = head.next;node.next = null;} else if (tail == node) {tail = tail.prev;tail.next = null;node.prev = null;} else {node.prev.next = node.next;node.next.prev = node.prev;node.prev = null;node.next = null;}}}}/** * Your LRUCache object will be instantiated and called as such: * LRUCache obj = new LRUCache(capacity); * int param_1 = obj.get(key); * obj.put(key,value); */總結(jié)
以上是生活随笔為你收集整理的如何设计LRU Cache算法的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: JVM专题(2)-类加载器子系统
- 下一篇: 你知道Integer和int的区别吗