引言
Least Recently Used (LRU),中文名为最近最少使用,它的设计原则借鉴了时间局部性原理——一个对象如果最近没有被使用,那么将来一段时间内都不会被使用,反之亦成立。具体而言,当空间资源被占用完毕时,如果新增资源加入到空间内部,那么空间内最近未被使用的资源将被调换出去为新资源腾出一个位置。
Least Frequently Used (LFU),中文名为最不经常使用,它的设计原则是一个对象如果使用频率越低,那么再次被使用的概率也越低。因此,当空间资源被占用完毕时,如果新增资源加入到空间内部,那么空间内使用频率最低的资源将被调换出去为新资源腾出一个位置。
如果第一次听说这两个算法,同学们可能会被他的名字吓懵,但是仔细了解后,发现LRU不过是利用了时间序列的思想进行预判,而LFU则利用了概率的思想进行预判罢了。本文会带着大家了解这两个算法的来源,并给出具体的Java实现。
算法背景
存储器的空间资源十分有限,操作系统为了管理这类资源,会对内存的分配进行优化,一般而言,内存的分配方式主要有四种:
- 连续分配方式
- 基本分页存储管理方式
- 基本分段存储管理方式
- 段页式存储管理方式
但这一类管理方式存在一定的局限性,当作业调度时必须一次性将所有页面调入内存,但若内存空间不够时,则作业需等待有足够空间时才能够调度运行。
请求分页管理方式则能够很好地解决这一问题,请求分页管理方式支持虚拟内存,具备页面置换功能,调度作业时仅将一部分页面调入内存,当运行时发生缺页时则采取一定策略将部分页面调出,将需要的页面调入内存。总而言之,请求分页存储管理并不是独立运行的,而是建立在分页式管理的基础之上的一种存储管理方式。
虚拟内存(百度百科):虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
上面提到请求分页管理的特点便是具备页面置换的功能,而页面置换的策略有许多种,LRU和LFU便是其中的两种策略,这也是这两种算法的由来。
LRU代码实现
LRU算法实现难点在于要在O(1)的时间复杂度下完成置换,本文采用 双向链表+哈希表 的方案解决这一问题,这一方案的核心在于:
- 将每个节点的key和节点本身以键值对的形式存入哈希表,当空间已被占用完毕且又有新节点加入时则将链表尾部节点删除,此时尾删和头插这两步操作都是O(1);
- 当获取已有节点数据时,则通过key值从HashMap中取出而不是遍历链表,由于HashMap在数据量不大的情况下,get操作可以被认为是O(1),因此获取已有页面也达到了O(1)的标准。
具体实现如下:
/**
* LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰
* LRUCache可以简单修改代码然后cv到LeetCode进行测试 https://leetcode-cn.com/problems/lru-cache-lcci/
*/
public class LRUCache {
public int capacity;
public int size;
static class LRUDoubleLinkedNode {
String key;
int val;
LRUDoubleLinkedNode next;
LRUDoubleLinkedNode prev;
LRUDoubleLinkedNode(String key, int val) {
this.key = key;
this.val = val;
}
}
public LRUDoubleLinkedNode head;
public LRUDoubleLinkedNode tail;
public Map<String, LRUDoubleLinkedNode> cache;
public LRUCache(int capacity) {
this.capacity = capacity;
this.size = 0;
head = new LRUDoubleLinkedNode(Constant.HEAD_SENTINEL, 0);
tail = new LRUDoubleLinkedNode(Constant.TAIL_SENTINEL, 0);
cache = new HashMap<>();
head.next = tail;
tail.prev = head;
}
/**
* 插入页面
*/
public void put(String key, int val) {
if (capacity == 0) {
throw new RuntimeException(Constant.CACHE_CAPACITY_ZERO);
}
LRUDoubleLinkedNode node = cache.get(key);
if (node != null) {
node.val = val;
moveToHead(node);
}else {
LRUDoubleLinkedNode newNode = new LRUDoubleLinkedNode(key, val);
cache.put(key, newNode);
addToHead(newNode);
size++;
if (size > capacity) {
String removeKey = removeLast();
cache.remove(removeKey);
size--;
}
}
}
/**
* 取出页面
*/
public int get(String key) {
if (!cache.containsKey(key)) {
throw new RuntimeException(Constant.PAGE_IS_NOT_EXIST);
}
LRUDoubleLinkedNode node = cache.get(key);
moveToHead(node);
return node.val;
}
/**
* 移动页面至头部
*/
private void moveToHead(LRUDoubleLinkedNode node) {
remove(node);
addToHead(node);
}
/**
* 头插法添加页面
*/
private void addToHead(LRUDoubleLinkedNode node) {
head.next.prev = node;
node.next = head.next;
node.prev = head;
head.next = node;
}
/**
* 删除页面
*/
private void remove(LRUDoubleLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
/**
* 删除尾部页面
*/
private String removeLast() {
LRUDoubleLinkedNode node = tail.prev;
remove(node);
return node.key;
}
/**
* 测试
* 因为LRU结构较为复杂,单一测试案例完全无法验证算法的正确性,因此可以前往LeetCode界面进行测试
*/
public static void main(String[] args) {
LRUCache lruCache = new LRUCache(1);
System.out.println("---------------原缓存---------------");
System.out.println(lruCache.cache.toString());
System.out.println("\n");
System.out.println("---------------put测试---------------");
lruCache.put("1", 2);
System.out.println(lruCache.cache.toString());
lruCache.put("1",3);
System.out.println(lruCache.cache.toString());
lruCache.put("2", 5);
System.out.println(lruCache.cache.toString());
System.out.println("\n");
System.out.println("---------------get测试---------------");
int page = lruCache.get("2");
System.out.println(page);
lruCache.get("1");
}
}
LFU代码实现
LFU实现的难点同样在于要在O(1)的时间复杂度下完成置换,但LFU同LRU不同,最近最少使用这一条件使得调出的页面被限定在了空间的尾部,因此查找到这一调出页面的效率是O(1),但LFU需要知道哪一个页面的历史使用次数最少,这个节点是不固定的。为了解决这一问题,本文采取 双HashMap+双向链表 的方案,这一方案的核心在于:
- 第一个HashMap的键值对为 key 和双向链表节点,第二个HashMap的键值对为频数和每个频数对应的双向链表(将所有相同频数的节点组成的链表),此外,再维护一个全局最小频数【即操作数】;
- 每次插入一个新节点时,如果空间已满,此时查找最小频数对应的链表,并将该链表的尾部节点取出并删除(O(1)操作),同时维护最小频数【查看最小频数对应的链表是否为空】,最后再删除第一个HashMap中对应的节点,最后将新的节点插入;
- 每次要获取一个已有节点时,则更新最小频数,并将对应的链表进行调整,由于操作了一次该节点,因此该节点需要移动至【频数+1】对应的链表头部
综合而言,就是在LRU的基础上将链表节点依照频数进行分组,从而解决了节点不固定的问题。具体实现如下:
/**
* LFU(least frequently used (LFU) page-replacement algorithm),即最不经常使用页置换算法.
* 定义:
* LFU根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
*/
public class LFUCache {
static class LFUDoubleLinkedNode {
int val;
int key;
int freq;
LFUDoubleLinkedNode prev;
LFUDoubleLinkedNode next;
public LFUDoubleLinkedNode() {}
public LFUDoubleLinkedNode(int key, int val) {
this.key = key;
this.val = val;
this.freq = 1;
}
}
static class LFUDoubleLinkedList {
public LFUDoubleLinkedNode head;
public LFUDoubleLinkedNode tail;
public LFUDoubleLinkedList() {
this.head = new LFUDoubleLinkedNode();
this.tail = new LFUDoubleLinkedNode();
head.next = tail;
tail.prev = head;
}
/**
* 插入节点
*/
private void addToHead(LFUDoubleLinkedNode node) {
head.next.prev = node;
node.next = head.next;
node.prev = head;
head.next = node;
}
/**
* 删除节点
*/
private void remove(LFUDoubleLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
/**
* 删除尾部节点
*/
private LFUDoubleLinkedNode removeLast() {
LFUDoubleLinkedNode node = tail.prev;
remove(node);
return node;
}
}
public int capacity; // 最大容量
public int size; // 当前容量
public Map<Integer, LFUDoubleLinkedList> freqMap; // 相同频次链表
public Map<Integer, LFUDoubleLinkedNode> cache; // 节点存储
public int minFreq; // 当前最小频次
public LFUCache(int capacity) {
this.capacity = capacity;
this.size = 0;
this.minFreq = 0;
freqMap = new HashMap<>();
cache = new HashMap<>();
}
/**
* 获取页面
*/
public int get(int key) {
LFUDoubleLinkedNode node = cache.get(key);
if (node == null) {
throw new RuntimeException(Constant.PAGE_IS_NOT_EXIST);
}
updateFreq(node);
return node.val;
}
/**
* 插入页面
*/
public void put(int key, int val) {
if (capacity == 0) {
throw new RuntimeException(Constant.CACHE_CAPACITY_ZERO);
}
LFUDoubleLinkedNode node = cache.get(key);
if (node == null) {
if (size == capacity) {
LFUDoubleLinkedList minLfuDoubleLinkedList = freqMap.get(minFreq);
LFUDoubleLinkedNode lfuDoubleLinkedNode = minLfuDoubleLinkedList.removeLast();
cache.remove(lfuDoubleLinkedNode.key);
size--;
}
node = new LFUDoubleLinkedNode(key, val);
cache.put(key, node);
LFUDoubleLinkedList lfuDoubleLinkedList = freqMap.get(node.freq);
if (lfuDoubleLinkedList == null) {
lfuDoubleLinkedList = new LFUDoubleLinkedList();
freqMap.put(node.freq, lfuDoubleLinkedList);
}
lfuDoubleLinkedList.addToHead(node);
size++;
minFreq = 1;
}else {
node.val = val;
updateFreq(node);
}
}
/**
* 更新频次
*/
public void updateFreq(LFUDoubleLinkedNode node) {
// 删除老频次链表中的节点
int freq = node.freq;
LFUDoubleLinkedList lfuDoubleLinkedListOld = freqMap.get(freq);
lfuDoubleLinkedListOld.remove(node);
// 更新当前最小频次
if (freq == minFreq && lfuDoubleLinkedListOld.head.next == lfuDoubleLinkedListOld.tail) {
minFreq = freq+1;
}
// 插入到新频次链表
node.freq = freq + 1;
LFUDoubleLinkedList lfuDoubleLinkedListNew = freqMap.get(node.freq);
if (lfuDoubleLinkedListNew == null) {
lfuDoubleLinkedListNew = new LFUDoubleLinkedList();
freqMap.put(node.freq, lfuDoubleLinkedListNew);
}
lfuDoubleLinkedListNew.addToHead(node);
}
/**
* 测试,详细测试前往 https://leetcode-cn.com/problems/lfu-cache/submissions/
*/
public static void main(String[] args) {
LFUCache lfuCache = new LFUCache(2);
System.out.println("------------LFU测试------------");
lfuCache.put(3, 1);
lfuCache.put(2, 1);
lfuCache.put(2, 2);
lfuCache.put(4, 4);
System.out.println(lfuCache.get(2));
}
}
结语
LRU和LFU仅仅是页面置换算法中的两种,其实现并不重要,重要的是理解 链表+哈希表 这种数据结构组合使用的思想,因为这两个算法说穿了就是数据结构的举一反三。