Отладка и трассировка сложных схем
Программа не падает, но выдаёт неправильный ответ или зависает — научимся находить такие ошибки таблицей трассировки.
Трассировка — это ручное пошаговое выполнение алгоритма с записью значений всех переменных на каждом шаге в таблицу; она показывает, где именно поведение программы расходится с задуманным.
Простую трассировку мы уже делали для линейных программ. Но настоящие ошибки прячутся во вложенных циклах и на границах диапазонов — там, где в голове уже не удержать. Тут и спасает таблица: она превращает невидимую логику в строки и столбцы, и ошибка всплывает сама. На экзамене трассировка — основной инструмент задач «что выведет программа».
Зачем это нужно
Ошибка в коде бывает двух сортов. Первая — программа падает с сообщением: это легко, интерпретатор сам укажет строку. Вторая, коварная, — программа отрабатывает молча, но результат неверный, либо она вовсе зависает. Здесь компьютер ничем не поможет — нужно стать компьютером самому и пройти алгоритм по шагам. Две самые частые ошибки школьных программ — бесконечный цикл и 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):
| i | j | i < j ? | добавили | total |
|---|---|---|---|---|
| 1 | 1 | нет | — | 0 |
| 1 | 2 | да | 1·2 = 2 | 2 |
| 1 | 3 | да | 1·3 = 3 | 5 |
| 2 | 1 | нет | — | 5 |
| 2 | 2 | нет | — | 5 |
| 2 | 3 | да | 2·3 = 6 | 11 |
| 3 | 1 | нет | — | 11 |
| 3 | 2 | нет | — | 11 |
| 3 | 3 | нет | — | 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, пусто, одно значение, минимум и максимум.