【算法题刷完这遍该会了】LRU-缓存

LRU(Least Recently Used)缓存是一种常见的缓存淘汰策略,属于是算法题中的超高频考题了。
力扣链接 - LRU缓存

什么是 LRU 缓存?

LRU 缓存的核心思想是:当缓存空间满时,优先淘汰最近最少使用的数据。就像我们整理书架,经常看的书放在最容易拿到的位置,很久不看的书则被移到角落甚至清理掉。

核心操作

  • get(key): 如果关键字存在于缓存中,则获取关键字的值(正整数),否则返回 -1
  • put(key, value): 如果关键字已经存在,则变更其值;如果不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据。

应用场景

LRU 缓存在实际应用中非常广泛:

  1. 数据库缓存层
  2. Web 应用的页面缓存
  3. 操作系统的页面置换算法
  4. Redis 等缓存系统的淘汰策略

数据结构设计

要实现 O(1) 时间复杂度的 LRU 缓存,我们需要结合两种数据结构:

  1. 哈希表:提供 O(1) 的查找能力
  2. 双向链表:提供 O(1) 的记录访问顺序的能力

核心思路(如下图):

  • 哈希表中存储 key 到链表节点的映射
  • 双向链表中按照使用顺序排列节点,最近使用的在头部,最久未使用的在尾部
  • 每次访问某个 key(包括更新),就将对应节点移到链表头部
  • 当需要淘汰时,直接删除链表尾部节点,同时从 map 中删除该 key!

在这里插入图片描述

思路具体到两个真正要实现的核心方法:

get 流程

从 map 中 get(key),如果没有就返回 -1,有则将 node 移动到头部并返回 node.val

put 流程

从 map 中 get(key),如果已经存在,更新 node.val,并且移动到头部;不存在则添加到头部,此时如果容量已超,则从链表中删除最后一个节点,同时从 map 中移除这个节点对应的 key (这也是为什么 node 中需要保存 key)。

上面三个加粗字体的操作可以封装成三个方法方便调用

代码详解

让我们逐步解析 LRU 缓存的 Java 实现:

1. 节点类定义

static class DNode {
    int val, key;
    DNode pre, next;
    // 构造函数和getter/setter方法...
}

双向链表节点,存储 key 和 value,以及前后指针引用。

2. 核心数据结构

DNode head, tail;  // 双向链表的虚拟头尾节点
Map<Integer, DNode> keyToNode = new HashMap<>();  // 哈希表映射key到节点
int capacity;  // 缓存容量
  • 使用 head 和 tail 作为哨兵节点,简化边界情况处理
  • 哈希表提供 O(1) 的查找能力
  • capacity 控制缓存大小

3. 构造函数

public LRUCache(int capacity) {
    head = new DNode();
    tail = new DNode();
    head.next = tail;
    tail.pre = head;
    this.capacity = capacity;
}

初始化时,创建双向链表的头尾节点并连接起来,形成一个空链表。

4. get 操作

public int get(int key) {
    DNode node = keyToNode.get(key);
    if (node == null) {
        return -1;
    }
    moveToHead(node);  // 更新使用顺序
    return node.getVal();
}
  1. 查找 key 对应的节点
  2. 如果不存在,返回 -1
  3. 如果存在,将节点移到链表头部(表示最近使用),并返回值

5. put 操作

public void put(int key, int value) {
    DNode node = keyToNode.get(key);
    DNode newNode = new DNode(value);
    newNode.setKey(key);
    if (node == null) {  // key不存在,需要新增
        addToHead(newNode);
        if (keyToNode.size() == capacity) {  // 缓存已满,需要淘汰
            DNode tailpre = removeTail();
            keyToNode.remove(tailpre.getKey());
        }
        keyToNode.put(key, newNode);
    } else {  // key已存在,更新值
        node.setVal(value);
        moveToHead(node);
    }
}

这是最核心的方法,分两种情况:

  1. key 不存在:创建新节点,添加到链表头部,如果缓存已满,删除尾部节点
  2. key 已存在:更新节点值,并将节点移到链表头部

6. 辅助方法

    private void addToHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }
   
    // 移动到头部和移除尾部,可以共用一个移除节点的方法。
    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    private void moveToHead(Node node) {
    	// 先移除,再添加...
        removeNode(node);
        addToHead(node);
    }
    
    private Node removeTail() {
        Node res = tail.prev;
        removeNode(res);
        return res;
    }

这几个方法封装了对双向链表的基本操作,使主方法只需关注思路

完整代码

class LRUCache {
    private static class Node {
        int key, value;
        Node prev, next;
        Node() {}
        Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    private final int capacity;
    private final Map<Integer, Node> keyToNode;
    private final Node head, tail;
    
    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.keyToNode = new HashMap<>(capacity);
        // 初始化 head,tail
        this.head = new Node();
        this.tail = new Node();
        head.next = tail;
        tail.prev = head;
    }
    
    public int get(int key) {
        Node node = keyToNode.get(key);
        if (node == null) {
            return -1;
        }
        // Move to head (most recently used)
        moveToHead(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        Node node = keyToNode.get(key);
        
        if (node != null) {
            // 更新已有的
            node.value = value;
            moveToHead(node);
        } else {
            // 创建新的
            Node newNode = new Node(key, value);
            keyToNode.put(key, newNode);
            addToHead(newNode);
            
            // 检查是否超过 capacity
            if (keyToNode.size() > capacity) {
                // Remove tail (least recently used)
                Node tail = removeTail();
                keyToNode.remove(tail.key);
            }
        }
    }
    
    private void addToHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }
   
    // 移动到头部和移除尾部,可以共用一个移除节点的方法。
    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    private void moveToHead(Node node) {
    	// 先移除,再添加...
        removeNode(node);
        addToHead(node);
    }
    
    private Node removeTail() {
        Node res = tail.prev;
        removeNode(res);
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(1),所有操作均为常数时间

    • get 操作:哈希表查询 O(1),移动节点 O(1)
    • put 操作:哈希表查询/插入 O(1),链表操作 O(1)
  • 空间复杂度:O(capacity),存储 capacity 个键值对的开销

常见错误和注意事项

  1. 忘记双向链表:单向链表无法在 O(1) 时间内删除任意节点
  2. 忘记在链表操作时同步更新哈希表:会导致数据不一致
  3. 链表操作顺序错误:这个先熟练掌握链表基本操作即可
  4. 忘记存储 key:删除尾节点时需要知道对应的 key 才能从哈希表中删除

总结

LRU 缓存是一种优雅而高效的缓存淘汰策略,通过哈希表和双向链表的结合,实现了 O(1) 时间复杂度的各项操作。理解其核心思想和实现细节,不仅对面试有帮助,在实际工作中也能指导我们更好地设计缓存系统。

记住这个算法的关键点:

  • 双向链表 + 哈希表的组合
  • Node 额外存储 key,且使用 head, tail 虚拟头尾节点。
  • 每次访问(包括更新!)都更新链表顺序
  • 缓存满时删除尾部节点,同时删除哈希表的 key!

如有问题或建议,欢迎在评论区留言讨论。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值