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

# теория · шаг 4 из 7

Сжатие таблицы при удалениях: почему порог 1/4, а не 1/2

Коротко

Если элементы не только добавляют, но и удаляют, буфер динамического массива имеет смысл уменьшать - иначе после всплеска данных память останется занятой навсегда. Наивное правило «ужать вдвое, когда заполнено меньше половины» ломает амортизацию: на чередовании вставок и удалений у самой границы каждая пара операций копирует весь массив, и цена становится Θ(n) на операцию. Правильное правило оставляет между расширением и сжатием зазор: расширять при заполнении 1, сжимать вдвое при заполнении ниже 1/4. Тогда после любой перестройки до следующей нужно Θ(size) дешёвых операций, и потенциал доказывает O(1) амортизированно на любую смесь операций.

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

Как думаешь, что сломается при симметричном правиле

Прошлый урок доказал: удвоение при заполнении даёт вставку за O(1) амортизированно. Добавим удаление. Самое естественное правило - зеркальное: «стало заполнено меньше половины - ужми буфер вдвое». Звучит честно: после сжатия массив снова полон наполовину, как сразу после удвоения.

Прежде чем читать дальше, ответь себе: массив на 1024 элемента, буфер 1024, и дальше идёт бесконечная серия «вставить, удалить, удалить, вставить». Сколько элементов будет скопировано на каждые четыре операции? Если ответ «почти ничего» - урок как раз для тебя.

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

Термостат с одной точкой. Нагреватель включается, когда в комнате ниже 20 градусов, и выключается, когда выше 20. Комната стоит ровно на 20 - и реле щёлкает каждую секунду, пока не сгорит. Настоящие термостаты делают ЗАЗОР: включиться ниже 19, выключиться выше 21. Между порогами система спокойна, и чтобы её снова дёрнуть, нужно реально изменить температуру на два градуса, а не на сотую.

С массивом то же самое. Расширение и сжатие - два реле, и если их пороги совпадают, массив у границы перестраивается на каждой второй операции. Зазор между 1/4 и 1 - это два градуса термостата: после любой перестройки нужно сделать много операций, чтобы дойти до следующей.

Механизм: два порога и потенциал из двух ветвей

ПРАВИЛА
вставка:  num = size  →  удвоить буфер, скопировать num, потом вставить
удаление: удалить; если теперь num < size/4  →  ужать вдвое,
          скопировать num

после удвоения заполнение 1/2, после сжатия - чуть меньше 1/2:
до любой следующей перестройки заполнение должно уйти до 1 или до 1/4

Потенциал теперь из двух ветвей - в зависимости от того, по какую сторону половины стоит коэффициент заполнения α = num/size:

Φ = 2·num − size        если α ≥ 1/2   (копим на удвоение)
Φ = size/2 − num        если α < 1/2   (копим на сжатие)

Обе ветви неотрицательны и обе равны нулю ровно при α = 1/2 - то есть сразу после любой перестройки запас пуст, и его надо накопить заново. Чем дальше заполнение уходит от половины в любую сторону, тем больше запас.

Цена операций (c - настоящая, ĉ = c + ΔΦ - амортизированная):

вставка при α ≥ 1/2, без удвоения     c = 1,      ΔΦ = +2      ĉ = 3
вставка с удвоением (num = size = s)  c = s + 1,  Φ: s → 2     ĉ = 3
вставка при α < 1/2                   c = 1,      ΔΦ = −1      ĉ = 0
удаление при α < 1/2, без сжатия      c = 1,      ΔΦ = +1      ĉ = 2
удаление со сжатием (было num = k, size = 4k)
                                      c = 1 + (k−1) = k
                                      Φ: (2k − k) = k  →  (k − (k−1)) = 1
                                      ĉ = k + 1 − k = 1
удаление при α ≥ 1/2                  c = 1,      ΔΦ = −2      ĉ = −1

Переходы через половину (вставка, после которой α стало 1/2, или удаление, после которого стало меньше) меняют ветвь, но значение Φ в точке α = 1/2 у обеих ветвей одинаковое (ноль), поэтому скачка нет, и цена такой операции тоже ограничена константой. Во всех случаях ĉ ≤ 3.

Правило

При вставках и удалениях буфер удваивают при заполнении 1, а сжимают вдвое при заполнении ниже 1/4 - не 1/2. Зазор между порогами гарантирует, что после любой перестройки (заполнение ровно половина) до следующей нужно Θ(size) операций. Доказательство - потенциал Φ = 2·num − size при α ≥ 1/2 и Φ = size/2 − num при α < 1/2: любая операция стоит не больше 3 амортизированно. Сжатие при 1/2 без зазора даёт Θ(n) на операцию на чередовании у границы.

Разбор: качели у границы при сжатии на половине

Возьмём симметричное правило и серию из начала урока. Буфер 1024, элементов 1024.

+   num = size = 1024 → удвоение, копируем 1024; size 2048, num 1025
−   num 1024 = ровно половина, сжатия нет
−   num 1023 < 1024 → сжатие, копируем 1023; size 1024
+   num 1024 = size, вставка без перестройки
+   ...и цикл повторяется: снова полон

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

szhatie.py
def run(ops, porog):
    num, size, copies = 0, 1, 0
    for op in ops:
        if op == "+":
            if num == size:
                copies += num
                size *= 2
            num += 1
        else:
            num -= 1
            if size > 1 and num < size * porog:
                copies += num
                size //= 2
    return copies


n = 1024
zapolnenie = ["+"] * n                 # заполнили ровно до 1024
kacheli = ["+", "-", "-", "+"] * 1000  # туда-обратно у границы
for porog, imya in ((1 / 2, "сжатие при 1/2"),
                    (1 / 4, "сжатие при 1/4")):
    c = run(zapolnenie + kacheli, porog)
    print(f"{imya}: копирований {c}, на операцию {c / (n + 4000):.1f}")
↳  выводpython3 szhatie.py
сжатие при 1/2: копирований 2048023, на операцию 407.6
сжатие при 1/4: копирований 2047, на операцию 0.4

Разница в тысячу раз. При пороге 1/4 все 2047 копирований - это удвоения при первом заполнении (1 + 2 + 4 + ... + 1024), а качели вообще ничего не перестраивают: чтобы дойти до сжатия, нужно опустить заполнение с половины до четверти, то есть сделать сотни удалений подряд.

Разбор: сжатие по шагам с потенциалом

Проверим ветвь сжатия на числах. Буфер 16, элементов 4, заполнение ровно 1/4. Потенциал по ветви α < 1/2: Φ = 16/2 − 4 = 4.

удаляем элемент: num = 3 < 16/4 = 4 → сжатие
настоящая цена: 1 (удаление) + 3 (копируем оставшиеся) = 4
после: size = 8, num = 3, α = 3/8 < 1/2 → Φ = 8/2 − 3 = 1
амортизированная цена: 4 + 1 − 4 = 1

Откуда взялся запас 4? Его накопили удаления, которые вели массив от половины к четверти: каждое поднимало Φ на единицу. Если массив был 16 с 8 элементами (Φ = 0), то четыре удаления до 4 элементов накопили ровно Φ = 4 - и этим заплатили за копирование трёх оставшихся. Тот же механизм, что у удвоения, только зеркальный: дешёвые операции заводят пружину, перестройка её отпускает.

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

Симметричный порог - классическая ошибка, и её симптом коварен: на тестах всё быстро, потому что тесты обычно либо заполняют, либо очищают структуру. Медленно становится только на реальной нагрузке, где размер колеблется у одной точки - очередь задач, которая то наполняется, то разгребается. Профиль показывает, что всё время уходит в memcpy или в перевыделение памяти, а размер структуры почти не меняется. Вторая ошибка - не сжимать вовсе: тогда после пикового часа структура на миллион элементов держит память под миллион, хотя в ней сто. Третья - взять потенциал из одной ветви 2·num − size: при заполнении ниже половины он отрицательный, и доказательство перестаёт быть оценкой сверху.

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

Так устроены реальные контейнеры. list в CPython при удалении ужимает буфер, только когда занято меньше половины выделенного, а выделяет с запасом около восьмой части - зазор есть, просто другой формы. Хеш-таблицы с открытой адресацией расширяются при заполнении около 2/3 и не сжимаются при удалении вовсе, пока их не перестроят явно, - это сознательный выбор «память в обмен на отсутствие качелей». Когда пишешь свой кольцевой буфер, пул соединений или кеш с переменным размером, правило «расширять и сжимать на одном пороге» - первое, что стоит проверить в чужом коде.

Теперь сам

Буфер 64, элементов 32. Правило «удвоить при заполнении 1, ужать вдвое ниже 1/4». Сколько удалений подряд нужно сделать, чтобы случилось первое сжатие? Какой будет потенциал перед ним и сколько элементов скопирует сжатие?

Ответ. Сжатие случится, когда после удаления станет num < 64/4 = 16, то есть на 17-м удалении (32 − 17 = 15). Перед последним удалением num = 16, Φ = 64/2 − 16 = 16 - запас копили все 16 предыдущих удалений, по единице каждое. Сжатие скопирует 15 оставшихся элементов, а потенциал после него 32/2 − 15 = 1. Амортизированная цена: (1 + 15) + 1 − 16 = 1.

Главное

Сжимать буфер на том же пороге, на котором его расширяют, нельзя: у границы каждая пара операций перестраивает массив, и цена становится Θ(n) на операцию (в программе - 407 копирований на операцию против 0.4). Нужен зазор: удваивать при заполнении 1, ужимать вдвое ниже 1/4. Тогда после любой перестройки заполнение ровно половина, а потенциал из двух ветвей (2·num − size выше половины, size/2 − num ниже) равен нулю и копится заново - любая операция стоит не больше 3 амортизированно.

Комментарии

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

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

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