1 前言
上节我们介绍了几个页面替换算法,也就是一种淘汰策略,这节我们就看一种新的算法:LRU哈。
2 LRU
LRU(Least Recently Used,最近最少使用)算法根据页面的历史请求记录来进行淘汰页面,其核心思想是 “如果页面数据最近被访问过,那么将来被访问的几率也更高”。基于这个思想,会存在一种缓存淘汰机制,每次从内存中找到最久未使用的数据然后置换出来,从而存入新的数据!它的主要衡量指标是使用的时间,附加指标是使用的次数。
在计算机中大量使用了这个机制,它的合理性在于优先筛选热点数据,所谓热点数据,就是最近最多使用的数据!因此,利用 LRU 我们可以解决很多实际开发中的问题,并且很符合业务场景。
如下图所示,假设页面请求序列为:[6,0,1,2,0,3,0,5,2,3,0,3,2]
,我们仅有 4 个页面存储块:
初始时,4 个页面存储块均为空,所有直接将 4 个存储块分配给请求页面 6,0,1,2
,发生了 4 次页面错误;
紧接着是请求页面 0,发现页面 0 已经在内存中了,发生 0 次页面错误;
当请求页面 3 到来时,发现不存在,发生了 1 次页面错误,此时内存块已经满了,所以只能替换最近最久未被使用页面 6 ,6 进来的最早,而且最近没有被使用过;
0 已经在内存块中,所以发生 0 次页面错误;
请求页面 5 将替换页面 1,因为请求页面 1 是当前最久未被使用的页面,发生一次页面错误;
之后的页面 2,3,0,3,2 均已在内存中,不会发生页面错误。
整个页面的替换过程就如下图所示:
设内存中可以容纳的页面数为 capacity
,请求的页面集合为 set
,请计算页面的缺页错误的次数是多少?
输入:capacity = 4, set[] = {6,0,1,2,0,3,0,5,2,3,0,3,2}
输出:6 (缺页错误发生的次数)
首先从大的角度,分为两种情况:
情况一:内存中容纳的页面数小于 capacity
,这种情况下,直接加入页面,并累计缺页错误的次数;
情况二:内存中容纳的页面数等于 capacity
,如果请求页面已经在内存中,没有发生页面错误,否则,找到最近最少被使用的的页面,并使用请求页面替换该页面。
这样的表述难免有些含混不清,缺少了太多细节,那就让我们再一次用示例输入讲解一遍,并彻底扫清这些细节。
2.1 哈希法
我们使用 HashSet
来模拟内存,容量为 capacity = 4
;用一个辅助的哈希表,java 中的 HashMap
来存储最近最少被使用的页面。
使用 HashSet
,使得可以在 O(1) 的时间判断当前的请求页面是否在内存中。
使用 HashMap
同样是有助于我们的查询最近最少被使用的页面,从而进行替换。
遍历请求页面序列 [6,0,1,2,0,3,0,5,2,3,0,3,2]
:
对于 6,0,1,2
而言,属于情况一,此时内存中可容纳的页面小于总容量,直接将其添加到内存块,即 HashSet
当中:
当请求接下来的页面 0 ,发现 HashSet
已经满了,属于情况二,接着判断页面是否在 HashSet
中,发现已经在了,我们就只更新页面 0 在 HashMap
中的 value ,即将 key = 0
的 value = 4
更新为当前请求页面的下标。
你一定观察到了判断一个页面是不是最近最少被使用的页面了,是不是其在 HashMap
中的 value 值最小,其就是最近最少被使用的页面呢?当然啦,比如上图中,页面 6 所对应的 value = 0 ,是最小的,它也就是最近最少被使用的页面。这里的最近最少被使用的页面,你也可以理解成 最久未被使用 的页面。
紧接着,请求页面为 3 ,内存块,即 HashSet
的容量已满,情况二(之后都是情况二),但是请求页面 3 不在 HashSet
当中,我们就需要找到最近最少被使用的页面,即页面 6 (直接通过比较找到 HashMap
中的最小值就可获得)。然后在 HashSet
中用当前的请求页面 3 替换最近最少被使用的页面 6 ,并清除 HashMap
中关于关键字 6 的记录,添加请求页面 3 ,及其下标 5 ,如下图所示:
[6,0,1,2,0,3,0,5,2,3,0,3,2]
的下一个请求页面为 0 ,已经在内存块 HashSet
中,所以仅更新其在 HashMap
中的值为当前下标 6 ,表示最近刚被访问过。
后面的请求页面 [5,2,3,0,3,2]
,我就不一一讲了。明白了上面的过程,写出下面的代码也不难:
class LRU { // 使用哈希统计发生缺页错误的次数 static int pageFaults(int pages[], int n, int capacity) { //使用 HashSet 来模拟内存块 HashSet<Integer> memory = new HashSet<>(capacity); /* 使用 HashMap 来存储页面的访问顺序信息 * 这里的顺序就是请求页面在pagesz中的下标 */ HashMap<Integer, Integer> indexes = new HashMap<>(); // 遍历请求页面序列并进行处理 int faultPages = 0; for (int i = 0; i < n; i++) { // 情况一:检查当前内存中的页面数是不是小于内存的容量 capacity if (memory.size() < capacity) { // 判断当前请求页面是否在内存中,不在,则直接添加 if (!memory.contains(pages[i])) { memory.add(pages[i]); // 缺页错误次数加一 faultPages++; } // 请求页面在内存中,更新请求页面最近被访问的标识,即其当前下标 indexes.put(pages[i], i); } else{ // 内存块已满的情况二 // 不在内存块中 if (!memory.contains(pages[i])) { // 找到 HashMap 中的最小值,即最近最近未被使用的页面 int minValue = Integer.MAX_VALUE; // 最小值 int key = Integer.MIN_VALUE; // 要替换的页面编号 Iterator<Integer> itr = memory.iterator(); while (itr.hasNext()) { int temp = itr.next(); if (indexes.get(temp) < minValue) { minValue = indexes.get(temp); key = temp; } } // 移除要替换的页面 memory.remove(key); // 从 HashMap 中移除被替换页面 indexes.remove(key); // insert the current page memory.add(pages[i]); faultPages++; } // 更新当前请求页面的下标 indexes.put(pages[i], i); } } return faultPages; } }View Code
复杂度分析:
时间复杂度:O(cn) ,其中 c 表示内存块的容量 capacity,n 表示请求页面的个数,当然这个 c 一般都是固定的,可以忽略不计,总的时间复杂度就是 O(n) 量级
空间复杂度:使用了一个容量为 c 的 Hashset
,以及至少为 O(n) 的哈希表,因为 n 个请求页面可能均不相同。
2.2 类队列法
我们每一次都是让最久未被使用的页面从内存块中被替换出去,而新的请求页面进来,将最久未被使用的页面想象成队头,而新的请求页面想成队尾,LRU 算法思想有没有和队列的先进先出很像呢,我们依旧以请求页面数组 [6,0,1,2,0,3,0,5,2,3,0,3,2]
为例进行说明。
设队列的容量为 4,对应于内存块的容量,如下图所示:
首先这个队列为空,所以依次将 6,0,1,2
入队,如下图所示:
然后到来了请求页面 0 ,0 已经在队列中了,此时就是处理的关键了,我们直接将队列中的 页面0 删除,然后再将请求页面 0 入队:
然后处理请求页面 3 ,发现不在队列中,此时队头的元素就是最久未被使用的页面(最近最少使用的页面),将队头页面 6 出队,然后将请求页面 3 入队:
接着是请求页面 0 ,已在队列中,删除,入队:
请求页面 5,不再队列中,队头页面 1 出队,请求页面入队:
不论何时,这个特殊的队列的队头是最近最少被使用的页面,队尾是刚才被访问的页面!
对于普通队列而言,只能是队头出队,队尾入队,而这个特殊的队列则不同,任意一个位置均可直接出队,而入队依旧是队尾,这样就可以保证这个特殊的队列中的元素从队头到队尾按照最近最少被使用进行排序。
实现代码我们看看:
class LRU { static int pageFaults(int pages[], int n, int capacity) { //用来模拟上面提到的特殊队列 ArrayList<Integer> queue = new ArrayList<>(capacity); int count=0; // 可以当做队尾指针 int faultPages=0; for(int page : pages) { // 队列中不包含页面 page if(!queue.contains(page)) { // 检查队列是否已满,已满则队头出队,队尾添加当前页面 if(queue.size()==capacity) { queue.remove(0); queue.add(capacity-1,page); } else{ queue.add(count, page); } faultPages++; ++count; } else { // 队列中存在当前请求页面 // 移除当前请求页面 queue.remove((Object)page); // 在队尾添加请求页面 queue.add(queue.size(),page); } } return faultPages; } }View Code
复杂度分析:
时间复杂度:O(cn) ,n 表示请求页面的个数。
空间复杂度:O(c) ,c 表示内存块可容纳的页面个数。
3 引申-LRU 缓存机制
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制的 数据结构 。
实现 LRUCache 类:
LRUCache(int capacity)
以正整数作为容量 capacity
初始化 LRU 缓存;
int get(int key)
如果关键字 key
存在于缓存中,则返回关键字的值,否则返回 -1 。
void put(int key, int value)
如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。
当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?这种O(1)其实一般就要用到哈希,我们来看看
输入 ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"] [[2], [1, 10], [2, 20], [1], [3, 30], [2], [4, 40], [1], [3], [4]] 输出 [null, null, null, 1, null, -1, null, -1, 3, 4] 解释 LRUCache lRUCache = new LRUCache(2); lRUCache.put(1, 10); // 缓存是 {1=10} lRUCache.put(2, 20); // 缓存是 {1=10, 2=20} lRUCache.get(1); // 返回 10 lRUCache.put(3, 30); // 该操作会使得关键字 2 作废,缓存是 {1=10, 3=30} lRUCache.get(2); // 返回 -1 (未找到) lRUCache.put(4, 40); // 该操作会使得关键字 1 作废,缓存是 {4=40, 3=30} lRUCache.get(1); // 返回 -1 (未找到) lRUCache.get(3); // 返回 30 lRUCache.get(4); // 返回 40
3.1 蛮力法
我们可以维护一个 Nodes
数组,其中每一 node
包含下面若干属性值:
class Node { int key; int value; // 用于记录页面 key 被存储的时间戳 // timeStamp 用于找到最近最久未被使用的页面 int timeStamp; public Node(int key, int value){ this.key = key; this.value = value; this.timeStamp = currentTimeStamp; } }
Nodes
数组的大小等于给定的缓存大小 capacity
.
(1)对于 int get(int key)
而言,我们只需要简单地遍历 Nodes
数组,然后比较要查找的关键字 key
和每一个 node 的 key
值,如果相等,则返回待查找的 key
所对应的 value
值,如果不相等,没有找到对应的关键字,则直接返回 -1 。时间复杂度为 O(n) 。
(2)对于 void put(int key, int value)
,如果数组已满,则不得不删除数组中的一个结点;遍历数组,找到时间戳值最小的结点,即要删除的 LRU 结点;使用新结点替换 LRU 结点。
如果数组没有满,则直接在数组的末尾插入新结点,时间复杂度 O(n) . 这里的 n 均表示缓存容量。
3.2 哈希表 + 双向链表
解决此问题的关键是使用双向链表,该链表使我们能够快速移动节点。LRU 缓存可以看做是哈希表 + 双向链表的特殊数据结构。哈希表使 get()
的时间为 O(1) , 双向链表可以保证结点的添加/删除操作为 O(1)。
那为什么只使用哈希表不可以呢?哈希表的查找,删除,添加的时间复杂度均为 O(1) 量级呀!但是你可记得哈希表的一个最大弊端就是无序性,而要模拟 LRU 缓存机制,我们必然要维护页面在缓存中的先后顺序,即哪一个页面是最近最少被访问页面?就像蛮力法一样,我们是通过时间戳 timestamp
来保存页面在缓存中的相对顺序信息,而链表刚好满足这样的条件,利用指针之间的指向关系来保存页面的访问顺序信息。
所以关键就是如何巧妙地将哈希表与链表各自的优点结合起来,从而保证 LRU 缓存的插入、删除和查找时间复杂度均为 O(1)。
我们将一个结点定义如下:
class Node { int key; int value; Node pre; Node next; public Node(int key, int value) { this.key = key; this.value = value; } }
上面的代码可图形式化表示为:
首先我们初始化一个大小为 capacity
的 LRUCache
,其中双向链表使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。
初始化 LRUCache
时,我们同时初始化了一个空的哈希表,从图中暂时还无法看出哈希表和双向链表的结合,我们必然是由哈希表的 key
查找到双向链表中的结点,所以哈希表的值就是双向链表中一个一个的结点。
初始化的代码如下:
public LRUCache(int capacity) { this.capicity = capacity; // LRU 缓存大小 map = new HashMap<>(); // 哈希表 head = new Node(0, 0); // dummy head 伪头部 tail = new Node(0, 0); // 伪尾部 head.next = tail; tail.pre = head; head.pre = null; tail.next = null; count = 0; // 用于记录当前 LRU 缓存中的页面数量 }
执行 void put(int key, int value)
,首先判断哈希表中是否有关键字 key
,如果没有则创建一个新的结点 node
,并在哈希表中添加 <key, node>
键值对,然后判断 LRU 缓存中页面的数量 count
是否小于 LRU 缓存容量 capacity
,如果小于,则直接在头部添加新结点,否则移除哈希表中伪尾部 tail
的前一个结点 tail.pre
所对应的 tail.pre.key
,并在双向链表的头部添加新结点 node
; 如果哈希表中有关键字,则由哈希表获取到该结点,并更新该结点的值,然后删除结点,并将结点添加到双向链表的头部。
执行 lruCache.put(1,10)
,此时 LRU 缓存中页面的数量 (0 = count) < (capacity = 2)
LRU 缓存容量,新建一个结点,并将该结点添加到双向链表的头部,同时哈希表中添加一个键值对,key
与结点的 key
相同,而 value
则为其指向的结点,count++
。我们将双向链表的头部作为最近刚被访问的页面,双向链表的尾部为最近最少使用的页面。
然后执行 lRUCache.put(2, 20);
,此时 LRU 缓存中的页面数量 (1 = count) < (capacity = 2)
LRU 缓存容量,与上面的情况一样:
执行 int get(int key)
时,首先判断哈希表中是否有关键字 key
,如果有,则取出关键字 key
所对应的结点 node
,删除 node
结点,然后将 node
结点插入双向链表的头部,并返回 node
结点的值 value
;否则,也就是哈希表中没有找到,则返回 -1 。
执行 int get(1)
时,发现哈希表中有关键字 1 ,首先删除结点 node1 ,然后将 node1 插入到双向链表的头部,并返回 10 。这里的删除和插入操作看似毫无意义,但是这正是 LRU 算法核心思想的一部分,保证最近刚被访问的页面位于双向链表的头部,最近最少访问的页面位于双向链表的尾部,中间的结点有序排列。
执行 lRUCache.put(3, 30)
, 首先没有在哈希表中找到关键 3 ,则创建一个新的结点 node3 ,并将该结点添加到哈希表中,然后判断当前页面容量 count = 2 和 LRU 缓存容量 capacity=2 ,发现 LRU 缓存已满,所以要删除缓存当中最久未被访问的页面(即双向链表的尾部结点 node2),然后在双向链表的头部插入新结点 node3 :
然后执行 lRUCache.get(2)
时,发现哈希表中不存在关键字 2 ,直接返回 -1 。
执行 lRUCache.put(4, 40)
,发现哈希表中不存在关键 4 ,创建新结点 node4 ,并在哈希表中添加 <4, node4>
,然后判断 LRU 缓存是否已满,发现满了,则删除双向链表尾部的元素 node1 ,并将 node4 添加到双向链表的头部。
执行 lRUCache.get(1)
,哈希表中未找到关键字 1 ,直接返回 -1 。执行 lRUCache.get(3)
,哈希表中有关键字 3, 获取到关键字所对应的结点 node3 ,然后删除结点 node3 ,再将 node3 插入到双向链表的头部,此时就会看到 node3 和 node4 调换了顺序,返回 30:
执行 lRUCache.get(4)
,与上面的情况相同,再一次调换 node3 和 node4 在双向链表中的顺序,并返回 40 ,如图所示。
我们关注于 LRU 缓存机制的数据结构(哈希表 + 双向链表)本身,而没有详细讲解哈希表和双向链表内部的内容,特别是双向链表的删除和插入操作。LRU 缓存机制的数据结构是集合了哈希表和双向链表两者各自优点的产物,也希望大家看到基础数据结构的重要性。
实现代码如下:
import java.util.HashMap; // 结点 class Node { int key; int value; Node pre; Node next; public Node(int key, int value) { this.key = key; this.value = value; } } // LRU 缓存 class LRUCache { private HashMap<Integer, Node> map; // 哈希表 private int capacity; // LRU 缓存容量 private int count; // 当前的缓存容量 private Node head, tail; // 伪头部和尾部结点 // 初始化 LRUCache public LRUCache(int capacity) { this.capacity = capacity; map = new HashMap<>(); head = new Node(0, 0); tail = new Node(0, 0); head.next = tail; tail.pre = head; head.pre = null; tail.next = null; count = 0; } // 删除一个结点 public void deleteNode(Node node) { node.pre.next = node.next; node.next.pre = node.pre; } // 在双链表的头部添加一个结点 public void addToHead(Node node) { node.next = head.next; node.next.pre = node; node.pre = head; head.next = node; } // 获取关键字 key 所对应的 value public int get(int key) { if (map.get(key) != null) { Node node = map.get(key); int result = node.value; deleteNode(node); addToHead(node); return result; } return -1; } // 时间复杂度为 O(1) public void put(int key, int value) { if (map.get(key) != null) { // 哈希表中包含关键字 key Node node = map.get(key); // 获取关键字key所对应的结点 node node.value = value; // 设置节点的值 deleteNode(node); // 删除结点 addToHead(node); // 将结点插入到头结点 } else {// 哈希表中不包含关键字 key Node node = new Node(key, value); // 创建新结点 node map.put(key, node); // 将结点<key node>添加到哈希表中 if (count < capacity) { // 当前的LRU缓存容量未满 count++; addToHead(node); // 直接将结点添加到双链表头部 } else { map.remove(tail.pre.key); // 哈希表中移除最久未被使用的页面 deleteNode(tail.pre); // 双链表中删除最近最少被使用页面 addToHead(node); // 将新结点添加到双链表头部 } } } } public class TestLRUCache { public static void main(String[] args) { LRUCache cache = new LRUCache(2); cache.put(1, 10); cache.put(2, 20); System.out.println("Key = 1, value = " + cache.get(1)); cache.put(3, 30); System.out.println("Key = 2, Value = " + cache.get(2)); cache.put(4, 40); System.out.println("Key = 1, Value = " + cache.get(1)); System.out.println("Key = 3, Value = " + cache.get(3)); System.out.println("Key = 4, Value = " + cache.get(4)); } }View Code
3.3 哈希链表
前面给的方法是 哈希表 + 双向链表 ,其中哈希表使用的是 Java 中的 HashMap
,双向链表英文名为 double Linked List ,两者的结合体就是 Linked Hash Map
,又名 哈希链表 。
Java 中提供了现成的哈希链表,即 LinkedHashMap
:
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>{}
基于 LinkedHashMap
的实现代码更加简单:
class LRUCache { private LinkedHashMap<Integer, Integer> map; private final int CAPACITY; // 初始化 LRU 缓存 public LRUCache(int capacity) { CAPACITY = capacity; map = new LinkedHashMap<Integer, Integer>(capacity, 0.75f, true) { protected boolean removeEldestEntry(Map.Entry eldest) { return size() > CAPACITY; } }; } // 时间复杂度 O(1) public int get(int key) { return map.getOrDefault(key, -1); } // 时间复杂度 O(1) public void set(int key, int value) { map.put(key, value); } }
看似在说 LRU 算法,也是顺便把 LinkedHashMap
的也给串起来了,以及对于基本数据哈希表和双向链表的理解。
基于哈希链表的实现简单,实则和哈希表 + 双向链表的实现方式一样,我们依次对代码中涉及的几个回调函数和构造方法进行说明。
首先是 LinkedHashMap
的构造函数:
/** * LinkedHashMap 构造方法 * * @param initialCapacity 初始容量 * @param loadFactor 装载因子 * @param accessOrder true 访问顺序 * accessOrder false 为插入序(默认) * @throws 如果初始容量为负值或者装载因子为非正值,则抛 IllegalArgumentException */ public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder = accessOrder; }
其中的 initialCapacity
就是 LRU 缓存的容量 capacity
,装载因子取 0.75f
,也就是默认值,为什么取 0.75,可以看看我们的哈希算法,accessOrder
表示哈希链表的排序方式, accessOrder = true
表示按照结点的访问顺序排序,accessOrder = false
表示按照结点的插入顺序排序,默认为 false,但是 LRU 缓存机制显然是按照结点的访问顺序排序的,所以传入 true
.
再来看一下我们覆写的方法 removeEldestEntry
,我给大家翻译了源码中的部分注释,大家凑活着看,水平有限,我们覆写的代码其实注释中给了,size() > CAPACITY
就表示当 LinkedHashMap
的容量大于 CAPACITY
时,则删除最旧的页面,由于我们的 accessOrder = true
则相当于删除最近最少被访问的结点。
/** * 如果哈希表要删除其最旧的结点,则返回 true. * 在哈希表中插入新的结点后,put 和 putAll 方法会调用该方法 * 每次添加新的结点时,该函数为实现者提供了删除旧结点的机会。 * 如果用哈希表表示缓存时,该函数就会非常有用: * 该函数允许删除旧的结点,从而减少内存消耗。 * 使用示例:覆写该方法,可以保证哈希表在最多可以容纳100个结点的情况下, * 每次添加新结点时都会删除最旧的结点,从而保持100个结点的稳定状态。 * <pre> * private static final int MAX_ENTRIES = 100; * * protected boolean removeEldestEntry(Map.Entry eldest) { * return size() > MAX_ENTRIES; * } * </pre> * * 此方法默认返回值为 false,也就是不会修改哈希表, * 但是其允许哈希表根据其返回值修改哈希表本身。 * 允许此方法直接修改哈希表,但如果这样做,则必须返回 false *(指示该哈希表不应尝试任何进一步的修改)。 * 在此方法中修改哈希表后返回true的效果未指定。 * 默认实现仅返回 false; * 此时 LinkedHashMap 类似于普通的哈希表-永远不会删除最旧的结点)。 * @param eldest:哈希表中最近最少插入(least recently inserted)的结点 * 如果是按访问顺序排序的哈希表, * eldest 则表示最近最少访问(least recently accessed)的结点。 * 如果该方法返回 true, 则删除该结点. * @return 如果最旧的结点要删除,返回 true, 否则,返回false。 */ protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return false; }
getOrDefault
方法的代码更简单,就是哈希链表中有关键字 key ,则返回关键字所对应结点则值,否则返回默认值 -1:
public V getOrDefault(Object key, V defaultValue) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) return defaultValue; if (accessOrder) afterNodeAccess(e); return e.value; }
至于添加方法 put(int key, int value)
则是向 LinkedHashMap
中添加结点,内部操作和哈希表 + 双向链表的思想基本一致,但是实现不同:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
其中 putVal(hash(key), key, value, false, true)
是 HashMap
的方法,我们就看到这里哈。
4 小结
好啦,关于LRU的我们就看到这里了,有理解不对的地方欢迎指正哈。
标签:结点,key,int,链表,算法,最少,LRU,哈希,页面 From: https://www.cnblogs.com/kukuxjx/p/17369023.html