实现一个LFU缓存(Least Frequently Used)。 在需要移除元素时,移除最近访问频率最低的。可以对每个元素增加一个计数器,访问一次就计数加一。若2个或多个元素拥有相同的最少访问次数时,则移除最久没有被访问的。
与实现LRU缓存类似,为了确保get
操作的复杂度为 O ( 1 ) O(1) O(1),我们都会用一个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
操作的时间复杂度并不是 O ( 1 ) O(1) O(1),因为每次get
或put
,都需要调整堆。所以get
和put
的时间复杂度都是 O ( l o g n ) O(log n) O(logn)的。
当然,小根堆的也实现可以借助jdk
中的TreeSet
或者PriorityQueue
。
另外还有一种,双Map
的解法,能做到时间复杂度 O ( 1 ) O(1) O(1)。待后续补充 -> 2022/05/25更新,已补充:
用一个Map
来存真实数据,另一个Map
以出现频次freq
为key
,维护一个双向链表,双向链表中全部都是频次相同的Node
,且链表的插入顺序就是其访问时间的早晚。若每次插入采用尾插法,则链表头就是访问时间最早的,再移除元素时,移除链表头即可。
还需要另外维护一个变量minFreq
,表示当前最小的频次,以方便做删除。在元素满了后,需要做删除时,删除完毕后无需更新minFreq
,因为后面肯定会有一个新的元素被插进来,minFreq
一定会被更新为1
。
若某个已存在元素被重复访问,则将其频次+1,并从旧的频次双向链表中移除,添加到新的频次的双向链表中,这个过程可能发生minFreq
的更新。
class LFUCache {
Map<Integer, Node> map;
Map<Integer, DoubleList> freqMap;
int minFreq;
int capacity;
int size;
public LFUCache(int capacity) {
this.capacity = capacity;
this.size = 0;
this.map = new HashMap<>();
this.freqMap = new HashMap<>();
this.minFreq = 0;
}
public int get(int key) {
if (!map.containsKey(key)) return -1;
Node node = map.get(key);
freqIncr(node); // 这个节点的访问频率 + 1
return node.value;
}
public void put(int key, int value) {
if (map.containsKey(key)) {
Node node = map.get(key);
node.value = value;
freqIncr(node);
return ;
}
if (size == capacity) removeStaleNode();
if (size < capacity) {
Node node = new Node(key, value);
node.freq = 1;
DoubleList list = freqMap.get(1);
if (list == null) {
list = new DoubleList();
freqMap.put(1, list);
}
list.add(node);
minFreq = 1;
map.put(key, node);
size++;
}
}
private void removeStaleNode() {
DoubleList list = freqMap.get(minFreq);
if (list == null || list.size == 0) return ;
// 从map中移除头节点
map.remove(list.head.next.key);
list.removeStale();
size--;
}
private void freqIncr(Node node) {
int oldFreq = node.freq;
node.freq++;
DoubleList oldList = freqMap.get(oldFreq);
oldList.remove(node); //从旧的频率的链表中移除
if (minFreq == oldFreq && oldList.size == 0) minFreq++; // 更新minFreq
DoubleList newList = freqMap.get(oldFreq + 1);
if (newList == null) {
newList = new DoubleList();
freqMap.put(oldFreq + 1, newList);
}
newList.add(node);
}
class Node {
private int key;
private int value;
private int freq;
private Node next;
private Node prev;
Node (int key, int value) {
this.key = key;
this.value = value;
}
}
class DoubleList {
// 2个虚拟节点
private Node head;
private Node tail;
private int size;
DoubleList() {
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
size = 0;
}
// 尾插法
void add(Node node) {
Node trueTail = tail.prev;
trueTail.next = node;
node.prev = trueTail;
node.next = tail;
tail.prev = node;
size++;
}
void remove(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
node.next = node.prev = null;
size--;
}
// 头节点是最早插入的
void removeStale() {
if (size == 0) return ;
Node trueHead = head.next;
head.next = trueHead.next;
trueHead.next.prev = head;
trueHead.next = trueHead.prev = null;
size--;
}
}
}
另:LFU/LRU来自于OS的页面置换算法,下面对OS的页面置换算法进行一个说明
FIFO:先进先出。会产生Belady现象(随着页面数量增大,缺页率反而上升的现象)。现已很少使用。参考cnblog这篇文章和这篇论文
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中的实现,实际没有用次数累加的方式,而是采用移位+定期衰减的方式。参考百度百科