Отладка и трассировка сложных схем

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

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

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

Зачем это нужно

Ошибка в коде бывает двух сортов. Первая — программа падает с сообщением: это легко, интерпретатор сам укажет строку. Вторая, коварная, — программа отрабатывает молча, но результат неверный, либо она вовсе зависает. Здесь компьютер ничем не поможет — нужно стать компьютером самому и пройти алгоритм по шагам. Две самые частые ошибки школьных программ — бесконечный цикл и off-by-one (сдвиг на единицу) — ловятся именно трассировкой.

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

Таблица трассировки для вложенных циклов

Разберём программу с двумя вложенными циклами. Колонки таблицы — переменные и то, что печатается; строки — шаги выполнения.

total = 0
for i in range(1, 4):
    for j in range(1, 4):
        if i < j:
            total += i * j
print(total)

Внешний счётчик i пробегает 1, 2, 3; для каждого i внутренний j тоже пробегает 1, 2, 3. Прибавляем $i \\cdot j$ только когда $i \\lt j$. Проследим за каждой парой (i, j):

iji < j ?добавилиtotal
11нет0
12да1·2 = 22
13да1·3 = 35
21нет5
22нет5
23да2·3 = 611
31нет11
32нет11
33нет11

Таблица предсказывает ответ 11. Запустим и проверим:

total = 0
for i in range(1, 4):
    for j in range(1, 4):
        if i < j:
            total += i * j
print(total)

Вывод:

11

Совпало. Главный приём: внутренний цикл полностью прокручивается при каждом значении внешнего — поэтому в таблице на одно i приходится несколько строк j.

Как это работает: ищем ошибку

Бесконечный цикл

Цикл while крутится, пока условие истинно. Если внутри забыть менять переменную условия, оно никогда не станет ложным:

i = 1
while i <= 5:
    print(i)
    # забыли строку i = i + 1

Трассировка мгновенно показывает беду: i всё время равно 1, условие $i \\le 5$ вечно истинно, программа печатает единицы без конца. Лекарство — обязательный шаг, приближающий к выходу: добавить i = i + 1. Правило: в каждом while найдите строку, которая рано или поздно сделает условие ложным.

Off-by-one (ошибка на единицу)

Это сдвиг диапазона на одну позицию: цикл делает на один шаг больше или меньше нужного. Сравните, что печатают два почти одинаковых цикла:

print("range(1, 5):", list(range(1, 5)))
print("range(1, 6):", list(range(1, 6)))

Вывод:

range(1, 5): [1, 2, 3, 4]
range(1, 6): [1, 2, 3, 4, 5]

Чтобы пройти числа с 1 по 5 включительно, нужна правая граница 6, потому что в Python она не включается. Если написать range(1, 5), потеряется пятёрка — классический off-by-one. Трассировка крайних значений ловит это сразу: всегда проверяйте, что делает цикл на первом и на последнем шаге.

Проверка граничных случаев

Алгоритм может работать на «средних» данных и ломаться на краях. Поэтому отдельно прогоняйте граничные случаи: пустой список, n = 0, одно значение, самое большое и самое маленькое допустимое. Например, цикл суммирования от 1 до n при $n = 0$ не должен сделать ни одной итерации (накопитель остаётся 0), а при $n = 1$ — ровно одну. Если на $n = 0$ программа падает или выдаёт мусор — вы нашли граничную ошибку, которую «обычные» тесты пропустили.

Частые ошибки

  • Не сбросить внутренний счётчик. Во вложенных циклах часто думают, что j «продолжается», — нет, при каждом новом i внутренний цикл стартует заново с начала.
  • Забыть шаг в while. Самая частая причина зависания. В таблице видно по неизменной переменной условия.
  • Сдвиг границы. < вместо <= или range(1, n) вместо range(1, n + 1) теряет последний элемент.
  • Проверять только «удобные» данные. Программа, верная на n = 5, может падать на n = 0 или на пустом вводе — гоняйте границы.
  • Трассировать в уме. Для вложенных циклов это ненадёжно: заводите таблицу со столбцом на каждую переменную, ошибки всплывают строкой.

Итоги

  • Таблица трассировки превращает невидимую логику в строки и столбцы — по ней видно, где значение пошло не так.
  • Во вложенных циклах внутренний полностью прокручивается при каждом значении внешнего.
  • Бесконечный цикл = в while нет шага, приближающего к выходу; ищите переменную условия, которая не меняется.
  • Off-by-one = сдвиг диапазона на единицу; проверяйте первый и последний шаг цикла.
  • Отдельно прогоняйте граничные случаи: 0, пусто, одно значение, минимум и максимум.
Проверьте себя
1. Во вложенных циклах внешний счётчик i принимает значения 1, 2, 3, а внутренний j для каждого i пробегает 1, 2, 3. Сколько всего раз выполнится тело внутреннего цикла?
A3 раза
B6 раз
C9 раз
D3 раза для i плюс 3 для j
2. Цикл while i <= 5 печатает i, но переменная i внутри тела никогда не меняется. Что произойдёт?
Aцикл выполнится 5 раз
Bцикл не выполнится ни разу
Cбесконечный цикл: условие всегда истинно
Dпрограмма упадёт с ошибкой синтаксиса