文章目录
- 题目描述
- 思路
- 实现
- 解法二
- 扩展
题目描述
实现一个LFU缓存(Least Frequently Used)。 在需要移除元素时,移除最近访问频率最低的。可以对每个元素增加一个计数器,访问一次就计数加一。若2个或多个元素拥有相同的最少访问次数时,则移除最久没有被访问的。
与实现LRU缓存类似,为了确保get
操作的复杂度为,我们都会用一个Map
来存储所有key-value
, 关键在于put
时需要移除元素的情况,要如何操作。
思路
先不考虑多个元素具有相同的访问次数,且次数都是最少的情况。先单独考虑,如果只需要移除访问次数最少的元素。这种每次都需要获取一个最值的情况。容易想到用堆来做。由于我们每次移除元素时,需要移除访问次数最少的,则根据访问次数,建一个小根堆,堆顶元素是最小值。
那么,用一个Map
加上一个小根堆,就能满足需求。
接下来考虑,如果访问次数最少的元素有多个,需要移除最久没有被访问的。那么对于每个元素,我们需要记录一个访问时间,然后在堆中排序时,不再只根据访问次数来排序,而是根据【访问次数,访问时间】,进行双关键字排序。这样就能保证在访问次数相同时,访问时间更早的元素,会排在更前面。则堆顶的元素就是访问次数最少,且访问时间最早。
实现
其中,堆用数组实现。
访问时间用一个int
变量在每次进行get
或put
时进行累加,来模拟时间戳。
class LFUCache {
private Node[] heap;
private Map<Integer, Node> map;
private int capacity; // 最大容量
private int size; // 当前大小
private int time; // 模拟时间戳
public LFUCache(int capacity) {
heap = new Node[capacity + 1]; // 堆下标从1开始, 方便计算父子节点的下标
map = new HashMap<>(capacity);
this.capacity = capacity;
this.size = 0;
this.time = 0; // 模拟时间戳
}
public int get(int key) {
if (map.containsKey(key)) {
Node x = map.get(key);
x.cnt++; // 访问次数+1
x.time = ++time; // 访问时间更新为当前时间戳
down(x.index); // 当前节点排序只可能变大, 只需要向下调整即可
return x.val;
}
return -1;
}
public void put(int key, int value) {
if (capacity <= 0) return;
if (map.containsKey(key)) {
Node x = map.get(key);
x.cnt++;
x.time = ++time;
x.val = value;
down(x.index);
return;
}
if (size == capacity) {
map.remove(heap[1].key); // 移除堆顶
swap(1, size--); // 交换堆顶和堆尾, 堆大小减1
down(1); // 向下调整堆顶
}
Node x = new Node(key, value, ++size);
x.time = ++time;
x.cnt = 1;
map.put(key, x);
heap[size] = x; // 插入堆尾
up(size); // 向上调整
}
private void down(int i) {
int min = i;
if (2 * i <= size && compare(2 * i, min) < 0) min = 2 * i;
if (2 * i + 1 <= size && compare(2 * i + 1, min) < 0) min = 2 * i + 1;
if (min != i) {
swap(i, min);
down(min);
}
}
private void up(int i) {
while (i / 2 >= 1 && compare(i / 2, i) > 0) {
swap(i, i / 2);
i /= 2;
}
}
// 交换堆中2个元素, 记得重设节点在数组中的下标信息
private void swap(int i, int j) {
Node t = heap[i];
heap[i] = heap[j];
heap[j] = t;
heap[i].index = i;
heap[j].index = j;
}
// 按访问次数和访问时间戳, 双关键字排序
private int compare(int i, int j) {
Node ni = heap[i], nj = heap[j];
if (ni.cnt != nj.cnt) return ni.cnt - nj.cnt;
return ni.time - nj.time;
}
class Node {
private int key;
private int val;
private int cnt; // 访问次数
private int time; // 最近访问的时间戳
private int index; // 这个node在堆中的下标
public Node(int key, int val, int index) {
this.key = key;
this.val = val;
this.index = index;
this.cnt = 0;
}
}
}
解法二
其实上面这样使用Map
加小根堆的实现,get
和put
操作的时间复杂度并不是,因为每次get
或put
,都需要调整堆。所以get
和put
的时间复杂度都是的。
当然,小根堆的也实现可以借助jdk
中的TreeSet
或者PriorityQueue
。
另外还有一种,双Map
的解法,能做到时间复杂度 。待后续补充 TODO
扩展
另:LFU/LRU来自于OS的页面置换算法,下面对OS的页面置换算法进行一个说明
FIFO:先进先出。会产生Belady现象(随着页面数量增大,缺页率反而上升的现象)。现已很少使用。参考这篇文章和这篇论文
LRU和LFU,都能够保证,随着可分配页数的增加,能够保证页面更少时的集合是页面更大时的子集,这样就能保证,增大页面数量,缺页率一定不会上升(只可能下降),这样的算法称为stack algorithm
。
If the pages in the frames of a memory are also in the frames of a larger memory, the algorithm is said to be a stack algorithm. Because a stack algorithm by definition prevents the discrepancy above, no stack algorithm can suffer from Belady’s anomaly
至于LRU,是根据最近最久未被使用的,进行置换,强调的是访问时间的早晚。
LFU,则是根据访问频率,移除访问频率最低的,强调的是过去一段时间内的访问次数的多少。
LRU和LFU的对比:
LRU的问题在于:对于偶发性,周期性的批量查询(冷数据),会淘汰掉大量热点数据,导致命中率急剧下降
LFU的问题在于:最近新加入的数据(由于访问次数很少)容易被淘汰(缓存末端抖动),无法对最初拥有高访问频率之后长时间未访问的数据负责。
对于LRU和LFU的对比,参考思否的这篇文章
LFU在OS中的实现,实际没有用次数累加的方式,而是采用移位+定期衰减的方式。参考百度百科