算法通过村第五关-队列和Hash黄金笔记|LRU的设计与实现

文章目录

  • 前言
  • 1. LRU的含义
  • 2. Hash+双向链表实现LRU
  • 总结


前言


提示:我曾如此渴望命运的波澜,到最后才发现:人生最曼妙的风景,竟是内心的淡定从容。 我们层如此盼望世界的认可,到最后才知道:世界是自己,与他人毫无关系。 --杨绛

LRU 是非常经典的问题,而且在常年的算法中也是热门,但是他是存在技巧的,我们这就来一起看看吧。

1. LRU的含义

缓存是应用软件的必备功能之一,在操作系统中,Java里面的Spring、mybatis,redis、mysql等软件中都有自己的内部缓存模块,然而缓存是如何实现的呢?在操作系统的教科书中,我们知道常见的有FIFO、LRU,LFU三种基本方法。FIFO也就是队列方式,但是它不能很好的利用程序局部性特征,缓存效果比较差,一般我们比较推荐使用LRU(最近最少使用)和LFU(最不经常使用的淘汰算法)。LRU是淘汰最长时间没有被使用的页面,LFU是淘汰一段时间内,使用次数最少的页面。

从实现上来说LRU相对更容易一些,而LFU比较麻烦,我们这里重点研究一下LRU的问题,当然这也是一道高频题目,让我们设计一个LRU缓存,该题可以排到算法前3是没有问题的。

参考题目介绍:146. LRU 缓存 - 力扣(LeetCode)

在这里插入图片描述算法通过村第五关-队列和Hash黄金笔记|LRU的设计与实现_第1张图片
算法通过村第五关-队列和Hash黄金笔记|LRU的设计与实现_第2张图片
百度百科:

LRU(最近最少使用)缓存:LRU_百度百科 (baidu.com)

首先关于什么是LRU,最简单的说法是当内存空间满了,不得不淘汰某些数据时(通常时容量已满),先择最久未呗使用的数据进行淘汰。

这里时做了简化的,题目是让我们实现一个容器固定LRUCache。如果插入数据时,发现容器已满时,则先按照LRU规则进行淘汰数据,再插入新的数据,这种【插入】和【查询】都算一次“使用”。

最近最少使用算法(LRU)是大部分操作系统为最大化页面命中率而广泛使用的一种页面置换算法。

该算法的思路是:

发生缺页中断时,选择未使用时间最长的页面置换出去。假设内存只能容纳3个页大小,按照7 0 1 2 0 3 0 4 的次序访问页面。假设内存按照栈的方式类描述访问时间,在上面时最近访问的,在下面的最远时间访问的,LRU就是这样工作的:
算法通过村第五关-队列和Hash黄金笔记|LRU的设计与实现_第3张图片
尝试了工作流程,那么我们需要怎么来实现它呢,有什么好的思路呢?定义一个数组,然后根据上面的写上一些规则吗?我估计这个要实现起来会非常难,即使写出来了,也非常复杂,超时等。那我们应该怎么做,这里告诉你标准的答案:Hash+双向链表。

2. Hash+双向链表实现LRU

目前公认最合理的方法,Hash+双向链表。没想到吧?那我们想一下为什么这么设计,我们要怎么实现。(展示

  • Hash的作用是:采用O(1)访问元素,哈希表就是普通的哈希映射(HashMap),通过缓存数据的键映射到其双向链表的位置。Hash里的数据结构是key-value,value就是我们自己封装好的node,key就是键值,也就是在Hash的地址
  • 双向链表的作用是:根据访问情况对元素进行排序。双向链表按照被使用的顺序存储这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。

这样以来,我们要确定元素的位置就可以直接访问哈希表就可以,找出缓存项的双向链表中的位置,随后将其移动到双向链表的头部,即可以实现O(1)的时间内完成get操作或者put操作。具体的方法放在下面:

  • 对于get操作,首先判断key是否存在:
    • 如果key不存在,则返回-1
    • 如果key存在,则key对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值
  • 对于put操作,首先要判断key是否存在:
    • 如果key不存在,使用key和value创建一个新的节点,在双向链表的头部添加该节点,并将key和该节点添加进哈希表中。然后判断双向链表的节点书是否超出容量,如果超出容量,则删除双向链表的尾节点,并删除哈希表中的对应项
    • 如果key存在,则于get操作类似,先通过哈希定位,再将对应的节点更新为value,并将该节点移到双向链表的头部。

上述的各项操作中,访问哈希表的时间复杂度为O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也是O(1)。而将一个节点移到双向链表的头部,可以分成【删除该节点】和【在双向链表的头部添加元素】两部操作,都可以在O(1)的时间内完成。

同时为了方便操作,在双向链表的实现中,使用伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

说了这么多,还是看图更方便一些:

双向链表的实现中,使用伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

我们先看这个容量为3 的例子,首先缓存了1,此时结构如图所示,之后缓存2,3结构如b所示:

算法通过村第五关-队列和Hash黄金笔记|LRU的设计与实现_第4张图片
之后4再进入,此时容量已经不够了,只能再最远未使用的元素1删掉,然后将4插入到链表头部,如此就变成了图c的样子。

如果接下类又访问了一次2,会怎么样呢?我们会将2移动到链表的首位,也就是下面d的样子。
算法通过村第五关-队列和Hash黄金笔记|LRU的设计与实现_第5张图片
之后存储5呢?此时将tail指向的3删除,然后将5插入到链表的头部,也就是如上图的e的样子。

上面的方案图示很容易实现,但是这里我们强调几个点哈:

  1. 假设容量没有满,可以直接将元素插入到链表头部就可以
  2. 如果容量满了,就要采取策略,新的元素到来,则tail指向的表尾元素删除就行
  3. 假设要访问的元素已经存在,则将此元素先从链表中删除,再插入到表头就行了。

我们再看一些Hash的操作:

  1. Hash没有容量的限制,凡是被访问的元素都会再Hash中有标记,key就是我们要查询的条件,而value就是链表的节点的引用,可以不访问链表直接定位到某个元素节点,然后执行我们上面提到的方法来删除对应的元素节点。
  2. 这里的双向链表的删除好理解,那HashMap中的删除要如何处理呢?其实就是将node变为null。这样get(key)的时候就会返回null,也就实现了删除的功能。

总结:

上述的各项操作中,访问哈希表的时间复杂度为O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也是O(1)。而将一个节点移到双向链表的头部,可以分成【删除该节点】和【在双向链表的头部添加元素】两部操作,都可以在O(1)的时间内完成。

代码展示看效果
难度:⭐⭐⭐⭐⭐

import java.util.HashMap;
import java.util.Map;

public class LRUCache {

    // 写一个双向链表的结构
    class DLinkedNode {
        int key;
        int value;
        // 前后引用
        DLinkedNode prev;
        DLinkedNode next;

        public DLinkedNode() {

        }

        public DLinkedNode(int _key, int _value) {
            this.key = _key;
            this.value = _value;
        }
    }

    // 需要一个HashMap
    private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>();
    // 需要一个size
    private int size;
    // 需要一个容量
    private int capacity;
    // 需要头尾伪节点
    private DLinkedNode head, tail;

    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        // 使用伪 头节点和尾节点
        head = new DLinkedNode();
        tail = new DLinkedNode();
        // 相互联系
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 这里如果key存在,先通过哈希表定位在移动到头部
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            // key 不存在就创建一个新的节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            // 添加到哈希表中
            cache.put(key, newNode);
            // 添加到双向链表的头部
            addToHead(newNode);
            ++size;
            if (size > capacity) {
                // 超出容量,对应策略 删除双向链表的尾部节点
                DLinkedNode tail = removeTail();
                // 删除对应哈希表中的项
                cache.remove(tail.key);
                --size;
            }
        } else {
            // 如果key存在  先通过hash定位,再修改value,并且将它移动到头部
            node.value = value;
            moveToHead(node);
        }
    }

    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }

    private DLinkedNode removeTail() {
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }


    public static void main(String[] args) {
        LRUCache lRUCache = new LRUCache(2);
        lRUCache.put(1, 1); // 缓存是 {1=1}
        lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
        System.out.println(lRUCache.get(1));    // 返回 1
        lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
        System.out.println(lRUCache.get(2));    // 返回 -1 (未找到)
        lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
        System.out.println(lRUCache.get(1));    // 返回 -1 (未找到)
        System.out.println(lRUCache.get(3));    // 返回 3
        System.out.println(lRUCache.get(4));    // 返回 4
    }
}

总结

提示:理解双向链表;了解LRU缓存机制;Hash+双向链表的思考

你可能感兴趣的:(算法集训营,双向链表,散列表,LRU,算法笔记,缓存机制,Java,数据结构)