Динамический массив
Предпосылки: массив (формула адреса, непрерывность, O(1) доступ по индексу, фиксированный размер; цена вставки и удаления — сдвиг элементов, асимметрия «конец дешёвый, начало дорогое»); оценка сложности O(…).
← Массив | Связный список →
У массива есть два жёстких ограничения. Размер задаётся при создании и потом не меняется — а заранее известно, сколько элементов придёт, далеко не всегда. И даже когда массив выделен с запасом, добавление в конец упирается в его границу: за концом нет места, которое массив мог бы занять. Динамический массив снимает оба ограничения: элементы добавляются по одному, без заранее известного количества, а O(1) доступ по индексу при этом сохраняется.
Цена за это видна не сразу. Добавление в конец почти всегда стоит O(1), но изредка — O(n): в среднем по серии вставок всё равно выходит O(1) на операцию, и почему так — главный вопрос этой заметки. Остальные операции массив отдаёт без изменений:
| Операция | Сигнатура | Сложность |
|---|---|---|
| Получить элемент | get(i) → value | O(1) |
| Записать элемент | set(i, value) | O(1) |
| Узнать размер | size() → n | O(1) |
| Добавить в конец | append(value) | O(1) в среднем |
Выделенное место и занятое место
Чтобы добавлять элементы за прежнюю границу массива, нужно различать два числа, которые в обычном массиве совпадали. Одно — сколько ячеек выделено в памяти (это и есть размер непрерывного блока, который массив занял). Второе — сколько из них реально заняты элементами; остальные стоят пустыми про запас. В коде реализаций они обычно называются capacity (выделено) и length (занято).
Внутри динамического массива лежит обычный статический массив на capacity ячеек. Пока length < capacity, есть куда класть: новый элемент пишется в первую свободную ячейку, length увеличивается на единицу — O(1). Трудность начинается, когда length == capacity: свободных ячеек не осталось, а массив расширить нельзя — за его концом память уже может быть занята чем-то другим.
Что происходит, когда массив заполнен
Раз расширить текущий блок нельзя, единственный выход — выделить новый блок побольше, перенести в него всё старое и работать дальше уже с ним. Это перевыделение с копированием называют resize:
Было: capacity=4, length=4
[A][B][C][D] — свободных ячеек нет
Хотим добавить E.
Шаг 1: Выделяем новый массив (обычно вдвое больше)
[_][_][_][_][_][_][_][_] — capacity=8
Шаг 2: Копируем старые элементы в новый блок
[A][B][C][D][_][_][_][_]
Шаг 3: Освобождаем старый блок
Шаг 4: Кладём новый элемент
[A][B][C][D][E][_][_][_] — length=5, capacity=8
Почему append всё равно O(1) в среднем
Шаг 2 копирует все элементы — это O(n). Кажется, что append испорчен: одна вставка из многих внезапно стоит как полный проход по массиву.
Прежде чем читать дальше — прикинь сами
Возьми серию из
nвставок подряд в пустой массив, который удваиваетcapacityпри заполнении. Сколько всего элементов будет скопировано за все resize вместе? И сколько в среднем приходится на одну вставку?
Соберём стоимость серии явно. Пусть capacity стартует с 1 и удваивается при каждом заполнении; считаем стоимость каждой вставки как «запись элемента» плюс «копирования при resize, если он случился»:
Вставка 1: capacity=1, есть место. Кладём. Стоимость: 1
Вставка 2: capacity=1, полон. Resize → 2, копируем 1. Кладём. Стоимость: 1+1 = 2
Вставка 3: capacity=2, полон. Resize → 4, копируем 2. Кладём. Стоимость: 2+1 = 3
Вставка 4: capacity=4, есть место. Кладём. Стоимость: 1
Вставка 5: capacity=4, полон. Resize → 8, копируем 4. Кладём. Стоимость: 4+1 = 5
Вставка 6: capacity=8, есть место. Кладём. Стоимость: 1
Вставка 7: capacity=8, есть место. Кладём. Стоимость: 1
Вставка 8: capacity=8, есть место. Кладём. Стоимость: 1
Итого за 8 вставок: 1 + 2 + 3 + 1 + 5 + 1 + 1 + 1 = 15. В среднем: 15/8 < 2 на операцию. Дорогие resize встречаются всё реже именно потому, что после каждого удвоения освободившихся ячеек ровно столько же, сколько уже лежит, — и следующий resize отодвигается вдвое дальше предыдущего.
Почему дешёвые вставки покрывают дорогой resize
Слово «амортизация» пришло из финансов: крупный разовый расход распределяют на много мелких платежей вместо одной выплаты. Здесь крупный расход — копирование при resize, а мелкие платежи — обычные вставки.
После очередного удвоения свободных ячеек ровно столько, сколько занятых: если перед resize было занято m элементов, новый capacity равен 2m, и до следующего resize пройдёт ещё m вставок. Эти m дешёвых вставок и «оплачивают» одно копирование m элементов: на каждую из них приходится примерно по одному скопированному элементу сверх её собственной работы.
Просуммируем копирования за n вставок. Последний resize скопировал около n элементов, предыдущий — вдвое меньше, и так далее: n + n/2 + n/4 + ... < 2n. Это O(n) работы на копирование за всю серию из n вставок — то есть O(1) в среднем на одну вставку. Отдельная вставка по-прежнему может стоить O(n), но такая случается раз на удвоение размера.
Почему capacity умножают, а не прибавляют
Удвоение здесь не случайно. Что если при resize увеличивать capacity не вдвое, а на фиксированную единицу — например, на 1? Тогда массив заполнен после каждой вставки, и каждая вставка запускает resize. Перед вставкой номер k в массиве уже лежит k-1 элементов, и все они копируются. За n вставок копирований выйдет:
0 + 1 + 2 + ... + (n-1) = n(n-1)/2 ≈ n²/2
Это O(n²) — добавление в конец дорожает до уровня вставки в начало обычного массива, где тоже сдвигаются все элементы. Весь смысл динамического массива теряется.
Прибавлять не по 1, а по 100 или 1000 — лучше, но проблема та же по сути: после каждого resize запас ячеек постоянный, а копировать с каждым разом приходится всё больше. Resize реже, но каждый дороже — суммарно снова O(n²).
Умножение спасает потому, что запас ячеек после resize растёт вместе с размером: чем больше массив, тем дольше до следующего копирования. Дешёвых вставок между двумя resize всегда хватает, чтобы покрыть копирование, — при любом постоянном множителе больше единицы (×2, ×1.5, ×1.3).
Множитель роста в реальных реализациях
Конкретный множитель — деталь реализации, и они расходятся. Java ArrayList растёт примерно в 1.5 раза, std::vector в C++ растёт мультипликативно, но точный множитель стандарт не фиксирует. CPython (основная реализация Python) держит для list чуть больший запас, чем нужно прямо сейчас, чтобы реже копировать. Ruby Array растёт примерно в 1.8 раза. Подробности устройства Ruby Array — в заметке о внутренностях Ruby Array.
Выбор множителя — это размен: больший множитель копирует реже, но держит больше пустых ячеек и поднимает пик памяти; меньший экономит память, но копирует чаще.
Цена запаса: пустые ячейки
Запас, который делает append дешёвым, занимает память. Сразу после resize массив заполнен примерно наполовину: до половины выделенного блока — пустые ячейки про запас. Для небольших массивов это незаметно, но массив на гигабайт держит около полугигабайта впустую — это давит на память процесса и может довести до ошибки нехватки памяти.
Память не возвращается при удалении
При добавлении length растёт, а вслед за ним растёт и capacity. При удалении наоборот: length уменьшается, но capacity обычно остаётся прежним. Массив, из которого удалили почти всё, продолжает удерживать память под свой пиковый размер — запас, выделенный когда-то под максимум, сам по себе не освобождается.
Реализации расходятся и здесь. Java ArrayList не уменьшает выделенный блок автоматически — вернуть память можно только явным вызовом trimToSize(). Другие сами выделяют блок поменьше и копируют в него элементы, когда занятая доля падает слишком низко.
Где динамический массив перестаёт подходить
Доступ по индексу — O(1). Добавление в конец — O(1) в среднем. Но вставка в начало осталась ровно такой же, как у обычного массива: O(n), потому что все элементы приходится сдвигать вправо, освобождая ячейку. Рост capacity снял фиксированный размер, но непрерывная раскладка в памяти сохранила асимметрию «конец дешёвый, начало дорогое».
Если задача — часто вставлять в начало или в середину, нужна другая организация данных. Связный список отказывается от непрерывности и этим убирает сдвиги.
Sources
- OpenJDK
ArrayList(JDK 21+,growиtrimToSize()): https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/ArrayList.java - CPython
list(Python 3.x, overallocation вlist_resize): https://github.com/python/cpython/blob/main/Objects/listobject.c std::vector(C++11+) — политика роста capacity не стандартизирована (зависит от реализации): https://en.cppreference.com/w/cpp/container/vector
← Массив | Связный список →