LRU Cache 算法


概述

LRU 是 Least Recently Used 的缩写,意思是最近最少使用,是一种常见的缓存淘汰策略,其他策略还有 FIFO、LFU 等。缓存的访问速度比较快,但是其是一种有限的资源,所以需要进行缓存的淘汰,一般情况下缓存会使用内存,计算机的内存一般来说都有比较有限的资源。

下面通过一个示意图来解释 LRU 的整体过程。

lru

如上图所示:

  1. 一个 capacity 为 5 的 LRU cache,前面五个元素 A、B、C、D、E 填充满 cache
  2. 第六个元素 F 添加到队尾后,最早加入的 A 元素被淘汰,即队头被删除
  3. 再次访问元素 C,那么 C 的新鲜度被刷新,重新添加到队尾
  4. 第七个元素 G 添加到队尾后,队头的 B 被淘汰

说明:这里的说明是以上面为队尾,下面为队头。反过来,上面是队头,下面是队尾也可以。

基本操作定义

LRU 主要包含两个操作:put 和 get:

  • put(key, value):插入新的 key value 数据,当数据条数超过阈值的时候,进行淘汰
  • get(key):读取指定 key 的数据,不存在的时候返回 -1 或 NULL

实现方法

这里的实现还是以 leetcode 146 为例,可以通过是否 AC 来检查代码编写是否正确。

[C++] map + double linked list

首先,需要思考使用什么数据结构:

  1. 因为是 cache,所以首先想到哈希表,对于 C++ 来说就是使用 unordered_map
  2. 难点是如何识别最近访问的节点,为了方便把最近使用过的节点添加到队尾,淘汰的时候直接删除队头即可,可以联想到使用双端链表

确定数据结构后,整体的流程是怎样的?

  • 对于 get、put 访问或更新过的 key,将其节点放入到队尾,队尾是最近使用过的节点
  • 队头就是最久使用过的节点,淘汰的时候就可以直接淘汰队头的节点

示例代码如下:

struct Node {
    int val = -1;
    int key = -1;
    Node* prev = nullptr;
    Node* next = nullptr;
};

class LRUCache {
public:
    LRUCache(int capacity) : capacity_(capacity) {
        m.reserve(capacity_);
        // dummy head 和 tail,用于简化双向链表的操作
        head = new Node;
        tail = new Node;
        head->next = tail;
        tail->prev = head;
    }
    
    int get(int key) {
        if (m.find(key) == m.end()) {
            return -1;
        }

        Node* node = m[key];
        // 新访问过的节点,移动到 tail
        moveToTail(node);
        return node->val;
    }
    
    void put(int key, int value) {
        if (m.find(key) == m.end()) {
            Node* node = new Node;
            node->key = key;
            node->val = value;
            m[key] = node;
            // 新增节点,添加到 tail
            addToTail(node);
            // 容量检查
            if (m.size() > capacity_) {
                // 删除 head 端最旧的节点
                Node* node = head->next;
                removeNode(node);
                m.erase(node->key);
                delete node;
            }
        } else {
            Node* node = m[key];
            node->val = value;
            // 新更新过的节点,移动到 tail
            moveToTail(node);
        }
    }

    void addToTail(Node* node) {
        Node* prev = tail->prev;
        prev->next = node;
        node->prev = prev;
        node->next = tail;
        tail->prev = node;
    }

    void removeNode(Node* node) {
        Node* prev = node->prev;
        Node* next = node->next;
        prev->next = next;
        next->prev = prev;
    }

    // 移动到 tail 是分两步:删除节点,然后添加到 tail
    void moveToTail(Node* node) {
        removeNode(node);
        addToTail(node);
    }
private:
    unordered_map<int, Node*> m;
    int capacity_{0};
    Node* head;
    Node* tail;
};

其他

对于 Python 来说,可以直接使用 OrderedDict。而对于 Java 来说,可以直接使用 LinkedHashMap 来实现。详情参考leetcode 官方题解

附录

Leetcode 官方题解的代码,拷贝到附录作为参考。

[Python] OrderedDict

Python 3.2 开始为 OrderedDict 添加了 move_to_end 方法,可以方便地把key移动到队尾或者对头(last=False),官方文档也添加了 LRU cache 的一些示例代码(LastUpdatedOrderedDict、TimeBoundedLRU、MultiHitLRUCache)。

class LRUCache(collections.OrderedDict):

    def __init__(self, capacity: int):
        super().__init__()
        self.capacity = capacity


    def get(self, key: int) -> int:
        if key not in self:
            return -1
        self.move_to_end(key)
        return self[key]

    def put(self, key: int, value: int) -> None:
        if key in self:
            self.move_to_end(key)
        self[key] = value
        if len(self) > self.capacity:
            self.popitem(last=False)

[Java] LinkedHashMap

LinkedHashMap 官方文档明确说非常适合做LRU:

  1. 使用构造函数 public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder),遍历顺序:from least-recently accessed to most-recently (access-order),正好符合 LRU 的语义
  2. removeEldestEntry(Map.Entry) 需要重载来实现删除逻辑

唯一与 LRU 的区别是:默认的 capacity 是 initial capacity,后续是可以容纳更多的数据,为了实现 capacity 是固定的,所以重载 removeEldestEntry 方法,当超出 capacity 后进行淘汰。

class LRUCache extends LinkedHashMap<Integer, Integer> {
    private int capacity;
    
    public LRUCache(int capacity) {
        super(capacity, 0.75F, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        return super.getOrDefault(key, -1);
    }

    public void put(int key, int value) {
        super.put(key, value);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity; 
    }
}

参考资料

  1. 百度百科
  2. 缓存淘汰算法(LFU、LRU、ARC、FIFO、2Q)分析
  3. leetcode - 146. LRU 缓存
  4. Python collections.OrderedDict
  5. Java SE 8 - LinkedHashMap

如果觉得文章对您有帮助,用微信请作者喝杯咖啡吧!这样他会更有动力,分享更多更好的知识!

wechat赞赏