collections: Counter, deque, defaultdict, ChainMap

Базовые коллекции (list, tuple, dict, set) покрывают большинство задач. Но для специфичных сценариев в стандартной библиотеке есть модуль collections с готовыми решениями. В этом уроке - Counter для подсчёта частот, deque для очередей, defaultdict для группировок, ChainMap для слоистых конфигов и OrderedDict (legacy).

Counter - подсчёт частот

Counter это специальный dict для подсчёта элементов:

from collections import Counter

words = ["apple", "banana", "apple", "cherry", "banana", "apple"]

counts = Counter(words)
print(counts)
# Counter({'apple': 3, 'banana': 2, 'cherry': 1})

print(counts["apple"])     # 3
print(counts["missing"])   # 0 - не KeyError, а 0

Главные методы:

counts.most_common(2)      # [('apple', 3), ('banana', 2)]
counts.most_common()       # все в порядке убывания
counts.elements()          # iterator с повторами

# Арифметика
c1 = Counter({"a": 3, "b": 2})
c2 = Counter({"a": 1, "c": 4})

c1 + c2    # Counter({'a': 4, 'b': 2, 'c': 4})
c1 - c2    # Counter({'b': 2, 'a': 2}) - только положительные
c1 & c2    # Counter({'a': 1}) - min значений
c1 | c2    # Counter({'a': 3, 'c': 4, 'b': 2}) - max

# Обновление
counts.update(["apple", "kiwi"])   # +1 к apple, новый kiwi
counts.subtract(["apple"])         # -1 от apple

Counter удобно для:

  • Подсчёта частот элементов
  • Топ-N элементов
  • Гистограмм
  • Multiset операций

deque - двусторонняя очередь

deque (double-ended queue) - оптимизирован для добавлений/удалений с обоих концов:

from collections import deque

dq = deque([1, 2, 3])

# Добавление
dq.append(4)         # справа: [1, 2, 3, 4]
dq.appendleft(0)     # слева:  [0, 1, 2, 3, 4]

# Удаление
dq.pop()             # справа - O(1)
dq.popleft()         # слева - O(1)

# Итерация
for x in dq:
    print(x)

# Длина
len(dq)

Сравнение со списком:

Операцияlistdeque
append (справа)O(1)O(1)
pop (справа)O(1)O(1)
insert(0, x)O(n)O(1) через appendleft
pop(0)O(n)O(1) через popleft
access [i] (середина)O(1)O(n)
lenO(1)O(1)

deque выигрывает для FIFO (очереди), list - для random access по индексу. Про сами структуры данных - в уроке «Стек и очередь».

С ограничением максимальной длины:

last_n = deque(maxlen=5)
for x in range(10):
    last_n.append(x)
print(last_n)   # deque([5, 6, 7, 8, 9], maxlen=5)

Старые элементы автоматически вытесняются. Полезно для скользящих окон, лога последних N событий.

defaultdict - dict с автосозданием значений

Уже видели в уроке про словари. Подробнее:

from collections import defaultdict

# Группировка
items = [("alice", "admin"), ("bob", "user"), ("alice", "moderator")]
groups = defaultdict(list)
for name, role in items:
    groups[name].append(role)
print(dict(groups))
# {'alice': ['admin', 'moderator'], 'bob': ['user']}

# Подсчёт через int
text = "hello world"
counts = defaultdict(int)
for char in text:
    counts[char] += 1
# Counter был бы короче для этого, но defaultdict гибче

# Вложенный
nested = defaultdict(lambda: defaultdict(int))
nested["users"]["alice"] += 1
nested["users"]["bob"] += 2

Фабрика может быть любой callable:

def get_default():
    return {"count": 0, "items": []}

data = defaultdict(get_default)
data["section_a"]["count"] += 1
data["section_a"]["items"].append("first")

OrderedDict - legacy для упорядоченности

До Python 3.7 dict был неупорядоченным, и для упорядоченности использовался OrderedDict. Сейчас обычный dict тоже упорядоченный, но OrderedDict остаётся полезен для:

from collections import OrderedDict

# move_to_end - переместить ключ в начало или конец
od = OrderedDict([("a", 1), ("b", 2), ("c", 3)])
od.move_to_end("a")          # {'b': 2, 'c': 3, 'a': 1}
od.move_to_end("a", last=False)  # {'a': 1, 'b': 2, 'c': 3}

# popitem с last=False - FIFO вместо LIFO
od.popitem(last=False)       # ('a', 1)

# Сравнение с учётом порядка
OrderedDict([("a", 1), ("b", 2)]) == OrderedDict([("b", 2), ("a", 1)])   # False
dict([("a", 1), ("b", 2)]) == dict([("b", 2), ("a", 1)])                  # True - dict игнорирует порядок при ==

Если эти специфические методы не нужны - используй обычный dict.

ChainMap - объединение нескольких dict в один view

ChainMap объединяет несколько dict в логический view без копирования. Поиск идёт по порядку - первый найденный выигрывает:

from collections import ChainMap

defaults = {"color": "red", "size": "M", "stock": 100}
user_config = {"color": "blue"}
runtime = {"stock": 50}

config = ChainMap(runtime, user_config, defaults)

print(config["color"])    # blue (из user_config)
print(config["size"])     # M (из defaults)
print(config["stock"])    # 50 (из runtime - первый источник)

Изменения идут в первый dict:

config["color"] = "green"
print(runtime)   # {'stock': 50, 'color': 'green'}
print(user_config)  # {'color': 'blue'} - не тронут

Полезно для:

  • Слоистых конфигов (defaults < user_config < runtime overrides)
  • Поиска переменной в nested scopes
  • Композиции настроек без слияния в один dict

namedtuple (краткое напоминание)

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(1, 2)
print(p.x, p.y)

Подробно разбирали в уроке про кортежи и NamedTuple.

UserDict, UserList, UserString

Базовые классы для создания собственных коллекций через наследование:

from collections import UserDict

class LimitedDict(UserDict):
    def __init__(self, max_size=10):
        super().__init__()
        self.max_size = max_size

    def __setitem__(self, key, value):
        if len(self.data) >= self.max_size and key not in self.data:
            raise ValueError(f"Превышен лимит {self.max_size}")
        super().__setitem__(key, value)

d = LimitedDict(max_size=2)
d["a"] = 1
d["b"] = 2
d["c"] = 3   # ValueError

Прямое наследование от dict тоже работает, но UserDict даёт более стабильное поведение (некоторые методы dict обходят __setitem__ в C-имплементации).

Чаще используется @dataclass или явная композиция, чем наследование от dict/list/str.

Когда что использовать

ЗадачаСтруктура
Подсчёт частотCounter
Топ-N элементовCounter.most_common(n)
Очередь (FIFO)deque
Двусторонняя очередьdeque
Скользящее окноdeque(maxlen=N)
Группировкаdefaultdict(list)
Подсчёт + кастомная логикаdefaultdict(int)
Слоистый конфигChainMap
Сравнение dict с учётом порядкаOrderedDict
FIFO с move_to_endOrderedDict (или dict)
Кастомные коллекции через наследованиеUserDict/UserList/UserString

Подводные камни

1. defaultdict создаёт ключ при доступе

d = defaultdict(list)
print(d["missing"])   # [] - но КЛЮЧ СОЗДАЁТСЯ!
print(d)              # defaultdict(list, {'missing': []})

Если хочешь проверить наличие без создания - используй key in d или dict(d).get(key).

2. deque не поддерживает быстрый random access

dq = deque(range(1000))
dq[500]   # O(n) - проходит до середины

Если нужен random access - используй list. Если оба - подумай о специальных структурах (например, sortedcontainers.SortedList).

3. Counter с отрицательными значениями

c = Counter(a=5)
c.subtract({"a": 10})
print(c)   # Counter({'a': -5}) - отрицательные допустимы

c1 - c2    # отбрасывает отрицательные - может удивить

+ и - не симметричны с обычным dict арифметикой из-за этого.

Сравнение с Go и PHP

В Go нет встроенных аналогов Counter или deque. Используются стандартные map для подсчёта, container/list для очередей.

В PHP SplStack, SplQueue, SplObjectStorage есть в SPL, но используются нечасто - чаще через массивы с особыми соглашениями.

Python collections даёт оптимизированные структуры для типичных задач в одном модуле. Это часть «батарейки в комплекте» философии.

Мини-задание

  1. Топ-3 слов в тексте через Counter:
from collections import Counter

text = """python is a great language python is widely used
python developers love python collections module"""

words = text.split()
counts = Counter(words)
print(counts.most_common(3))
# [('python', 4), ('is', 2), ('great', 1)] (или похожее)
  1. Очередь через deque:
from collections import deque

queue = deque()
queue.append("task1")
queue.append("task2")
queue.append("task3")

while queue:
    task = queue.popleft()   # FIFO - O(1)
    print(f"Обрабатываю: {task}")
  1. ChainMap для конфигов:
from collections import ChainMap
import os

defaults = {"DATABASE_URL": "sqlite:///app.db", "DEBUG": False}
env_config = {k: v for k, v in os.environ.items() if k in defaults}

config = ChainMap(env_config, defaults)
print(config["DATABASE_URL"])  # env переменная или дефолт
print(config["DEBUG"])

Что дальше

Модуль 5 завершён. Освоили все основные коллекции Python: list, tuple, dict, set и специализированные из collections. В следующем модуле перейдём к ООП: классы, наследование, dunder-методы и Protocol.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.