146. LRU 缓存 : 实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构

🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇

⭐LRU⭐

🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇


前言

这道题其实刷了有段时间了,很久之前就像写这道题的博客了,被期末耽误了。这道题挺特别的,可能是我确实刷题不够亦或者刷题不够深刻,这道题是刷这么久的题(其实没很久)第一次深刻的感受到算法在实际业务中的运用,很奇妙的感觉,就像天天被风吹的脸生疼,但这次我感觉到了风的律动(有点小夸张哈哈哈哈)。

更神奇的是,在期末考完之后,最后一门就是操作系统,很意外,我竟然在这操作系统中再次碰见了 LRU ,页面置换算法中的 LRU,今天刚考完试,就这份缘分也必须把这博客今日毕。


1. LRU 缓存简介

LRU(最近最少使用)是一种缓存淘汰算法,通常用于缓存管理系统中。当缓存的容量达到上限时,LRU算法会优先淘汰那些最久未被访问的数据项。具体而言,LRU缓存会保持一个顺序列表,其中最"旧"的数据项(即最久未被使用的数据项)会被移除,以腾出空间给新的数据项。


2. 问题描述

我们需要设计一个类 LRUCache,它支持以下两种操作:

  • get(key) : 如果缓存中存在该 key,则返回其对应的值,否则返回 -1
  • put(key, value) : 将给定的 keyvalue 放入缓存中。如果缓存已满,则需要通过 LRU 算法移除最不常用的缓存项。

操作复杂度要求

  • getput 操作的时间复杂度要求为 O(1),即常数时间复杂度。

为了达到这个目标,我们需要选择合适的数据结构来支持快速访问、插入和删除操作。


3. 数据结构选择

重点:

在边读题边思考的时候你就会发现,这道题实际上就是实现加了一个功能的哈希表,方法都是一样的,所以毫无疑问会用到哈希表。那么就是这个容量满了会自动删除最久没有使用的算法。

我那会写的时候,可能那段时间队列用多了,下意识的直接把他们放队列中,但是要怎么实现删除最久没有使用的节点呢,我想了挺久,队列是先进先出的,当我把1 2 3 4放入队列后,put是删尾巴,不好实现,如果我此时get 3,那么就要删除 3,然后再加入3。有一个删除,然后添加操作,此时

3 在中间,要出队列就得把前面的数都出了,那前面的数又放在哪,可以再用一个队列,但是这样的时间算法复杂度就不会是 O(1)了。

那么队列就无法时间,队列的好兄弟同理栈也不行,对于中间删除,木讷的数组更不行,二最后一个,最后只剩链表了。

这里我们定义 链表头是最新,链表尾最久。

先单链表,get里边含有的节点,此时节点在中间,删除中间节点,先要找到节点,可以靠哈希表直接 O(1)找到节点,然后就是删除,需要前一个节点才可以,这时我们可以用双向链表解决这个问题。

删除解决了,现在就是添加 ,添加直接头插就行,put 的话直接尾删。

**那么,为了解决这个问题,我结合了两种数据结构:**哈希表(HashMap)和 双向链表(Doubly Linked List)。

3.1 哈希表

哈希表用于通过 key 快速定位到缓存中的节点。哈希表的查找、插入、删除操作的时间复杂度是 O(1)。在 LRUCache 中,哈希表的键(key)对应缓存项的 key,而值(value)则是缓存项的双向链表节点(Doubly Linked Node)。

3.2 双向链表

双向链表允许我们在 O(1) 的时间内将节点移动到链表的头部或尾部,也能在 O(1) 时间内删除节点。双向链表的头部表示最新使用的数据,尾部表示最久未使用的数据。每次访问一个缓存项时,我们将其移动到链表头部,表示它是最新使用的;每次淘汰缓存项时,我们会从尾部移除最旧的数据项。


4. LRUCache 类设计

下面是 LRUCache 类的实现,结合了哈希表和双向链表。

4.1 数据成员

java 复制代码
class LRUCache {
    class DLinkedNode {
        int key;
        int value;
        DLinkedNode pre;
        DLinkedNode next;
        public DLinkedNode(){}
        public DLinkedNode(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    // 哈希表用于快速定位节点
    HashMap<Integer, DLinkedNode> map;
    
    // 缓存容量和当前缓存中的元素数量
    private int capacity;
    private int size;

    // 虚拟头尾节点,便于操作链表
    DLinkedNode head, tail;
}

4.2 构造函数

构造函数初始化缓存容量,并且创建了一个空的虚拟头节点和尾节点,这样我们就可以方便地在链表的两端进行操作。

java 复制代码
public LRUCache(int capacity) {
    map = new HashMap<>();
    this.capacity = capacity;
    size = 0;

    // 创建头尾虚拟节点
    head = new DLinkedNode();
    tail = new DLinkedNode();
    
    // 连接头尾虚拟节点
    head.next = tail;
    tail.pre = head;
}

4.3 get 方法

get 方法用于查询一个 key 是否存在于缓存中。如果存在,返回对应的值,并将该节点移动到链表的头部,表示它是最新访问的。如果不存在,返回 -1

java 复制代码
public int get(int key) {
    DLinkedNode node = map.get(key);
    if (node == null) {
        return -1;  // 缓存中没有该节点
    } else {
        moveToHead(node);  // 将该节点移动到链表头部
        return node.value;
    }
}

4.4 put 方法

put 方法将一个 keyvalue 放入缓存。如果缓存已满,会根据 LRU 算法淘汰掉最旧的节点。如果 key 已经存在于缓存中,我们只需要更新其值并将其移动到链表的头部。如果 key 不在缓存中,则需要创建一个新的节点并插入到链表的头部。

java 复制代码
public void put(int key, int value) {
    // 查询缓存中是否已有该节点
    DLinkedNode node = map.get(key);
    
    if (node == null) {
        // 如果节点不存在,创建新节点并插入到头部
        node = new DLinkedNode(key, value);
        addToHead(node);
        map.put(key, node);
        size++;

        // 如果缓存超出容量,移除尾部最旧的节点
        if (size > capacity) {
            DLinkedNode tail = removeTail();
            map.remove(tail.key);
        }
    } else {
        // 如果节点存在,更新其值并移动到头部
        node.value = value;
        moveToHead(node);
    }
}

4.5 辅助函数

  • removeTail: 删除并返回尾部节点,即最久未使用的节点。
  • addToHead: 将节点插入到链表的头部。
  • moveToHead: 将一个节点从当前的位置移到链表的头部。
  • removeNode: 删除指定的节点。
java 复制代码
public DLinkedNode removeTail() {
    DLinkedNode node = tail.pre;
    removeNode(node);
    return node;
}

public void addToHead(DLinkedNode node) {
    head.next.pre = node;
    node.next = head.next;
    node.pre = head;
    head.next = node;
}

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

public void removeNode(DLinkedNode node) {
    node.next.pre = node.pre;
    node.pre.next = node.next;
}

5. 算法原理总结

  1. 哈希表的使用:通过哈希表,我们可以 O(1) 时间复杂度地查找和更新缓存中的数据项。
  2. 双向链表的使用:双向链表保证了我们可以在 O(1) 的时间复杂度内将一个节点移动到链表的头部或尾部,支持 LRU 缓存的高效实现。
  3. 缓存容量管理:当缓存达到容量上限时,我们通过删除尾部节点来确保缓存的容量不超过上限。

6. 总结

通过结合哈希表和双向链表,我们可以实现一个高效的 LRU Cache 。哈希表提供了快速的查找和更新,而双向链表则使得我们可以快速地更新缓存项的顺序。整体上,该算法能够在 O(1) 时间复杂度内完成 getput 操作,确保在大规模数据访问时也能保持高效。

感谢阅览!!!

相关推荐
萌の鱼2 小时前
leetcode 2279. 装满石头的背包的最大数量
数据结构·c++·算法·leetcode
DC_BLOG4 小时前
数据结构排序
java·数据结构·算法·排序算法
你啊我啊你好5 小时前
缓存常见问题
java·redis·缓存
唐梓航-求职中5 小时前
缓存-Redis-API-Redission-自动续期-watch dog
数据库·redis·缓存
唐梓航-求职中5 小时前
缓存-Redis-API-Redission-可重入锁-原理
数据库·redis·缓存
Nydia.J6 小时前
【学习笔记】数据结构(十)
数据结构·考研
float_六七6 小时前
二叉树的二叉链表和三叉链表
数据结构·链表
Allo2027 小时前
数据结构——图
数据结构
麻瓜老宋7 小时前
【手搓一个脚本语言】七、用C语言抽象语法树AST实现一个可交互运行的表达式计算器
c语言·开发语言·数据结构·算法