Алгоритмы на массивах · модуль 10 · окно переменного размера
Глава 10.3
Условие допустимости,
расширение и сжатие
В этой главе механизм окна переменного размера разобран на простой задаче: найти самый длинный отрезок с суммой не больше лимита. Сначала — решение перебором всех отрезков и его цена. Затем — схема прохода: правая граница расширяет окно, цикл while сжимает его слева, пока нарушено условие допустимости. Отдельно показано, почему левая граница не возвращается назад и откуда берётся время O(n). Проход нужно сделать руками, собрать цикл из строк, написать функцию в песочнице и посмотреть пошаговый прогон. В конце — две ошибки в порядке строк, каждая с прогоном.
- Категория
- Алгоритм
- Уровень
- Начальный
- Опирается на
- Главы 10.1 и 10.2; глава 9.5: цикл
whileдля левой границы - Зачем это знать
- На этой схеме построены обе задачи КТП занятия 10
§1Задача: самый длинный отрезок в пределах лимита
Задачи КТП разобраны в главах 10.4 и 10.5. Здесь — задача, на которой схема прохода видна без дополнительных деталей. Состояние окна в ней — одно число, сумма, как в модуле 9. Ответ — одно число, длина. Задача уже встречалась в тренажёре главы 10.2: расходы по дням и бюджет.
longest_within(nums, limit)самый длинный отрезок с суммой не больше лимита
nums — список целых неотрицательных чисел, может быть пустым. limit — лимит суммы, limit ≥ 0, иначе ValueError. Вернуть длину самого длинного отрезка подряд, сумма которого не больше limit. Если такого отрезка нет, вернуть 0. Исходный список не меняется.
Пример: расходы по дням nums = [4, 2, 5, 1, 1, 2, 1, 9], limit = 7. Ответ 4: отрезок [1, 1, 2, 1] с суммой 5. Отрезка длины 5 с суммой не больше 7 в списке нет.
Условие допустимости здесь — total <= limit. Числа неотрицательные, поэтому расширение окна сумму не уменьшает, а сжатие не увеличивает. Это требование из главы 10.2, без него схема не работает.
§2Решение перебором
Самое прямое решение — проверить все отрезки. Внешний цикл выбирает начало отрезка l, внутренний двигает конец r от l до конца списка. Сумма отрезка накапливается в total: при каждом сдвиге r к ней прибавляется один элемент. Если сумма не больше лимита, длина отрезка сравнивается с лучшей. Это решение пишется сразу и служит эталоном: с ним сравнивают быстрое.
def longest_within_brute(nums, limit): if limit < 0: raise ValueError("limit должно быть не меньше 0") best = 0 for l in range(len(nums)): total = 0 for r in range(l, len(nums)): total += nums[r] if total <= limit: best = max(best, r - l + 1) return best
Python в этой строке: best = max(best, r - l + 1)
Отрезок с индекса l по индекс r включительно содержит r − l + 1 элементов: с 3 по 6 — это индексы 3, 4, 5, 6, четыре элемента. Функция max возвращает большее из двух чисел, поэтому в best остаётся наибольшая из уже встреченных длин.
>>> best = 3 >>> r, l = 6, 3 >>> r - l + 1 4 >>> best = max(best, r - l + 1) >>> best 4 >>> max(4, 0) 4
Прогон ниже сделан на первых четырёх числах примера, чтобы он не был слишком длинным: в списке из четырёх элементов 10 отрезков, в списке из восьми — 36. Голубые ячейки — текущий отрезок с l по r.
На шагах 15 и 18 видно лишнюю работу. Отрезок nums[0:3] с суммой 11 уже больше лимита, а перебор всё равно проверяет nums[0:4] с суммой 12. На шаге 21 видно вторую потерю: при переходе к l = 1 сумма сбрасывается в ноль и набирается заново. При этом сумма nums[1:3] отличается от уже посчитанной суммы nums[0:3] на один элемент.
§3Сколько стоит перебор
Отрезков с началом 0 в списке n штук, с началом 1 — на один меньше, с началом n − 1 — один. Всего n + (n − 1) + … + 1 = n·(n + 1)/2. На каждый отрезок перебор тратит одно сложение и одно сравнение, поэтому время — O(n²).
| n | Отрезков: n·(n + 1)/2 | Сдвигов границ у окна: не больше 2·n |
|---|---|---|
| 8 | 36 | 16 |
| 1 000 | 500 500 | 2 000 |
| 100 000 | 5 000 050 000 | 200 000 |
| 1 000 000 | 500 000 500 000 | 2 000 000 |
Перебор можно ускорить оператором break: выйти из внутреннего цикла, как только сумма превысила лимит. На списке, где все отрезки допустимы, например из одних нулей, break не сработает ни разу, и проверок останется столько же. Поэтому оценка в худшем случае та же, O(n²). Третий столбец таблицы — число шагов окна, оно получено в §5.
§4Схема прохода
Обе потери перебора убирает одна идея: при переходе к следующему отрезку менять сумму на один элемент и не проверять отрезки, про которые ответ уже известен. Сравнение из жизни: покупатель идёт вдоль полки и кладёт в корзину каждый следующий товар. Если сумма превысила деньги в кошельке, он выкладывает товары, взятые раньше всех, пока сумма снова не уложится. Корзину заново он не собирает и к пройденной части полки не возвращается.
В коде корзина — окно, сумма покупок — total, кошелёк — limit. На каждом шаге внешнего цикла выполняются три действия.
- Расширение. Правая граница
rсдвигается на один элемент, он добавляется в состояние окна:total += nums[r]. - Сжатие. Пока условие допустимости нарушено, то есть пока
total > limit, самый левый элемент убирается из состояния окна. Затем левая граница сдвигается:total -= nums[l],l += 1. - Запись. После сжатия окно допустимо. Его длина
r − l + 1сравнивается с лучшей.
окно [4, 2], сумма 6 допустимо, длина 2 расширение: пришло 5 окно [4, 2, 5], сумма 11 > 7 сжатие: ушло 4 окно [2, 5], сумма 7 ≤ 7, цикл while закончен запись: длина 2 best = max(2, 2) = 2
В этой схеме r — индекс последнего элемента окна, как в главе 9.5. Поэтому в окно входят элементы с l по r включительно, и длина окна — r − l + 1. В главе 10.1 окно записано полуинтервалом [l, r) с длиной r − l. Это две записи одного и того же окна: правая граница полуинтервала на единицу больше индекса последнего элемента.
Почему сжатие — цикл whileусловие цикла — отрицание условия допустимости
После расширения одного сжатия может не хватить. В примере из §1 в окно [1, 1, 2, 1] с суммой 5 приходит число 9, сумма становится 14. Чтобы она снова уложилась в лимит 7, из окна нужно убрать все пять элементов. Сколько раз сдвигать левую границу, заранее неизвестно, поэтому сдвиг стоит в цикле с условием.
Условие цикла — отрицание условия допустимости. Допустимо: total <= limit. Цикл работает, пока недопустимо: while total > limit. Когда цикл закончился, условие допустимости выполнено.
Python в этой строке: while с двумя строками в теле
Цикл while проверяет условие перед каждым повтором и выполняет тело, пока условие истинно. Число повторов заранее не задано: оно зависит от значений переменных. Ниже — сжатие окна после прихода числа 9: тело выполнилось пять раз, после пятого сумма стала 0 и условие total > 7 стало ложным.
>>> total = 14 >>> l = 3 >>> nums = [4, 2, 5, 1, 1, 2, 1, 9] >>> while total > 7: ... total -= nums[l] ... l += 1 ... print(l, total) ... 4 13 5 12 6 10 7 9 8 0
В выводе видно, что окно может стать пустым: l = 8 стоит правее r = 7, сумма равна 0, длина r − l + 1 равна 0. Так происходит, когда один элемент больше лимита. Отдельной проверки для этого случая не нужно: сумма пустого окна равна 0, лимит не меньше 0, поэтому цикл останавливается раньше, чем l выйдет за границу списка.
§5Почему левая граница не возвращается назад
Перебор для каждого конца отрезка проверяет все начала. Окно этого не делает: после сдвига правой границы левая остаётся там, где была, или уходит вправо. Нужно убедиться, что при этом не пропущен ни один отрезок, который мог быть ответом.
Рассуждение на числах примера. При r = 2 окно nums[0:3] = [4, 2, 5] имеет сумму 11, и левая граница сдвигается на 1. Затем правая граница переходит на r = 3. Отрезок с началом 0 и концом 3 — это [4, 2, 5, 1]. Он содержит отрезок [4, 2, 5] целиком и ещё один элемент. Числа неотрицательные, поэтому его сумма не меньше 11 и тоже больше лимита. Проверять его не нужно, и возвращать левую границу в 0 незачем.
Правилоначала, которые левая граница уже прошла
Если левая граница ушла с позиции l, значит отрезок с началом l и концом в текущем r недопустим. Любой отрезок с тем же началом и более правым концом содержит его целиком, поэтому его сумма не меньше, и он тоже недопустим. Начало l не подходит ни для одного из следующих положений правой границы.
Из этого следует и обратное: окно после сжатия — самый длинный допустимый отрезок с концом в r. Начала левее l отброшены как недопустимые, а начала правее дают более короткий отрезок.
Из правила получается оценка времени. Правая граница сдвигается n раз: по одному разу на каждый повтор цикла for. Левая граница двигается только вправо и не обгоняет правую больше чем на одну позицию, поэтому за весь проход она сдвигается не больше n раз. Всего сдвигов не больше 2·n, каждый стоит одно сложение или вычитание.
В коде цикл while стоит внутри цикла for, но время при этом не O(n²). Оценку «цикл в цикле — n² шагов» дают, когда внутренний цикл на каждом повторе внешнего начинается заново, как в переборе из §2. Здесь переменная l между повторами внешнего цикла не сбрасывается. Тело while за весь проход выполняется не больше n раз в сумме: на одном шаге внешнего цикла оно может выполниться пять раз, на других — ни разу. В примере из §1 цикл for делает 8 повторов, и тело while за весь проход выполняется тоже 8 раз. По одному разу — при r = 2, r = 3 и r = 5, пять раз — при r = 7.
§6Расширение и сжатие вручную
Прежде чем смотреть код, сделайте проход сами на другом списке. На каждом шаге выберите, какую границу сдвинуть. Длина допустимого окна сравнивается с лучшей автоматически. Неверный ход не выполняется, вместо него появляется объяснение.
§7Сборка цикла из строк
Начало функции уже написано: проверка limit и три переменные — best = 0, total = 0, l = 0. Ниже семь строк, из которых состоит остальная часть: цикл с расширением, сжатием и записью и возврат ответа. Расставьте их по порядку. Готовая функция — в §8.
Напишите функцию сами в окне ниже, прямо на странице. «Запустить тесты» проверяет её на десяти тестах. Среди них сумма точно на лимите, элемент больше лимита, пустой список, неверный лимит и сравнение с перебором на двухстах случайных списках. Часть тестов заготовка проходит сразу. Первый запуск загружает Python в браузер, около 10 МБ.
§8Код и пошаговый прогон
def longest_within(nums, limit): if limit < 0: raise ValueError("limit должно быть не меньше 0") best = 0 total = 0 l = 0 for r in range(len(nums)): total += nums[r] while total > limit: total -= nums[l] l += 1 best = max(best, r - l + 1) return best
Прогон на примере из §1. Голубые ячейки — окно с l по r, бледные — элементы, которые из окна ушли. В прогоне стоит проследить за тремя местами. На шагах 15–18 одно сжатие возвращает окно в лимит. На шагах 44–59 после прихода числа 9 цикл while выполняется пять раз подряд, и окно становится пустым. На шаге 41 записывается ответ — длина 4.
Инвариантокно переменного размера
После цикла while на шаге с индексом r выполнены три условия. total — сумма элементов с l по r, и эта сумма не больше limit. Окно с l по r — самый длинный допустимый отрезок с концом в r. В best — наибольшая длина такого отрезка среди всех уже пройденных концов.
Инициализация: до цикла окно пустое, total = 0, best = 0. Сохранение: расширение добавляет в сумму nums[r], каждое сжатие убирает из неё nums[l] перед сдвигом l, поэтому total всегда равна сумме окна. Цикл while заканчивается при total <= limit, а по правилу из §5 отброшенные начала не подходят. Завершение: правая граница прошла все индексы, значит в best — длина самого длинного допустимого отрезка всего списка.
Это ответ на контрольный вопрос занятия из КТП: «Какой инвариант остаётся истинным после каждого перемещения границы?» Для окна переменного размера он состоит из двух частей: состояние окна соответствует его границам, и после сжатия окно допустимо.
§9Две ошибки в порядке строк
В КТП типичная ошибка занятия названа так: «неверное обновление состояния до или после сдвига границы». В обеих версиях ниже строки те же, что в верной функции, изменён только их порядок. Каждая запущена на примере из §1, и её можно пройти по шагам. Верный ответ — 4.
longest_within с переставленными строкамипереключите вкладку
Строка best = max(best, r - l + 1) стоит сразу после расширения, перед циклом while.
5Длина записывается для окна, которое ещё не проверено. На шаге 15 в best попадает длина 3 окна [4, 2, 5] с суммой 11. На шаге 44 — длина 5 окна [1, 1, 2, 1, 9] с суммой 14, и функция возвращает 5 вместо 4. Запись должна стоять после цикла while: только там окно допустимо.
В теле цикла while строки переставлены: сначала l += 1, затем total -= nums[l].
3На шаге 17 левая граница уже стоит на индексе 1, и из суммы вычитается nums[1] = 2. Это элемент, который остался в окне. Ушедший nums[0] = 4 остался в сумме. После этого total не равна сумме окна, цикл while останавливается не там, где нужно, и функция возвращает 3 вместо 4. На списке [9, 1] с лимитом 8 эта версия заканчивается исключением IndexError: индекс l выходит за конец списка.
Верная версия — прогон в §8. Обе ошибки находят тесты песочницы test_example, test_no_window, test_zeros и сравнение с перебором, вторую — ещё и test_big_element. Порядок строк в теле цикла запоминают по смыслу: элемент сначала убирают из состояния окна и только после этого сдвигают границу, иначе индекс l уже показывает на другой элемент.
§10Проверьте себя
Вопрос 1Лимит 7. Окно [2, 5] с суммой 7, приходит элемент 1. Что произойдёт на этом шаге внешнего цикла?
Сумма окна
[2, 5, 1]равна 8, это больше лимита. Перед записью циклwhileсожмёт окно.Верно. После расширения сумма 8 больше 7. Одно сжатие убирает 2, сумма 6 укладывается в лимит, и с лучшей сравнивается длина 2.
Окно сжимается по одному элементу и останавливается, как только сумма уложилась в лимит. После ухода числа 2 сумма уже равна 6.
Вопрос 2В списке 1 000 элементов. Сколько раз за весь вызов функции может выполниться тело цикла while?
Переменная
lмежду повторами циклаforне сбрасывается и только растёт. Сдвиги, сделанные на одном повторе, на следующих не повторяются.Число повторов
whileзависит от данных: на одном шаге тело может выполниться пять раз, на другом — ни разу. Ограничена только сумма.Верно. Каждое выполнение тела увеличивает
lна 1, аlне превышает длину списка. Поэтому весь проход стоит O(n).
Вопрос 3Условие допустимости задачи — «нулей в окне не больше k», счётчик нулей хранится в zeros. Какое условие у цикла сжатия?
Верно. Условие цикла — отрицание условия допустимости: допустимо при
zeros <= k, сжимать нужно приzeros > k.Так цикл сжимал бы допустимое окно. В задаче на самый длинный отрезок сжимают только недопустимое окно.
Окно, в котором нулей точно k, допустимо: в условии сказано «не больше k». Такая версия сжимала бы его зря и теряла длину.