Структура — общая для set и frozenset
Set в Python реализован как хеш-таблица с открытой адресацией. Это означает, что все элементы хранятся в одном непрерывном массиве фиксированного размера (в памяти), а не в связном списке или дереве.
Каждая ячейка (slot) массива может находиться в одном из трёх состояний: - Пустая — никогда не использовалась - Занятая — содержит элемент - Удалённая (dummy) — элемент был, но его удалили (tombstone)
Внутри каждая занятая ячейка хранит два поля:
- hash — кешированное значение хеша элемента (целое число, Py_hash_t)
- key — указатель на сам объект Python
Кеширование хеша критично: при операциях объединения, пересечения и поиска не нужно пересчитывать хеш заново — он уже хранится рядом с элементом.
Начальный размер таблицы — 8 слотов.
Python поддерживает коэффициент заполнения (load factor) ≤ 2/3 (т.е. не более ~66% слотов занято). Как только этот порог превышается — происходит resize.
При resize таблица перестраивается в новый массив размером: - ×4 — если сет небольшой - ×2 — если сет уже большой
Размер таблицы всегда является степенью двойки — это позволяет заменить дорогую операцию деления на быстрый битовый AND при вычислении индекса слота.
Индекс первого кандидата вычисляется как hash & (size - 1) — битовое AND с маской, что эквивалентно hash % size, но значительно быстрее.
Python не использует линейное или квадратичное зондирование в чистом виде. Вместо этого применяется алгоритм perturbation (возмущение):
На каждом шаге зондирования новый индекс вычисляется по формуле:
i = (i * 5 + 1 + perturb) & mask
perturb >>= 5
Переменная perturb инициализируется исходным значением хеша и постепенно сдвигается вправо на 5 бит на каждой итерации. Это гарантирует, что в конечном итоге задействуются все биты хеша, а не только младшие — что даёт гораздо лучшее распределение при коллизиях.
В отличие от dict, который в 3.6 получил компактное разделённое хранение (индексный массив + плотный массив записей), set по-прежнему использует классическую единую хеш-таблицу — разреженный массив, где пустые слоты занимают память. Это осознанный компромисс: set оптимизирован под скорость lookup и операции над множествами, а не под порядок вставки.
При удалении элемент не обнуляется полностью — слот помечается как dummy (tombstone). Это необходимо для корректности поиска: если просто очистить слот, цепочка зондирования оборвётся, и элементы, добавленные после коллизии, станут недостижимыми.
Dummy-слоты учитываются при resize: таблица перестраивается в том числе когда накапливается слишком много tombstone-ов — они занимают место, но не несут данных.
Операции union, intersection, difference реализованы на уровне C и используют внутренние структуры напрямую, без Python-уровня итерации:
| Операция | Стратегия |
|---|---|
\| union |
итерация по меньшему, вставка в копию большего |
& intersection |
итерация по меньшему, проверка membership в большем |
- difference |
итерация по левому, исключение найденных в правом |
^ symmetric diff |
объединение двух difference |
Выбор "итерировать по меньшему" — намеренная оптимизация: меньше итераций, меньше lookup-ов.
Set требует, чтобы элементы были хешируемыми (__hash__) и сравнимыми (__eq__). При коллизии хешей Python сначала сравнивает сами хеши (быстро, через кешированное значение), и только при совпадении вызывает __eq__ — это минимизирует дорогие объектные сравнения.
Оценка потребления памяти — sys.getsizeof возвращает размер только самой хеш-таблицы, без учёта объектов-элементов. Из-за разреженности хеш-таблица занимает больше памяти, чем аналогичный список — это прямая плата за O(1) lookup.
Пустой set: ~200 байт (8 слотов)
После resize: размер удваивается/учетверяется ступенчато
dict начиная с 3.7PySetObject — общая для set и frozenset)┌──────────────────────────────────────────┐
│ ob_refcnt (8 байт) │
│ ob_type → frozenset (8 байт) │
├──────────────────────────────────────────┤
│ fill (8 байт) ← занятые + dummy │
│ used (8 байт) ← живые элементы │
│ mask (8 байт) ← capacity - 1 │
│ table* (8 байт) ← указатель на HT │
│ hash (8 байт) ← кешированный хеш │
│ finger (8 байт) ← итератор-курсор │
│ smalltable (216 байт)← инлайн-буфер │
│ (8 slotentries × 27 байт) │
└──────────────────────────────────────────┘
Каждый слот (setentry):
┌──────────────────┬───────────────────┐
│ hash (8 байт) │ key* (8 байт) │
└──────────────────┴───────────────────┘
smalltable (инлайн, без malloc)table переключается на внешний heap-массивi = (5*i + 1 + perturb) % capacity; perturb >>= 5| set | frozenset | |
|---|---|---|
| Структура памяти | идентична | идентична |
| Мутация | разрешена | запрещена (проверка типа) |
| Хешируем | нет | да (кешируется в поле hash) |
| Может быть ключом dict | нет | да |
hash поле в PySetObject — прояснениеВ CPython исходниках (Objects/setobject.c) одна структура PySetObject используется для обоих типов:
typedef struct {
ob_refcnt
ob_type
fill
used
mask
table*
hash ← поле ЕСТЬ в структуре у обоих
finger
smalltable[8]
weakreflist
} PySetObject;
Поле hash физически присутствует в памяти и для set, и для frozenset — потому что структура одна.
set |
frozenset |
|
|---|---|---|
Поле hash в памяти |
есть | есть |
| Начальное значение | -1 (sentinel) |
-1 |
| Вычисляется когда | никогда | при первом вызове hash() |
| Кешируется | нет смысла | да, записывается в поле |
__hash__ метод |
None (явно зануляет) |
вычисляет и кеширует |
Поле
hashесть в обоих объектах на уровне C-структуры, но используется толькоfrozenset. Уsetоно всегда остаётся-1и никогда не задействуется, потому что__hash__ = Noneна уровне типа.