【LFU缓存机制】+双哈希表解法+排序解法

Tag

【LFU缓存】【哈希表】【设计数据结构】【2023-09-25】


题目来源

460. LFU 缓存


题目解读

为 LFU 缓存算法设计并实现数据结构。

LRU 缓存算法是一种页面缓存置换算法,有两个基本的操作:getput

  • get:通过 key 获得相应的页面缓存。如果获取的 key 存在于缓存中,则返回键的值,否则返回 -1
  • put:向缓存中更新或加入缓存页面。如果 key 已经存在,则变更其对应的缓存值;如果键不存在,就插入新的缓存页面的键值对。 当缓存达到了容量 capacity 时,应该在插入新的缓存页面之前删除最近不经常使用的项。这个最近不经常使用的项指的是使用频次最小,如果遇到使用频次相同的键,则去除最久未使用的键(最近一次使用的时间小的)。

本题要求实现 LFUCache 类:

  • LFUCache(int capacity):用数据结构的容量 capacity 初始化对象;
  • int get(int key):通过 key 获得相应的页面缓存;
  • void put(int key, int value):向缓存中更新或加入缓存页面。

函数 getput 必须以 O ( 1 ) O(1) O(1) 的平均时间复杂度运行。


解题思路

方法一:排序解法

基础数据结构的设计与选择

get 比较容易实现,使用一个哈希表存放键,值为对应的缓存数据结构。缓存数据结构如何定义呢?因为在插入新的键时可能需要移除最近不经常使用的键,因此缓存的数据结构需要包括:

  • cnt:统计缓存的使用次数;
  • time:缓存的最近一次使用时间;

因为还会牵涉到缓存数据内容的更新,所以需要表示缓存数据内容的 value。并且需要一个 key 变量,用来表示当前缓存内容的键。
为了统计最近不经常使用的键,需要对 < 运算符进行重载,于是缓存数据结构为:

struct Node {
    int cnt, time, key, value;

    Node(int _cnt, int _time, int _key, int _value):cnt(_cnt), time(_time), key(_key), value(_value){}
    
    bool operator < (const Node& rhs) const {
        return cnt == rhs.cnt ? time < rhs.time : cnt < rhs.cnt;
    }
};

于是使用的基本数据包括:

  • 哈希表:键为 key,值为 Node (自己设计的数据结构),我们声明为 key_table
  • 集合:保存数据 Node,方便更新缓存内容的使用频次、时间戳以及具体内容,我们声明为 S

LFUCache 实现

LFUCache(int _capacity) {
	capacity = _capacity;  // 缓存容量
	time = 0;                    // 时间戳
	key_table.clear();
	S.clear();
}

get 实现

如果 capacity = 0,表示现在没有任何缓存数据,直接返回 -1

否则,再判断哈希表中是否有 key,如果没有,直接返回 -1。如果有,则表示找到了 key 对应的缓存,我们首先要将删除缓存,更新该缓存的使用频次和最近一次使用时间,然后将更新后的缓存存入哈希表和集合。

put 实现

如果,此时 capacity = 0,不需要进行任何操作,因为最大缓存容量为空。

否则,表示最大缓存容量非空,可以继续更新或者加入新的缓存:

  • 如果哈希表中没有 key,则需要向缓存中加入新的缓存数据,但是需要先判断当前的缓存容量是否已经达到最大的缓存容量:

    • 如果达到了,需要删除最近不经常使用的缓存即删除集合中的第一个缓存,并更新哈希表;
    • 如果未达到,则新建缓存并加入哈希表和集合。
  • 如果哈希表中有 key,则执行修改缓存内容的操作:

    • 获得缓存内容,并删除集合中的该缓存;
    • 更新缓存的使用频次、时间戳和值;
    • 使用新的缓存更新哈希表并加入到集合中。

实现代码

struct Node {
    int cnt, time, key, value;

    Node(int _cnt, int _time, int _key, int _value):cnt(_cnt), time(_time), key(_key), value(_value){}
    
    bool operator < (const Node& rhs) const {
        return cnt == rhs.cnt ? time < rhs.time : cnt < rhs.cnt;
    }
};
class LFUCache {
    // 缓存容量,时间戳
    int capacity, time;
    unordered_map<int, Node> key_table;
    set<Node> S;
public:
    LFUCache(int _capacity) {
        capacity = _capacity;
        time = 0;
        key_table.clear();
        S.clear();
    }
    
    int get(int key) {
        if (capacity == 0) return -1;
        auto it = key_table.find(key);
        // 如果哈希表中没有键 key,返回 -1
        if (it == key_table.end()) return -1;
        // 从哈希表中得到旧的缓存
        Node cache = it -> second;
        // 从平衡二叉树中删除旧的缓存
        S.erase(cache);
        // 将旧缓存更新
        cache.cnt += 1;
        cache.time = ++time;
        // 将新缓存重新放入哈希表和平衡二叉树中
        S.insert(cache);
        it -> second = cache;
        return cache.value;
    }
    
    void put(int key, int value) {
        if (capacity == 0) return;
        auto it = key_table.find(key);
        if (it == key_table.end()) {
            // 如果到达缓存容量上限
            if (key_table.size() == capacity) {
                // 从哈希表和平衡二叉树中删除最近最少使用的缓存
                key_table.erase(S.begin() -> key);
                S.erase(S.begin());
            }
            // 创建新的缓存
            Node cache = Node(1, ++time, key, value);
            // 将新缓存放入哈希表和平衡二叉树中
            key_table.insert(make_pair(key, cache));
            S.insert(cache);
        }
        else {
            // 这里和 get() 函数类似
            Node cache = it -> second;
            S.erase(cache);
            cache.cnt += 1;
            cache.time = ++time;
            cache.value = value;
            S.insert(cache);
            it -> second = cache;
        }
    }
};

复杂度分析

时间复杂度:get 时间复杂度 O ( l o g ⁡ n ) O(log⁡n) O(logn)put 时间复杂度 O ( l o g ⁡ n ) O(log⁡n) O(logn),操作的时间复杂度瓶颈在于平衡二叉树(集合)的插入删除均需要 O ( l o g ⁡ n ) O(log⁡n) O(logn) 的时间。

空间复杂度: O ( c a p a c i t y ) O(capacity) O(capacity),其中 c a p a c i t y capacity capacityLFU 的缓存容量。哈希表和平衡二叉树(集合)不会存放超过缓存容量的键值对。


方法二:双哈希表

引入

该方法需要首先了解 460. LFU 缓存,可以参考 我的题解

现在,我们假设所有的缓存内容使用的频次都一样,根据 LFU 缓存 的置换页面规则,接下来就会将最近不使用的缓存内容置换出去,就变成了 LRU 缓存问题。

于是想到维护一个哈希表 freq_table 来存放缓存内容的使用频率和双向链表。
每一个使用频率对应一个双向链表,这个链表里存放的是使用频率为 freq 的所有缓存内容。

接下来就是 LRU 缓存机制问题了,使用一个键为 key 索引,每个索引对应的是缓存节点,我们声明这个数据结构为 key_table

LRU 中的最经常等价于使用频次最高,最近等价于缓存处于双向链表的头部,置换操作移除的是最久最不经常使用的即为使用频次最小且处于双向链表尾部的缓存内容。

这样我们就能利用两个哈希表来使得两个操作的时间复杂度均为 O ( 1 ) O(1) O(1)

基础数据结构的设计与选择

自己设计的数据结构为 DLinkedNode,为对应缓存频次的双向链表。

class DLinkedNode {
public:
    int key, value;
		int freq = 1;  // 使用频次初始为 1
    DLinkedNode* prev;
    DLinkedNode* next;
    DLinkedNode(int k = 0, int v = 0): key(k), value(v) {};
};

选择现有的数据结构有:

  • 哈希表 key_table:存放键与缓存内容,用来保证 get O ( 1 ) O(1) O(1) 操作;
  • 哈希表 freq_table:存放缓存内容的使用频次和对应频次的使用内容,用来保证 put O ( 1 ) O(1) O(1) 操作。

LFUCache 实现

LFUCache(int _capacity): capacity(_capacity) {
	min_freq = 0;
	key_table.clear();
	freq_table.clear();
}

其中的 min_freq 表示最小的缓存使用频次,在删除缓存内容时会使用到。

get 实现

如果 capacity = 0,表示现在没有任何缓存数据,直接返回 -1

否则,再判断哈希表 key_table 中是否有 key,如果没有,直接返回 -1。如果有,则表示找到了 key 对应的缓存 node。接着,我们首先要将缓存内容从双向链表中删除,通过 remove(node) 完成;然后判断:如果删除这个缓存内容后,freq 对应的双向链表为空了,需要从 freq_table 中移除 freq 并更新 min_freq。最后,更新 node 的使用频次以及在双向链表中的位置(加入到双向链表的头部位置)。

在双向链表中,移除一个节点属于基本操作了,直接贴上代码:

// 删除双向链表中一个节点
void remove(DLinkedNode* node) {
	node->prev->next = node->next;
	node->next->prev = node->prev;
}

put 实现

如果,此时 capacity = 0,不需要进行任何操作,因为最大缓存容量为空。

否则,表示最大缓存容量非空,可以继续更新或者加入新的缓存:

  • 如果哈希表 key_table 中没有 key,则需要向使用频次为 1 的双向链表中加入新的缓存数据(通过 push_front() 实现),但是需要先判断当前的缓存容量是否已经达到最大的缓存容量:

    • 如果达到了,需要删除最近不经常使用的缓存即 freq_table[min_freq] 表示的双向链表中头部尾部节点,如果移除后的双向链表空了,还要移除 min_freq 这个键值对;
    • 如果未达到,则新建缓存并加入 key_tablefreq_table
  • 此时更新 min_freq = 1

  • 如果哈希表中有 key,则执行修改缓存内容的操作:

    • 删除节点,删除后双向链表空了,要从 freq_table 中移除 freq
    • 更新缓存的使用频次、时间戳和值;
    • 使用新的缓存更新哈希表 key_tablefreq_table

实现代码

class DLinkedNode {
public:
    int key, value, freq = 1;
    DLinkedNode* prev;
    DLinkedNode* next;
    DLinkedNode(int k = 0, int v = 0): key(k), value(v) {};
};

class LFUCache {
private:
    int min_freq, capacity;
    unordered_map<int, DLinkedNode*> key_table;     // key 和双向链表映射
    unordered_map<int, DLinkedNode*> freq_table;    // 使用频次和双向链表映射


    DLinkedNode* new_list() {
        auto dummy = new DLinkedNode();
        dummy->prev = dummy;
        dummy->next = dummy;
        return dummy;
    }

    // 在链表头部增加一个节点
    void push_front(int freq, DLinkedNode* node) {
        auto it = freq_table.find(freq);
        if (it == freq_table.end()) {   // 没有 freq 对应的双向链表
            it = freq_table.emplace(freq, new_list()).first;    // 增加一个空的双向链表
        }
        auto dummy = it->second;
        node->prev = dummy;
        node->next = dummy->next;
        node->prev->next = node;
        node->next->prev = node;
    }

    // 删除双向链表中一个节点
    void remove(DLinkedNode* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

public:
    LFUCache(int _capacity): capacity(_capacity) {
        min_freq = 0;
        key_table.clear();
        freq_table.clear();
    }
    
    int get(int key) {
        if (capacity == 0) return -1;
        auto it = key_table.find(key);
        if (it == key_table.end()) return -1;

        auto node = it->second;
        // 删除节点,删除后双向链表空了,要从 freq_table 中移除 freq
        remove(node);
        auto dummy = freq_table[node->freq];
        if (dummy->prev == dummy) {
            freq_table.erase(node->freq);
            delete dummy;
            if (min_freq == node->freq) {   // 当前的频次是最少使用的
                ++min_freq;
            }
        }
        // 更新这个节点的使用频次两个哈希表都要更新,node只是使用频次变了,内容不变,所以可以不更新到key_table
        push_front(++node->freq, node);
        return node->value;
    }
    
    void put(int key, int value) {
        if (capacity == 0) return;
        auto it = key_table.find(key);
        if (it == key_table.end()) {
            if (key_table.size() == capacity) {     // 缓存的最大容量已经满了
                auto dummy = freq_table[min_freq];  // 双向链表的头部节点
                auto back_node = dummy->prev;       // 双向链表的尾部节点
                key_table.erase(back_node->key);    // 移除使用频次最小的节点
                remove(back_node);
                delete back_node;
                if (dummy->prev == dummy) {   // 移除后空了
                    freq_table.erase(min_freq);
                    delete dummy;
                }
            }
            // 放入新书
            auto node = new DLinkedNode(key, value);
            key_table[key] = node;
            push_front(1, node);
            min_freq = 1;
        } 
        else {
            // 直接修改值
            auto node = it->second;
            // 删除节点,删除后双向链表空了,要从 freq_table 中移除 freq
            remove(node);
            auto dummy = freq_table[node->freq];
            if (dummy->prev == dummy) {
                freq_table.erase(node->freq);
                delete dummy;
                if (min_freq == node->freq) {   // 当前的频次是最少使用的
                    ++min_freq;
                }
            }
            node->value = value;
            push_front(++node->freq, node);
            key_table[key] = node;
        }
    }
};

/**
 * Your LFUCache object will be instantiated and called as such:
 * LFUCache* obj = new LFUCache(capacity);
 * int param_1 = obj->get(key);
 * obj->put(key,value);
 */

复杂度分析

时间复杂度: O ( 1 ) O(1) O(1)

空间复杂度: O ( m i n ( p , c a p a c i t y ) ) O(min(p,capacity)) O(min(p,capacity)),其中 p p p p u t put put 的调用次数。


知识回顾

双向链表的操作

不同于 我的题解 知识回顾中有两个伪节点的双向链表操作,现在来讲述一下代码中只使用一个伪节点的双向链表操作。其实明白了原理之后,会发现一个伪节点(实际上是两个相同的伪头部)与两个伪节点的双向链表操作大同小异。

初始化

DLinkedNode* new_list() {
	auto dummy = new DLinkedNode();
	dummy->prev = dummy;
	dummy->next = dummy;
	return dummy;
}

不同于有伪头部和伪尾部的构造方法,以上代码中构造的是一个伪节点——伪头部(伪尾部)。

node 头插法

// 在链表头部增加一个节点
void push_front(int freq, DLinkedNode* node) {
	auto it = freq_table.find(freq);
	if (it == freq_table.end()) {   // 没有 freq 对应的双向链表
		it = freq_table.emplace(freq, new_list()).first;    // 增加一个空的双向链表
	}
	auto dummy = it->second;
	node->prev = dummy;
	node->next = dummy->next;
	node->prev->next = node;
	node->next->prev = node;
}

删除 node

// 删除双向链表中一个节点
void remove(DLinkedNode* node) {
	node->prev->next = node->next;
	node->next->prev = node->prev;
}

判空

使用一个伪节点的双向链表在判空上,只要判断 dummy->prev == dummy 即可。

Tips

当然也可以使用两个伪节点来实现本题的代码。


写在最后

如果文章内容有任何错误或者您对文章有任何疑问,欢迎私信博主或者在评论区指出 💬💬💬。

如果大家有更优的时间、空间复杂度方法,欢迎评论区交流。

最后,感谢您的阅读,如果感到有所收获的话可以给博主点一个 👍 哦。

  • 21
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wang_nn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值