保研面试 算法题_面试挂在了一道 LRU 缓存算法设计题

fdf2e9583a37d1fb28956fe178734eee.png

前言

好吧,有人可能觉得我标题党了,但我想告诉你们的是,前阵子面试确实挂在了 RLU 缓存算法的设计上了。

当时面试官问到这个题的时候,很快给了一个思路,但是手写的时候,发现自己没有深刻理解它,加上当时有点紧张,感觉设计一个 LRU(Least recently used) 缓存算法,不会这么简单啊,后来就卡住了,再去仔细看题,发现自己应该是理解错了,就是这么简单,设计一个 LRU 缓存算法。

面试的时候本来时间就很紧张,大部分面试官其实还是有自己的工作任务的,不会等你很长时间,按道理如果你真的对这个算法很熟,十分钟就能写出来了,但是,自己虽然理解 LRU 缓存算法的思想,也知道具体步骤,但之前却从来没有去动手写过,导致在写的时候,非常不熟练,也就是说,你感觉自己会和你能够用代码完美着写出来是完全不是一回事。

所以在此提醒各位,如果可以,一定要自己用代码实现一遍自己自以为会的东西。千万不要觉得自己理解了思想,就不用去写代码了,独自撸一遍代码,才是真的理解了。

今天我用分别用 C++ 和 Java 代码来实现一遍 LRU 缓存算法,希望以后各位在遇到这类型的题,保证心中有数。

1、题目描述

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

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

put(key, value) - 如果键不存在,请设置或插入值。当缓存达到其容量时,它应该在插入新项目之前, 使最不经常使用的项目无效。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时, 最近最少使用的键将被去除。

来一个经典的例子:

LRUCache cache = new LRUCache( 3 /* capacity (缓存容量) */ );

cache.put("key1", 7); //插入 7

cache.put("key2", 0); //插入 0

cache.put("key3", 1); //插入 1

cache.put("key4", 2); //淘汰最后的 7,把新访问的 2 放入队头

cache.get("key2");    //返回 0 ,并把 0 作为最新访问的放入队头

cache.put("key5", 3); //淘汰最后的 1,把 3 作为最新访问的放入队头

cache.get("key2");    //返回 3 ,并把 0 作为最新访问的放入队头

cache.put("key6", 4); //淘汰最后的 2,把 4 作为最新访问的放入队头

3d9f8e7fe34f0d86e3e3cd3dcb34a9c9.png

2、思路解决

2.1 单链表

我们要删的是 最近最少使用的节点,一种比较容易想到的方法就是使用单链表这种数据结构来存储了。
对于 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)。

2.2 哈希表+单链表

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

所以在实际的应用中,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) 时间复杂度才能删除,主要是时间是花在了节点前驱的查找上,为了解决这个问题,其实,我们可以把单链表换成双链表,这样的话,我们就可以很好的解决这个问题了,而且,换成双链表之后,你会发现,它要比单链表的操作简单多了。

整体的设计思路是:可以使用 HashMap 存储 key,这样可以做到 put 和 get key 的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点。

所以我们最后的方案是:双向链表 + 哈希表,采用这两种数据结构的组合,我们的 get 操作就可以在 O(1) 时间复杂度内完成了。

C++ 参考代码

我们知道,STL 中的 list 就是一种双向链表,我们在定义一个 cachesMap ,key 保存键,value 保存前面的 list 中指向双向链表实现的 LRU 的 Node 节点,也就是说 cachesMap 里面 每一个 key 对应的是一个双向链表的结点,那么具体访问的时候,利用迭代器就可以了,非常的方便。

代码简洁版

class LRUCache
{
private:
    int cap;
    list<pair<int,int>> l;
    unordered_map<int,list<pair<int,int>>::iterator> m;
public:
    LRU(int cap_)
    {
        cap = cap_;
    }
    int get(int k)
    {
        auto it = m.find(k);
        if(it==m.end()) return -1;
        l.splice(l.begin(), l ,l->second);
        return it->second->second;
    }
    void put(int k,int v)
    {
        auto it = m.find(k);
        if(it != m.end()) l.erase(it->second);
        l.push_front(make_pair(k,v));
        m[k] = l.begin();
        if(m.size()>cap)
        {
            int k = l.rbegin()->first;
            l.pop_back();
            m.erase(k);
        }
    }
};

原先版:

//双向链表+哈希表
class LRUCache {
public:
    LRUCache(int capacity) {
        cap = capcity;
    }
    void put(int key, int val) {
        //1、如果要 put(key,value) 已经存在于链表之中了(根据key来判断),
        //那么我们需要把链表中旧的数据删除,然后把新的数据插入到链表的头部。
        //更新cacheMap,把当前key的结点指向第一位的位置
        unordered_map<int,list<pair<int, int> >::iterator > ::iterator it = cachesMap.find(key);
        if(it != cachesMap.end()) {
            list<pair<int,int>>::iterator del = it->second;
            del->second = val;
            pair<int,int >tmpdel = *del;
            caches.erase(del);
            caches.push_front(tmpdel);
            cachesMap[key] = caches.begin();
        } else {
            pair<int,int >tmpPair = make_pair(key,val);
            if(cachesMap.size()>=capcity) {
                //删除最后一个链表元素
                int delKey = caches.back().first;
                caches.pop_back();
                //删除在map中的相应项
                unordered_map<int, list<pair<int, int> > ::iterator> ::iterator delIt = cachesMap.find(delKey);
                cachesMap.erase(delIt++);
            }
            caches.push_front(tmpPair);
            cachesMap[key] = caches.begin(); //更新map
        }
    }
    // 1、如果要 get(key) 的数据存在于链表中,则把 value 返回,并且把该节点删除,删除之后把它插入到链表的头部(表示最新用到了该节点数据)。
    // 2、如果要 get(key) 的数据不存在于链表之后,则直接返回 -1 即可。
    int get(int key) {
        int retValue = -1;
        unordered_map<int, list<pair<int, int> > ::iterator> ::iterator it = cachesMap.find(key);
        if(it != cachesMap.end()) {
            retValue = it->second->second;
            list<pair<int,int>>::iterator del = it->second;
            pair<int,int >tmpdel = *del;
            caches.erase(del);
            caches.push_front(tmpdel);
            cachesMap[key] = caches.begin();
        }
        return retValue;
    }
private:
    unordered_map<int,list<pair<int, int>>::iterator >cachesMap;//哈希表
    list<pair<int,int> > caches;//双向链表
    int cap;
};

Java 参考代码

由于 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<>();
    LRUNode head;
    LRUNode 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;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值