LRU缓存单向链表O(1)读写,JAVA实现详解

LRU(Least Recently Used) Cache的运行机制,通俗点说,就是缓存最近使用的数据,并淘汰最久没有使用的数据。

LRU的核心思想是数据的时间局部性(Temporal Locality),即"一个被访问的数据,在不久之后很可能还会被再次访问"。把它反过来说就是LRU的实现方式:“最早读取的数据,它不再被使用的可能性比刚刚读取的数据大”。

但是面对周期性读取的数据,如果周期大于缓存容量,那缓存总是无效的。

  • e.g. 假如周期性的读取五个数据:ABCDE,而缓存大小只有3,那么缓存总是不会被命中。

实现LRU本身也是一道面试题。我们就来看看怎么设计和实现吧。

分析与设计

首先来看一下详细的需求,然后设计我们的LRU:

  1. 缓存使用键值对储存,我们使用泛型K来表示键的类型,V来表示值的类型。因此需要实现以下两个接口:
    • public V get(K key)
    • public void put(K key, V value)
  2. 缓存需要设置一个容量。超出容量后会淘汰最长时间没有访问的数据
    • 淘汰最长时间没有访问的数据,也就是先进入缓存的先被删除,我们很自然地就可以联想到队列
    • 当缓存中某个数据被使用时,我们要将这个数据从队列中拿出来放在对列的尾部。而且LRU在每次使用时都要更新使用次序的信息,因此增删数据的时间复杂度对缓存性能至关重要。如此一来,问题就变成了:“用什么数据结构能在最短的时间内增删数元素?”。最短那肯定要从O(1)时间开始思考,而我们正好熟知这样的一种数据结构:链表
    • 同时我们还需要解决怎么尽快从缓存中找到我们的数据。如果仅仅使用链表,我们需要O(n)的时间才能找到想要的元素。而基本数据结构中,能做到O(1)时间根据键查找元素的,就是哈希表了。
    • 结论是:我们使用链表储存实际的数据,来实现缓存队列,然后用哈希表作为数据的索引帮助实现O(1)查找。

下面我们还需要考虑一些细节问题:

  1. 如果想要在O(1)时间内获得尾部节点,或往链表尾部添加节点,我们必须维持一个尾部的引用。
  2. 使用单向链表还是双向链表?都可以,但单向链表有限制。
    • 使用双向链表,在删除节点时可以直接连接前后两个节点,更加方便。具体实现我专门写了一篇,欢迎捧场和指教:LRU缓存双向链表O(1)读写,JAVA实现详解。
    • 使用单向链表则稍微复杂一些,需要将被删除节点的下一个节点的信息与被删除节点的信息互换,然后删除下一个节点,否则我们就得用O(n)的时间去获取上一个节点(这个算法是不是很熟悉?剑指Offer第18题)。而这时,哈希表中的键值对就出现了错位。所以我们需要更新对应的哈希表。但是这个算法并不适用于删除尾结点,所以我们只能将尾节点作为最新的节点,即队列的尾部(只需要添加,不需要删除)。

代码实现(JAVA 1.8)

分析完了,接下来开始准备代码。
首先我们要定义链表的节点,Node

  • K:key的类型, V:value的类型
  • 在节点中储存key是为了在删除的时候,能通过节点中储存的key值,在map中使用O(1)时间查找到对应的链表节点

一个队列结构需要有两个操作:添加到队列尾部(即最新的节点)和删除队列头部(即最旧的节点)。

单向链表中:

  • 添加到last是O(1)时间,而删除last后将last赋值为last的前一个节点需要O(n)时间(重新遍历到尾部)
  • 添加和删除first都是O(1)时间。

因此与双向链表头尾都可以做最新或最旧的节点不同,我们这里只能选择链表头部作为队列头部(最旧的节点),链表尾部作为队列尾部(即最新的节点)。那么我们操作链表的三个方法就变成了:

void linkLast (Node node)
    添加一个节点到尾部
Node unlinkFirst() {
    删除头部,并返回删除的节点
Node unlink(Node node)
    删除一个非尾部节点,如果node是尾部则不需要更新。
    因为我们是通过将给定节点的下一个节点的内容复制到给定节点,
    来实现O(1)删除的,相当于我们对调了两个节点的内容。
    所以我们还需要在map中更新这两个节点的key对应的引用。
    因此我们让unlink方法返回下一个节点的引用,方便更新key
    (也可以直接在unlink内更新map,这里因为考虑到模块化,
    所以我们选择让与链表相关的方法只处理链表)

添加新的缓存(下面称为newNode)的步骤,每一步都是O(1):

  1. 将newNode加入队列尾部,作为最新的缓存
  2. 在map中添加键值对:newNode.key=newNode
  3. 检查是否超过缓存容量,如果超过,删除最旧的缓存

更新已有的缓存(下面称为node)步骤,每一步也都是O(1):

  1. 根据key值在map中找到node
  2. 将node从队列中移除
  3. 将node加入队列尾部,作为最新的缓存
import java.util.HashMap;

public class LRUCacheSinglyLinkedList<K, V> {
    private int capacity;
    private int size;
    private HashMap<K, Node<K, V>> index;
    /**
     * 最旧值
     */
    private Node<K, V> first;
    /**
     * 最新值
     */
    private Node<K, V> last;

    public LRUCacheSinglyLinkedList(int capacity) {
        this.capacity = capacity;
        this.size = 0;
        this.first = null;
        this.last = null;
        // 给一个初始值,避免频繁rehash
        index = new HashMap<>(capacity*2);
    }

    public V get(K key) {
        Node<K, V> node = index.get(key);
        if (node == null) {
            return null;
        } else {
            V value = node.value;
            cacheExistingItem(node);
            return value;
        }
    }

    public void put(K key, V value) {
        Node<K, V> node = index.get(key);
        //缓存一个新的值
        if (node == null) {
            node = new Node<>(key, value, last, null);
            appendLatest(node);
            if (size > capacity) {
                removeOldest();
            }
        } else {
            node.value = value;
            cacheExistingItem(node);
        }
    }

    private void appendLatest(Node<K, V> node) {
        linkLast(node);
        index.put(node.key, node);
    }
   
    private void removeOldest() {
        //删除最旧的数据
        Node<K, V> removedNode = unlinkFirst();
        if (removedNode != null) {
            index.remove(removedNode.key);
            // 帮助GC
            removedNode.key = null;
        }
    }

    private void cacheExistingItem(Node<K, V> node) {
        //如果节点本来就是最新的,就不用更新了
        if (node == last) {
            return;
        }
        /*
        我们对调了node和next两个节点的内容后,unlink了next来实现O(1)删除,
        但index中的键仍然映射的是对调以前的节点。所以我们还需要在index中更新他们。
        e.g.
           before swap:
               address of "node" = a, node.key = 1, node.value = 11
               address of "next" = b, next.key = 2, next.value = 22
               in index, we have
                   1: a
                   2: b
           after swap:
               address of "node" = a, node.key = 2, node.value = 22
               address of "next" = b, next.key = 1, next.value = 11
               in index, we still have
                   1: a
                   2: b
         */

        Node<K, V> next = unlink(node);
        index.put(node.key, node);

        appendLatest(next);
    }

    private void linkLast(Node<K, V> node) {
        if (node == null) {
            return;
        }
        if (last == null) {
            assert first == null;
            first = node;
        } else {
            last.next = node;
        }
        last = node;

        size++;
    }
    
    private Node<K, V> unlinkFirst() {
        if (first == null) {
            return null;
        }
        Node<K, V> copyFirst = first;
        first = first.next;
        if (first == null) {
            last = null;
        }

        //帮助GC
        copyFirst.value = null;
        //不清空copyLast.key,key要用来删除index里面的值

        size--;
        return copyFirst;
    }

    private Node<K, V> unlink(Node<K, V> node) {
        //如果node是尾节点,这个方法不应被调用
        if (node == null || node == last) {
            return null;
        }
        
        Node<K, V> next = node.next;
        K keyCopy = node.key;
        V valueCopy = node.value;
        node.key = next.key;
        node.value = next.value;
        node.next = next.next;
        next.key = keyCopy;
        next.value = valueCopy;
        next.next = null;

        if (next == last) {
            last = node;
        }

        size--;
        return next;
    }

    @Override
    public String toString() {
        StringBuffer buff = new StringBuffer("LRUCache{" +
                "capacity=" + capacity +
                ", size=" + size +
                ", elements=["
        );
        Node<K, V> node = first;
        while (node != null) {
            buff.append(node.toString());
            buff.append(", ");
            node = node.next;
        }
        buff.append("]}");

        return buff.toString();
    }

    public void showIndexStructure() {
        StringBuffer buff = new StringBuffer('{');
        for (K key : index.keySet()) {
            buff.append("{entry="+key+":"+index.get(key).toString()+", ");
            buff.append("}, \n");
        }

        System.out.println(buff.toString());
    }
    
    private static class Node<K, V> {
        /**
         * 用于在O(1)时间更新index
         */
        K key;
        /**
         * 缓存的内容
         */
        V value;

        Node<K, V> next;

        public Node(K key, V value, Node<K, V> prev, Node<K, V> next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }

        @Override
        public String toString() {
            return "Node{"+ key +
                    ": " + value +
                    '}';
        }
    }

}


测试类:

import org.junit.Assert;
import org.junit.Test;

public class TestLRUCache {
    @Test
    public void testDoublyLinkedLRUCache() {
        LRUCacheSinglyLinkedList<Integer, Integer> cache = new LRUCacheSinglyLinkedList<>(3);
        System.out.println(cache);
        Assert.assertEquals(null, cache.get(5));
        cache.put(5, 55);
        cache.put(5, 55);
        System.out.println(cache);
        cache.put(4, 44);
        System.out.println(cache);
        cache.put(10, 0);
        System.out.println(cache);
        cache.put(2, 22);
        System.out.println(cache);
        cache.showIndexStructure();

        Assert.assertEquals(Integer.valueOf(44), cache.get(4));
        System.out.println(cache);
        cache.showIndexStructure();
    }
}

码字不易,觉得有帮助就给我点个赞吧!我会继续努力的!

Reference:
局部性原理,百度百科
LRU,百度百科

你可能感兴趣的:(火箭工程,链表,数据结构,java,哈希表)