LeetCode 146. LRU cache

本文详细介绍了Cache的基本概念,包括其作用、原理及替换策略。深入探讨了LRU算法,包括其原理、实现方式(如双向链表和哈希表结合、使用LinkedHashMap)及其优缺点。同时,还介绍了LRU-K、twoqueue和MultiQueue等算法,对比分析了各种算法的特点。

1 什么是Cache

1.1 概念

Cache,即高速缓存,是介于CPU和内存之间的高速小容量存储器。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近CPU的频率。

当CPU发出内存访问请求时,会先查看 Cache 内是否有请求数据。如果存在(命中),则直接返回该数据;如果不存在(失效),再去访问内存 —— 先把内存中的相应数据载入缓存,再将其返回处理器。

提供“高速缓存”的目的是数据访问的速度适应CPU的处理速度,通过减少访问内存的次数来提高数据存取的速度。

如今高速缓存的概念已被扩充,不仅在CPU和主内存之间有Cache,而且在内存和硬盘之间也有Cache(磁盘缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。

1.2 原理

Cache 技术所依赖的原理是”程序执行与数据访问的局部性原理“,这种局部性表现在两个方面:
时间局部性:如果程序中的某条指令一旦执行,不久以后该指令可能再次执行,如果某数据被访问过,不久以后该数据可能再次被访问。
空间局部性:一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令或数据通常是顺序存放的。

时间局部性是通过将近来使用的指令和数据保存到Cache中实现。空间局部性通常是使用较大的高速缓存,并将 预取机制 集成到高速缓存控制逻辑中来实现。

1.3 替换策略

Cache的容量是有限的,当Cache的空间都被占满后,如果再次发生缓存失效,就必须选择一个缓存块来替换掉。常用的替换策略有以下几种:

  • 随机算法(Rand):随机法是随机地确定替换的存储块。设置一个随机数产生器,依据所产生的随机数,确定替换块。这种方法简单、易于实现,但命中率比较低。
  • 先进先出算法(FIFO, First In First Out):先进先出法是选择那个最先调入的那个块进行替换。当最先调入并被多次命中的块,很可能被优先替换,因而不符合局部性规律。这种方法的命中率比随机法好些,但还不满足要求。
  • 最久未使用算法(LRU, Least Recently Used):LRU法是依据各块使用的情况, 总是选择那个最长时间未被使用的块替换。这种方法比较好地反映了程序局部性规律。
  • 最不经常使用算法(LFU, Least Frequently Used):将最近一段时期内,访问次数最少的块替换出Cache。

2 LRU 介绍

对一个Cache的操作无非三种:插入(insert)、替换(replace)、查找(lookup)。
为了能够快速删除最久没有访问的数据项和插入最新的数据项,我们使用 双向链表 连接Cache中的数据项,并且保证链表维持数据项从最近访问到最旧访问的顺序

  • 插入:当Cache未满时,新的数据项只需插到双链表头部即可。时间复杂度为O(1)O(1).
  • 替换:当Cache已满时,将新的数据项插到双链表头部,并删除双链表的尾结点即可。时间复杂度为O(1)O(1).
  • 查找:每次数据项被查询到时,都将此数据项移动到链表头部。

经过分析,我们知道使用双向链表可以保证插入和替换的时间复杂度是O(1)O(1),但查询的时间复杂度是O(n)O(n),因为需要对双链表进行遍历。为了让查找效率也达到O(1)O(1),很自然的会想到使用 hashtable

当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。
在这里插入图片描述

2.1 LRU实现(hashtable+双向链表)

  • 代码
import java.util.Hashtable;

//146. LRU Cache
public class LRUCache {
    private int capacity;
    private Hashtable<Integer,Node> table=new Hashtable<>();
    //head和tail的作用是:标记双向链表的头和尾,不记录实际意义上的数据
    private Node head;
    private Node tail;

    public LRUCache(int capacity) {
        this.capacity=capacity;
        head=new Node(-1,-1);
        tail=new Node(-1,-1);

        head.next=tail;
        head.pre=null;

        tail.pre=head;
        tail.next=null;
    }

    public int get(int key) {
        Node n=table.get(key);
        if(n!=null){
            moveToHead(n);
            return n.value;
        }
        return -1;
    }

    public void put(int key, int value) {
        Node n=table.get(key);
        if(n!=null){
            n.value=value;
            moveToHead(n);
        }else {
            n=new Node(key,value);
            if(table.size()>=capacity){
                //注意这里
                Node nn=popTail();
                table.remove(nn.key);
            }
            putHead(n);
            table.put(key,n);
        }
    }

    private void remove(Node node){
        Node pre=node.pre;
        Node next=node.next;

        pre.next=next;
        next.pre=pre;
    }

    private void putHead(Node node){
        node.pre=head;
        node.next=head.next;

        head.next.pre=node;
        head.next=node;
    }

    private void moveToHead(Node node){
        remove(node);
        putHead(node);
    }

    private Node popTail(){
        Node n=tail.pre;
        remove(n);
        return n;
    }

    class Node{
        int key;
        int value;
        Node pre;
        Node next;

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

2.2 LRU实现(LinkedHashMap)

LinkedHashMap底层就是用的HashMap+双链表实现的,而且本身已经实现了按照访问顺序的存储。此外,LinkedHashMap中本身就实现了一个方法removeEldestEntry用于判断是否需要移除最不常读取的数,方法默认是直接返回false,不会移除元素,所以需要重写该方法。即当缓存满后就移除最不常用的数。

public class LRUCache {

    private static final float hashLoadFactory = 0.75f;
    private LinkedHashMap<Integer,Integer> map;
    private int cacheSize;

    public LRUCache(int cacheSize) {
        this.cacheSize = cacheSize;
        int capacity = (int)Math.ceil(cacheSize / hashLoadFactory) + 1;
        map = new LinkedHashMap<Integer,Integer>(capacity, hashLoadFactory, true){


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

    public synchronized Integer get(Integer key) {
        return map.getOrDefault(key,-1);
    }

    public synchronized void put(Integer key, Integer value) {
        map.put(key, value);
    }

    public synchronized void clear() {
        map.clear();
    }

    public synchronized int usedSize() {
        return map.size();
    }

    public void print() {
        for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
            System.out.print(entry.getValue() + "--");
        }
        System.out.println();
    }
}

3 扩展

3.1 LRU-K

LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。

相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。

数据第一次被访问时,加入到历史访问列表,如果数据在访问历史列表中没有达到K次访问,则按照一定的规则(FIFO或者LRU)淘汰;当访问历史队列中的数据访问次数达到K次后,将数据从历史队列中删除,并移到缓存队列中,缓存数据,缓存队列重新按照时间排序;缓存数据队列中被再次访问后,重新排序,需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即“淘汰倒数K次访问离现在最久的数据”。

LRU-K具有LRU的优点,同时还能避免LRU的缺点,实际应用中LRU-2是综合最优的选择。由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多。

3.2 two queue

Two queues(以下使用2Q代替)算法类似于LRU-2,不同点在于2Q将LRU-2算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。

新访问的数据插入到FIFO队列中,如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;如果数据在FIFO队列中再次被访问到,则将数据移到LRU队列头部,如果数据在LRU队列中再次被访问,则将数据移动LRU队列头部,LRU队列淘汰末尾的数据。

3.3 Multi Queue(MQ)

MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,其核心思想是:优先缓存访问次数多的数据。详细的算法结构图如下,Q0,Q1…Qk代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:
新插入的数据放入Q0,每个队列按照LRU进行管理,当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列中删除,加入到高一级队列的头部;为了防止高优先级数据永远不会被淘汰,当数据在指定的时间里没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;需要淘汰数据时,从最低一级队列开始按照LRU淘汰,每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部。如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列头部。Q-history按照LRU淘汰数据的索引。

MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。

3.4 LRU算法对比

在这里插入图片描述
参考
https://blog.youkuaiyun.com/maoyeqiu/article/details/50452870
https://blog.youkuaiyun.com/elricboa/article/details/78847305

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值