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

# теория · шаг 1 из 6

Массив: элемент по адресу за O(1), сдвиг за O(n)

Коротко

Массив хранит элементы непрерывным куском памяти, одинакового размера. Поэтому адрес i-го считается арифметикой - начало + i · размер_элемента, - и «взять i-й» стоит O(1), хоть первый, хоть миллионный. Обратная сторона непрерывности: вставить или удалить в середине - значит сдвинуть весь хвост, O(n). У статического массива ещё и фиксированный размер. Динамический массив снимает ограничение размера, удваивая буфер при заполнении (амортизированный append мы доказали в прошлом разделе), но профиль остаётся тем же: индексация дёшева, сдвиг в середине дорог.

Если ты понимаешь, почему индексация массива - это O(1), а вставка в середину - O(n), и чем динамический массив отличается от статического, листай до «Теперь сам».

Как думаешь, почему массив мгновенно достаёт любой элемент

Ты пишешь a[1000000] и получаешь ответ сразу, без всякого перебора. Откуда структура знает, где лежит миллионный элемент, не проходя первый миллион? Секрет в том, КАК массив хранит данные: подряд, вплотную, элементами одинакового размера. Тогда положение i-го - не загадка, а формула. Именно эта укладка даёт массиву его сильную сторону и, как обратную сторону, слабую. Разберём обе.

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

Многоэтажный дом с одинаковыми квартирами. Чтобы попасть в квартиру №1000, тебе не надо обходить 999 предыдущих: ты знаешь, что этажи равны, считаешь этаж = номер / 10 и едешь прямо туда. Адрес вычисляется, а не отыскивается. Но попробуй ВСЕЛИТЬ нового жильца в квартиру №5 так, чтобы номера остались подряд: всем от пятой и выше придётся переехать на квартиру вперёд. Одинаковость и порядок дают мгновенный доступ - и они же делают вставку в середину переселением всего хвоста.

Механизм: адрес - это арифметика

Массив занимает непрерывный блок памяти начиная с некоторого адреса начало. Все элементы одного размера s байт. Тогда i-й элемент лежит по адресу начало + i · s. Это одно умножение и сложение - O(1), независимо от i и от длины массива. Вот почему индексация мгновенна.

a[0]a[1] a[2]a[3] a[4]a[5] начало начало + 3·s адрес i-го = начало + i · размер → «взять i-й» за O(1), без перебора
Непрерывная укладка одинаковых элементов превращает индекс в адрес - одно вычисление вместо прохода.

Обратная сторона той же укладки - вставка и удаление в середине. Чтобы вставить элемент на место i, надо освободить ячейку i: сдвинуть все элементы от i до конца на одну позицию вправо. Это до n перемещений - O(n). Удаление из середины - то же самое наоборот: хвост сдвигается влево, чтобы закрыть дырку. Порядок и непрерывность, которые дарят O(1)-доступ, требуют держать элементы вплотную - а значит платить сдвигом за любую правку не с краю.

Правило

Массив хранит элементы непрерывно, одного размера, поэтому «взять i-й» - это O(1) (адрес считается арифметикой). За тот же порядок платят сдвигом: вставка и удаление в середине - O(n), потому что хвост надо подвинуть. У статического массива размер фиксирован; динамический массив растёт, удваивая буфер, но профиль операций тот же. Массив - структура для быстрого доступа по индексу, а не для частых правок в середине.

Разбор: почему вставка в середину - это O(n)

Пусть в массиве [a, b, c, d] надо вставить x на место 1, чтобы вышло [a, x, b, c, d]. Ячейка 1 занята буквой b. Освобождаем её: d едет на место 4, c на место 3, b на место 2 - весь хвост от позиции вставки сдвигается на один вправо. Только теперь в ячейку 1 можно положить x. Сдвинули три элемента; в массиве длины n вставка у начала сдвигает почти все n - отсюда O(n). Чем ближе к началу вставка, тем длиннее сдвигаемый хвост; вставка в самый конец не сдвигает ничего и потому дёшева. Ровно это начисляет и наш тренажёр: a.insert(0, x) стоит по числу сдвинутых элементов, а a.append(x) - один шаг.

Разбор: динамический массив - это массив, который переезжает

Статический массив выделяет память один раз и не может вырасти. Динамический массив (это и есть list в Python) снимает ограничение: он держит буфер с запасом, а когда буфер заполнен, выделяет вдвое больший, копирует туда старое и продолжает. В прошлом разделе мы доказали потенциалом, что такое удвоение делает append амортизированно O(1), несмотря на редкие дорогие копирования. Но заметь: рост буфера меняет только операцию «добавить в конец». Индексация как была O(1) (память по-прежнему непрерывна), так и осталась; вставка в середину как была O(n) (сдвиг хвоста), так и осталась. Динамичность решает вопрос размера, а не профиль остальных операций.

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

Главная ошибка - строить алгоритм на частой вставке или удалении в СЕРЕДИНЕ массива и удивляться квадрату. Собираешь результат, вставляя каждый новый элемент в начало списка? Каждая вставка - сдвиг всего, что уже есть, и n таких вставок дают O(n²). Массив за это наказывает, потому что непрерывность требует держать элементы вплотную. Вторая ошибка - думать, что раз массив «динамический», у него ВСЕ операции дёшевы: дёшев только append (и то амортизированно); индексация дёшева всегда, а вот правки в середине дороги независимо от того, статический массив или динамический.

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

list в Python, ArrayList в Java, vector в C++, slice в Go - всё это динамические массивы, и у всех один профиль: доступ по индексу и добавление в конец - дёшево, вставка и удаление не с конца - дорого. Отсюда практические правила: собирать данные append-ом в конец, а не вставками в начало; не удалять из середины большого списка в цикле (каждое удаление - сдвиг); если нужен частый доступ по номеру - массив идеален. А когда наоборот, нужны частые вставки и удаления НЕ с конца, в дело идёт другая структура - связный список, о нём следующий урок.

Теперь сам

У тебя массив на n элементов. Ты n раз удаляешь ПЕРВЫЙ элемент (каждый раз a.pop(0)), пока массив не опустеет. Во что обойдётся вся серия и почему? Что изменилось бы, удаляй ты каждый раз ПОСЛЕДНИЙ (a.pop())?

Ответ. Удаление первого элемента сдвигает весь оставшийся хвост влево, чтобы закрыть дырку в начале: это O(длины) на каждый pop(0). Длины идут n, n−1, ..., 1, сумма n + (n−1) + ... + 1 = n(n+1)/2 = Θ(n²) - вся серия квадратична. А pop() с конца не сдвигает ничего (за удалённым элементом пусто), это O(1), и n таких удалений - всего Θ(n). Разница O(n²) против O(n) - только из-за того, с какого конца массива ты его правишь: непрерывная укладка делает конец дешёвым, а начало - дорогим.

Главное

Массив хранит элементы непрерывно и одного размера, поэтому «взять i-й» - O(1) (адрес = начало + i · размер, чистая арифметика). Плата за порядок - сдвиг: вставка и удаление в середине O(n), потому что хвост надо подвинуть; правки с конца дёшевы, с начала дороги. Статический массив фиксированного размера; динамический (list) растёт удвоением буфера, делая append амортизированно O(1), но индексацию и сдвиг это не меняет. Массив - для быстрого доступа по индексу, не для частых правок в середине.

Комментарии

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

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

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