Алгоритмы на массивах · модуль 8 · два указателя
Глава 8.7
Указатели в одну сторону:
повторы и сжатие
В этой главе разобрана вторая форма техники: оба указателя идут слева направо, один читает, другой пишет. На ней решаются задача 2 — удаление повторов из отсортированного списка — и задача 3 — сжатие последовательности. Для каждой задачи — правило по шагам, ручное упражнение или разбор на примере, сборка функции из строк, песочница, код с прогоном и инвариант. Затем — почему запись в том же списке не портит данные, которые ещё не прочитаны. В конце та же форма техники применена к поиску по двум последовательностям.
- Категория
- Алгоритм
- Уровень
- Начальный
- Опирается на
- Занятие 7: удаление спама на месте, три зоны списка
- Зачем это знать
- Менять список на месте за один проход без дополнительной памяти
§1Чтение и запись в одном списке
В задаче про спам из занятия 7 уже использовались два указателя в одну сторону. Указатель чтения r проходит все элементы по порядку, по одному разу. Указатель записи w показывает, куда встанет следующий элемент, который нужно оставить. Список при этом делится на три зоны: [0, w) — готовый результат, собранный из прочитанной части; [w, r) — пропущенные элементы и старые копии; [r, n) — то, что ещё не прочитано. В ручном упражнении ниже и в прогонах зоны раскрашены: готовое — зелёным, пропущенное — штриховкой.
Всегда w ≤ r: записывается не больше, чем прочитано. Поэтому запись попадает в уже прочитанную часть и не затирает элементы, до которых r ещё не дошёл. После цикла мусор обрезается одной операцией del a[w:]. Время O(n), дополнительная память O(1).
Задачи этой формы отличаются только правилом «оставить или пропустить» и тем, что пишется в позицию w. Разберём две.
§2Задача 2: удаление повторов
dedup_sorted(ids)задача 2 · LeetCode 26
ids — идентификаторы заказов по возрастанию, при выгрузке некоторые продублировались. Удалить повторы в том же списке, чтобы каждое значение осталось один раз, и вернуть число оставшихся значений. Новый список не создавать.
Пример: [101, 101, 102, 105, 105, 105, 110] → [101, 102, 105, 110], функция возвращает 4.
Список отсортирован, поэтому одинаковые значения стоят подряд. Это позволяет обойтись одним сравнением: прочитанное значение сравнивается только с последним записанным ids[w - 1]. Если оно другое — это новое значение, и раньше в списке его не было: иначе оно стояло бы рядом с такими же. Если равное — это повтор, его пропускаем.
Первый элемент остаётся всегда, поэтому оба указателя начинают с позиции 1. Весь алгоритм — одно правило, которое повторяется для каждого прочитанного элемента:
- Прочитать. Взять элемент
ids[r]и сравнить его с последним записаннымids[w - 1]. - Новое значение. Если они различаются, записать
ids[r]в позициюwи сдвинутьwна одну позицию вправо. - Повтор. Если равны, ничего не записывать,
wостаётся на месте. - Следующий. Сдвинуть
rвправо и повторить, пока список не прочитан. - Хвост. После прохода удалить всё начиная с позиции
wи вернутьw— число различных значений.
§3Удаление повторов вручную
Сделайте проход сами. На каждом шаге прочитан элемент ids[r], и нужно решить, записать его в позицию w или пропустить. Неверное решение не выполняется, вместо него появляется объяснение. Зелёная зона — готовый результат, штриховка — пропущенные повторы и старые копии.
§4Сборка dedup_sorted из строк
Правило из §2 записывается функцией из десяти строк. Расставьте их по порядку: сначала проверка пустого списка и начальное w, затем цикл по r с правилом записи, после цикла — обрезка хвоста. Потом напишите функцию сами в песочнице ниже.
Python в этой строке: if not ids
Пустой список в условии считается ложным, непустой — истинным. not меняет истину на ложь и обратно. Поэтому if not ids: читается как «если список пустой».
>>> not [] True >>> not [5] False
Python в этой строке: del ids[w:]
del со срезом удаляет из списка весь участок: здесь — всё, начиная с индекса w и до конца. Список становится короче, новый список не создаётся.
>>> ids = [101, 102, 105, 110, 105, 110] >>> del ids[4:] >>> ids [101, 102, 105, 110]
Напишите функцию сами в окне ниже. «Запустить тесты» проверяет её на пяти входах, как в песочнице главы 8.4; часть из них заготовка проходит сразу.
§5Код и прогон задачи 2
def dedup_sorted(ids): if not ids: return 0 w = 1 for r in range(1, len(ids)): if ids[r] != ids[w - 1]: ids[w] = ids[r] w += 1 del ids[w:] return w
Прогон ниже — на примере из условия. Зелёная зона — готовый результат, штриховка — пропущенные повторы и старые копии.
Инвариантзадача 2
Перед шагом с номером r: ids[0:w] — все различные значения из ids[0:r], по возрастанию, каждое один раз.
Инициализация: w = r = 1, первый элемент — единственное значение прочитанной части. Сохранение: новое значение дописывается в конец зоны «готово», повтор пропускается. Завершение: r = n, в ids[0:w] все различные значения списка, del ids[w:] убирает остальное.
В задаче 1 пустой список обрабатывался без отдельного if, а здесь проверка if not ids нужна. Причина в начальном значении w = 1: оно означает, что первый элемент уже оставлен. В пустом списке первого элемента нет. Цикл for r in range(1, 0) не выполнится ни разу, и без проверки функция вернёт 1 — одно различное значение в пустом списке. Обе версии запущены на пустом списке.
dedup_sorted([])переключите вкладку
1Функция вернула 1, хотя различных значений нет. Список остался пустым, поэтому ошибку видно только по возвращённому числу.
0Проверка срабатывает на первом шаге, и функция сразу возвращает 0. До w = 1 дело не доходит.
§6Задача 3: сжатие последовательности
compress(chars)задача 3 · LeetCode 443
chars — список символов. Каждую серию одинаковых символов подряд заменить символом и длиной серии, записанной цифрами. Серия длины 1 записывается одним символом. Результат — в том же списке, функция возвращает его длину.
Пример: list("aaabcc") → ['a', '3', 'b', 'c', '2'], функция возвращает 5. Серия из 12 символов x превращается в три элемента: 'x', '1', '2'.
Такой способ записи называется кодированием длин серий, по-английски run-length encoding или RLE. Он выгоден, когда в данных много длинных серий, — например, в изображениях с большими одноцветными областями.
Отличие от задачи 2 в том, что за один шаг читается целая серия. Указатель start запоминает начало серии, внутренний цикл двигает r до первого другого символа. Длина серии — r − start. Затем в позицию w пишется символ и, если серия длиннее одного символа, её длина по цифрам.
- Начало серии. Запомнить
start = r— позицию первого символа серии. - Конец серии. Двигать
rвправо, пока символ тот же и список не кончился. - Символ. Записать символ серии в позицию
w, сдвинутьw. - Длина. Если серия длиннее одного символа, записать цифры числа
r − startпо одной, каждый раз сдвигаяw. - Дальше. Повторять, пока
rне дойдёт до конца; затем удалить хвост с позицииwи вернутьw.
Так правило срабатывает на примере list("aaabcc"). Каждая строка таблицы — одна серия:
| Серия | start | r после серии | Длина | Что пишется | В позиции |
|---|---|---|---|---|---|
aaa | 0 | 3 | 3 | 'a', '3' | 0, 1 |
b | 3 | 4 | 1 | 'b' | 2 |
cc | 4 | 6 | 2 | 'c', '2' | 3, 4 |
После трёх серий w = 5, хвост с позиции 5 удаляется, результат ['a', '3', 'b', 'c', '2'].
§7Сборка compress из строк
В функции два цикла: внешний идёт по сериям, внутренний двигает r до конца текущей серии. Расставьте семнадцать строк по порядку, сверяясь с правилом выше, затем напишите функцию сами.
Python в этой строке: list("aaabcc") и for d in str(count)
list от строки даёт список её символов. str(12) превращает число в строку '12', а цикл по строке перебирает её символы по одному — так число раскладывается на цифры.
>>> list("abc")
['a', 'b', 'c']
>>> str(12)
'12'
>>> for d in str(12):
... print(d)
...
1
2Напишите функцию сами в окне ниже. «Запустить тесты» проверяет её на пяти входах, как в песочнице главы 8.4; часть из них заготовка проходит сразу.
§8Код и прогон задачи 3
def compress(chars): w = 0 r = 0 n = len(chars) while r < n: start = r while r < n and chars[r] == chars[start]: r += 1 chars[w] = chars[start] w += 1 count = r - start if count > 1: for d in str(count): chars[w] = d w += 1 del chars[w:] return w
Во внутреннем цикле порядок условий важен: r < n проверяется первым. Когда серия доходит до конца списка, r становится равным n, и обращение chars[r] дало бы IndexError. Оператор and не вычисляет второе условие, если первое ложно.
Python в этой строке: почему and спасает от IndexError
Выражение A and B вычисляется слева направо. Если A ложно, B уже не вычисляется: ответ и так ложный. Поэтому сначала проверяют, что индекс в пределах списка, и только потом читают элемент.
>>> chars = ["a"] >>> r = 1 >>> r < len(chars) and chars[r] == "a" False >>> chars[r] Traceback (most recent call last): File "<stdin>", line 1, in <module> IndexError: list index out of range
Если поменять условия местами — while chars[r] == chars[start] and r < n:, — функция будет работать, пока серия не дойдёт до конца списка. Обе записи условия запущены на list("aab"): последняя серия b заканчивается на последнем элементе.
compress(list("aab"))переключите вкладку
IndexErrorСерии aa и начало серии b обрабатываются так же, как в верной версии. На шаге 22 r становится равным 3, то есть n. На шаге 23 первым вычисляется chars[3], такого элемента нет, и функция прерывается исключением.
3На шаге 23 первым проверяется 3 < 3. Условие ложно, and не вычисляет вторую часть, и внутренний цикл заканчивается. Функция дописывает символ 'b' и возвращает 3: ['a', '2', 'b'].
Ниже — прогон верной функции. Под ячейками три метки: w — запись, start — начало текущей серии, r — чтение. Зелёная зона — уже записанный результат.
>>> chars = list("aaabccddd") >>> compress(chars) 7 >>> chars ['a', '3', 'b', 'c', '2', 'd', '3'] >>> x = ["x"] * 12 >>> compress(x) 3 >>> x ['x', '1', '2']
§9Почему запись не обгоняет чтение
В задаче 2 это следует из кода: за шаг читается один элемент и записывается не больше одного. В задаче 3 за шаг пишется символ и несколько цифр, и нужно проверить, что w не окажется правее r и не затрёт непрочитанную серию.
Серия длины c занимает в исходном списке c позиций, а в результате — один символ и количество цифр числа c. Таблица сравнивает эти два числа.
| Длина серии c | Прочитано позиций | Записано позиций | Запись не длиннее чтения? |
|---|---|---|---|
| 1 | 1 | 1 (только символ) | да, поровну |
| 2 | 2 | 2 (символ и «2») | да, поровну |
| 3–9 | 3–9 | 2 | да |
| 10–99 | 10–99 | 3 | да |
| 100–999 | 100–999 | 4 | да |
Записанный вариант серии никогда не длиннее исходного. Значит, после обработки каждой серии w ≤ r, и запись идёт в уже прочитанную часть. Так проверяется сохранение инварианта задачи 3: «chars[0:w] — сжатая запись прочитанной части chars[0:r]».
Задача 2 за шаг читает один элемент и сравнивает его с последним записанным. Задача 3 за шаг читает целую серию и пишет её сжатую запись. Общее у них — две зоны слева от r и одна обрезка хвоста после цикла. Эта же схема была в remove_spam из занятия 7.
§10Поиск по двум последовательностям
До сих пор указатели в одну сторону шли по одному списку: один читал, другой писал. Та же форма работает и для поиска, когда последовательностей две. Пример — задача о подпоследовательности. Сам термин разбирали в занятии 7: элементы идут в исходном порядке, но между ними могут быть пропуски.
is_subsequence(word, text)LeetCode 392 · Is Subsequence
Проверить, можно ли получить слово word из текста text, вычёркивая буквы и не меняя порядок оставшихся. Вернуть True или False.
Пример: «кот» получается из «компот» — к, о, т стоят в нём по порядку. Из «картошка» — нет: после «о» буквы «т» уже нет.
Указатель j идёт по тексту и читает каждую букву. Указатель i показывает следующую нужную букву слова и сдвигается, только когда она нашлась. Оба двигаются только вперёд, поэтому шагов не больше длины текста: время O(len(text)), память O(1).
- Начало.
i = 0: ещё не найдено ни одной буквы слова. - Чтение.
jпроходит все буквы текста по порядку. - Совпадение. Если
text[j]равна следующей нужной буквеword[i], сдвинутьi. Если слово уже найдено целиком, дальше ничего не сравнивать. - Ответ. После текста слово получилось, если найдены все его буквы:
i == len(word).
Напишите функцию сами в окне ниже. «Запустить тесты» проверяет её на пяти входах, как в песочнице главы 8.4; часть из них заготовка проходит сразу.
def is_subsequence(word, text): i = 0 for j in range(len(text)): if i < len(word) and text[j] == word[i]: i += 1 return i == len(word)
В прогонах под ячейками текста стоит указатель j, а i — сколько букв слова уже найдено — видно в таблице переменных.
is_subsequence на двух текстахпереключите вкладку
В условии if две проверки. Первая, i < len(word), нужна для случая, когда все буквы слова уже найдены, а текст ещё не дочитан. Тогда i == len(word), и обращение word[i] выходит за границу слова. Как и в compress, and не вычисляет вторую проверку, если первая ложна. Обе версии запущены на слове «кот» и тексте «котик»: буквы слова кончаются раньше текста.
is_subsequence("кот", "котик")переключите вкладку
IndexErrorНа шаге 10 найдена третья буква, i = 3. На шаге 12 сравнение читает word[3], а в слове из трёх букв индексы от 0 до 2. Функция прерывается, хотя слово уже найдено.
TrueНа шагах 12 и 14 проверка 3 < 3 ложна, и буквы «и», «к» с буквами слова не сравниваются. Текст дочитывается, функция возвращает True.
Остаётся вопрос, почему можно брать первую подходящую букву текста и не проверять другие. Пусть «к» нашлась на позиции 0. Любая другая «к» в тексте стоит правее, и после неё для остальных букв слова остаётся меньше текста. Значит, выбор самой ранней буквы никогда не хуже выбора более поздней, и возвращаться к уже пройденным буквам текста не нужно. Поэтому оба указателя двигаются только вперёд.
§11Проверьте себя
Вопрос 1Почему в задаче 2 достаточно сравнить ids[r] с одним значением ids[w - 1], без остальных записанных?
Повторы недопустимы по условию. Одного сравнения хватает благодаря порядку в списке.
До
del ids[w:]ничего не удаляется. Значения левееw − 1лежат в зоне «готово», сравнивать с ними можно, но незачем: равное им значение стояло бы рядом с ними, доids[w - 1].Верно. Если
ids[r]равно какому-то записанному значению, то оно равно и последнему записанному: между ними не может быть другого значения.
Вопрос 2Что вернёт compress(list("aabaa"))?
Верно. Три серии: «aa», «b», «aa». Для каждой — символ, для двух длинных — ещё цифра 2.
Серия длины 1 записывается одним символом, без цифры 1: после
bничего не пишется.Вторая серия «aa» тоже длины 2, после символа записывается её длина.
Вопрос 3Сколько раз выполнится проверка в цикле is_subsequence для слова длины 3 и текста длины 10?
Цикл идёт по буквам текста. Даже когда все буквы найдены, текст дочитывается до конца.
Так работал бы перебор. Здесь
iне возвращается к началу слова, и каждая буква текста сравнивается один раз.Верно. Указатель
jпроходит текст один раз,iтолько растёт. Время O(len(text)).