Java实现一个LRU算法

163 阅读7分钟

LRU

LRU (Least Recently Used),即“最近最少使用”算法,是一种常见的缓存淘汰策略。它的核心思想是:如果一个数据在最近一段时间没有被访问,那么在未来也很可能不会被访问。当缓存空间不足,需要淘汰数据时,就优先淘汰那些最近最少使用的数据。

这种策略基于“局部性原理”(Locality of Reference),包括时间局部性(如果一个数据项被访问,那么在短时间内它很可能再次被访问)和空间局部性(如果一个数据项被访问,那么它附近的其它数据项也可能很快被访问)。LRU 主要利用了时间局部性。

为什么需要缓存淘汰算法?

在许多应用中,缓存(Cache)被用来存储经常访问的数据,以提高数据访问速度。然而,缓存的容量通常是有限的。当缓存满时,新数据需要入缓存,就必须淘汰一些旧数据。此时,就需要一个缓存淘汰策略来决定淘汰哪些数据,以最大化缓存命中率(即从缓存中获取数据的次数占总访问次数的比例)。

LRU 算法的实现原理

为了实现 LRU 算法,我们需要跟踪每个数据项的“使用时间”或者“使用频率”。当数据被访问时,它的“使用时间”就需要更新到最新。当需要淘汰数据时,我们就找到那个“使用时间”最久远的数据。

最常见的 LRU 实现方式是结合使用哈希表 (Hash Map) 和双向链表 (Doubly Linked List)。

  • 哈希表 (Hash Map)

    • 用于存储缓存中的键值对 (key-value)。
    • 哈希表的键 (key) 是数据项的标识,值 (value) 是指向双向链表中对应节点的指针。
    • 作用:O(1) 时间复杂度快速查找缓存中的数据项,并快速获取其在链表中的位置。
  • 双向链表 (Doubly Linked List)

    • 用于维护缓存中数据项的访问顺序。
    • 链表的节点存储实际的数据项(或者指向数据项的指针)。
    • 链表的头部 (Head) 总是表示最近被使用的数据项。
    • 链表的尾部 (Tail) 总是表示最近最少使用的数据项。
    • 作用:O(1) 时间复杂度在链表头部插入新节点或将已有节点移到头部,O(1) 时间复杂度在链表尾部删除节点。

LRUCache 类结构

LRUCache 类的属性和方法可以归类如下:

属性归类

  1. 缓存相关属性:

    • private Map<K, Node> cache;
      存储缓存的键值对,键为类型 K,值为 Node 对象。
  2. 链表相关属性:

    • private Node head;
      指向双向链表的头节点。
    • private Node tail;
      指向双向链表的尾节点。
  3. 容量和大小属性:

    • private int capacity;
      缓存的最大容量。
    • private int size;
      当前缓存中存储的元素数量。

方法归类

  1. 构造方法:

    • public LRUCache(int capacity)
      构造函数,初始化缓存的容量、大小和链表的头尾节点。
  2. 获取方法:

    • public V get(K key)
      根据键获取缓存中的值。如果键不存在,返回 null;如果存在,将节点移动到链表头部并返回其值。
  3. 插入/更新方法:

    • public void put(K key, V value)
      插入或更新键值对。如果键已存在,更新其值并移动到链表头部;如果键不存在且缓存已满,淘汰最近最少使用的节点。
  4. 链表操作方法:

    • private void addToHead(Node node)
      将指定节点添加到链表头部。
    • private void removeNode(Node node)
      从链表中移除指定节点。
    • private void moveToHead(Node node)
      将指定节点移动到链表头部,先从当前位置移除,再添加到头部。
    • private Node removeTail()
      移除链表尾部的节点,并返回被移除的节点。
  5. 主方法:

    • public static void main(String[] args)
      示例用法,展示如何使用 LRUCache 类进行插入和获取操作。

流程

初始化最大容量为 2 。

  1. put(1, 1):

    • 将键 1 和值 1 插入缓存。
    • 当前缓存状态: {(1, 1)}
  2. put(2, 2):

    • 将键 2 和值 2 插入缓存。
    • 当前缓存状态: {(2, 2), (1, 1)}2 是最新插入的)。
  3. get(1):

    • 获取键 1 的值。
    • 返回值为 1,并将节点移动到链表头部。
    • 当前缓存状态: {(1, 1), (2, 2)}1 是最新的)。
  4. put(3, 3):

    • 将键 3 和值 3 插入缓存。
    • 此时缓存已满(容量为 2),需要淘汰最近最少使用的节点(即键 2)。
    • 当前缓存状态: {(3, 3), (1, 1)}3 是最新的)。
  5. get(2):

    • 尝试获取键 2 的值。
    • 返回值为 null,因为键 2 已被淘汰。
  6. put(4, 4):

    • 将键 4 和值 4 插入缓存。
    • 此时缓存已满,需要淘汰最近最少使用的节点(即键 1)。
    • 当前缓存状态: {(4, 4), (3, 3)}4 是最新的)。
  7. get(1):

    • 尝试获取键 1 的值。
    • 返回值为 null,因为键 1 已被淘汰。
  8. get(3):

    • 获取键 3 的值。
    • 返回值为 3,并将节点移动到链表头部。
    • 当前缓存状态: {(3, 3), (4, 4)}3 是最新的)。
  9. get(4):

    • 获取键 4 的值。
    • 返回值为 4,并将节点移动到链表头部。
    • 当前缓存状态: {(4, 4), (3, 3)}4 是最新的)。

Java代码示例

package cn.srw.EvictionAlgorithm;

import java.util.HashMap;
import java.util.Map;

// 基于双向链表和HashMap实现
public class LRUCache<K, V> {
    private Map<K, Node> cache;
    private Node head; // 链表头
    private Node tail; // 链表尾
    private int capacity; // 缓存的最大容量
    private int size; // 缓存的当前容量

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.size = 0;
        this.cache = new HashMap<>();

        head = new Node(null, null);
        tail = new Node(null, null);
        head.next = tail;
        tail.prev = head;
    }

    /**
     * 获取缓存中的值。
     * 如果 key 不存在,返回 null。
     * 如果 key 存在,则将节点移动到链表头部,并返回其值。
     */
    public V get(K key) {
        Node node = cache.get(key);
        if (node == null) {
            return null; // 未命中
        }

        // 命中,将节点移动到链表头部
        moveToHead(node);
        return (V) node.value;
    }

    /**
     * 插入或更新 key-value 对。
     * 如果 key 已存在,则更新其值并将其移动到链表头部。
     * 如果 key 不存在:
     * 如果缓存已满,则淘汰链表尾部的最近最少使用节点。
     * 然后将新节点添加到链表头部。
     */
    public void put(K key, V value) {
        Node node = cache.get(key);

        if (node != null) {
            // key 已存在,更新值并移动到头部
            node.value = value;
            moveToHead(node);
        } else {
            // key 不存在
            // 检查缓存是否已满
            if (size == capacity) {
                // 缓存已满,淘汰最近最少使用的节点(尾部节点)
                Node tailNode = removeTail();
                cache.remove(tailNode.key); // 从 HashMap 中移除
                size--; // 减少当前大小
            }

            // 创建新节点并添加到头部
            Node newNode = new Node(key, value);
            cache.put(key, newNode); // 添加到 HashMap
            addToHead(newNode); // 添加到链表头部
            size++; // 增加当前大小
        }
    }

    /**
     * 将节点添加到链表头部(head 之后)。
     */
    private void addToHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }

    /**
     * 从链表中移除指定节点。
     */
    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    /**
     * 将指定节点移动到链表头部。
     * 先从当前位置移除,再添加到头部。
     */
    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    /**
     * 移除链表尾部的节点(在 tail 之前)。
     * 返回被移除的节点。
     */
    private Node removeTail() {
        Node actualTail = tail.prev;
        removeNode(actualTail);
        return actualTail;
    }

    public static void main(String[] args) {
        // 示例用法
        LRUCache<Integer, Integer> lruCache = new LRUCache<>(2); // 容量为 2

        System.out.println("put(1, 1)");
        lruCache.put(1, 1); // 缓存: {(1,1)}

        System.out.println("put(2, 2)");
        lruCache.put(2, 2); // 缓存: {(2,2), (1,1)} (2是最新)

        System.out.println("get(1) -> " + lruCache.get(1)); // 返回 1。缓存: {(1,1), (2,2)} (1是最新)

        System.out.println("put(3, 3)"); // 缓存满,淘汰 (2,2)
        lruCache.put(3, 3); // 缓存: {(3,3), (1,1)} (3是最新)

        System.out.println("get(2) -> " + lruCache.get(2)); // 返回 -1 (2已被淘汰)

        System.out.println("put(4, 4)"); // 缓存满,淘汰 (1,1)
        lruCache.put(4, 4); // 缓存: {(4,4), (3,3)} (4是最新)

        System.out.println("get(1) -> " + lruCache.get(1)); // 返回 -1 (1已被淘汰)
        System.out.println("get(3) -> " + lruCache.get(3)); // 返回 3。缓存: {(3,3), (4,4)} (3是最新)
        System.out.println("get(4) -> " + lruCache.get(4)); // 返回 4。缓存: {(4,4), (3,3)} (4是最新)
    }
}

// 内部节点类
class Node<K, V> {
    K key;
    V value;
    Node<K, V> prev; // <K,V>只能指向同类型,类型安全,如果删除
    Node<K, V> next;

    public Node(K key, V value) {
        this.key = key;
        this.value = value;
    }
}