LRU缓存机制(146)

今天为大家分享很出名的 LRU 算法,第一讲共包括 4 节。

  • LRU概述
  • LRU使用
  • LRU实现
  • Redis近LRU概述

01、LRU 概述

LRU 是 Least Recently Used 的缩写,译为最近最少使用。它的理论基础为“最近使用的数据会在未来一段时期内仍然被使用,已经很久没有使用的数据大概率在未来很长一段时间仍然不会被使用”由于该思想非常契合业务场景 ,并且可以解决很多实际开发中的问题,所以我们经常通过 LRU 的思想来作缓存,一般也将其称为LRU缓存机制。因为恰好 leetcode 上有这道题,所以我干脆把题目贴这里。但是对于 LRU 而言,希望大家不要局限于本题(大家不用担心学不会,我希望能做一个全网最简单的版本,希望可以坚持看下去!)下面,我们一起学习一下。

第146题:LRU缓存机制
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作:获取数据 get 和 写入数据 put 。

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


写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。


进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?


示例:

  1. LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
  2. cache.put(1, 1);
  3. cache.put(2, 2);
  4. cache.get(1); // 返回 1
  5. cache.put(3, 3); // 该操作会使得密钥 2 作废
  6. cache.get(2); // 返回 -1 (未找到)
  7. cache.put(4, 4); // 该操作会使得密钥 1 作废
  8. cache.get(1); // 返回 -1 (未找到)
  9. cache.get(3); // 返回 3
  10. cache.get(4); // 返回 4

02、LRU 使用(解释)

由于我实在担心部分同学完全懵逼零基础,所以我把上面的LRUCache的示例解释一下。

第一步:我们申明一个 LRUCache ,长度为 2。

PNG

第二步:我们分别向 cache 里边 put(1,1) 和 put(2,2),这里因为最近使用的是 2( put 也算作使用)所以2在前,1 在后。

PNG

第三步:我们 get(1),也就是我们使用了 1,所以需要将 1 移到前面。

PNG

第四步:此时我们 put(3,3),因为 2 是最近最少使用的,所以我们需要将 2 进行作废。此时我们再 get(2),就会返回 -1 。

PNG

第五步:我们继续 put(4,4),同理我们将 1 作废。此时如果 get(1) ,也是返回 -1 。

PNG

第六步:此时我们 get(3) ,实际为调整 3 的位置。

PNG

第七步:同理,get(4),继续调整 4 的位置。

PNG

03、LRU 实现(层层剖析)

上面的图搞了我半小时,基本不是弱智的话,应该都能理解 LRU 的使用了。现在我们聊一下实现。LRU 一般来讲,我们是使用双向链表实现。基本上在面试的时候,能写出来双向链表的实现,已经可以打 9 分了。但是这里我要强调的是,其实在项目中,并不绝对是这样。比如 Redis 源码里,LRU 的淘汰策略,就没有使用双向链表,而是使用一种模拟链表的方式。因为 Redis 大多是当内存在用(我知道可以持久化),如果再在内存中去维护一个链表,就平添了一些复杂性,同时也会多耗掉一些内存,后面我会单独拉出来 Redis 的源码给大家分析,这里不细说。


回到题目,为什么我们要选择双向链表来实现呢?看看上面的使用步骤图,大家会发现,在整个 LRUCache 的使用中,我们需要频繁的去调整首尾元素的位置。而双向链表的结构,刚好满足这一点(再啰嗦一下,前几天我刚好看了 groupcache 的源码,里边就是用双向链表来做的 LRU,当然它里边做了一些改进。groupcache 是memcache 作者实现的 go 版本,如果有 go 的读者,可以去看看源码,还是有一些收获。)


下面,我们采用 hashmap+ 双向链表的方式进行实现。


首先,我们定义一个 LinkNode ,用以存储元素。因为是双向链表,自然我们要定义 pre 和 next。同时,我们需要存储下元素的 key 和 value 。val 大家应该都能理解,关键是为什么需要存储 key?举个例子,比如当整个cache 的元素满了,此时我们需要删除 map 中的数据,需要通过 LinkNode 中的 key 来进行查询,否则无法获取到 key。

  1. type LinkNode struct {
  2. key, val int
  3. pre, next *LinkNode
  4. }

现在有了 LinkNode ,自然需要一个 Cache 来存储所有的 Node。我们定义 cap 为 cache 的长度,m 用来存储元素。head 和 tail 作为 Cache 的首尾。

  1. type LRUCache struct {
  2. m map[int]*LinkNode
  3. cap int
  4. head, tail *LinkNode
  5. }

接下来我们对整个 Cache 进行初始化。在初始化 head 和 tail 的时候将它们连接在一起。

  1. func Constructor(capacity int) LRUCache {
  2. head := &LinkNode{0, 0, nil, nil}
  3. tail := &LinkNode{0, 0, nil, nil}
  4. head.next = tail
  5. tail.pre = head
  6. return LRUCache{make(map[int]*LinkNode), capacity, head, tail}
  7. }

大概是这样:

PNG

现在我们已经完成了 Cache 的构造,剩下的就是添加它的 API 了。因为 Get 比较简单,我们先完成 Get 方法。这里分两种情况考虑,如果没有找到元素,我们返回 -1。如果元素存在,我们需要把这个元素移动到首位置上去。

  1. func (this *LRUCache) Get(key int) int {
  2. head := this.head
  3. cache := this.m
  4. if v, exist := cache[key]; exist {
  5. v.pre.next = v.next
  6. v.next.pre = v.pre 7
  7. v.next = head.next
  8. head.next.pre = v v.pre = head
  9. head.next = v
  10. return v.val
  11. } else {
  12. return -1
  13. }
  14. }

大概就是下面这个样子(假若 2 是我们 get 的元素)

PNG

我们很容易想到这个方法后面还会用到,所以将其抽出。

  1. func (this *LRUCache) AddNode(node *LinkNode) {
  2. head := this.head
  3. //从当前位置删除
  4. node.pre.next = node.next
  5. node.next.pre = node.pre
  6. //移动到首位置
  7. node.next = head.next
  8. head.next.pre = node
  9. node.pre = head
  10. head.next = node
  11. }
  12. func (this *LRUCache) Get(key int) int {
  13. cache := this.m
  14. if v, exist := cache[key]; exist {
  15. this.MoveToHead(v)
  16. return v.val
  17. } else {
  18. return -1
  19. }
  20. }

现在我们开始完成 Put。实现 Put 时,有两种情况需要考虑。假若元素存在,其实相当于做一个 Get 操作,也是移动到最前面(但是需要注意的是,这里多了一个更新值的步骤)。

  1. func (this *LRUCache) Put(key int, value int) {
  2. head := this.head
  3. tail := this.tail
  4. cache := this.m
  5. //假若元素存在
  6. if v, exist := cache[key]; exist {
  7. //1.更新值
  8. v.val = value
  9. //2.移动到最前
  10. this.MoveToHead(v)
  11. } else {
  12. //TODO
  13. }
  14. }

假若元素不存在,我们将其插入到元素首,并把该元素值放入到 map 中。

  1. func (this *LRUCache) Put(key int, value int) {
  2. head := this.head
  3. tail := this.tail
  4. cache := this.m
  5. //存在
  6. if v, exist := cache[key]; exist {
  7. //1.更新值
  8. v.val = value
  9. //2.移动到最前
  10. this.MoveToHead(v)
  11. } else {
  12. v := &LinkNode{key, value, nil, nil}
  13. v.next = head.next
  14. v.pre = head
  15. head.next.pre = v
  16. head.next = v
  17. cache[key] = v
  18. }
  19. }

但是我们漏掉了一种情况,如果恰好此时Cache中元素满了,需要删掉最后的元素。 处理完毕,附上 Put 函数完整代码。

  1. func (this *LRUCache) Put(key int, value int) {
  2. head := this.head
  3. tail := this.tail
  4. cache := this.m
  5. //存在
  6. if v, exist := cache[key]; exist {
  7. //1.更新值
  8. v.val = value
  9. //2.移动到最前
  10. this.MoveToHead(v)
  11. } else {
  12. v := &LinkNode{key, value, nil, nil}
  13. if len(cache) == this.cap {
  14. //删除最后元素
  15. delete(cache, tail.pre.key)
  16. tail.pre.pre.next = tail
  17. tail.pre = tail.pre.pre
  18. }
  19. v.next = head.next
  20. v.pre = head
  21. head.next.pre = v
  22. head.next = v
  23. cache[key] = v
  24. }
  25. }

PNG

最后,我们完成所有代码:

  1. type LinkNode struct {
  2. key, val int
  3. pre, next *LinkNode
  4. }
  5. type LRUCache struct {
  6. m map[int]*LinkNode
  7. cap int
  8. head, tail *LinkNode
  9. }
  10. func Constructor(capacity int) LRUCache {
  11. head := &LinkNode{0, 0, nil, nil}
  12. tail := &LinkNode{0, 0, nil, nil}
  13. head.next = tail
  14. tail.pre = head
  15. return LRUCache{make(map[int]*LinkNode), capacity, head, tail}
  16. }
  17. func (this *LRUCache) Get(key int) int {
  18. cache := this.m
  19. if v, exist := cache[key]; exist {
  20. this.MoveToHead(v)
  21. return v.val
  22. } else {
  23. return -1
  24. }
  25. }
  26. func (this *LRUCache) AddNode(node *LinkNode) {
  27. head := this.head
  28. //从当前位置删除
  29. node.pre.next = node.next
  30. node.next.pre = node.pre
  31. //移动到首位置
  32. node.next = head.next
  33. head.next.pre = node
  34. node.pre = head
  35. head.next = node
  36. }
  37. func (this *LRUCache) Put(key int, value int) {
  38. head := this.head
  39. tail := this.tail
  40. cache := this.m
  41. //存在
  42. if v, exist := cache[key]; exist {
  43. //1.更新值
  44. v.val = value
  45. //2.移动到最前
  46. this.MoveToHead(v)
  47. } else {
  48. v := &LinkNode{key, value, nil, nil}
  49. if len(cache) == this.cap {
  50. //删除最后元素
  51. delete(cache, tail.pre.key)
  52. tail.pre.pre.next = tail
  53. tail.pre = tail.pre.pre
  54. }
  55. v.next = head.next
  56. v.pre = head
  57. head.next.pre = v
  58. head.next = v
  59. cache[key] = v
  60. }
  61. }

优化后:

  1. type LinkNode struct {
  2. key, val int
  3. pre, next *LinkNode
  4. }
  5. type LRUCache struct {
  6. m map[int]*LinkNode
  7. cap int
  8. head, tail *LinkNode
  9. }
  10. func Constructor(capacity int) LRUCache {
  11. head := &LinkNode{0, 0, nil, nil}
  12. tail := &LinkNode{0, 0, nil, nil}
  13. head.next = tail
  14. tail.pre = head
  15. return LRUCache{make(map[int]*LinkNode), capacity, head, tail}
  16. }
  17. func (this *LRUCache) Get(key int) int {
  18. cache := this.m
  19. if v, exist := cache[key]; exist {
  20. this.MoveToHead(v)
  21. return v.val
  22. } else {
  23. return -1
  24. }
  25. }
  26. func (this *LRUCache) RemoveNode(node *LinkNode) {
  27. node.pre.next = node.next
  28. node.next.pre = node.pre
  29. }
  30. func (this *LRUCache) AddNode(node *LinkNode) {
  31. head := this.head
  32. node.next = head.next
  33. head.next.pre = node
  34. node.pre = head
  35. head.next = node
  36. }
  37. func (this *LRUCache) MoveToHead(node *LinkNode) {
  38. this.RemoveNode(node)
  39. this.AddNode(node)
  40. }
  41. func (this *LRUCache) Put(key int, value int) {
  42. tail := this.tail
  43. cache := this.m
  44. if v, exist := cache[key]; exist {
  45. v.val = value
  46. this.MoveToHead(v)
  47. } else {
  48. v := &LinkNode{key, value, nil, nil}
  49. if len(cache) == this.cap {
  50. delete(cache, tail.pre.key)
  51. this.RemoveNode(tail.pre)
  52. }
  53. this.AddNode(v)
  54. cache[key] = v
  55. }
  56. }

执行结果:

PNG

因为该算法过于重要,给一个 Java 版本的:

  1. //java版本
  2. import java.util.Hashtable;
  3. public class LRUCache {
  4. class DLinkedNode {
  5. int key;
  6. int value;
  7. LinkedNode prev;
  8. LinkedNode next;
  9. }
  10. private void addNode(DLinkedNode node) {
  11. node.prev = head;
  12. node.next = head.next;
  13. head.next.prev = node;
  14. head.next = node;
  15. }
  16. private void removeNode(DLinkedNode node){
  17. LinkedNode prev = node.prev;
  18. LinkedNode next = node.next;
  19. prev.next = next;
  20. next.prev = prev;
  21. }
  22. private void moveToHead(DLinkedNode node){
  23. removeNode(node);
  24. addNode(node);
  25. }
  26. private DLinkedNode popTail() {
  27. DLinkedNode res = tail.prev;
  28. removeNode(res);
  29. return res;
  30. }
  31. private Hashtable<Integer, DLinkedNode> cache =
  32. new Hashtable<Integer, DLinkedNode>();
  33. private int size;
  34. private int capacity;
  35. private DLinkedNode head, tail;
  36. public LRUCache(int capacity) {
  37. this.size = 0;
  38. this.capacity = capacity;
  39. head = new DLinkedNode();
  40. tail = new DLinkedNode();
  41. head.next = tail;
  42. tail.prev = head;
  43. }
  44. public int get(int key) {
  45. DLinkedNode node = cache.get(key);
  46. if (node == null) return -1;
  47. moveToHead(node);
  48. return node.value;
  49. }
  50. public void put(int key, int value) {
  51. DLinkedNode node = cache.get(key);
  52. if(node == null) {
  53. DLinkedNode newNode = new DLinkedNode();
  54. newNode.key = key;
  55. newNode.value = value;
  56. cache.put(key, newNode);
  57. addNode(newNode);
  58. ++size;
  59. if(size > capacity) {
  60. DLinkedNode tail = popTail();
  61. cache.remove(tail.key);
  62. --size;
  63. }
  64. } else {
  65. node.value = value;
  66. moveToHead(node);
  67. }
  68. }
  69. }

04、Redis 近LRU 介绍

上文完成了咱们自己的 LRU 实现,现在现在聊一聊 Redis 中的近似 LRU 。由于真实LRU需要过多的内存(在数据量比较大时),所以 Redis 是使用一种随机抽样的方式,来实现一个近似 LRU 的效果。说白了,LRU 根本只是一个预测键访问顺序的模型

在 Redis 中有一个参数,叫做 “maxmemory-samples”,是干嘛用的呢?

  1. # LRU and minimal TTL algorithms are not precise algorithms but approximated # algorithms (in order to save memory), so you can tune it for speed or # accuracy. For default Redis will check five keys and pick the one that was # used less recently, you can change the sample size using the following # configuration directive. 6# # The default of 5 produces good enough results. 10 Approximates very closely # true LRU but costs a bit more CPU. 3 is very fast but not very accurate. # 10maxmemory-samples 5

上面我们说过了,近似LRU是用随机抽样的方式来实现一个近似的LRU效果。这个参数其实就是作者提供了一种方式,可以让我们人为干预样本数大小,将其设的越大,就越接近真实 LRU 的效果,当然也就意味着越耗内存。(初始值为 5 是作者默认的最佳)

PNG

这个图解释一下,绿色的点是新增加的元素,深灰色的点是没有被删除的元素,浅灰色的是被删除的元素。最下面的这张图,是真实 LRU 的效果,第二张图是默认该参数为 5 的效果,可以看到浅灰色部分和真实的契合还是不错的。第一张图是将该参数设置为 10 的效果,已经基本接近真实 LRU 的效果了。


今天基本就说到这里。那 Redis 中的近似 LRU 是如何实现的呢?因为时间的关系,我打算做到下一期的内容。最后,评论区留下你的想法吧!