LRU缓存

        有人从网络读数据,有人从磁盘读数据,LRU缓存_第1张图片机智的人懂得合理利用缓存加速数据的读取效率,提升程序的性能,搏得上司的赏识,赢得白富美的青睐,进一步走向人生巅峰~

LRU假说

        LRU缓存(Least Recently Used Cache)即最近最少使用缓存算法,是一种常用的缓存淘汰策略,它基于这样一个假设:

LRU缓存_第2张图片如果数据最近被访问过,那么它在未来被访问的可能性也更高。

        因此,当缓存空间不足时,LRU缓存会优先移除最长时间未被访问的数据项。        

LRU是怎么干活的

新访问的数据添加到缓存

        当一个数据项被访问时,它会被添加到缓存中。如果该数据项已经在缓存中,它会被更新,并且移动到缓存的最前面,表示最近被访问过。

缓存满时移除最老的数据

        如果缓存已满(达到预设的容量限制),最久未被访问的数据项(位于缓存的最后面)会被移除,以便为新的数据项腾出空间。

维护访问顺序

        缓存需要维护数据项的访问顺序,以便快速确定哪些数据项是最近被访问的,哪些是最久未被访问的。

为了有效地实现LRU缓存,通常需要以下两种数据结构:
        双向链表:用于维护数据项的访问顺序。最近访问的数据项位于链表一头,最久未访问的数据项位于链表另一头。当数据项被访问时,它会被移动到链表最近访问那一头。当需要移除数据项时,最久未访问的末尾数据项会被移除。
        哈希表:用于存储键和指向双向链表中相应节点的指针,以便快速定位缓存中的数据项。这样可以在O(1)时间复杂度内访问缓存项。

LRU缓存_第3张图片

LRU的简单示例

如下是一个简单的LRU实现LRU缓存_第4张图片

#include 
#include 
#include 
#include 
#include 

using namespace std;

template 
class LRUCache {
public:
    LRUCache(int capacity) {
        cap = capacity;
    }

    V get(const K& key) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            return V();
        }

        auto val = it->second->second;
        put(key, val);
        return val;
    }

    void put(const K& key, const V& value) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            if (hash.size() >= cap) {
                auto d_it = data_list.begin();
                auto h_it = d_it->first;
                data_list.erase(d_it);
                hash.erase(h_it);
            }
        } else {
            auto d_it = it->second;
            data_list.erase(d_it);
            hash.erase(it);
        }

        data_list.emplace_back(key, value);
        hash[key] = --data_list.end();
    }

private:
    int cap;
    list > data_list;

    using LIST_IT = typename list >::iterator;
    unordered_map hash;
};

int main() {
    LRUCache lru(2);

    vector > > test_case = {
        {"put", {1, 1}},
        {"put", {2, 2}},
        {"get", {1}},
        {"put", {3, 3}},
        {"get", {2}},
        {"put", {4, 4}},
        {"get", {1}},
        {"get", {3}},
        {"get", {4}},
    };

    for (const auto& [opt, param] : test_case) {
        if (opt == "get") {
            auto val = lru.get(param.front());
            cout << val << endl;
        } else {
            lru.put(param.front(), param.back());
        }
    }

    return 0;
}

运行测试用例可以得到如下结果:

code % g++ lru.cpp -std=c++17
code % ./a.out       
1
0
0
3
4
code % 

        如上,实现一个LRU的代码量并不算多,并且简单易懂,性能也很不错,毕竟时间复杂度为O(1)。但LRU也有其缺点,例如它没有考虑数据的访问频率。这可能会导致一些不经常使用的数据被缓存,而一些经常使用的数据被淘汰LRU缓存_第5张图片

LRU的改进-LFU

        LFU(Least Frequently Used),即最少使用频率缓存,考虑到访问频率,而不是最近一次访问时间。其可以与LRU结合,形成其他变种,以更好地适应不同的数据访问模式。

LFU的简单示例

        例如,可以通过给LRU缓存数据项加上访问频率,当缓存满需要淘汰时,取尾部的数据选一个访问频次最低的来淘汰LRU缓存_第6张图片

#include 
#include 
#include 
#include 
#include 

using namespace std;

template 
class LRUCache {
public:
    LRUCache(int capacity) {
        cap = capacity;
    }

    V get(const K& key) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            return V();
        }

        const auto& data_tuple = *(it->second);
        auto val = std::get<1>(data_tuple);
        auto cnt = std::get<2>(data_tuple);
        put(key, val, cnt + 1);
        return val;
    }

    void put(const K& key, const V& value, int cnt = 1) {
        auto it = hash.find(key);
        if (it == hash.end()) {
            if (hash.size() >= cap) {
                remove_one_elem();
            }
        } else {
            auto d_it = it->second;
            data_list.erase(d_it);
            hash.erase(it);
        }

        data_list.emplace_back(key, value, cnt);
        hash[key] = --data_list.end();
    }

private:
    void remove_one_elem() {
        auto need_rm = data_list.begin();
        auto it = need_rm;
        for (int i = 1; i < 3 && it != data_list.end(); ++i, ++it) {
            if (std::get<2>(*it) < std::get<2>(*need_rm)) {
                need_rm = it;
            }
        }

        hash.erase(std::get<0>(*need_rm));
        data_list.erase(need_rm);
    }

private:
    int cap;
    list > data_list;

    using LIST_IT = typename list >::iterator;
    unordered_map hash;
};

int main() {
    LRUCache lru(2);

    vector > > test_case = {
        {"put", {1, 1}},
        {"put", {2, 2}},
        {"get", {1}},
        {"put", {3, 3}},
        {"get", {2}},
        {"put", {4, 4}},
        {"get", {1}},
        {"get", {3}},
        {"get", {4}},
    };

    for (const auto& [opt, param] : test_case) {
        if (opt == "get") {
            auto val = lru.get(param.front());
            cout << val << endl;
        } else {
            lru.put(param.front(), param.back());
        }
    }

    return 0;
}

运行测试用例可以得到如下结果:

code % g++ lfu.cpp -std=c++17
code % ./a.out       
1
0
1
0
4
code % 

        与LRU示例的差异点在于,当缓存满时,LFU为从最近未使用的一头,挑选一个访问频次最小的元素进行淘汰。值得注意的是,挑选最少频次并不需要遍历所有的数据,而是针对具体的业务场景,设定一个合适的值即可。

        虽然LRU开销很小,时间复杂度又是O(1),但毕竟每次访问都需要调整链表,对于一些性能要求高的场景,负担还是有点重的LRU缓存_第7张图片,实际的使用场景中,又会根据具体的业务场景,做一些响应的改变。

衍生一下

Clock算法

        Clock算法是一种用于页面置换的缓存淘汰策略,它是LRU算法的一种近似实现,旨在降低实现LRU的开销。Clock算法有时也被称为Second-Chance算法,因为它给了每个页面一个“第二次机会”来避免被置换。

Clock是怎么干活的

        Clock算法维护一个循环链表:所有的页面都被组织成一个循环链表(或称为时钟LRU缓存_第8张图片结构),每个页面都有一个关联的访问位(通常是一个标志位),用于表示该页面自上次检查以来是否被访问过。

        使用指针指向链表中的一个页面:有一个指针(称为时钟指针)指向循环链表中的某个页面。

        维护访问位:当一个页面被访问时,其访问位被设置为1,表示该页面最近被使用过。

        缓存满时检查访问位:当有新需要加载到缓存中,但缓存已满,算法会检查当前时钟指针指向的页面的访问位。如果访问位为1,则将其清零(给予第二次机会),并将时钟指针移动到下一个页面。如果访问位为0,则选择该页面进行置换

        Clock算法的优点是实现简单,开销较低,因为它不需要像真正的LRU算法那样在每次页面访问时都对链表进行调整。它只需要在页面置换时检查和更新访问位。这使得Clock算法特别适合于大规模的缓存系统,如操作系统的页面缓存。

        Clock算法的缺点是它不是完全精确的LRU实现,因为它可能会保留一些不太常用的页面(如果它们在时钟指针到达之前刚好被访问过)。然而,对于许多实际应用来说,Clock算法提供了一个很好的折中方案,既保留了LRU的大部分优点,又显著降低了实现的复杂性和开销。

你可能感兴趣的:(C++,缓存,LRU,Clock,LFU)