手撕LRU

126 阅读3分钟

LRU

小编最近面试碰到让手撕 LRU 算法,面试官注重思路以及纯手写,不能用任何辅助类完成,思考再三,总结以下几点

  • 用什么数据结构存储
  • 怎么达到增删改时间复杂度 O(1)
  • 怎么实现 LRU 特性

1 什么是 LRU

它属于一种内存管理算法,在内存中但又不用的数据块叫 LRU,操作系统会根据哪些数据属于 LRU 而将其移出内存腾出空间来加载另外的数据,按照时间序排序,最近常用数据具备更高的留存,淘汰那些不常被访问的数据,Redis 的内存淘汰算法中也包含到了 LRU 算法,包含三个特性:

  • 最近被使用或访问的数据放置最前面
  • 每当缓存命中则将数据移到头部
  • 当缓存数量达到最大值时,将最近最少访问的数据剔除

2 代码设计

2.1 数据存储

涉及到数据存储,LRU 需要有时序性(方便淘汰最近最少使用),高效查询存储特性。时序性可通过有序的链表 LinkedList 来存储,同时增删只需要更改指针指向即可效率很高,但是查询效率 O(n),高效率查询可以用 hash 结构实现,但是内部元素无序,java 中结合这几个特点有 LinkedHashMap 数据结构可满足这点,但是题目要求需要我们自己手写结构(我也不知道为啥这么苛刻,可能是想看下你的基本链表算法基本功吧~~)。

链表节点用 Node 表示,由于是双向链表,同时需要标注前后驱指针

class Node {
  int key;
  int val;
  Node next,prev;
  Node(int k,int v){
    this.key = k;
    this.val = v;
  }
}
}

LRU 结构,这里用Map+Node结构来实现LinkedHashMap

class LRUCache {
  private HashMap<Integer,Node> map;
  //头尾虚结点
  private Node head,tail;
  //限制容量
  private int limit;
  private int size;
  public LRUCache(int limit) {
      this.map = new HashMap<>();
      this.limit = limit;
      this.size = 0;
      listInit();
  }
  private void listInit() {
      //链表初始化
      this.head = new Node(-1,-1);
      this.tail = new Node(-1,-1);
      this.head.next = this.tail;
      this.tail.prev = this.head;
  }
}
}

这里可能会想问,为什么要用双向链表作为存储结构,单链表不行吗?这是因为双向链表每个节点都有 prev(前驱) 和 next(后继) 指针分别指向他的前一个和后一个节点,这样在删除节点时候能保证在O(1)情况下完成。

2.2 LRU 细节

编码之前,先想好 get,put 细节功能

  • 当使用 get 时候

    • 如果节点不存在,返回-1

    • 节点存在

      • 刚好是头节点,皆大欢喜不需要移动元素,直接返回
      • 不是头节点,需要将该元素从链表中删除->置顶->返回结果
  • 当使用 put 时候

    • 如果节点存在

      • 刚好是首节点,就更新map和链表,不移动元素
      • 不是首节点,就需要将该节点置顶
    • 不存在,首先判断容量

      • 容量满了,先淘汰尾部元素,头插新元素
      • 未满,不淘汰
      • size增长
public class LRUCache {

    public int get(int key){
        if(!map.containsKey(key)) return -1;
        Node node = map.get(key);
        //不需要移动元素
        if(node == this.head.next){
            return node.val;
        }
        setRecentlyUse(key);
        return node.val;
    }

    public void put(int key,int val){
        if(map.containsKey(key)) {
            //不需要移动元素
            if(key == this.head.next.key) {
                Node node = map.get(key);
                node.val = val;
                this.head.next.val = val;
                return;
            }
            removeKey(key);
            addRecentlyUse(key,val);
            return;
        }
        //容量满了
        if(this.limit <= this.size) {
            removeRecentlyNotUse();
        }
        addRecentlyUse(key,val);
    }
    //把节点从链表中删除,头插到链表
    private void setRecentlyUse(int key) {
        Node val = map.get(key);
        //从链表中删除元素
        remove(val);
        //头插
        addFirst(val);
    }
    
    private void addFirst(Node node) {
        node.next = this.head.next;
        node.prev = head;
        this.head.next.prev = node;
        this.head.next = node;
        size++;
    }

    private void remove(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
        size--;
    }

    //删除链表节点同时删除map节点
    private void removeKey(int key) {
        Node node = map.get(key);
        remove(node);
        map.remove(key);
    }
    //添加新节点置顶
    private void addRecentlyUse(int key, int val) {
        Node node = new Node(key,val);
        addFirst(node);
        map.put(key,node);
    }
    //淘汰不常用的
    private void removeRecentlyNotUse() {
        //尾部淘汰
        Node last = removeLast();
        map.remove(last.key);
    }

    private Node removeLast() {
        Node del = this.tail.prev;
        remove(del);
        return del;
    }

3 LRU不足之处

LRU实现简单,在一般情况下能够表现出很好的命中率,是一个“性价比”很高的算法。但是如果对于一个长时间不使用且还未被淘汰的key,在某个时间突然被访问了一次,后来没有被访问,那么就会在短时间内就变为热点数据,这显然不符合常理,LFU算法靠着数据访问频次解决了这一问题.....