Все темы
10

Перестроить массив, не заводя второй

Tier 1Бесплатно

Где применяется

1. Задача

Тебе дают большой массив (список пронумерованных коробок) и просят его перестроить. Например, развернуть, сдвинуть все нули в конец или убрать часть элементов. Самый простой путь: завести новый массив и заполнить его. Это работает, но памяти уходит вдвое больше. На какое-то время в памяти лежат сразу оба массива, старый и новый.

Иногда это не страшно. Но бывает, что массив огромный или его используют заново тысячи раз в секунду. Тогда на вторую копию памяти нет. Приходится менять тот массив, который уже есть, и брать для работы всего пару лишних переменных. Такое изменение называют изменением на месте (in-place): ты правишь тот же массив и не создаёшь новый.

2. Простыми словами

Возьми первый тест, [0, 1, 0, 3]. Ответ должен быть [1, 3, 0, 0], и второй массив для этого заводить нельзя.

Поэтому работаешь в тех же четырёх ячейках. Одна переменная, write, помнит, в какую ячейку пойдёт следующее число, которое ты оставляешь. Сначала это ячейка 0. Вторая переменная, read, просматривает ячейки по одной слева направо. Когда read находит число, не равное 0, ты копируешь его в ячейку write и сдвигаешь write на одну ячейку вправо. Так 1 попадает в ячейку 0, а 3 в ячейку 1. Потом в ячейки 2 и 3 записываешь нули. Те же четыре ячейки теперь хранят [1, 3, 0, 0], и лишняя память не понадобилась. Ради этой сэкономленной памяти и нужен весь урок.

3. Идея

«На месте» значит, что ты правишь исходные ячейки напрямую и почти не тратишь лишнюю память. Большинство таких задач решают два приёма. В обоих есть указатель: так называют переменную, которая хранит номер ячейки.

Два указателя с концов (чтобы развернуть массив или поменять элементы местами). Указатель lo стоит в начале, указатель hi в конце. Меняешь местами две ячейки, на которые они указывают. Потом сдвигаешь оба на шаг навстречу друг другу. Останавливаешься, когда они встретятся.

Указатель записи (чтобы сдвинуть или удалить элементы). Медленный указатель write помнит, куда положить следующий оставляемый элемент. Быстрый указатель read проходит весь массив. Каждый раз, когда read находит элемент, который нужно оставить, ты копируешь его в ячейку write и сдвигаешь write на шаг вперёд.

0 1 0 3 пропуск оставить →write пропуск оставить →write потом заполнить остаток нулями → [1, 3, 0, 0]

Проследим «нули в конец» на [0, 1, 0, 3]. Читаем 0: пропускаем. Читаем 1: оставляем, пишем его в ячейку 0, массив стал [1, 1, 0, 3], write стал 1. Читаем 0: пропускаем. Читаем 3: оставляем, пишем в ячейку 1, массив стал [1, 3, 0, 3], write стал 2. Числа, которые надо оставить, уже стоят в начале. В ячейках 2 и 3 остались старые значения, их заполняем нулями: [1, 3, 0, 0]. Готово, второй массив не понадобился.

4. Во что это обходится

Время O(n): один проход по массиву. Память O(1): пара переменных под индексы и одна временная ячейка для обмена. Ничего из этого не растёт вместе с массивом. Новый массив стоил бы O(n) лишней памяти, а здесь её тратить нельзя.

Одна тонкость в JavaScript: строку нельзя изменить на месте. Поэтому в таких задачах тебе дают массив символов (по одной букве в коробке). Ты правишь массив, а не строку.

5. Где это встречается

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

Убираем все нули на месте
inPlaceTransform([0, 4, 0, 0, 7, 2, 0, 5]) // shouldKeep = x => x !== 0
arr
0
4
0
0
7
2
0
5

write
read = -write = 0

Убираем все нули без второго массива. read смотрит каждую ячейку, write отмечает, куда пойдёт следующее нужное значение.

Гонка скорости
Перенос нулей в конец одних и тех же 40 чисел сдвигом и одним указателем записи

Столбики: числа, у нуля столбика нет. Обе дорожки сохраняют порядок остальных чисел.

Одна операция: одно сравнение или одна запись. За каждый кадр все дорожки делают одинаковое число операций, поэтому медленная отстаёт.

Сдвиг на каждом нуле O(n²)
операций: 0
Указатель записи O(n)
операций: 0

 

А если чисел больше

Операции для n чисел и примерное время при 100 миллионах простых операций в секунду. Это оценка по тому же счёту, а не замер.

n = 1 000
Сдвиг на каждом нуле188,5 тыс. · 1,9 мс
Указатель записи2 тыс. · меньше 1 мс
n = 100 000
Сдвиг на каждом нуле1,9 млрд · 19 с
Указатель записи200 тыс. · 2 мс
n = 10 000 000
Сдвиг на каждом нуле18,8 трлн · 2,2 дн.
Указатель записи20 млн · 200 мс

Как узнать

Преобразование массива на месте (In-place array transform)

Эти слова в условии значат этот паттерн

  • изменить массив на месте
  • O(1) дополнительной памяти
  • не выделять ещё один массив/строку
  • развернуть/повернуть/разбить/переместить элементы в одном буфере
  • вернуть новую длину после удаления дубликатов

Похоже, но не то

n до 1e6 и один проход дают O(n) времени. Память остаётся O(1), потому что ничего не выделяется.

На LeetCode

Задачи, где массив нужно переставить, отфильтровать или развернуть на месте, с O(1) дополнительной памяти. Ищи в условии слова «in-place» и «do not allocate extra space».

Твоя задача (Tier 1)

Напиши функцию moveZeroes(nums).

Перенеси все 0 в конец списка. Остальные числа оставь в том же порядке. Не создавай новый список, меняй этот.

  • Вход: nums, список чисел. Пример: [0, 1, 0, 3].
  • Возвращает: ничего. Ты только меняешь nums.

Шаги:

  1. Заведи счётчик write = 0. Он показывает следующее свободное место в начале списка.
  2. Иди по списку слева направо. Если число не 0, скопируй его в nums[write] и прибавь 1 к write.
  3. В конце поставь 0 во все места от write до конца списка.

Готово, когда проходят все тесты:

  • [0, 1, 0, 3] -> [1, 3, 0, 0]
  • [1, 2, 3] -> [1, 2, 3] (нулей нет, ничего не меняется)
  • [0, 0] -> [0, 0] (все нули)
  • [0] -> [0] (одно число)
  • [] -> [] (пустой список, не должно падать)

Практика

moveZeroes (or reverseInPlace)

moveZeroes(nums) -> void (mutates nums)

export function moveZeroes(nums) {
  // Your code here. The task is described in the box above the editor.
}

Ты здесь как гость

Тесты запускаются прямо здесь, без аккаунта. Код сохраняется только в этом браузере. Бесплатный аккаунт сохранит его на любом устройстве, отметит шаг пройденным и покажет, где ты в курсе.

Лестница подсказок

Застрял? Открывай по одной, но сначала попробуй сам.

    Мы используем необходимые куки для работы сайта (вход и язык). Если ты согласишься, мы также загрузим Google Analytics, чтобы видеть, какие страницы читают, и Google reCAPTCHA для защиты форм обратной связи и сообщений об ошибке от спама. Политика конфиденциальности