← все задачи

Python · задача 8 из 10

LRU-кэш своими руками

Продвинутый 20–25 минут OrderedDictсложность операцийвытеснение

Условие

Реализуйте кэш с ограниченным размером и вытеснением наименее недавно использованного элемента. Нужны операции get и put, обе — за O(1).

Что требуется

  • get возвращает значение и делает элемент «свежим»
  • put при переполнении выбрасывает самый давно не используемый элемент
  • Обе операции — константное время

Пример

cache = LRUCache(2)
cache.put("a", 1)
cache.put("b", 2)
cache.get("a")        # 1 -> теперь "a" свежее, чем "b"
cache.put("c", 3)     # вытеснится "b"
cache.get("b")        # None

Сначала уточните

Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.

  • Что возвращать при промахе: None, исключение или значение по умолчанию?
  • Нужна ли потокобезопасность — будут ли обращаться из нескольких потоков?
  • Нужен ли TTL или ограничение только по размеру?
Показать решение Скрыть решение

Решение

from collections import OrderedDict


class LRUCache:
    def __init__(self, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.capacity = capacity
        self._data = OrderedDict()

    def get(self, key, default=None):
        if key not in self._data:
            return default

        self._data.move_to_end(key)          # обращение делает элемент свежим
        return self._data[key]

    def put(self, key, value):
        if key in self._data:
            self._data.move_to_end(key)

        self._data[key] = value

        if len(self._data) > self.capacity:
            self._data.popitem(last=False)   # выбрасываем самый старый


# В проде для функций это одна строка:
# @functools.lru_cache(maxsize=128)

Почему так

Почему OrderedDict, а не dict

  • Нужен не просто порядок, а дешёвое перемещение элемента в конец: move_to_end — O(1)
  • Обычный dict с 3.7 хранит порядок вставки, но «освежить» ключ можно только через del и повторную вставку
  • popitem(last=False) достаёт самый старый элемент с начала — тоже O(1)

Что внутри и почему это O(1)

  • OrderedDict — это хеш-таблица плюс двусвязный список: словарь даёт доступ по ключу, список — порядок
  • Перемещение в конец меняет несколько ссылок и не зависит от размера кэша
  • Если попросят без OrderedDict — именно эту связку dict + двусвязный список и нужно написать руками

Почему проверка «больше capacity» после вставки

  • Сначала вставляем, потом при необходимости убираем один — так не нужно разбирать случай «ключ уже есть»
  • Вытеснить до вставки было бы ошибкой: обновление существующего ключа выбросило бы чужой элемент зря
  • Размер за раз может превысить предел только на единицу, поэтому одного popitem достаточно

Что спросят дальше

  • Спросят про потоки: OrderedDict не потокобезопасен, нужен Lock — и тогда O(1) остаётся, но появляется конкуренция
  • Спросят про TTL: хранить время записи рядом со значением и проверять при чтении; чистка «мусора» — отдельный вопрос
  • Спросят про functools.lru_cache: он именно для функций, ключ — аргументы, и они должны быть хешируемыми

Следующая задача

Разобрать лог и собрать статистику — Задача про файлы: проверяют потоковую обработку и то, не прочитаете ли вы гигабайт целиком.