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

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

Динамический массив: O(1) на вставку через потенциал

Коротко

Динамический массив удваивает буфер, когда заполнен, и копирует всё старое. Возьмём потенциал Φ = 2·num − size (num - занято, size - ёмкость). При обычной вставке Φ растёт на 2, амортизированная цена 1 + 2 = 3. При вставке с удвоением дорогое копирование гасится ПАДЕНИЕМ Φ, и цена снова выходит 3. Значит вставка амортизированно O(1), хотя отдельная бывает Θ(n). Удвоение тут принципиально: расти на константу - и вставка стала бы Θ(n).

Если умеешь доказать амортизированное O(1) вставки в динамический массив потенциалом Φ=2·num−size, листай до «Теперь сам».

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

num - сколько элементов лежит, size - на сколько хватает буфера. Пока num < size, вставка кладёт элемент за O(1). Когда num = size, места нет: заводим буфер вдвое больше, копируем все num элементов, потом вставляем - это Θ(num). Отношение num/size называют коэффициентом заполнения. Вопрос: если таких удвоений на длинной серии мало, во что обходится ОДНА вставка в среднем по серии? Докажем это методом потенциалов, а не агрегатно.

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

Φ = 2·num − size - это счётчик «насколько массив заполнен СВЕРХ половины». Сразу после удвоения массив полупустой (num ≈ size/2), запас Φ мал. По мере заполнения num растёт, Φ копится - и к моменту, когда массив снова полон, запаса ровно столько, чтобы оплатить копирование при следующем удвоении. Как пружина: сжимаешь её дешёвыми вставками, а на удвоении она распрямляется и толкает копирование даром.

Механизм: считаем амортизированную цену

Потенциал Φ = 2·num − size. Проверим условия: на пустом массиве num = size = 0, Φ = 0; пока массив заполнен хотя бы наполовину (num ≥ size/2, а удвоение это как раз держит), 2·num ≥ size, то есть Φ ≥ 0. Условия метода выполнены. Считаем ĉ = c + Φ_после − Φ_до для двух видов вставки.

Вставка без удвоения (num было меньше size). Кладём один элемент: настоящая цена c = 1. num вырос на 1, size тот же, значит Φ вырос на 2·1 = 2. Амортизированно: ĉ = 1 + 2 = 3.

Вставка с удвоением (num = size = s до вставки). Копируем s элементов и вставляем один: c = s + 1. До: Φ_до = 2s − s = s. После: num = s + 1, size = 2s, Φ_после = 2(s+1) − 2s = 2. Амортизированно:

ĉ = c + Φ_после − Φ_до = (s + 1) + 2 − s = 3

Дорогое s из настоящей цены сократилось с падением потенциала Φ_до − Φ_после = s − 2. В обоих случаях ĉ = 3 = O(1). Значит любая серия из n вставок стоит не больше 3n - амортизированно O(1) на вставку.

Правило

Вставка в динамический массив с удвоением стоит O(1) амортизированно. Доказательство - потенциал Φ = 2·num − size: обычная вставка поднимает Φ на 2 (цена 3), вставка с удвоением роняет Φ ровно настолько, чтобы погасить копирование (цена снова 3). Запас копится, пока массив заполняется, и тратится на удвоении.

Разбор: почему именно удвоение, а не рост на константу

Пусть массив растёт не вдвое, а на фиксированные +c ячеек при каждом заполнении. Тогда n вставок вызовут n/c перестроек-расширений (это уже не удвоения), и на k-й перестройке копируется ≈ k·c элементов. Суммарное копирование: c + 2c + 3c + ... ≈ c·(n/c)²/2 = Θ(n²). На n вставок это Θ(n²), амортизированно Θ(n) на вставку - как наивный перебор. Рост на константу даёт арифметический ряд (растёт как n²), а удвоение - геометрический (1+2+4+...+n < 2n, Θ(n)). Вот почему буфер именно УДВАИВАЮТ (или множат на 1.5), а не наращивают по чуть-чуть: только геометрия превращает Θ(n) копирований в O(1) амортизированных.

Разбор: то же самое предоплатой

Проверим ответ вторым методом. Назначим вставке предоплату 3: 1 за само добавление, 2 кладём кредитом. Между двумя удвоениями (размер вырос с s/2 до s) мы сделали s/2 вставок, каждая отложила 2, - накопилось s кредита. На следующем удвоении копируем s элементов по цене 1 - ровно s, весь кредит уходит в дело и не в минус. Предоплата 3 держится, кредит неотрицателен - O(1) на вставку. Тот же ответ, что потенциалом: Φ и есть этот накопленный кредит.

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

Две ошибки. Посчитать вставку по худшему случаю Θ(n) и решить, что построение массива из n элементов стоит Θ(n²), - на деле Θ(n), потому что дорогие удвоения редки. И нарастить буфер на константу ради «экономии памяти»: сэкономишь память, но вставка честно станет Θ(n) амортизированно, и заполнение массива превратится в Θ(n²) - обычная причина внезапно медленного кода.

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

Это внутренность list в Python, ArrayList в Java, vector в C++, slice в Go. Все они растят буфер в постоянное число раз (у списка Python это примерно ×1.125, у ArrayList - ×1.5, часто берут ×2), и append считают O(1) именно амортизированно - доказательство ровно то, что выше и работает для любого множителя больше единицы, мы лишь взяли для счёта удвоение. Знать это нужно, когда собираешь список в цикле: append в цикле - Θ(n) на всё, а вот вставка в начало (insert(0, x)) сдвигает хвост и даёт Θ(n²). Понимание амортизации подсказывает, какая операция дёшева по серии, а какая только выглядит дешёвой.

Теперь сам

Массив с удвоением построили, вставив n элементов с нуля. Сколько стоит всё построение и сколько - одна вставка амортизированно? Почему это не Θ(n²)?

Ответ. Каждая вставка амортизированно 3 = O(1) (доказано потенциалом), значит n вставок - Θ(n) на всё построение. Не Θ(n²), потому что дорогие удвоения редки: они случаются на размерах 1, 2, 4, ..., n, и суммарное копирование 1 + 2 + ... + n < 2n = Θ(n), а не n копирований по n. Геометрический ряд удвоений - линеен, поэтому и построение линейно.

Главное

Вставка в динамический массив с удвоением - O(1) амортизированно. Потенциал Φ = 2·num − size («заполнение сверх половины»): обычная вставка поднимает Φ на 2, цена 1 + 2 = 3; вставка с удвоением (было num = size = s) копирует s, но Φ падает с s до 2, и цена снова (s+1) + 2 − s = 3. Запас копится при заполнении и тратится на удвоении. Удвоение принципиально: рост на константу дал бы арифметический ряд Θ(n²) и вставку Θ(n), а геометрический ряд удвоений - Θ(n) и вставку O(1).

Комментарии

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

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

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