LRU算法的原理与实现

作者: 愤怒的谜团 | 来源:发表于2019-11-17 17:51 被阅读0次

    一、LRU算法的原理

    LRU是Least Recently Used的缩写,即最近最少使用算法,应用面非常的广泛,比如redis当中的内存淘汰策略。因为计算机的内存都是有限的,当用户访问的数据如果存在内存当中直接返回的给用户的话,效率会非常快,但是如果内存不存在,在去磁盘里面查找的,效率会大打折扣。所以我们希望在有限的内存空间当中,多存放点热点数据,用户不经常访问的数据,尽量淘汰掉,避免占用内存空间。

    二、LRU算法实现-java

    使用双向链表来实现LRU这篇文章已经用双向链表来实现过LRU算法了,但是基于双向链表的特性,使得该算法的时间复杂度为O(n),显然不是最优的算法,那么有没有算法,可以达到O(1),当然是有的,早早的计算机科学家已经想到,并且已经实现了。

    在笔者介绍接下来的内容时,还是希望先了解一下两篇博文:
    一、图解HashMap原理
    二、图解LinkedHashMap

    之前使用双向链表去实现LRU算法时,时间复杂度没有达到O(1),主要原因在于遍历结点时,带来的时间开销,那么换句话说,要实现遍历结点时,时间复杂度为O(1),第一时间想到的应该是hash数组,但是hash算法可能会存在不同的key值,产生相同的hash值,那么可以将不同key,但是相同hash值的结点,以单链表的形式存放。这样仅仅是实现了存取时间复杂度为O(1),如何实现数据能够按访问顺序存放呢?并且增删的时间复杂度为O(1),这个可以使用双向链表来实现,所以综合来讲,就是实现散列数组+双向链表来使用LRU,可以达到时间复杂度为O(1)。

    逻辑视图如下:


    LinkedHashMap逻辑视图.png

    咋一看这个图乱的很,稍微解释一下,如果感觉理解上有点困难,可以先去了解一下之前推荐的两篇博文,那里会介绍的更加详细一点。

    1.最左侧其实就是一个普通的数组,数组的大小必须是2的倍数,这个原因是什么呢?因为这个数组的存放方式是散列的,意思就是需要key.hashcode & (length -1)才能得出存放位置的方式,hash的好处是可以根据key值,在时间复杂度为O(1)的前提找到对应的存放位置,这也是我们的初衷,说到这里其实还没有解释为什么一定要是2的倍数,因为2的倍数-1,这个数的二进制,一定全是1,比如16-1=15,二进制表示就是1111,&运算符其实就是将值全部化成二进制逐位与,比如10111011 & 1111 = 1011 = 11,但是如果不是2的倍数,比如7-1=6,化成二进制就是0110,由于末位是0,不管什么二进制值与0110做&运算,一定是偶数,这样会导致散列分布不均匀。

    2.不同key值,相同hash值,如何存放呢?相同的hash值做与运算一定能够得到相同的数组脚标,这些结点,以单链表的形式存在,就是图中数组右侧的单链表。

    3.如何实现按访问顺序?上图除去数组和挂在数组右侧的单链表,还有绿色和黄色的单向箭头,在右上角还有一个双向链表的头指针。其实这些箭头就是维护不同结点的访问顺序,即双向链表。

    总上所述,这种数据结构定义的结构体如下:
    class Node{
    Object key; //存放key值,用于计算存放数组脚标位置
    Object value;//存放元素值
    int hash;//存放key.hashcode
    Node next;//维护单链表顺序
    Node before;//维护双向链表顺序
    Node after;
    }

    笔者用java实现如下,感兴趣可以用自己喜欢的语言去实现一遍,加深理解:

    public class LeastRecentlyUsedWithLinkedHashMap {
    
        /**
         * 使用数组+双向链表可以使用LRU算法,并且可以达到O(1)复杂度
         */
        private Object[] myLinkedHashMap = null; //hash数组
        private int maxSize;
        private int currSize;
        entry head = null; // 双向链表的头结点
    
        class entry {
            Object key; //key值,可以用于计算存于哪个脚标
            Object value; //对应数组里面存储的值
            int hash;  //hash(key)可以实现散列数组
            entry next = null; //当不同的key,hash值相同时,会以单链表的形式下挂在该结点下面
            entry before = null; //before和after实现双向链表,来维护数据的访问顺序
            entry after = null;
        }
    
        public LeastRecentlyUsedWithLinkedHashMap(int maxSize) {
            // 初始化一个头结点
            this.maxSize = getMaxSize(maxSize); //hash数组大小必须为2的倍数
            this.currSize = 0;
            myLinkedHashMap = new Object[this.maxSize]; //申请的数组大小必须是2的倍数
            head = new entry();
            head.key = 0;
            head.value = 0;
            head.hash = head.key.hashCode();
            head.next = head.before = head.after = null;
        }
    
        public int getMaxSize(int maxSize) {
            // 获取大于当前大小,并且最接近2倍数的值
            if (maxSize == 1){
                return 2;
            }
            int capacity = 1;
            while (capacity < maxSize)
                capacity = capacity << 1;
            return capacity;
        }
    
        /**
         * 添加一个数据步骤:
         * -》输入key和value值
         * -》根据key计算出hash值,然后 K = hash & (maxSize-1),计算出应该插入的脚标K值
         * -》申请一个新的结点,将key和value进行赋值
         * -》判断K脚标的结点上是否存在数据,如果存在数据,遍历该单链表,看是否存在相同的key值,如果
         * 存在,替换掉原有结点,如果没有相同的key值,插入到链表的头部。
         * -》使用双向链表维护插入的顺序
         */
        public Boolean put(Integer key, Object value) {
            int index = key.hashCode() & (this.maxSize - 1); //计算待插入的脚标
            //新增一个结点
            entry indexEntry = new entry();
            indexEntry.key = key;
            indexEntry.value = value;
            indexEntry.hash = key.hashCode();
    
            if (myLinkedHashMap[index] != null) {
                //说明该结点已经有值,存在单链表
                entry node = null;
                if (myLinkedHashMap[index] instanceof entry) {
                    node = (entry) myLinkedHashMap[index];
                } else {
                    return false;
                }
                for (; node != null; node = node.next) {
                    // 遍历结点所在的单链表
                    if (node.key.equals(key)) {
                        /**
                         * 表示找到了存在key值的结点,这个时候需要替换该结点的值,并且在
                         * 双向链表当中删除该结点,并且将该结点添加至双向链表的尾部。
                         */
                        node.value = value;
                        removeDoubleLinkedListElem(node);
                        addElem(node);
                        return true;
                    } else {
                        continue;
                    }
                }
                if (node == null) {
                    /**
                     * 说明该结点所在的单链表当中不存在与key相等的结点,则需要将该结点插至
                     * 该单链表的第一个结点,并且更新双向链表。
                     */
                    if (currSize == this.maxSize){
                        //双向链表已经处于full状态,则需要移除第一个结点
                        removeLinkedListElem(head.after);
                        removeDoubleLinkedListElem(head.after);
                    }
                    indexEntry.next = (entry)myLinkedHashMap[index]; //将新新增的结点插入到单链表的头部
                    myLinkedHashMap[index] = indexEntry; //将新增结点放置在hash数组上
                    addElem(indexEntry); //维护双向链表
                    return true;
                }
            } else {
                //说明index位置上不存在值,那么直接插入即可,并且维护双向链表
                if (currSize == this.maxSize){
                    //双向链表已经处于full状态,则需要移除第一个结点
                    removeLinkedListElem(head.after);
                    removeDoubleLinkedListElem(head.after);
                }
                myLinkedHashMap[index] = indexEntry;
                addElem(indexEntry); //维护双向链表
                return true;
            }
    
            return true;
        }
    
        public Object removeDoubleLinkedListElem(entry entry) {
            //在双向链表当中删除该结点
            Object returnObject = entry.value;
            entry.before.after = entry.after;
            entry.after.before = entry.before;
            entry.before = entry.after = null;
            this.currSize--;
            return returnObject;
        }
    
        public Object removeLinkedListElem(entry entry){
            int index = entry.hash & (this.maxSize - 1); //计算出该结点所在散列数组的脚标
            entry currNode = null; //记录脚标所在结点的位置
            entry delNode = null; //记录单链表待删除的结点
            entry preNode = null; //记录单链表待删除结点的前一个结点位置
            Object returnObject = null;
            if (myLinkedHashMap[index] instanceof  entry){
                currNode = (entry)myLinkedHashMap[index];
            }else{return -1;}
    
            if (currNode.next == null && currNode.key.equals(entry.key)){
                returnObject = currNode.value;
                myLinkedHashMap[index] = null; //如果对应脚标的位置只有一个结点,直接置为空
                return returnObject;
            }else{
                for (preNode = currNode,delNode = currNode.next;delNode != null;preNode = delNode,delNode = delNode.next){
                    if (delNode.key.equals(entry.key)){
                        //说明已经找到对应的结点
                        returnObject = delNode.value;
                        if (delNode.next != null){
                            //表示该结点不处于单链表的尾部
                            preNode.next = delNode.next;
                            delNode.next = null;
                            return returnObject;
                        }else{
                            preNode.next = null;
                            return returnObject;
                        }
                    }else{continue;}
                }
                return -1;
            }
        }
    
        public Boolean addElem(entry entry) {
            //在双向链表的尾部添加元素结点
            if (currSize == 0){
                // 表示是空的双向链表
                entry.before = entry.after = head;
                head.before = head.after = entry;
                this.currSize++;
                return true;
            }
            entry.after = head;
            entry.before = head.before;
            head.before.after = entry;
            head.before = entry;
            this.currSize++;
            return true;
        }
    
        /**
         * 查找一个数据步骤
         * -》根据key值计算hashcode,然后 K = hash & (maxSize-1),计算出脚标
         * -》如果该结点是一个单链表,就从头结点开始遍历,查找与key相同的结点,如果没找到返回-1
         * 如果找到了,在双向链表当中删除该结点,然后将该结点插到双向链表的尾部
         */
    
        public Object get(Object key) {
            int index = key.hashCode() & (this.maxSize - 1);
            entry node = null;
            if (myLinkedHashMap[index] instanceof entry) {
                node = (entry) myLinkedHashMap[index];
            } else {
                return -1; //表示未查找到数据
            }
    
            if (node.next != null){
                //表示该结点存在单链表,需要进行遍历
                for (;node != null;node = node.next){
                    if (node.key.equals(key)){
                        removeDoubleLinkedListElem(node);
                        addElem(node);
                        return node.value;
                    }else{
                        continue;
                    }
                }
                if (node == null){
                    //说明未存在key
                    return -1;
                }
            }else{
                if (node.key.equals(key)){
                    removeDoubleLinkedListElem(node);
                    addElem(node);
                    return node.value;
                } else{return -1;}
            }
            return -1;
        }
    
        //实现toString功能,方便测试
        public String toString(){
            StringBuffer sb = new StringBuffer();
            if (currSize == 0){
                return "";
            }
            entry currNode = head.after;
            for (;currNode != head;currNode = currNode.after){
                if (currNode.after != head){
                    sb.append(currNode.value);
                    sb.append(",");
                }else {
                    sb.append(currNode.value);
                }
            }
            return sb.toString();
        }
    
        public static void main(String[] args) {
        }
    }
    
    

    其实以上实现底层原理就是LinkedHashMap的实现原理,只不过笔者做了一些简化,去掉了繁琐的继承,扩容等,突出了算法核心,如果读者感兴趣也可以去研究一下LinkedHashMap的源码。

    使用LinkedHashMap来实现LRU算法:

    import java.util.LinkedHashMap;
    
    public class LRULinkedHashMap {
        public static void main(String[] args) {
            LinkedHashMap<Object, Object> objectObjectLinkedHashMap = new LinkedHashMap<Object, Object>(1,0.75f,true);
            objectObjectLinkedHashMap.put(1,"zhangsan");
            objectObjectLinkedHashMap.put(2,"lisi");
            objectObjectLinkedHashMap.put(3,"wangwu");
            objectObjectLinkedHashMap.get(1);
            System.out.println(objectObjectLinkedHashMap.toString());
        }
    }
    

    看起来是不是简单了很多,因为LinkedHashMap底层已经封装好了,我们直接调用就好,但是作为一个想要变优秀的码农,一定要知其然知其所以然。

    三、时间复杂度分析

    使用散列+双向链表的方式是如何实现O(1)复杂度的?在实现LRU算法过程中,无非两种操作,查找和修改,使用散列数组实现查找时间复杂度为O(1),使用双向链表实现修改复杂度为O(1),并且双向链表还可以维护访问顺序,所以使用这种方式,可以达到O(1)。

    相关文章

      网友评论

        本文标题:LRU算法的原理与实现

        本文链接:https://www.haomeiwen.com/subject/jugpictx.html