Модуль 10 · Окно переменного размера · ОП.04

Алгоритмы на массивах · модуль 10 · окно переменного размера

Глава 10.6

10.6

Границы: пустая строка,
повторы, ответа нет

В этой главе разобран контроль КТП занятия 10: «Кандидат объясняет условие while и состояние окна. Проверяются дубликаты и пустая строка». Сначала обе функции занятия разобраны по строкам: какая строка отвечает за какой граничный вход. Затем запущены четыре версии, в которых одна строка записана иначе, — каждая рядом с верной на том же входе. Дальше — что все функции модуля возвращают на граничных входах и чем ответ «отрезка нет» отличается от ошибки вызова. В конце — тесты границ из стартового набора с настоящим выводом pytest, функция для исправления и разбор того, как объяснить условие while.

Категория
Проверка решения
Уровень
Начальный
Опирается на
Главы 10.4 и 10.5: обе задачи КТП; глава 9.6: тесты границ и исключения
Зачем это знать
Контроль занятия по КТП и вопросы интервьюера о крайних случаях

§1Разбор двух функций по строкам

В главах 10.4 и 10.5 функции собраны из строк и пройдены по шагам на обычных входах. Здесь каждая строка разобрана с другой стороны: на каком граничном входе она проявляется и что получится, если записать её иначе. Граничный вход — это вход на краю допустимого: пустой список, один элемент, порог, которого не достигает сумма, строка из одинаковых символов. На таких входах цикл выполняется ноль раз или один раз, и ошибки, незаметные на обычном примере, дают неверный ответ.

solution.pyshortest_window из главы 10.4
def shortest_window(nums, s):
    if s < 1:
        raise ValueError("s должно быть не меньше 1")
    best = 0
    total = 0
    l = 0
    for r in range(len(nums)):
        total += nums[r]
        while total >= s:
            length = r - l + 1
            if best == 0 or length < best:
                best = length
            total -= nums[l]
            l += 1
    return best
СтрокаГраничный входЕсли записать иначе
if s < 1: и raises = 0, s < 0Без проверки пустое окно с суммой 0 считается подходящим, цикл while не останавливается, и индекс l выходит за конец списка: IndexError, §2
best = 0Отрезка нет: сумма всего списка меньше sbest = len(nums) как «наибольшая возможная длина» — функция возвращает длину списка там, где ответ 0, §2
for r in range(len(nums)):Пустой списокОтдельная проверка if not nums: return 0 не нужна: цикл не выполняется, возвращается best = 0
while total >= s:Сумма отрезка точно равна s> — окна с суммой точно на пороге не записываются, глава 10.4, §6
if best == 0 or length < best:Первое подходящее окноbest = min(best, length) при best = 0 — ответ всегда 0, глава 10.4, §7
total -= nums[l], затем l += 1Один элемент не меньше порога: окно сжимается до пустогоОбратный порядок вычитает элемент, оставшийся в окне, глава 10.3, §9

У второй функции проверки входа нет: у единственного параметра неверных значений не бывает. Граничные входы у неё другие — пустая строка, повторы и вопросы о том, что считать одинаковыми символами.

solution.pylongest_unique из главы 10.5
def longest_unique(text):
    count = {}
    best = 0
    l = 0
    for r in range(len(text)):
        ch = text[r]
        count[ch] = count.get(ch, 0) + 1
        while count[ch] > 1:
            count[text[l]] -= 1
            l += 1
        best = max(best, r - l + 1)
    return best
СтрокаГраничный входЕсли записать иначе
best = 0Пустая строкаbest = 1 — «подстрока из одного символа всегда есть»: для пустой строки функция возвращает 1, §2
ch = text[r]Буквы разного регистра: 'aA'text[r].lower() — 'a' и 'A' попадают под один ключ и считаются повтором, §2
count[ch] = count.get(ch, 0) + 1Символ встретился впервыеcount[ch] += 1 без get — исключение KeyError на первом же символе
while count[ch] > 1:Повтор, перед которым в окне стоят другие символы: 'abba'if — одно сжатие, повтор остаётся в окне, глава 10.5, §7
count[text[l]] -= 1Слева уходит не тот символ, что повторился: 'pwwkew'count[ch] -= 1 — карта перестаёт соответствовать окну, глава 10.5, §7
for r in range(len(text)):Пустая строкаОтдельная проверка не нужна: цикл не выполняется, возвращается best = 0

§2Запуск изменённых версий

Четыре изменения из таблиц, которые в главах 10.3–10.5 ещё не запускались. На каждой вкладке сначала прогон версии с изменённой строкой, под ним — верная версия на том же входе. Вход подобран так, чтобы изменение проявилось.

Функции с одной изменённой строкойпереключите вкладку

В shortest_window нет проверки порога. Вход — [1, 2], s = 0.

изменённая версия

IndexErrorПри пороге 0 подходит любое окно, в том числе пустое: его сумма 0 не меньше 0. На шаге 12 окно уже пустое, а цикл while продолжается. На шаге 16 из суммы вычитается nums[1] — элемент, который в окно ещё не входил. На шаге 25 индекс l = 2 выходит за конец списка, и функция заканчивается исключением IndexError. На пустом списке эта версия возвращает 0 без исключения, поэтому одного теста на пустой список для неё мало.

верная версия

ValueErrorПроверка в первой строке останавливает функцию до работы с окном. Исключение называет неверный аргумент.

В shortest_window начальное значение ответа — длина списка, сравнение — if length < best. Вход — [1, 1, 1, 1], s = 5: сумма всего списка 4, отрезка нет.

изменённая версия

4Цикл while не выполняется ни разу, в best остаётся начальное значение 4. Функция сообщает, что подходит весь список. На входе [1, 2, 3] с s = 6 эта версия возвращает верный ответ 3: весь список там действительно подходит. Отличить два случая по ответу нельзя.

верная версия

0Значение 0 не может быть длиной подходящего окна, поэтому оно однозначно означает «отрезка нет».

В longest_unique начальное значение ответа — 1. Вход — пустая строка.

изменённая версия

1Цикл не выполняется, функция возвращает начальное значение. На любой непустой строке эта версия верна: подстрока из одного символа в ней есть, и ответ не меньше 1. Ошибка видна только на пустой строке — поэтому контроль КТП называет её отдельно.

верная версия

0В пустой строке нет подстрок, кроме пустой, её длина 0.

В longest_unique символ приводится к нижнему регистру: ch = text[r].lower(). Вход — 'aA'.

изменённая версия

1На шаге 10 буква A записывается в карту под ключом 'a', и на шаге 12 окно 'aA' считается окном с повтором. По контракту из главы 10.2 регистр различается, верный ответ 2. На строке 'Aa' эта версия заканчивается исключением KeyError: в карте есть только ключ 'a', а при сжатии уменьшается счётчик 'A'.

верная версия

2'a' и 'A' — разные ключи словаря, повтора нет.

Если интервьюер ответил бы, что регистр не различается, верной стала бы версия с приведением регистра, но приводить нужно всю строку до цикла: text = text.lower(). Тогда и расширение, и сжатие работают с одними и теми же символами, и KeyError не возникает.

§3Граничные входы всех функций

В модуле шесть функций. Значения в таблицах получены запуском эталонных решений. Первая таблица — функции с порогом или лимитом.

Входshortest_window(nums, s)longest_within(nums, limit)
пустой список, порог 300
[7], порог 711
[7], порог 8 / лимит 600
[0, 0, 0], порог 1 / лимит 003
[5, 5], порог 511
порог 0 / лимит −1ValueErrorValueError

У shortest_window и longest_within ноль в списке ведёт себя по-разному. Для порога сумма из нулей никогда не достигает 1, поэтому ответ 0. Для лимита 0 нули допустимы, и самый длинный отрезок — весь список. Вторая таблица — функции главы 10.5 и задачи с LeetCode.

Входlongest_uniquelongest_unique_jumpcount_unique_windows
пустой вход000
один элемент111
два одинаковых112
'abba' / [1, 2, 1, 3]228

longest_ones из главы 10.4 — решение LeetCode 1004, где условие гарантирует 0 ≤ k ≤ len(nums) и непустой список, поэтому проверки входа в ней нет. На пустом списке она возвращает 0, на [0, 0] с k = 0 — тоже 0. На k = −1 она заканчивается исключением IndexError: условие zeros > −1 истинно и для пустого окна, как условие total >= 0 в первой вкладке §2.

На полях: почему пустой вход проверяют отдельно

Ни в одной функции модуля нет строки if not nums. Пустой вход всё равно нужно проверять тестом: верный ответ на нём даёт только начальное значение ответа, а оно не участвует ни в одном шаге обычного прогона. Ошибка best = 1 из §2 не видна ни на одном непустом входе.

§4Ответ «отрезка нет» и ошибка вызова

В задаче о минимальном отрезке два разных случая, когда ответа в обычном смысле нет. Первый — подходящего отрезка в списке нет, например сумма всех чисел меньше порога. Второй — неверный вызов: порог 0 или отрицательный. Интервьюер в главе 10.2 дал на них разные ответы: в первом случае вернуть 0, во втором — исключение ValueError.

Различие следует из того, где источник проблемы: в данных или в вызове функции. Список без подходящего отрезка — обычные данные: за час с маленькими продажами ответ «не набралось» правильный, и программа продолжает работу. Порог 0 — ошибка в коде, который вызвал функцию: задача с таким порогом не имеет смысла, и продолжать работу с ответом на неё нельзя. Об ошибках вызова, как в главе 9.6, сообщают исключением.

Способ сообщить «отрезка нет»Где встречаетсяЧто нужно вызывающему коду
0LeetCode 209, этот модульПроверить if length == 0. Ноль не бывает длиной подходящего отрезка, поэтому путаницы нет
-1LeetCode 862 — тот же вопрос, но числа бывают отрицательнымиПроверить if length == -1. Выбор между 0 и −1 — договорённость, о ней спрашивают до кода
NoneФункции, которые возвращают сам отрезокПроверить if result is None; без проверки ошибка появится позже, при работе с результатом
ИсключениеВ этом модуле — только для неверного порогаДля отсутствия отрезка не подходит: это обычный результат, его не перехватывают через try

Для longest_unique вопроса «что вернуть, если ответа нет» не возникает: в любой непустой строке есть подстрока из одного символа без повторов. Остаётся только пустая строка, для неё ответ 0 — длина пустой подстроки.

§5Тесты границ

Тесты ниже — файл tests.py стартового набора занятия 10 в его нынешнем виде: проверки границ обеих функций. Остальные тесты будут добавлены к нему в главе 10.10, эти при этом не изменятся. Каждый тест параметризован: один набор строк parametrize даёт несколько проверок, а параметр ids задаёт им короткие имена, которые видны в выводе pytest.

tests.pyграницы обеих функций
import pytest

from solution import longest_unique, shortest_window


@pytest.mark.parametrize("s", [0, -1], ids=["s_zero", "s_negative"])
def test_bad_s_raises(s):
    with pytest.raises(ValueError):
        shortest_window([1, 2], s)


@pytest.mark.parametrize("nums, s, want", [
    ([], 3, 0),
    ([1, 1, 1, 1], 5, 0),
    ([7], 7, 1),
    ([7], 8, 0),
    ([1, 2, 3], 6, 3),
], ids=["empty", "no_window", "one_exact", "one_short", "whole_list"])
def test_shortest_edges(nums, s, want):
    assert shortest_window(nums, s) == want


@pytest.mark.parametrize("text, want", [
    ("", 0),
    ("z", 1),
    ("bbbbb", 1),
    ("pwwkew", 3),
    ("abba", 2),
    ("aA", 2),
    ("a b a", 3),
], ids=["empty", "one_char", "all_same", "adjacent_repeat", "repeat_left_window", "case", "space"])
def test_unique_edges(text, want):
    assert longest_unique(text) == want

Контроль КТП назван в тестах прямо. Пустая строка — test_unique_edges[empty]. Дубликаты — четыре случая: все символы одинаковые, повтор рядом, повтор символа, который уже вышел из окна, и повтор пробела. Вывод на эталонном решении:

Терминалpython -m pytest tests.py -v
collected 14 items

tests.py::test_bad_s_raises[s_zero] PASSED                               [  7%]
tests.py::test_bad_s_raises[s_negative] PASSED                           [ 14%]
tests.py::test_shortest_edges[empty] PASSED                              [ 21%]
tests.py::test_shortest_edges[no_window] PASSED                          [ 28%]
tests.py::test_shortest_edges[one_exact] PASSED                          [ 35%]
tests.py::test_shortest_edges[one_short] PASSED                          [ 42%]
tests.py::test_shortest_edges[whole_list] PASSED                         [ 50%]
tests.py::test_unique_edges[empty] PASSED                                [ 57%]
tests.py::test_unique_edges[one_char] PASSED                             [ 64%]
tests.py::test_unique_edges[all_same] PASSED                             [ 71%]
tests.py::test_unique_edges[adjacent_repeat] PASSED                      [ 78%]
tests.py::test_unique_edges[repeat_left_window] PASSED                   [ 85%]
tests.py::test_unique_edges[case] PASSED                                 [ 92%]
tests.py::test_unique_edges[space] PASSED                                [100%]

============================== 14 passed in 0.01s ==============================

Вывод, если в shortest_window убрать проверку порога и начать с best = len(nums), а в longest_unique приводить символ к нижнему регистру, — три изменения из §2:

Терминалpython -m pytest tests.py -q · три изменения из §2
FAILED tests.py::test_bad_s_raises[s_zero] - IndexError: list index out of range
FAILED tests.py::test_bad_s_raises[s_negative] - IndexError: list index out o...
FAILED tests.py::test_shortest_edges[no_window] - assert 4 == 0
FAILED tests.py::test_shortest_edges[one_short] - assert 1 == 0
FAILED tests.py::test_unique_edges[case] - AssertionError: assert 1 == 2
5 failed, 9 passed in 0.02s

Тест whole_list с изменённой версией проходит: на нём best = len(nums) совпадает с верным ответом. Ошибку находят только тесты, где отрезка нет. Тест empty для shortest_window тоже проходит: на пустом списке len(nums) равно 0. Поэтому в наборе есть оба случая «отрезка нет» — пустой список и короткая сумма.

§6Исправьте функции

В окне ниже обе функции занятия, в каждой по две ошибки из §2. В shortest_window нет проверки порога, и ответ начинается с длины списка. В longest_unique ответ начинается с 1, и символ приводится к нижнему регистру. Исправьте их так, чтобы прошли все девять тестов. Сначала запустите тесты и посмотрите, какие падают и с каким сообщением.

§7Как объяснить условие while и состояние окна

Первая часть контроля КТП — «кандидат объясняет условие while и состояние окна». На собеседовании это звучит как вопросы «что хранится в этой переменной между итерациями?» и «почему здесь while с таким условием?». Ответ строится из трёх частей, они одинаковы для всех задач модуля.

  1. Состояние окна. Назвать переменную и то, что она хранит для окна с l по r. Например: «total — сумма элементов окна», «count — для каждого символа окна, сколько раз он в нём встречается». Назвать, как она меняется при сдвиге каждой границы.
  2. Условие цикла. Назвать условие допустимости и объяснить, почему цикл проверяет его или его отрицание. Для короткого отрезка: «сжимаем, пока окно подходит, — total >= s». Для длинного: «сжимаем, пока окно недопустимо, — count[ch] > 1». Назвать, почему здесь нужен цикл: одного сжатия может не хватить, и if оставит повтор в окне.
  3. Инвариант после цикла. Сказать, что верно, когда цикл закончился: «после цикла в окне нет повторов, и это самая длинная подстрока без повторов с концом в r». Из инварианта следуют место записи ответа и время O(n).

Объяснение принято, если кандидат

Называет состояние окна и его смыслНапример: «для каждого символа окна — сколько раз он в нём встречается». Слова «словарь» для этого мало.
Показывает обновление при каждой границеРасширение добавляет пришедший элемент, сжатие убирает ушедший до сдвига l.
Выводит условие цикла из задачиКороткий отрезок — пока окно подходит, длинный — пока недопустимо.
Объясняет, зачем нужен цикл whileИ приводит вход, на котором if ошибается: 'abba'.
Говорит, где записывается ответ и почемуВнутри цикла — для короткого отрезка, после цикла — для длинного.
Называет граничные входы и ответы на нихПустой вход, повторы, отрезка нет, неверный порог.
Разбор 1Кандидат объясняет shortest_window. Какое объяснение условия цикла верное?
while total >= s:
    length = r - l + 1
    ...
    total -= nums[l]
    l += 1
  • Большая сумма ответу не мешает: подходит любое окно с суммой не меньше s. Окно сжимают, чтобы проверить, подойдёт ли окно короче.

  • Верно. Условие цикла совпадает с условием допустимости, длина записывается перед каждым сжатием, а после цикла окно уже не подходит.

  • Слева уходят элементы, которые могли бы войти в более короткое окно, если бы суммы хватило. Причина сжатия — поиск самого короткого окна.

Разбор 2Кандидат говорит: «В count хранятся все символы строки, которые мы уже видели». Что не так в этом объяснении?
  • При сжатии счётчики ушедших символов уменьшаются. Если бы в карте были все просмотренные символы, условие count[ch] > 1 срабатывало бы на повторах вне окна.

  • Позиции хранит словарь last в варианте из главы 10.5, §8. В count — числа вхождений.

  • Верно. Состояние окна описывает только окно с l по r. Символ, который ушёл, остаётся ключом со счётчиком 0 и на условие цикла не влияет.

Разбор 3В Pull Request к longest_unique добавлены тесты. Каких тестов не хватает до контроля КТП?
def test_example():
    assert longest_unique("abcabcbb") == 3

def test_all_different():
    assert longest_unique("abcdef") == 6

def test_case():
    assert longest_unique("aA") == 2
  • Верно. Контроль КТП — «проверяются дубликаты и пустая строка». Пустой строки в тестах нет, а версии с if и с count[ch] -= 1 проходят все три теста.

  • Скорость проверяют замером, глава 10.7. Тесты границ проверяют правильность на крайних входах.

  • На 'abcabcbb' версия с count[ch] -= 1 возвращает верный ответ 3, глава 10.5, §7. Одного примера с повторами мало.

Модуль 10 · Окно переменного размера · ОП.04 · 3 курс Автор: Макаров Максим Николаевич