算法:LRU缓存机制的c++实现

2,223 阅读16分钟

一.定义

LRU缓存机制的全称是Least Recently Used cache.即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。LRU算法的设计原则是:**如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。**也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。

二.问题设计

leetcode146题对这个问题进行了详细的描述:

设计和构建一个“最近最少使用”缓存,该缓存会删除最近最少使用的项目。缓存应该从键映射到值(允许你插入和检索特定键对应的值),并在初始化时指定最大容量。当缓存被填满时,它应该删除最近最少使用的项目。 它应该支持以下操作: 获取数据 get 和 写入数据 put 。 获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。 写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间 。

最后还要求get和put操作的时间复杂度都为O(1)。那我们该如何解决这个问题呢?

三.数据结构分析

这个问题的难点就在于如何设计合适的数据结构。

一种很容易想到的方法就是用链表来实现,每次插入操作都把新数据插入队列或者链表的头部,再检查内存有木有溢出,溢出的话还要进行删除队尾,表尾的操作。每次访问的时候从头结点顺序向后遍历,并把访问的数据移动到队头或者表头。这种思路比较简单,put操作时O(1)的时间复杂度,但是get操作却是O(n),不满足要求。另外值得注意的是,对链表某个节点进行删除操作时,需要把这个节点的前驱节点指向后驱节点,如果在O(1)复杂度的条件下,每个节点需要保存其前驱节点,也就是双向链表来实现。

但即便是这样查询操作还是需要O(n)的复杂度去遍历,那么为了降低复杂度,我们就会想到用一个map去映射双向链表的每个节点,这样这两个操作都变成了O(1)的时间复杂度。这样的数据结构我们称为LinkedHashmap。下面这个图转自www.bilibili.com/video/BV18A…

四.算法流程梳理

整个算法的流程其实很简单,先看get,查询到了数据就把数据从当前位置移动到表头并返回当前数据的value,否则返回-1;再看put,数据如果在表中就把数据移动到表头,否则检查内存是否溢出,如果溢出,把表尾数据删除,之后把当前数据插入表头。

五.c++代码的实现

我们先建立一个双向链表,一个节点中保存key,value以及前驱节点和后驱节点。

class LinkNode{
    public:
        int key;
        int value;
        LinkNode* prev;
        LinkNode* next;
        LinkNode():key(-1),value(-1),prev(nullptr),next(nullptr){}
        LinkNode(int _key, int _value):key(_key),value(_value),prev(nullptr),next(nullptr){}
};

之后我们按照刚刚梳理的算法流程来书写代码,另外我把删除表尾deleteLastNode()和数据移动到表头操作moveNodeToTop(LinkNode* cur)单独用使用成员函数封装了一下。

class LRUCache {
    int capacity;
    map<int, LinkNode*> myMap;
    LinkNode* head = new LinkNode();
    LinkNode* tail = new LinkNode();
public:
    LRUCache(int capacity) {
        this->capacity = capacity;
        head->next = tail;
        tail->prev = head;
    }

    int get(int key) {
        if(myMap.find(key) != myMap.end()){
            LinkNode* cur = myMap[key];
            moveNodeToTop(cur);
            return cur->value; 
        } else return -1;
    }

    void put(int key, int value) {
        if(myMap.find(key) == myMap.end()){
            if(myMap.size() == capacity) deleteLastNode();
            LinkNode* temp = head->next;
            LinkNode* newNode = new LinkNode(key,value);
            head->next = newNode;
            newNode->prev = head;
            newNode->next = temp;
            temp->prev = newNode;
            myMap[key] = newNode;
        } else {
            LinkNode* cur = myMap[key];
            cur->value = value;
            moveNodeToTop(cur);
        }
    }
    private:
        void deleteLastNode(){
            LinkNode* last = tail->prev;
            last->prev->next = tail;
            tail->prev = last->prev;
            myMap.erase(last->key);
        }
        void moveNodeToTop(LinkNode* cur){
            cur->prev->next = cur->next;
            cur->next->prev = cur->prev;
            LinkNode* temp = head->next;
            head->next = cur;
            cur->prev = head;
            cur->next = temp;
            temp->prev = cur;
        }
};

附上leetcode运行结果:

六.c++改进版本

其实上面我们手撕的双向链表可以用stl中的list容器来替代,来减少代码量。

关于list容器:

list是一种序列式容器。list容器完成的功能实际上和数据结构中的双向链表是极其相似的,list中的数据元素是通过链表指针串连成逻辑意义上的线性表,也就是list也具有链表的主要优点,即:在链表的任一位置进行元素的插入、删除操作都是快速的。list的实现大概是这样的:list的每个节点有三个域:前驱元素指针域、数据域和后继元素指针域。前驱元素指针域保存了前驱元素的首地址;数据域则是本节点的数据;后继元素指针域则保存了后继元素的首地址。其实,list和循环链表也有相似的地方,即:头节点的前驱元素指针域保存的是链表中尾元素的首地址,list的尾节点的后继元素指针域则保存了头节点的首地址,这样,list实际上就构成了一个双向循环链。由于list元素节点并不要求在一段连续的内存中,显然在list中是不支持快速随机存取的,因此对于迭代器,只能通过“++”或“--”操作将迭代器移动到后继/前驱节点元素处。而不能对迭代器进行+n或-n的操作,这点,是与vector等不同的地方。

对比vector,list,deque:

vector : vector和built-in数组类似,拥有一段连续的内存空间,能非常好的支持随即存取,即[]操作符,但由于它的内存空间是连续的,所以在中间进行插入和删除会造成内存块的拷贝,另外,当插入较多的元素后,预留内存空间可能不够,需要重新申请一块足够大的内存并把原来的数据拷贝到新的内存空间。这些影响了vector的效率,但是实际上用的最多的还是vector容器,建议大多数时候使用vector效率一般是不错的。

list: list就是数据结构中的双向链表(根据sgi stl源代码),因此它的内存空间是不连续的,通过指针来进行数据的访问,这个特点使得它的随即存取变的非常没有效率,因此它没有提供[]操作符的重载。但由于链表的特点,它可以以很好的效率支持任意地方的删除和插入。

deque: deque是一个double-ended queue,它的具体实现不太清楚,但知道它具有以下两个特点:它支持[]操作符,也就是支持随即存取,并且和vector的效率相差无几,它支持在两端的操作:push_back,push_front,pop_back,pop_front等,并且在两端操作上与list的效率也差不多。

因此在实际使用时,如何选择这三个容器中哪一个,应根据你的需要而定,具体可以遵循下面的原则
1. 如果你需要高效的随即存取,而不在乎插入和删除的效率,使用vector
2. 如果你需要大量的插入和删除,而不关心随即存取,则应使用list
3. 如果你需要随即存取,而且关心两端数据的插入和删除,则应使用deque。

代码实现如下:

这里我们把list最后一个元素看成最近使用过的元素就行了

class LRUCache {    list<pair<int,int>> mlist;    unordered_map<int,list<pair<int,int>>::iterator> mp;    int cap;public:    LRUCache(int capacity):cap(capacity) {}        int get(int key) {        if (mp.count(key)){            auto it = mp[key];            mlist.splice(mlist.end(),mlist,it);            return it->second;        }         return -1;    }        void put(int key, int value) {        if (mp.count(key)){            mp[key]->second = value;            mlist.splice(mlist.end(),mlist,mp[key]);        } else if (cap){            if (mp.size() == cap){                mp.erase(mlist.begin()->first);                mlist.erase(mlist.begin());            }            mp[key] = mlist.insert(mlist.end(),make_pair(key,value));                    }     }};