LRU淘汰算法实现

568 阅读4分钟

引言

Redis中将数据存储在内存中, 内存是有限的, 如果一直往里面放数据, 总会有放满的一天, 如果不进行特殊处理, 放满之后再进行放元素则会OOM(个人感觉), 所以Redis有很多中淘汰策略, 以下是具体提供的淘汰策略

  • noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键
  • allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键
  • volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键
  • allkeys-random:加入键的时候如果过限,从所有key随机删除
  • volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐
  • volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
  • volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
  • allkeys-lfu:从所有键中驱逐使用频率最少的键

实现

那么具体的淘汰策略算法是如何实现的, 拿LRU(Least Recently Used)最近最少使用淘汰策略 来举例, 这也是面试当中经常被问到的题目. 具体的场景就是设计一个容器, 容器提供以下操作

  • 初始化: 可以设置容器存放元素的大小
  • 新增: 往容器里新增元素, 如果容器满了, 需要淘汰之前最早使用到的元素
  • 查询: 在容器中查询某个元素
  • 删除: 在容器中移除某个元素

双向链表法

我们很容易就能想到用链表在实现LRU淘汰策略, 具体的实现就是维护一个链表, 每次操作时, 需要遍历链表来判断当前的元素是否存在在链表中, 然后进行一些移除、将元素放入链表头的操作, 具体实现如下

/**
 * @program:
 * @description: 用双向链表实现LRU淘汰策略
 * @create: 2021/7/12 下午11:00
 **/
public class SimpleLRU<K, V> {
  private static final int DEFAULT_CAPACITY = 10;
  private int capacity; // 容量
  private int length; // 当前长度
  private Node<K, V> headNode; // 链表的头指针
  private Node<K, V> tailNode; // 链表的尾指针

  public SimpleLRU() {
    this(DEFAULT_CAPACITY);
  }

  public SimpleLRU(int capacity) {
    this.capacity = capacity;
    headNode = new Node<>();
    tailNode = new Node<>();
    this.length = 0;
  }

  /**
   * 新增
   * @param key
   * @param value
   */
  public void add(K key, V value) {
    Node<K, V> node = getNode(key);
    if (node != null) {
      node.value = value;
      moveToHead(node);
    } else { // 不存在
      Node<K, V> newNode = new Node<>(key, value);
      if (++length > capacity) { // 如果超出容量的话,需要移除尾结点
        popTail(); // 移除链表最后一个节点
        --length;
      }
      addNode(newNode);
    }
  }

  private void addNode(Node<K,V> node) {
    headNode.next.prev = node;
    node.next = headNode.next;
    node.prev = headNode;
    headNode.next = node;
  }

  private void popTail() {
    tailNode.prev.prev.next = tailNode;
    tailNode.prev = tailNode.prev.prev;
  }

  private void moveToHead(Node<K,V> node) {
    headNode.next.prev = node;
    node.next = headNode.next;
    headNode.next = node;
    node.prev = headNode;
  }

  /**
   * 查询
   * @param key
   * @return
   */
  public V get(K key) {
    Node<K, V> node = getNode(key);
    if (node == null) return null;
    moveToHead(node);
    return node.value;
  }

  public Node getNode(K key) {
    Node<K, V> dummyNode = headNode.next; // 当前指针
    // 查找链表中是否存在当前的 key
    while (dummyNode != null && dummyNode.key != key) {
      dummyNode = dummyNode.next;
    }
    if (dummyNode == null) return null;
    return dummyNode;
  }

  /**
   * 移除结点数据
   * @param key
   */
  public void remove(K key) {
    Node<K, V> node = getNode(key);
    if (node == null) return;
    removeNode(node);
  }

  private void removeNode(Node<K,V> node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  static class Node<K, V> {
    K key;
    V value;
    Node prev;
    Node next;
    public Node() {}
    public Node(K key, V value) {
      this.key = key;
      this.value = value;
    }
  }
}

散列表+双向链表法

由于每次操作都需要遍历整个链表, 所以时间复杂度时O(N), 这种实现在实际应用场景肯定是不行的, 容器里元素越多, 操作越慢. 重点就是每次操作都需要遍历整个链表, 依次比较来获取我们想要的元素, 这时可以引入散列表, 这样根据key获取value, 时间复杂度是O(1), 性能更好, 具体实现如下

/**
 * @program:
 * @description:
 * 核心:通过前驱和后驱指针
 * Least Recently Used
 * 时间复杂度O(1), 通过散列表和双向链表完成
 * 提供的操作:
 * 1。 往集合中添加元素
 * 2。 最后使用最先淘汰
 * 3。 有初始化的一个长度
 *
 * @create: 2021/7/9 下午10:56
 **/
public class LRUDemo<K, V> {

  private static final Integer DEFAULT_CAPACITY = 10;

  private int length;

  private int capacity;

  private DNode<K, V> headNode;

  private DNode<K, V> tailNode;

  private Map<K, DNode<K, V>> table;

  public LRUDemo() {
    this(DEFAULT_CAPACITY);
  }

  public LRUDemo(int capacity) {
    this.capacity = capacity;
    length = 0;
    headNode = new DNode<>();
    tailNode = new DNode<>();
    headNode.next = tailNode;
    tailNode.prev = headNode;
    table = new HashMap<>();
  }

  static class DNode<K, V> {
    private K key;
    private V value;
    private DNode prev;
    private DNode next;
    DNode() { }
    DNode(K key, V value) {
      this.key = key;
      this.value = value;
    }
  }

  /**
   * 新增
   * @param key
   * @param value
   */
  public void add(K key, V value) {
    DNode<K, V> node = table.get(key);
    if (node == null) {
      DNode<K, V> newNode = new DNode<>(key , value);
      if (++length > capacity) {
        DNode<K, V> tail = popTail();
        removeNode(tail);
      }
      table.put(key, newNode);
      addNode(node);
    } else {
      node.value = value;
      moveToHead(node);
    }
  }

  private DNode<K,V> popTail() {
    DNode<K, V> tail = tailNode.prev;
    removeNode(tail);
    return tail;
  }

  private void addNode(DNode<K,V> node) {
    headNode.next.prev = node;
    node.next = headNode.next;

    this.headNode.next = node;
    node.prev = headNode;
  }

  private void moveToHead(DNode<K,V> node) {
    removeNode(node);
    addNode(node);
  }

  /**
   * 获取结点数据
   * @param key
   * @return
   */
  public V get(K key) {
    DNode<K, V> node = table.get(key);
    if (node == null) {
      return null;
    }
    moveToHead(node);
    return node.value;
  }

  /**
   * 移除结点
   * @param
   */
  public void removeNode(DNode<K, V> node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
    table.remove(node.key);
  }

}

总结

一般这种算法题, 都是通过引入数据结构, 通过一定的算法进行实现, 链表类的话, 就需要自己多在纸上画图, 节点和节点之间的prev和next指针, 图一画, 就很清晰, 接下来要做的就是指针赋值即可