文章目录

  • ​​题目描述​​
  • ​​思路​​
  • ​​实现​​
  • ​​解法二​​
  • ​​扩展​​

题目描述

实现一个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中的实现,实际没有用次数累加的方式,而是采用移位+定期衰减的方式。参考​​百度百科​