从leetcode真题讲解手写LRU算法

2,333 阅读6分钟

前言

大家好,我是jack xu,大家不管是在刷leetcode的时候让你手写LRU算法,还是面试中让你手写LRU算法,都会碰到LRU算法。不管是为了应付面试还是平时工作中用的到,都是你学习本文的动力。

image.png LRU 算法,全称是Least Recently Used。翻译过来就是最近最少使用算法: 如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。所以,当指定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。

leetcode真题

链接:leetcode-cn.com/problems/lr… ,这道题的难度是middle,所以更应该掌握了。

运用你所掌握的数据结构,设计和实现一个  LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put。

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

进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?

示例:

LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得关键字 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得关键字 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4

数据结构选型

题目中要求,查找和插入的时间复杂度都需要是O(1),有两种实现方式,一种是自己设计数据结构,通过双向链表+哈希表,另一种是用Java中帮我们封装好的LinkedHashMap,但一般这不是面试官想听到的完美答案。

接下来分析一下选型,如果我们想要查询和插入的时间复杂度都是 O(1),那么我们需要一个满足下面三个条件的数据结构:

  • 1.首先这个数据结构必须是有时序的,以区分最近使用的和很久没有使用的数据,当容量满了之后,要删除最久未使用的那个元素。
  • 2.要在这个数据结构中快速找到某个 key 是否存在,并返回其对应的 value。
  • 3.每次访问这个数据结构中的某个 key,需要将这个元素变为最近使用的。也就是说,这个数据结构要支持在任意位置快速插入和删除元素。

查找快,我们能想到哈希表。但是哈希表的数据是乱序的。

有序,我们能想到链表,插入、删除都很快,但是查询慢。

所以,我们得让哈希表和链表结合一下,成长一下,形成一个新的数据结构,那就是:哈希链表。 结构大概长这样:

image.png 借助这个结构,我们再来分析一下上面的三个条件:

  • 1.如果每次默认从链表尾部添加元素,那么显然越靠近尾部的元素就越是最近使用的。越靠近头部的元素就是越久未使用的。
  • 2.对于某一个 key ,可以通过哈希表快速定位到链表中的节点,从而取得对应的 value。
  • 3.链表显示是支持在任意位置快速插入和删除的,修改指针就行。但是单链表无非按照索引快速访问某一个位置的元素,都是需要遍历链表的,所以这里借助哈希表,可以通过 key,快速的映射到任意一个链表节点,然后进行插入和删除。

一、为什么这里要使用双向链表,而不是单向链表?

我们在找到了节点,需要删除节点的时候,如果使用单向链表的话,后驱节点的指针是直接能拿到的,但是这里要求时间复杂度是O(1),要能够直接获取到前驱节点的指针,那么只能使用双向链表。

二、哈希表里面已经保存了 key ,那么链表中为什么还要存储 key 和 value 呢,只存入 value 不就行了?

当我们在删除节点的时候,除了需要删除链表中的节点,还需要删除hash表中的节点,删除哈希表需要知道key,那么这个key从哪里来?那只能从节点里来,所以在节点里key和value都需要存(在删除链表中节点的方法里需要return key,具体见下面的代码)。

代码实现

首先建一个Node类,Node中有key,value,前驱,后驱节点,在上面画的图中已经展示出来了。

public class Node {

    public Node pre;

    public Node next;

    public String key;

    public String value;

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

}

建立一个LRUCache类,类中有头结点,尾节点,容量限制,字典表,以及构造方法。

public class LRUCache {

    private Node head;

    private Node end;

    private int limit;

    private HashMap<String, Node> hashMap;

    public LRUCache(int limit) {
        this.limit = limit;
        hashMap = new HashMap<>();
    }
}

在类里面首先写取值方法

/**
 * 取值
 *
 * @param key
 * @return
 */
public String get(String key) {
    Node node = hashMap.get(key);
    if (node == null) {
        return "-1";
    }
    //返回之前把节点移动到链尾
    moveNodeTail(node);
    return node.value;
}

把节点移动到链尾方法moveNodeTail

/**
 * 刷新被访问的节点位置
 * @param node
 */
private void moveNodeTail(Node node) {
    //如果已经是队尾的节点无需移动
    if (node == end) {
        return;
    }
    //先从原位置删掉
    deleteNode(node);
    //放到链尾
    addTailNode(node);
}

移除节点方法deleteNode

/**
 * 移除节点
 *
 * @param node
 */
public String deleteNode(Node node) {
    if (node == head && node == end) {
        //移除唯一的节点
        head = null;
        end = null;
    } else if (node == end) {
        //移除尾节点
        end = end.pre;
        end.next = null;
    } else if (node == head) {
        //移除头节点
        head = head.next;
        head.pre = null;
    } else {
        //移除中间节点
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }
    return node.key;
}

链尾添加节点方法addTailNode

/**
 * 尾部插入节点
 *
 * @param node
 */
public void addTailNode(Node node) {
    if (end != null) {
        end.next = node;
        node.pre = end;
        node.next = null;
    }
    end = node;
    if (head == null) {
        head = node;
    }
}

最后看下存值put的方法

/**
 * 存值
 *
 * @param key
 * @param value
 */
public void put(String key, String value) {
    Node node = hashMap.get(key);
    if (node != null) {
        //节点已存在更新里面的值
        node.value = value;
        //移动到链尾
        moveNodeTail(node);
    } else {
        //不存在,首先判断容量,容量满的情况下先删除不常用的,然后插入新节点,容量不满的情况下直接插入
        if (hashMap.size() >= limit) {
            //从链表中移除最不常用的
            String oldKey = deleteNode(head);
            //从hashmap中移除
            hashMap.remove(oldKey);
        }
        node = new Node(key, value);
        //添加到链尾
        addTailNode(node);
        //添加到hashmap
        hashMap.put(key, node);
    }
}

最后写个测试类,跑一下题目中的get和put顺序

public static void main(String[] args) {
    LRUCache cache = new LRUCache(2);
    cache.put("1", "1");
    cache.put("2", "2");
    System.out.println(cache.get("1"));
    cache.put("3", "3");
    System.out.println(cache.get("2"));
    cache.put("4", "4");
    System.out.println(cache.get("1"));
    System.out.println(cache.get("3"));
    System.out.println(cache.get("4"));
}

和预期完全正确

image.png

需要注意的是,这段代码不是线程安全的代码,要想做到线程安全,需要加上syncchronized修饰符。

应用

LRU 在 MySQL 中的应用

LRU 在 MySQL 的应用就是 Buffer Pool,也就是缓冲池,它的目的是为了减少磁盘 IO。它是一块连续的内存,默认大小 128M,可以进行修改。这一块连续的内存,被划分为若干默认大小为 16KB 的页。

既然它是一个 pool,那么必然有满了的时候,这时候就要移除某些页了,为了减少磁盘 IO,所以应该淘汰掉很久没有被访问过的页,这个就是 LRU 。

LRU 在 Redis 中的应用

我们知道Redis也会有满的时候,满了就要淘汰掉长期不使用的key,在 Redis 中用了一个类似的 LRU 算法,而不是严格的 LRU 算法。具体内容请看我另一篇文章:《Redis高级特性精讲》

最后出一道扩散的面试题:数据库中有 3000w 的数据,而 Redis 中只有 100w 数据,如何保证 Redis 中存放的都是热点数据?

这道题的考点就是LRU,我们先指定Redis的淘汰策略为 allkeys-lru 或者 volatile-lru,然后再计算一下 100w 数据大概占用多少内存,根据算出来的内存,限定 Redis 占用的内存。接下来的事,就交给Redis的淘汰策略即可。

本文源码在:github.com/xuhaoj/Data… ,感谢观看。