什么是 LRU
LRU(Least Recently Used,最近最少使用)算法是一种内存数据淘汰策略,当内存不足时,需要淘汰最近最少使用的数据。
其核心思想是长期不被使用的数据,在未来被使用到的几率也不大。因此,当数据所占内存达到一定阈值的时候需要淘汰掉这些数据。
LRU 原理
按照 LRU 的核心思想,不被使用的数据,在未来被使用到的几率也不大,那么当数据在最近一段时间经常被访问,那么它在以后也会经常被访问。这就意味着,如果经常访问的数据,我们需要然其能够快速命中,而不常访问的数据,我们在容量超出限制内,要将其淘汰。即:
- 当新数据插入到链表头部;
- 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
- 当链表满的时候,将链表尾部的数据丢弃。
LRU 的实现
常用的数据结构有很多,如数组、链表、栈、队列等等,这里演示 2 种做法,单链表和双向链表 + 哈希表实现。
为什么要先用单链表去实现?其目的是为了从简单到复杂的去了解 LRU 的设计过程。
当前,前提是你得了解单双链表的数据结构,不了解的可以参考:手写链表之 LinkedList 源码分析
单链表实现 LRU
既然要用单链表实现,首先得定义一个单链表,并且具备增删改查功能,如下:
1、先定义链表节点信息
/**
* 节点信息
*
* @param <E>
*/
private static class Node<E> {
// 元素
E item;
// 后继节点(这里是单链表,只有一个后继节点)
Node<E> next;
public Node(E item, Node<E> next) {
this.item = item;
this.next = next;
}
}
2、定义增删方法
/**
* 在头部添加节点,一般来说是在尾部添加,但这里为了演示 LRU 所以从头添加元素
*
* @param e
*/
public void addFirst(E e) {
Node<E> curNode = new Node<>(e, list);
list = curNode;
size++;
}
/**
* 删除最后一个元素
*/
public void removeLast() {
if (list != null) {
// 当前节点
Node<E> cur = list;
// 最后一个节点的前一个节点
Node<E> pre = list;
// 判断当前节点是否是最后一个节点
while (cur.next != null) {
pre = cur;
cur = cur.next;
}
pre = null;
size--;
}
}
完整代码:
public class SingleLinkeList<E> {
// 链表
Node<E> list;
// 链表节点数量
int size;
/**
* 在头部添加节点,一般来说是在尾部添加,但这里为了演示 LRU 所以从头添加元素
*
* @param e
*/
public void addFirst(E e) {
Node<E> curNode = new Node<>(e, list);
list = curNode;
size++;
}
/**
* 删除最后一个元素
*/
public void removeLast() {
if (list != null) {
// 当前节点
Node<E> cur = list;
// 最后一个节点的前一个节点
Node<E> pre = list;
// 判断当前节点是否是最后一个节点
while (cur.next != null) {
pre = cur;
cur = cur.next;
}
pre = null;
size--;
}
}
/**
* 检测index是否在链表范围以内
*
* @param index
*/
public void checkPositionIndex(int index) {
if (!(index >= 0 && index <= size)) {
throw new IndexOutOfBoundsException("index: " + index + ", size: " + size);
}
}
/**
* 节点信息
*
* @param <E>
*/
private static class Node<E> {
// 元素
E item;
// 后继节点(这里是单链表,只有一个后继节点)
Node<E> next;
public Node(E item, Node<E> next) {
this.item = item;
this.next = next;
}
}
@Override
public String toString() {
Node node = list;
for (int i = 0; i < size; i++) {
System.out.print(node.item + " ");
node = node.next;
}
System.out.println();
return super.toString();
}
}
注:以上只写了对 LRU 有用的新增和删除方法,单链表的查询和修改并没有添加。
单链表实现 LRU 缓存
1、定义内存限制
既然是缓存,内存大小肯定是存在限制的,因此我们要定义其内存大小,如下:
// 用于限定内存空间大小,也就是缓存的大小
int size;
// 默认容量,假设为 5
static final int DEFAULT_CAP = 5;
2、添加元素
在添加元素的时候我们需要考虑以下 2 点:
- 当链表满的时候,将链表尾部的数据丢弃;
- 新数据插入到链表头部。
/**
* LRU 添加节点
*
* @param e
*/
public void lruAdd(E e) {
// 如果链表的容量大于缓存容量,将链表尾部的数据丢弃
if (size > memory_size) {
removeLast();
addFirst(e);
} else {
// 否则新数据插入到链表头部
addFirst(e);
}
}
3、LRU 删除
对于删除元素,只需将链表的最后一个元素删除即可。
/**
* 删除
*/
public void lruRemove() {
removeLast();
}
4、LRU 访问
对于访问的数据,则将数据移到链表头部。
/**
* 获取
*/
public E lruGet(int index) {
// 检查是否在链表范围内
checkPositionIndex(index);
// 目标节点,即 index 位置的节点
Node<E> cur = list;
// 目标节点的前一个节点
Node<E> pre = list;
// for 循环获取目标节点
for (int i = 0; i < index; i++) {
pre = cur;
cur = cur.next;
}
//获取目标元素
E item = cur.item;
// 把目标节点的后继节点重新赋值给目标节点前置节点的后置节点
pre.next = cur.next;
Node<E> head = list;
// 然后把目标节点移动到头结点,即把剩下的节点当做目标节点的后继节点
cur.next = head;
// 重新赋值给 list
list = cur;
return item;
}
完整代码:
/**
* LRU 的3要素
* 1. 当新数据插入到链表头部
* 2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部
* 3. 当链表满的时候,将链表尾部的数据丢弃
*/
public class LRUCache<E> extends SingleLinkeList<E> {
// 用于限定内存空间大小,也就是缓存的大小
int memory_size;
// 默认容量,假设为 5
static final int DEFAULT_CAP = 5;
public LRUCache() {
this(DEFAULT_CAP);
}
public LRUCache(int memory_size) {
this.memory_size = DEFAULT_CAP;
}
/**
* LRU 添加节点
*
* @param e
*/
public void lruAdd(E e) {
// 如果链表的容量大于缓存容量,将链表尾部的数据丢弃
if (size > memory_size) {
removeLast();
addFirst(e);
} else {
// 否则新数据插入到链表头部
addFirst(e);
}
}
/**
* 删除
*/
public void lruRemove() {
removeLast();
}
/**
* 获取
*/
public E lruGet(int index) {
// 检查是否在链表范围内
checkPositionIndex(index);
// 目标节点,即 index 位置的节点
Node<E> cur = list;
// 目标节点的前一个节点
Node<E> pre = list;
// for 循环获取目标节点
for (int i = 0; i < index; i++) {
pre = cur;
cur = cur.next;
}
//获取目标元素
E item = cur.item;
// 把目标节点的后继节点重新赋值给目标节点前置节点的后置节点
pre.next = cur.next;
Node<E> head = list;
// 然后把目标节点移动到头结点,即把剩下的节点当做目标节点的后继节点
cur.next = head;
// 重新赋值给 list
list = cur;
return item;
}
}
测试:
public static void main(String[] args) {
LRUCache<Integer> lruCache = new LRUCache<>(5);
// 初始化元素
for (int i = 1; i < 5; i++) {
lruCache.lruAdd(i);
}
System.out.println("初始化元素");
lruCache.toString();
System.out.println("获取第 3 个位置的元素:" + lruCache.lruGet(3));
// 此时,应该把第 3 个位置的元素设置为头结点
lruCache.toString();
lruCache.lruAdd(10);
System.out.println("新增第一个元素");
lruCache.toString();
lruCache.lruAdd(15);
System.out.println("新增第二个元素");
lruCache.toString();
}
输出:
初始化元素
4 3 2 1
获取第 3 个位置的元素:1
1 4 3 2
新增第一个元素
10 1 4 3 2
新增第二个元素
15 10 1 4 3
双链表实现 LRU
除了单链表 JDK 中自带了双向链表的实现,即 LinkedList,但如果在面试的情况下,可能需要你实现一个简单的双向链表,而不是用现有的。
因此,我们按照单链表的实现方式来实现一个简单的双向链表,与单链表不同的是,节点有前驱和后继 2 个引用,如下:
节点构造如下:
class Node<E> {
E item;
Node<E> prev;
Node<E> next;
public Node(E item, Node<E> prev, Node<E> next) {
this.item = item;
this.prev = prev;
this.next = next;
}
}
然后构建一个新增头节点和删除尾结点的方法,参考 JDK 中的 LinkedList,如下:
public class LinkedList<E> {
// 头结点
Node<E> head;
// 尾结点
Node<E> tail;
// 链表节点数量
int size;
/**
* 添加头结点
*
* @param e
*/
public void addFirst(E e) {
Node<E> f = head;
// 新添加的元素作为新的头结点
Node<E> newNode = new Node<>(e, null, f);
head = newNode;
// 如果原头结点为 null,则说明原链表不存在数据,即空链表,因此头尾节点相等
if (f == null) {
tail = newNode;
} else {
// 否则,将原头结点的前置指针指向新的节点
f.prev = newNode;
}
size++;
}
/**
* 删除最后一个元素,参考 LinkedList
*/
public E removeLast() {
// 1.这里要先判断链表是否为空,为空直接抛异常
final Node<E> h = head;
if (h == null) {
throw new NoSuchElementException();
}
Node<E> t = tail;
// 2.取出尾结点的值,用于方法返回
E element = t.item;
// 3.获取尾结点的前置节点
Node<E> prev = t.prev;
t.item = null;
t.prev = null; // help GC
// 4.将原尾结点的前置节点变为新的尾结点
tail = prev;
// 5.如果原尾结点的前置节点为 null,则说明当前链表只有一个元素,因此需将原头结点置为 null
if (prev == null) {
head = null;
} else {
// 6.否则将 prev 的后置节点置为 null,因为此时的 prev 为当前链表的尾结点
prev.next = null;
}
size--;
return element;
}
/**
* 遍历双向链表,从头结点开始遍历
*/
public String showList() {
// 判断链表是否为空
if (head.next == null && head.item == null) {
System.out.println("链表为空");
return null;
}
Node<E> temp = head;
String s = String.valueOf(temp.item);
while (temp.next != null) {
temp = temp.next;
s = s + " " + String.valueOf(temp.item);
}
return s;
}
class Node<E> {
E item;
Node<E> prev;
Node<E> next;
public Node(E item, Node<E> prev, Node<E> next) {
this.item = item;
this.prev = prev;
this.next = next;
}
}
}
对于查找元素来说,JDK 中的 LinkedList 先判断想要找的节点是在集合的前半部分还是后半部分,如果是在前半部分,则从前往后查找,如果是在后半部分,则从后往前查找,这样做的目的是提高效率而不需要从头到尾去扫描。
但这里实现 LRU 的查找时为了简单则是从头到尾遍历,但有两个点需要注意:
- 如果查询 index 位置的是头节点,则直接返回头节点的值;
- 如果查询 index 位置的是尾节点,则在移动目标节点到头节点的时候,将目标节点的前置节点的后置节点置为 null 即可。
完整代码:
public class LRUCache<E> extends LinkedList<E> {
// 用于限定内存空间大小,也就是缓存的大小
int memory_size;
// 默认容量,假设为 5
static final int DEFAULT_CAP = 5;
public LRUCache() {
this(DEFAULT_CAP);
}
public LRUCache(int memory_size) {
this.memory_size = memory_size;
}
/**
* 添加节点
*
* @param e
*/
public void put(E e) {
// 如果链表的容量大于缓存容量,将链表尾部的数据丢弃
if (size >= memory_size) {
removeLast();
addFirst(e);
} else {
// 否则新数据插入到链表头部
addFirst(e);
}
}
/**
* 获取
*
* @param index
* @return
*/
public E get(int index) {
// 这里省略 index 范围判断,懂的都懂,懒得写了
// 目标节点,即 index 位置的节点
Node<E> curr = head;
// 如果是 index = 0,则说明是首节点,无需移动
if (index == 0)
return curr.item;
//查找 index 位置的元素
for (int i = 0; i < index; i++) {
curr = curr.next;
}
// 获取目标元素
E element = curr.item;
// 目标节点的前一个节点
Node<E> prev = curr.prev;
// 目标节点的后一个节点
Node<E> next = curr.next;
// 然后把当前节点移动到头结点
// 如果 next 为 null,则说明 index 位置的节点是尾结点
if (next == null) {
prev.next = null;
// 同时尾结点为 prev节点
tail = prev;
} else {
prev.next = next;
next.prev = prev;
}
curr.prev = null;
curr.next = head;
head = curr;
return element;
}
}
测试:
public static void main(String[] args) {
LRUCache<Integer> node = new LRUCache<>(5);
// 初始化元素
for (int i = 1; i < 5; i++) {
node.put(i);
}
System.out.println("初始化:" + node.showList());
node.get(2);
System.out.println("获取一个元素:" + node.showList());
node.put(10);
System.out.println("添加第一个元素:" + node.showList());
node.put(15);
System.out.println("添加第二个元素:" + node.showList());
}
结果如下:
链表 + 哈希表
上面通过单双链表的形式实现了 LRU,相对来说比较简单,而针对上面的操作,我们可以发现,当查询 index 位置的元素时,需要循环遍历 index 次,时间复杂度为O(n)
,查询比较慢,双向链表同理,当然增删是很快的,那么为了使其查询速度快,可以使用链表 + 哈希表的方式,哈希表的搜索可以达到0(1)
时间复杂度。
用一个哈希表和一个双向链表维护所有在缓存中的键值对。
- 双向链表按照被使用的顺序存储这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。
- 哈希表即为普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。
既然用 HashMap,则原 Node 需要再添加一个 key 属性,如下:
class Node<K, V> {
K key;
V value;
Node<K, V> prev;
Node<K, V> next;
public Node(K key, V value, Node<K, V> prev, Node<K, V> next) {
this.key = key;
this.value = value;
this.prev = prev;
this.next = next;
}
}
思路
-
构建双向链表节点 Node,应包含
key,value,prev,next
属性; -
定义 HashMap 用于存放 Node 节点;
-
对于
get
操作,在 HashMap 通过key
获取- 如果存在,则将该 Node 移到链表头部
- 如果不存在,则直接返回 Null
-
对于
put
操作,如下:
代码实现
import java.util.HashMap;
import java.util.NoSuchElementException;
public class LRUCache<K, V> {
// 容量
private int capacity;
// 默认容量,假设为 5
static final int DEFAULT_CAP = 5;
// 头结点
Node<K, V> head;
// 尾结点
Node<K, V> tail;
// 用来存储映射
HashMap<K, Node<K, V>> map;
public LRUCache() {
this(DEFAULT_CAP);
}
public LRUCache(int initialCapacity) {
capacity = initialCapacity;
map = new HashMap<>();
}
static class Node<K, V> {
K key;
V value;
Node<K, V> prev;
Node<K, V> next;
public Node(K key, V value, Node<K, V> prev, Node<K, V> next) {
this.key = key;
this.value = value;
this.prev = prev;
this.next = next;
}
}
/**
* 添加头结点
*/
public void addFirst(K key, V val) {
Node<K, V> h = head;
// 新添加的元素作为新的头结点
Node<K, V> newNode = new Node<>(key, val, null, h);
head = newNode;
// 如果原头结点为 null,则说明原链表不存在数据,即空链表,因此头尾节点相等
if (h == null) {
tail = newNode;
} else {
// 否则,将原头结点的前置指针指向新的节点
h.prev = newNode;
}
map.put(key, newNode);
}
public V removeLast() {
// 1.这里要先判断链表是否为空,为空直接抛异常
final Node<K, V> h = head;
if (h == null) {
throw new NoSuchElementException();
}
Node<K, V> t = tail;
// 2.取出尾结点的值,用于方法返回
V value = t.value;
// 3.获取尾结点的前置节点
Node<K, V> prev = t.prev;
t.key = null;
t.value = null;
t.prev = null; // help GC
// 4.将原尾结点的前置节点变为新的尾结点
tail = prev;
// 5.如果原尾结点的前置节点为 null,则说明当前链表只有一个元素,因此需将原头结点置为 null
if (prev == null) {
head = null;
} else {
// 6.否则将 prev 的后置节点置为 null,因为此时的 prev 为当前链表的尾结点
prev.next = null;
}
map.remove(t.key);
return value;
}
/**
* 移动节点到链表头部
*
* @param curr
* @return
*/
public V moveToHead(Node<K, V> curr) {
// 目标节点,即 key 位置的节点
// 判断是否为首节点,时则无需移动
if (curr == head)
return curr.value;
// 目标节点的前一个节点
Node<K, V> prev = curr.prev;
// 目标节点的后一个节点
Node<K, V> next = curr.next;
// 然后把当前节点移动到头结点
// 如果 next 为 null,则说明 index 位置的节点是尾结点
if (next == null) {
prev.next = null;
// 同时尾结点为 prev节点
tail = prev;
} else {
// 如果目标节点的前一个节点的前置节点为null,则说明目标节点是链表的第二个节点,
// 那么目标节点的前一个节点就是头结点,原头结点为前置节点为null,因此要重新赋值
if (prev.prev == null) {
prev.prev = curr;
}
prev.next = next;
next.prev = prev;
}
curr.prev = null;
curr.next = head;
head = curr;
return curr.value;
}
public void put(K key, V val) {
Node<K, V> curr = map.get(key);
// 如果为空,则添加
if (curr == null) {
// 先判断容量,未满则添加
if (map.size() < capacity) {
addFirst(key, val);
} else {
// 否则先删除链表尾部的数据
removeLast();
addFirst(key, val);
}
} else {
// 如果不为空,则修改 key 对应的 value 值,并移动到头结点
curr.value = val;
moveToHead(curr);
}
}
public V get(K key) {
Node<K, V> curr = map.get(key);
// 如果为 null 直接返回
if (curr == null) {
return null;
} else {
// 否者把他移动到链表头部
return moveToHead(curr);
}
}
/**
* 遍历双向链表,从头结点开始遍历
*/
public String showList() {
// 判断链表是否为空
if (head.next == null && head.value == null) {
System.out.println("链表为空");
return null;
}
Node<K, V> temp = head;
String s = String.valueOf(temp.value);
while (temp.next != null) {
temp = temp.next;
s = s + " " + String.valueOf(temp.value);
}
return s;
}
}
其实现其实和双链表大致一致,不同的就是添加了 HashMap 用于存放 Node 节点。
测试:
public static void main(String[] args) {
LRUCache<String, String> cache = new LRUCache<>(5);
// 初始化元素
cache.put("key1", "1");
cache.put("key2", "2");
cache.put("key3", "3");
cache.put("key4", "4");
System.out.println("初始化:" + cache.showList());
cache.get("key3");
System.out.println("获取一个元素:" + cache.showList());
cache.put("key10", "10");
System.out.println("添加第一个元素:" + cache.showList());
cache.put("key20", "20");
System.out.println("添加第二个元素:" + cache.showList());
// 添加 key 相同的元素
cache.put("key4", "40");
System.out.println("添加第二个元素:" + cache.showList());
}
输出:
LinkedHashMap
JDK 中其实有哈希表和双向链表组合,就是 LinkedHashMap, 它就是 HashMap 和 LinkedList 的一个结合。
LinkedHashMap 中本身就实现了一个方法 removeEldestEntry 用于判断是否需要移除最不常读取的数,方法默认是直接返回false,不会移除元素,所以需要重写该方法,即当缓存满后就移除最不常用的数。
我们可以直接使用它的方法来完成一个 LRU,代码如下:
import java.util.LinkedHashMap;
import java.util.Map;
class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75F, true);
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}