# теория · шаг 2 из 7
Уменьшение контрпримера: от тридцати отрезков до двух
Коротко
Стресс-тест на больших входах находит расхождение, но вход из тридцати отрезков ничего не объясняет. Уменьшение контрпримера выбрасывает из входа куски и уменьшает числа, пока расхождение держится, - и оставляет вход, который ошибку объясняет сам. Делает это программа, а не человек: выбросить половину, четверть, по одному элементу, потом сдвинуть числа к нулю. Главная ловушка - признак, по которому решают «ошибка всё ещё есть». Признак «ответы разошлись» за три проверки увёл тридцать отрезков к пустому списку - к другой ошибке. Признак «вход непустой и быстрый ответ меньше верного» за 56 проверок привёл к паре вложенных отрезков, которая показывает настоящий дефект.
Если ты умеешь автоматически уменьшать контрпример и знаешь, почему признак ошибки должен быть точным, листай до «Теперь сам».
Как думаешь, где ошибка
Функция считает длину объединения отрезков - сколько времени занят хоть один сервер. Стресс-тест нашёл
расхождение с перебором на входе из тридцати отрезков от 0 до 47. Перебор говорит 44, быстрая функция -
40 (сами отрезки увидишь, если напечатаешь vhod в первой программе урока).
Где ошибка? Глядя на тридцать пар чисел, ответить невозможно (их строит генератор в первой программе урока,
а 44 и 40 она печатает второй строкой). А теперь тот же дефект на двух отрезках: [1, 2) и [0, 3).
Быстрая функция говорит 2, перебор - 3. Подумай до чтения, что пошло не так.
После сортировки первым идёт [0, 3), вторым - вложенный в него [1, 2). Функция «сливает» их и ставит
конец объединения на конец второго - 2, хотя первый тянется до 3. Пропущен max.
На что это похоже
Поиск пробки в водопроводе. Не разбирают всю систему - перекрывают половину стояков и смотрят, течёт ли. Течёт - пробка в другой половине, нет - в этой. Делят дальше, пока не останется одна труба. Уменьшение контрпримера ищет так же: убирает куски входа и смотрит, держится ли ошибка.
Механизм: выбросить куски, уменьшить числа, держа признак
Алгоритм работает с двумя вещами: входом и признаком - функцией, которая отвечает, проявляется ли на входе та самая ошибка.
- Выбросить куски. Попробовать удалить первую половину входа; если признак держится - удалить. Затем вторую половину, затем куски по четверти, и так до одного элемента. Это упрощённая идея алгоритма ddmin Зеллера и Хильдебрандта (2002).
- Уменьшить числа. Каждое число пробовать заменить меньшим: сдвинуть отрезок к нулю, укоротить его. Если признак держится - оставить замену.
- Повторять, пока что-то меняется.
Результат минимален в одну сторону относительно тех правок, которые алгоритм пробует: ни одна из них по отдельности не сохраняет ошибку. Но это верно, только если фазы повторяют, пока что-то меняется, - так делают настоящие инструменты, и правок они пробуют больше. Учебная программа ниже делает один проход «куски, потом числа» и из чисел пробует только сдвиг влево и укорачивание справа. Её результат минимален лишь относительно последней фазы: после уменьшения чисел выброс какого-то куска мог бы снова сохранить ошибку, а повторно она его не пробует. Это не обязательно самый маленький контрпример вообще, но почти всегда достаточно маленький, чтобы ошибка стала видна глазами.
Так же сжимают входы инструменты проверки свойствами - QuickCheck, Hypothesis: найдя падение, они сначала уменьшают вход и только потом показывают его человеку.
Правило
Найденный контрпример уменьшай программой: выбрасывай куски от половины до одного элемента, потом уменьшай числа, пока ошибка держится. Признак ошибки делай точным - «ошибка именно этого вида», а не «что-то не так»: иначе уменьшение уйдёт к другой ошибке.
Разбор: признак «ответы разошлись» уводит к другой ошибке
У функции две ошибки. Первая - пропущенный max при вложенном отрезке. Вторая - на пустом входе она
возвращает −1 вместо 0. Стресс-тест нашёл тридцать отрезков; уменьшаем с признаком «ответы разошлись».
import random
def dlina_bystro(otrezki):
# длина объединения отрезков [a, b) - с двумя ошибками
if len(otrezki) == 0:
return -1 # ошибка 2: для пустого нужно 0
o = sorted(otrezki)
nach = o[0][0]
kon = o[0][1]
summa = 0
for s in o[1:]:
if s[0] <= kon:
kon = s[1] # ошибка 1: нужно max(kon, s[1])
else:
summa = summa + kon - nach
nach = s[0]
kon = s[1]
return summa + kon - nach
def dlina_perebor(otrezki):
# по определению (границы целые): сколько целых точек t покрыто
# хоть одним [a, b)
pokryto = set()
for s in otrezki:
for t in range(s[0], s[1]):
pokryto.add(t)
return len(pokryto)
# счётчик проверок признака - в списке: без объявления global функция
# не переприсвоит внешнюю переменную, а элемент внешнего списка изменит
vyzovov = [0]
def razoshlis(otrezki):
vyzovov[0] = vyzovov[0] + 1
return dlina_bystro(otrezki) != dlina_perebor(otrezki)
def umenshit(vhod, plohoj):
# 1) выбрасываем куски: от половины списка до одного отрезка
kusok = len(vhod) // 2
while kusok >= 1:
i = 0
while i < len(vhod):
proba = vhod[:i] + vhod[i + kusok:]
if plohoj(proba):
vhod = proba
else:
i = i + kusok
kusok = kusok // 2
# 2) уменьшаем числа: сдвиг влево и укорачивание, пока помогает
menyalos = True
while menyalos:
menyalos = False
for i in range(len(vhod)):
a = vhod[i][0]
b = vhod[i][1]
for zamena in [[a - 1, b - 1], [a, b - 1]]:
if zamena[0] >= 0 and zamena[1] > zamena[0]:
proba = vhod[:i] + [zamena] + vhod[i + 1:]
if plohoj(proba):
vhod = proba
menyalos = True
break
return vhod
random.seed(5)
vhod = []
for k in range(30):
a = random.randint(0, 40)
vhod.append([a, a + random.randint(1, 10)])
print("найден стресс-тестом: 30 отрезков, разошлись:", razoshlis(vhod))
print(" перебор:", dlina_perebor(vhod), "| быстро:",
dlina_bystro(vhod))
print("после уменьшения:", umenshit(vhod, razoshlis), "| проверок",
vyzovov[0])
найден стресс-тестом: 30 отрезков, разошлись: True
перебор: 44 | быстро: 40
после уменьшения: [] | проверок 3
Три проверки - и вход пуст. Первым же шагом уменьшение попробовало выбросить половину отрезков, расхождение осталось, потом вторую половину - и пустой список тоже даёт расхождение: −1 против 0. Признак «разошлись» честно выполнен, но это другая ошибка. В документации к инструментам проверки свойствами такое называют проскальзыванием (slippage): уменьшение «соскальзывает» с одной ошибки на другую, более простую. Тридцать отрезков указывали на вложенность, а результат - на пустой вход.
Разбор: точный признак держит ошибку
Тот же вход, тот же алгоритм уменьшения - но признак описывает именно первую ошибку: вход непустой, и быстрая функция недосчитывает.
import random
def dlina_bystro(otrezki):
# длина объединения отрезков [a, b) - с двумя ошибками
if len(otrezki) == 0:
return -1 # ошибка 2: для пустого нужно 0
o = sorted(otrezki)
nach = o[0][0]
kon = o[0][1]
summa = 0
for s in o[1:]:
if s[0] <= kon:
kon = s[1] # ошибка 1: нужно max(kon, s[1])
else:
summa = summa + kon - nach
nach = s[0]
kon = s[1]
return summa + kon - nach
def dlina_perebor(otrezki):
# по определению (границы целые): сколько целых точек t покрыто
# хоть одним [a, b)
pokryto = set()
for s in otrezki:
for t in range(s[0], s[1]):
pokryto.add(t)
return len(pokryto)
vyzovov = [0]
def nedoschityvaet(otrezki):
# признак ИМЕННО первой ошибки: вход непустой,
# а быстрый ответ меньше верного
vyzovov[0] = vyzovov[0] + 1
if len(otrezki) == 0:
return False
return dlina_bystro(otrezki) < dlina_perebor(otrezki)
def umenshit(vhod, plohoj):
# 1) выбрасываем куски: от половины списка до одного отрезка
kusok = len(vhod) // 2
while kusok >= 1:
i = 0
while i < len(vhod):
proba = vhod[:i] + vhod[i + kusok:]
if plohoj(proba):
vhod = proba
else:
i = i + kusok
kusok = kusok // 2
# 2) уменьшаем числа: сдвиг влево и укорачивание, пока помогает
menyalos = True
while menyalos:
menyalos = False
for i in range(len(vhod)):
a = vhod[i][0]
b = vhod[i][1]
for zamena in [[a - 1, b - 1], [a, b - 1]]:
if zamena[0] >= 0 and zamena[1] > zamena[0]:
proba = vhod[:i] + [zamena] + vhod[i + 1:]
if plohoj(proba):
vhod = proba
menyalos = True
break
return vhod
random.seed(5)
vhod = []
for k in range(30):
a = random.randint(0, 40)
vhod.append([a, a + random.randint(1, 10)])
print("найден стресс-тестом: 30 отрезков, недосчитывает:",
nedoschityvaet(vhod))
mal = umenshit(vhod, nedoschityvaet)
print("после уменьшения:", mal, "| проверок", vyzovov[0])
print("быстро:", dlina_bystro(mal), "перебор:", dlina_perebor(mal))
найден стресс-тестом: 30 отрезков, недосчитывает: True
после уменьшения: [[1, 2], [0, 3]] | проверок 56
быстро: 2 перебор: 3
56 проверок - и два отрезка, вложенные друг в друга. Дальше уменьшать нельзя: если сдвинуть [1, 2) к
нулю, он станет первым после сортировки и ошибка пропадёт; если укоротить [0, 3), он перестанет
выступать за вложенный. Такой вход объясняет дефект без отладчика. Граница правила видна в обоих
разборах: уменьшение находит то, что описывает признак, - не больше и не меньше. Вторая ошибка тоже
настоящая, и её стоит уменьшить отдельно, своим признаком.
Что ломается без этого
Отладка по большому входу. Час с отладчиком на тридцати отрезках вместо минуты на двух.
Проскальзывание. Исправили «найденную» ошибку пустого входа, перезапустили стресс-тест - и через минуту снова падение: настоящая ошибка осталась.
Ручное уменьшение. Человек убирает элементы «на глаз», устаёт на пятой итерации и останавливается на входе из восьми элементов.
Зачем это в работе
Отчёт об ошибке с входом из двух строк чинят в тот же день; отчёт с выгрузкой на мегабайт - через неделю. Тот же приём работает и вне алгоритмов: уменьшение конфигурации, на которой падает сервис, уменьшение SQL-запроса, на котором оптимизатор выдаёт неверный ответ, уменьшение файла, который роняет парсер. Везде нужны только вход, функция-признак и терпение машины.
Теперь сам
- Уменьшение выбрасывает куски от половины до одного элемента. Сколько проверок потребуется в лучшем случае, чтобы из 32 элементов оставить один, если ошибку вызывает именно он? Считай по программе урока.
- Признак: «программа упала с исключением». Почему он склонен к проскальзыванию?
- Почему результат уменьшения не обязательно самый маленький контрпример вообще?
Ответ. 1. Десять, и не только в лучшем случае - при любом месте виновника. Размеров куска пять: 16, 8,
4, 2, 1 (32 → 16 → 8 → 4 → 2 → 1). На каждом в списке ровно два куска: выброс того, где виновника нет,
удаётся, выброс второго - нет. Две проверки на размер, 5 · 2 = 10.
-
Падают от разного: пустой вход, деление на ноль, выход за границу. Признак должен включать тип исключения и место, где оно случилось.
-
Алгоритм жадный: он не пробует одновременно убрать один элемент и изменить другой, и пробует только свой набор правок. Если повторять фазы, пока что-то меняется, результат минимален «в одну сторону» - никакая одиночная правка из этого набора не сохраняет ошибку. Показанная программа фазы не повторяет, и её результат минимален только относительно последней фазы.
Главное
Контрпример уменьшает программа: выбросить куски, уменьшить числа, держа признак ошибки. Признак должен описывать именно эту ошибку - иначе уменьшение соскользнёт на другую, более простую.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий