目录

题目描述

460. LFU 缓存

题意分析

设计一个带容量上限的 LFU 缓存:get(key) 返回对应值或 -1put(key, value) 插入或更新。容量满时淘汰「使用计数最小」的键;若最小计数有多个键并列,再按 LRU 规则淘汰其中最久未使用的那个——淘汰规则是两层的,这是与普通 LRU 缓存的本质区别。

题目显式要求 getput 都以 $O(1)$ 平均时间完成,这直接排除了「淘汰时扫一遍找最小」的任何实现。

细节信号:get 命中和 put 更新已有 key 都算一次「使用」,计数都要加一;边界上 capacity 可以为 0,此时 put 应当直接无效;调用次数可达 $10^5$ 量级,常数也要控制。

解法:哈希表 + 频率链表

核心思路

问题关键:淘汰规则有两级——先找最低访问频次,再在同频节点中淘汰最久未使用者。若淘汰时遍历所有节点,put 会退化为 $O(n)$,无法满足题目的 $O(1)$ 平均复杂度要求。

为什么选该解法:用 key → Node 哈希表 $O(1)$ 找节点;用 freq → 双向链表 按频次分桶,每个桶按最近使用顺序排列,头部最新、尾部最旧;再维护 minFreq 直接定位淘汰桶。这样淘汰就是删除 minFreq 桶的尾节点。

不变量:每个节点只属于与自身 freq 对应的一个桶;每个桶内保持 LRU 顺序;minFreq 始终指向当前最低的非空频次桶。访问只会让频次从 f 增至 f + 1,所以旧桶若不是最低频,minFreq 不变;若旧桶恰是最低频且迁移后为空,新的最低频只能是 f + 1。插入新节点时频次固定为 1,直接令 minFreq = 1

get 与更新已有 key 都复用一次“升频”操作:从旧桶摘除节点,修正 minFreq,再把节点插到新桶头部。双向链表使用头尾哨兵,任意节点的删除和插入都是 $O(1)$。

解题步骤

  1. 初始化节点表、频次桶表和 minFreq;容量为 0 时,put 直接返回。
  2. get(key) 未命中返回 -1;命中则执行升频,再返回节点值。
  3. put(key, value) 命中时更新值并升频。更新也算一次访问,不能把频次重置为 1。
  4. 插入新 key 前若容量已满,从 minFreq 桶尾删除最旧节点,并从节点表同步删除。
  5. 新节点以 freq = 1 插入桶头,同时设置 minFreq = 1
  6. 升频严格按“从旧桶摘除 → 旧桶为空时修正 minFreqfreq++ → 插入新桶头部”的顺序执行。

例如容量为 2,执行 put(1,1)、put(2,2)、get(1) 后,频次桶为 1:[2]、2:[1]。此时 put(3,3) 必须淘汰频次最低的 key 2,而不是刚访问过的 key 1。

代码实现

class LFUCache {
    private final int capacity;
    private int minFreq;
    private final Map<Integer, Node> nodes = new HashMap<>();
    private final Map<Integer, DoubleList> lists = new HashMap<>();

    public LFUCache(int capacity) {
        this.capacity = capacity;
    }

    public int get(int key) {
        Node node = nodes.get(key);
        if (node == null) {
            return -1;
        }
        increase(node);
        return node.value;
    }

    public void put(int key, int value) {
        if (capacity == 0) {
            return;
        }

        Node node = nodes.get(key);
        if (node != null) {
            node.value = value;
            increase(node);
            return;
        }

        if (nodes.size() == capacity) {
            DoubleList list = lists.get(minFreq);
            Node removed = list.removeLast();
            nodes.remove(removed.key);
            if (list.isEmpty()) {
                lists.remove(minFreq);
            }
        }

        node = new Node(key, value);
        nodes.put(key, node);
        lists.computeIfAbsent(1, ignored -> new DoubleList()).addFirst(node);
        minFreq = 1;
    }

    private void increase(Node node) {
        int oldFreq = node.freq;
        DoubleList oldList = lists.get(oldFreq);
        oldList.remove(node);
        if (oldList.isEmpty()) {
            lists.remove(oldFreq);
            if (oldFreq == minFreq) {
                minFreq++;
            }
        }

        node.freq++;
        lists.computeIfAbsent(node.freq, ignored -> new DoubleList()).addFirst(node);
    }

    private static class Node {
        int key;
        int value;
        int freq = 1;
        Node pre;
        Node next;

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

    private static class DoubleList {
        private final Node head = new Node(0, 0);
        private final Node tail = new Node(0, 0);

        DoubleList() {
            head.next = tail;
            tail.pre = head;
        }

        void addFirst(Node node) {
            node.next = head.next;
            node.pre = head;
            head.next.pre = node;
            head.next = node;
        }

        void remove(Node node) {
            node.pre.next = node.next;
            node.next.pre = node.pre;
        }

        Node removeLast() {
            Node node = tail.pre;
            remove(node);
            return node;
        }

        boolean isEmpty() {
            return head.next == tail;
        }
    }
}
type LFUCache struct {
    capacity int
    minFreq  int
    nodes    map[int]*lfuNode
    lists    map[int]*lfuList
}

type lfuNode struct {
    key   int
    value int
    freq  int
    pre   *lfuNode
    next  *lfuNode
}

type lfuList struct {
    head *lfuNode
    tail *lfuNode
}

func Constructor(capacity int) LFUCache {
    return LFUCache{
        capacity: capacity,
        nodes:    make(map[int]*lfuNode),
        lists:    make(map[int]*lfuList),
    }
}

func (this *LFUCache) Get(key int) int {
    node := this.nodes[key]
    if node == nil {
        return -1
    }
    this.increase(node)
    return node.value
}

func (this *LFUCache) Put(key int, value int) {
    if this.capacity == 0 {
        return
    }
    if node := this.nodes[key]; node != nil {
        node.value = value
        this.increase(node)
        return
    }

    if len(this.nodes) == this.capacity {
        list := this.getList(this.minFreq)
        removed := list.removeLast()
        delete(this.nodes, removed.key)
        if list.isEmpty() {
            delete(this.lists, this.minFreq)
        }
    }

    node := &lfuNode{key: key, value: value, freq: 1}
    this.nodes[key] = node
    this.getList(1).addFirst(node)
    this.minFreq = 1
}

func (this *LFUCache) increase(node *lfuNode) {
    oldFreq := node.freq
    oldList := this.getList(oldFreq)
    oldList.remove(node)
    if oldList.isEmpty() {
        delete(this.lists, oldFreq)
        if oldFreq == this.minFreq {
            this.minFreq++
        }
    }

    node.freq++
    this.getList(node.freq).addFirst(node)
}

func (this *LFUCache) getList(freq int) *lfuList {
    if this.lists[freq] == nil {
        head := &lfuNode{}
        tail := &lfuNode{}
        head.next = tail
        tail.pre = head
        this.lists[freq] = &lfuList{head: head, tail: tail}
    }
    return this.lists[freq]
}

func (list *lfuList) addFirst(node *lfuNode) {
    node.next = list.head.next
    node.pre = list.head
    list.head.next.pre = node
    list.head.next = node
}

func (list *lfuList) remove(node *lfuNode) {
    node.pre.next = node.next
    node.next.pre = node.pre
}

func (list *lfuList) removeLast() *lfuNode {
    node := list.tail.pre
    list.remove(node)
    return node
}

func (list *lfuList) isEmpty() bool {
    return list.head.next == list.tail
}

复杂度分析

  • 时间复杂度getput 的平均时间复杂度均为 $O(1)$。每次只进行常数次哈希查找和链表摘插,minFreq 也只做一步修正。
  • 空间复杂度:$O(\text{capacity})$。每个缓存项对应一个节点,空频次桶会被及时删除。

关键点总结

  • 两张哈希表分别解决“按 key 定位”和“按频次分组”,桶内双向链表解决同频时的 LRU 淘汰。
  • minFreq 能 $O(1)$ 更新的依据是频次只增不减:最低桶被升频搬空时只需加一,新节点插入时重置为 1。
  • get 和更新已有 key 都必须升频,统一复用 increase 可避免两条逻辑不同步。
  • 面试讲解顺序:先说两级淘汰规则,再给出三项不变量,最后解释 minFreq 为什么不用扫描。

易错点总结

  • 升频时必须先从旧桶删除,再修改 freq;否则会去错误的桶操作链表。
  • 只有“旧桶等于 minFreq 且删除后为空”时才能 minFreq++。例如桶 1:[2,1] 中访问 key 1 后,key 2 仍在频次 1,最低频不能变。
  • 淘汰和插入顺序不能反:容量为 1、旧节点频次为 2 时,若先插入并把 minFreq 设为 1,会把新节点自己淘汰。
  • 淘汰节点时要同时从链表和节点表删除;空桶也应删除,否则空间会随历史频次增长。
  • 新节点必须令 minFreq = 1capacity = 0 必须直接返回,避免从不存在的桶中淘汰。

相似题目

题目 难度 考察点
146. LRU 缓存 中等 单层「最近使用」淘汰,本题的前置与退化情形
432. 全 O(1) 的数据结构 困难 同款计数分桶 + 桶间迁移,桶本身也串成双向链表
LCR 031. LRU 缓存 中等 146 的镜像题,练哈希表 + 双向链表的标准组合
面试题 16.25. LRU 缓存 中等 同为 LRU 设计,可对比内置有序结构与手写链表的取舍