LRU缓存

LRU算法,即最近最久未使用算法,广泛应用于内存管理之中,在MySQL,Redis,操作系统的内存淘汰策略中均有它的影子。

该算法通过双向链表+哈希表实现。当我们访问某个节点的时候,先看链表中是否存在该节点,如果存在,将该节点移动到链表首部,并更新该节点的值。当我们插入节点的时候,插入到链表首部,如果链表容量达到上限,删去尾部节点。

由于双向链表+哈希表,我们的LRU缓存插入删除的时间复杂度都是O(1)。

LRU缓存的实现,包含几部分:

- 双向链表,通过内部类Node表示
- 容量,用capacity表示
- 首尾哨兵节点,dummy表示,
- 哈希表,keyToNode表示

核心实现在于三个方法:

- remove(Node x)方法,移除指定节点
- pushFront(Node x)方法,插入节点至头部
- getNode(int key)方法,查询链表中的节点,如果存在,移动到头部

暴露的两个方法get和put均基于上面三个方法实现

下面是该算法的实现

public class LRUCache {
    private class Node {
        private int key, value;
        private Node prev, next;

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

    private final int capacity;
    private final Node dummy = new Node(0, 0);
    private final Map<Integer, Node> keyToNode = new HashMap<>();

    public LRUCache(int capacity) {
        this.capacity = capacity;
        dummy.next = dummy;
        dummy.prev = dummy;
    }

    public int get(int key) {
        Node node = getNode(key);
        return node == null ? -1 : node.value;
    }

    public void put(int key, int value) {
        Node node = getNode(key);
        if (node != null) {
            node.value = value;
            return;
        }
        node = new Node(key, value);
        pushFront(node);
        keyToNode.put(key, node);
        if (keyToNode.size() > capacity) {
            Node backNode = dummy.prev;
            remove(backNode);
            keyToNode.remove(backNode.key);
        }
    }

    private Node getNode(int key) {
        Node node = keyToNode.get(key);
        if (node == null) {
            return null;
        }
        remove(node);
        pushFront(node);
        return node;
    }

    private void remove(Node x) {
        x.next.prev = x.prev;
        x.prev.next = x.next;
    }

    private void pushFront(Node x) {
        x.prev = dummy;
        x.next = dummy.next;
        x.prev.next = x;
        x.next.prev = x;
    }
}

这里面一大难点就在于理解为什么dummy.prev就是尾节点

Node backNode = dummy.prev;

我们先看初始化方法

public LRUCache(int capacity) {
    this.capacity = capacity;
    dummy.next = dummy;
    dummy.prev = dummy;
}

其实这段代码可以这样理解

dummy <---> dummy

然后看pushFront方法

private void pushFront(Node x) {
    x.prev = dummy;
    x.next = dummy.next;
    x.prev.next = x;
    x.next.prev = x;
}

相当于

dummy <---> x1...xn <---> dummy

我们发现dummy.prev就是尾节点。并且LRU通过首尾哨兵节点省略了边界条件特判。

链表中的哨兵节点

不带哨兵节点的情况下,插入节点

class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
}

class LinkedListNoSentinel {
    ListNode head;

    // 在第 index 个位置插入 val(0-based)
    public void insert(int index, int val) {
        if (index == 0) {
            ListNode newNode = new ListNode(val);
            newNode.next = head;
            head = newNode;
            return;
        }

        ListNode prev = head;
        for (int i = 0; i < index - 1 && prev != null; i++) {
            prev = prev.next;
        }

        if (prev == null) {
            throw new IllegalArgumentException("Index out of bounds");
        }

        ListNode newNode = new ListNode(val);
        newNode.next = prev.next;
        prev.next = newNode;
    }
}

带哨兵节点的情况下,插入节点

class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
}

class LinkedListWithSentinel {
    private ListNode dummy;

    public LinkedListWithSentinel() {
        dummy = new ListNode(-1); // dummy 节点,不存实际数据
    }

    // 在第 index 个位置插入 val(0-based,相对于第一个真实节点)
    public void insert(int index, int val) {
        ListNode prev = dummy;
        for (int i = 0; i < index && prev != null; i++) {
            prev = prev.next;
        }

        if (prev == null) {
            throw new IllegalArgumentException("Index out of bounds");
        }

        ListNode newNode = new ListNode(val);
        newNode.next = prev.next;
        prev.next = newNode;
    }

    public ListNode getHead() {
        return dummy.next; // 返回真实头结点
    }
}

不带哨兵节点的情况下,删除节点

class LinkedListNoSentinel {
    ListNode head;

    // 删除第 index 个节点(0-based)
    public void delete(int index) {
        if (head == null) return;

        if (index == 0) {
            head = head.next;  // 头节点特殊处理
            return;
        }

        ListNode prev = head;
        for (int i = 0; i < index - 1 && prev.next != null; i++) {
            prev = prev.next;
        }

        if (prev.next == null) {
            throw new IllegalArgumentException("Index out of bounds");
        }

        prev.next = prev.next.next;
    }
}

带哨兵节点的情况下,删除节点

class LinkedListWithSentinel {
    private ListNode dummy;

    public LinkedListWithSentinel() {
        dummy = new ListNode(-1);
    }

    // 删除第 index 个节点(0-based)
    public void delete(int index) {
        ListNode prev = dummy;
        for (int i = 0; i < index && prev.next != null; i++) {
            prev = prev.next;
        }

        if (prev.next == null) {
            throw new IllegalArgumentException("Index out of bounds");
        }

        prev.next = prev.next.next;
    }

    public ListNode getHead() {
        return dummy.next;
    }
}

我们从上面的代码例子中不难发现,加入哨兵节点可以不用判断链表为空或者插入/删除的是头节点的情况。省略了烦人的边界条件判断,提高写代码的正确性和简便性。