摘要
这两个缓存机制算法,要解决共同难点:在缓存满的时候,如何快速删除恰当的数据。
区别在于:
- LRU 要删除的是「最不常用的数据」
- LFU 则比较复杂,要删除的是「使用频率最低,且最不常用的数据」
所以,虽然都是借助「哈希表 + 双向链表」来解决,但又有小小的差别。
LRU (Least Recently Used)
题目来源:146. LRU 缓存机制 - 力扣(LeetCode)
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。 实现 LRUCache 类:
- LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
- int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
- void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字 - 值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?
我们直接关注,如何在 O(1) 时间复杂度内完成呢?
get 比较容易,一般都能想到使用哈希表作为数据结构。
关键点在于,当缓存满的时候,如何在 O(1) 时间内,找到「最不常用的数据」并删除。
答案是「双向链表」。
题解
数据结构:双向链表 List
+ 哈希表 [Int:Node]
关键点:需要保证 List 的头结点是「最近」使用的。
算法:
get 时,取值后,将该结点移动到 List 的头部。
put 时:
- 若已存在,则更新 value,并将其移动到 List 的头部
- 若缓存已满,则删除 List 尾结点(最不常用)
- 否则直接插入到 List 头部
结合下图理解:
文字描述不是很具体,可直接看代码:
class LRUCache {
// 双向链表
class Node {
var key = 0, value = 0
var pre: Node?
var nx: Node?
}
class List {
var head: Node?
var tail: Node?
func appendToHead(_ node: Node) {
if let h = head {
node.nx = h
h.pre = node
head = node
return
}
// 无 head
head = node
tail = node
node.pre = nil
node.nx = nil
}
func moveToHead(_ node: Node) {
guard let _ = head else {
// 链表中没有结点的情况
appendToHead(node)
return
}
// 删除
delete(node)
// 移到头部
appendToHead(node)
}
/// 删除某个结点
func delete(_ node: Node) {
guard let _ = head, let t = tail else {
// 无结点
return
}
if node === t { // 尾结点,需要更新 tail
tail = t.pre
tail?.nx = nil
}
// 借助哨兵结点,简化代码
// 情况 1 只有一个结点
// 情况 2 多于一个结点
let dummy = Node()
dummy.nx = head
head?.pre = dummy
node.pre?.nx = node.nx
node.nx?.pre = node.pre
head = dummy.nx
head?.pre = nil
}
/// 清理
func removeAll() {
head = nil
tail = nil
}
}
private var capacity = 0
/// [key : node]
private var kvT = [Int:Node]()
private var list = List()
// 初始化时设置最大值
init(_ capacity: Int) {
self.capacity = capacity
}
func get(_ key: Int) -> Int {
// 若需要保证线程安全,可加锁
// read
let v = self._get(key)
return v
}
private func _get(_ key: Int) -> Int {
guard capacity > 0, let n = kvT[key] else {
return -1
}
_handleGetNode(n)
return n.value
}
/// get 时,取值后,将该结点移动到 List 的头部
private func _handleGetNode(_ n: Node) {
list.moveToHead(n)
}
func put(_ key: Int, _ value: Int) {
// 若需要保证线程安全,可加锁
// write
self._put(key, value)
}
/// 1. 若已存在,则更新 value,并将其移动到 List 的头部
/// 2. 若缓存已满,则删除 List 尾结点(最不常用)
/// 3. 否则直接插入到 List 头部
private func _put(_ key: Int, _ value: Int) {
if let n = kvT[key] { // 已存在
n.value = value
_handleGetNode(n)
return
}
// 缓存已满
_handleFull()
// 新 node
let n = Node()
n.key = key
n.value = value
// 插入到 kvT
kvT[key] = n
// 插入到 List 头部
list.appendToHead(n)
}
/// 缓存满了,删除尾结点
private func _handleFull() {
guard kvT.count == capacity else {
return
}
guard let n = list.tail else {
return
}
list.delete(n) // 删除尾结点
kvT.removeValue(forKey: n.key) // 移除 kvT 中的
}
}
LFU (Least Frequently Used)
题目来源:460. LFU 缓存
请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。
实现 LFUCache 类:
- LFUCache(int capacity) - 用数据结构的容量 capacity 初始化对象
- int get(int key) - 如果键存在于缓存中,则获取键的值,否则返回 -1。
- void put(int key, int value) - 如果键已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量时,则应该在插入新项之前,使最不经常使用的项无效。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除 最近最久未使用 的键。
注意「项的使用次数」就是自插入该项以来对其调用 get 和 put 函数的次数之和。使用次数会在对应项被移除后置为 0 。
为了确定最不常使用的键,可以为缓存中的每个键维护一个 使用计数器 。使用计数最小的键是最久未使用的键。
当一个键首次插入到缓存中时,它的使用计数器被设置为 1 (由于 put 操作)。对缓存中的键执行 get 或 put 操作,使用计数器的值将会递增。
相比 LRU,在删除的时候,多了一个需要考量的条件:使用频率。
题解
数据结构:双向链表 + 2 个哈希表
即 kvT: [Int:Node]
和 freqT: [Int:List]
。
有以下注意点:
- 额外用一个 minFreq 保存「最小频次」
- 往 List 中添加元素时,只能添加到头部。
算法:
get 时,取值后,更新该结点的频率,及其在 freqT 中的位置。
put 时:
- 若已存在,则更新 value,其使用频率 + 1,并更新其在 freqT 中的位置
- 若缓存已满,则删除 List 尾结点。
- 否则直接插入到频率 1 对应的 List 头部。
结合图片
文字描述不是很具体,直接看以下代码:
class LFUCache {
class Node {
var key = 0, freq = 0, value = 0
var pre: Node?
var nx: Node?
}
class List {
var head: Node?
var tail: Node?
func appendToHead(_ node: Node) {
if let h = head {
node.nx = h
h.pre = node
head = node
return
}
// 无 head
head = node
tail = node
node.pre = nil
node.nx = nil
}
func delete(_ node: Node) {
guard let _ = head, let t = tail else {
// 无结点
return
}
if node === t { // 尾结点,需要更新 tail
tail = t.pre
tail?.nx = nil
}
// 借助哨兵结点,简化代码
// 情况 1 只有一个结点
// 情况 2 多于一个结点
let dummy = Node()
dummy.nx = head
head?.pre = dummy
node.pre?.nx = node.nx
node.nx?.pre = node.pre
head = dummy.nx
head?.pre = nil
}
}
private var capacity = 0
/// [key : node]
private var kvT = [Int:Node]()
/// [freq : List]
private var freqT = [Int:List]()
/// 最小频率,便于删除
private var minFreq = 0
init(_ capacity: Int) {
self.capacity = capacity
}
func get(_ key: Int) -> Int {
guard capacity > 0, let n = kvT[key] else {
return -1
}
_handleGetNode(n)
return n.value
}
private func _handleGetNode(_ n: Node) {
// 暂存 new freq
let nf = n.freq + 1
// 从 freq 的链表中删除
freqT[n.freq]?.delete(n)
// 添加到 freq+1 的链表头部
let l = freqT[nf] ?? List()
l.appendToHead(n)
freqT[nf] = l
// 更新 minFreq,后一个条件与 List 结构有关
if minFreq == n.freq, freqT[minFreq]?.head == nil {
minFreq = nf
}
// 更新 freq
n.freq = nf
}
func put(_ key: Int, _ value: Int) {
if let n = kvT[key] { // 已存在
n.value = value
_handleGetNode(n)
return
}
// 缓存已满
_handleFull()
// 插入
let n = Node()
n.key = key
n.value = value
n.freq = 1
// 插入到 kvT
kvT[key] = n
// 插入到 freqT
let list1 = freqT[1] ?? List()
list1.appendToHead(n)
freqT[1] = list1
minFreq = 1
}
private func _handleFull() {
guard kvT.count == capacity else {
return
}
guard let n = freqT[minFreq]?.tail else {
return
}
freqT[minFreq]?.delete(n) // 删除尾结点
kvT.removeValue(forKey: n.key) // 移除 kvT 中的
}
}
小结
这两个算法核心差异,以及解决方法的区别:
缓存机制 | 缓存满时,需要删除 | 解决方法 - 数据结构 |
---|---|---|
LRU | 最不常用的数据 | 双向链表 + 哈希表 |
LFU | 使用频率最低,且最不常用的数据 | 双向链表 + 2 个哈希表 |
笔者觉得,这 2 道题目其实很好证明了双向链表的强大之处。
它的优势在于,插入、删除操作时间复杂度 O(1)。
再配合哈希表,就可以做到查找时间复杂度 O(1)。
题外话
笔者做这 2 道题时,看了下 iOS 平台 NSCache 的源码,发现底层实现大同小异。
作为实际应用例子,阅读起来,应该会比干巴巴的算法题好理解。
有兴趣的读者可以看看 源码浅析 - iOS 缓存 NSCache。