cswork / 模板打开 500 路题单

第 33 站 · 设计与位运算

缓存、随机访问与流式索引

LRU:哈希表 + 双向链表 get 与 put 均为 O(1)

周赛难度分 难度分 1575–难度分 1782· 中位 难度分 1737(4/12 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 33 站 · 12 题
先学
学完去
没有后续站依赖它。
面试频次
29 家公司考过 · 面经出现 40 次Amazon 5、TikTok / ByteDance 4、Microsoft 3、Apple 2、Oracle 2

先做这道

LC 146LRU 缓存中等
卡住再看

组合哈希表和双向链表实现O(1)读取更新淘汰,维护最近使用顺序。

要点

  1. 哈希表负责 O(1) 找到结点,双向链表负责 O(1) 把结点移到头部、删除尾部。
  2. get 也算一次「使用」,要把结点移到最新的一端;put 一个已存在的键要更新值并移动,而不是再插一份。
  3. 面试要求手写链表时,用头尾两个哨兵结点,插入删除就不用判断空指针。

易错点

  • 淘汰尾结点时只删了链表、没删哈希表里的键(或者反过来)。
  • 先插入再淘汰还是先淘汰再插入要统一,并想清楚容量为 0 这种边界。
  • 链表结点里不存 key,淘汰时就不知道该删哈希表里的哪一项。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

LRU 缓存 · 哈希表 + 双向链表

容量 2:put(1, 1)、put(2, 2)、get(1)、put(3, 3)、get(2)、put(4, 4)、get(1)、get(3)、get(4)

换个输入
头最近尾最久
当前处理已发现 / 已计算答案
get 的返回值
空
容量
2
大小
0
操作
—

哈希表(格子)按键找到链表结点;双向链表从头到尾按「最近使用」排序。头尾是两个哨兵结点。

始终成立链表从头到尾按最近使用排序;哈希表和链表里的键始终是同一批,最多 capacity 个。

模板

选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 146。

Python
from collections import OrderedDict


class LRUCache:
    def __init__(self, capacity: int):
        self.cap = capacity
        self.data = OrderedDict()  # a hash map over a doubly linked list; the end is most recent

    def get(self, key: int) -> int:
        if key not in self.data:
            return -1
        self.data.move_to_end(key)  # mark as most recently used
        return self.data[key]

    def put(self, key: int, value: int) -> None:
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.cap:
            self.data.popitem(last=False)  # evict the least recently used
C++
class LRUCache {
    int cap;
    list<pair<int, int>> items;                               // front = most recently used
    unordered_map<int, list<pair<int, int>>::iterator> pos;   // key -> its node in items

public:
    LRUCache(int capacity) : cap(capacity) {}

    int get(int key) {
        auto it = pos.find(key);
        if (it == pos.end()) return -1;
        items.splice(items.begin(), items, it->second);  // move to front in O(1)
        return it->second->second;
    }

    void put(int key, int value) {
        if (auto it = pos.find(key); it != pos.end()) {
            it->second->second = value;
            items.splice(items.begin(), items, it->second);
            return;
        }
        items.emplace_front(key, value);
        pos[key] = items.begin();
        if ((int)items.size() > cap) {
            pos.erase(items.back().first);  // evict the least recently used
            items.pop_back();
        }
    }
};
Java
// LinkedHashMap in access order is a hash map over a doubly linked list.
class LRUCache extends LinkedHashMap<Integer, Integer> {
    private final int cap;

    LRUCache(int capacity) {
        super(16, 0.75f, true); // accessOrder = true: every get moves the entry to the end
        cap = capacity;
    }

    int get(int key) {
        return getOrDefault(key, -1);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > cap; // evict the least recently used after an insert
    }
}
Go
import "container/list"

type entry struct{ key, value int }

type LRUCache struct {
	cap   int
	order *list.List            // front = most recently used
	pos   map[int]*list.Element // key -> its element in order
}

func NewLRUCache(capacity int) *LRUCache {
	return &LRUCache{cap: capacity, order: list.New(), pos: map[int]*list.Element{}}
}

func (c *LRUCache) Get(key int) int {
	el, ok := c.pos[key]
	if !ok {
		return -1
	}
	c.order.MoveToFront(el)
	return el.Value.(*entry).value
}

func (c *LRUCache) Put(key, value int) {
	if el, ok := c.pos[key]; ok {
		el.Value.(*entry).value = value
		c.order.MoveToFront(el)
		return
	}
	c.pos[key] = c.order.PushFront(&entry{key, value})
	if c.order.Len() > c.cap {
		oldest := c.order.Back() // evict the least recently used
		c.order.Remove(oldest)
		delete(c.pos, oldest.Value.(*entry).key)
	}
}

常见变体

这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。

对顶堆:大顶堆存较小一半,小顶堆存较大一半插入 O(log n) · 取中位数 O(1)

数据流里随时要中位数(或第 k 小、滑动窗口中位数):较小的一半放大顶堆,较大的一半放小顶堆,每次插入先进大顶堆、再把它的堆顶挪到小顶堆,最后保证大顶堆不比小顶堆少。中位数就在两个堆顶。LC 295 数据流的中位数。

Python
import heapq


class MedianFinder:
    """Running median of a stream of numbers (LC 295)."""

    def __init__(self):
        self.low: list[int] = []  # the smaller half, negated so heapq gives a max-heap
        self.high: list[int] = []  # the larger half, a min-heap

    def add(self, x: int) -> None:
        heapq.heappush(self.low, -x)
        heapq.heappush(self.high, -heapq.heappop(self.low))  # the largest of low moves up
        if len(self.high) > len(self.low):  # low keeps the same size or one more
            heapq.heappush(self.low, -heapq.heappop(self.high))

    def median(self) -> float:
        if len(self.low) > len(self.high):
            return -self.low[0]
        return (-self.low[0] + self.high[0]) / 2
C++
// Running median of a stream of numbers (LC 295).
class MedianFinder {
    priority_queue<int> low;  // the smaller half, largest on top
    priority_queue<int, vector<int>, greater<int>> high;  // the larger half, smallest on top

public:
    void add(int x) {
        low.push(x);
        high.push(low.top());  // the largest of low moves up
        low.pop();
        if (high.size() > low.size()) {  // low keeps the same size or one more
            low.push(high.top());
            high.pop();
        }
    }
    double median() const {
        return low.size() > high.size() ? low.top() : ((double)low.top() + high.top()) / 2;
    }
};
Java
// Running median of a stream of numbers (LC 295).
class MedianFinder {
    private final PriorityQueue<Integer> low = new PriorityQueue<>(Comparator.reverseOrder()); // the smaller half
    private final PriorityQueue<Integer> high = new PriorityQueue<>(); // the larger half

    void add(int x) {
        low.offer(x);
        high.offer(low.poll()); // the largest of low moves up
        if (high.size() > low.size()) low.offer(high.poll()); // low keeps the same size or one more
    }

    double median() {
        return low.size() > high.size() ? low.peek() : ((double) low.peek() + high.peek()) / 2;
    }
}
Go
import "container/heap"

// intHeap is a container/heap of ints in the order less gives.
type intHeap struct {
	xs   []int
	less func(a, b int) bool
}

func (h *intHeap) Len() int           { return len(h.xs) }
func (h *intHeap) Less(i, j int) bool { return h.less(h.xs[i], h.xs[j]) }
func (h *intHeap) Swap(i, j int)      { h.xs[i], h.xs[j] = h.xs[j], h.xs[i] }
func (h *intHeap) Push(x any)         { h.xs = append(h.xs, x.(int)) }
func (h *intHeap) Pop() any {
	x := h.xs[len(h.xs)-1]
	h.xs = h.xs[:len(h.xs)-1]
	return x
}

// MedianFinder keeps the running median of a stream of numbers (LC 295).
type MedianFinder struct {
	low, high *intHeap
}

func NewMedianFinder() *MedianFinder {
	return &MedianFinder{
		low:  &intHeap{less: func(a, b int) bool { return a > b }}, // the smaller half, largest on top
		high: &intHeap{less: func(a, b int) bool { return a < b }}, // the larger half, smallest on top
	}
}

func (m *MedianFinder) Add(x int) {
	heap.Push(m.low, x)
	heap.Push(m.high, heap.Pop(m.low)) // the largest of low moves up
	if m.high.Len() > m.low.Len() { // low keeps the same size or one more
		heap.Push(m.low, heap.Pop(m.high))
	}
}

func (m *MedianFinder) Median() float64 {
	if m.low.Len() > m.high.Len() {
		return float64(m.low.xs[0])
	}
	return float64(m.low.xs[0]+m.high.xs[0]) / 2
}
数组 + 下标表:删除时和末尾交换每次操作平均 O(1) · 空间 O(n)

既要 O(1) 增删、又要 O(1) 随机取一个(或按位置访问):值存在数组里,哈希表记每个值的下标;删除时把末尾元素搬到被删的位置、更新它的下标,再弹出末尾,数组始终没有空洞。LC 380 O(1) 时间插入、删除和获取随机元素,LC 381 允许重复。

Python
import random


class RandomizedSet:
    """insert, remove and get_random in O(1) on average (LC 380)."""

    def __init__(self):
        self.items: list[int] = []  # the values, in no particular order
        self.where: dict[int, int] = {}  # value -> its index in items

    def insert(self, x: int) -> bool:
        if x in self.where:
            return False
        self.where[x] = len(self.items)
        self.items.append(x)
        return True

    def remove(self, x: int) -> bool:
        if x not in self.where:
            return False
        i, last = self.where.pop(x), self.items.pop()
        if i < len(self.items):  # x was not last: the last value fills its slot
            self.items[i] = last
            self.where[last] = i
        return True

    def get_random(self) -> int:
        return random.choice(self.items)
C++
// insert, remove and getRandom in O(1) on average (LC 380).
class RandomizedSet {
    vector<int> items;  // the values, in no particular order
    unordered_map<int, int> where;  // value -> its index in items

public:
    bool insert(int x) {
        if (where.count(x)) return false;
        where[x] = items.size();
        items.push_back(x);
        return true;
    }
    bool remove(int x) {
        auto it = where.find(x);
        if (it == where.end()) return false;
        int i = it->second, last = items.back();
        items[i] = last;  // the last value fills x's slot (x itself if it was last)
        where[last] = i;
        items.pop_back();
        where.erase(x);
        return true;
    }
    int getRandom() const { return items[rand() % items.size()]; }
};
Java
// insert, remove and getRandom in O(1) on average (LC 380).
class RandomizedSet {
    private final List<Integer> items = new ArrayList<>(); // the values, in no particular order
    private final Map<Integer, Integer> where = new HashMap<>(); // value -> its index in items
    private final Random random = new Random();

    boolean insert(int x) {
        if (where.containsKey(x)) return false;
        where.put(x, items.size());
        items.add(x);
        return true;
    }

    boolean remove(int x) {
        Integer i = where.get(x);
        if (i == null) return false;
        int last = items.get(items.size() - 1);
        items.set(i, last); // the last value fills x's slot (x itself if it was last)
        where.put(last, i);
        items.remove(items.size() - 1);
        where.remove(x);
        return true;
    }

    int getRandom() {
        return items.get(random.nextInt(items.size()));
    }
}
Go
import "math/rand"

// RandomizedSet supports Insert, Remove and GetRandom in O(1) on average (LC 380).
type RandomizedSet struct {
	items []int       // the values, in no particular order
	where map[int]int // value -> its index in items
}

func NewRandomizedSet() *RandomizedSet { return &RandomizedSet{where: map[int]int{}} }

func (s *RandomizedSet) Insert(x int) bool {
	if _, ok := s.where[x]; ok {
		return false
	}
	s.where[x] = len(s.items)
	s.items = append(s.items, x)
	return true
}

func (s *RandomizedSet) Remove(x int) bool {
	i, ok := s.where[x]
	if !ok {
		return false
	}
	last := s.items[len(s.items)-1]
	s.items[i] = last // the last value fills x's slot (x itself if it was last)
	s.where[last] = i
	s.items = s.items[:len(s.items)-1]
	delete(s.where, x)
	return true
}

func (s *RandomizedSet) GetRandom() int { return s.items[rand.Intn(len(s.items))] }

为什么对

  1. 链表顺序就是最近使用顺序:每次 get 或 put 都把节点移到头部,所以尾部永远是最久未使用的,淘汰它正好符合 LRU 的定义。
  2. 哈希表和链表始终同步:每个键在表里恰好对应链表中的一个节点,插入、移动、删除都同时改两边,所以 get 不会读到已淘汰的节点。
  3. 双向链表加哨兵,摘下一个节点只要改它前后两个指针,O(1),不需要从头找前驱。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
LRU 缓存我用哈希表加双向链表。哈希表从键 O(1) 找到节点,链表按最近使用排序,头部最新、尾部最旧。get 命中就把节点移到头部;put 已有的键就更新并移到头部,否则插到头部,超出容量就删掉尾部节点,并从哈希表里一起删掉。手写链表时加头尾哨兵,所有操作都不用判空,get 和 put 都是 O(1)。
English
For an LRU cache I combine a hash map with a doubly linked list. The map finds a key's node in O(1), and the list is ordered by recency, newest at the head and oldest at the tail. A hit on get moves the node to the head; put on an existing key updates it and moves it, otherwise it inserts at the head and, past capacity, removes the tail node from both the list and the map. With sentinel head and tail nodes there are no null checks, and get and put are both O(1).

自评一下

对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

4 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

4 道题
  1. 要让 LRU 缓存的 get 和 put 都是 O(1),标准结构是?
  2. LRU 缓存里的链表为什么要用双向链表,单向的不行吗?
  3. get 和 put 每次的时间复杂度是?
    from collections import OrderedDict
    
    class LRU:
        def __init__(self, cap):
            self.cap, self.d = cap, OrderedDict()
    
        def get(self, key):
            if key not in self.d:
                return -1
            self.d.move_to_end(key)
            return self.d[key]
    
        def put(self, key, value):
            self.d[key] = value
            self.d.move_to_end(key)
            if len(self.d) > self.cap:
                self.d.popitem(last=False)
  4. 缓存里已有 cap 个键时,一次命中的 get 的时间复杂度是?
    class LRU:
        def __init__(self, cap):
            self.cap, self.d, self.order = cap, {}, []
    
        def get(self, key):
            if key not in self.d:
                return -1
            self.order.remove(key)
            self.order.append(key)
            return self.d[key]

作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。

模板练习

用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。

下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。

from collections import OrderedDict


class LRUCache:
            self.data.popitem(last=False)

分档练习

本站在 500 路里的 12 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。

核心 8

#题目一句话思路难度分
146LRU 缓存组合哈希表和双向链表实现O(1)读取更新淘汰,维护最近使用顺序。
380O(1) 时间插入、删除和获取随机元素用数组加位置映射支持等概率随机访问,交换末尾元素完成O(1)删除。
703数据流中的第 K 大元素用容量为 k 的最小堆维护流中的前 k 大元素,处理初始化不足 k 个值及重复插入,解释 O(log k) 更新成本。
295数据流的中位数用双堆维持数据流上下半区,说明堆大小与堆顶顺序两个不变量。
981基于时间的键值存储按键维护时间有序版本并二分前驱,处理查询早于首个版本的情况。难度分 1575
1146快照数组用每个下标的稀疏版本日志实现快照,合并同一快照内的重复写入。难度分 1771
2080区间内查询数字的频率建立值到有序位置的倒排索引,用两个二分回答静态区间频次查询。难度分 1702
362敲击计数器 🔒按时间窗口聚合命中次数,处理同秒多事件和恰好过期的边界。

进阶 4

#题目一句话思路难度分
460LFU 缓存组合频次桶和同频次LRU顺序,正确维护最小频次及更新已有键。
381O(1) 时间插入、删除和获取随机元素 - 允许重复将位置映射扩展为索引集合,在重复元素存在时维持删除和抽样概率。
2353设计食物评分系统维护分组排名和动态更新,解释有序集合或惰性堆中的过期记录处理。难度分 1782
432全 O(1) 的数据结构用相邻频次桶维护增减计数和最大最小键,训练多结构同步不变量。

Core Skills

不调库,亲手实现这一站用到的数据结构或算法,四种语言都能判题。