Где применяется
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]. Начинаем с 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]slow
Убираем повторы из отсортированного массива на месте. fast читает каждое значение, slow отмечает следующую свободную ячейку для того, что оставляем.
Столбики: отсортированные числа, большинство повторяется. Обе дорожки оставляют по одному в начале и заполняют освободившийся хвост нулями.
Одна операция: одно сравнение или одна запись. За каждый кадр все дорожки делают одинаковое число операций, поэтому медленная отстаёт.
O(n²)O(n)
Операции для n чисел и примерное время при 100 миллионах простых операций в секунду. Это оценка по тому же счёту, а не замер.
Как узнать
Эти слова в условии значат этот паттерн
- удалить дубликаты на месте, сохранив порядок
- переместить/разбить элементы с сохранением порядка
- сжать массив, используя O(1) дополнительной памяти
- сдвинуть нули или заданное значение в конец
Похоже, но не то
- Преобразование массива на месте (In-place array transform)
Работа в одном буфере это цель. Отстающий указатель записи это один из способов её достичь.
- Два указателя (с концов) (Two pointers (opposite ends))
Те указатели стартуют по краям отсортированных данных и сходятся. Эти оба стартуют с нулевого индекса.
- Скользящее окно (переменное) (Sliding window (variable))
Окно тоже двигает два индекса вперёд, но хранит живой отрезок. Здесь отстающий индекс это место записи.
- Быстрый и медленный указатели (Fast & slow pointers)
Там быстрый указатель делает два шага и ловит цикл. Здесь он делает один и читает.
n до 1e6 даёт O(n) времени и O(1) памяти. Указатель чтения видит каждый элемент один раз.
На LeetCode
Задачи на отсортированный массив или строку, где нужно убрать повторы или лишнее прямо на месте и вернуть новую длину. Медленный указатель записи и быстрый указатель чтения идут в одну сторону.
Это задача урока, так что оставляй значение, только если оно отличается от предыдущего.
Разреши каждому значению повториться дважды, сравнивая его со значением на две позиции раньше в уже записанной части.
Один указатель идёт по короткой строке, другой по длинной, и оба двигаются только вперёд.
Прочитай серию одинаковых символов и запиши символ и длину серии в позицию указателя записи.
Твоя задача (Tier 1)
Напиши функцию dedupeSorted(nums).
Список отсортирован, поэтому равные числа стоят рядом. Перенеси разные числа в начало списка, сохранив порядок, и верни, сколько их.
- Вход:
nums, список чисел по возрастанию. Пример:[1, 1, 2, 3, 3]. - Что меняется: разные числа переезжают в начало
nums, по порядку. - Возвращает: сколько разных чисел (новая длина). Тесты проверяют только начало списка. Что осталось дальше, им неважно.
Шаги:
- Если список пустой, верни
0. - Заведи счётчик
write = 1. Число на позиции 0 уже на своём месте. - Иди по списку с позиции 1. Если число отличается от того, что стоит прямо перед ним, скопируй его в
nums[write]и прибавь 1 кwrite. - Верни
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.
}
Ты здесь как гость
Тесты запускаются прямо здесь, без аккаунта. Код сохраняется только в этом браузере. Бесплатный аккаунт сохранит его на любом устройстве, отметит шаг пройденным и покажет, где ты в курсе.
Лестница подсказок
Застрял? Открывай по одной, но сначала попробуй сам.