Deep Engineering

ЗАМЕР

bench/dict/hash_collisions.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/data-structures/dict
Как запустить
python3.13 bench/dict/dict_bench.py

Запись прогона

Замеры для статьи «Словари изнутри»

Скрипт Что меряет
dict_bench.py раскладка dk_indices/dk_entries, ширина индекса, пороги роста, что делают удаления, режим строковых ключей
python3.13 bench/dict/dict_bench.py

Почему этот скрипт появился поздно и что он сразу поймал

Числа статьи считались вручную и скрипта не имели вовсе — то есть перезапустить их было нечем. Первая же сверка формулы с sys.getsizeof показала ошибку.

Ширина слота dk_indices — не всегда два байта. Она растёт ступенями вместе с таблицей, потому что в слот надо уместить номер записи:

dk_size байт на слот
≤ 255 1
≤ 65 535 2
≤ 4 294 967 295 4
дальше 8

В статье для таблицы на 65 536 слотов стояло 1 179 632 байта — это расчёт по двум байтам на слот. На 65 536 ширина уже четыре байта, и верное число — 1 310 704. Экономия против наивной таблицы там не «20–25 %», а 16,7 %: заявленные 20–25 % — про таблицы среднего размера (на dk_size = 256 получается 25,3 %), и с ростом индекса выигрыш сжимается.

Чтобы такое ловилось запуском, а не вычиткой, блок 1b скрипта восстанавливает getsizeof из формулы и печатает сравнение по шести размерам. Если формула разойдётся с интерпретатором, это будет видно сразу.

Чего здесь нет и почему

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

Память здесь меряется sys.getsizeof намеренно, вопреки общему правилу замеров: предмет статьи — размер самой хеш-таблицы, то есть ровно tp_basicsize + ... без того, на что словарь ссылается. Именно это getsizeof и возвращает, и именно это число видит читатель у себя в консоли.

hash_collisions.py — граница худшего случая

Добавлен 27.08.2026. В статье в полной глубине стоит «O(1) вместо O(n)» без оговорки о худшем случае. Оговорка нужна: O(1) у словаря — амортизированная средняя при равномерном хеше, а худший случай ровно O(n) и достижим пользовательским __hash__.

python3.13 bench/dict/hash_collisions.py

Скрипт меряет две разные по природе вещи и печатает их отдельно:

  • счётчик вызовов __eq__ на один поиск — точное число, не зависящее ни от машины, ни от сборки. Это и есть доказательство асимптотики;
  • время поиска — только как иллюстрация к счётчику, сравниваются строки одного прогона.

Важная деталь устройства пробы: ищется новый равный объект, а не тот же самый. CPython в lookdict сначала проверяет тождество и только потом вызывает __eq__; поиск того же объекта завершился бы на первом слоте при любом хеше и ничего бы не показал.

Запись прогона: 27.08.2026, Python 3.13.7, Xeon 2.80GHz, 2 vCPU

1. Сколько сравнений (__eq__) уходит на один поиск
      n |  плохой хеш: первый | плохой хеш: последний |  нормальный хеш: последний
------------------------------------------------------------------------------------
     16 |                   1 |                    16 |                          1
     64 |                   1 |                    64 |                          1
    256 |                   1 |                   256 |                          1
   1024 |                   1 |                  1024 |                          1
   4096 |                   1 |                  4096 |                          1

2. Время поиска последнего ключа, нс (иллюстрация к таблице выше)
      n |     плохой хеш |   нормальный хеш |  во сколько раз
--------------------------------------------------------------
     16 |         2051.5 |            263.5 |            7.8x
     64 |         7523.0 |            248.1 |           30.3x
    256 |        30287.2 |            264.7 |          114.4x
   1024 |       129091.8 |            267.6 |          482.5x
   4096 |       484452.8 |            266.5 |         1817.7x

3. Цена самого ключа при отсутствии коллизий, нс на поиск
  int                                        45.4
  str (кеш хеша в объекте)                   37.1
  объект без __hash__/__eq__ (по id)         35.5
  объект с __hash__/__eq__ на Python        143.6

Что показал прогон

Блок 1 — главный, и он без единой наносекунды. Число сравнений при плохом хеше равно числу ключей: 16 → 16, 4096 → 4096. При нормальном хеше — одно сравнение на любом размере. Это O(n) и O(1) буквально, счётчиком.

Асимметрия «первый — последний» объясняет, почему худший случай легко проглядеть: первый вставленный ключ находится за одно сравнение даже при полностью вырожденном хеше, потому что он лежит в начале цепочки проб. Плохой хеш проявляется на последнем вставленном.

Блок 2 подтверждает блок 1 по форме, а не по числу. Время плохого хеша при росте n в 256 раз выросло примерно в 236 раз (2051 → 484 452 нс на этом прогоне), время нормального — в 1,0 раза. Отношение внутри строки («во сколько раз») — то, что воспроизводится; абсолютные наносекунды привязаны к машине.

Блок 3 — цена протокола, а не коллизий. Здесь коллизий нет вовсе, и всё равно объект с __hash__/__eq__ на Python обходится вчетверо дороже объекта без них: у int и str хеширование — это код на C, у пользовательского класса — вызов функции Python на каждом поиске.

Строка int в этом прогоне вышла медленнее строки str (45,4 против 37,1 нс), и это не ошибка замера: словарь со строковыми ключами идёт по специализированной lookdict_unicode, а словарь с целыми — по общей lookdict. Плюс у str хеш посчитан один раз и лежит в самом объекте.

Чего в скрипте нет

Времени вставки и роста таблицы — это в dict_bench.py. Здесь только поиск.

Числа блоков 1 и 2 сняты на классах со счётчиком внутри __hash__/__eq__, то есть завышены на цену инкремента; сравнивать их с блоком 3 (там счётчиков нет) нельзя — сравнивать можно только внутри блока.

Скрипт

185 строк
"""Граница худшего случая у `dict`: что стоит за «`O(1)` вместо `O(n)`».

`O(1)` у словаря — амортизированная средняя при равномерном хеше. Худший
случай — `O(n)`, и он не гипотетический: достаточно `__hash__`, который для
всех ключей возвращает одно и то же. Тогда все ключи садятся в одну цепочку
проб, и поиск последнего вставленного ключа перебирает всю цепочку.

Скрипт меряет две вещи:

  * **точно** — сколько раз вызвался `__eq__` на один поиск. Это счётчик, а не
    время: число не зависит ни от машины, ни от сборки;
  * **как иллюстрацию** — время поиска. Сравниваются только строки одного
    прогона (правило замеров репозитория).

Плюс цена пользовательских `__hash__`/`__eq__` против встроенных ключей.

    python3.13 bench/dict/hash_collisions.py
"""
import sys
import timeit


class Counter:
    eq_calls = 0
    hash_calls = 0

    @classmethod
    def reset(cls):
        cls.eq_calls = 0
        cls.hash_calls = 0


class Colliding:
    """Намеренно плохой `__hash__`: все объекты — в одну корзину."""

    __slots__ = ("i",)

    def __init__(self, i):
        self.i = i

    def __hash__(self):
        Counter.hash_calls += 1
        return 0

    def __eq__(self, other):
        Counter.eq_calls += 1
        return isinstance(other, Colliding) and self.i == other.i


class Spread:
    """Тот же класс, но хеш различает ключи."""

    __slots__ = ("i",)

    def __init__(self, i):
        self.i = i

    def __hash__(self):
        Counter.hash_calls += 1
        return hash(self.i)

    def __eq__(self, other):
        Counter.eq_calls += 1
        return isinstance(other, Spread) and self.i == other.i


def probe_counts(cls, n):
    """Сколько сравнений уходит на поиск первого и последнего ключа.

    Ищется **новый равный объект**, а не тот же самый: CPython сначала
    проверяет тождество (`startkey == key` идёт после `is`), и поиск того же
    объекта завершился бы на первом же слоте независимо от хеша.
    """
    d = {cls(i): i for i in range(n)}
    out = {}
    for label, i in (("первый вставленный", 0), ("последний вставленный", n - 1)):
        key = cls(i)
        Counter.reset()
        assert d[key] == i
        out[label] = (Counter.eq_calls, Counter.hash_calls)
    return out


def lookup_time_ns(cls, n, repeat=5):
    """Время одного поиска последнего вставленного ключа, нс."""
    d = {cls(i): i for i in range(n)}
    key = cls(n - 1)
    number = max(10, 200_000 // max(n, 1))
    best = min(timeit.repeat(lambda: d[key], number=number, repeat=repeat))
    return best / number * 1e9


def cost_of_key_kind():
    """Цена ключа: встроенные типы против пользовательских протоколов."""
    n = 1000
    cases = []

    d_int = {i: i for i in range(n)}
    last_int = n - 1                  # ключ вычисляется заранее: меряем поиск
    cases.append(("int", lambda: d_int[last_int]))

    keys_str = [f"key-{i}" for i in range(n)]
    d_str = {k: i for i, k in enumerate(keys_str)}
    last_str = keys_str[-1]
    cases.append(("str (кеш хеша в объекте)", lambda: d_str[last_str]))

    class Plain:
        __slots__ = ("i",)

        def __init__(self, i):
            self.i = i

    objs = [Plain(i) for i in range(n)]
    d_obj = {o: i for i, o in enumerate(objs)}
    last_obj = objs[-1]
    cases.append(("объект без __hash__/__eq__ (по id)", lambda: d_obj[last_obj]))

    class PyHash:
        __slots__ = ("i",)

        def __init__(self, i):
            self.i = i

        def __hash__(self):
            return hash(self.i)

        def __eq__(self, other):
            return isinstance(other, PyHash) and self.i == other.i

    pyobjs = [PyHash(i) for i in range(n)]
    d_py = {o: i for i, o in enumerate(pyobjs)}
    probe_py = PyHash(n - 1)          # новый равный объект: тождество не спасёт
    cases.append(("объект с __hash__/__eq__ на Python", lambda: d_py[probe_py]))

    out = []
    for label, fn in cases:
        number = 200_000
        best = min(timeit.repeat(fn, number=number, repeat=5))
        out.append((label, best / number * 1e9))
    return out


def main():
    print("PY", sys.version.split()[0])
    print("Числа времени — только для сравнения строк внутри этого прогона.")
    print()

    print("1. Сколько сравнений (__eq__) уходит на один поиск")
    print(f"{'n':>7} | {'плохой хеш: первый':>19} | {'плохой хеш: последний':>21}"
          f" | {'нормальный хеш: последний':>26}")
    print("-" * 84)
    for n in (16, 64, 256, 1024, 4096):
        bad = probe_counts(Colliding, n)
        good = probe_counts(Spread, n)
        print(f"{n:>7} | {bad['первый вставленный'][0]:>19} |"
              f" {bad['последний вставленный'][0]:>21} |"
              f" {good['последний вставленный'][0]:>26}")
    print("  Плохой хеш: число сравнений равно числу ключей — это и есть O(n).")
    print("  Нормальный хеш: одно сравнение независимо от размера — O(1).")
    print()

    print("2. Время поиска последнего ключа, нс (иллюстрация к таблице выше)")
    print(f"{'n':>7} | {'плохой хеш':>14} | {'нормальный хеш':>16} | {'во сколько раз':>15}")
    print("-" * 62)
    base_bad = base_good = None
    for n in (16, 64, 256, 1024, 4096):
        t_bad = lookup_time_ns(Colliding, n)
        t_good = lookup_time_ns(Spread, n)
        if base_bad is None:
            base_bad, base_good = t_bad, t_good
        print(f"{n:>7} | {t_bad:>14.1f} | {t_good:>16.1f} | {t_bad / t_good:>14.1f}x")
    print(f"  рост плохого хеша от n=16 к n=4096: x{lookup_time_ns(Colliding, 4096) / base_bad:.1f}")
    print(f"  рост нормального за то же           : x{lookup_time_ns(Spread, 4096) / base_good:.1f}")
    print()

    print("3. Цена самого ключа при отсутствии коллизий, нс на поиск")
    for label, ns in cost_of_key_kind():
        print(f"  {label:<38} {ns:8.1f}")
    print("  Пользовательский протокол оплачивается на каждом поиске: `__hash__`")
    print("  на Python — это вызов функции там, где у int и str его нет вовсе.")


if __name__ == "__main__":
    main()