LRU算法

104 阅读10分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

业务场景

首先考虑这样的一个业务场景,小王在A公司上班,有一天产品提出了一个需求:“咱们系统的用户啊,每天活跃的就那么多,有太多的僵尸用户,根本不登录,你能不能考虑做一个筛选机制把这些用户刨出去,并且给活跃的用户做一个排名,我们可以设计出一些奖励活动,提升咱们的用户粘性,咱们只需要关注那些活跃的用户就行了“”。小王连忙点头,说可以啊,然而心里犯起嘀咕来了:这简单,按照常规思路,给用户添加一个最近活跃时间长度和登录次数,然后按照这两个数据计算他们的活跃度,最后直接排序就行了。嘿嘿,简直完美!不过!用户表字段已经很多了,又要加两个字段,然后还得遍历所有的数据排序?这样查询效率是不是会受影响啊?并且公司的服务器上次就蹦过一次,差点没忙出命来才调好。有没有更优雅的一种方式呢?小王面朝天空45°,陷入了无限的思考中.....

LRU是什么?

  1. LRU是什么?按照英文的直接原义就是Least Recently Used,最近最久未使用法,它是按照一个非常著名的计算机操作系统基础理论得来的:最近使用的页面数据会在未来一段时期内仍然被使用,已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。基于这个思想,会存在一种缓存淘汰机制,每次从内存中找到最久未使用的数据然后置换出来,从而存入新的数据!它的主要衡量指标是使用的时间,附加指标是使用的次数。在计算机中大量使用了这个机制,它的合理性在于优先筛选热点数据,所谓热点数据,就是最近最多使用的数据!因为,利用LRU我们可以解决很多实际开发中的问题,并且很符合业务场景。
  2. 小王的困惑

当小王看到LRU的时候,瞬间感觉抓住了救命稻草,这个算法不是就完全契合产品的需求吗?只要把用户数据按照LRU去筛选,利用数据结构完成的事情,完全减少了自己存储、添加字段判断、排序的过程,这样对于提高服务器性能肯定有很大的帮助,岂不美哉!小王考虑好之后,就决定先写一个demo来实现LRU,那么在java中是如何实现LRU呢?考虑了许久,小王写下了这些代码。

LRU的实现

利用双向链表实现

双向链表有一个特点就是它的链表是双路的,我们定义好头节点和尾节点,然后利用先进先出(FIFO),最近被放入的数据会最早被获取。其中主要涉及到添加、访问、修改、删除操作。首先是添加,如果是新元素,直接放在链表头上面,其他的元素顺序往下移动;访问的话,在头节点的可以不用管,如果是在中间位置或者尾巴,就要将数据移动到头节点;修改操作也一样,修改原值之后,再将数据移动到头部;删除的话,直接删除,其他元素顺序移动;

元素越靠近头结点,说明热度越高

代码实现

先定义双向链表

/**
 * <>
 *
 * @author zhengtong
 * @create 2021/8/14
 * @since 1.0.0
 */
public class Node<k, v> {

    k key;

    v value;

    Node<k, v> pre;

    Node<k, v> next;

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

再定义一个LRU算法的实现类,来操作双向链表,首先要定义一个最大容量,保证插入的数据不会超过最大值,然后使用定义一个hashMap来存储所有的节点,这样便于我们直接获取的节点的值,使用hashMap来存储是因为它查询速度非常快,时间复杂度为O(1)

/**
 * <>
 *
 * @author zhengtong
 * @create 2021/8/14
 * @since 1.0.0
 */
public class Lru<k, v> {

    /**
     * 当前容量
     */
    private int currentSize;

    /**
     * 最大容量
     */
    private int capcity;

    /**
     * 所有的node节点
     */
    private Map<k, Node> cache;

    /**
     * 头节点
     */
    private Node first;

    /**
     * 尾节点
     */
    private Node last;

    public Lru(int size) {
        this.currentSize = 0;
        this.capcity = size;
        cache = new ConcurrentHashMap<k, Node>(size);
    }
    
}

添加元素

  • 添加元素的时候首先判断是不是新的元素,
    如果是新元素,判断当前的大小是不是大于总容量了,防止超过总链表大小,
    如果大于的话直接抛弃最后一个节点,然后再以传入的key\value值创建新的节点。
    对于已经存在的元素,直接覆盖旧值,再将该元素移动到头部,然后保存在map中
  /* @param key
     * @param value
     */
    public void put(k key, v value) {
        Node node = cache.get(key);
        // 如果为新节点
        if (node == null) {
            // 判断大小是否超过阈值,是的话移除最后一个节点(移除最少使用的节点)
            if (cache.size() >= capcity) {
                cache.remove(last.key);
                removeLast();
            }
            node = new Node(key, value);
        }
        // 已存在,新值覆盖旧值
        cache.put(key, node);
        node.value = value;
        // 元素移动到头部
        moveToHead(node);
    }

移除最后一个元素

  • 移除最少使用的节点(最后一个节点)
  • 如果最后一个节点存在,移除最后一个节点,并且最后一个节点的前节点,现在变成了最后一个节点
  • 如果最后一个节点的前一个结点为null,说明最后一个节点是链表中的唯一节点,现在被移除了,first就为null了
  • 如果最后一个节点的前一个结点不为null,则它成为了最后一个节点,将它的后一个节点指为null\
 private void removeLast() {
        if (last != null) {
            last = last.pre;
            if (last == null) {
                first = null;
            } else {
                last.next = null;
            }
        }
    }

将当前节点移动到头部

  1. 移除当前节点
  2. 将当前节点的前一个节点的next指针,指向当前节点的下一个节点
  3. 将当前节点的下一个节点的pre指针,指向当前节点的前一个节点
  4. 将当前节点的next指针指向头结点
  5. 将头结点的pre指针指向当前节点
  6. 将头结点替换成当前节点
  7. 将头结点的pre指针指向null
  • 临界值
    • 如果就是头结点,就不需要操作
    • 如果是尾节点,则把前一个节点变成尾节点
    • 如果头尾节点都为空,说明当前没有节点,头尾节点都是当前节点

\

 private void moveToHead(Node node) {

        if (first == node) {
            return;
        }
        if (last == node) {
            last = node.pre;
            last.next = null;
        }
        if (node.pre != null) {
            node.pre.next = node.next;
        }
        if (node.next != null) {
            node.next.pre = node.pre;
        }
        if (first == null || last == null) {
            first = last = node;
            return;
        }

        node.next = first;
        first.pre = node;
        first = node;
        first.pre = null;

    }

获取元素

  • 通过key获取元素
  • 如果存在则返回value,因为被访问了,将节点移动到头部(更新为热点数据)
  • 不存在返回null

当访问3这个元素的时候,因为被访问了,将它更新为热点数据往链表的前面放,这时候就需要将它移动到链表的头部

public Object get(k key) {
        Node node = cache.get(key);
        if (node == null) {
            return null;
        }
        moveToHead(node);
        return node.value;
}

删除某个元素

  • 移除当前节点
    • 将当前节点的前一个节点的next指针,指向当前节点的下一个节点
    • 将当前节点的下一个节点的pre指针,指向当前节点的前一个节点
    • 这里有两个临界值,判断当前节点是不是就是头结点或者尾节点,需要特殊处理

\

public void remove(k key) {
        Node node = cache.get(key);
        if (node != null) {
            if (node.pre != null) {
                node.pre.next = node.next;
            }
            if (node.next != null) {
                node.next.pre = node.pre;
            }
            if (node == first) {
                first = node.next;
            }
            if (node == last) {
                last = node.pre;
            }
        }
        cache.remove(key);
}

完整代码

package com.ztiyou.springboottest.lru;

import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * <>
 *
 * @author zhengtong
 * @create 2021/8/14
 * @since 1.0.0
 */
public class Lru<k, v> {

    /**
     * 当前容量
     */
    private int currentSize;

    /**
     * 最大容量
     */
    private int capcity;

    /**
     * 所有的node节点
     */
    private Map<k, Node> cache;

    /**
     * 头节点
     */
    private Node first;

    /**
     * 尾节点
     */
    private Node last;

    public Lru(int size) {
        this.currentSize = 0;
        this.capcity = size;
        cache = new ConcurrentHashMap<k, Node>(size);
    }

    /**
     * 添加元素的时候首先判断是不是新的元素,
     * 如果是新元素,判断当前的大小是不是大于总容量了,防止超过总链表大小,
     * 如果大于的话直接抛弃最后一个节点,然后再以传入的key\value值创建新的节点。
     * 对于已经存在的元素,直接覆盖旧值,再将该元素移动到头部,然后保存在map中
     *
     * @param key
     * @param value
     */
    public void put(k key, v value) {
        Node node = cache.get(key);
        // 如果为新节点
        if (node == null) {
            // 判断大小是否超过阈值,是的话移除最后一个节点(移除最少使用的节点)
            if (cache.size() >= capcity) {
                cache.remove(last.key);
                removeLast();
            }
            node = new Node(key, value);
        }
        // 已存在,新值覆盖旧值
        cache.put(key, node);
        node.value = value;
        // 元素移动到头部
        moveToHead(node);
    }

    /**
     * 通过key获取元素
     * 如果存在则返回value,因为被访问了,将节点移动到头部
     * 不存在返回null
     *
     * @param key
     * @return
     */
    public Object get(k key) {
        Node node = cache.get(key);
        if (node == null) {
            return null;
        }
        moveToHead(node);
        return node.value;
    }

    /**
     * 移除当前节点
     * 将当前节点的前一个节点的next指针,指向当前节点的下一个节点
     * 将当前节点的下一个节点的pre指针,指向当前节点的前一个节点
     * 这里有两个临界值,判断当前节点是不是就是头结点或者尾节点,需要特殊处理
     *
     * @param key
     * @return
     */
    public void remove(k key) {
        Node node = cache.get(key);
        if (node != null) {
            if (node.pre != null) {
                node.pre.next = node.next;
            }
            if (node.next != null) {
                node.next.pre = node.pre;
            }
            if (node == first) {
                first = node.next;
            }
            if (node == last) {
                last = node.pre;
            }
        }
        cache.remove(key);
    }

    /**
     * 移除最少使用的节点(最后一个节点)
     * 如果最后一个节点存在,移除最后一个节点,并且最后一个节点的前节点,现在变成了最后一个节点
     * 如果最后一个节点的前一个结点为null,说明最后一个节点是链表中的唯一节点,现在被移除了,first就为null了
     * 如果最后一个节点的前一个结点不为null,则它成为了最后一个节点,将它的后一个节点指为null
     */
    private void removeLast() {
        if (last != null) {
            last = last.pre;
            if (last == null) {
                first = null;
            } else {
                last.next = null;
            }
        }
    }

    /**
     * 将当前节点移动到头部
     * <p>
     * 移除当前节点
     * 将当前节点的前一个节点的next指针,指向当前节点的下一个节点
     * 将当前节点的下一个节点的pre指针,指向当前节点的前一个节点
     * <p>
     * 将当前节点的next指针指向头结点
     * 将头结点的pre指针指向当前节点
     * 将头结点替换成当前节点
     * 将头结点的pre指针指向null
     * <p>
     * 临界值,
     * 如果就是头结点,就不需要操作
     * 如果是尾节点,则把前一个节点变成尾节点
     * 如果头尾节点都为空,说明当前没有节点,头尾节点都是当前节点
     *
     * @param node
     */
    private void moveToHead(Node node) {

        if (first == node) {
            return;
        }
        if (last == node) {
            last = node.pre;
        }
        if (node.pre != null) {
            node.pre.next = node.next;
        }
        if (node.next != null) {
            node.next.pre = node.pre;
        }
        if (first == null || last == null) {
            first = last = node;
            return;
        }

        node.next = first;
        first.pre = node;
        first = node;
        first.pre = null;

    }

     @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        Node node = first;
        while (node != null) {
            sb.append(String.format("%s:%s ", node.key, node.value));
            node = node.next;
        }
        return sb.toString();
    }

   
}

测试结果

    public static void main(String[] args) {
        Lru<Integer, String> lru = new Lru<Integer, String>(5);
        lru.put(1, "a");
        lru.put(2, "b");
        lru.put(3, "c");
        lru.put(4, "d");
        lru.put(5, "e");
        System.out.println("原始链表为:"+lru.toString());

        lru.get(4);
        System.out.println("获取key为4的元素之后的链表:" + lru.toString());

        lru.put(6, "f");
        System.out.println("新添加一个key为6之后的链表:" + lru.toString());

        lru.remove(3);
        System.out.println("移除key=3的之后的链表:" + lru.toString());
    }

总容量为5,所以当增加元素的时候会移除最后一个元素

原始链表为:5:e 4:d 3:c 2:b 1:a 
获取key为4的元素之后的链表:4:d 5:e 3:c 2:b 1:a 
新添加一个key为6之后的链表:6:f 4:d 5:e 3:c 2:b 
移除key=3的之后的链表:6:f 4:d 5:e 2:b