За последние пятнадцать лет амортизация стала мощным инструментом в построении и анализе структур данных. Реализации с амортизированными характеристиками производительности часто оказываются проще и быстрее, чем реализации со сравнимыми жёсткими характеристиками. ### Методы амортизированного анализа Имея последовательность операций, мы можем интересоваться временем, которое отнимает вся эта последовательность, однако при этом нам может быть безразлично время каждой отдельной операции. Например, имея $n$ операций, мы можем желать, чтобы время всей последовательности было ограничено показателем `O(n)`, не настаивая, чтобы каждая из этих операций происходила за время `O(1)`. Нас может устраивать, чтобы некоторые из операций занимали `O(log n)` или даже `O(n)`, при условии, что общая стоимость всей последовательности будет `O(n)`. Такая дополнительная степень свободы открывает широкое пространство возможностей при проектировании, и часто позволяет найти более простые и быстрые решения, чем варианты с аналогичными жёсткими ограничениями. Чтобы доказать, что соблюдается амортизированное ограничение, нужно определить амортизированную стоимость для каждой операции, и доказать, что для любой последовательности операций общая амортизированная стоимость является верхней границей общей реальной стоимости ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/CodeCogsEqn.png) где `ai`- амортизированная стоимость операции `i`, `t_i` - ее реальная стоимость, а `m` - общее число операций. Обычно доказывается несколько более сильный результат: что на любой промежуточной стадии в последовательности операций общая текущая амортизированная стоимость является верхней границей для общей текущей реальной стоимости. Амортизация позволяет некоторым операциям быть дороже, чем их амортизированная стоимость. Такие операции называются **дорогими(expensive)**. Операции, для которых амортизированная стоимость превышает реальную, называются **дешевыми(cheap)**. Дорогие операции уменьшают текущие накопления, а дешевые их увеличивают. Главное при доказательстве амортизированных характеристик стоимости - показать, что дорогие операции случаются только тогда, когда текущих накоплений хватает, чтобы покрыть их дополнительную стоимость. Существует два метода для анализа амортизированных структур данных: 0. метод банкира 0. метод физика В методе банкира текущие накопления представляются как кредит(credits), привязанный к определенным ячейкам структуры данных. Этот кредит используется, чтобы расплатиться за будущие операции доступа к этим ячейкам. Амортизированная стоимость операции определяется как ее реальная стоимость плюс размер кредита, выделяемого этой операцией, минус размер кредита, который она расходует ``` ai = ti + ci − c ̄ i ``` де `c_i`- размер кредита, выделяемого операцией `i`, а `c ̄ ` - размер кредита, расходуемого операцией `i`. Каждая единица кредита должна быть выделена, прежде чем израсходована, и нельзя расходовать кредит дважды. В методе физика определяется функция `Phi`, отображающая всякий объект `d` на действительное число, называемое его потенциалом(potential). Потенциал обычно выбирается так, чтобы изначально равняться нулю и оставаться неотрицательным. В таком случае потенциал представляет нижнюю границу текущих накоплений. Пусть объект `di` будет результатом операции `i` и аргументом операции `i+1`. Тогда амортизированная стоимость операции `i` определяется как сумма реальной стоимости и изменения потенциалов между `di-1` и `di`, т.е., ``` ai = ti + Phi(di) - Phi(di-1) ``` ### Очереди Самая распространенная чисто функциональная реализация очередей представляет собой пару списков, `f` и `r`, где `f` содержит головные элементы очереди в правильном порядке, а r состоит из хвостовых элементов в обратном порядке. Например, очередь, содержащая целые числа 1...6, может быть представлена списками `f=[1,2,3]` и `r=[6,5,4]`. Это представление можно описать следующим типом: ``` type alpha Queue = alpha list x alpha list ``` В этом представлении голова очереди - первый элемент `f`, так что функции `head` и `tail` возвращают и отбрасывают этот элемент, соответственно. ``` fun head (x :: f, r) = x fun tail (x :: f, r) = f ``` Подобным образом, хвостом очереди является первый элемент `r`, так что `snoc` добавляет к `r` новый элемент. ``` fun snoc ((f,r), x) = (f, x :: r) ``` Элементы добавляются к `r` и убираются из `f`, так что они должны как-то переезжать из одного списка в другой. Этот переезд осуществляется путем обращения `r` и установки его на место`f` всякий раз, когда в противном случае `f` оказался бы пустым. Одновременно `r` устанавливается в `[]`. Наша цель - поддерживать инвариант, что список `f` может быть пустым только в том случае, когда список `r` также пуст (т.е., пуста вся очередь). Заметим, что если бы `f` был пустым при непустом `r`, то первый элемент очереди находился бы в конце `r`, и доступ к нему занимал бы `O(n)` времени. Поддерживая инвариант, мы гарантируем, что функция `head` всегда может найти голову очереди за `O(1)` времени. ### Расширяющиеся деревья}{splay trees} Возможно, самая известная и успешно применяемая амортизированная структура данных. Расширяющиеся деревья являются ближайшими родственниками двоичных сбалансированных деревьев поиска, но они не хранят никакую информацию о балансе явно. Вместо этого каждая операция перестраивает дерево при помощи некоторых простых преобразований, которые имеют тенденцию увеличивать сбалансированность. Несмотря на то, что каждая конкретная операция может занимать до `O(n)` времени, амортизированная стоимость ее не превышает `O(log n)`. Важное различие между расширяющимися деревьями и сбалансированными двоичными деревьями поиска вроде красно-чёрных деревьев из состоит в том, что расширяющиеся деревья перестраиваются даже во время запросов (таких, как `member`), а не только во время обновлений (таких, как `insert`). Это свойство мешает использованию расширяющихся деревьев для реализации абстракций вроде множеств или конечных отображений в чисто функциональном окружении, поскольку приходилось бы возвращать в запросе новое дерево наряду с ответом на запрос. Однако в некоторых абстракциях операции-запросы достаточно ограничены, чтобы эту проблему можно было обойти. Хорошим примером служит абстракция кучи, поскольку здесь единственным интересным запросом является `findMin`. Как мы увидим, расширяющиеся деревья дают нам отличную реализацию кучи. Представление расширяющихся деревьев идентично представлению несбалансированных двоичных деревьев поиска. ``` datatype Tree = E | T of Tree × Elem.T × Tree ``` Однако в отличие от несбалансированных двоичных деревьев поиска, мы позволяем дереву содержать повторяющиеся элементы. Эта разница не является фундаментальным различием расширяющихся деревьев и несбалансированных двоичных деревьев поиска; она просто отражает отличие абстракции множества от абстракции кучи. Рассмотрим следующую стратегию реализации для`insert`: разобьем существующее дерево на два поддерева, чтобы одно содержало все элементы, меньше или равные новому, а второе все элементы, большие нового. Затем породим новый узел из нового элемента и двух этих поддеревьев. В отличие от вставки в обыкновенное двоичное дерево поиска, эта процедура добавляет элемент как корень дерева, а не как новый лист. Однако при таком решении не делается никакой попытки перестроить дерево, добиваясь лучшего баланса. Вместо этого мы применяем простую эвристику для перестройки: каждый раз, пройдя по двум левым ветвям подряд, мы проворачиваем два пройденных узла. ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/sd3.png В сущности, в этом и состоит принцип расширяющихся деревьев: нужно перестраивать путь поиска так, чтобы глубина каждого лежащего на пути узла уменьшалась примерно вполовину. ### Парные кучи(pairing heaps) одна из тех структур, которые сводят специалистов с ума. С одной стороны, их легко реализовать и они весьма хорошо показали себя на практике. С другой стороны, провести их полный анализ не удается уже более 10 лет! Парные кучи представляют собой упорядоченные по принципу кучи деревья с переменной степенью ветвления; их можно определить следующим типом данных: ``` datatype Heap = E | T of Elem.T × Heap list ``` Мы считаем правильными только такие деревья, где `E` никогда не встречается в качестве ребенка вершины `T`. Парные деревья называются именно так благодаря операции `deleteMin`. Эта операция отбрасывает корень, а затем сливает деревья в два прохода. Первый проход сливает деревья парами слева направо (т.е., первое дерево сливается со вторым, третье с четвертым и т.д.). При втором проходе получившиеся деревья сливаются справа налево. ## Избавление от амортизации Чаще всего нас не интересует, являются ли ограничения, соблюдаемые структурой, жёсткими или амортизированными; основные критерии для выбора одной структуры данных вместо другой - общая эффективность и простота реализации (возможно, ещё наличие исходного кода). Однако в некоторых прикладных областях оказывается важно ограничить время выполнения отдельных операций, а не их последовательностей. В этих случаях структура с жёсткими ограничениями часто предпочтительнее структуры с амортизированными характеристиками, даже если амортизированная структура в целом проще и быстрее. * Системы реального времени. В системах реального времени предсказуемость важнее голой скорости. Если из-за дорогой операции система пропустит жёсткий предельный срок, неважно будет, сколько дешёвых операций завершилось раньше назначенного времени. * Параллельные системы. Если один процессор в синхронной системе выполняет дорогую операцию в то время, как остальные выполняют дешёвые, то остальным процессорам придётся ждать, пока закончит работу самый медленный. * Диалоговые системы. Диалоговые системы подобны системам реального времени - для пользователей предсказуемость часто важнее, чем чистая скорость. Например, пользователи могут предпочесть 100 ответов с задержкой 1 секунда варианту с 99 ответами при задержке 0.25 секунд и одним ответом с задержкой 25 секунд, даже при том, что второй из этих сценариев вдвое быстрее. ### Расписания Метод для преобразования амортизированных структур данных в структуры с жёсткими ограничениями путем систематического вынуждения задержек, так что ни одна из них не выполняется слишком долго. При использовании этого метода к каждому объекту добавляется дополнительная компонента - **расписание(schedule)**, управляющая порядком вынуждения задержек внутри этого объекта. Амортизированные структуры данных и структуры данных с жёсткими ограничениями различаются в основном временем, когда происходит вычисление, входящее в стоимость какой-либо операции. В структуре с жёсткими ограничениями для наихудшего случая все вычисления, составляющие стоимость операции, происходят во время самой этой операции. В амортизированной структуре данных некоторые вычисления, входящие в стоимость операции, могут на самом деле производиться во время более поздних операций. Отсюда мы видим, что почти все структуры данных, считающиеся жёсткими, будучи реализованы на чисто ленивом языке, превращаются в амортизированные, поскольку многие вычисления оказываются задержаны без особой нужды. Следовательно, для описания структур с жёсткими ограничениями нам нужен энергичный язык. Если же нам нужно описывать как амортизированные, так и жёсткие структуры данных, требуется язык, поддерживающий как ленивый порядок вычисления, так и энергичный. Имея такой язык, мы можем рассмотреть любопытный гибридный подход: структуры с жёсткими характеристиками, использующие внутри своей реализации ленивое вычисление. Мы получаем такие структуры данных, беря за основу ленивые амортизированные структуры и модифицируя их так, чтобы каждая операция укладывалась в отведенное ей время. В ленивой амортизированной структуре данных каждая конкретная операция может занять дольше, чем её заявленное ограничение. Однако такое происходит только в том случае, если эта операция вынуждает задержку, которая уже была оплачена, но требует большого времени для выполнения. Чтобы уложиться в жёсткие ограничения, мы должны гарантировать, что всякая задержка выполняется не дольше отведенного ей времени. Определим **собственную стоимость(intrinsic cost)** задержки как время, которое уходит на вынуждение задержки в предположении, что все другие задержки, от которых она зависит, уже были вынуждены и мемоизированы, и, следовательно, занимают по `O(1)` времени каждая. (Это определение похоже на определение нераздельной стоимости операции.) Первым шагом в преобразовании амортизированной структуры в жёсткую будет уменьшение собственной стоимости каждой задержки до размеров меньше желаемого ограничения. Обычно при этом требуется переписать дорогие монолитные функции и сделать их пошаговыми - либо путем небольших изменений в алгоритмах, либо путем перехода от представления, поддерживающего только монолитные функции, скажем, задержанных списков, к такому, которое также поддерживает пошаговые функции, скажем, к потокам. Даже если каждая задержка имеет маленькую собственную стоимость, некоторые задержки по-прежнему могут занимать долгое время. Это происходит, когда одна задержка зависит от другой, эта вторая от третьей, и так далее. Если ни одна из этих задержек заранее не была выполнена, то вынуждение первой задержки приводит к каскаду других вынуждений. Случалось ли вам ставить костяшки домино в ряд, чтобы каждая из них сбивала следующую? Несмотря на то, что собственная стоимость опрокидывания каждой костяшки равна `O(1)`, реальная стоимость опрокидывания первой костяшки может быть намного, намного больше. Второй шаг при преобразовании амортизированной структуры данных в жёсткую - избежать каскадирования вынуждений, устроив так, чтобы всякий раз при вынуждении задержки все другие задержки, от которых она зависит, были уже вынуждены и мемоизированы. Тогда ни одна задержка не занимает при выполнении больше, чем её собственная стоимость. Мы этого добиваемся, строя систематическое расписание(schedule) выполнений каждой задержки, чтобы все они были готовы к тому времени, как нам понадобятся. Трюк здесь состоит в том, чтобы рассматривать выплату долга буквально, и вынуждать каждую задержку в момент, когда она оплачивается. Работа с расписанием подобна опрокидыванию ряда костяшек начиная с хвоста, так чтобы всякий раз, когда одна костяшка падает на другую, эта другая была уже заранее сбита. Тогда реальная стоимость опрокидывания каждой костяшки будет мала.