【LeetCode双向链表】LRU详解,双向链表实战

这篇具有很好参考价值的文章主要介绍了【LeetCode双向链表】LRU详解,双向链表实战。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

LRU缓存

请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

示例:

输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

思路

LRU 的全称是 Least Recently Used,也就是“最近使用过的”,就是字面意思,这里指的是缓存中最近被使用过的数据。

我们都知道缓存的大小是有限的,因此需要使用有效的方法去管理缓存

LRU的思路很直接,就是以“最近是否被使用”为标准将缓存中每个数据进行排序,被使用越频繁的数据排序越靠前,相对的,不经常使用的数据会随着时间推移,排序逐渐靠后,直到被丢弃(当有新数据要进缓存而此时缓存空间又不够时,就会丢弃最不常使用的数据)

假设当前缓存中只能存3个值,那么根据LRU的规则会有以下情况:

【LeetCode双向链表】LRU详解,双向链表实战

好了,LRU算法的原理大概是这样了,那么本题应该怎么解?

参考labuladong的题解,问题可以描述如下:

首先要接收一个 capacity 参数作为缓存的最大容量,然后实现两个 API,一个是 put(key, val) 方法存入键值对,另一个是 get(key) 方法获取 key 对应的 val,如果 key 不存在则返回 -1。

/* 缓存容量为 2 */
LRUCache cache = new LRUCache(2);
// 你可以把 cache 理解成一个队列
// 假设左边是队头,右边是队尾
// 最近使用的排在队头,久未使用的排在队尾
// 圆括号表示键值对 (key, val)

cache.put(1, 1);
// cache = [(1, 1)]

cache.put(2, 2);
// cache = [(2, 2), (1, 1)]

cache.get(1);       // 返回 1
// cache = [(1, 1), (2, 2)]
// 解释:因为最近访问了键 1,所以提前至队头
// 返回键 1 对应的值 1

cache.put(3, 3);
// cache = [(3, 3), (1, 1)]
// 解释:缓存容量已满,需要删除内容空出位置
// 优先删除久未使用的数据,也就是队尾的数据
// 然后把新的数据插入队头

cache.get(2);       // 返回 -1 (未找到)
// cache = [(3, 3), (1, 1)]
// 解释:cache 中不存在键为 2 的数据

cache.put(1, 4);    
// cache = [(1, 4), (3, 3)]
// 解释:键 1 已存在,把原始值 1 覆盖为 4
// 不要忘了也要将键值对提前到队头

通过上面的分析可知,我们需要实现一种数据结构cache,该数据结构具有以下特点:

1、cache中的元素需要是有序的,用以区分最近使用的数据

​ 从LRU算法的解释也不难理解这一点,在该数据结构中,元素是以"是否被使用"作为排序规则的,我们可以将其转换为"被访问的次数",分别进行排序

2、支持通过key快速在cache中找到对应的val

​ 有key又有val很难不联系到map(哈希表)

3、每次访问完cache中的某个key,要将对应元素变为最近使用

​ 也就是要支持在任意位置插入和删除元素

满足上述描述的设计方案一般有两种:双向链表+哈希表 以及 队列+哈希表

先说第一种吧(因为最经典,有可能被要求手撕)

双向链表+哈希表
【LeetCode双向链表】LRU详解,双向链表实战

首先说明,这里双向链表的节点中应该存放两个数据,即key, val

如果不自己手写双向链表的话,那么调用std::list时,存放的数据应该是一个pair<int, int>,即pair<key, val>

来对一对之前提到的三个要求

首先是有序。

如果我们始终按照某一顺序往双向链表添加节点,那么最后该链表就是有序的

例如,每次默认从链表尾部添加元素,那么显然越靠尾部的元素就是最近使用的,越靠头部的元素就是最久未使用的。

其次是通过key快速查找val。

引入了哈希表显然这是可以做到的,从哈希表中拿到key之后,通过修改链表指针就可以快速移动到对应节点处并取出val

最后是插入和删除任意值。

这点的实现方式和查找val一样,如果只是用链表的话,查找某一个节点最坏情况下我们需要遍历整个链表

哈希表提供的快速定位的可能

ok开始动手做,从手写双向链表开始

双向链表

定义一个双向链表并对成员变量进行初始化(使用参数列表)

class LRUCache {
    struct ListNode{//定义节点结构体
        int key;
        int val;
        ListNode* next;
        ListNode* pre;
    };
    ListNode* dummy;
    int maxSize;//最大缓存数量
    int nodeNums;//当前缓存中的节点数量
    //定义哈希表,key是int,val是节点
    unordered_map<int, ListNode*> hash;
    
public:
    LRUCache(int capacity): maxSize(capacity), dummy(new ListNode){//不用参数列表也行
        nodeNums = 0;
        //dummy的 next 和 prev 指针都指向自身,这样当缓存为空时,dummy既是头节点也是尾节点
        dummy->next = dummy;
        dummy->pre = dummy;
    }
    
    int get(int key) {
    }
    
    void put(int key, int value) {
    }
};

其实也不难写,就是多一个指针

除了定义双向链表的节点外,顺便声明一下dummy节点、最大缓存值maxSize以及当前缓存中的节点数量nodeNums,和hashmap

然后在LRUCache类的初始化函数中对成员变量进行初始化与赋值,完成对双向链表的初始化

注意dummy在双向链表初始化中的初始化方式,如下图所示

【LeetCode双向链表】LRU详解,双向链表实战

现在我们有了一个"哈希链表",接下来开始实现取值函数get

get方法

在get方法中,首先在哈希表中查找是否存在指定的key。

如果存在,则将该节点从链表中原位置取出,然后将其插入到链表头的后一个位置,以表示最近被访问过,最终返回该节点对应的value值。

如果不存在,则直接返回-1。

class LRUCache {
    struct ListNode{//定义节点结构体
        ...
    };
    ListNode* dummy;
    int maxSize;//最大缓存数量
    int nodeNums;//当前缓存中的节点数量
    //定义哈希表,key是int,val是节点
    unordered_map<int, ListNode*> hash;
    
public:
    LRUCache(int capacity): maxSize(capacity), dummy(new ListNode){//不用参数列表也行
        nodeNums = 0;
        //dummy的 next 和 prev 指针都指向自身,这样当缓存为空时,dummy既是头节点也是尾节点
        dummy->next = dummy;
        dummy->pre = dummy;
    }
    
    int get(int key) {// 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
        if(hash.find(key) != hash.end()){
            //找到对应节点,取出
            ListNode* node = hash[key];
            //将node从当前位置移除
            node->pre->next = node->next;
            node->next->pre = node->pre;
            //把node插到dummy的后面,也就是链表头部
            node->next = dummy->next;
            node->pre = dummy;
            dummy->next->pre = node;//令dummy后面节点的前面节点为node
            dummy->next = node;//令dummy的后面节点为node
            return node->val;      
        }
        return -1;//没找到对应节点返回-1
    }
    
    void put(int key, int value) {
    }
};

这里涉及双向链表的节点CRUD,过程图示如下:

删除节点node

【LeetCode双向链表】LRU详解,双向链表实战

删除节点很好理解,和单向链表差不多,就不多说了

插入节点node

【LeetCode双向链表】LRU详解,双向链表实战

这里需要注意,要先将dummy的下一个节点(dummy->next)的前指针指向node,再将dummy的后指针指向node

在LRU缓存淘汰算法中,最近使用的节点应该放在链表的尾部,最久未使用的节点应该放在链表的头部。因此,在get函数中,我们需要将访问过的节点移动到链表的头部,也就是dummy节点和第一个节点之间。因为dummy节点并不存储任何的key和val值,所以dummy节点不能算作是节点的前驱节点或后继节点。正确的做法是让原本的第一个节点作为新节点的前驱节点,而不是dummy节点,即当前双向链表理论上的第一个节点是node

好了,get函数写完了

put方法

在put方法中,首先检查哈希表中是否已经存在指定的key。

如果已经存在,则更新该节点的value值,并将其移动到链表头的后一个位置来表示最近被访问过。

如果不存在,则需要根据缓存容量是否已满来进行不同的处理。

如果缓存未满,则创建一个新的节点,并将其插入到链表头的后一个位置,同时在哈希表中记录该key对应的节点。

如果缓存已满,则需要替换最老的节点,这里选择删除链表尾部的节点,并在哈希表中删除该key对应的节点,然后再创建一个新的节点来保存新的key和value,并将其插入到链表头的后一个位置,同样需要在哈希表中记录该key对应的节点。

class LRUCache {
    struct ListNode{//定义节点结构体
        int key;
        int val;
        ListNode* next;
        ListNode* pre;
    };
    ListNode* dummy;
    int maxSize;//最大缓存数量
    int nodeNums;//当前缓存中的节点数量
    //定义哈希表,key是int,val是节点
    unordered_map<int, ListNode*> hash;
    
public:
    LRUCache(int capacity): maxSize(capacity), dummy(new ListNode){//不用参数列表也行
        nodeNums = 0;
        //dummy的 next 和 prev 指针都指向自身,这样当缓存为空时,dummy既是头节点也是尾节点
        dummy->next = dummy;
        dummy->pre = dummy;
    }
    
    int get(int key) {// 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
        ...
    }
    //如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 
    void put(int key, int value) {
        //检查是否存在对应键值
        if(hash.find(key) != hash.end()){//存在
            hash[key]->val = value;//键已经存在于哈希表中,那么需要更新这个键对应的节点的值
            get(key);//调用 get(key) 函数,将这个节点移动到链表头部,表示最近访问过它
        }else{//不存在,添加进链表
            if(nodeNums < maxSize){//缓存没满
                nodeNums++;//缓存中当前节点数增加
                //创建新节点
                ListNode* node = new ListNode;
                node->key = key;
                node->val = value;
                //哈希表对应位置进行记录
                hash[key] = node;
                //将新节点插到dummy后面,也就是链表头部
                node->next = dummy->next;
                node->pre = dummy;
                dummy->next->pre = node;
                dummy->next = node;
            }else{//缓存满了,删除此时链表末尾的节点
                //取链表最后一个节点,即dummy的pre指针指向的节点
                ListNode* node = dummy->pre;
                hash.erase(node->key);//在哈希表中删除对应节点
                hash[key] = node;//在哈希表中添加新的键值对,其中 key 是缓存节点的键,node 则是新的节点。
				node->key=key;//更新 node 节点的键值为新的 key。	
				node->val=value;
                get(key);
            }
        }      
    }
};

put函数中对节点的插入删除操作与get方法中一致,就不重复说明了

有一点需要额外说明的是关于dummy的

dummy是一个虚拟头节点,用于简化链表操作。因为对于任意一个节点node,它的前一个节点可以通过node->prev访问到,但是对于头节点,我们无法访问它的前一个节点,因此引入了dummy作为虚拟头节点。

dummy的next指针指向链表逻辑上的第一个节点,dummy的prev指针指向链表逻辑上的最后一个节点。

【LeetCode双向链表】LRU详解,双向链表实战

代码

双向链表+哈希表
class LRUCache {
    struct ListNode{//定义节点结构体
        int key;
        int val;
        ListNode* next;
        ListNode* pre;
    };
    ListNode* dummy;
    int maxSize;//最大缓存数量
    int nodeNums;//当前缓存中的节点数量
    //定义哈希表,key是int,val是节点
    unordered_map<int, ListNode*> hash;
    
public:
    LRUCache(int capacity): maxSize(capacity), dummy(new ListNode){//不用参数列表也行
        nodeNums = 0;
        //dummy的 next 和 prev 指针都指向自身,这样当缓存为空时,dummy既是头节点也是尾节点
        dummy->next = dummy;
        dummy->pre = dummy;
    }
    
    int get(int key) {// 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
        if(hash.find(key) != hash.end()){
            //找到对应节点,取出
            ListNode* node = hash[key];
            //将node从当前位置移除
            node->pre->next = node->next;
            node->next->pre = node->pre;
            //把node插到dummy的后面,也就是链表头部
            node->next = dummy->next;
            node->pre = dummy;
            dummy->next->pre = node;//令dummy后面节点的前面节点为node
            dummy->next = node;//令dummy的后面节点为node
            return node->val;      
        }
        return -1;//没找到对应节点返回-1
    }
    //如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 
    void put(int key, int value) {
        //检查是否存在对应键值
        if(hash.find(key) != hash.end()){//存在
            hash[key]->val = value;//键已经存在于哈希表中,那么需要更新这个键对应的节点的值
            get(key);//调用 get(key) 函数,将这个节点移动到链表头部,表示最近访问过它
        }else{//不存在,添加进链表
            if(nodeNums < maxSize){//缓存没满
                nodeNums++;//缓存中当前节点数增加
                //创建新节点
                ListNode* node = new ListNode;
                node->key = key;
                node->val = value;
                //哈希表对应位置进行记录
                hash[key] = node;
                //将新节点插到dummy后面,也就是链表头部
                node->next = dummy->next;
                node->pre = dummy;
                dummy->next->pre = node;
                dummy->next = node;
            }else{//缓存满了,删除此时链表末尾的节点
                //取链表最后一个节点,即dummy的pre指针指向的节点
                ListNode* node = dummy->pre;
                hash.erase(node->key);//在哈希表中删除对应节点
                hash[key] = node;//在哈希表中添加新的键值对,其中 key 是缓存节点的键,node 则是新的节点。
				node->key=key;//更新 node 节点的键值为新的 key。	
				node->val=value;
                get(key);
            }
        }      
    }
};
队列+哈希表

TBD

二刷问题

将节点插入dummy后的顺序

顺序必须得是先将dummy后面的节点的前指针指向node,再将dummy的后节点指向node,也就是

				//将新节点插到dummy后面,也就是链表头部
                node->next = dummy->next;
                node->pre = dummy;
                dummy->next->pre = node;
                dummy->next = node;

                //错误
				//dummy->next = node;
				//dummy->next->pre = node;

在LRUCache的put函数中,当缓存已满时需要删除末尾节点,并将新节点插入到头部。为了实现这一操作,我们需要维护链表的双向指针,同时更新hash表。

在加入一个新节点时,我们首先将它的next指向dummy的next,然后将它的pre指向dummy。接着,因为dummy的next节点是原来的头节点,所以我们还需要更新原来的头节点的pre指针,将其指向新节点。

如果我们对调了这两行代码的顺序,先执行dummy->next = node;,那么原来的头节点的pre指针就会被更新为新节点,而新节点的pre指针还没有更新,指向的仍然是dummy节点。这样就破坏了链表的正确性。

因此,dummy->next->pre = node;应该在 dummy->next = node;之后执行,确保链表的双向指针正确。

举个例子

假设当前LRUCache中已经存满了3个节点,这三个节点依次为A、B、C,它们的顺序是由时间先后决定的。此时我们想要插入一个新的节点D,因为缓存已满,所以需要将最早使用的节点C删除。

现在来看一下dummy->next->pre = node;和dummy->next = node;两行代码的顺序对调之后会发生什么。

如果我们先执行dummy->next = node;,那么链表的头节点就会变成D,同时我们可以得到以下的链表:

dummy -> D -> A -> B -> C -> dummy

接着我们再执行dummy->next->pre = node;,这样链表就变成了以下的形式:

dummy -> D -> A -> B -> C
         ^    ^    ^    ^
        pre  pre  pre  pre

从上面的图中可以看出,原来的头节点A的pre指针指向了D,而不是dummy节点,这破坏了链表的双向指针,也就是说链表的正确性被破坏了。

因此,我们应该按照原本的顺序执行dummy->next->pre = node;和dummy->next = node;,这样才能保证链表的正确性。

其实就是如果按照dummy->next = node;到dummy->next->pre = node;的顺序执行

那么在第一次操作完之后,dummy的下一个节点就已经是D而不是A了,而我们在执行dummy->next->pre = node;时希望找到的是A

于是就出错了文章来源地址https://www.toymoban.com/news/detail-462543.html

到了这里,关于【LeetCode双向链表】LRU详解,双向链表实战的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 35、链表-LRU缓存

            首先要了解LRU缓存的原理,首先定下容量,每次get请求和put请求都会把当前元素放最前/后面,如果超过容量那么头部/尾部元素就被移除,所以最近最少使用的元素会被优先移除,保证热点数据持续存在。 不管放在头部还是尾部都可以。看你怎么定义         那

    2024年04月23日
    浏览(37)
  • 如何用链表实现LRU缓存淘汰算法

    缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存 等等 大小有限,当缓存被用满时,哪些数据应该被清理掉,哪些数据又应该保留呢? 先进先出策略 FIFO 最少使用策略 LFU 最近最少使用策略

    2024年02月01日
    浏览(62)
  • 【数据结构】LRU缓存的简单模拟实现(leetcode力扣146LRU缓存)

    LRU是Least Recently Used的缩写,意思是最近最少使用,它是一种Cache替换算法。 Cache的容量有限,因此当Cache的容量用完后,而又有新的内容需要添加进来时, 就需要挑选并舍弃原有的部分内容,从而腾出空间来放新内容。LRU Cache 的替换原则就是将最近最少使用的内容替换掉。

    2024年02月03日
    浏览(43)
  • LeetCode刷题---LRU缓存

    LRU是Least Recently Used的缩写,即最近最少使用,是一种内存管理算法,也可以用作缓存淘汰策略。 这种算法的核心思想是:如果数据最近被访问过,那么将来被访问的几率也更高。 因此,当内存或缓存容量有限,需要淘汰部分数据时,LRU算法会优先淘汰那些最长时间未被访问

    2024年02月22日
    浏览(38)
  • leetcode 146. LRU 缓存

             本题核心就是要将map中,最近最少操作的那个key给剔除掉,于是我们可以使用双端链表LinkedList 来维护每个元素的操作情况,最近操作的元素就将其移至表头,越久没操作的元素,自然就会沉到表尾。  一旦缓存满了,将表尾元素剔除即可。  java代码如下:

    2024年02月08日
    浏览(43)
  • 【LeetCode】146.LRU缓存

    请你设计并实现一个满足  LRU (最近最少使用) 缓存 约束的数据结构。 实现  LRUCache  类: LRUCache(int capacity)  以  正整数  作为容量  capacity  初始化 LRU 缓存 int get(int key)  如果  key  存在于缓存中,则返回的值,否则返回  -1  。 void put(int key, int value)  如果

    2024年02月09日
    浏览(42)
  • 【LeetCode-中等题】146. LRU 缓存

    LRU缓存是什么:LRU缓存机制,你想知道的这里都有 实现 LRU 缓存算法 map —key存 integer value存链表节点 ListNode 存 next 和prev 引用 以及 存 key 和value 具体值 图解:官方图解 步骤: 定义一个自定义的双向链表节点类 DLinkedNode,该类包含 key 和 value 字段,并且具有 prev 和 next 指针

    2024年02月10日
    浏览(51)
  • leetcode做题笔记146. LRU 缓存

    请你设计并实现一个满足  LRU (最近最少使用) 缓存 约束的数据结构。 实现  LRUCache  类: LRUCache(int capacity)  以  正整数  作为容量  capacity  初始化 LRU 缓存 int get(int key)  如果  key  存在于缓存中,则返回的值,否则返回  -1  。 void put(int key, int value)  如果

    2024年02月07日
    浏览(38)
  • Leetcode 146. LRU 缓存(Hashmap+双链表)

    请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。 实现 LRUCache 类: ● LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存 ● int get(int key) 如果 key 存在于缓存中,则返回的值,否则返回 -1 。 ● void put(int key, int value) 如果 key 已

    2024年02月16日
    浏览(50)
  • LeetCode讲解篇之面试题 16.25. LRU 缓存

    设计和构建一个“最近最少使用”缓存,该缓存会删除最近最少使用的项目。缓存应该从键映射到值(允许你插入和检索特定键对应的值),并在初始化时指定最大容量。当缓存被填满时,它应该删除最近最少使用的项目。 它应该支持以下操作: 获取数据 get 和 写入数据 put 。

    2024年02月09日
    浏览(50)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包