前言
大家好,我是jack xu,大家不管是在刷leetcode的时候让你手写LRU算法,还是面试中让你手写LRU算法,都会碰到LRU算法。不管是为了应付面试还是平时工作中用的到,都是你学习本文的动力。
LRU 算法,全称是Least Recently Used。翻译过来就是最近最少使用算法: 如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。所以,当指定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。
leetcode真题
链接:leetcode-cn.com/problems/lr… ,这道题的难度是middle,所以更应该掌握了。
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put。
- 获取数据 get(key) - 如果关键字 (key) 存在于缓存中,则获取关键字的值(总是正数),否则返回 -1。
- 写入数据 put(key, value) - 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字/值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?
示例:
LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得关键字 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得关键字 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
数据结构选型
题目中要求,查找和插入的时间复杂度都需要是O(1),有两种实现方式,一种是自己设计数据结构,通过双向链表+哈希表,另一种是用Java中帮我们封装好的LinkedHashMap,但一般这不是面试官想听到的完美答案。
接下来分析一下选型,如果我们想要查询和插入的时间复杂度都是 O(1),那么我们需要一个满足下面三个条件的数据结构:
- 1.首先这个数据结构必须是有时序的,以区分最近使用的和很久没有使用的数据,当容量满了之后,要删除最久未使用的那个元素。
- 2.要在这个数据结构中快速找到某个 key 是否存在,并返回其对应的 value。
- 3.每次访问这个数据结构中的某个 key,需要将这个元素变为最近使用的。也就是说,这个数据结构要支持在任意位置快速插入和删除元素。
查找快,我们能想到哈希表。但是哈希表的数据是乱序的。
有序,我们能想到链表,插入、删除都很快,但是查询慢。
所以,我们得让哈希表和链表结合一下,成长一下,形成一个新的数据结构,那就是:哈希链表。 结构大概长这样:
借助这个结构,我们再来分析一下上面的三个条件:
- 1.如果每次默认从链表尾部添加元素,那么显然越靠近尾部的元素就越是最近使用的。越靠近头部的元素就是越久未使用的。
- 2.对于某一个 key ,可以通过哈希表快速定位到链表中的节点,从而取得对应的 value。
- 3.链表显示是支持在任意位置快速插入和删除的,修改指针就行。但是单链表无非按照索引快速访问某一个位置的元素,都是需要遍历链表的,所以这里借助哈希表,可以通过 key,快速的映射到任意一个链表节点,然后进行插入和删除。
一、为什么这里要使用双向链表,而不是单向链表?
我们在找到了节点,需要删除节点的时候,如果使用单向链表的话,后驱节点的指针是直接能拿到的,但是这里要求时间复杂度是O(1),要能够直接获取到前驱节点的指针,那么只能使用双向链表。
二、哈希表里面已经保存了 key ,那么链表中为什么还要存储 key 和 value 呢,只存入 value 不就行了?
当我们在删除节点的时候,除了需要删除链表中的节点,还需要删除hash表中的节点,删除哈希表需要知道key,那么这个key从哪里来?那只能从节点里来,所以在节点里key和value都需要存(在删除链表中节点的方法里需要return key,具体见下面的代码)。
代码实现
首先建一个Node类,Node中有key,value,前驱,后驱节点,在上面画的图中已经展示出来了。
public class Node {
public Node pre;
public Node next;
public String key;
public String value;
public Node(String key, String value) {
this.key = key;
this.value = value;
}
}
建立一个LRUCache类,类中有头结点,尾节点,容量限制,字典表,以及构造方法。
public class LRUCache {
private Node head;
private Node end;
private int limit;
private HashMap<String, Node> hashMap;
public LRUCache(int limit) {
this.limit = limit;
hashMap = new HashMap<>();
}
}
在类里面首先写取值方法
/**
* 取值
*
* @param key
* @return
*/
public String get(String key) {
Node node = hashMap.get(key);
if (node == null) {
return "-1";
}
//返回之前把节点移动到链尾
moveNodeTail(node);
return node.value;
}
把节点移动到链尾方法moveNodeTail
/**
* 刷新被访问的节点位置
* @param node
*/
private void moveNodeTail(Node node) {
//如果已经是队尾的节点无需移动
if (node == end) {
return;
}
//先从原位置删掉
deleteNode(node);
//放到链尾
addTailNode(node);
}
移除节点方法deleteNode
/**
* 移除节点
*
* @param node
*/
public String deleteNode(Node node) {
if (node == head && node == end) {
//移除唯一的节点
head = null;
end = null;
} else if (node == end) {
//移除尾节点
end = end.pre;
end.next = null;
} else if (node == head) {
//移除头节点
head = head.next;
head.pre = null;
} else {
//移除中间节点
node.pre.next = node.next;
node.next.pre = node.pre;
}
return node.key;
}
链尾添加节点方法addTailNode
/**
* 尾部插入节点
*
* @param node
*/
public void addTailNode(Node node) {
if (end != null) {
end.next = node;
node.pre = end;
node.next = null;
}
end = node;
if (head == null) {
head = node;
}
}
最后看下存值put的方法
/**
* 存值
*
* @param key
* @param value
*/
public void put(String key, String value) {
Node node = hashMap.get(key);
if (node != null) {
//节点已存在更新里面的值
node.value = value;
//移动到链尾
moveNodeTail(node);
} else {
//不存在,首先判断容量,容量满的情况下先删除不常用的,然后插入新节点,容量不满的情况下直接插入
if (hashMap.size() >= limit) {
//从链表中移除最不常用的
String oldKey = deleteNode(head);
//从hashmap中移除
hashMap.remove(oldKey);
}
node = new Node(key, value);
//添加到链尾
addTailNode(node);
//添加到hashmap
hashMap.put(key, node);
}
}
最后写个测试类,跑一下题目中的get和put顺序
public static void main(String[] args) {
LRUCache cache = new LRUCache(2);
cache.put("1", "1");
cache.put("2", "2");
System.out.println(cache.get("1"));
cache.put("3", "3");
System.out.println(cache.get("2"));
cache.put("4", "4");
System.out.println(cache.get("1"));
System.out.println(cache.get("3"));
System.out.println(cache.get("4"));
}
和预期完全正确
需要注意的是,这段代码不是线程安全的代码,要想做到线程安全,需要加上syncchronized修饰符。
应用
LRU 在 MySQL 中的应用
LRU 在 MySQL 的应用就是 Buffer Pool,也就是缓冲池,它的目的是为了减少磁盘 IO。它是一块连续的内存,默认大小 128M,可以进行修改。这一块连续的内存,被划分为若干默认大小为 16KB 的页。
既然它是一个 pool,那么必然有满了的时候,这时候就要移除某些页了,为了减少磁盘 IO,所以应该淘汰掉很久没有被访问过的页,这个就是 LRU 。
LRU 在 Redis 中的应用
我们知道Redis也会有满的时候,满了就要淘汰掉长期不使用的key,在 Redis 中用了一个类似的 LRU 算法,而不是严格的 LRU 算法。具体内容请看我另一篇文章:《Redis高级特性精讲》
最后出一道扩散的面试题:数据库中有 3000w 的数据,而 Redis 中只有 100w 数据,如何保证 Redis 中存放的都是热点数据?
这道题的考点就是LRU,我们先指定Redis的淘汰策略为 allkeys-lru 或者 volatile-lru,然后再计算一下 100w 数据大概占用多少内存,根据算出来的内存,限定 Redis 占用的内存。接下来的事,就交给Redis的淘汰策略即可。
本文源码在:github.com/xuhaoj/Data… ,感谢观看。