Design and implement a data structure for Least Recently Used (LRU) cache. It should support the following operations: get and put.
get(key) - Get the value (will always be positive) of the key if the key exists in the cache, otherwise return -1.
put(key, value) - Set or insert the value if the key is not already present. When the cache reached its capacity, it should invalidate the least recently used item before inserting a new item.
Follow up:
Could you do both operations in O(1) time complexity?
Example:
LRUCache cache = new LRUCache( 2 /* capacity */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // returns 1
cache.put(3, 3); // evicts key 2
cache.get(2); // returns -1 (not found)
cache.put(4, 4); // evicts key 1
cache.get(1); // returns -1 (not found)
cache.get(3); // returns 3
cache.get(4); // returns 4
while (mem_freed < mem_tofree) {
sds bestkey = NULL;
struct evictionPoolEntry *pool = EvictionPoolLRU;
while(bestkey == NULL) {
evictionPoolPopulate(i, dict, db->dict, pool);
/* Go backward from best to worst element to evict. */
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
if (pool[k].key == NULL) continue;
de = dictFind(server.db[pool[k].dbid].dict,
pool[k].key);
/* Remove the entry from the pool. */
if (pool[k].key != pool[k].cached)
sdsfree(pool[k].key);
bestkey = dictGetKey(de);
break;
}
}
优缺点
优点
LRU 实现简单,get、put 时间复杂度都为 O(1)
利用了 locality ,即最近使用的数据很可能再次被使用
能更快的对短期行为作出反应
缺点
long scan 的时候,会导致 lru 不断发生 evcit 行为。(数据库操作,从磁盘加载文件等,LFU 避免了该行为)
跟网友的讨论中,有人又贴出了 google 的一版实现,在 Golang/groupcache 项目下。就顺便看了下对应的源码。
// Package lru implements an LRU cache.
package lru
import "container/list"
// Cache is an LRU cache. It is not safe for concurrent access.
type Cache struct {
// MaxEntries is the maximum number of cache entries before
// an item is evicted. Zero means no limit.
MaxEntries int
// OnEvicted optionally specifies a callback function to be
// executed when an entry is purged from the cache.
OnEvicted func(key Key, value interface{})
ll *list.List
cache map[interface{}]*list.Element
}
// Add adds a value to the cache.
func (c *Cache) Add(key Key, value interface{}) {
if c.cache == nil {
c.cache = make(map[interface{}]*list.Element)
c.ll = list.New()
}
if ee, ok := c.cache[key]; ok {
c.ll.MoveToFront(ee)
ee.Value.(*entry).value = value
return
}
ele := c.ll.PushFront(&entry{key, value})
c.cache[key] = ele
if c.MaxEntries != 0 && c.ll.Len() > c.MaxEntries {
c.RemoveOldest()
}
}
// Get looks up a key's value from the cache.
func (c *Cache) Get(key Key) (value interface{}, ok bool) {
if c.cache == nil {
return
}
if ele, hit := c.cache[key]; hit {
c.ll.MoveToFront(ele)
return ele.Value.(*entry).value, true
}
return
}
LRUCache 的实现
缘起
刷 leetcode 的时候碰到的这道题。LRUCache 在现实中也经常用到:
内存换页,需要淘汰掉不常用的 page。
缓存函数的结果,比如 Python 就自带的
lru_cache
的实现。redis 在设置了 maxmemory 时,在内存占用达到最大值时会通过 LRU 淘汰掉对应的 key。
要求
Leetcode 题目要求如下
关键点在于
get
和put
操作的时间复杂度都需为 O(1)。实现
初版
为了实现 O(1) 的复杂度,使用哈希表来存储对应的元素。然后通过双向链表来实现
lru cache
相关的逻辑,get
时,将命中的节点移动到头部put
时如果命中已存在的节点,参照get
操作,如果为新节点Leetcode 经典实现
初版实现的方法,需要不断判断删除,移动的是不是尾部指针,引入了很多不必要的
if
判断。而Leetcode
讨论区里面提出了一个更好的方法。原本我们通过引入
Dummy Head
已经简化了头部相关的操作,这里额外再引入一个Dummy tail
,这样的话在移动删除尾部节点的时候就不需要额外判断了。Python 内置的 LRUCache 实现
但是上面的方式还有一个问题,就是在
lru_cache
满了的时候,此时新增一个节点,会导致需要从链表中删除一个尾部的旧节点,然后同时在头部插入一个新节点。有没有办法直接使用旧的删除的节点来代替新增的节点呢?这样在
LRUCache
满了的时候,put
新元素的性能会获得很大的提升。而 Python 内部实现正是考虑了这一点,利用了带头结点
root
的循环双向链表,避免了该问题。root
作为新节点,使用原来的尾部节点即root.prev
作为新的 root 节点。list
代替node
节省空间。下面是
Python
的lru_cache
的实现,因为原实现是装饰器,这里略作修改为类的实现:redis 的 LRU 淘汰算法的实现
因为想到 redis 也实现了
lru cache
,就抽空看了下源码,发现跟想象中非常不一样,并不是常规的实现方式。当 redis 达到设置的
maxmemory
,会从所有key 中随机抽样 5 个值,然后计算它们的 idle time,插入一个长度为 16 的待淘汰数组中,数组中的 entry 根据 idle time 升序排列,最右侧的就是接下来第一个被淘汰的。淘汰后如果内存还是不满足需要,则继续随机抽取key
并循环以上过程。因为是随机抽样,所以分为以下情况
抽样的 key idle 小于最左侧最小的 idle time,什么都不做,直接跳过
找到适合的插入位置 i
pool[i: end]
pool[0: i + 1]
,这样的话 idle 时间最小的就被淘汰了关键实现逻辑如下:
优缺点
优点
get
、put
时间复杂度都为 O(1)locality
,即最近使用的数据很可能再次被使用缺点
long scan 的时候,会导致 lru 不断发生 evcit 行为。(数据库操作,从磁盘加载文件等,LFU 避免了该行为)
只利用了到了一部分的 locality,没有利用
最经常使用的数据很可能再次被使用
(LFU 做到了,但是更慢,Log(N))B 站源码解析之 LRUCache 实现
在这篇文章还没完稿的时候,看到了 B 站的 LRUCache 的源码实现,下面就顺便来分析一下。下面是对应的源码:
怎么说呢,有点失望。一开始在看到结构定义了 head 、tail时以为是使用了 Dummy head 和 Dummy tail 的经典实现,但是在初始化时发现没有初始化对应的 head、tail,以为是使用了一种未知的新方法,但是一看 refresh 和 remove 的逻辑,发现是通过大量的 if、else 来判断 corner cases。
而大量使用 if 会严重干扰代码的可读性和可维护性,具体可见 Applying the Linus Torvalds “ Good Taste ” Coding Requirement 这篇文章。
Golang/groupcache LRUCache 实现
跟网友的讨论中,有人又贴出了 google 的一版实现,在
Golang/groupcache
项目下。就顺便看了下对应的源码。关键点在于
container/list
,这是一个带头结点的循环双向链表,但是并没有暴露 root 节点,所以google
的实现同Leetcode
经典实现是一致的。 我还发了一个 issue 去询问为什么不采用类似Python
的实现。官方的回答是目前够用,如果需要变更的话,需要 benchmark 的支持。理论上Python
的实现在不断读取新数值的时候性能会好很多。综合下来 Python 内置库 functools.lru_cache 的带头结点的双向循环队列的实现是最优雅的