Deep Engineering

ЗАМЕР

bench/complexity/basics.py

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

Цитируется в статье
/ru/interview/python/complexity
Как запустить
python3.11 bench/complexity/basics.py
python3.12 bench/complexity/basics.py
python3.13 bench/complexity/basics.py
python3.14 bench/complexity/basics.py

python3.13 bench/complexity/cost.py
python3.14 bench/complexity/cost.py

python3.13 bench/complexity/strkeys.py
python3.14 bench/complexity/strkeys.py

python3.11 bench/complexity/find.py
python3.12 bench/complexity/find.py
python3.13 bench/complexity/find.py
python3.14 bench/complexity/find.py

python3.11 bench/complexity/practice.py
python3.12 bench/complexity/practice.py
python3.13 bench/complexity/practice.py
python3.14 bench/complexity/practice.py

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

Замеры для урока «Сложность операций»

Скрипт Что делает
basics.py счёт без времени: сколько сравнений делают x in list, x in set и bisect; сколько раз список перевыделяет память на append; сколько сравнений у [x for x in a if x in b] со списком и множеством; сколько сравнений у sorted() на случайных, отсортированных и обратных данных; поиск в словаре, где у всех ключей один хеш
cost.py время на операцию при n = 1 000, 10 000 и 100 000: append, insert(0, x), x in list, x in set, sorted() на элемент
practice.py источник ответов двух практических задач урока
strkeys.py цена hash и == у строк длиной 10, 1 000 и 100 000 символов
find.py полный короткий пример урока: bisect_left даёт позицию вставки, «найден ли» — отдельная проверка
python3.11 bench/complexity/basics.py
python3.12 bench/complexity/basics.py
python3.13 bench/complexity/basics.py
python3.14 bench/complexity/basics.py

python3.13 bench/complexity/cost.py
python3.14 bench/complexity/cost.py

python3.13 bench/complexity/strkeys.py
python3.14 bench/complexity/strkeys.py

python3.11 bench/complexity/find.py
python3.12 bench/complexity/find.py
python3.13 bench/complexity/find.py
python3.14 bench/complexity/find.py

python3.11 bench/complexity/practice.py
python3.12 bench/complexity/practice.py
python3.13 bench/complexity/practice.py
python3.14 bench/complexity/practice.py

Записи в runs/: без суффикса — CPython 3.13.7, остальные — с суффиксом версии (3.11.15, 3.12.3, 3.14.7). Сравнения считают сами элементы — объекты, у которых __eq__ и __lt__ увеличивают счётчик; искомый элемент — новый объект, равный последнему, иначе Python узнал бы его по тождеству. Счёт сравнений — наблюдение CPython для этих объектов, а не контракт языка: при коллизиях хешей он другой (раздел 6). Числа cost.py и strkeys.py — этой машины (2 ядра; разделы 6–7 basics.py и strkeys.py сняты 14.10.2026 на linux/x86_64); отношения зависят от данных, версии и машины, у себя их надо мерить заново. CPython 3.15 (вышел 9 октября 2026) здесь не запускался.

Что получилось

Поиск (одинаково на 3.11–3.14): на 100 000 элементах x in list — 100 000 сравнений, x in set — 1 (у Key без коллизий), bisect — 17. bisect по неотсортированному списку возвращает неверный индекс без ошибки.

append: ёмкости 4, 8, 16, 24, 32, 40, 52, 64, 76, 92, … — как в комментарии в Objects/listobject.c; на 1 000 / 10 000 / 100 000 добавлений — 28 / 47 / 66 перевыделений.

Скрытый квадрат: [x for x in a if x in b] без общих элементов — 10 000 сравнений при n = 100 и 1 000 000 при n = 1 000, если b список; 0, если множество.

sorted(): на случайных данных (зерно 42) — 8 655 / 120 184 / 1 531 824 сравнений при n = 1 000 / 10 000 / 100 000 на 3.13 и 3.14; на 3.11 и 3.12 — 8 632 / 119 836 / 1 529 011. lg(n!) — 8 529 / 118 458 / 1 516 704. На отсортированных и строго убывающих данных — n − 1 на всех версиях.

Словарь с одинаковыми хешами: поиск последнего ключа — n сравнений (10 / 100 / 1 000) против одного при обычных хешах.

Множество с одним хешем на всех (раздел 6, одинаково на 3.11–3.14): поиск последнего — 10 / 105 / 1 336 / 11 672 вызовов __eq__ при n = 10 / 100 / 1 000 / 10 000 (хеш 1); с хешем 0 при n = 100 — 114. Построение — 45 / 5 982 / 605 201 / 57 720 235. Найденный по тождеству элемент — 0 вызовов __eq__.

bisect и «нашли» (раздел 7): bisect_left возвращает позицию вставки; для 77 777 в списке чётных — позиция 38 889 и __eq__ → False; для 200 001 — позиция 100 000, равная длине, сравнивать не с чем. Полный короткий пример того же — find.py: для [2, 4, 6, 8] у 6 и 5 позиция одна — 2, contains даёт True и False.

Строковые ключи (strkeys.py, медианы, 3.13.7, нс): hash новой строки длиной 10 / 1 000 / 100 000 — 35,3 / 431,8 / 29 768,6; a == b у равных разных объектов — 21,3 / 36,0 / 2 567,6. Повторный hash той же строки — около 30–35 при любой длине: хеш запоминается в объекте.

Время (медианы, 3.13.7, нс на операцию при n = 1 000 / 10 000 / 100 000): append 35,1 / 26,3 / 28,4; insert(0, x) 308,2 / 2 242,9 / 19 988,0; x in list 5 332,6 / 52 136,7 / 643 060,2; x in set 25,4 / 27,2 / 26,2; sorted() на элемент 106,7 / 127,0 / 220,3.

Скрипт

205 строк
"""Сложность операций: O-нотация, посчитанная, а не заученная.

Времени здесь нет — только счёт. O-нотация говорит не о секундах, а о том,
как растёт объём работы с ростом n. Работу проще всего посчитать прямо:
сколько раз Python сравнил два элемента. Для этого элементы — объекты
класса Key, который считает вызовы своих __eq__ и __lt__. Время — в cost.py.

СЕМЬ СЮЖЕТОВ:

1. ПОИСК. `x in list` сравнивает элементы по очереди: n сравнений, если
   элемент последний. `x in set` находит элемент по хешу: у Key без
   коллизий — одно сравнение на всех проверенных n (это наблюдение для
   этого набора, а не свойство множества — см. раздел 6). `bisect` по
   отсортированному списку — около log2(n).

2. APPEND. Список выделяет память с запасом, поэтому перевыделений на
   тысячи append — десятки, а не тысячи. Отсюда «амортизированное O(1)».

3. СКРЫТЫЙ КВАДРАТ. `[x for x in a if x in b]` — цикл внутри цикла, если
   b — список: n × n сравнений. Тот же код с b-множеством — ноль сравнений
   сверх хешей.

4. СОРТИРОВКА. sorted() на случайных данных делает около n·log2(n)
   сравнений — близко к lg(n!), теоретическому минимуму сортировки
   сравнениями в среднем, — а на уже отсортированных — ровно n − 1.

5. ХУДШИЙ СЛУЧАЙ СЛОВАРЯ. Если у всех ключей одинаковый хеш, поиск по
   ключу перестаёт быть O(1) и сравнивает ключи по очереди.

6. ХУДШИЙ СЛУЧАЙ МНОЖЕСТВА. То же для set: при одном хеше на всех поиск
   вызывает __eq__ порядка n раз (и больше n — множество проверяет часть
   ячеек повторно), а построение — порядка n²/2. Найденный по тождеству
   элемент __eq__ не вызывает вовсе. Счётчики — наблюдение CPython, не
   контракт языка.

7. BISECT И «НАШЛИ». bisect_left возвращает позицию вставки; найден ли
   элемент, решает отдельная проверка границы и равенства после неё.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import bisect
import math
import random
import sys

print("PY", sys.version.split()[0])
print()

COUNT = {"eq": 0, "lt": 0}


class Key:
    """Целое число, которое считает, сколько раз его сравнили."""

    __slots__ = ("v",)

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

    def __eq__(self, other):
        COUNT["eq"] += 1
        return self.v == other.v

    def __lt__(self, other):
        COUNT["lt"] += 1
        return self.v < other.v

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


def counted(fn):
    COUNT["eq"] = COUNT["lt"] = 0
    fn()
    return COUNT["eq"] + COUNT["lt"]


# ── 1 ─────────────────────────────────────────────────────────────────────
print("1) поиск: сколько сравнений, искомый элемент последний")
print(f"   {'n':>7}{'x in list':>12}{'x in set':>10}{'bisect':>8}{'log2(n)':>9}")
for n in (10, 100, 1_000, 10_000, 100_000):
    lst = [Key(i) for i in range(n)]
    st = set(lst)
    # Искомый — новый объект, равный последнему: иначе Python узнал бы его
    # по тождеству и не стал бы вызывать __eq__.
    target = Key(n - 1)
    c_list = counted(lambda: target in lst)
    c_set = counted(lambda: target in st)
    c_bis = counted(lambda: bisect.bisect_left(lst, target))
    print(f"   {n:>7}{c_list:>12}{c_set:>10}{c_bis:>8}{math.log2(n):>9.1f}")
unsorted = [5, 1, 4, 2, 3]
print(f"   bisect на неотсортированном {unsorted}, ищем 1: индекс {bisect.bisect_left(unsorted, 1)},"
      f" а 1 стоит на месте {unsorted.index(1)} — ошибки нет")
print()

# ── 2 ─────────────────────────────────────────────────────────────────────
print("2) append: сколько раз список перевыделял память")
EMPTY = sys.getsizeof([])
lst = []
capacities = []
last = sys.getsizeof(lst)
reallocs = {}
for i in range(1, 100_001):
    lst.append(i)
    size = sys.getsizeof(lst)
    if size != last:
        capacities.append((size - EMPTY) // 8)
        last = size
    if i in (1_000, 10_000, 100_000):
        reallocs[i] = len(capacities)
print("   первые ёмкости (мест под элементы):", capacities[:12])
for n, r in reallocs.items():
    print(f"   {n:>7} append — перевыделений: {r}")
print()

# ── 3 ─────────────────────────────────────────────────────────────────────
print("3) скрытый квадрат: [x for x in a if x in b], общих элементов нет")
print(f"   {'n':>7}{'b — список':>14}{'b — множество':>16}")
for n in (100, 1_000):
    a = [Key(i) for i in range(n)]
    b_list = [Key(i) for i in range(n, 2 * n)]
    b_set = set(b_list)
    c_list = counted(lambda: [x for x in a if x in b_list])
    c_set = counted(lambda: [x for x in a if x in b_set])
    print(f"   {n:>7}{c_list:>14}{c_set:>16}")
print()

# ── 4 ─────────────────────────────────────────────────────────────────────
print("4) sorted(): сколько сравнений")
print(f"   {'n':>7}{'случайные':>11}{'lg(n!)':>9}{'n·log2(n)':>11}{'отсортированные':>17}{'обратные':>10}")
rng = random.Random(42)
for n in (1_000, 10_000, 100_000):
    data = [Key(i) for i in range(n)]
    shuffled = data[:]
    rng.shuffle(shuffled)
    c_rand = counted(lambda: sorted(shuffled))
    c_sorted = counted(lambda: sorted(data))
    c_rev = counted(lambda: sorted(data[::-1]))
    lg_fact = round(math.lgamma(n + 1) / math.log(2))
    print(f"   {n:>7}{c_rand:>11}{lg_fact:>9}{round(n * math.log2(n)):>11}{c_sorted:>17}{c_rev:>10}")
print()

# ── 5 ─────────────────────────────────────────────────────────────────────
print("5) словарь, у всех ключей один и тот же хеш: сравнений на поиск последнего ключа")


class SameHash(Key):
    __slots__ = ()

    def __hash__(self):
        return 1


for n in (10, 100, 1_000):
    normal = {Key(i): i for i in range(n)}
    bad = {SameHash(i): i for i in range(n)}
    c_normal = counted(lambda: normal[Key(n - 1)])
    c_bad = counted(lambda: bad[SameHash(n - 1)])
    print(f"   n = {n:>5}: обычные хеши — {c_normal}, один хеш на всех — {c_bad}")
print()

# ── 6 ─────────────────────────────────────────────────────────────────────
# Добавлено 14.10.2026 по внешнему аудиту (PY-CMP-01): «одно сравнение у
# x in set» в разделе 1 — наблюдение для Key без коллизий, а не свойство
# множества. Здесь то же множество, но у всех элементов один хеш.
print("6) множество, у всех элементов один и тот же хеш: вызовов __eq__")
print(f"   {'n':>7}{'обычные хеши':>14}{'один хеш: поиск':>17}{'один хеш: построение':>22}")
for n in (10, 100, 1_000, 10_000):
    normal = set(Key(i) for i in range(n))
    COUNT["eq"] = 0
    bad = set(SameHash(i) for i in range(n))
    c_build = COUNT["eq"]
    c_normal = counted(lambda: Key(n - 1) in normal)
    c_bad = counted(lambda: SameHash(n - 1) in bad)
    print(f"   {n:>7}{c_normal:>14}{c_bad:>17}{c_build:>22}")


class ZeroHash(Key):
    __slots__ = ()

    def __hash__(self):
        return 0


zero = set(ZeroHash(i) for i in range(100))
print("   тот же опыт с хешем 0 вместо 1, n = 100: поиск —", counted(lambda: ZeroHash(99) in zero))
same_obj = [Key(i) for i in range(100)]
same_set = set(same_obj)
print("   обычные хеши, искомый — тот же объект, а не равный ему: поиск —",
      counted(lambda: same_obj[-1] in same_set))
print()

# ── 7 ─────────────────────────────────────────────────────────────────────
# bisect_left возвращает позицию вставки, а не ответ «нашли»: найден ли
# элемент, решает отдельная проверка границы и равенства после неё.
print("7) bisect: позиция вставки ещё не «нашли» — нужна проверка после неё")
evens = [Key(2 * i) for i in range(100_000)]
for x in (199_998, 77_777, 200_001):
    COUNT["eq"] = COUNT["lt"] = 0
    i = bisect.bisect_left(evens, Key(x))
    c_lt = COUNT["lt"]
    found = i < len(evens) and evens[i] == Key(x)
    print(f"   ищем {x:>7} в 0, 2, 4, …, 199998: позиция {i:>6}, __lt__ — {c_lt},"
          f" __eq__ — {COUNT['eq']}, найден: {found}")