💻 Примеры

⚡ Четыре инструмента поверх dict и list

from collections import Counter, defaultdict
from functools import lru_cache
words = ["apple", "banana", "apple"]
print(Counter(words).most_common(1))     # [('apple', 2)]
groups = defaultdict(list); groups["fruit"].append("apple")
print(dict(groups))                      # {'fruit': ['apple']}
@lru_cache(maxsize=2)
def square(n): return n * n
print(square(3), square(3))              # второй вызов из кэша
ИнструментЗадачаКлючевая особенность
Counterсчитает элементыmost_common(), арифметика +/-
defaultdictзначение по умолчанию для нового ключаключ создаётся уже при чтении
OrderedDictуправление порядком парmove_to_end(), popitem(last=False)
lru_cacheкэширует результаты функцииаргументы должны быть хешируемыми
Топ-3 ошибки: чтение dd["x"] в defaultdict тихо создаёт ключ · @lru_cache падает на списке-аргументе · Counter вычитание - отбрасывает минус, subtract() — нет.

Примеры идут от простого к сложному: измерение времени, затем каждая структура модуля collections по отдельности — OrderedDict, defaultdict, Counter — с типичными применениями и подводными камнями, и в конце — functools.lru_cache и связка двух инструментов вместе. Весь вывод в комментариях получен запуском кода.

Пример 1. Измерение времени выполнения кода

time.time() возвращает текущее время в секундах с начала эпохи Unix; разность двух вызовов даёт грубую оценку длительности. Для коротких участков кода точнее time.perf_counter() — он не зависит от системных часов.

# measure_time.py
import time

start = time.time()
values = list(range(1000))
end = time.time()
print(len(values))          # 1000
print((end - start) >= 0)   # True

t0 = time.perf_counter()
total = sum(range(100000))
t1 = time.perf_counter()
print(total)                # 4999950000
print((t1 - t0) >= 0)       # True
⚠️ Проверить по документации: time.perf_counter() не упомянут в конспекте урока (там только time.time() и sleep()), но это рекомендуемый инструмент для замеров в реальном коде. Сверьтесь с разделом time.perf_counter().

Пример 2. OrderedDict: порядок и move_to_end()

OrderedDict — словарь с дополнительными операциями управления порядком пар: можно переместить любой ключ в начало или конец.

# ordered_dict_basics.py
from collections import OrderedDict

queue = OrderedDict()
queue["first"] = 1
queue["second"] = 2
queue["third"] = 3
print(queue)   # OrderedDict([('first', 1), ('second', 2), ('third', 3)])

print(queue.popitem(last=False))   # ('first', 1) — удалили самый первый
print(queue)                        # OrderedDict([('second', 2), ('third', 3)])

od = OrderedDict({"a": 1, "b": 2, "c": 3})
od.move_to_end("a")
print(od)   # OrderedDict([('b', 2), ('c', 3), ('a', 1)])
od.move_to_end("c", last=False)
print(od)   # OrderedDict([('c', 3), ('b', 2), ('a', 1)])

Что происходит: popitem(last=False) удаляет первый добавленный элемент — это делает OrderedDict удобным для очереди FIFO. move_to_end(key, last=False) перемещает ключ в начало, а не в конец — параметр называется одинаково у обоих методов, но управляет разными сторонами структуры.

Пример 3. Обычный dict тоже хранит порядок вставки

В современном Python обычный dict с версии 3.7 гарантированно сохраняет порядок добавления ключей — это стандарт языка, а не побочный эффект реализации. Разница с OrderedDict в другом: сравнение на равенство.

# dict_order_vs_ordereddict.py
from collections import OrderedDict

d1 = {"first": 1, "second": 2}
d2 = {"second": 2, "first": 1}
print(d1 == d2)                              # True — обычный dict сравнивает без учёта порядка
print(list(d1) == list(d2))                  # False — порядок ключей разный

print(OrderedDict(d1) == OrderedDict(d2))    # False — OrderedDict сравнивает и порядок тоже

Что происходит: в теории OrderedDict подан как «нужен для порядка», но это устарело — порядок сохраняет и обычный dict. Настоящая причина использовать OrderedDict сегодня — методы move_to_end() и popitem(last=False), которых у обычного dict нет, и то, что == для него учитывает порядок.

Пример 4. functools.lru_cache: кэш вызовов функции

@lru_cache(maxsize=N) запоминает результат функции по значениям аргументов. Повторный вызов с теми же аргументами не выполняет тело функции заново, а сразу возвращает сохранённый результат.

# lru_cache_basics.py
from functools import lru_cache

@lru_cache(maxsize=2)
def compute_square(number):
    print(f"compute {number}")
    return number * number

print(compute_square(2))   # compute 2 \n 4
print(compute_square(2))   # 4 — без "compute 2", результат взят из кэша
print(compute_square(3))   # compute 3 \n 9
print(compute_square.cache_info())
# CacheInfo(hits=1, misses=2, maxsize=2, currsize=2)

print(compute_square(4))   # compute 4 \n 16 — вытеснил самый старый результат (2)
print(compute_square.cache_info())
# CacheInfo(hits=1, misses=3, maxsize=2, currsize=2)

Что происходит: при втором вызове compute_square(2) строка "compute 2" не печатается — функция вообще не выполнялась. maxsize=2 ограничивает кэш двумя записями: когда приходит третий уникальный аргумент, самая старая запись вытесняется по принципу LRU (Least Recently Used).

Пример 5. defaultdict: группировка и подсчёт без проверок

defaultdict(factory) вызывает factory() и создаёт значение автоматически, если обращаются к отсутствующему ключу. Для группировки берут list, для подсчёта — int.

# defaultdict_grouping.py
from collections import defaultdict

students = [("Иван", "Физика"), ("Мария", "Математика"), ("Пётр", "Физика")]

result = defaultdict(list)
for name, faculty in students:
    result[faculty].append(name)
print(dict(result))
# {'Физика': ['Иван', 'Пётр'], 'Математика': ['Мария']}

counts = defaultdict(int)
for _, faculty in students:
    counts[faculty] += 1
print(dict(counts))
# {'Физика': 2, 'Математика': 1}

Что происходит: без defaultdict пришлось бы писать if faculty not in result: result[faculty] = [] перед каждым append(). counts[faculty] += 1 работает даже для первого упоминания факультета: defaultdict(int) подставляет 0, и 0 + 1 даёт 1.

Пример 6. Ловушка: defaultdict создаёт ключ уже при чтении

Обращение по [] к отсутствующему ключу — это не только чтение, но и запись: defaultdict сразу добавляет ключ со значением фабрики. Если нужно только проверить значение, не изменяя словарь, — используйте get().

# defaultdict_mutates_on_read.py
from collections import defaultdict

dd = defaultdict(list)
print(dd.get("missing", []))   # []
print(dict(dd))                # {}  — get() не создал ключ

print(dd["missing"])           # []
print(dict(dd))                # {'missing': []} — а [] создал!
Правило: если код только читает значение и ключ может отсутствовать — берите .get(). Если код собирается тут же дополнить значение (append(), += 1) — используйте [], ради этого defaultdict и существует.

Пример 7. Counter: частотный анализ текста

Counter строит словарь частот из любой последовательности за один вызов. most_common(n) возвращает n самых частых элементов, отсортированных по убыванию количества.

# counter_word_frequency.py
from collections import Counter

text = "This is a test. This test is only a test."
words = text.lower().replace(".", "").replace(",", "").split()
word_count = Counter(words)
print(dict(word_count))
# {'this': 2, 'is': 2, 'a': 2, 'test': 3, 'only': 1}

print(word_count.most_common(2))
# [('test', 3), ('this', 2)]

Что происходит: перед подсчётом текст приводится к нижнему регистру и очищается от точек — иначе "This" и "this." считались бы разными словами. most_common(2) берёт два самых частых слова; при равном количестве порядок среди них определяется порядком первого появления.

Пример 8. Counter: update(), subtract(), elements() и арифметика

Counter поддерживает операторы + и -, а также методы update() (добавить счётчики) и subtract() (вычесть). Разница между - и subtract() — в отрицательных значениях.

# counter_arithmetic.py
from collections import Counter

c1 = Counter("banana")
print(c1)   # Counter({'a': 3, 'n': 2, 'b': 1})
c1.update("nan")
print(c1)   # Counter({'a': 4, 'n': 4, 'b': 1})

c2 = Counter(a=3, b=1)
c3 = Counter(a=1, b=2)
print(c2 + c3)   # Counter({'a': 4, 'b': 3})
print(c2 - c3)   # Counter({'a': 2}) — b получился бы -1, оператор его отбросил
print(list((c2 - c3).elements()))   # ['a', 'a']

c2.subtract(c3)
print(c2)   # Counter({'a': 2, 'b': -1}) — subtract() отрицательные значения сохраняет

Что происходит: оператор - создаёт новый Counter и молча убирает записи с нулевым или отрицательным количеством — это удобно для «что осталось после вычитания». subtract() меняет счётчик на месте и сохраняет отрицательные значения — подходит, когда отрицательное число само по себе значимо (например, «на сколько не хватает»).

Пример 9. Очередь задач по приоритету на OrderedDict

Практическая задача из лекции: расставить задачи по приоритету, перемещая записи OrderedDict внутри одного прохода по items().

# priority_queue.py
from collections import OrderedDict

tasks = OrderedDict({
    "task1": "low priority",
    "task2": "medium priority",
    "task3": "low priority",
    "task4": "high priority",
})

for key, value in list(tasks.items()):
    if "low" in value:
        tasks.move_to_end(key)
    if "high" in value:
        tasks.move_to_end(key, last=False)

print(tasks)
# OrderedDict([('task4', 'high priority'), ('task2', 'medium priority'),
#              ('task1', 'low priority'), ('task3', 'low priority')])

Что происходит: цикл идёт по list(tasks.items()) — копии пар на момент старта, а не по самому словарю, который меняется внутри цикла. Так безопасно переставлять элементы во время обхода: без копии Python выбросил бы RuntimeError: dictionary changed size during iteration (здесь размер не меняется, только порядок, но копия — общий безопасный приём).

Пример 10. Ограничения на практике: нехешируемый Counter и фабрика-функция для defaultdict

Два дополнения, которых нет в лекции напрямую, но которые часто встречаются: почему Counter не примет список элементов-списков, и что фабрикой defaultdict может быть любая функция, а не только тип вроде list/int.

# practical_limits.py
from collections import Counter, defaultdict

try:
    bad_counter = Counter([["a"], ["b"]])
except TypeError as error:
    print("TypeError:", error)
    # unhashable type: 'list'


def default_score():
    return {"wins": 0, "losses": 0}


scoreboard = defaultdict(default_score)
scoreboard["alice"]["wins"] += 1
scoreboard["bob"]["losses"] += 1
scoreboard["alice"]["wins"] += 1
print(dict(scoreboard))
# {'alice': {'wins': 2, 'losses': 0}, 'bob': {'wins': 0, 'losses': 1}}

Что происходит: Counter считает элементы, а элементы становятся ключами внутреннего словаря — список внутри списка нехешируем, поэтому подсчёт падает. Во втором блоке defaultdict(default_score) вызывает свою функцию при каждом новом ключе — так получают словарь словарей со своей структурой по умолчанию, а не просто list или int.

Что делать дальше

Посмотрите файлы репозитория — там те же идеи в исходном виде из лекции, затем переходите к заданиям. Если что-то не запустилось — загляните в типичные ошибки.