参考https://zhuanlan.zhihu.com/p/75390654

LRU: Least Recently Used.最近最少使用(也有说最远最少使用或最久未使用,都对,看怎么理解。总而言之就是给元素一个“热度值”,对元素的操作会给其“添加热度”,在容器满了之后,再次添加时,删除掉“热度最低”的那个)

题目:

实现 LRUCache 类:

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

注意:get(key)操作会”使用key”,put()已经存在的key也会”使用key”。凡是“使用key”都会给key添加热度。
函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

示例:

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

来源:力扣(LeetCode)

实现1

分析:

要想O(1)时间复杂度存取元素,大概率是使用HashMap;而元素热度会变化,超出容量时低热度元素被挤掉,则可以使用链表来维护元素位置。

思路:

使用 双向链表 + HashMap实现。双向链表维护元素热度,HashMap储存元素。热度的维护靠移动元素到头部来实现,这样尾部自然就是热度最低的元素;那些会增加元素热度的操作,一律会将元素移动到头部;容量不够了,去尾部删除就行了。

代码:

  1. package com.example.demo.core.Leecode;
  2. import java.util.HashMap;
  3. /**
  4. * https://leetcode-cn.com/problems/lru-cache/
  5. *
  6. * LRU: Least Recently Used.最近最少使用
  7. * 双向链表 + HashMap,双向链表维护元素热度,HashMap储存元素
  8. * 维护热度就需要有 添加元素到头部,移动元素到头部,移除尾部元素 的操作;其中,移动元素到头部可以使用删除元素和添加元素到头部两个方法实现。所以底层基本操作要有3个(添加元素到头部、删除当前元素、移除尾部元素)。
  9. * @author hanxu03
  10. */
  11. public class LRUCache {
  12. /**
  13. * 双向链表节点
  14. */
  15. class Node {
  16. int key;
  17. int value;
  18. Node before;
  19. Node after;
  20. public Node() {
  21. }
  22. public Node(int key, int value) {
  23. this.key = key;
  24. this.value = value;
  25. }
  26. }
  27. /**
  28. * 真实储存数据的容器
  29. */
  30. private HashMap<Integer, Node> cache = new HashMap<>();
  31. /**
  32. * 容量
  33. */
  34. private int capacity;
  35. /**
  36. * 头尾指针,并不储存元素。第一个节点的前面是head,最后一个节点的后面是tail
  37. */
  38. private Node head, tail;
  39. /**
  40. * 初始化
  41. * @param capacity
  42. */
  43. public LRUCache(int capacity) {
  44. //赋值容量
  45. this.capacity = capacity;
  46. //创建头尾指针
  47. head = new Node();
  48. head.before = null;
  49. tail = new Node();
  50. tail.after = null;
  51. //连接头尾结点
  52. head.after = tail;
  53. tail.before = head;
  54. }
  55. /**
  56. * 插入节点到链表头部
  57. * @return
  58. */
  59. private void addToHead(Node node) {
  60. //只知道node应该在head后面,不知道node后面是啥,所以操作时只能使用head与node
  61. node.before = head;
  62. node.after = head.after;
  63. //顺讯不能调换
  64. head.after.before = node;
  65. head.after = node;
  66. }
  67. /**
  68. * 移除双向链表中的某个节点
  69. * @param node
  70. */
  71. private void removeNode(Node node) {
  72. Node before = node.before;
  73. Node after = node.after;
  74. before.after = after;
  75. after.before = before;
  76. }
  77. /**
  78. * 将某节点移动到双向链表头部(head后)
  79. * @param node
  80. */
  81. private void moveToHead(Node node) {
  82. //删除当前位置
  83. removeNode(node);
  84. //添加到head后
  85. addToHead(node);
  86. }
  87. /**
  88. * 删除尾部节点(tail前)
  89. * @return
  90. */
  91. private Node delTail() {
  92. //获取,删除,返回
  93. Node node = tail.before;
  94. removeNode(node);
  95. return node;
  96. }
  97. public int get(int key) {
  98. Node node = cache.get(key);
  99. if (node == null) {
  100. return -1;
  101. }
  102. //加热元素(移到头部)
  103. moveToHead(node);
  104. return node.value;
  105. }
  106. public void put(int key, int value) {
  107. // (注意判定顺序不能弄混)
  108. // 1、判断是不是新node:是就添加,不是就更新
  109. // 2、判断添加后是否超出容量:是就移除尾部元素
  110. Node node = cache.get(key);
  111. //如果是新Node
  112. if (node == null) {
  113. Node newNode = new Node(key, value);
  114. //新来的都是最热的(放在头部)
  115. addToHead(newNode);
  116. cache.put(key, newNode);
  117. //如果添加后容量超出,则弹出尾部元素
  118. if (cache.size() > capacity) {
  119. Node lastNode = delTail();
  120. cache.remove(lastNode.key);
  121. }
  122. } else {
  123. //不是新Node就替换vale,并加热元素(放到链表前面)
  124. node.value = value;
  125. moveToHead(node);
  126. }
  127. }
  128. public static void main(String[] args) {
  129. LRUCache lruCache = new LRUCache(2);
  130. lruCache.put(1, 1);
  131. lruCache.put(2, 2);
  132. System.out.println(lruCache.get(1)); // 1
  133. lruCache.put(3, 3); //
  134. System.out.println(lruCache.get(2)); // -1
  135. lruCache.put(4, 4);
  136. System.out.println(lruCache.get(1)); // -1
  137. System.out.println(lruCache.get(3)); // 3
  138. System.out.println(lruCache.get(4)); // 4
  139. }
  140. }

实现2

思路:

JDK中的LinkedHashMap,可以实现元素按照访问顺序倒叙输出:(先访问的最后迭代出来,就是把先访问的放在后面)

注意,第三个入参accessOrder需要为true才能实现这个效果。

  1. public static void main(String[] args) {
  2. LinkedHashMap<Object, Object> linkedHashMap = new LinkedHashMap<>(2, 0.75F, true);
  3. linkedHashMap.put(1, 1);
  4. linkedHashMap.put(2, 2);
  5. linkedHashMap.get(1);
  6. linkedHashMap.put(3, 3);
  7. linkedHashMap.put(4, 4);
  8. System.out.println(linkedHashMap);
  9. /*
  10. 头部 ----> 尾部
  11. 本来是 1,1 2,2
  12. 当get(1)时,会把1,1放在尾部
  13. 变成了 2,2 1,1 再放入3,3 4,4
  14. 变成了 2,2 1,1 3,3 4,4
  15. 输出时从头部到尾部输出
  16. */
  17. //{2=2, 1=1, 3=3, 4=4}
  18. }

LinkedHashMap继承HashMap,HashMap的putVal方法在添加元素后,又会执行一个afterNodeInsertion(evict);方法,这个方法就是添加元素之后做的事情。它在HashMap中是一个空方法,子类可以实现它。

  1. void afterNodeInsertion(boolean evict) { }

而LinkedHashMap的实现是这样的:

  1. void afterNodeInsertion(boolean evict) { // possibly remove eldest
  2. LinkedHashMap.Entry<K,V> first;
  3. if (evict && (first = head) != null && removeEldestEntry(first)) {
  4. K key = first.key;
  5. removeNode(hash(key), key, null, false, true);
  6. }
  7. }

当满足(evict && (first = head) != null && removeEldestEntry(first))这些条件时,会将头部元素删除(removeNode(hash(key), key, null, false, true))。

我们想一下,尾部元素是最近常访问的,那头部元素自然就是最近不常访问的了,所以删除头部元素刚好能满足我们的诉求。

接下来的问题就是什么时候删,总不能每次添加元素都把尾部删除吧。我们来看这个条件:

evict传过来就是true,有元素时head!=null,剩下的就是removeEldestEntry(first)了。我们的诉求是当空间不够时删除尾部元素。所以我们只需要控制removeEldestEntry这个方法,当空间不够时返回true即可!

  1. @Override
  2. protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
  3. return size() > capacity;
  4. }

总结一下这个实现思路:LinkedHashMap可以控制元素按照访问顺序倒叙遍历(内部将常访问的元素放在尾部),又有一个方法afterNodeInsertion可以在添加元素后干一些事情,而LinkedHashMap的实现就是删除头部元素,所以我们可以自由定制我们要在什么条件下删除那些元素。

代码:

  1. package com.example.demo.core.Leecode;
  2. import java.util.LinkedHashMap;
  3. import java.util.Map;
  4. /**
  5. * @author: HanXu
  6. * on 2022/2/24
  7. * Class description: JDK中的LinkedHashMap实现LRU
  8. */
  9. public class LRUCache2 extends LinkedHashMap<Integer, Integer> {
  10. private int capacity;
  11. public LRUCache2(int capacity) {
  12. //这里必须为true
  13. super(capacity, 0.75F, true);
  14. this.capacity = capacity;
  15. }
  16. public int get(int key) {
  17. return super.getOrDefault(key, -1);
  18. }
  19. public void put(int key, int value) {
  20. super.put(key, value);
  21. }
  22. /**
  23. * 这个必须有,控制添加元素后,哪些情况下删除头部元素
  24. * @param eldest
  25. * @return
  26. */
  27. @Override
  28. protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
  29. return size() > capacity;
  30. }
  31. public static void main(String[] args) {
  32. LRUCache2 lruCache2 = new LRUCache2(2);
  33. lruCache2.put(1, 1);
  34. lruCache2.put(2, 2);
  35. System.out.println(lruCache2.get(1)); // 1
  36. lruCache2.put(3, 3); //
  37. System.out.println(lruCache2.get(2)); // -1
  38. lruCache2.put(4, 4);
  39. System.out.println(lruCache2.get(1)); // -1
  40. System.out.println(lruCache2.get(3)); // 3
  41. System.out.println(lruCache2.get(4)); // 4
  42. }
  43. }

为什么需要双向链表+hashMap?

使用其他数据结构能实现吗?可以。只不过双向链表+hasMap是相对最优解。
这里的双向链表能换成单向链表吗?能,就算只使用单向链表也是可以的。
但如果换成单向链表,删除node节点时,就需要从前向后遍历,拿到node的前驱节点,才能删除;而双向链表,则可以直接获得node的前驱节点。双向链表+hashMap,所有操作时间复杂度都趋向于O(1)。