前言

相信有很多同学和我一样,第一次碰到 LRU(Least Recently Used) 的这个解释「最近最少使用」都不知道是什么意思,用汤家凤老师的话来说:

我真的感到匪夷所思啊!

最近是表示时间,最少是表示频度,拆开来都知道,但是合在一起就不知道是什么意思了。经过一番搜索后,我发现这可能是国内一些专业名词的通病:翻译问题。甚至百度百科对 LRU 的解释也是这样:

LRU 是 Least Recently Used 的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少使用的页面予以淘汰。

什么叫「选择最近最久未使用的页面予以淘汰」?今天我们就来摸一摸它的底!

LRU 算法过程

LRU 常见的实现是使用一个双向链表保存缓存数据,算法步骤如下:

  1. 插入新数据时会插入到链表头部;
  2. 当缓存数据被访问时,将该数据移到链表尾部;
  3. 当链表满的时候,将链表头部的数据丢弃。

看完算法过程,内心惊呼:这不就是当容量不够用的时候淘汰最久没访问的数据么,和最少有个毛关系啊!按我的理解,Least Recently 其实应该翻译成 「最不是最近的」也就是最远的,Least 其实是修饰 Recently,而不应该和它并列翻译成「最近最少」,LRU 说到底就是一个时间维度上的缓存优化算法。

LRU 的兄弟 LFU

LFU(Least Frequently Used)和 LRU 比较相似,也是缓存优化算法,但是他和 LRU 唯一的区别就是,LRU 是时间维度上的,当缓存满的时候,将最久没使用的数据丢弃,而 LFU 是频率维度上的,会将最少使用(使用频次最低)的数据丢弃。

百度百科上对 LFU 的解释如下:

LFU(least frequently used (LFU) page-replacement algorithm)。即最不经常使用页置换算法,要求在页置换时置换引用计数最小的页,因为经常使用的页应该有一个较大的引用次数。但是有些页在开始时使用次数很多,但以后就不再使用,这类页将会长时间留在内存中,因此可以将引用计数寄存器定时右移一位,形成指数衰减的平均使用次数。

这就有点搞笑了, LFU 的翻译是「最不经常使用」,既然作为 LRU 的兄弟,难道你不应该翻译成「最经常最少使用」算法吗?? 不过这好像也侧面印证了我上面的猜想应该是正确的。

同样的翻译灾难鲁棒性

这个名词乍一听,这是什么玩意儿,完全不能从字面意思上推测出实际的含义,结果一查阅发现,这其实是英文 Robustness 的翻译,应该是直接音译的,更直观也更好听的翻译应该是「健壮性」、「稳健性」。

有理数

有人提过一个问题:有理数为什么叫「有理数」?难道是有道理的数?这个答案显然很离谱,但是我们大多数人也就得过且过吧,没有去较真了。但是细加挖掘,会发现有理数的英文是 rational number,其中 rational 被翻译成了「合理的」、「有道理的」,实际上 rational 还有一个意思是「可比的」,也就是能表示成两个整数之比的数是有理数。

这里面还有一段很有意思的历史,感兴趣的可以自行查阅。

总结

从这次经历来看,以后大家要是在一些专业名词上有困惑,不妨看看它翻译之前的英文,结合专业名称具体的含义多多推敲,如果是一个算法,就理解它的算法过程。最后会发现,一切都有源头,每个名词都有它的来历,不管是翻译错误还是什么,说不定还能了解到背后的一些历史渊源。

附:LeetCode 146.LRU 缓存实现

既然说到了 LRU 的实现,我们不妨将 LeetCode 上关于 LRU 的一道高频面试题给解决了。

题目要求如下:

请你设计并实现一个满足LRU (最近最少使用) 缓存约束的数据结构。

实现LRUCache类:

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

函数getput必须以O(1)的平均时间复杂度运行。

首先分析一下这道题需要的数据结构,因为获取缓存元素的 get 方法需要 O(1) 的时间复杂度,因此存储元素很容易想到使用哈希表。但是再仔细读题就会发现,LRU 的核心思想「容量不够删除最远使用的元素」蕴含了数据需要以时间为维度排列成有序序列,且需要频繁在序列头部和尾部操作,因此最终选定使用双向链表。

最终,数据结构选取「哈希表+双向链表」来实现。

首先,先定义双向链表的节点:

var ListNode = function (key, value) { this.key = key; this.value = value; this.prev = null; this.next = null;}

然后,初始化 LRU 的结构:

var LRUCache = function (capacity) { this.capacity = capacity; this.map = new Map(); // 哈希表   // 初始化双向链表 this.head = new ListNode(); this.tail = new ListNode(); this.head.next = this.tail; this.tail.prev = this.head;};

由于算法中访问过的节点要移到链表末尾,因此先实现一下节点插入末尾的操作:

LRUCache.prototype.insertTail = function (node) { this.tail.prev.next = node; node.prev = this.tail.prev; node.next = this.tail; this.tail.prev = node;}

准备工作做好之后,就开始实现最关键的 get 和 put 方法,由于题目中已经将各个情况都描述清楚了,因此按照题目要求进行逐个实现即可。

首先是 get 方法,如果缓存中没有 key,则返回 -1。如果有 key,那么将该 key 对应的节点从链表中原来的位置删除,然后插入链表末尾,代表最近访问过。最好返回该节点对应的值:

LRUCache.prototype.get = function(key) { if (!this.map.has(key)) return -1;  let node = this.map.get(key); // 将 node 从原来位置删除 node.prev.next = node.next; node.next.prev = node.prev; this.insertTail(node); // 插入链表末尾   return node.value;};

然后是 put 方法,如果 key 已经存在,那么就把对应的节点找出来修改值,然后移到链表末尾。如果 key 不存在,要先判断一下缓存是否已满,如果满了,要先把头部节点(最远使用)删除,并在哈希表中删除记录,再插入新节点,如果没满,就直接插入新节点:

LRUCache.prototype.put = function(key, value) { if (this.map.has(key)) {  // 如果 key 已经存在,则修改值  let node = this.map.get(key);  node.value = value;  // 移到链表末尾  node.prev.next = node.next;  node.next.prev = node.prev;  this.insertTail(node); } else { // 如果 key 不存在,判断是否超出容量  if (this.map.size >= this.capacity) {   let rmNode = this.head.next;   // 删除头部节点   this.head.next = rmNode.next;   rmNode.next.prev = this.head;      this.map.delete(rmNode.key);  }// 新建节点插入链表末尾,并存入哈希表  let newNode = new ListNode(key, value);  this.map.set(key, newNode);  this.insertTail(newNode); }};

这道中等题在面试中出现的频率还是比较高的,因此最好还是能掌握。