💻
Разработка в IT
Опубликовано:
15.04.2026
Обновлено:
14.04.2026

Словари Python (dict) как инструмент частотного анализа текста

Алексей Иванов

Кто из нас не сталкивался с задачей «посчитай, сколько раз встречается каждое слово в логе»? Или «выведи топ-10 доменов из почтового дампа»? Мы в команде решаем такое регулярно, и каждый раз основной рабочий инструмент - обычный Python-словарь. Тип dict реализует ассоциативный массив на основе хеш-таблицы и закрывает целый класс задач, где нужно связать произвольный ключ с произвольным значением. Частотный анализ текста - подсчёт слов, символов, доменов - каноническое применение этой структуры.

Сегодня разберём внутреннюю механику dict, паттерны подсчёта, работу с файлами, продвинутые инструменты стандартной библиотеки и типичные грабли. С примерами кода и объяснениями, почему оно работает именно так.

Внутренняя механика dict: что под капотом

Python реализует словарь как хеш-таблицу. При добавлении пары ключ-значение интерпретатор вычисляет хеш ключа через встроенную hash(), определяет позицию в массиве слотов и помещает туда значение. Это обеспечивает амортизированную сложность O(1) на вставку и поиск. Быстро, предсказуемо, надёжно.

Требования к ключам

Ключом может быть только хешируемый (неизменяемый) объект: строка, число, кортеж из неизменяемых элементов. Списки и словари использовать нельзя - попытка приведёт к TypeError: unhashable type. Значения могут быть любого типа, включая вложенные структуры.

# Допустимые ключи
d = {
    'name': 'Alice',
    42: 'число',
    (1, 2): 'кортеж',
}

# Ошибка: список не хешируется
# d = {[1, 2]: 'список'}  # TypeError: unhashable type: 'list'

Порядок элементов

Начиная с CPython 3.7 (и гарантированно в спецификации с Python 3.7+) словарь сохраняет порядок вставки. В более ранних версиях порядок ключей не определён. Если пишете переносимый код, полагаться на порядок стоит только для 3.7+.

Потребление памяти

Хеш-таблица ест заметно больше памяти, чем список аналогичного размера - внутренний массив слотов выделяется с запасом для минимизации коллизий. Для словаря из 1 000 000 записей потребление может составлять 70-100 МБ. При работе с очень большими наборами данных стоит рассмотреть потоковую обработку или специализированные структуры.

Словарь как набор счётчиков: базовый паттерн

Центральная идея - использование словаря для построения гистограммы. Пример подсчёта символов:

word = 'brontosaurus'
d = dict()
for c in word:
    if c not in d:
        d[c] = 1
    else:
        d[c] = d[c] + 1
print(d)
# {'b': 1, 'r': 2, 'o': 2, 'n': 1, 't': 1, 's': 2, 'a': 1, 'u': 2}

Шаблон из трёх элементов: инициализация пустого dict(), проверка наличия ключа оператором in, условное создание или инкремент значения. Оператор not in выполняет поиск в хеш-таблице за O(1).

Идиома dict.get(): убираем лишние if-ы

Метод get(key, default) возвращает значение по ключу, если он существует, иначе - значение по умолчанию. Сворачивает блок if/else в одну строку:

word = 'brontosaurus'
d = dict()
for c in word:
    d[c] = d.get(c, 0) + 1
print(d)

Сравнение подходов

Характеристика

if/in

get()

Строк в цикле

4

1

Читаемость для новичка

Выше (явная логика)

Ниже (нужно знать API)

Производительность

Идентичная

Идентичная

Идиоматичность

Базовая

Продвинутая

setdefault(): когда значение по умолчанию - мутабельный объект

Метод setdefault(key, default) возвращает значение по ключу, а если ключ отсутствует - устанавливает его со значением default и возвращает это значение. Удобен для группировки:

# Группировка слов по первой букве
words = ['apple', 'avocado', 'banana', 'blueberry', 'cherry']
groups = {}
for w in words:
    groups.setdefault(w[0], []).append(w)
print(groups)
# {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}

Для простого подсчёта get() остаётся предпочтительным вариантом.

collections.Counter: промышленный подсчёт

Модуль collections содержит класс Counter, который инкапсулирует весь паттерн подсчёта в одну конструкцию:

from collections import Counter


word = 'brontosaurus'
counter = Counter(word)
print(counter)
# Counter({'r': 2, 'o': 2, 's': 2, 'u': 2, 'b': 1, 'n': 1, 't': 1, 'a': 1})

# Три самых частых символа
print(counter.most_common(3))
# [('r', 2), ('o', 2), ('s', 2)]

Counter наследуется от dict, поэтому поддерживает все стандартные операции. Плюс даёт бонусы:

  • most_common(n) - топ-N элементов по частоте.
  • Арифметика счётчиков: сложение, вычитание, пересечение (+, -, &, |).
  • elements() - итератор, повторяющий каждый элемент столько раз, сколько указано.
# Подсчёт слов в тексте - одна строка
from collections import Counter


with open('romeo.txt') as f:
    word_counts = Counter(f.read().lower().split())

for word, count in word_counts.most_common(10):
    print(f'{word:15} {count}')

Для учебных целей важно понимать ручной паттерн, но в рабочем коде Counter - стандартный инструмент.

collections.defaultdict: автоинициализация значений

Когда значение по умолчанию сложнее нуля (список, множество, вложенный словарь), defaultdict автоматически создаёт его при первом обращении:

from collections import defaultdict


# Группировка без setdefault
groups = defaultdict(list)
words = ['apple', 'avocado', 'banana', 'blueberry', 'cherry']
for w in words:
    groups[w[0]].append(w)

# Подсчёт (аналог Counter)
counts = defaultdict(int)
for c in 'brontosaurus':
    counts[c] += 1

Подсчёт слов в файле: вложенные циклы и парсинг

Реальная задача частотного анализа - чтение данных из файла и разделение строк на слова:

fname = input('Enter the file name: ')
try:
    fhand = open(fname)
except FileNotFoundError:
    print('File cannot be opened:', fname)
    exit()

counts = dict()
for line in fhand:
    words = line.split()
    for word in words:
        counts[word] = counts.get(word, 0) + 1

print(counts)

Важный момент: оригинальный пример из учебника использует голый except:, который ловит все исключения, включая KeyboardInterrupt и SystemExit. Всегда указывайте конкретный тип: FileNotFoundError для отсутствующих файлов или OSError для общих ошибок ввода-вывода.

Почему split() без аргументов

split() без аргументов разбивает строку по любому пробельному символу и автоматически удаляет пустые элементы. Если использовать split(' '), при двойных пробелах в результате появятся пустые строки. Мелочь, а ломает подсчёт.

Архитектура вложенных циклов

Внешний цикл for line in fhand читает файл построчно - это эффективно по памяти, поскольку в RAM загружена только текущая строка. Внутренний цикл for word in words проходит по результату split() и полностью завершается на каждой итерации внешнего.

Итерация по словарю: keys(), values(), items()

Словарь предоставляет три представления (views) для итерации:

counts = {'chuck': 1, 'annie': 42, 'jan': 100}

# Итерация по ключам (по умолчанию)
for key in counts:
    print(key)

# Явное получение ключей
list(counts.keys())  # ['chuck', 'annie', 'jan']

# Только значения
list(counts.values())  # [1, 42, 100]
total = sum(counts.values())  # 143

# Пары ключ-значение
for key, value in counts.items():
    print(f'{key}: {value}')

Все три метода возвращают view-объекты, которые отражают текущее состояние словаря и не создают отдельную копию в памяти.

Фильтрация по значению

for key, count in counts.items():
    if count > 10:
        print(key, count)

# annie 42
# jan 100

Этот паттерн пригождается при анализе логов, когда нужно выделить аномально частые события.

Словарные включения (dict comprehension)

Аналогично списковым включениям, словари поддерживают компактный синтаксис создания:

# Квадраты чисел
squares = {x: x**2 for x in range(6)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}


# Фильтрация словаря
counts = {'chuck': 1, 'annie': 42, 'jan': 100}
frequent = {k: v for k, v in counts.items() if v > 10}
# {'annie': 42, 'jan': 100}


# Инверсия словаря (значения <-> ключи)
inverted = {v: k for k, v in counts.items()}
# {1: 'chuck', 42: 'annie', 100: 'jan'}

Dict comprehension читается лучше, чем эквивалентный цикл, и работает с такой же скоростью.

Нормализация и очистка текста: без этого подсчёт врёт

Реальный текст содержит знаки препинания и смешанный регистр. Слова «Who» и «who» будут считаться разными ключами. Для корректного анализа нужна нормализация:

import string


line = 'Hello, World! Hello...'
line = line.translate(str.maketrans('', '', string.punctuation))
line = line.lower()

# 'hello world hello'

Для более сложных правил очистки (удаление URL, извлечение email-доменов) подходит модуль re с регулярными выражениями. Словарь при этом остаётся структурой хранения результатов.

Практическое упражнение: подсчёт доменов

Задача - извлечь почтовый домен из строк, начинающихся с From , и подсчитать частоту:

from collections import Counter


fname = input('Enter file name: ')
try:
    fhand = open(fname)
except FileNotFoundError:
    print('File not found:', fname)
    exit()

domains = Counter()
for line in fhand:
    if line.startswith('From '):
        email = line.split()[1]
        domain = email.split('@')[1]
        domains[domain] += 1

for domain, count in domains.most_common():
    print(f'{domain}: {count}')

Поиск максимального значения в словаре

После построения гистограммы часто нужно определить элемент с наибольшей частотой. Учебный вариант с явным циклом:

bigcount = None
bigword = None

for word, count in counts.items():
    if bigcount is None or count > bigcount:
        bigword = word
        bigcount = count

print(bigword, bigcount)

Конструкция for word, count in counts.items() использует распаковку кортежей (tuple unpacking) для одновременного извлечения обоих элементов.

Продакшен-альтернативы покороче:

# Через max() с ключевой функцией
most_common_word = max(counts, key=counts.get)
print(most_common_word, counts[most_common_word])

# Через Counter
from collections import Counter


c = Counter(counts)
print(c.most_common(1))

# [('jan', 100)]

Вложенные словари: иерархические данные

Значения словаря могут быть любыми объектами, включая другие словари. Удобно для представления структурированных данных:

users = {
    'alice': {'email': 'alice@example.com', 'posts': 42},
    'bob': {'email': 'bob@example.com', 'posts': 17},
}

print(users['alice']['posts'])  # 42

# Безопасный доступ к вложенным данным
posts = users.get('charlie', {}).get('posts', 0)  # 0

Типичные грабли: на что наступают все

KeyError при прямом обращении

Обращение counts[word] к несуществующему ключу вызывает KeyError. Именно поэтому паттерн if word not in counts или метод get() обязательны. Проверка in выполняется за O(1) благодаря хеш-таблице.

Мутация словаря во время итерации

Добавление или удаление ключей внутри for key in d приводит к RuntimeError: dictionary changed size during iteration. Безопасный способ - итерация по копии:

for key in list(d.keys()):
    if d[key] < 2:
        del d[key]

Голый except

# Плохо - ловит KeyboardInterrupt, SystemExit и всё остальное
try:
    fhand = open(fname)
except:
    print('Error')

# Правильно - конкретный тип исключения
try:
    fhand = open(fname)
except FileNotFoundError:
    print('File not found:', fname)

Сравнение словарей

Два словаря равны, если содержат одинаковые пары ключ-значение, независимо от порядка вставки:

{'a': 1, 'b': 2} == {'b': 2, 'a': 1}  # True

А у вас есть любимые паттерны работы со словарями? Может, история, когда KeyError в проде всё уронил, или хитрый трюк с defaultdict? Делитесь в комментах, обсудим!

Читайте также