open atlas
↑ К треку
Логика с нуля LOGIC · 06 · 03

Инварианты на практике: доказать недостижимость цели без перебора

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

LOGIC ◷ 17 min

Во время миграции шардов коллега задал вопрос, заморозивший стендап: «Может ли потеряться хоть один цент?» Инструмент переносил счета между шардами базы по одному переводу за раз — миллионы ходов за выходные. Прогнать его на стейджинге тысячу раз ничего не доказывает: цент, потерянный на сорокамиллионном ходе, в смоук-тесте не всплывёт. Комнату разморозило одно предложение: каждый отдельный ход списывает с одного шарда ровно столько, сколько зачисляет на другой, поэтому сумма по всем шардам измениться не может — ни после одного хода, ни после сорока миллионов, ни в каком порядке. Кто-то превратил это предложение в assert, сравнивающий общую сумму до и после, — и через неделю assert сработал, поймав настоящий баг округления в конвертации валют, которую никто не подозревал. У свойства, переживающего каждый разрешённый ход, есть имя — инвариант — и это самое близкое, что есть у инженерии к доказательству отрицания. Этот урок — практикум: маленькие головоломки, доказанные до конца, а затем тот же клинок в циклах, бухгалтерии и конечных автоматах.

Цель
  • Определять инвариант и применять его для доказательства недостижимости
  • Применять чек-лист метода инвариантов к новым головоломкам и задачам кода
  • Переводить инварианты в исполняемые assert’ы сохранения
  • Понимать асимметрию: инвариант доказывает невозможность, но не достижимость

Возьмите доску 8 × 8 и отрежьте два противоположных угла — они одного цвета, скажем оба белые. Осталось 62 клетки. Замостят ли их 31 доминошка, каждая из которых накрывает две соседние клетки?

Доказательство того, что любая попытка обязана провалиться, умещается в три предложения.

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

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

Викторина

Почему аргумент с доской исключает ВСЕ замощения, а не только испробованные?

Игра с перевёртыванием знаков. На доске четыре знака: +, +, −, +. Разрешённый ход: выбрать любые два и перевернуть оба. Цель: все четыре плюса.

Инвариант: посчитайте минусы и взгляните на чётность их числа. Ход переворачивает две записи, и случаев всего три: оба были плюсами (минусов стало на 2 больше), оба минусами (на 2 меньше), по одному каждого (итого 0). В каждом случае число минусов меняется на −2, 0 или +2: его чётность не меняется никогда. На старте один минус — нечётно. В цели ноль — чётно. Недостижимо: после любого числа ходов, в любом порядке.

Переливания. Два кувшина — 12 литров и 8 литров, кран и слив. Можно ли отмерить ровно 6 литров? Инвариант: 4 — наибольший общий делитель 12 и 8: обе ёмкости кратны 4, старт (0) кратен 4, а каждый ход лишь добавляет, вычитает или переносит объёмы, собранные из ёмкостей — кратные четырём остаются кратными четырём. Достижимые объёмы — ровно 12, и шести среди них нет. Цель отличается от старта по инвариантному свойству «каждый объём кратен НОД» — недостижима.

Каждая головоломка выше шла по одному сценарию, и его стоит записать:

  1. Перечислите все разрешённые ходы, исчерпывающе — аргумент через инвариант хорош ровно настолько, насколько полон список ходов.
  2. Ищите величину, которую каждый ход сохраняет: неизменную сумму, чётность, «кратность d», цветовой баланс — или величину, движущуюся только в одну сторону (только растёт, только убывает).
  3. Сверьте кандидата с каждым ходом — единственный ломающий ход убивает кандидата, и эта сверка и есть настоящая работа.
  4. Сравните старт и цель по инварианту — различаются, значит, цель недостижима, и в руках у вас доказательство, а не догадка.

Вы писали инварианты всё это время. Инвариант цикла суммирования: свойство «acc равен сумме первых i элементов» выполняется до старта цикла (i = 0, acc = 0, пустая сумма) и восстанавливается каждой итерацией:

let acc = 0;                      // инвариант выполнен: сумма первых 0 элементов — 0
for (let i = 0; i < xs.length; i++) {
  // инвариант здесь: acc === xs[0] + … + xs[i-1]
  acc += xs[i];                   // ход восстанавливает его для i + 1
}
// цикл кончается при i === xs.length, значит: acc === сумма всего массива

Миграция из пролога — «сохранительная» разновидность, инвариант в продакшен-одежде:

// Инвариант сохранения: перевод перемещает центы — не чеканит и не сжигает их.
function transfer(from, to, cents) {
  from.cents -= cents;
  to.cents += cents;              // списание равно зачислению — ход сохраняет сумму
}

const total = (accs) => accs.reduce((sum, a) => sum + a.cents, 0);

const before = total(accounts);
applyAllMoves(accounts);          // миллионы переводов, в каком угодно порядке
console.assert(total(accounts) === before, "cents created or destroyed");

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

Одно честное ограничение: инвариант доказывает недостижимость, когда старт и цель по нему различаются. Когда совпадают — сам по себе он не доказывает ничего: цель может быть достижима, а может быть заперта другим препятствием. Шесть литров с кувшинами 12 и 8 доказуемо невозможны; а что 4 литра достижимы, всё равно показывается предъявлением ходов (наполнить 12, перелить в 8, осталось 4 — готово). Доказательство невозможности и построение — разные артефакты.

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

Миграция шардов — conservation assert пошагово.

Задача: доказать, что за миллионы переводов между шардами ни один цент не создаётся и не уничтожается.

Шаг 1 — перечислить все ходы: единственный разрешённый ход — transfer(from, to, cents), который вычитает cents из from и прибавляет cents к to.

Шаг 2 — найти величину: total = sum of all account.cents.

Шаг 3 — сверить с каждым ходом: после transfer: (from.cents - cents) + (to.cents + cents) + остальные = from.cents + to.cents + остальные. Сумма не изменилась. Инвариант пережил ход.

Шаг 4 — сравнить старт и цель: total(before) === total(after) — свойство должно выполняться после любого числа ходов.

Шаг 5 — assert в коде:

const before = total(accounts);
applyAllMoves(accounts);
console.assert(total(accounts) === before, "cents created or destroyed");

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

Практика 0 / 5

Проверь себя
Викторина

Вы час искали последовательность ходов до цели и не нашли. Что вы установили?

Итог

Инвариант — свойство состояния системы, которое выполняется на старте и переживает каждый разрешённый ход, — и это инструмент, доказывающий недостижимость цели без перебора: если у цели свойства нет, никакая последовательность ходов — любой длины, в любом порядке — туда не приведёт. Доску без двух противоположных углов не замостить, потому что каждая доминошка накрывает одну чёрную и одну белую клетку — накрытые количества вечно равны, а доска предлагает 32 чёрных против 30 белых. Игра с перевёртыванием знаков сохраняет чётность числа минусов — двойной переворот меняет его на −2, 0 или +2, — поэтому один минус никогда не станет нулём. Кувшины на 12 и 8 литров держат каждый объём кратным НОД(12, 8) = 4, так что 6 литров доказуемо вне досягаемости. Метод — чек-лист из 4 шагов: перечислить все разрешённые ходы; искать величину, которая сохраняется, держит чётность или движется только в одну сторону; сверить с каждым ходом — один ломающий ход убивает кандидата; сравнить старт и цель. В коде тот же клинок — инвариант цикла, доказывающий, что вычисляет цикл; assert сохранения, поймавший баг округления среди миллионов переводов; монотонный флаг, который не откатывается. Помните асимметрию: безуспешный поиск не доказывает ничего — невозможности нужен инвариант, достижимости — предъявленная последовательность.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.