Collections в Python: Counter, defaultdict и deque на практике
Три структуры из модуля collections закрывают задачи, где обычный dict и list заставляют писать лишний код и теряют в скорости. Разбираем, что каждая даёт и чем за это платишь.

Вы считаете частоту слов в тексте, группируете записи по ключу или ведёте очередь последних событий. С обычным dict и list это работает, но код обрастает проверками if key in d и вставками в начало списка, которые внезапно становятся узким местом. Модуль collections из стандартной библиотеки (актуально для CPython 3.11–3.13) закрывает ровно эти три задачи специализированными структурами. Разбираем Counter, defaultdict и deque: что выигрываете, чем платите и когда стоит остаться на встроенных типах.
Counter: подсчёт без ручной инициализации
Задача: посчитать, сколько раз каждый элемент встречается в последовательности. Наивное решение на dict:
# CPython 3.12
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = {}
for word in words:
if word in counts:
counts[word] += 1
else:
counts[word] = 1
Проблема не в скорости, а в том, что проверка существования ключа расползается по коду и её легко забыть. Counter убирает её:
from collections import Counter
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = Counter(words)
# Counter({'apple': 3, 'banana': 2, 'cherry': 1})
# самые частые элементы, отсортированы по убыванию
top = counts.most_common(2)
# [('apple', 3), ('banana', 2)]
# обращение к отсутствующему ключу даёт 0, а не KeyError
print(counts["grape"]) # 0
Ключевая деталь: обращение к несуществующему ключу возвращает 0 и не добавляет его в словарь. Это отличает Counter от defaultdict(int), где чтение отсутствующего ключа создаёт запись.
Арифметика счётчиков
Counter поддерживает сложение и вычитание — удобно, когда объединяете статистику из нескольких источников:
page_a = Counter({"click": 10, "view": 40})
page_b = Counter({"click": 5, "view": 20, "scroll": 3})
total = page_a + page_b
# Counter({'view': 60, 'click': 15, 'scroll': 3})
diff = page_b - page_a
# Counter({'scroll': 3}) — отрицательные и нулевые отброшены
Обратите внимание на поведение вычитания через оператор -: он выбрасывает результаты, которые стали нулём или ушли в минус. Если минусы нужны, применяйте метод subtract(), он меняет объект на месте и сохраняет отрицательные значения.
defaultdict: группировка без KeyError
Задача: сгруппировать записи по ключу. Классика — разложить пары (категория, значение) в списки по категориям. На обычном dict приходится каждый раз проверять, создан ли список:
pairs = [("fruit", "apple"), ("veg", "carrot"), ("fruit", "pear")]
groups = {}
for category, value in pairs:
if category not in groups:
groups[category] = []
groups[category].append(value)
С defaultdict фабрика значения по умолчанию вызывается автоматически при первом обращении к новому ключу:
from collections import defaultdict
pairs = [("fruit", "apple"), ("veg", "carrot"), ("fruit", "pear")]
groups = defaultdict(list)
for category, value in pairs:
groups[category].append(value)
# defaultdict(list, {'fruit': ['apple', 'pear'], 'veg': ['carrot']})
Первым аргументом идёт вызываемый объект без скобок: list, int, set или своя функция без аргументов. Не list(), а именно list — иначе вы передадите готовый пустой список, и все ключи будут делить один объект.
Главная ловушка defaultdict: само чтение отсутствующего ключа создаёт запись. Проверка d["нет"] в условии молча раздует словарь пустыми значениями.
Если нужно просто прочитать значение без побочного создания ключа, используйте d.get(key) или обычный dict.setdefault(). Для существующих словарей, куда данные пришли извне, часто безопаснее setdefault, чем переливать всё в defaultdict.
Что выбрать для группировки
С Python 3.7 обычный dict получил метод setdefault, а стандартная библиотека — itertools.groupby. Разница простая: groupby группирует только соседние элементы и требует предварительной сортировки, а defaultdict собирает по ключу независимо от порядка. Для группировки неотсортированного потока defaultdict прямее.
deque: очередь с обоих концов
Задача: удалять элементы из начала последовательности. Вот где обычный list действительно проигрывает по скорости, а не только по удобству.
У списка list.pop(0) и list.insert(0, x) — операции сложности O(n): при удалении из начала весь хвост сдвигается на одну позицию. На списке из миллиона элементов это заметно. deque — двусвязная структура*, у которой вставка и удаление с любого конца стоят O(1).
from collections import deque
# очередь задач
queue = deque(["task1", "task2", "task3"])
queue.append("task4") # добавить в конец
queue.appendleft("task0") # добавить в начало
first = queue.popleft() # взять из начала — O(1)
last = queue.pop() # взять из конца — O(1)
Отдельно полезен ограниченный deque с параметром maxlen — готовое скользящее окно последних N событий. При переполнении элемент автоматически вытесняется с противоположного конца:
# хранить только 3 последних события
recent = deque(maxlen=3)
for event in ["a", "b", "c", "d", "e"]:
recent.append(event)
# deque(['c', 'd', 'e'], maxlen=3)
Цена: доступ по индексу в середине deque — операция O(n), потому что нужно пройти по звеньям. Если вам нужен произвольный доступ x[500] в горячем цикле — оставайтесь на list. deque выигрывает именно на краях.
Когда что брать
| Структура | Задача | Выигрыш | Чем платите |
|---|---|---|---|
Counter | подсчёт частот, most_common | нет ручной инициализации, чтение → 0 | арифметика отбрасывает нули и минусы |
defaultdict | группировка по ключу | нет проверки существования ключа | чтение создаёт запись — раздувает словарь |
deque | очередь, стек, скользящее окно | O(1) на обоих концах, maxlen | O(n) при индексации середины |
Критерий короче: считаете — Counter; группируете неотсортированный поток — defaultdict; работаете с краями последовательности или ведёте окно — deque. Во всех остальных случаях встроенные dict и list проще и понятнее, не усложняйте на ровном месте.
Что ломается на практике
- defaultdict передают наружу как обычный словарь. Функция, которая ожидает
dictи делаетd[key]для проверки, начнёт создавать ключи. Перед возвратом наружу приведите кdict(d), если побочное создание не нужно. - Counter при сериализации.
json.dumpsсериализуетCounterкак обычный объект-словарь — это нормально, но при загрузке обратно вы получитеdict, а неCounter. Оборачивайте заново:Counter(json.loads(...)). - deque в многопоточности. Методы
append,appendleft,pop,popleftпотокобезопасны на уровне отдельной операции благодаря GIL, но составные действия (проверил длину — потом извлёк) — нет. Для полноценной очереди между потоками беритеqueue.Queue, а неdeque.
Что дальше
Если группируете по ключу и сразу считаете — посмотрите на defaultdict(Counter) или defaultdict(list) с вложенной структурой, они комбинируются. Для неизменяемых записей вместо кортежей есть collections.namedtuple и typing.NamedTuple, для словаря с фиксированным порядком и слиянием — ChainMap. Полный список структур и их сложность операций смотрите в разделе документации по collections для вашей версии Python: поведение Counter.total(), например, появилось только в 3.10.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: collections — Container datatypes, официальная документация Python
Частые вопросы
А если у меня Python старой версии, всё это работает?
Чем Counter отличается от defaultdict(int)?
deque всегда быстрее list?
Почему мой defaultdict неожиданно вырос новыми пустыми ключами?
Можно ли использовать deque как потокобезопасную очередь между потоками?
Что передавать первым аргументом в defaultdict?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.