1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9

# теория · шаг 3 из 9

Почему амортизация ломается, когда к старой версии возвращаются

Коротко

Амортизированная оценка из раздела 3 молча предполагает, что структуру используют цепочкой: каждая операция работает с результатом предыдущей, и у каждого состояния одно будущее. Тогда дорогую операцию оплачивают накопленные раньше дешёвые. Персистентность это ломает: к одной версии можно вернуться сколько угодно раз, и дорогая операция повторится в каждом будущем, а «накопления» были одни. Очередь из двух стеков в модели урока: 100 извлечений цепочкой стоят 99 шагов, 100 извлечений из одной и той же версии - 9900. Метод предоплаты тратит кредит дважды, метод потенциалов использует одно состояние дважды - оба неприменимы.

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

Как думаешь, сколько это стоит

Очередь из двух стеков из раздела 4: кладём в «зад», берём из «переда», а когда перед пуст - переливаем в него перевёрнутый зад. Амортизированно - O(1) на операцию, это доказано методом потенциалов. Теперь стеки персистентные, узлы неизменяемые. Положили 100 элементов, получили версию q. Сто раз подряд извлекаем первый элемент - каждый раз из той же версии q. Сколько это стоит в сумме?

Ответь себе до чтения. «Сто, амортизированно по единице» - неверно: разбор покажет 9900.

На что это похоже

Копилка на крупную покупку. Каждую неделю бросаешь по сотне, и через год хватает на холодильник: дорогая покупка оплачена дешёвыми взносами. Теперь у тебя машина времени: можно вернуться в день, когда копилка полна, и купить холодильник ещё раз. И ещё. Покупок сколько угодно, а копилка одна - деньги в ней были на один холодильник. Амортизация - это копилка; персистентность - машина времени.

Механизм: у версии много будущих

Вспомним, что доказывали методы раздела 3.

Предоплата (метод банкира). Дешёвые операции переплачивают, кредит лежит на элементах; дорогая операция тратит кредит. Требование: каждая монета тратится один раз. В очереди каждый элемент зада несёт монету на будущий переворот. Первое извлечение из q тратит все монеты зада. Второе извлечение из той же q снова переворачивает тот же зад. Монеты - не данные очереди, а запись в доказательстве: сама q не изменилась, но доказательство уже засчитало её монеты первому будущему, и второму платить нечем.

Потенциалы (метод физика). Амортизированная цена = настоящая + изменение потенциала Φ от состояния до операции к состоянию после. Сумма телескопически сокращается - но только если каждая следующая операция начинается с результата предыдущей. Если начать снова с q, потенциал q используется дважды, и сокращения нет.

Окасаки в «Чисто функциональных структурах данных» (глава 5.6) показывает именно этот пример: обычные амортизированные структуры при персистентном использовании теряют свои оценки. Худшие оценки при этом живут: операция, которая в худшем случае O(log n), остаётся такой в любом будущем.

Правило

Амортизированная оценка верна только для цепочки операций, где каждая берёт результат предыдущей. Персистентный доступ даёт версии много будущих, и дорогая операция повторяется в каждом; накопленный кредит и потенциал одни на всех. Персистентной структуре нужны либо оценки в худшем случае, либо особая амортизация, переживающая повторы, - ленивая, со следующего урока.

Разбор: очередь из двух стеков

Стеки - персистентные, из прошлых уроков: узлы в общих списках, версия очереди - пара вершин. Считаем работу - сколько узлов создано, в том числе при перевороте; остальные шаги дешёвые и тут не считаются.

# очередь из двух неизменяемых стеков: перед (откуда берём) и зад (куда
# кладём); узлы стеков - в общих списках, версия - [перед, зад]
znach = []
dalshe = []
rabota = [0]

def polozhit(verh, x):
    znach.append(x)
    dalshe.append(verh)
    rabota[0] = rabota[0] + 1
    return len(znach) - 1

def perevernut(verh):
    out = -1
    while verh != -1:
        out = polozhit(out, znach[verh])
        verh = dalshe[verh]
    return out

def proverit(pered, zad):
    # перед пуст - переливаем зад: дорогой шаг
    if pered == -1:
        return [perevernut(zad), -1]
    return [pered, zad]

def v_konec(q, x):
    return proverit(q[0], polozhit(q[1], x))

def bez_pervogo(q):
    # вызывают только у непустой очереди
    return proverit(dalshe[q[0]], q[1])

n = 100
q = [-1, -1]
for x in range(n):
    q = v_konec(q, x)
print("после", n, "вставок перед - 1 элемент, зад -", n - 1)

rabota[0] = 0
v = q
for i in range(n):                  # обычное использование: по цепочке
    v = bez_pervogo(v)
print("цепочкой", n, "раз bez_pervogo - работа", rabota[0])

rabota[0] = 0
for i in range(n):                  # персистентно: всё время от версии q
    v = bez_pervogo(q)
print("от одной версии", n, "раз - работа", rabota[0])
после 100 вставок перед - 1 элемент, зад - 99
цепочкой 100 раз bez_pervogo - работа 99
от одной версии 100 раз - работа 9900

Первая вставка попала в пустой перед, остальные 99 - в зад. Цепочкой: первое извлечение опустошило перед и перевернуло 99 элементов, следующие 99 извлечений - бесплатные. Итого 99 на 100 операций, меньше единицы на операцию, как и обещала амортизация. От одной версии: каждое из ста извлечений застаёт тот же пустой после извлечения перед и тот же зад из 99 - и переворачивает его заново. 9900 = 100 · 99: каждая операция стоит O(n), суммарно - квадрат.

Разбор: двоичный счётчик

Счётчик из раздела 3: прибавить единицу - перевернуть хвост из единиц и один ноль. Амортизированно - меньше двух переворотов на прибавление. Сравним цепочку и повторные прибавления к одной «неудобной» версии.

def perevernut_bity(x):
    # прибавить 1 к двоичному счётчику: сколько битов перевернётся
    bitov = 1
    while x & 1:
        bitov = bitov + 1
        x = x >> 1
    return bitov

vsego = 0
for x in range(1024):               # счётчик по цепочке: 0, 1, ..., 1023
    vsego = vsego + perevernut_bity(x)
print("1024 прибавления по цепочке:", vsego, "переворотов")

vsego = 0
for i in range(1024):               # 1024 раза от одной версии 1023
    vsego = vsego + perevernut_bity(1023)
print("1024 прибавления к версии 1023:", vsego, "переворотов")
1024 прибавления по цепочке: 2047 переворотов
1024 прибавления к версии 1023: 11264 переворотов

Цепочкой - 2047, ровно 2n − 1. Версия 1023 - это десять единиц подряд, каждое прибавление к ней переворачивает 11 битов, и 1024 повтора стоят 11 264. Здесь проигрыш - логарифм, а не n: самая дорогая операция счётчика стоит log₂ n + 1 переворот, то есть O(log n). Граница правила: персистентность превращает амортизированную оценку в худшую, а не во что-то ещё более страшное. Насколько это плохо, зависит от того, сколько стоит худшая операция структуры.

Что ломается без этого

Отмена действий поверх амортизированной структуры. Редактор хранит состояния документа в очередях-из-двух-стеков, пользователь отменяет и повторяет одну и ту же операцию - и каждый раз платит полный переворот.

Перебор с возвратом. Поиск ветвится от одного состояния: каждая ветка продолжает ту же версию. Если в состоянии лежит структура с отложенным дорогим шагом, его выполнит каждая ветка.

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

Зачем это в работе

Функциональные языки (Haskell, OCaml, Clojure, Scala) держат все коллекции персистентными, поэтому их стандартные очереди и кучи опираются либо на худшие оценки, либо на ленивую амортизацию Окасаки. В императивном коде ловушка та же, только незаметнее: «снимок» объекта с отложенной работой внутри (ленивый кеш, буфер, ожидающий сброса) при ветвлении выполнит эту работу в каждой ветке.

Теперь сам

  1. Очередь: перед из 1 элемента, зад из 999. Сколько стоят 50 извлечений цепочкой и 50 извлечений из этой же версии?
  2. Какое требование метода предоплаты нарушает повторный доступ к версии?
  3. Почему оценка в худшем случае не ломается при персистентности?

Ответ. 1. Цепочкой - 999: один переворот, дальше бесплатно. От версии - 50 · 999 = 49 950.

  1. Каждая монета кредита тратится один раз; у версии с кредитом много будущих, и каждое хочет его потратить.

  2. Худшая оценка - про каждую операцию отдельно, без займов у прошлого: сколько бы раз операцию ни повторили, каждый раз она не дороже границы.

Главное

Амортизация - это займ у прошлого, а персистентность позволяет потратить одно прошлое много раз. Поэтому оценки раздела 3 верны только для цепочки операций; персистентной структуре нужны худшие оценки или амортизация, которая переживает повторы.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

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