LRU 算法,全称是Least Recently Used。
翻译过来就是最近最少使用算法。
这个算法的思想就是:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。所以,当指定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。
听描述你也知道了,它是一种淘汰算法。
这个算法也是面试的一个高频考点。
有的面试官甚至要求手撸一个 LRU 算法出来。
其实我觉得吧,遇到这种情况也不要慌,你就按照自己的思路写一个出来就行。
赌一把,面试官也许自己短时间内都手撸不出来一个无 bug 的 LRU。他也只是检查几个关键点、看看你的代码风格、观察一下你的解题思路而已。
但其实大多数情况下面试场景都是这样的:
面试官:你知道 LRU 算法吗?
我:知道,翻译过来就是最近最少使用算法。其思想是(前面说过,就不复述了)......
面试官:那你能给我谈谈你有哪些方法来实现 LRU 算法呢?
这个时候问的是什么?
问的是:我们都知道这个算法的思路了,请你按照这个思路给出一个可以落地的解决方案。
不用徒手撸一个。
方法一:数组
如果之前完全没有接触过 LRU 算法,仅仅知道其思路。
第一次听就要求你给一个实现方案,那么数组的方案应该是最容易想到的。
假设我们有一个定长数组。数组中的元素都有一个标记。这个标记可以是时间戳,也可以是一个自增的数字。
我这里用自增的数字。
每放入一个元素,就把数组中已经存在的数据的标记更新一下,进行自增。当数组满了后,就将数字最大的元素删除掉。
每访问一个元素,就将被访问的元素的数字置为 0 。
这不就是 LRU 算法的一个实现方案吗?
按照这个思路,撸一份七七八八的代码出来,问题应该不大吧?
但是这一种方案的弊端也是很明显:需要不停地维护数组中元素的标记。
每次操作都伴随着一次遍历数组修改标记的操作,所以时间复杂度是 O(n)。
但是这个方案,面试官肯定是不会满意的。因为,这不是他心中的标准答案。
也许他都没想过:你还能给出这种方案呢?
但是它不会说出来,只会轻轻的说一句:还有其他的方案吗?
方法二:链表
最近最少使用,感觉是需要一个有序的结构。
我每插入一个元素的时候,就追加在数组的末尾。
等等。
我每访问一个元素,也要把被访问的元素移动到数组的末尾。
这样最近被用的一定是在最后面的,头部的就是最近最少使用的。
当指定长度被用完了之后,就把头部元素移除掉就行了。
这是个什么结构?
这不就是个链表吗?
维护一个有序单链表,越靠近链表头部的结点是越早之前访问的。
当有一个新的数据被访问时,我们从链表头部开始顺序遍历链表。
如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据的对应结点,并将其从原来的位置删除,并插入到链表尾部。
如果此数据没在缓存链表中,怎么办?
分两种情况:
- 如果此时缓存未满,可直接在链表尾部插入新节点存储此数据;
- 如果此时缓存已满,则删除链表头部节点,再在链表尾部插入新节点。
按照这个思路,撸一份八九不离十的代码出来,问题应该不大吧?
这个方案比数组的方案好在哪里呢?
我觉得就是莫名其妙的高级感,就是看起来就比数组高级了一点。从时间复杂度的角度看,因为链表插入、查询的时候都要遍历链表,查看数据是否存在,所以它还是O(n)。
总之,这也不是面试官想要的答案。
当你回答出这个方案之后,面试官也许会说:你能不能给我一个查询和插入的时间复杂度都是O(1)的解决方案?
方法三:双向链表+哈希表
如果我们想要查询和插入的时间复杂度都是 O(1),那么我们需要一个满足下面三个条件的数据结构:
1.首先这个数据结构必须是有时序的,以区分最近使用的和很久没有使用的数据,当容量满了之后,要删除最久未使用的那个元素。
2.要在这个数据结构中快速找到某个 key 是否存在,并返回其对应的 value。
3.每次访问这个数据结构中的某个 key,需要将这个元素变为最近使用的。也就是说,这个数据结构要支持在任意位置快速插入和删除元素。
查找快,我们能想到哈希表。但是哈希表的数据是乱序的。
有序,我们能想到链表,插入、删除都很快,但是查询慢。
所以,我们得让哈希表和链表结合一下,成长一下,形成一个新的数据结构,那就是:哈希链表,LinkedHashMap。
借助这个结构,我们再来分析一下上面的三个条件:
1.如果每次默认从链表尾部添加元素,那么显然越靠近尾部的元素就越是最近使用的。越靠近头部的元素就是越久未使用的。
2.对于某一个 key ,可以通过哈希表快速定位到链表中的节点,从而取得对应的 value。
3.链表显示是支持在任意位置快速插入和删除的,修改指针就行。但是单链表无非按照索引快速访问某一个位置的元素,都是需要遍历链表的,所以这里借助哈希表,可以通过 key,快速的映射到任意一个链表节点,然后进行插入和删除。
这才是面试官想要关于 LRU 的正确答案。
但是你以为回答到这里就结束了吗?
面试官为了确认你的掌握程度,还会追问一下。
那么请问:为什么这里要用双链表呢,单链表为什么不行?
因为这里涉及到删除元素的操作?
那么链表删除元素除了自己本身的指针信息,还需要什么东西?
是不是还需要前驱节点的指针?
那么我们这里要求时间复杂度是O(1),所以怎么才能直接获取到前驱节点的指针?
这玩意是不是就得上双链表?
面试官的第二个问题又随之而来了:哈希表里面已经保存了 key ,那么链表中为什么还要存储 key 和 value 呢,只存入 value 不就行了?
刚刚我们说删除链表中的节点,需要借助双链表来实现 O(1)。
删除了链表中的节点,然后呢?
是不是还忘记了什么东西?
是不是还有一个哈希表忘记操作了?
哈希表是不是也得进行对应的删除操作?
删除哈希表需要什么东西?
是不是需要 key,才能删除对应的 value?
这个 key 从哪里来?
是不是只能从链表中的结点里面来?
如果链表中的结点,只有 value 没有 key,那么我们就无法删除哈希表的 key。那不就完犊子了吗?
另外在多说一句,有的小伙伴可能会直接回答借助 LinkedHashMap 来实现。
我觉得吧,你要是实在不知道,也可以这样说。
但是,这个回答可能是面试官最不想听到的回答了。
他会觉得你投机取巧。
但是呢,实际开发中,真正要用的时候,我们还是用的 LinkedHashMap。
而且更多的实际情况是,你开发,写业务代码的时候,根本就不会用到 LRU 算法。
你说这个事情,难受不难受。
LRU 在 MySQL 中的应用
面试官:小伙子刚刚 LRU 回答的不错哈。要不你给我讲讲,LRU 在 MySQL 中的应用?
LRU 在 MySQL 的应用就是 Buffer Pool,也就是缓冲池。
它的目的是为了减少磁盘 IO。
缓冲池具体是干啥的,我这里就不展开说了。
你就知道它是一块连续的内存,默认大小 128M,可以进行修改。
这一块连续的内存,被划分为若干默认大小为 16KB 的页。
既然它是一个 pool,那么必然有满了的时候,怎么办?
就得移除某些页了,对吧?
那么问题就来了:移除哪些页呢?
刚刚说了,它是为了减少磁盘 IO。所以应该淘汰掉很久没有被访问过的页。
很久没有使用,这不就是 LRU 的主场吗?
但是在 MySQL 里面并不是简单的使用了 LRU 算法。
因为 MySQL 里面有一个预读功能。预读的出发点是好的,但是有可能预读到并不需要被使用的页。
这些页也被放到了链表的头部,容量不够,导致尾部元素被淘汰。
哦豁,降低命中率了,凉凉。