ОП.04 · Тема 05Лекция · 45 минут
Страница 1 из 2шесть разделов · примеры запускаются

Корректность решения: вопросы, тесты и инварианты

Лекция о том, как убедиться, что программа решает именно поставленную задачу. Разбираем три вещи, которыми это проверяют: уточняющие вопросы к условию, тестовые примеры и инвариант цикла.

1. Что значит «решение верное»

Частый случай: функция написана, три примера дали верный ответ. Через день её вызывает другой человек на своих данных и получает не то. Ошибки при этом может не быть ни у кого — вы по-разному поняли, что функция должна делать.

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

Такое описание почти никогда не бывает полным. «Найдите второй максимум», «сожмите строку», «посчитайте слова» — за каждой такой фразой прячется десяток случаев, о которых не сказано ни слова. Что делать с пустым входом? Считать ли одинаковые значения одним? Что вернуть, если ответа нет? Пока эти случаи не оговорены, у задачи не один правильный ответ, а несколько, и любая проверка превращается в спор.

Условие, которое читается двумя способами

Возьмём задачу про сжатие строки. Формулировка звучит так: «Сожмите строку, записывая подряд идущие одинаковые символы как символ и количество: aaabb → a3b2». Пример в условии есть, и кажется, что этого достаточно.

Но пример ничего не говорит про символ, который встретился один раз. Писать a1 или просто a? Обе трактовки разумны, обе дают на примере из условия один и тот же ответ, и обе дают разные ответы на строке abc:

1_sjatie_stroki.py · две трактовки одного условияСкачать файл ↓
def sjat_vsegda_s_chislom(text):
    """Пишет число всегда, даже когда символ один: abc → a1b1c1."""

def sjat_bez_edinic(text):
    """Одиночный символ пишет без числа: abc → abc."""
Вывод в терминал$ python3 1_sjatie_stroki.py
'aaabb'  с числом всегда: 'a3b2'     без единиц: 'a3b2'
'abc'    с числом всегда: 'a1b1c1'   без единиц: 'abc'
''       с числом всегда: ''         без единиц: ''

На aaabb версии совпали, на abc разошлись. Если такой вопрос не задать, версию выберет случайность — то, как вы прочитали условие в первый раз. Расхождение вскроется, когда результат сверят с ожиданиями заказчика.

Контракт функции

Чтобы прекратить споры, договорённости записывают. Такая запись называется контрактом.

Контракт функции

Описание того, какие входные данные допустимы, что функция возвращает и как она ведёт себя в особых случаях: на пустом входе, при повторах, когда ответа не существует. Контракт пишут до реализации: по нему составляют тесты и по нему же проверяют готовый код.

У контракта две половины, и у них есть свои имена. Предусловие — то, что должно быть верно на входе; за это отвечает тот, кто вызывает функцию. Постусловие — то, что будет верно на выходе, если предусловие соблюдено; за это отвечает сама функция.

Работает это как договор проката: прокат обещает исправную машину (постусловие), но при условии, что у вас есть права и вы вернёте её с тем же баком (предусловие). Если условие нарушил клиент — претензий к прокату нет. Если клиент всё выполнил, а машина сломалась — виноват прокат.

В Python обе половины можно записать прямо в коде инструкцией assert. Она проверяет, что утверждение истинно, и останавливает программу с сообщением, если это не так:

6_predusloviye.py · предусловие и постусловиеСкачать файл ↓
def srednee(chisla):
    """Среднее арифметическое непустого списка чисел.

    Предусловие: список непустой.
    Постусловие: ответ не меньше минимума и не больше максимума списка.
    """
    assert len(chisla) > 0, 'предусловие: список не должен быть пустым'
    itog = sum(chisla) / len(chisla)
    assert min(chisla) <= itog <= max(chisla), 'постусловие: среднее вне диапазона'
    return itog
Вывод в терминал$ python3 6_predusloviye.py
srednee([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. Список непустой.
  2. Возвращённое число — допустимый индекс списка.
  3. Все элементы — числа одного типа, их можно сравнивать.
  4. Элемент по этому индексу не меньше любого другого элемента списка.
Открыть разбор

Предусловие — пункты 1 и 3: их обеспечивает вызывающий код, функция вправе на них опираться. Постусловие — пункты 2 и 4: их обещает сама функция.

Заметьте, что пункт 4 — это и есть определение максимума, переписанное как проверяемое утверждение. В таком виде его используют и в тестах, и в объяснении цикла.

2. Уточняющие вопросы к условию

Работа над задачей начинается не с кода: сначала условие дополняют вопросами. На собеседовании это прямо ожидаемая часть работы: интервьюер смотрит, заметит ли кандидат пробелы. В обычной работе то же самое происходит с задачей в трекере — только вопросы задают автору задачи, а неверно понятое условие обнаруживается через неделю на приёмке.

Вопросов к любой задаче можно придумать много, а времени мало. Поэтому нужен критерий отбора.

Полезен тот вопрос, ответ на который меняет код, контракт или набор тестов. Если при любом ответе решение останется прежним, вопрос можно не задавать.

По этому критерию «Как назвать функцию?» отсеивается сразу: имя не меняет ни алгоритм, ни проверки. А вот «Могут ли значения повторяться?» остаётся: от ответа зависит, убирать ли дубликаты, и появляется отдельный тест.

Пять групп, о которых спрашивают

Вопросы задают группами: так их проще не забыть под давлением времени.

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

Вторая группа — ответ. Что именно вернуть: значение, индекс, True или False, список пар. Разница не косметическая. Для задачи «найти в списке два числа с заданной суммой» ответ True/False позволяет обойтись множеством просмотренных значений, а ответ «индексы» требует словаря «значение → индекс».

Почему множество отвечает быстрее списка

Проверка x in spisok перебирает элементы подряд: чтобы ответить «нет», приходится дойти до конца. На списке из миллиона чисел это миллион сравнений на каждый вопрос.

Множество (set) устроено иначе: по самому значению оно вычисляет, в какой ячейке этому значению полагается лежать, и смотрит только туда. Это как искать слово в бумажном словаре — сразу на нужную букву, а не листая подряд. Поэтому проверка занимает примерно одинаковое время независимо от размера, и говорят, что она стоит O(1) в среднем.

Словарь (dict) работает так же, но рядом со значением хранит ещё что-нибудь: индекс, количество, любую пометку. Отсюда и правило выбора: нужен только ответ «есть или нет» — хватает множества; нужно ещё что-то знать про найденное — берут словарь.

Третья группа — особые случаи. Что вернуть, когда ответа не существует? Как считать повторы? Что делать, если подходящих ответов несколько? Здесь чаще всего обнаруживается, что задач в условии две.

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

Пятая группа — данные. Можно ли менять то, что пришло на вход. Сортировка на месте экономит память, но вызывающий код теряет исходный порядок; если он ему нужен, придётся делать копию и платить за неё O(n) памяти.

ГруппаПример вопросаЧто меняется от ответа
ВходБывает ли список пустым?Появляется отдельная ветка в коде и отдельный тест
ОтветВернуть значение или индекс?Меняется сигнатура, а иногда и структура данных внутри
Особые случаиЧто вернуть, если ответа нет?None, исключение или особое значение — от этого зависит, что писать в тесте
ОграниченияНасколько большим бывает вход?Выбор алгоритма: линейный проход, сортировка или счётчик
ДанныеМожно ли менять исходный список?Сортировка на месте или копия, то есть O(1) или O(n) памяти
Напоминание из темы 4: что означает запись 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) памяти
Нужен ли исходный порядок после вызова?Разрешена ли перестановка элементов
Данные можно считать корректными или их нужно проверять?Появляются проверки входа и тесты на их срабатывание
Кто ещё работает с этим списком одновременно?Безопасно ли менять его на месте
Данные приходят один раз или обновляются?Разовый проход или структура, которую поддерживают

Вопросы задают до кода и в том порядке, в каком они влияют на решение: сначала вход и ответ, потом особые случаи, потом ограничения. Если время кончается, первым задают тот вопрос, без ответа на который нельзя написать ни строчки.

Три кейса: вопрос, ответ, что изменилось

Ниже три условия в том виде, в каком их дают, и разбор: какие вопросы к ним задали, что ответили и чем это обернулось для кода.

Кейс 1

«Найдите в списке два числа с суммой target»

Выпишите свои вопросы к этому условию, потом откройте разбор и сравните.

Открыть разбор
ВопросОтветЧто изменилось в решении
Можно ли использовать один элемент дважды?Нет, индексы должны быть разнымиДля [3] и target = 6 ответ False. Появился тест, который без этого вопроса никто бы не написал
Что вернуть: индексы, сами числа или True/False?True или FalseДостаточно множества просмотренных значений; словарь «значение → индекс» не нужен
Что вернуть, если пары нет?FalseОтказной случай записан в контракт, для него есть тест
Насколько большим бывает список?До 100 000 чиселПеребор пар — это 5·10⁹ сравнений, он отпадает. Остаётся один проход с множеством
Отсортирован ли список?НетДва указателя с концов не подходят, сортировать ради одного прохода невыгодно
Бывают ли отрицательные числа и ноль?БываютАлгоритм не меняется, но в тесты добавляются [-2, 5] и [0, 0]

Пять вопросов заняли меньше минуты, а изменили и алгоритм, и два теста. Шестой вопрос алгоритм не поменял: заранее не видно, какой ответ окажется решающим.

Кейс 2

«Выведите заявки, отсортированные по дате»

Прикладная задача, в которой алгоритм известен, а вопросов к условию не меньше.

Открыть разбор
ВопросОтветЧто изменилось в решении
В каком виде хранится дата: строкой или объектом даты?Строкой вида 12.03.2026Строки сравниваются посимвольно слева направо, а «1» больше «0». Поэтому «12.03.2026» окажется больше «01.04.2026», хотя по времени наоборот. Дату нужно разобрать на число, месяц и год
По возрастанию или по убыванию?Сначала новыеreverse=True и тест, который поймает обратный порядок
Что делать с заявками без даты?Отправлять в конец спискаОтдельная ветка: такие заявки нельзя передавать в сравнение дат
Что делать с заявками, у которых дата совпала?Раньше идёт та, у которой меньше номерКлюч сортировки — то значение, по которому сравнивают элементы. Здесь он становится парой «дата, номер»: сначала сравниваются даты, а при равных датах — номера
Можно ли менять исходный список?Нет, он нужен в прежнем порядкеsorted() вместо list.sort(), плюс O(n) памяти
Сколько бывает заявок?До 50 000Встроенная сортировка справляется; писать свою незачем

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

Кейс 3

«Проверьте, есть ли среди адресов почты одинаковые»

Здесь ответ на один из вопросов меняет саму структуру данных в решении.

Открыть разбор
ВопросОтветЧто изменилось в решении
Важен ли регистр букв?Нет, Ivan@mail.ru и ivan@mail.ru — один адресПеред сравнением адреса приводят к нижнему регистру
Бывают ли лишние пробелы по краям?Бывают, их нужно отбрасыватьК нормализации добавляется strip(), в тесты — адрес с пробелом
Вернуть True/False или сами повторяющиеся адреса?Список повторяющихсяМножества уже мало: нужен словарь «адрес → сколько раз»
Сколько бывает адресов?До миллионаПеребор пар исключён, остаётся один проход со словарём
Считать ли ivan+news@mail.ru и ivan@mail.ru одним адресом?Нет, это разные адресаНормализация ограничивается регистром и пробелами, разбор части до «плюса» не нужен

Третий вопрос здесь решающий: пока ответ «True или False», хватает множества; как только нужен список повторов, появляется словарь со счётчиком. Такую развилку находит вопрос, а не чтение готового кода.

Куда записать ответы

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

Контракт в docstringпишется до первой строки кода
def sjat(text):
    """Сжимает подряд идущие одинаковые символы: aaabb → a3b2.

    Одиночный символ пишется без числа: abc → abc.
    Пустая строка даёт пустую строку.
    Исходная строка не изменяется.
    """

Такая запись занимает минуту, а дальше служит трижды: по ней пишут тесты, по ней проверяют готовый код и по ней же объясняют своё решение тому, кто пришёл с вопросом.

Проверьте себя

Какие вопросы задать к задаче о поиске

Условие: «Верните индекс числа x в массиве». Выпишите вопросы, которые вы задали бы до кода, и отметьте, что меняется от каждого ответа. Потом сверьтесь.

Открыть разбор
  • Отсортирован ли массив? Меняет алгоритм: да — двоичный поиск за O(log n), нет — линейный проход за O(n).
  • Что вернуть, если x нет? Меняет контракт: −1, None или исключение. Пока ответа нет, для этого случая нельзя записать ожидаемый результат теста.
  • Может ли x встречаться несколько раз? Меняет контракт: нужен первый индекс, последний или любой.
  • Насколько большим бывает массив и сколько будет запросов? Меняет решение: для многих запросов выгодно один раз построить словарь «значение → индекс».
  • Можно ли менять массив? Решает, допустима ли сортировка на месте ради двоичного поиска.

А вот вопрос «Использовать index() или писать цикл?» по нашему критерию бесполезен: обе записи дают один и тот же линейный поиск.

3. Тестовые примеры и классы входов

Контракт — это текст, и сам по себе он ничего не проверяет. Чтобы им можно было пользоваться, его переводят в примеры.

Тестовый случай

Конкретный вход и результат, который функция должна вернуть по контракту. Ожидаемый результат записывают до запуска: если сначала запустить код и посмотреть на ответ, неверный ответ легко принять за правильный — человек склонен соглашаться с тем, что уже увидел.

Количество тестов при этом значения не имеет: важен порядок действий. Посмотрим на подсчёт слов. Версия ниже считает пробелы и прибавляет единицу — так пишут часто, и на первом же примере она отвечает верно:

2_schet_slov.py · версия, считающая разделителиСкачать файл ↓
def count_words(text):
    """Ошибочная версия: считает пробелы, а не слова."""
    probelov = 0
    for simvol in text:
        if simvol == ' ':
            probelov += 1
    return probelov + 1
Вывод в терминал$ python3 2_schet_slov.py
'кот пёс'        ждём 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++. Проверять нужно диапазон принимающей стороны, а границу считать по худшему случаю — длина входа, умноженная на наибольший модуль элемента.

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

3_osobye_vhody.py · вывод в терминалСкачать файл ↓
пустой вход        вход []
                   ждём ошибку или 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.

7_korzina.py · сумма того, что уже в корзинеСкачать файл ↓
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] — сама корзина на этот момент.

Вывод в терминал$ python3 7_korzina.py
цены: [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 равна их сумме.

Три шага объяснения

Из инварианта правильность выводят в три шага. Проверим их на той же корзине.

  1. Инициализация. До цикла обработано ноль товаров, их стоимость равна нулю, и itogo = 0. Утверждение верно при k = 0.
  2. Сохранение. Если до шага itogo была стоимостью первых k товаров, то после itogo += cena она станет стоимостью первых k + 1. Шаг переносит утверждение с k на k + 1.
  3. Завершение. Цикл заканчивается, когда обработаны все n товаров. Подставляем k = n: itogo равна стоимости всего списка — это и есть нужный ответ.

Такое рассуждение — обычная математическая индукция, только шагает она не по натуральным числам, а по итерациям цикла: база — инициализация, переход — сохранение.

Третий шаг объясняет, почему инвариант выбирают именно таким, а не любым верным. Утверждение «itogo не больше миллиона» тоже держится на каждом шаге, но при k = n из него не следует ответ, поэтому пользы от него нет.

Разбор на примере

В корзине переменная была одна, и утверждение про неё получалось само собой. Возьмём случай посложнее — поиск самой длинной серии одинаковых соседей, где переменных две: longest_run([1, 1, 2, 2, 2]) должна вернуть 3.

Тот же счёт встречается и вне кода. Человек идёт вдоль забора и считает, сколько досок подряд выкрашены в один цвет. В голове два числа: длина полосы, которая тянется прямо сейчас, и самая длинная полоса из уже пройденных. Первое сбрасывается, как только цвет сменился, второе только растёт. Это и есть две переменные из кода.

4_seriya.py · две переменные, два инвариантаСкачать файл ↓
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 — самая длинная серия во входе. Это и есть требуемый ответ.

Чтобы утверждение не осталось словами, тот же проход можно запустить с печатью состояний и проверить его глазами после каждого шага:

Вывод в терминал$ python3 4_seriya.py
вход [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. Ручной прогон

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

Ручной прогон (dry run)

Выполнение алгоритма вручную на конкретном входе, по строке. На каждом шаге записывают значения переменных до шага, какое условие сработало и что изменилось после. Ошибка находится там, где записанное состояние впервые расходится с ожидаемым.

Разберём двоичный поиск с ошибкой в границе цикла. Условие записано как while left < right, хотя отрезок задан включительно с обеих сторон:

5_ruchnoy_progon.py · ошибка в границе циклаСкачать файл ↓
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). Записываем состояние на каждом шаге:

ШагleftrightmiddleЧто сравнилиЧто стало
1021chisla[1] = 3, это меньше 5left = 2
222—условие 2 < 2 ложновыход из цикла, ответ −1

Место ошибки видно на втором шаге: в отрезке остался один элемент — тот самый, который мы ищем, — но цикл в него уже не зашёл. Инвариант двоичного поиска звучит так: «если target есть в списке, он находится внутри отрезка от left до right». Утверждение верно и на выходе, а вот использовать его нечем, потому что отрезок из одного элемента остался непроверенным.

Вывод в терминал$ python3 5_ruchnoy_progon.py
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.
ПослеПрогнать минимальный контрпример руками и сравнить с тем, что печатает программа.
ПослеОценить время и память и проверить границу значений на принимающей стороне.
Проверьте себя

Вопросы на выход

Пять вопросов по всей лекции. Ответьте своими словами, потом откройте разбор.

  1. Чем предусловие отличается от постусловия и кто за них отвечает?
  2. Почему ожидаемый результат теста записывают до запуска?
  3. Что такое класс входов и зачем он нужен при составлении тестов?
  4. Почему один контрпример доказывает ошибку, а сто пройденных тестов не доказывают правильность?
  5. Какие три шага составляют объяснение через инвариант?
Открыть разбор
  1. Предусловие — что должно быть верно на входе, за него отвечает вызывающий код. Постусловие — что функция обещает на выходе, если предусловие соблюдено.
  2. Иначе неверный ответ легко принять за правильный: увидев результат, человек склонен согласиться с ним.
  3. Класс входов — группа данных, которые функция обрабатывает одинаково. Тесты из одного класса проходят или падают вместе, поэтому набор собирают по одному тесту на класс.
  4. Контрпример — конкретный вход, на котором нарушен контракт; этого достаточно. Полная проверка потребовала бы перебрать все входы, а их бесконечно много.
  5. Инициализация, сохранение и завершение: инвариант верен до цикла, сохраняется каждой итерацией и при выходе даёт требуемый ответ.

Дальше — практикум: две задачи, к каждой десять готовых решений, и выбор под ограничения заказчика.