Алгоритмы на массивах · модуль 9 · скользящее окно
Глава 9.8
Задача целиком:
решение на интервью
В предыдущих главах этапы решения разбирались по отдельности. В этой главе новая задача проходит все этапы подряд, в том порядке и примерно с той скоростью, что на алгоритмической секции, — как задача о подарках в главе 8.11. Задача — LeetCode 1423: карты берут только с краёв ряда. В условии нет слов «подряд» и «окно». Решение начинается с того, что окно находится в картах, которые остались.
- Категория
- Разбор задачи
- Уровень
- Средний
- Опирается на
- Главы 9.2–9.7; глава 8.11: этапы interview canvas
- Зачем это знать
- Узнать окно в задаче, где оно не названо, и уложиться в 25 минут
§1Этапы и время
Порядок этапов — шаблон interview canvas из КТП, как в главе 8.11: условие → уточнения → примеры → перебор → оптимизация → сложность → код → проверка. Ориентир — 25 минут на задачу.
| Минуты | Этап | Что получается к концу этапа |
|---|---|---|
| 0–2 | Условие | Задача пересказана своими словами, интервьюер подтвердил |
| 2–5 | Уточнения | Известны размер, знак чисел, крайние k, формат ответа |
| 5–8 | Примеры | Пять входов с ответами, среди них k = n |
| 8–10 | Перебор | Названо верное медленное решение и его сложность |
| 10–15 | Оптимизация | Задача сведена к окну, правило сдвига и объяснение |
| 15–16 | Сложность | Время и память быстрого решения, интервьюер согласен |
| 16–21 | Код | Функция записана в редакторе |
| 21–25 | Проверка | Код прогнан руками на примерах, найденные ошибки исправлены |
§2Условие и пересказ
Интервьюер формулирует задачу коротко:
«Карты лежат в ряд, на каждой написано число очков. За один ход можно взять карту с левого или с правого края ряда. Нужно взять ровно k карт так, чтобы сумма очков была наибольшей. Верните эту сумму».
Первое действие — пересказать условие и дождаться подтверждения: «Я беру несколько карт с левого края и остальные с правого, всего k. Порядок ходов на сумму не влияет, важно только, сколько карт взято слева. Нужно вернуть наибольшую сумму». Пересказ уже содержит первый вывод: взятые карты — это начало ряда длины left и конец ряда длины k − left.
§3Уточнения
Вопросы те же по смыслу, что в главе 9.2, ответы взяты из ограничений LeetCode. Задайте их по очереди.
Решение меняет первый ответ: раз порядок не важен, взятые карты всегда образуют префикс и суффикс ряда, а способов всего k + 1.
§4Примеры
Пять входов, записанных до кода. Первые три — примеры из условия LeetCode, остальные проверяют ответы на уточнения. Ответы получены запуском итогового решения и совпадают с перебором.
| cards | k | Ответ | Что проверяет |
|---|---|---|---|
[1, 2, 3, 4, 5, 6, 1] | 3 | 12 | все три карты справа: 5 + 6 + 1 |
[2, 2, 2] | 2 | 4 | все способы дают одну сумму |
[9, 7, 7, 9, 7, 7, 9] | 7 | 55 | k = n: взяты все карты |
[100, 40, 17, 9, 73, 75] | 3 | 248 | одна слева и две справа: 100 + 73 + 75 |
[1, 1000, 1] | 1 | 1 | самая дорогая карта в середине недоступна |
§5Перебор
Перебор следует из пересказа: для каждого left от 0 до k взять left карт слева и k − left справа и сложить. «Проверю все k + 1 способов, сумму каждого считаю срезами — это O(k) на способ, всего O(k²). При k = 10⁵ это около 10¹⁰ операций, слишком долго».
def max_card_points_brute(cards, k): best = 0 for left in range(k + 1): right = k - left points = sum(cards[:left]) + sum(cards[len(cards) - right:]) best = max(best, points) return best
Python в этой строке: cards[len(cards) - right:]
Срез с индекса n − right до конца — последние right элементов. Запись cards[-right:] короче, но при right = 0 она даёт весь список: cards[-0:] — это cards[0:]. Поэтому здесь индекс начала записан явно.
>>> cards = [1, 2, 3, 4, 5, 6, 1] >>> cards[len(cards) - 2:] [6, 1] >>> cards[len(cards) - 0:] [] >>> cards[-0:] [1, 2, 3, 4, 5, 6, 1]
Перебор повторяет ту же работу, что и срезы в главе 9.3. Соседние способы отличаются одной картой: она переходит из правой части в левую. А сумма каждого способа считается заново.
§6Оптимизация: окно в оставшихся картах
Задача похожа на окно, но окно в условии не названо: взятые карты лежат по краям, не подряд. Решение находится, если посмотреть на карты, которые остались. Пусть взяты left карт слева и k − left справа. Тогда остались карты с индекса left по left + n − k − 1. Это отрезок подряд, и его длина всегда n − k.
left = 0: · · · · [5 6 1] остались [1 2 3 4], сумма 10 → взято 22 − 10 = 12 left = 1: [1] · · · · [6 1] остались [2 3 4 5], сумма 14 → взято 22 − 14 = 8 left = 2: [1 2] · · · · [1] остались [3 4 5 6], сумма 18 → взято 22 − 18 = 4 left = 3: [1 2 3] · · · · остались [4 5 6 1], сумма 16 → взято 22 − 16 = 6
Сумма взятых карт равна сумме всех карт минус сумма оставшихся. Сумма всех карт для всех способов одна и та же. Значит, наибольшая сумма взятых карт получается там, где сумма оставшегося отрезка наименьшая. Задача сводится к уже решённой: найти окно длины n − k с наименьшей суммой — это best_window из главы 9.4 со знаком «меньше».
- Длина окна.
size = n − k— сколько карт останется. - Первое окно.
total = sum(cards[:size]), оно же пока наименьшее. - Сдвиг. Для каждого
rотsizeдоn − 1:total += cards[r] − cards[r − size], запомнить наименьшую сумму. Сдвиг окна на одну позицию — это одна карта, которая переходит из правой взятой части в левую. - Ответ.
sum(cards) − smallest.
Блок ниже — окно над картами примера, но ищется наименьшая сумма. Сдвиньте окно до конца.
Ту же задачу решают и без окна: начать со способа «все k карт слева» и по одной переносить карту из левой части в правую, исправляя сумму вычитанием и сложением. Это k шагов, время O(k). Оба способа верны, отсюда и две метки на LeetCode — Sliding Window и Prefix Sum. Решение через окно сводит задачу к уже написанной функции best_window.
§7Сложность
Сложность называют до кода.
| Часть решения | Время | Дополнительная память |
|---|---|---|
Первое окно sum(cards[:size]) | O(n − k) | O(n − k) на срез |
| Сдвиги окна | O(k): их k штук | O(1) |
| Сумма всех карт | O(n) | O(1) |
| Всё решение | O(n) | O(n − k) на один срез, без него — O(1) |
Срез первого окна создаёт временный список. Если на review спросят про память O(1), первое окно складывают циклом по индексам или выражением sum(cards[i] for i in range(size)) — оно не создаёт копию.
§8Код
На секции код пишет кандидат, поэтому до готового кода напишите функцию сами в окне ниже. «Запустить тесты» проверяет её на пяти примерах из §4 и сравнивает с перебором на трёхстах случайных раскладах; часть тестов заготовка проходит сразу. Первый запуск загружает Python в браузер, около 10 МБ.
def max_card_points(cards, k): size = len(cards) - k total = sum(cards[:size]) smallest = total for r in range(size, len(cards)): total += cards[r] - cards[r - size] smallest = min(smallest, total) return sum(cards) - smallest
При k = n длина окна 0: первое окно пустое, его сумма 0. В цикле r − size = r, и на каждом шаге прибавляется и вычитается одна и та же карта — сумма остаётся 0. Ответ — сумма всех карт, что и требуется. Отдельный if для этого случая не нужен, тест test_take_all это проверяет.
§9Проверка
Готовый код проверяют на примерах из §4. Первый пример проходят шаг за шагом; ниже тот же проход — пошаговым прогоном. Голубые ячейки — оставшиеся карты, перечёркнутая — карта, которая только что перешла во взятые слева.
Остальные примеры проверяют быстрее. На [2, 2, 2] с k = 2 окно длины 1, все его суммы равны 2, ответ 6 − 2 = 4. На [9, 7, 7, 9, 7, 7, 9] с k = 7 окно пустое, ответ 55. На [1, 1000, 1] с k = 1 окно длины 2 даёт суммы 1001 и 1001, ответ 1002 − 1001 = 1. Все ответы совпадают с таблицей в §4.
По ходу секции кандидат объясняет интервьюеру каждое решение. Ниже — три фразы для этой задачи и этап, к которому относится каждая.
«Взятые карты — начало и конец ряда. Значит, оставшиеся — отрезок подряд длины n − k. Сумма взятых — общая сумма минус сумма оставшихся».
ЭтапОптимизация: сведение к окну
«Ищу окно длины n − k с наименьшей суммой. Сдвиг окна — одна карта переходит из правой части в левую, сумма исправляется за O(1)».
ЭтапПравило и обоснование
«Итого O(n) по времени. При k = n окно пустое, и код это обрабатывает без отдельной проверки».
ЭтапСложность и крайний случай
§10Проверьте себя
Вопрос 1Почему наибольшая сумма взятых карт соответствует наименьшей сумме оставшихся?
Это не так: при k = n оставшихся нет вовсе. Дело в постоянной сумме всех карт.
Верно. Взятые = все − оставшиеся. Чем меньше вычитаемое, тем больше разность.
Знак очков на это не влияет: равенство «взятые = все − оставшиеся» верно при любых числах.
Вопрос 2Карт 10, взять нужно 4. Какой длины окно и сколько у него положений?
Длина 4 — это взятые карты, а они лежат не подряд. Окно — оставшиеся карты.
Верно. Остаётся 10 − 4 = 6 карт, окон длины 6 в списке из 10 — 10 − 6 + 1 = 5, по числу способов k + 1.
Окно длины 6 не может начинаться с индекса 5 и дальше: ему не хватит карт. Положений n − (n − k) + 1 = k + 1.
Вопрос 3Чем решение через окно лучше перебора способов?
Перебор верен при любом k. Он медленнее: каждую сумму считает заново.
Способов столько же, k + 1. Разница в том, сколько стоит переход к следующему.
Верно. Время O(n) против O(k²): при k = 10⁵ это 10⁵ шагов против 10¹⁰.