LRU算法的原理与实现

一、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)。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349