Все темы
20

Убираем повторы прямо в массиве

Tier 1Бесплатно

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

1. Задача

У тебя есть отсортированный список, в котором часть значений повторяется: [1, 1, 2, 3, 3]. Нужно оставить каждое значение по одному разу: [1, 2, 3]. Порядок тот же, повторов нет.

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

Отсортированный порядок помогает. В таком списке одинаковые числа всегда стоят рядом. Поэтому можно переписать тот список, который уже есть, и почти не тратить лишнюю память.

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

В отсортированном списке повторы стоят рядом. Нужно по одному экземпляру каждого значения, и записать их надо в тот же список.

Ты идёшь по списку слева направо и смотришь на каждое число. Число новое, если оно отличается от предыдущего. Новое число ты переписываешь в начало списка, на первое свободное место. Повтор просто пропускаешь.

На [1, 1, 2, 3, 3] это выглядит так. Первую 1 оставляешь. Вторую 1 пропускаешь. 2 записываешь на второе место, 3 на третье. Последнюю 3 пропускаешь. Начало списка теперь 1, 2, 3, и второй список не понадобился.

3. Идея

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

Список отсортирован, поэтому значение новое ровно тогда, когда оно отличается от соседа слева. Значит, read сравнивает текущее значение с предыдущим элементом. Если значение новое, скопируй его в ячейку write и сдвинь write на шаг вперёд. Если это повтор, пропусти его: вперёд двигается только read.

В уроке 19 два маркера сходились с разных концов. Здесь оба идут слева направо. Медленный маркер показывает, куда писать, и границей поиска не служит.

1 1 2 3 3 берём пропуск берём берём пропуск первые 3 слота станут [1, 2, 3], новая длина 3

Проследим на [1, 1, 2, 3, 3]. Начинаем с write = 1: первый элемент всегда остаётся.

  • read=1: 1 == 1, это повтор, пропускаем.
  • read=2: 2 != 1, пишем 2 в ячейку 1, write становится 2. Массив: [1, 2, 2, 3, 3].
  • read=3: 3 != 2, пишем 3 в ячейку 2, write становится 3. Массив: [1, 2, 3, 3, 3].
  • read=4: 3 == 3, это повтор, пропускаем.

Первые 3 ячейки: [1, 2, 3]. Новая длина 3. В ячейках 3 и 4 остались старые значения, их никто не проверяет.

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

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

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

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

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

Убираем повторы из отсортированного массива на месте
compact([1, 1, 2, 2, 2, 3, 4]) // keep x when x !== arr[slow - 1]
arr (отсортирован)
1
1
2
2
2
3
4

slow
fast = -slow = 0arr[slow - 1] = -

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

Гонка скорости
Удаление повторов из одних и тех же 40 отсортированных чисел сдвигом и двумя указателями

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

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

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

 

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

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

n = 1 000
Сдвиг на каждом повторе338,5 тыс. · 3,4 мс
Указатели чтения и записи2 тыс. · меньше 1 мс
n = 100 000
Сдвиг на каждом повторе3,4 млрд · 34 с
Указатели чтения и записи200 тыс. · 2 мс
n = 10 000 000
Сдвиг на каждом повторе33,8 трлн · 3,9 дн.
Указатели чтения и записи20 млн · 200 мс

Как узнать

Два указателя (в одну сторону) (Two pointers (same direction))

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

  • удалить дубликаты на месте, сохранив порядок
  • переместить/разбить элементы с сохранением порядка
  • сжать массив, используя O(1) дополнительной памяти
  • сдвинуть нули или заданное значение в конец

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

n до 1e6 даёт O(n) времени и O(1) памяти. Указатель чтения видит каждый элемент один раз.

На LeetCode

Задачи на отсортированный массив или строку, где нужно убрать повторы или лишнее прямо на месте и вернуть новую длину. Медленный указатель записи и быстрый указатель чтения идут в одну сторону.

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

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

Список отсортирован, поэтому равные числа стоят рядом. Перенеси разные числа в начало списка, сохранив порядок, и верни, сколько их.

  • Вход: nums, список чисел по возрастанию. Пример: [1, 1, 2, 3, 3].
  • Что меняется: разные числа переезжают в начало nums, по порядку.
  • Возвращает: сколько разных чисел (новая длина). Тесты проверяют только начало списка. Что осталось дальше, им неважно.

Шаги:

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

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

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

Практика

dedupeSorted

dedupeSorted(nums) -> number (new length; nums mutated in place so nums[0..len-1] is deduped)

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

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

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

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

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

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