面试挂在了 LRU 缓存算法设计上

好吧,有人可能觉得我标题党了,但我想告诉你们的是,前阵子面试确实挂在了 RLU 缓存算法的设计上了。当时做题的时候,自己想的太多了,感觉设计一个 LRU(Least recently used) 缓存算法,不会这么简单啊,于是理解错了题意(我也是服了,还能理解成这样,,,,),自己一波操作写了好多代码,后来卡住了,再去仔细看题,发现自己应该是理解错了,就是这么简单,设计一个 LRU 缓存算法。

不过这时时间就很紧了,按道理如果你真的对这个算法很熟,十分钟就能写出来了,但是,自己虽然理解 LRU 缓存算法的思想,也知道具体步骤,但之前却从来没有去动手写过,导致在写的时候,非常不熟练,也就是说,你感觉自己会 和你能够用代码完美着写出来是完全不是一回事,所以在此提醒各位,如果可以,一定要自己用代码实现一遍自己自以为会的东西。千万不要觉得自己理解了思想,就不用去写代码了,独自撸一遍代码,才是真的理解了。

今天我带大家用代码来实现一遍 LRU 缓存算法,以后你在遇到这类型的题,保证你完美秒杀它。

题目描述

设计并实现最不经常使用(LFU)缓存的数据结构。它应该支持以下操作:get 和 put。

get(key) - 如果键存在于缓存中,则获取键的值(总是正数),否则返回 -1。

put(key, value) - 如果键不存在,请设置或插入值。当缓存达到其容量时,它应该在插入新项目之前,

使最不经常使用的项目无效。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,

最近最少使用的键将被去除。

进阶:

你是否可以在 O(1) 时间复杂度内执行两项操作?

示例:

LFUCache cache = new LFUCache( 2 /* capacity (缓存容量) */ );

cache.put(1, 1);

cache.put(2, 2);

cache.get(1);       // 返回 1

cache.put(3, 3);    // 去除 key 2

cache.get(2);       // 返回 -1 (未找到key 2)

cache.get(3);       // 返回 3

cache.put(4, 4);    // 去除 key 1

cache.get(1);       // 返回 -1 (未找到 key 1)

cache.get(3);       // 返回 3

cache.get(4);       // 返回 4

基础版:单链表来解决

我们要删的是最近最少使用的节点,一种比较容易想到的方法就是使用单链表这种数据结构来存储了。当我们进行 put 操作的时候,会出现以下几种情况:

1、如果要 put(key,value) 已经存在于链表之中了(根据key来判断),那么我们需要把链表中久的数据删除,然后把新的数据插入到链表的头部。、

2、如果要 put(key,value) 的数据没有存在于链表之后,我们我们需要判断下缓存区是否已满,如果满的话,则把链表尾部的节点删除,之后把新的数据插入到链表头部。如果没有满的话,直接把数据插入链表头部即可。

对于 get 操作,则会出现以下情况

1、如果要 get(key) 的数据存在于链表中,则把 value 返回,并且把该节点删除,删除之后把它插入到链表的头部。

2、如果要 get(key) 的数据不存在于链表之后,则直接返回 -1 即可。

大概的思路就是这样,不要觉得很简单,让你手写的话,十分钟你不一定手写的出来。具体的代码,为了不影响阅读,我在文章的最后面在放出来。

时间、空间复杂度分析

对于这种方法,put 和 get 都需要遍历链表查找数据是否存在,所以时间复杂度为 O(n)。空间复杂度为 O(1)。

空间换时间

在实际的应用中,当我们要去读取一个数据的时候,会先判断该数据是否存在于缓存器中,如果存在,则返回,如果不存在,则去别的地方查找该数据(例如磁盘),找到后在把该数据存放于缓存器中,在返回。

所以在实际的应用中,put 操作一般伴随着 get 操作,也就是说,get 操作的次数是比较多的,而且命中率也是相对比较高的,进而 put 操作的次数是比较少的,我们我们是可以考虑采用空间换时间的方式来加快我们的 get 的操作的。

例如我们可以用一个额外哈希表(例如HashMap)来存放 key-value,这样的话,我们的 get 操作就可以在 O(1) 的时间内寻找到目标节点,并且把 value 返回了。

然而,大家想一下,用了哈希表之后,get 操作真的能够在 O(1) 时间内完成吗?

用了哈希表之后,虽然我们能够在 O(1) 时间内找到目标元素,可以,我们还需要删除该元素,并且把该元素插入到链表头部啊,删除一个元素,我们是需要定位到这个元素的前驱的,然后定位到这个元素的前驱,是需要 O(n) 时间复杂度的。

最后的结果是,用了哈希表时候,最坏时间复杂度还是 O(1),而空间复杂度也变为了 O(n)。

双向链表+哈希表

我们都已经能够在 O(1) 时间复杂度找到要删除的节点了,之所以还得花 O(n) 时间复杂度才能删除,主要是时间是花在了节点前驱的查找上,为了解决这个问题,其实,我们可以把单链表换成双链表,这样的话,我们就可以很好着解决这个问题了,而且,换成双链表之后,你会发现,它要比单链表的操作简单多了。

所以我们最后的方案是:双链表 + 哈希表,采用这两种数据结构的组合,我们的 get 操作就可以在 O(1) 时间复杂度内完成了。由于 put 操作我们要删除的节点一般是尾部节点,所以我们可以用一个变量 tai 时刻记录尾部节点的位置,这样的话,我们的 put 操作也可以在 O(1) 时间内完成了。

具体代码如下:

// 链表节点的定义
class LRUNode{
    String key;
    Object value;
    LRUNode next;
    LRUNode pre;

    public LRUNode(String key, Object value) {
        this.key = key;
        this.value = value;
    }
}
// LRU
public class LRUCache {
    Map<String, LRUNode> map = new HashMap<>();
    RLUNode head;
    RLUNode tail;
    // 缓存最大容量,我们假设最大容量大于 1,
    // 当然,小于等于1的话需要多加一些判断另行处理
    int capacity;

    public RLUCache(int capacity) {
        this.capacity = capacity;
    }

    public void put(String key, Object value) {
        if (head == null) {
            head = new LRUNode(key, value);
            tail = head;
            map.put(key, head);
        }
        LRUNode node = map.get(key);
        if (node != null) {
            // 更新值
            node.value = value;
            // 把他从链表删除并且插入到头结点
            removeAndInsert(node);
        } else {
            LRUNode tmp = new LRUNode(key, value);
            // 如果会溢出
            if (map.size() >= capacity) {
                // 先把它从哈希表中删除
                map.remove(tail);
                // 删除尾部节点
                tail = tail.pre;
                tail.next = null;
            }
            map.put(key, tmp);
            // 插入
            tmp.next = head;
            head.pre = tmp;
            head = tmp;
        }
    }

    public Object get(String key) {
        LRUNode node = map.get(key);
        if (node != null) {
            // 把这个节点删除并插入到头结点
            removeAndInsert(node);
            return node.value;
        }
        return null;
    }
    private void removeAndInsert(LRUNode node) {
        // 特殊情况先判断,例如该节点是头结点或是尾部节点
        if (node == head) {
            return;
        } else if (node == tail) {
            tail = node.pre;
            tail.next = null;
        } else {
            node.pre.next = node.next;
            node.next.pre = node.pre;
        }
        // 插入到头结点
        node.next = head;
        node.pre = null;
        head.pre = node;
        head = node;
    }
}

这里需要提醒的是,对于链表这种数据结构,头结点和尾节点是两个比较特殊的点,如果要删除的节点是头结点或者尾节点,我们一般要先对他们进行处理。

这里放一下单链表版本的吧


// 定义链表节点
class RLUNode{
    String key;
    Object value;
    RLUNode next;

    public RLUNode(String key, Object value) {
        this.key = key;
        this.value = value;
    }
}
// 把名字写错了,把 LRU写成了RLU
public class RLUCache {
    RLUNode head;
    int size = 0;// 当前大小
    int capacity = 0; // 最大容量

    public RLUCache(int capacity) {
        this.capacity = capacity;
    }

    public Object get(String key) {
        RLUNode cur = head;
        RLUNode pre = head;// 指向要删除节点的前驱
        // 找到对应的节点,并把对应的节点放在链表头部
        // 先考虑特殊情况
        if(head == null)
            return null;
        if(cur.key.equals(key))
            return cur.value;
        // 进行查找
        cur = cur.next;
        while (cur != null) {
            if (cur.key.equals(key)) {
                break;
            }
            pre = cur;
            cur = cur.next;
        }
        // 代表没找到了节点
        if (cur == null)
            return null;

        // 进行删除
        pre.next = cur.next;
        // 删除之后插入头结点
        cur.next = head;
        head = cur;
        return cur.value;
    }

    public void put(String key, Object value) {
        // 如果最大容量是 1,那就没办法了,,,,,
        if (capacity == 1) {
            head = new RLUNode(key, value);
        }
        RLUNode cur = head;
        RLUNode pre = head;
        // 先查看链表是否为空
        if (head == null) {
            head = new RLUNode(key, value);
            return;
        }
        // 先查看该节点是否存在
        // 第一个节点比较特殊,先进行判断
        if (head.key.equals(key)) {
            head.value = value;
            return;
        }
        cur = cur.next;
        while (cur != null) {
            if (cur.key.equals(key)) {
                break;
            }
            pre = cur;
            cur = cur.next;
        }
        // 代表要插入的节点的 key 已存在,则进行 value 的更新
        // 以及把它放到第一个节点去
        if (cur != null) {
            cur.value = value;
            pre.next = cur.next;
            cur.next = head;
            head = cur;
        } else {
            // 先创建一个节点
            RLUNode tmp = new RLUNode(key, value);
            // 该节点不存在,需要判断插入后会不会溢出
            if (size >= capacity) {
                // 直接把最后一个节点移除
                cur = head;
                while (cur.next != null && cur.next.next != null) {
                    cur = cur.next;
                }
                cur.next = null;
                tmp.next = head;
                head = tmp;
            }
        }
    }
}

如果要时间,强烈建议自己手动实现一波。

最后推广下我的公众号:苦逼的码农戳我即可关注,文章都会首发于我的公众号,期待各路英雄的关注交流。

原文地址:https://www.cnblogs.com/kubidemanong/p/10669805.html

时间: 2024-10-13 04:10:27

面试挂在了 LRU 缓存算法设计上的相关文章

说说Android LRU缓存算法实现笔记(二)--LRU的应用

上一篇文章说说Android LRU缓存算法实现学习笔记(一) 中我们介绍了最常用的实现LRU缓存的数据结构LinkedHashMap,这一节我们会针对LinkedHashMap的数据结构的特性,来自己实现缓存结构和学习Android源码和项目中对缓存的完善. 上一篇说到对于缓存实现,我们很重要的会考虑以下几点:1.访问速度:2.逐出旧的缓存策略:3.最好还能考虑到一定的并发度.LinkedHashMap对哈希表的实现保证了我们缓存的快速访问速度,我们通过源码知道,LinkedHashMap默认

常见的缓存算法设计策略

对于缓存,大家应该都不会感到陌生,但是关于缓存算法有哪些,大家可能不会太清楚,这里我大概介绍下. 缓存的设计目的就是为了我们访问方便,减少访问时间,大体上有这四种策略: 一:基于时间的策略.当缓存未满的时候,一直向缓存区添加,当缓存区满的时候,再有数据进来,就需要将以访问过的数据清除掉. 清除的就是那些访问时间久的数据.说白了就是访问时间距离现在越远的将首先被淘汰. 二:基于频率的策略.当缓冲区满的时候,按照访问频率将数据进行排序,将那些访问频率较少的数据淘汰掉. 三:基于时间和频率的策略.当缓

LRU缓存算法

引子: 我们平时总会有一个电话本记录所有朋友的电话,但是,如果有朋友经常联系,那些朋友的电话号码不用翻电话本我们也能记住,但是,如果长时间没有联系了,要再次联系那位朋友的时候,我们又不得不求助电话本,但是,通过电话本查找还是很费时间的.但是,我们大脑能够记住的东西是一定的,我们只能记住自己最熟悉的,而长时间不熟悉的自然就忘记了. 其实,计算机也用到了同样的一个概念,我们用缓存来存放以前读取的数据,而不是直接丢掉,这样,再次读取的时候,可以直接在缓存里面取,而不用再重新查找一遍,这样系统的反应能力

说说Android LRU缓存算法实现学习笔记(一)

在我们的手机应用开发时候,我们经常会遇到大数据访问的时候,我们通常会考虑以下几个方面的情况.一.手机内存的限制还必须保证应用反应的流畅:二.尽量小的流量消耗,不然,你的应用流畅度再好体验再好,用户还是会毫不犹豫的卸载掉你的应用.大数据量访问的情况下,数据缓存是我们一定会考虑到的解决方案.而作为缓存,我们很重要的会考虑以下几点:1.访问速度:2.逐出旧的缓存策略:3.最好还能考虑到一定的并发度.这篇我们主要说说LRU策略的缓存算法实现,我们就用图片缓存为例来谈谈Android应用开发中的缓存实现.

《PHP程序员面试笔试宝典》——如何回答算法设计问题?

如何巧妙地回答面试官的问题? 本文摘自<PHP程序员面试笔试宝典> 程序员面试中的很多算法设计问题,都是历年来各家企业的"炒现饭",不管求职者以前对算法知识掌握得是否扎实,理解得是否深入,只要面试前买本<程序员面试笔试宝典>,应付此类题目完全没有问题.但遗憾的是,很多世界级知名企业也深知这一点,如果纯粹是出一些毫无技术含量的题目,对于考前"突击手"而言,可能会占尽便宜,但对于那些技术好的人而言是非常不公平的.所以,为了把优秀的求职者与一般的求

LRU缓存算法与pylru

这篇写的略为纠结,算法原理.库都是现成的,我就调用了几个函数而已,这有啥好写的?不过想了想,还是可以介绍一下LRU算法的原理及简单的用法. LRU(Least Recently Used,最近最少使用)是一种内存页面置换算法.什么叫内存页面置换?我们知道,相对于内存的速度来讲,磁盘的速度是很慢的.我们需要查询数据的时候,不能每次都跑到磁盘去查,需要在内存里设置一块空间,把一些常用的数据放在这块空间里,以后查的时候就直接在这里查,而不必去磁盘,从而起到“加速”的作用.但是这块空间肯定是远远小于磁盘

LinkedHashMap实现LRU缓存算法

LinkedHashMap的get()方法除了返回元素之外还可以把被访问的元素放到链表的底端,这样一来每次顶端的元素就是remove的元素. 构造函数如下: public LinkedHashMap (int initialCapacity, float loadFactor, boolean accessOrder): initialCapacity   初始容量 loadFactor    加载因子,一般是 0.75f accessOrder   false基于插入顺序,true 基于访问顺

HashMap+双向链表手写LRU缓存算法/页面置换算法

import java.util.Hashtable; //https://zhuanlan.zhihu.com/p/34133067 class DLinkedList { String key; //键 int value; //值 DLinkedList pre; //双向链表前驱 DLinkedList next; //双向链表后继 } public class LRUCache { private Hashtable<String,DLinkedList> cache = new H

[转]缓存、缓存算法和缓存框架简介

以下内容转自:http://www.leexiang.com/cache-algorithm (转载请注明出处)-----------------------------------分割线---------------------------------------------- 引言 我们都听过 cache,当你问他们是什么是缓存的时候,他们会给你一个完美的答案,可是他们不知道缓存是怎么构建的,或者没有告诉你应该采用什么标准去选择缓存框架.在这边文章,我们会去讨论缓存,缓存算法,缓存框架以及哪