層層拆解,帶你手寫 LFU 算法

讀完本文,你不僅學(xué)會了算法套路,還可以順便去 LeetCode 上拿下如下題目:

460.LFU緩存機(jī)制

-----------

上篇文章 帶你手寫LRU算法 寫了 LRU 緩存淘汰算法的實(shí)現(xiàn)方法,本文來寫另一個(gè)著名的緩存淘汰算法:LFU 算法。

LRU 算法的淘汰策略是 Least Recently Used,也就是每次淘汰那些最久沒被使用的數(shù)據(jù);而 LFU 算法的淘汰策略是 Least Frequently Used,也就是每次淘汰那些使用次數(shù)最少的數(shù)據(jù)。

LRU 算法的核心數(shù)據(jù)結(jié)構(gòu)是使用哈希鏈表 LinkedHashMap,首先借助鏈表的有序性使得鏈表元素維持插入順序,同時(shí)借助哈希映射的快速訪問能力使得我們可以在 O(1) 時(shí)間訪問鏈表的任意元素。

從實(shí)現(xiàn)難度上來說,LFU 算法的難度大于 LRU 算法,因?yàn)?LRU 算法相當(dāng)于把數(shù)據(jù)按照時(shí)間排序,這個(gè)需求借助鏈表很自然就能實(shí)現(xiàn),你一直從鏈表頭部加入元素的話,越靠近頭部的元素就是新的數(shù)據(jù),越靠近尾部的元素就是舊的數(shù)據(jù),我們進(jìn)行緩存淘汰的時(shí)候只要簡單地將尾部的元素淘汰掉就行了。

PS:我認(rèn)真寫了 100 多篇原創(chuàng),手把手刷 200 道力扣題目,全部發(fā)布在 labuladong的算法小抄,持續(xù)更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

而 LFU 算法相當(dāng)于是把數(shù)據(jù)按照訪問頻次進(jìn)行排序,這個(gè)需求恐怕沒有那么簡單,而且還有一種情況,如果多個(gè)數(shù)據(jù)擁有相同的訪問頻次,我們就得刪除最早插入的那個(gè)數(shù)據(jù)。也就是說 LFU 算法是淘汰訪問頻次最低的數(shù)據(jù),如果訪問頻次最低的數(shù)據(jù)有多條,需要淘汰最舊的數(shù)據(jù)。

所以說 LFU 算法是要復(fù)雜很多的,而且經(jīng)常出現(xiàn)在面試中,因?yàn)?LFU 緩存淘汰算法在工程實(shí)踐中經(jīng)常使用,也有可能是應(yīng)該 LRU 算法太簡單了。不過話說回來,這種著名的算法的套路都是固定的,關(guān)鍵是由于邏輯較復(fù)雜,不容易寫出漂亮且沒有 bug 的代碼

那么本文 labuladong 就帶你拆解 LFU 算法,自頂向下,逐步求精,就是解決復(fù)雜問題的不二法門。

一、算法描述

要求你寫一個(gè)類,接受一個(gè) capacity 參數(shù),實(shí)現(xiàn) getput 方法:

class LFUCache {
    // 構(gòu)造容量為 capacity 的緩存
    public LFUCache(int capacity) {}
    // 在緩存中查詢 key
    public int get(int key) {}
    // 將 key 和 val 存入緩存
    public void put(int key, int val) {}
}

get(key) 方法會去緩存中查詢鍵 key,如果 key 存在,則返回 key 對應(yīng)的 val,否則返回 -1。

put(key, value) 方法插入或修改緩存。如果 key 已存在,則將它對應(yīng)的值改為 val;如果 key 不存在,則插入鍵值對 (key, val)

當(dāng)緩存達(dá)到容量 capacity 時(shí),則應(yīng)該在插入新的鍵值對之前,刪除使用頻次(后文用 freq 表示)最低的鍵值對。如果 freq 最低的鍵值對有多個(gè),則刪除其中最舊的那個(gè)。

// 構(gòu)造一個(gè)容量為 2 的 LFU 緩存
LFUCache cache = new LFUCache(2);

// 插入兩對 (key, val),對應(yīng)的 freq 為 1
cache.put(1, 10);
cache.put(2, 20);

// 查詢 key 為 1 對應(yīng)的 val
// 返回 10,同時(shí)鍵 1 對應(yīng)的 freq 變?yōu)?2
cache.get(1);

// 容量已滿,淘汰 freq 最小的鍵 2
// 插入鍵值對 (3, 30),對應(yīng)的 freq 為 1
cache.put(3, 30);   

// 鍵 2 已經(jīng)被淘汰刪除,返回 -1
cache.get(2);       

二、思路分析

一定先從最簡單的開始,根據(jù) LFU 算法的邏輯,我們先列舉出算法執(zhí)行過程中的幾個(gè)顯而易見的事實(shí):

1、調(diào)用 get(key) 方法時(shí),要返回該 key 對應(yīng)的 val。

2、只要用 get 或者 put 方法訪問一次某個(gè) key,該 keyfreq 就要加一。

3、如果在容量滿了的時(shí)候進(jìn)行插入,則需要將 freq 最小的 key 刪除,如果最小的 freq 對應(yīng)多個(gè) key,則刪除其中最舊的那一個(gè)。

好的,我們希望能夠在 O(1) 的時(shí)間內(nèi)解決這些需求,可以使用基本數(shù)據(jù)結(jié)構(gòu)來逐個(gè)擊破:

1、使用一個(gè) HashMap 存儲 keyval 的映射,就可以快速計(jì)算 get(key)。

HashMap<Integer, Integer> keyToVal;

2、使用一個(gè) HashMap 存儲 keyfreq 的映射,就可以快速操作 key 對應(yīng)的 freq

HashMap<Integer, Integer> keyToFreq;

3、這個(gè)需求應(yīng)該是 LFU 算法的核心,所以我們分開說。

3.1、首先,肯定是需要 freqkey 的映射,用來找到 freq 最小的 key

3.2、將 freq 最小的 key 刪除,那你就得快速得到當(dāng)前所有 key 最小的 freq 是多少。想要時(shí)間復(fù)雜度 O(1) 的話,肯定不能遍歷一遍去找,那就用一個(gè)變量 minFreq 來記錄當(dāng)前最小的 freq 吧。

3.3、可能有多個(gè) key 擁有相同的 freq,所以 freqkey 是一對多的關(guān)系,即一個(gè) freq 對應(yīng)一個(gè) key 的列表。

3.4、希望 freq 對應(yīng)的 key 的列表是存在時(shí)序的,便于快速查找并刪除最舊的 key。

3.5、希望能夠快速刪除 key 列表中的任何一個(gè) key,因?yàn)槿绻l次為 freq 的某個(gè) key 被訪問,那么它的頻次就會變成 freq+1,就應(yīng)該從 freq 對應(yīng)的 key 列表中刪除,加到 freq+1 對應(yīng)的 key 的列表中。

HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;
int minFreq = 0;

介紹一下這個(gè) LinkedHashSet,它滿足我們 3.3,3.4,3.5 這幾個(gè)要求。你會發(fā)現(xiàn)普通的鏈表 LinkedList 能夠滿足 3.3,3.4 這兩個(gè)要求,但是由于普通鏈表不能快速訪問鏈表中的某一個(gè)節(jié)點(diǎn),所以無法滿足 3.5 的要求。

LinkedHashSet 顧名思義,是鏈表和哈希集合的結(jié)合體。鏈表不能快速訪問鏈表節(jié)點(diǎn),但是插入元素具有時(shí)序;哈希集合中的元素?zé)o序,但是可以對元素進(jìn)行快速的訪問和刪除。

PS:我認(rèn)真寫了 100 多篇原創(chuàng),手把手刷 200 道力扣題目,全部發(fā)布在 labuladong的算法小抄,持續(xù)更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

那么,它倆結(jié)合起來就兼具了哈希集合和鏈表的特性,既可以在 O(1) 時(shí)間內(nèi)訪問或刪除其中的元素,又可以保持插入的時(shí)序,高效實(shí)現(xiàn) 3.5 這個(gè)需求。

綜上,我們可以寫出 LFU 算法的基本數(shù)據(jù)結(jié)構(gòu):

class LFUCache {
    // key 到 val 的映射,我們后文稱為 KV 表
    HashMap<Integer, Integer> keyToVal;
    // key 到 freq 的映射,我們后文稱為 KF 表
    HashMap<Integer, Integer> keyToFreq;
    // freq 到 key 列表的映射,我們后文稱為 FK 表
    HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;
    // 記錄最小的頻次
    int minFreq;
    // 記錄 LFU 緩存的最大容量
    int cap;

    public LFUCache(int capacity) {
        keyToVal = new HashMap<>();
        keyToFreq = new HashMap<>();
        freqToKeys = new HashMap<>();
        this.cap = capacity;
        this.minFreq = 0;
    }

    public int get(int key) {}

    public void put(int key, int val) {}

}

三、代碼框架

LFU 的邏輯不難理解,但是寫代碼實(shí)現(xiàn)并不容易,因?yàn)槟憧次覀円S護(hù) KV 表,KF 表,FK 表三個(gè)映射,特別容易出錯(cuò)。對于這種情況,labuladong 教你三個(gè)技巧:

1、不要企圖上來就實(shí)現(xiàn)算法的所有細(xì)節(jié),而應(yīng)該自頂向下,逐步求精,先寫清楚主函數(shù)的邏輯框架,然后再一步步實(shí)現(xiàn)細(xì)節(jié)。

2、搞清楚映射關(guān)系,如果我們更新了某個(gè) key 對應(yīng)的 freq,那么就要同步修改 KF 表和 FK 表,這樣才不會出問題。

3、畫圖,畫圖,畫圖,重要的話說三遍,把邏輯比較復(fù)雜的部分用流程圖畫出來,然后根據(jù)圖來寫代碼,可以極大減少出錯(cuò)的概率。

下面我們先來實(shí)現(xiàn) get(key) 方法,邏輯很簡單,返回 key 對應(yīng)的 val,然后增加 key 對應(yīng)的 freq

public int get(int key) {
    if (!keyToVal.containsKey(key)) {
        return -1;
    }
    // 增加 key 對應(yīng)的 freq
    increaseFreq(key);
    return keyToVal.get(key);
}

增加 key 對應(yīng)的 freq 是 LFU 算法的核心,所以我們干脆直接抽象成一個(gè)函數(shù) increaseFreq,這樣 get 方法看起來就簡潔清晰了對吧。

下面來實(shí)現(xiàn) put(key, val) 方法,邏輯略微復(fù)雜,我們直接畫個(gè)圖來看:

image

這圖就是隨手畫的,不是什么正規(guī)的程序流程圖,但是算法邏輯一目了然,看圖可以直接寫出 put 方法的邏輯:

public void put(int key, int val) {
    if (this.cap <= 0) return;

    /* 若 key 已存在,修改對應(yīng)的 val 即可 */
    if (keyToVal.containsKey(key)) {
        keyToVal.put(key, val);
        // key 對應(yīng)的 freq 加一
        increaseFreq(key);
        return;
    }

    /* key 不存在,需要插入 */
    /* 容量已滿的話需要淘汰一個(gè) freq 最小的 key */
    if (this.cap <= keyToVal.size()) {
        removeMinFreqKey();
    }

    /* 插入 key 和 val,對應(yīng)的 freq 為 1 */
    // 插入 KV 表
    keyToVal.put(key, val);
    // 插入 KF 表
    keyToFreq.put(key, 1);
    // 插入 FK 表
    freqToKeys.putIfAbsent(1, new LinkedHashSet<>());
    freqToKeys.get(1).add(key);
    // 插入新 key 后最小的 freq 肯定是 1
    this.minFreq = 1;
}

increaseFreqremoveMinFreqKey 方法是 LFU 算法的核心,我們下面來看看怎么借助 KV 表,KF 表,FK 表這三個(gè)映射巧妙完成這兩個(gè)函數(shù)。

四、LFU 核心邏輯

首先來實(shí)現(xiàn) removeMinFreqKey 函數(shù):

private void removeMinFreqKey() {
    // freq 最小的 key 列表
    LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq);
    // 其中最先被插入的那個(gè) key 就是該被淘汰的 key
    int deletedKey = keyList.iterator().next();
    /* 更新 FK 表 */
    keyList.remove(deletedKey);
    if (keyList.isEmpty()) {
        freqToKeys.remove(this.minFreq);
        // 問:這里需要更新 minFreq 的值嗎?
    }
    /* 更新 KV 表 */
    keyToVal.remove(deletedKey);
    /* 更新 KF 表 */
    keyToFreq.remove(deletedKey);
}

刪除某個(gè)鍵 key 肯定是要同時(shí)修改三個(gè)映射表的,借助 minFreq 參數(shù)可以從 FK 表中找到 freq 最小的 keyList,根據(jù)時(shí)序,其中第一個(gè)元素就是要被淘汰的 deletedKey,操作三個(gè)映射表刪除這個(gè) key 即可。

但是有個(gè)細(xì)節(jié)問題,如果 keyList 中只有一個(gè)元素,那么刪除之后 minFreq 對應(yīng)的 key 列表就為空了,也就是 minFreq 變量需要被更新。如何計(jì)算當(dāng)前的 minFreq 是多少呢?

PS:我認(rèn)真寫了 100 多篇原創(chuàng),手把手刷 200 道力扣題目,全部發(fā)布在 labuladong的算法小抄,持續(xù)更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

實(shí)際上沒辦法快速計(jì)算 minFreq,只能線性遍歷 FK 表或者 KF 表來計(jì)算,這樣肯定不能保證 O(1) 的時(shí)間復(fù)雜度。

但是,其實(shí)這里沒必要更新 minFreq 變量,因?yàn)槟阆胂?removeMinFreqKey 這個(gè)函數(shù)是在什么時(shí)候調(diào)用?在 put 方法中插入新 key 時(shí)可能調(diào)用。而你回頭看 put 的代碼,插入新 key 時(shí)一定會把 minFreq 更新成 1,所以說即便這里 minFreq 變了,我們也不需要管它。

下面來實(shí)現(xiàn) increaseFreq 函數(shù):

private void increaseFreq(int key) {
    int freq = keyToFreq.get(key);
    /* 更新 KF 表 */
    keyToFreq.put(key, freq + 1);
    /* 更新 FK 表 */
    // 將 key 從 freq 對應(yīng)的列表中刪除
    freqToKeys.get(freq).remove(key);
    // 將 key 加入 freq + 1 對應(yīng)的列表中
    freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
    freqToKeys.get(freq + 1).add(key);
    // 如果 freq 對應(yīng)的列表空了,移除這個(gè) freq
    if (freqToKeys.get(freq).isEmpty()) {
        freqToKeys.remove(freq);
        // 如果這個(gè) freq 恰好是 minFreq,更新 minFreq
        if (freq == this.minFreq) {
            this.minFreq++;
        }
    }
}

更新某個(gè) keyfreq 肯定會涉及 FK 表和 KF 表,所以我們分別更新這兩個(gè)表就行了。

和之前類似,當(dāng) FK 表中 freq 對應(yīng)的列表被刪空后,需要?jiǎng)h除 FK 表中 freq 這個(gè)映射。如果這個(gè) freq 恰好是 minFreq,說明 minFreq 變量需要更新。

能不能快速找到當(dāng)前的 minFreq 呢?這里是可以的,因?yàn)槲覀儎偛虐?keyfreq 加了 1 嘛,所以 minFreq 也加 1 就行了。

至此,經(jīng)過層層拆解,LFU 算法就完成了。

_____________

我的 在線電子書 有 100 篇原創(chuàng)文章,手把手帶刷 200 道力扣題目,建議收藏!對應(yīng)的 GitHub 算法倉庫 已經(jīng)獲得了 70k star,歡迎標(biāo)星!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容