Comience construyendo ruedas para comprender a fondo el mecanismo de caché de LRU

Leetcode146. Mecanismo de almacenamiento en caché LRU

Haciendo ruedas

En una entrevista, los entrevistadores generalmente esperan que los lectores implementen una lista simple doblemente enlazada por sí mismos, en lugar de usar la estructura de datos encapsulada propia del lenguaje. Por eso, es necesario hacer ruedas.

El algoritmo de eliminación de caché LRU es una estrategia común. El nombre completo de LRU es Menos recientemente utilizado, que se traduce como el menos utilizado recientemente.

La Pregunta 146 de Leikou "Mecanismo de almacenamiento en caché LRU" es para que diseñemos dicha estructura de datos:

Recibir un primer capacityparámetro como la capacidad máxima de la caché, y luego implementar la API dos, es un put(key, val)método de pares clave-valor almacenados, el otro get(key)método de adquirir keyel correspondiente val, si keyno está presente o -1.

La firma de la función es la siguiente:

class LRUCache {
    
    
    public LRUCache(int capacity) {
    
    }
    public int get(int key) {
    
    }
    public void put(int key, int value) {
    
    }
}

Nota: gety los putmétodos deben medir la O(1)complejidad, démos un ejemplo concreto para ver cómo funciona el algoritmo LRU.

LRUCache cache = new LRUCache(2);  // 缓存容量为 2
// 可以把 cache 理解成一个队列
// 假设左边是队头,右边是队尾
// 那么最近使用的排在队头,最久未使用的排在队尾
// 圆括号表示键值对 (key, val)

cache.put(1, 1);  // cache = [(1, 1)]

cache.put(2, 2);  // 新来的添加到队头(左边),此时 cache = [(2, 2), (1, 1)] 

cache.get(1);     // 访问一次 key=1,返回 1。
// 因为(1,1)被访问,被提到队头,此时 cache = [(1, 1), (2, 2)] 

cache.put(3, 3);  // 新添加一个元素
// 但是此时已经满了,需要将队尾(最久未使用)元素删除,然后将新元素添加到队头
// 此时 cache = [(3, 3), (1, 1)] 

cache.get(2);     // 返回 -1 (未找到)

cache.put(1, 4);  // key=1 已存在,把value覆盖为 4,同时将(1,4)提到队头
// 此时cache = [(1, 4), (3, 3)] 

El análisis de la operación anterior, marca puty getcomplejidad del tiempo del método es O (1), podemos concluir que cachelas condiciones necesarias de esta estructura de datos:

1, obviamente cacheel elemento de temporización debe tener que distinguir entre los datos usados ​​recientemente y los usados ​​recientemente, cuando la capacidad total de ese elemento debe ser eliminada para dejar espacio para los usados ​​menos recientemente.

2, queremos cacheencontrar rápidamente uno que keyya existe y ha sido correspondiente val;

3, cada visita cachees una keynecesidad para usar este elemento se vuelve reciente, esta operación se puede dividir en tres pasos: ① encontrar rápidamente ese elemento (es decir, arriba de 2); ② los elementos que fueron eliminados; ③ el elemento se vuelve a agregar a la cabeza de la fila. Eso es cachepara admitir elementos de eliminación e inserción rápidos en cualquier posición.

Entonces, ¿qué estructura de datos cumple con las condiciones anteriores al mismo tiempo? La búsqueda de tablas hash es rápida, pero los datos no tienen un orden fijo; la lista vinculada tiene orden, la inserción y eliminación son rápidas, pero la búsqueda es lenta. Así que combínelos para formar una nueva estructura de datos: lista enlazada hash LinkedHashMap.

La estructura de datos central del algoritmo de caché LRU es una combinación de lista enlazada hash, lista enlazada doblemente y tabla hash. Esta estructura de datos se ve así:

Pueden surgir dos problemas al leer esto:

  1. ¿Por qué es una lista doblemente enlazada, una lista enlazada individualmente?
  2. La tabla hash se ha guardado key, ¿por qué debería existir la lista keyy valqué?

Estas dos preguntas no son fáciles de explicar de la nada y luego mira hacia abajo para encontrar las respuestas en el código.

Diseño de arquitectura :

Nuestro objetivo final es implementar LRUCachedicha estructura de datos. Antes de eso, dibujemos primero el diagrama de arquitectura:

Necesitamos implementar la 底层、抽象层、实现层:.arquitectura de

Nuestra capa inferior necesita tres categorías: NodeDoubleListHashMap

La estructura general es la siguiente:

Código**:

class Node{
    
    
  	public int key, val;
  	public Node next, prev;
  	public Node(int k, int v){
    
    
      	this.key = k;
      	this.val = v;
    }
}

Luego, confíe en nuestro Nodetipo para construir una lista doblemente vinculada, varios algoritmos LRU deben lograr una API (nada más que una serie de adiciones y eliminaciones al método):

class DoubleList {
    
      
    // 头尾虚节点
    private Node head, tail;  
    // 链表元素数
    private int size;

    public DoubleList() {
    
    
        // 初始化双向链表的数据
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
        size = 0;
    }

    // 在链表尾部添加节点 x,时间 O(1)
    public void addLast(Node x) {
    
    
        x.prev = tail.prev;
        x.next = tail;
        tail.prev.next = x;
        tail.prev = x;
        size++;
    }

    // 删除链表中的 x 节点(x 一定存在)
    // 由于是双链表且给的是目标 Node 节点,时间 O(1)
    public void remove(Node node) {
    
    
        x.prev.next = x.next;
        x.next.prev = x.prev;
        size--;
    }

    // 删除链表中第一个节点,并返回该节点,时间 O(1)
    public Node removeFirst() {
    
    
        if (head.next == tail)
            return null;
        Node first = head.next;
        remove(first);
        return first;
    }

    // 返回链表长度,时间 O(1)
    public int size() {
    
     return size; }
}

En este punto, podemos responder a la pregunta de "¿Por qué tenemos que usar una lista doblemente enlazada" ahora mismo, porque necesitamos eliminar. La eliminación de un nodo requiere no solo el puntero del nodo en sí, sino también el puntero de su nodo predecesor. La lista doblemente vinculada puede admitir la búsqueda directa del predecesor, y la complejidad temporal de la operación es O (1).

Entre ellos: addLast(Node x)、remove(Node node)、removeFirst()el diagrama esquemático de los tres métodos es el siguiente:

Tenga en cuenta que la API de lista de doble enlace que implementamos solo se puede insertar desde la cola, lo que significa que los datos de la cola son los utilizados más recientemente y los datos de la cabeza son los más antiguos.

Con la realización de la lista doblemente enlazada, solo necesitamos combinarla con la tabla hash en el algoritmo LRU, y primero construir el marco de código:

class LRUCache {
    
    
    // key -> Node(key, val)
    private HashMap<Integer, Node> map;
    // Node(k1, v1) <-> Node(k2, v2)...
    private DoubleList cache;
    // 最大容量
    private int cap;

    public LRUCache(int capacity) {
    
    
        this.cap = capacity;
        map = new HashMap<>();
        cache = new DoubleList();
    }
  	
  	/* 将某个 key 提升为最近使用的 */
    private void makeRecently(int key) {
    
    
        Node x = map.get(key);
        // 先从链表中删除这个节点
        cache.remove(x);
        // 重新插到队尾
        cache.addLast(x);
    }

    /* 添加最近使用的元素 */
    private void addRecently(int key, int val) {
    
    
        Node x = new Node(key, val);
        // 链表尾部就是最近使用的元素
        cache.addLast(x);
        // 别忘了在 map 中添加 key 的映射
        map.put(key, x);
    }

    /* 删除某一个 key */
    private void deleteKey(int key) {
    
    
        Node x = map.get(key);
        // 从链表中删除
        cache.remove(x);
        // 从 map 中删除
        map.remove(key);
    }

    /* 删除最久未使用的元素 */
    private void removeLeastRecently() {
    
    
        // 链表头部的第一个元素就是最久未使用的
        Node deletedNode = cache.removeFirst();
        // 同时别忘了从 map 中删除它的 key
        int deletedKey = deletedNode.key;
        map.remove(deletedKey);
    }
}

Aquí, antes de que pueda responder a las preguntas del cuestionario, "¿Por qué debería almacenar tanto la clave como el valor en la lista, en lugar de simplemente almacenar el valor?", Preste atención a la removeLeastRecentlyfunción, necesitamos deletedNodeobtener deletedKey.

Es decir, cuando el búfer está lleno, no solo tenemos que eliminar el último Nodenodo, sino también mapasignarlo al nodo keyeliminado, y este keysolo puede estar Nodedisponible. Si la Nodeestructura solo está almacenada val, entonces no podemos saber keyqué es, no puede eliminar maplas claves, lo que resulta en errores.

El método anterior es un paquete de operación simple, para evitar llamar a estas funciones manipule directamente cachelistas enlazadas y maptablas hash, ahora comienzo a darme cuenta del getmétodo del algoritmo LRU :

public int get(int key) {
    
    
    if (!map.containsKey(key)) {
    
    
        return -1;
    }
    // 将该数据提升为最近使用的
    makeRecently(key);
    return map.get(key).val;
}

put El método es un poco más complicado, hagamos un dibujo para descubrir su lógica:

Entonces podemos escribir fácilmente el putcódigo para el método:

public void put(int key, int val) {
    
    
    if (map.containsKey(key)) {
    
    
        // 删除旧的数据
        deleteKey(key);
        // 新插入的数据为最近使用的数据
        addRecently(key, val);
        return;
    }
    if (cap == cache.size()) {
    
    
        // 删除最久未使用的元素
        removeLeastRecently();
    }
    // 添加为最近使用的元素
    addRecently(key, val);
}

En este punto, debería haber comprendido completamente el principio y la implementación del algoritmo LRU.

Resolución de problemas

Finalmente, usamos los tipos integrados de Java LinkedHashMappara implementar el algoritmo LRU, y exactamente la misma lógica antes:

class LRUCache {
    
    

    int capacity;
    LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();

    public LRUCache(int capacity) {
    
    
        this.capacity = capacity;
    }
    
    public int get(int key) {
    
    
        if(!cache.containsKey(key)){
    
    
            return -1;
        }
        makeRecently(key);
        return cache.get(key);
    }
    
    public void put(int key, int value) {
    
    
        if(cache.containsKey(key)){
    
    
            cache.put(key, value);  // 修改 key 的值
            makeRecently(key);
            return;
        }
        if(cache.size() >= this.capacity){
    
    
          	// 链表头部就是最久未使用的 key
            int oldestKey = cache.keySet().iterator().next();
            cache.remove(oldestKey);
        }
      	// 将新的 key 添加链表尾部
        cache.put(key, value);
    }

    private void makeRecently(int key){
    
    
        int val = cache.get(key);
      	// 删除 key,重新插入到队尾
        cache.remove(key);
        cache.put(key, val);
    }
}
  • Complejidad del tiempo: tanto para poner como para conseguir O(1)
  • Complejidad del espacio: O(capacity)porque las tablas hash y las listas doblemente enlazadas almacenan capacity+1hasta elementos.

Articulo de referencia

Supongo que te gusta

Origin blog.csdn.net/weixin_44471490/article/details/109720273
Recomendado
Clasificación