Bootstrap

LeetCode题练习与总结:LRU缓存--146

一、题目描述

请你设计并实现一个满足  LRU (最近最少使用) 缓存 约束的数据结构。

实现 LRUCache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

示例:

输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

提示:

  • 1 <= capacity <= 3000
  • 0 <= key <= 10000
  • 0 <= value <= 10^5
  • 最多调用 2 * 10^5 次 get 和 put

二、解题思路

为了实现LRU缓存,我们需要一个能够快速访问、更新和删除元素的数据结构。哈希表提供快速的查找时间,但是它不能保持元素的顺序。而双向链表可以保持元素的顺序,但是查找、更新和删除操作不是快速的。因此,我们可以结合使用哈希表和双向链表来实现LRU缓存。

以下是解题思路:

  1. 使用哈希表来存储键和对应的节点(而不是值),这样我们可以在O(1)时间内找到节点。
  2. 使用双向链表来维护键的顺序,当某个键被访问时,将其移动到链表的头部;当插入新键时,如果缓存已满,则删除链表尾部的节点,并在头部插入新节点。
  3. 自定义一个双向链表的节点类Node,包含keyvalue以及前驱和后继节点的指针。
  4. LRUCache类中,维持对头部和尾部节点的引用,以便在O(1)时间内进行删除和添加操作。

三、具体代码

import java.util.HashMap;

class Node {
    int key, value;
    Node prev, next;

    public Node(int key, int value) {
        this.key = key;
        this.value = value;
    }
}

public class LRUCache {
    private HashMap<Integer, Node> map;
    private Node head, tail;
    private int capacity, count;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.count = 0;
        map = new HashMap<>();
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        Node node = map.get(key);
        if (node == null) {
            return -1;
        }
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node == null) {
            Node newNode = new Node(key, value);
            map.put(key, newNode);
            addNode(newNode);
            count++;
            if (count > capacity) {
                Node toDel = popTail();
                map.remove(toDel.key);
                count--;
            }
        } else {
            node.value = value;
            moveToHead(node);
        }
    }

    private void addNode(Node node) {
        node.prev = head;
        node.next = head.next;
        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);
        addNode(node);
    }

    private Node popTail() {
        Node res = tail.prev;
        removeNode(res);
        return res;
    }
}

四、时间复杂度和空间复杂度

1. 时间复杂度
  • get(int key) 方法的时间复杂度是 O(1)。这是因为我们首先在哈希表中查找键,这需要 O(1) 时间。如果找到了节点,我们将其移动到链表的头部,这个操作也是 O(1) 时间,因为它只涉及到几个节点的指针变化。

  • put(int key, int value) 方法的时间复杂度同样是 O(1)。在最好的情况下,我们只需要在哈希表中插入一个新节点并把它添加到链表头部,这需要 O(1) 时间。在最坏的情况下,我们需要先删除链表尾部的节点,然后再将新节点添加到链表头部,这些操作也都是 O(1) 时间。

  • addNode(Node node)removeNode(Node node) 和 moveToHead(Node node) 方法的时间复杂度都是 O(1),因为它们只涉及到链表中的常数个节点的指针操作。

  • popTail() 方法的时间复杂度也是 O(1),因为它只是返回链表尾部的节点并将其从链表中移除,这些操作都是 O(1) 时间。

2. 空间复杂度
  • LRUCache 类的空间复杂度主要由哈希表和双向链表组成。哈希表的空间复杂度是 O(capacity),因为哈希表中最多只能存储 capacity 个键值对。

  • 双向链表的空间复杂度也是 O(capacity),因为链表中最多只能有 capacity 个节点。

  • 因此,LRUCache 类的总空间复杂度是 O(capacity),即与缓存容量成线性关系。

综上所述,LRUCache 类的 get 和 put 方法的时间复杂度都是 O(1),空间复杂度是 O(capacity)。

五、总结知识点

1. 数据结构:

  • HashMap: 用于存储键值对,提供快速的查找、插入和删除操作。
  • 双向链表: 由Node类实现,用于维护元素的访问顺序,支持快速的节点插入和删除。

2. 链表操作:

  • 链表节点的添加(addNode)和删除(removeNode)操作。
  • 将节点移动到链表头部(moveToHead),以标记为最近使用。
  • 删除链表尾部的节点(popTail),以实现LRU淘汰策略。

3. 缓存算法:

  • LRU (Least Recently Used) 缓存算法: 当缓存达到容量上限时,优先淘汰最久未使用的元素。

4. 设计模式:

  • 迭代器模式: 双向链表可以看作是迭代器模式的一个实例,它允许遍历元素而无需暴露其内部表示。

5. 编程技巧:

  • 使用伪头部和伪尾部节点来简化链表操作,避免空指针异常和边界条件的处理。
  • put方法中,先检查节点是否存在,然后决定是更新值还是添加新节点。

6. 封装:

  • LRUCache类封装了所有的实现细节,对外只暴露了getput两个方法,遵循了良好的封装原则。

7. 错误处理:

  • get方法中,如果键不存在于缓存中,返回-1作为错误指示。

以上就是解决这个问题的详细步骤,希望能够为各位提供启发和帮助。

;