LRU 算法描述
LRU 算法实际上是让你设计数据结构:⾸先要接收⼀个 capacity 参数作为缓存的最⼤容量,然后实现两个 API,⼀个是 put(key, val) ⽅法存⼊键值对,另⼀个是 get(key) ⽅法获取 key 对应的 val,如果 key 不存在则返回
-1。
注意哦,get 和 put ⽅法必须都是 O(1) 的时间复杂度,我们举个具体例⼦来看看 LRU 算法怎么⼯作。
/* 缓存容量为 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
// 不要忘了也要将键值对提前到队头
LRU 算法设计
分析上⾯的操作过程,要让 put 和 get ⽅法的时间复杂度为 O(1),我们可以总结出 cache 这个数据结构必要的条件:查找快,插⼊快,删除快,有顺序之分。
因为显然 cache 必须有顺序之分,以区分最近使⽤的和久未使⽤的数据;⽽且我们要在 cache 中查找键是否已存在;如果容量满了要删除最后⼀个数据;每次访问还要把数据插⼊到队头。
那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据⽆固定顺序;链表有顺序之分,插⼊删除快,但是查找慢。所以结合⼀下,形成⼀种新的数据结构:哈希链表。
LRU 缓存算法的核⼼数据结构就是哈希链表,双向链表和哈希表的结合体。这个数据结构⻓这样:
思想很简单,就是借助哈希表赋予了链表快速查找的特性嘛:可以快速查找某个 key 是否存在缓存(链表)中,同时可以快速删除、添加节点。回想刚才的例⼦,这种数据结构是不是完美解决了 LRU 缓存的需求?
也许读者会问,为什么要是双向链表,单链表⾏不⾏?另外,既然哈希表中已经存了 key,为什么链表中还要存键值对呢,只存值不就⾏了?
想的时候都是问题,只有做的时候才有答案。这样设计的原因,必须等我们亲⾃实现 LRU 算法之后才能理解,所以我们开始看代码吧〜
代码实现
很多编程语⾔都有内置的哈希链表或者类似 LRU 功能的库函数,但是为了帮⼤家理解算法的细节,我们⽤ Java ⾃⼰造轮⼦实现⼀遍 LRU 算法。
⾸先,我们把双链表的节点类写出来,为了简化,key 和 val 都认为是 int 类型:
然后依靠我们的 Node 类型构建⼀个双链表,实现⼏个需要的 API(这些操作的时间复杂度均为 O(1) ):
PS:这就是普通双向链表的实现,为了让读者集中精⼒理解 LRU 算法的逻辑,就省略链表的具体代码。
到这⾥就能回答刚才“为什么必须要⽤双向链表”的问题了,因为我们需要删除操作。删除⼀个节点不光要得到该节点本⾝的指针,也需要操作其前驱节点的指针,⽽双向链表才能⽀持直接查找前驱,保证操作的时间复杂度
O(1) 。
有了双向链表的实现,我们只需要在 LRU 算法中把它和哈希表结合起来即可。我们先把逻辑理清楚:
如果能够看懂上述逻辑,翻译成代码就很容易理解了:
这⾥就能回答之前的问答题“为什么要在链表中同时存储 key 和 val,⽽不是只存储 val”,注意这段代码:
当缓存容量已满,我们不仅仅要删除最后⼀个 Node 节点,还要把 map 中映射到该节点的 key 同时删除,⽽这个 key 只能由 Node 得到。如果 Node 结构中只存储 val,那么我们就⽆法得知 key 是什么,就⽆法删除 map 中的键,造成错误。
⾄此,你应该已经掌握 LRU 算法的思想和实现了,很容易犯错的⼀点是: 处理链表节点的同时不要忘了更新哈希表中对节点的映射。