Корректность решения: вопросы, тесты и инварианты
Лекция о том, как убедиться, что программа решает именно поставленную задачу. Разбираем три вещи, которыми это проверяют: уточняющие вопросы к условию, тестовые примеры и инвариант цикла.
1. Что значит «решение верное»
Частый случай: функция написана, три примера дали верный ответ. Через день её вызывает другой человек на своих данных и получает не то. Ошибки при этом может не быть ни у кого — вы по-разному поняли, что функция должна делать.
Правильность программы определяется условием задачи: есть описание того, что считается верным ответом, и решение либо ему соответствует, либо нет. Условие в учебнике, условие на собеседовании, задача в трекере, техническое задание — всё это формы одного и того же: описания требуемого поведения.
Такое описание почти никогда не бывает полным. «Найдите второй максимум», «сожмите строку», «посчитайте слова» — за каждой такой фразой прячется десяток случаев, о которых не сказано ни слова. Что делать с пустым входом? Считать ли одинаковые значения одним? Что вернуть, если ответа нет? Пока эти случаи не оговорены, у задачи не один правильный ответ, а несколько, и любая проверка превращается в спор.
Условие, которое читается двумя способами
Возьмём задачу про сжатие строки. Формулировка звучит так: «Сожмите строку, записывая подряд идущие одинаковые символы как символ и количество: aaabb → a3b2». Пример в условии есть, и кажется, что этого достаточно.
Но пример ничего не говорит про символ, который встретился один раз. Писать a1 или просто a? Обе трактовки разумны, обе дают на примере из условия один и тот же ответ, и обе дают разные ответы на строке abc:
def sjat_vsegda_s_chislom(text):
"""Пишет число всегда, даже когда символ один: abc → a1b1c1."""
def sjat_bez_edinic(text):
"""Одиночный символ пишет без числа: abc → abc."""'aaabb' с числом всегда: 'a3b2' без единиц: 'a3b2'
'abc' с числом всегда: 'a1b1c1' без единиц: 'abc'
'' с числом всегда: '' без единиц: ''На aaabb версии совпали, на abc разошлись. Если такой вопрос не задать, версию выберет случайность — то, как вы прочитали условие в первый раз. Расхождение вскроется, когда результат сверят с ожиданиями заказчика.
Контракт функции
Чтобы прекратить споры, договорённости записывают. Такая запись называется контрактом.
Описание того, какие входные данные допустимы, что функция возвращает и как она ведёт себя в особых случаях: на пустом входе, при повторах, когда ответа не существует. Контракт пишут до реализации: по нему составляют тесты и по нему же проверяют готовый код.
У контракта две половины, и у них есть свои имена. Предусловие — то, что должно быть верно на входе; за это отвечает тот, кто вызывает функцию. Постусловие — то, что будет верно на выходе, если предусловие соблюдено; за это отвечает сама функция.
Работает это как договор проката: прокат обещает исправную машину (постусловие), но при условии, что у вас есть права и вы вернёте её с тем же баком (предусловие). Если условие нарушил клиент — претензий к прокату нет. Если клиент всё выполнил, а машина сломалась — виноват прокат.
В Python обе половины можно записать прямо в коде инструкцией assert. Она проверяет, что утверждение истинно, и останавливает программу с сообщением, если это не так:
def srednee(chisla):
"""Среднее арифметическое непустого списка чисел.
Предусловие: список непустой.
Постусловие: ответ не меньше минимума и не больше максимума списка.
"""
assert len(chisla) > 0, 'предусловие: список не должен быть пустым'
itog = sum(chisla) / len(chisla)
assert min(chisla) <= itog <= max(chisla), 'постусловие: среднее вне диапазона'
return itogsrednee([4, 6]) = 5.0
srednee([-5, 5]) = 0.0
srednee([]) — вызов с нарушенным предусловием:
Traceback (most recent call last):
File "6_predusloviye.py", line 23, in <module>
srednee([])
File "6_predusloviye.py", line 14, in srednee
assert len(chisla) > 0, 'предусловие: список не должен быть пустым'
AssertionError: предусловие: список не должен быть пустымСтроки после слова Traceback — это цепочка вызовов, которая привела к ошибке. Читают её снизу вверх: в последней строке тип ошибки и сообщение, выше — строка, где она произошла, ещё выше — откуда был сделан вызов.
Здесь важны две детали. Программа остановилась в той строке, где нарушен договор, а не через три вызова, когда None где-то сложили с числом. И сообщение после запятой попадает в текст ошибки, поэтому в нём пишут, чего ожидали.
Постусловие в примере не проверяет ответ полностью — оно проверяет свойство: среднее лежит между минимумом и максимумом. Так делают часто: полную проверку написать сложнее самой функции, а свойство ловит грубые ошибки без заметных затрат.
Предусловие или постусловие
Функция indeks_maksimuma(chisla) возвращает индекс наибольшего элемента. Распределите четыре утверждения на предусловие и постусловие.
- Список непустой.
- Возвращённое число — допустимый индекс списка.
- Все элементы — числа одного типа, их можно сравнивать.
- Элемент по этому индексу не меньше любого другого элемента списка.
Открыть разбор
Предусловие — пункты 1 и 3: их обеспечивает вызывающий код, функция вправе на них опираться. Постусловие — пункты 2 и 4: их обещает сама функция.
Заметьте, что пункт 4 — это и есть определение максимума, переписанное как проверяемое утверждение. В таком виде его используют и в тестах, и в объяснении цикла.
2. Уточняющие вопросы к условию
Работа над задачей начинается не с кода: сначала условие дополняют вопросами. На собеседовании это прямо ожидаемая часть работы: интервьюер смотрит, заметит ли кандидат пробелы. В обычной работе то же самое происходит с задачей в трекере — только вопросы задают автору задачи, а неверно понятое условие обнаруживается через неделю на приёмке.
Вопросов к любой задаче можно придумать много, а времени мало. Поэтому нужен критерий отбора.
По этому критерию «Как назвать функцию?» отсеивается сразу: имя не меняет ни алгоритм, ни проверки. А вот «Могут ли значения повторяться?» остаётся: от ответа зависит, убирать ли дубликаты, и появляется отдельный тест.
Пять групп, о которых спрашивают
Вопросы задают группами: так их проще не забыть под давлением времени.
Первая группа — вход. Какие данные приходят, какого они типа, насколько велики, бывает ли вход пустым. Пустой вход стоит отдельного упоминания: это самый частый случай, о котором молчит условие, и самый частый повод для падения.
Вторая группа — ответ. Что именно вернуть: значение, индекс, True или False, список пар. Разница не косметическая. Для задачи «найти в списке два числа с заданной суммой» ответ True/False позволяет обойтись множеством просмотренных значений, а ответ «индексы» требует словаря «значение → индекс».
Проверка x in spisok перебирает элементы подряд: чтобы ответить «нет», приходится дойти до конца. На списке из миллиона чисел это миллион сравнений на каждый вопрос.
Множество (set) устроено иначе: по самому значению оно вычисляет, в какой ячейке этому значению полагается лежать, и смотрит только туда. Это как искать слово в бумажном словаре — сразу на нужную букву, а не листая подряд. Поэтому проверка занимает примерно одинаковое время независимо от размера, и говорят, что она стоит O(1) в среднем.
Словарь (dict) работает так же, но рядом со значением хранит ещё что-нибудь: индекс, количество, любую пометку. Отсюда и правило выбора: нужен только ответ «есть или нет» — хватает множества; нужно ещё что-то знать про найденное — берут словарь.
Третья группа — особые случаи. Что вернуть, когда ответа не существует? Как считать повторы? Что делать, если подходящих ответов несколько? Здесь чаще всего обнаруживается, что задач в условии две.
Четвёртая группа — ограничения. Насколько большим бывает вход, в каком диапазоне значения, сколько раз функцию вызовут. Эти ответы определяют не правильность, а выбор алгоритма: для тысячи элементов подойдёт двойной цикл, для миллиона — нет.
Пятая группа — данные. Можно ли менять то, что пришло на вход. Сортировка на месте экономит память, но вызывающий код теряет исходный порядок; если он ему нужен, придётся делать копию и платить за неё O(n) памяти.
| Группа | Пример вопроса | Что меняется от ответа |
|---|---|---|
| Вход | Бывает ли список пустым? | Появляется отдельная ветка в коде и отдельный тест |
| Ответ | Вернуть значение или индекс? | Меняется сигнатура, а иногда и структура данных внутри |
| Особые случаи | Что вернуть, если ответа нет? | None, исключение или особое значение — от этого зависит, что писать в тесте |
| Ограничения | Насколько большим бывает вход? | Выбор алгоритма: линейный проход, сортировка или счётчик |
| Данные | Можно ли менять исходный список? | Сортировка на месте или копия, то есть O(1) или O(n) памяти |
Она описывает не секунды, а то, как растёт объём работы при росте входа. O(1) — работа не зависит от размера: обращение к элементу по индексу. O(n) — один проход по всем элементам. O(n log n) — столько стоит сортировка. O(n²) — перебор всех пар: для тысячи элементов это миллион шагов, для миллиона — триллион.
Практический вывод для вопросов ниже: без ответа на «насколько большим бывает вход» выбрать между этими вариантами нельзя.
Банк вопросов: что можно спросить дословно
Ниже вопросы в той формулировке, в которой их произносят. Их не задают все подряд — из каждой группы выбирают те, что подходят к задаче. Список есть отдельным файлом, его удобно держать перед глазами первое время: bank-voprosov.md.
| Вход | Что меняется от ответа |
|---|---|
| Какие данные приходят: список, строка, словарь? | От типа зависит и алгоритм, и способ обхода |
| Бывает ли вход пустым? | Отдельная ветка в коде и отдельный тест |
| Бывает ли вход из одного элемента? | Проверяет циклы, которые сравнивают соседей |
| Какие значения допустимы: только положительные, ноль, отрицательные? | Начальное значение переменных и набор тестов |
| Могут ли значения повторяться? | Убирать ли дубликаты; что считать ответом при повторе |
| Данные уже отсортированы? | Двоичный поиск вместо перебора; для поиска пары — два указателя, один от начала, другой от конца списка |
| Все элементы одного типа? | Сравнение числа со строкой в Python 3 даёт TypeError |
| Ответ | Что меняется от ответа |
|---|---|
| Что возвращать: значение, индекс, True/False или новый список? | Сигнатура и структура данных внутри |
| Если подходящих ответов несколько, какой нужен? | Первое вхождение, последнее или любое — три разные задачи |
| Что вернуть, если ответа нет? | None, исключение или особое значение; без этого тест не написать |
| В каком порядке должны идти элементы ответа? | Нужна ли сортировка результата и какая |
| Нужны ли повторы в ответе? | Множество или список с дубликатами |
| Функция возвращает результат или печатает его? | Кто отвечает за вывод — она или вызывающий код |
| Нужен ли сам ответ или достаточно узнать, что он есть? | «Есть ли пара» дешевле, чем «какая именно пара» |
| Особые случаи | Что меняется от ответа |
|---|---|
| Считается ли ноль положительным? | Знак сравнения: > 0 или >= 0 |
| Одинаковые значения — это одно значение или разные? | Для [5, 5, 3] второй максимум равен 5 или 3 |
| Можно ли использовать один элемент дважды? | С какого индекса начинается внутренний цикл |
| Что считать совпадением: точное равенство или без учёта регистра и пробелов? | Нужна ли нормализация данных до сравнения |
Могут ли внутри быть пустые строки, пропуски, None? | Отдельная ветка или отсев до основного прохода |
| Есть ли значения, которые нужно пропускать? | Условие внутри цикла и тесты на такие элементы |
| Что делать с некорректными данными: конец раньше начала, отрицательный возраст? | Запрещены контрактом или функция сообщает об ошибке |
| Ограничения | Что меняется от ответа |
|---|---|
| Насколько большим бывает вход? | Допустим ли перебор пар за O(n²) |
| В каком диапазоне значения? | Узкий диапазон разрешает счётчик вместо сортировки |
| Сколько раз вызовут функцию? | Окупается ли подготовка данных: множество, словарь, сортировка |
| Есть ли ограничение по памяти? | Копия входа или работа на месте |
| Данные помещаются в память целиком или приходят потоком, по одному значению без возможности вернуться к прежним? | Можно ли хранить весь список или нужны две переменные |
| В каком типе хранится результат у принимающей стороны? | Переполнение поля int32 при верном алгоритме |
| Есть ли ограничение по времени ответа? | Имеет смысл только вместе с размером входа |
| Данные | Что меняется от ответа |
|---|---|
| Можно ли менять исходные данные? | Сортировка на месте или копия и O(n) памяти |
| Нужен ли исходный порядок после вызова? | Разрешена ли перестановка элементов |
| Данные можно считать корректными или их нужно проверять? | Появляются проверки входа и тесты на их срабатывание |
| Кто ещё работает с этим списком одновременно? | Безопасно ли менять его на месте |
| Данные приходят один раз или обновляются? | Разовый проход или структура, которую поддерживают |
Вопросы задают до кода и в том порядке, в каком они влияют на решение: сначала вход и ответ, потом особые случаи, потом ограничения. Если время кончается, первым задают тот вопрос, без ответа на который нельзя написать ни строчки.
Три кейса: вопрос, ответ, что изменилось
Ниже три условия в том виде, в каком их дают, и разбор: какие вопросы к ним задали, что ответили и чем это обернулось для кода.
«Найдите в списке два числа с суммой target»
Выпишите свои вопросы к этому условию, потом откройте разбор и сравните.
Открыть разбор
| Вопрос | Ответ | Что изменилось в решении |
|---|---|---|
| Можно ли использовать один элемент дважды? | Нет, индексы должны быть разными | Для [3] и target = 6 ответ False. Появился тест, который без этого вопроса никто бы не написал |
| Что вернуть: индексы, сами числа или True/False? | True или False | Достаточно множества просмотренных значений; словарь «значение → индекс» не нужен |
| Что вернуть, если пары нет? | False | Отказной случай записан в контракт, для него есть тест |
| Насколько большим бывает список? | До 100 000 чисел | Перебор пар — это 5·10⁹ сравнений, он отпадает. Остаётся один проход с множеством |
| Отсортирован ли список? | Нет | Два указателя с концов не подходят, сортировать ради одного прохода невыгодно |
| Бывают ли отрицательные числа и ноль? | Бывают | Алгоритм не меняется, но в тесты добавляются [-2, 5] и [0, 0] |
Пять вопросов заняли меньше минуты, а изменили и алгоритм, и два теста. Шестой вопрос алгоритм не поменял: заранее не видно, какой ответ окажется решающим.
«Выведите заявки, отсортированные по дате»
Прикладная задача, в которой алгоритм известен, а вопросов к условию не меньше.
Открыть разбор
| Вопрос | Ответ | Что изменилось в решении |
|---|---|---|
| В каком виде хранится дата: строкой или объектом даты? | Строкой вида 12.03.2026 | Строки сравниваются посимвольно слева направо, а «1» больше «0». Поэтому «12.03.2026» окажется больше «01.04.2026», хотя по времени наоборот. Дату нужно разобрать на число, месяц и год |
| По возрастанию или по убыванию? | Сначала новые | reverse=True и тест, который поймает обратный порядок |
| Что делать с заявками без даты? | Отправлять в конец списка | Отдельная ветка: такие заявки нельзя передавать в сравнение дат |
| Что делать с заявками, у которых дата совпала? | Раньше идёт та, у которой меньше номер | Ключ сортировки — то значение, по которому сравнивают элементы. Здесь он становится парой «дата, номер»: сначала сравниваются даты, а при равных датах — номера |
| Можно ли менять исходный список? | Нет, он нужен в прежнем порядке | sorted() вместо list.sort(), плюс O(n) памяти |
| Сколько бывает заявок? | До 50 000 | Встроенная сортировка справляется; писать свою незачем |
Алгоритм здесь никто не выбирал: сортировка понятна сразу. Вопросы решили другое — формат данных, порядок, ветку для пропусков и ключ сравнения. На этих четырёх местах такие задачи и ломаются.
«Проверьте, есть ли среди адресов почты одинаковые»
Здесь ответ на один из вопросов меняет саму структуру данных в решении.
Открыть разбор
| Вопрос | Ответ | Что изменилось в решении |
|---|---|---|
| Важен ли регистр букв? | Нет, Ivan@mail.ru и ivan@mail.ru — один адрес | Перед сравнением адреса приводят к нижнему регистру |
| Бывают ли лишние пробелы по краям? | Бывают, их нужно отбрасывать | К нормализации добавляется strip(), в тесты — адрес с пробелом |
| Вернуть True/False или сами повторяющиеся адреса? | Список повторяющихся | Множества уже мало: нужен словарь «адрес → сколько раз» |
| Сколько бывает адресов? | До миллиона | Перебор пар исключён, остаётся один проход со словарём |
Считать ли ivan+news@mail.ru и ivan@mail.ru одним адресом? | Нет, это разные адреса | Нормализация ограничивается регистром и пробелами, разбор части до «плюса» не нужен |
Третий вопрос здесь решающий: пока ответ «True или False», хватает множества; как только нужен список повторов, появляется словарь со счётчиком. Такую развилку находит вопрос, а не чтение готового кода.
Куда записать ответы
Ответ, услышанный на словах, через десять минут превращается в «кажется, договаривались, что пустой список допустим». Записывать его нужно там, где его увидит любой, кто откроет файл, — в документирующей строке функции:
def sjat(text):
"""Сжимает подряд идущие одинаковые символы: aaabb → a3b2.
Одиночный символ пишется без числа: abc → abc.
Пустая строка даёт пустую строку.
Исходная строка не изменяется.
"""Такая запись занимает минуту, а дальше служит трижды: по ней пишут тесты, по ней проверяют готовый код и по ней же объясняют своё решение тому, кто пришёл с вопросом.
Какие вопросы задать к задаче о поиске
Условие: «Верните индекс числа x в массиве». Выпишите вопросы, которые вы задали бы до кода, и отметьте, что меняется от каждого ответа. Потом сверьтесь.
Открыть разбор
- Отсортирован ли массив? Меняет алгоритм: да — двоичный поиск за O(log n), нет — линейный проход за O(n).
- Что вернуть, если x нет? Меняет контракт: −1,
Noneили исключение. Пока ответа нет, для этого случая нельзя записать ожидаемый результат теста. - Может ли x встречаться несколько раз? Меняет контракт: нужен первый индекс, последний или любой.
- Насколько большим бывает массив и сколько будет запросов? Меняет решение: для многих запросов выгодно один раз построить словарь «значение → индекс».
- Можно ли менять массив? Решает, допустима ли сортировка на месте ради двоичного поиска.
А вот вопрос «Использовать index() или писать цикл?» по нашему критерию бесполезен: обе записи дают один и тот же линейный поиск.
3. Тестовые примеры и классы входов
Контракт — это текст, и сам по себе он ничего не проверяет. Чтобы им можно было пользоваться, его переводят в примеры.
Конкретный вход и результат, который функция должна вернуть по контракту. Ожидаемый результат записывают до запуска: если сначала запустить код и посмотреть на ответ, неверный ответ легко принять за правильный — человек склонен соглашаться с тем, что уже увидел.
Количество тестов при этом значения не имеет: важен порядок действий. Посмотрим на подсчёт слов. Версия ниже считает пробелы и прибавляет единицу — так пишут часто, и на первом же примере она отвечает верно:
def count_words(text):
"""Ошибочная версия: считает пробелы, а не слова."""
probelov = 0
for simvol in text:
if simvol == ' ':
probelov += 1
return probelov + 1'кот пёс' ждём 2 получили 2 ок обычный случай
'кот' ждём 1 получили 1 ок одно слово без разделителей
'' ждём 0 получили 1 ОШИБКА пустая строка
' кот пёс ' ждём 2 получили 7 ОШИБКА лишние пробелы по краям и внутри
'кот\nпёс' ждём 2 получили 1 ОШИБКА разделитель — перевод строкиРазберём результат построчно. Первые два теста прошли — и именно поэтому они бесполезны как доказательство: функция считает разделители, а в этих строках число разделителей случайно совпало с ответом. Третий тест ловит пустую строку: разделителей ноль, функция прибавляет единицу и сообщает про одно слово, которого нет. Четвёртый показывает, что лишние пробелы считаются наравне со словами. Пятый — что перевод строки для этой версии вообще не разделитель.
Класс входов
Почему пять тестов поймали три разные ошибки, а три первых теста не поймали бы ни одной? Потому что они относятся к разным классам.
Группа входных данных, которые функция обрабатывает одинаковым способом. Если два входа попадают в один класс, второй тест почти ничего не добавляет к первому: они пройдут или упадут вместе.
Для подсчёта слов классы такие: обычная строка из нескольких слов, строка из одного слова, пустая строка, строка с лишними пробелами, строка с другим разделителем. Тесты 'кот пёс' и 'мама мыла раму' лежат в одном классе — первом. Тест '' лежит в своём, и без него ошибку с пустой строкой не найти никогда, сколько бы обычных строк вы ни перебрали.
Рядом с каждым тестом полезно записывать, какой класс он проверяет. Если у двух тестов формулировка совпала — один из них лишний, и его лучше заменить тестом из непокрытого класса.
Пять классов, которые проверяют почти всегда
- Обычный случай. Один тест, чтобы проверить сам способ решения.
- Границы допустимого. Пустой вход, один элемент, наибольшее допустимое значение.
- Особые значения. Ноль, отрицательные числа, повторы, разделители подряд.
- Отказной случай. Ответа не существует:
None,Falseили исключение — смотря что записано в контракте. - Случай, о котором договорились отдельно. Тот самый, ради которого задавали уточняющий вопрос.
Контрпример
Отдельно стоит назвать особый вид теста.
Допустимый по контракту вход, на котором функция возвращает неверный ответ. Чтобы показать, что в коде есть ошибка, достаточно одного контрпримера.
В обратную сторону это не работает, и разница здесь принципиальная. Один контрпример доказывает, что решение неверно. Любое количество пройденных тестов не доказывает, что оно верно, — проверить все возможные входы невозможно. Поэтому дальше понадобится рассуждение об инварианте: тесты находят ошибку, а правильность объясняет оно.
Разложите тесты по классам
Функция возвращает индекс первого максимума списка; для пустого списка — None. Даны шесть тестов: [4, 8, 2], [2, 4, 8, 16], [], [7], [5, 9, 9], [-3, -1, -2]. Какие из них лежат в одном классе и какой можно убрать?
Открыть разбор
[4, 8, 2] и [2, 4, 8, 16] — обычные списки положительных чисел, они в одном классе; второй можно убрать, ничего не потеряв. Остальные четыре берут свой класс: пустой вход, один элемент, повторяющийся максимум (проверяет, вернётся ли первый индекс), только отрицательные числа (ловит инициализацию нулём).
Вместо выброшенного теста полезнее добавить список, где максимум стоит последним: он проверяет, доходит ли цикл до конца.
4. Особые входы: где ломаются решения
Классы входов — вещь общая, но на практике падают на одних и тех же пяти. Разберём каждый: что именно в коде оказывается неверным и почему это не видно на обычных данных.
Пустой вход
Пустой список ломает две конструкции. Первая — деление на длину: sum(chisla) / len(chisla) при нулевой длине даёт ZeroDivisionError. Вторая — обращение к первому элементу: chisla[0] вызывает IndexError, потому что элемента с индексом 0 не существует. Обе строки на непустом входе работают, поэтому в обычных тестах не проявляются.
Лечится это не проверкой «на всякий случай», а решением, записанным в контракте: вернуть ноль, вернуть None или объявить пустой вход недопустимым и выбросить исключение. У каждого варианта своя цена. None легко не заметить: вызывающий код сложит его с числом и упадёт в другом месте, далеко от причины. Исключение останавливает программу там, где нарушен договор.
Отрицательные числа
Строка best = 0 в поиске максимума — не техническая деталь, а утверждение: «ответ точно не меньше нуля». Для списка [-7, -3] оно ложно, и функция возвращает 0 — число, которого во входе нет вообще.
Правильное начальное значение берут из самих данных: первый элемент списка. Тогда утверждение «best — максимум просмотренной части» истинно с самого начала. Та же ошибка в зеркальном виде встречается при поиске минимума: старт с нуля даёт неверный ответ, если все числа положительные.
Дубликаты
Когда ответ — индекс, а не значение, повторы перестают быть безразличными. Сравнение chisla[i] <= chisla[best] вместо < переносит ответ на каждое следующее равное значение, и вместо первого вхождения возвращается последнее. На списке без повторов обе версии неразличимы.
Это тот случай, когда ошибка в коде — следствие незаданного вопроса. Пока не сказано, какое вхождение нужно, обе версии по-своему правы.
Последний элемент
Цикл for i in range(len(chisla) - 1) перебирает индексы от 0 до n − 2 и не доходит до последнего. Если тело цикла читает пару соседей chisla[i] и chisla[i + 1], такая граница правильная: последняя пара как раз заканчивается на n − 1. Если же тело читает только chisla[i], последний элемент остаётся непроверенным.
Граница цикла зависит не от длины списка, а от того, к каким индексам обращается тело цикла.
Большие числа
Последний случай отличается от предыдущих: здесь алгоритм верный, а ответ всё равно неверный. Если каждое слагаемое помещается в тип фиксированной разрядности, это ещё не значит, что в него поместится сумма. Знаковое 32-битное целое — это тип, под который отведено 32 двоичных разряда, причём один из них уходит на знак. Вмещает он значения до 2 147 483 647; два числа по два миллиарда по отдельности проходят, их сумма — нет.
В самом Python у целых нет предела разрядности, поэтому сумма посчитается точно. Ограничение появляется на границе: поле int32 в базе данных, формат обмена, переменная в Java или C++. Проверять нужно диапазон принимающей стороны, а границу считать по худшему случаю — длина входа, умноженная на наибольший модуль элемента.
Все пять случаев собраны в один запускаемый файл: он вызывает каждую функцию на её особом входе и печатает, что ожидалось и что получилось.
пустой вход вход []
ждём ошибку или None · получили ZeroDivisionError: division by zero
отрицательные вход [-7, -3]
ждём -3 · получили 0
дубликаты вход [3, 1, 1]
ждём 1 · получили 2
последний элемент вход [4, 8, 0]
ждём True · получили False
переполнение вход [2000000000, 2000000000]
ждём 4000000000 · получили не помещается в int32: 4000000000| Вход | Что в коде неверно | Как чинить |
|---|---|---|
| Пустой | деление на длину или обращение к chisla[0] | отдельная ветка с тем ответом, который записан в контракте |
| Отрицательные | best = 0 — скрытое утверждение об ответе | начальное значение берётся из данных |
| Дубликаты | <= вместо < при поиске индекса | договориться о вхождении и поставить знак под договорённость |
| Последний элемент | range(len(chisla) - 1) там, где тело читает один индекс | сверить границу с обращениями внутри цикла |
| Большие числа | сумма выходит за диапазон принимающего типа | оценить границу по худшему случаю до запуска |
5. Инвариант цикла
У тестов есть ограничение. Они показывают, что функция верна на тех входах, которые вы перебрали. Входов бесконечно много, перебрать их нельзя, а утверждать нужно про все. Остаётся рассуждение о том, что происходит в цикле.
Сумма покупок в корзине
Человек ходит по магазину со списком, складывает товары в корзину и держит сумму в уме. После каждого товара сумма в уме меняется, но одно остаётся верным всегда: в уме лежит стоимость того, что уже в корзине. Не всего списка, не будущих покупок — именно уже положенного.
Это утверждение и есть инвариант. Удобство его вот в чём: помнить весь путь по магазину не нужно, достаточно знать, что правило соблюдалось на каждом шаге. Тогда на кассе сумма в уме окажется стоимостью всей корзины — просто потому, что в конце «уже положенное» и есть всё, что вы взяли.
Сломать такое рассуждение легко: если на одном товаре вы забыли прибавить цену, правило нарушилось, и на кассе числа не сойдутся. В коде это выглядит так же, только «забыть прибавить» превращается в пропущенную строку или неверную ветку if.
def summa(ceny):
itogo = 0
for k, cena in enumerate(ceny, start=1):
itogo += cena
print(f' положили {k}-й товар за {cena} | в корзине {ceny[:k]} | itogo = {itogo}')
return itogoФункция enumerate(ceny, start=1) выдаёт пары «номер, значение» и нумерует с единицы, поэтому k — это сколько товаров уже положили, а ceny[:k] — сама корзина на этот момент.
цены: [120, 45, 300, 60]
проход:
положили 1-й товар за 120 | в корзине [120] | itogo = 120
положили 2-й товар за 45 | в корзине [120, 45] | itogo = 165
положили 3-й товар за 300 | в корзине [120, 45, 300] | itogo = 465
положили 4-й товар за 60 | в корзине [120, 45, 300, 60] | itogo = 525
итог: 525 | проверка суммой Python: 525Каждая строка вывода читается отдельно от остальных. В третьей itogo = 465 — это сумма трёх товаров, которые к тому моменту в корзине. Про четвёртый товар строка ничего не утверждает, потому что его ещё не клали.
Утверждение про обработанную часть данных, которое остаётся верным после каждого шага цикла. Значения переменных при этом меняются — неизменным остаётся само утверждение о них.
Формально точку проверки выбирают между итерациями: предыдущие элементы уже обработаны, следующий ещё нет. Для примера выше инвариант звучит так: после обработки первых k цен переменная itogo равна их сумме.
Три шага объяснения
Из инварианта правильность выводят в три шага. Проверим их на той же корзине.
- Инициализация. До цикла обработано ноль товаров, их стоимость равна нулю, и
itogo = 0. Утверждение верно при k = 0. - Сохранение. Если до шага
itogoбыла стоимостью первых k товаров, то послеitogo += cenaона станет стоимостью первых k + 1. Шаг переносит утверждение с k на k + 1. - Завершение. Цикл заканчивается, когда обработаны все n товаров. Подставляем k = n:
itogoравна стоимости всего списка — это и есть нужный ответ.
Такое рассуждение — обычная математическая индукция, только шагает она не по натуральным числам, а по итерациям цикла: база — инициализация, переход — сохранение.
Третий шаг объясняет, почему инвариант выбирают именно таким, а не любым верным. Утверждение «itogo не больше миллиона» тоже держится на каждом шаге, но при k = n из него не следует ответ, поэтому пользы от него нет.
Разбор на примере
В корзине переменная была одна, и утверждение про неё получалось само собой. Возьмём случай посложнее — поиск самой длинной серии одинаковых соседей, где переменных две: longest_run([1, 1, 2, 2, 2]) должна вернуть 3.
Тот же счёт встречается и вне кода. Человек идёт вдоль забора и считает, сколько досок подряд выкрашены в один цвет. В голове два числа: длина полосы, которая тянется прямо сейчас, и самая длинная полоса из уже пройденных. Первое сбрасывается, как только цвет сменился, второе только растёт. Это и есть две переменные из кода.
def longest_run(chisla):
if not chisla:
return 0
best = tekushchaya = 1
for i in range(1, len(chisla)):
if chisla[i] == chisla[i - 1]:
tekushchaya += 1
else:
tekushchaya = 1
if tekushchaya > best:
best = tekushchaya
return best| Шаг | Что утверждаем про longest_run |
|---|---|
| Инвариант | После обработки первых k элементов tekushchaya — длина серии, которая заканчивается на k-м элементе, а best — длина самой длинной серии внутри этих k элементов. |
| Инициализация | До цикла обработан один элемент. Серия, заканчивающаяся на нём, имеет длину 1; самая длинная серия внутри одного элемента тоже равна 1. Обе переменные равны 1 — инвариант верен при k = 1. |
| Сохранение | Очередной элемент либо равен предыдущему, и тогда серия продолжается, tekushchaya растёт на единицу; либо не равен, и начинается новая серия длины 1. В обоих случаях первое утверждение снова верно. После этого best обновляется, если новая серия оказалась длиннее, — значит, верно и второе. |
| Завершение | Цикл конечен: range перебирает фиксированный набор индексов. При k = n инвариант говорит про весь список, то есть best — самая длинная серия во входе. Это и есть требуемый ответ. |
Чтобы утверждение не осталось словами, тот же проход можно запустить с печатью состояний и проверить его глазами после каждого шага:
вход [1, 1, 2, 2, 2]
k = 2: обработано [1, 1] tekushchaya = 2 best = 2
k = 3: обработано [1, 1, 2] tekushchaya = 1 best = 2
k = 4: обработано [1, 1, 2, 2] tekushchaya = 2 best = 2
k = 5: обработано [1, 1, 2, 2, 2] tekushchaya = 3 best = 3
ответ: 3Посмотрите на строку k = 3. Цвет сменился, tekushchaya сбросилась в единицу, и это не ошибка: новая полоса действительно состоит пока из одной доски. А best помнит полосу из двух, которую мы уже прошли. Оба утверждения на этой строке верны — значит, инвариант держится.
Как отличить рабочую формулировку от бесполезной
Своя формулировка редко получается точной с первого раза. Проверить её можно на трёх типичных промахах — все три встречаются в работах постоянно.
- «
best = 3» — это состояние одного запуска на одном входе, а не утверждение про любой вход. - «
bestхранит максимум» — не сказано, максимум чего. Проверить такое утверждение после шага нечем. - «После первых k элементов
best— самая длинная серия внутри них» — названы и смысл переменной, и обработанная часть. Вот это проверяется на каждом шаге и при k = n даёт ответ.
Инвариант не совпадает с условием цикла. Условие while left < right решает, будет ли следующая итерация, и после выхода из цикла оно ложно. Инвариант, наоборот, истинен и после выхода — на этом и построен третий шаг.
Для цикла while к трём шагам добавляется четвёртый вопрос: почему цикл вообще закончится. У for список конечен: шагов столько, сколько элементов. У while нужно назвать величину, которая уменьшается на каждой итерации и не может уменьшаться бесконечно. В двоичном поиске это длина отрезка: каждый шаг делит её пополам, и рано или поздно она доходит до нуля.
Сформулируйте инвариант
Функция считает, сколько в списке строго положительных чисел: count = 0, дальше цикл по элементам, и count += 1, когда value > 0. Сформулируйте инвариант и проверьте его на инициализации и на завершении.
Открыть разбор
Инвариант: после обработки первых k элементов count равен количеству строго положительных среди этих k.
Инициализация: до цикла обработано ноль элементов, положительных среди них ноль, и count = 0 — верно. Сохранение: очередной элемент либо положителен, и счётчик растёт на единицу, либо нет, и счётчик не меняется; в обоих случаях утверждение остаётся верным. Завершение: при k = n утверждение говорит про весь список, то есть count — искомый ответ.
Формулировка «count — количество положительных» без слов «среди первых k» проверку после шага не выдерживает: на середине списка она неверна.
6. Ручной прогон
Остаётся случай, когда запустить код нельзя. На алгоритмической секции кнопку запуска обычно выключают именно для того, чтобы увидеть, умеет ли человек рассуждать о своём коде. В работе бывает похожее: ошибка воспроизводится только на данных заказчика, и разбираться приходится по листингу.
Выполнение алгоритма вручную на конкретном входе, по строке. На каждом шаге записывают значения переменных до шага, какое условие сработало и что изменилось после. Ошибка находится там, где записанное состояние впервые расходится с ожидаемым.
Разберём двоичный поиск с ошибкой в границе цикла. Условие записано как while left < right, хотя отрезок задан включительно с обеих сторон:
def binary_search(chisla, target):
left, right = 0, len(chisla) - 1
while left < right: # ошибка: нужно left <= right
middle = (left + right) // 2
if chisla[middle] == target:
return middle
if chisla[middle] < target:
left = middle + 1
else:
right = middle - 1
return -1Прогоним binary_search([1, 3, 5], 5). Записываем состояние на каждом шаге:
| Шаг | left | right | middle | Что сравнили | Что стало |
|---|---|---|---|---|---|
| 1 | 0 | 2 | 1 | chisla[1] = 3, это меньше 5 | left = 2 |
| 2 | 2 | 2 | — | условие 2 < 2 ложно | выход из цикла, ответ −1 |
Место ошибки видно на втором шаге: в отрезке остался один элемент — тот самый, который мы ищем, — но цикл в него уже не зашёл. Инвариант двоичного поиска звучит так: «если target есть в списке, он находится внутри отрезка от left до right». Утверждение верно и на выходе, а вот использовать его нечем, потому что отрезок из одного элемента остался непроверенным.
binary_search([1, 3, 5], 5)
left = 0, right = 2, middle = 1, chisla[middle] = 3
выход из цикла: left = 2, right = 2 — отрезок из одного элемента не проверен
ответ: -1
binary_search([5], 5)
выход из цикла: left = 0, right = 0 — отрезок из одного элемента не проверен
ответ: -1Минимальный контрпример
Второй вызов показывает ту же ошибку короче: список из одного элемента, цикл не выполняется ни разу. Такой вход и называют минимальным контрпримером.
Ищут его сужением: взяв вход, на котором функция ошибается, выбрасывают из него всё, что можно, пока ошибка не исчезнет. На коротком входе каждое изменение переменной видно целиком, и прогон занимает три строки вместо тридцати.
Что проверять в границах цикла
range(n)перебирает индексы от 0 до n − 1: правая граница не включается.range(n - 1)нужен, когда тело читает паруchisla[i]иchisla[i + 1], и ошибочен, когда тело читает толькоchisla[i].while left < rightоставляет непроверенным отрезок из одного элемента;while left <= rightпроверяет и его.- Если тело меняет индекс само, отдельно проверяют, что величина «сколько осталось» действительно уменьшается: иначе цикл не закончится.
Прогон на минимальном входе
Функция est_nol(chisla) должна вернуть True, если в списке есть ноль. Написана она так: for i in range(len(chisla) - 1): if chisla[i] == 0: return True, затем return False. Найдите самый короткий вход, на котором она ошибается, и проведите прогон.
Открыть разбор
Минимальный контрпример — [0]. Длина списка 1, значит range(0) пуст, цикл не выполняется ни разу, функция сразу возвращает False, хотя ноль в списке есть.
Вход [4, 8, 0] тоже ловит ошибку: перебираются индексы 0 и 1, индекс 2 остаётся непроверенным. Но на нём прогон занимает три строки, а на [0] — одну. Причина в обоих случаях одна: тело цикла читает chisla[i], поэтому граница должна быть range(len(chisla)).
7. Итоги
Корректность решения — это соответствие условию, а условие почти всегда неполное. Поэтому работа над задачей состоит из трёх частей, и код в ней — не первая.
Сначала условие дополняют вопросами: о входе, об ответе, об особых случаях, об ограничениях и о том, можно ли менять данные. Ответы записывают контрактом рядом с кодом. Потом контракт превращают в тестовые примеры, разложенные по классам входов: каждый тест берёт свой класс, а один контрпример уже доказывает ошибку. И наконец, правильность цикла объясняют инвариантом — утверждением про обработанную часть, которое проверяют на инициализации, на шаге и на завершении.
| Когда | Что сделать |
|---|---|
| До кода | Задать вопросы по пяти группам и записать ответы контрактом в docstring. |
| До кода | Составить тесты по классам входов: обычный, границы, особые значения, отказной случай. |
| Во время | Проверить начальное значение: best = 0 — это утверждение об ответе, а не пустая строка. |
| Во время | Сверить границу цикла с тем, к каким индексам обращается тело. |
| Во время | Сформулировать инвариант с указанием обработанной части и проверить его при k = 1 и k = n. |
| После | Прогнать минимальный контрпример руками и сравнить с тем, что печатает программа. |
| После | Оценить время и память и проверить границу значений на принимающей стороне. |
Вопросы на выход
Пять вопросов по всей лекции. Ответьте своими словами, потом откройте разбор.
- Чем предусловие отличается от постусловия и кто за них отвечает?
- Почему ожидаемый результат теста записывают до запуска?
- Что такое класс входов и зачем он нужен при составлении тестов?
- Почему один контрпример доказывает ошибку, а сто пройденных тестов не доказывают правильность?
- Какие три шага составляют объяснение через инвариант?
Открыть разбор
- Предусловие — что должно быть верно на входе, за него отвечает вызывающий код. Постусловие — что функция обещает на выходе, если предусловие соблюдено.
- Иначе неверный ответ легко принять за правильный: увидев результат, человек склонен согласиться с ним.
- Класс входов — группа данных, которые функция обрабатывает одинаково. Тесты из одного класса проходят или падают вместе, поэтому набор собирают по одному тесту на класс.
- Контрпример — конкретный вход, на котором нарушен контракт; этого достаточно. Полная проверка потребовала бы перебрать все входы, а их бесконечно много.
- Инициализация, сохранение и завершение: инвариант верен до цикла, сохраняется каждой итерацией и при выходе даёт требуемый ответ.
Дальше — практикум: две задачи, к каждой десять готовых решений, и выбор под ограничения заказчика.