Deep Engineering

MEASUREMENT

bench/collections/cost.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/builtin-collections
How to run it
python3.11 bench/collections/basics.py
python3.12 bench/collections/basics.py
python3.13 bench/collections/basics.py
python3.14 bench/collections/basics.py

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

python3.11 bench/collections/hashable.py
python3.12 bench/collections/hashable.py
python3.13 bench/collections/hashable.py
python3.14 bench/collections/hashable.py

python3.13 bench/collections/build.py
python3.14 bench/collections/build.py

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

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Встроенные коллекции»

Скрипт Что делает
basics.py наблюдения без времени: дубли, хешируемость, порядок словаря и множества (множество строк при трёх разных PYTHONHASHSEED), размер контейнера на 1000 целых, сводка «что умеет каждый»
cost.py цена x in коллекция на 10, 1 000 и 100 000 элементов и удаления из начала: list.pop(0) против deque.popleft()
practice.py источник ответов двух практических задач урока
hashable.py полный короткий пример урока: значение словаря может быть списком, ключ и элемент множества — нет
build.py цена сценария «построить set из списка и проверить» против проверок по самому списку, 1 и 100 проверок
python3.11 bench/collections/basics.py
python3.12 bench/collections/basics.py
python3.13 bench/collections/basics.py
python3.14 bench/collections/basics.py

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

python3.11 bench/collections/hashable.py
python3.12 bench/collections/hashable.py
python3.13 bench/collections/hashable.py
python3.14 bench/collections/hashable.py

python3.13 bench/collections/build.py
python3.14 bench/collections/build.py

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

Записи в runs/: без суффикса — CPython 3.13.7, остальные — с суффиксом версии (3.11.15, 3.12.3, 3.14.7). Числа cost.py и build.py — этой машины (2 ядра; build.py, hashable.py и разделы 6 basics.py сняты 14.10.2026 на linux/x86_64). Отношения между формами — наблюдение этой конфигурации: они зависят от размера и вида данных, версии и машины, и на своей машине их надо мерить заново. CPython 3.15 (вышел 9 октября 2026) здесь не запускался.

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

Дубли. set оставляет разные элементы, dict.fromkeys — тоже, но в порядке первого появления; в литерале словаря с повторённым ключом побеждает последнее значение.

Хешируемость. Требование — к элементам множества и к ключам словаря, а не к значениям: {"items": []} создаётся, а {[]: "x"} и {[1, 2]} падают (hashable.py, на 3.11–3.14). Список и множество не могут быть ни элементом множества, ни ключом словаря; кортеж может, но только если хешируемо всё, что в нём: {(1, [2])} падает. frozenset — может. На 3.14 текст ошибки называет роль объекта: cannot use 'list' as a set element (…).

Порядок. Словарь хранит порядок вставки; удалённый и вставленный снова ключ уходит в конец. Множество из пяти строк при PYTHONHASHSEED = 1, 2, 3 напечаталось в трёх разных порядках — на всех четырёх версиях одинаково. Множество маленьких целых часто печатается по возрастанию, потому что hash(n) == n, но не всегда: {8, 1} печатается как {8, 1}.

Память на 1000 целых (сам контейнер, 3.13.7): list 8056 байт, tuple 8040, set 32984, dict 36952 — множество в 4,1 раза больше списка. На 3.14.7 кортеж — 8048.

x in коллекция, элемент последний (медианы, 3.13.7; в каждое число входит цикл — пустой стоит 18,7 нс на шаг): на 10 элементах список 119,5 нс против 35,2 у множества — но если элемент первый, список находит его за 33,7 нс. На 1 000 — 9 690 нс против 44,9 (в 216 раз), на 100 000 — около миллисекунды против 79,4 нс. У множества и ключей словаря время почти не зависит от размера.

Удаление из начала (50 000 удалений из 100 000, 3.13.7): list.pop(0) — 12 662 нс на удаление, deque.popleft() — 42,4, list.pop() с конца — 43,7.

Память и вид ключей (раздел 6, одинаково на 3.11–3.14): dict на 1000 ключей int — 36952 байта, на 1000 ключей str — 26032; set — 32984 в обоих случаях. Записи словаря со строковыми ключами в CPython хранятся без поля под хеш (PyDictUnicodeEntry в Include/internal/pycore_dict.h).

Построить множество ради проверки (build.py, медианы, 3.13.7, нс на сценарий): на 10 элементах одна проверка по списку — 98,8, set(lst) плюс одна проверка — 211,1; сто проверок — 8 499,6 против 1 962,7. На 1 000: одна проверка — 4 638,7 против 7 926,6, сто — 596 511,4 против 10 674,0. Ради одной проверки множество не окупилось, ради ста — окупилось.

Script

125 lines
"""Цена двух операций, по которым выбирают коллекцию: поиск `in` и удаление из начала.

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

ЧТО МЕРЯЕТСЯ (нс на одну операцию):

  `x in коллекция` — искомый элемент последний в списке (худший случай для
  списка), размеры 10, 1 000 и 100 000; список, кортеж, множество, ключи
  словаря.

  удаление из начала — `lst.pop(0)` против `deque.popleft()` и против
  `lst.pop()` с конца, на коллекции из 100 000 элементов: каждая операция
  уменьшает коллекцию на один, поэтому меряется проход до половины длины.

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

Запускать: python3.13 / 3.14. Числа — этой машины; отношения — наблюдение
этой конфигурации, на другой машине и версии их надо мерить заново.
"""
import statistics
import sys
import time
from collections import deque

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

ROUNDS = 9


def clock(fn):
    """Время вызова. Если функция сама вернула время — берём его: так из
    замера исключается подготовка (создание коллекции)."""
    t0 = time.perf_counter()
    inner = fn()
    total = time.perf_counter() - t0
    return inner if inner is not None else total


def report(title, forms, per):
    samples = {name: [] for name, _ in forms}
    for _ in range(ROUNDS):
        for name, fn in forms:
            samples[name].append(clock(fn) / per * 1e9)
    print(title)
    print(f"   {'форма':<26}{'мин':>10}{'медиана':>10}{'макс':>10}")
    med = {}
    for name, _ in forms:
        xs = samples[name]
        med[name] = statistics.median(xs)
        print(f"   {name:<26}{min(xs):>10.1f}{med[name]:>10.1f}{max(xs):>10.1f}")
    return med


# ── 1: x in коллекция ────────────────────────────────────────────────────
print("1) x in коллекция: искомый элемент последний, нс на проверку")
REPS = 20_000
for size in (10, 1_000, 100_000):
    items = list(range(size))
    target = size - 1
    lst, tup, st, dct = list(items), tuple(items), set(items), dict.fromkeys(items)
    reps = REPS if size <= 1_000 else 200

    def mk(container, reps=reps, target=target):
        def run():
            for _ in range(reps):
                target in container
        return run

    def empty(reps=reps):
        for _ in range(reps):
            pass

    forms = [("пустой цикл", empty), ("list", mk(lst)), ("tuple", mk(tup)), ("set", mk(st)), ("dict (ключи)", mk(dct))]
    if size == 10:
        # На маленьком списке исход зависит от того, где элемент: первый
        # находится сразу, без хеширования.
        forms.insert(2, ("list, элемент первый", mk(lst, target=0)))
    med = report(f"   размер {size}:", forms, reps)
    print(f"   list / set по медианам: {med['list'] / med['set']:.1f}")
    print()

# ── 2: удаление из начала ────────────────────────────────────────────────
print("2) удаление из начала: 50 000 удалений из коллекции в 100 000, нс на удаление")
N = 100_000
K = 50_000


# Коллекция создаётся до засечки времени: меряется только цикл удалений.
def pop_front_list():
    lst = list(range(N))
    t0 = time.perf_counter()
    for _ in range(K):
        lst.pop(0)
    return time.perf_counter() - t0


def popleft_deque():
    dq = deque(range(N))
    t0 = time.perf_counter()
    for _ in range(K):
        dq.popleft()
    return time.perf_counter() - t0


def pop_back_list():
    lst = list(range(N))
    t0 = time.perf_counter()
    for _ in range(K):
        lst.pop()
    return time.perf_counter() - t0


med = report(
    "   (создание коллекции в замер не входит)",
    [("list.pop(0)", pop_front_list), ("deque.popleft()", popleft_deque), ("list.pop() с конца", pop_back_list)],
    K,
)
print(f"   list.pop(0) / deque.popleft() по медианам: {med['list.pop(0)'] / med['deque.popleft()']:.0f}")