Learning Platform
Глоссарий Troubleshooting
Урок 03.04 · 25 мин
Начальный
Amortized analysisDynamic arrayslist.append

Зачем понадобилась «амортизированная» сложность

Возьмём list.append(x) в Python. Большинство туториалов скажут «это O(1)». Это правда — но не всегда. Иногда конкретный вызов append работает медленно, и иногда даже сильно медленно. Тем не менее в среднем на длинной серии вызовов он действительно O(1). Эта «средняя по серии» характеристика и называется амортизированная сложность.

Конкретно: если вы делаете 1 миллион lst.append(x), подавляющее большинство вызовов отрабатывает за наносекунды. Но редкие вызовы (раз в ~100 операций) делают неожиданно дорогую работу — могут занять микросекунды. Если усреднить — всё равно получится O(1) на операцию. Это и есть амортизированный O(1).

Разница между worst-case и amortized

Worst-case vs Amortized

Worst-case — самая дорогая операция. Amortized — средняя за серию.

Worst-case O(n)Один вызов append может быть медленнымКогда массиву нужно расти — он копирует все n элементов в новый блок памяти
Amortized O(1)Усреднение N вызовов appendДорогие вызовы редкие — раз в ~n операций. Их стоимость распределяется на все остальные.
Это не противоречиеОба верны одновременноОдин append может быть O(n), но в серии N append-ов суммарная работа O(N), значит среднее O(1).

Почему так — устройство list

Python list — это динамический массив. Реально в памяти лежит сплошной блок с указателями. У этого блока есть два размера:

  • len — сколько элементов сейчас в списке (то, что возвращает len(lst)).
  • capacity — сколько ещё может вместить без перевыделения.

Когда вы делаете append, происходит:

если len < capacity:
    положить x на позицию [len]
    len += 1
    # O(1)
иначе (массив полон):
    выделить новый блок размером capacity * growth_factor
    скопировать все len элементов туда
    освободить старый блок
    capacity = новый размер
    положить x, len += 1
    # O(n)

«Иногда дорогой» append случается только когда массив полон. После такого расширения у массива снова много свободного места — следующие N-1 append-ов снова дешёвые.

В CPython growth factor примерно 1.125 для больших списков (точнее формула чуть сложнее). Это означает: после увеличения capacity у нас есть N/8 свободных слотов, в которые мы делаем дешёвые append-ы перед следующей переаллокацией.

Банковский метод на пальцах

Самый понятный способ понять амортизированный анализ — банковский метод (он же accounting method). Идея простая: каждой операции мы приписываем некоторую «амортизированную стоимость» (фиктивную плату) — чуть больше, чем её реальная работа. «Излишек» откладываем на «счёт» как кредит. Когда происходит дорогая операция — оплачиваем её из накопленного счёта.

Применим к append. Предположим, growth factor 2 (для простоты — каждое расширение удваивает capacity):

  1. Каждому append присваиваем амортизированную стоимость 3.
  2. Реальная стоимость дешёвого append — 1 (положить элемент).
  3. Излишек 2 — кладём на счёт элемента.

Когда массив капасити N полон и нужно расширение:

  • Реальная стоимость расширения = N (скопировать все элементы).
  • Но у нас уже есть N/2 элементов с накопленным счётом 2 каждый. Итого 2 * N/2 = N кредитов. Хватает.
  • Счёт обнуляется, capacity удваивается до 2N. Опять начинаем накапливать.

Итог: каждый append «стоит» нам 3 операции амортизированно. То есть O(1) per operation. Дорогая операция расширения полностью оплачена заранее предыдущими дешёвыми.

Пример роста list-а с growth factor 2

Каждое расширение копирует все элементы — но между расширениями много дешёвых append-ов.

append 1capacity 1, cost 1Первый append, пустой массив получает capacity 1
append 2capacity 2, cost 1+2=3Capacity 1 заполнен — расширяемся до 2, копируя 1 элемент
append 3capacity 4, cost 1+3=4Расширение до 4, копирование 2 элементов
append 4capacity 4, cost 1Дешёвый — есть место
append 5capacity 8, cost 1+5=6Расширение до 8, копирование 4 элементов
append 6-8capacity 8, cost 1+1+1=3Три дешёвых append-а подряд
append 9capacity 16, cost 1+9=10Расширение до 16, копирование 8 элементов
append 10-16cost 7Семь дешёвых append-ов
Итого 16 append-овtotal cost = 1+3+4+1+6+3+10+7 = 35Среднее 35/16 = 2.2 операции на append. Это и есть O(1) amortized.

Проверим эмпирически

Покажем «всплески» в реальном времени. Будем мерить время каждого отдельного append и смотреть, какие из них «дорогие»:

import time

def measure_appends(n):
    lst = []
    times = []
    for i in range(n):
        t0 = time.perf_counter_ns()
        lst.append(i)
        t1 = time.perf_counter_ns()
        times.append(t1 - t0)
    return times

times = measure_appends(100_000)

# Найдём 10 самых медленных вызовов и их индексы
slowest = sorted(enumerate(times), key=lambda p: -p[1])[:10]
for idx, t in slowest:
    print(f"append #{idx:>6}: {t:>6} ns")

# Среднее
print(f"\nMean: {sum(times) / len(times):.1f} ns")
print(f"Min:  {min(times)} ns")
print(f"Max:  {max(times)} ns")

Типичный вывод:

append #80000:   8500 ns
append #71000:   7100 ns
append #62000:   6300 ns
append #54000:   5500 ns
append #...      ...

Mean:   85 ns
Min:    40 ns
Max:  8500 ns

Видим: большинство append-ов работают за 40-100 ns. Но редкие — за тысячи ns. Эти всплески — точно те моменты, когда массив расширялся и копировал N элементов. Между всплесками — много-много дешёвых append-ов.

Среднее — 85 ns. Это и есть амортизированный O(1).

TIP

Запустите код выше у себя. Постройте график (matplotlib или просто print каждые 1000 append-ов). Вы увидите регулярные «зубцы» — это и есть моменты расширения. По их расположению можно прикинуть growth factor вашей версии CPython.

Полезно понять интуитивно, что дорогие операции редкие, но регулярные. Не «случайно медленный», а «закономерно медленный в момент расширения».

Capacity vs len в Python

Можно «подсмотреть» текущий capacity через sys.getsizeof. Размер списка — фиксированный header плюс capacity * 8 байт (указатели):

import sys

lst = []
prev_size = sys.getsizeof(lst)
for i in range(50):
    lst.append(i)
    sz = sys.getsizeof(lst)
    if sz != prev_size:
        # capacity = (sz - header) / 8
        # На 64-bit header обычно 56 байт
        capacity = (sz - 56) // 8
        print(f"len={len(lst):>3}, size={sz} bytes, capacity={capacity}")
        prev_size = sz

Типичный вывод (на Python 3.13):

len=  1, size=88 bytes, capacity=4
len=  5, size=120 bytes, capacity=8
len=  9, size=184 bytes, capacity=16
len= 17, size=248 bytes, capacity=24
len= 25, size=312 bytes, capacity=32
len= 33, size=376 bytes, capacity=40
len= 41, size=440 bytes, capacity=48

Видим: capacity растёт не точно в 2 раза. CPython использует формулу:

new_capacity = (current + (current >> 3) + 6) & ~3

(сдвиг на 3 = деление на 8, плюс 6, и округление к 4). Получается growth factor около 1.125 — экономнее, чем 2x в учебниках. Это и есть тот случай, когда реальная имплементация отличается от учебника, но амортизированный O(1) сохраняется — потому что суммарная работа всё равно линейна.

Не только append амортизируется

Амортизированный анализ работает и в других местах:

  • list.pop() (из конца) — O(1) amortized.
  • dict[key] = value — O(1) amortized (иногда нужна реаллокация хеш-таблицы).
  • set.add(x) — O(1) amortized.
  • str.join(list_of_str) — O(total length) amortized (внутри буфер растёт по той же логике).

А вот что не амортизируется:

  • list.insert(0, x) — каждый раз O(n), не амортизируется.
  • list.pop(0) — каждый раз O(n).
  • x in list — O(n) каждый раз.

Когда амортизированный анализ ломается

Главное «но» — амортизация работает для средней по серии, не для каждого вызова. Иногда это важно:

  • Realtime системы — если у вас latency-critical код, и нужна гарантия «каждая операция не дольше X микросекунд» — амортизированный O(1) не годится. Будет проседание в момент расширения.
  • GC-паузы — расширение list-а может триггерить garbage collection, что добавит непредсказуемое время.

В DE-задачах это редко проблема (мы про throughput, не про latency), но в системах вроде high-frequency trading или embedded — пользуются специальными структурами с гарантированно O(1) операциями (например, fixed-size ring buffer).

Шпаргалка по амортизированному анализу

Что нужно запомнить

Эта тема часто всплывает на собесах. Знание определения отделяет junior от middle.

Worst-case O(n)Самая дорогая отдельная операцияДля list.append — момент расширения
Amortized O(1)Усреднено по серии вызововДорогие редки, оплачены дешёвыми
Не амортизируетсяЕсли операция всегда дорогаяinsert(0) — каждый раз O(n), без 'удачных серий'
ПрименимостьКогда важен throughput, не latencyДля realtime нужна гарантия per-operation, а не average
Python listgrowth factor ~1.125, не 2xCPython экономит память на больших списках

В следующем уроке — грабли big-O, которые встречаются в реальном коде. Скрытые O(n) внутри встроенных операций, ловушки конкатенации строк, проблема копирования slices.

Python list как непрерывный массив: amortized O(1) append изнутри
Проверка знанийKnowledge check
В чём смысл фразы 'append амортизированно O(1)'? Почему её нельзя заменить на просто 'O(1)' или 'O(n) worst case'?
ОтветAnswer
'Просто O(1)' — неточно, потому что отдельный append может быть O(n) (момент расширения). 'O(n) worst case' — тоже неточно, потому что вводит в заблуждение: если бы каждый append стоил O(n), то 1M append-ов было бы O(n^2), что неверно. На самом деле сумма N append-ов всегда O(N), а не O(N^2) — благодаря тому, что дорогие операции редки и оплачены предыдущими дешёвыми. 'Amortized O(1)' точно описывает эту ситуацию: per-operation worst case может быть выше, но в серии N операций суммарная работа O(N), значит среднее на операцию — O(1).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что значит 'list.append амортизированно O(1)'?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5