面试题 16.25. LRU 缓存(C++【OPT/FIFO/LRU】操作系统页面置换算法一网打尽)
4091
2022.04.07
2022.04.07
发布于 未知归属地

【16.25 LRU题目】
【16.25 LRU题目】
【16.25 LRU题目】

背景知识补充(操作系统面试常问篇)

  • 为什么需要页面置换:当进程运行时,要访问的页面不在内存,则出现缺页中断,需要把该页面调入内存,如果内存中没有空闲物理块,则需要进行页面置换.页面置换算法依据一定策略,把物理块中的某个页面置换出去,送到磁盘交换区,在空出的一个物理块中装入导致缺页中断的页面。
  • 置换算法命中率的重要性:缺页会产生一系列动作,但在任何情况下,缺页中断处理所花费的时间主要由三部分组成,分别
    1. 处理缺页中断的时间;
    2. 调入该页的时间;
    3. 重新启动该进程的时间
    • 分析:其中,操作系统可以通过精心设计代码使第(1)、(3)项的执行时间控制在1~100μs,而第(2)项是将页面从磁盘上读到内存,这个过程所花费的时间包括:磁盘寻道时间、旋转延迟时间和数据传输时间,将近25ms.从辅存调入该页的时间,即启动一次I/O操作的时间,直接影响着缺页处理时间,并与有效的页面存取时间成正比关系.所以,在请求分页系统中使缺页率保持在很低的水平是非常重要的,也即是说,页面置换算法的优劣直接影响系统的性能。

几种页面置换算法

  1. 最佳置换算法(OPT)
    发生缺页中断时,选择将来不被使用,或者是在最远的将来才被访问的页面置换出去,但是实际中由于 OPT 算法需要预先知道一个进程在整个运行过程中页面走向的全部情况,因此只是一种理想状态,实际不可实现,但可作为基准,衡量其它算法的优劣。
    • 最优页面置换算法
  2. 先进先出算法(FIFO)
    发生缺页中断时,选择在内存中驻留时间最长的一页,即先进入内存的页,先被换出.该算法简单易于实现,将所有页面按照进入内存的次序排队,一个页面进入内存时入队尾,需要换出时则取队头页面,仅当按线性顺序访问地址空间时才是理想的,否则,效率不高.存在 Belady异常,即分配的物理块数增多,而缺页中断次数反而增加
    • image.png
  3. 最近最少使用置换算法(LRU)
    对最优算法的一个很好的近似是基于这样的观察:在前面几条指令中频繁使用的页面很可能在后面几条指令中被使用.反过来,已经很久没有使用的页面很可能在未来较长一段时间内仍然不会被使用,这就是著名的局部性原理.LRU 算法是:发生缺页中断时,选择未使用时间最长的页面置换出去.从程序运行的原理来看,LRU 算法是比较接近理想的一种置换算法,这种算法既充分利用了内存中页面调用的历史信息,又正确反映了程序的局部问题.
    • image.png

【LRU页面置换算法题目】
【LRU页面置换算法题目】
【LRU页面置换算法题目】
思路分析:
数据结构选择

  • 先说 get 的部分,想在 O(1) 时间内根据 key 获取 value,很自然就会想到之前提到的哈希表。通过维护哈希表,就可以在 O(1) 时间内判断某个 key 是否存在 LRU 中,或者访问到该 key 对应的 value。
  • 我们还要保证最近最少使用的替换策略,要想办法记录下内存中数据访问的先后关系,才可以保证最近访问过的,要比更早之前访问过的后淘汰。一种很自然的想法就是维护一个基于最近访问时间的有序列表。这当然有很多种实现方式:
    • 比如我们可以维护一个数组,从前到后依次存放最近访问过到最久没有访问过的 key。可是这样每次 get 的时候,我们就需要把数组中某个位置的 key 移动到数组的开始位置,并把后续的元素全部顺移一位。根据我们之前学到的知识,这样整体移动数组的操作的复杂度是 O(N)。
    • 所以要达到 O(1) 操作,双链表就可以实现,在 O(1) 内,删除节点并移动到指定位置的操作。我们可以构建一个双链表,让链表元素按照访问时间顺序从前到后依次排列。通过双链表 + 哈希表,就可以完美实现 LRU 基于最近访问时间排序的有序列表,这两种数据结构的组合非常常见,也有人称之为 LinkedHashMap
      实现
      通过以上思路分析首先使用哈希表进行定位,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 O(1) 的时间内完成 get 或者 put 操作。具体的方法如下:
  • 对于 get 操作,首先判断 key 是否存在:
    • 如果 key 不存在,则返回 -1;
    • 如果 key 存在,则 key 对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值。
  • 对于 put 操作,首先判断 key 是否存在:
    • 如果 key 不存在,使用 key 和 value 创建一个新的节点,在双向链表的头部添加该节点,并将 key 和该节点添加进哈希表中。然后判断双向链表的节点数是否超出容量,如果超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;
    • 如果 key 存在,则与 get 操作类似,先通过哈希表定位,再将对应的节点的值更新为 value,并将该节点移到双向链表的头部。
      -image.png

具体实现代码如下:

struct DLinkedNode {
    int key, value;
    DLinkedNode* prev;
    DLinkedNode* next;
    /* 构造函数便于新定义节点时初始化对象 */
    DLinkedNode(): key(0), value(0), prev(nullptr), next(nullptr) {}
    DLinkedNode(int _key, int _value): key(_key), value(_value), prev(nullptr), next(nullptr) {}
};

class LRUCache {
private:
    unordered_map<int, DLinkedNode*> map;
    DLinkedNode* head;
    DLinkedNode* tail;
    int size;       // 缓冲区使用的大小
    int capacity;   // 缓冲区的容量

public:
    LRUCache(int _capacity): capacity(_capacity), size(0) {
        /* 使用伪头部和伪尾部节点 */
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head->next = tail;
        tail->prev = head;
    }
    /* 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 */
    int get(int key) {
        if (!map.count(key)) return -1;
        /* 如果 key 存在,先通过哈希表定位,再移到头部 */
        DLinkedNode* node = map[key];
        moveToHead(node);
        return node->value;
    }
    
    void put(int key, int value) {
        if (!map.count(key)) {
            /* 如果 key 不存在,创建一个新的节点 */
            DLinkedNode* node = new DLinkedNode(key, value);
            /* 添加进哈希表 */
            map[key] = node;
            /* 添加至双向链表的头部 */
            addToHead(node);
            /* 缓存大小+1 */
            size++;
            if (size > capacity) {
                DLinkedNode* removed = removeTail();// 如果超出容量,删除双向链表的尾部节点
                map.erase(removed->key);// 删除哈希表中对应的项
                delete removed;// 防止内存泄漏
                size--;// 缓存大小-1
            }
        }
        else {
            /* 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部 */
            DLinkedNode* node = map[key];
            node->value = value;
            moveToHead(node);
        }
    }

/****************定义双向链表需要用的API函数****************/
public:
    /* 在虚拟头节点后添加新的节点 node */
    void addToHead(DLinkedNode* node) {
        node->prev = head;
        node->next = head->next;
        head->next->prev = node;
        head->next = node;
    }
    /* 删除当前节点 node 这里也是为什么使用双向链表的原因方便删除节点 */
    void removeNode(DLinkedNode* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }
    /* 把出现频率比较高的节点放入 头部 */
    void moveToHead(DLinkedNode* node) {
        removeNode(node);
        addToHead(node);
    }
    /* 移除尾部节点 并返回尾部最后一个节点 */
    DLinkedNode* removeTail() {
        DLinkedNode* node = tail->prev;
        removeNode(node);
        return node;
    }
};

本题拓展总结
通过分页和虚拟内存的抽象,操作系统解放了用户对内存管理和容量的心智负担。当缓存的数据越来越多,如何选择一个合适的页面或缓存内容来替换,就是缓存置换算法的用武之地。页面置换策略有多种,包括随机置换、FIFO、LRU 等,非常重要且常见的 LRU 通过利用引用列表的局部相关性,提高了页面的命中率。
LRU 的实现也并不是非常复杂,但需要对链表和哈希表有很好的理解才行,所以我们一定要认真打好数据结构和算法的基础。LRU 不但是面试的常见考点,实际开发也相当常用。
面试可能会问道出了上面的问题外还有一个问题
为什么在 LRU 的数据结构中,我们选择了双链表而不是单链表呢?

LRU需要把中间的节点移动到头部,并且要删除尾部节点,单链表对这两个操作都需要O(n),双链表可以用O(1)
完成。LRU组合了哈希表和双向链表各自的强项,因而提供了快速的查询和灵活的资源管理两项功能。

【如果有好的理解欢迎在评论区留言】

评论 (1)