1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8

# теория · шаг 1 из 8

Порядок вычисления: почему от перестановки циклов ломается ответ

Коротко

Таблица верна только если каждая клетка считается ПОСЛЕ всех, от которых зависит. Формально это топологический порядок на графе зависимостей состояний; на практике - правило «смотри, на какие индексы ссылается переход». Два учебных случая стоят рядом: у Флойда-Уоршелла цикл по k обязан быть внешним, иначе теряются пути; у свёрнутого в одну строку рюкзака вместимость надо обходить НАЗАД, иначе один предмет берётся дважды. Второй случай особенно коварен: ошибка видна только когда предмет влезает в рюкзак дважды - на тяжёлых предметах неверный код даёт верный ответ.

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

Как думаешь, что решает порядок

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

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

На что это похоже

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

Механизм: зависимости и правило обхода

ПРАВИЛО
клетка считается ПОСЛЕ всех клеток, на которые ссылается её переход

формально: состояния - вершины графа, зависимости - рёбра,
нужен ТОПОЛОГИЧЕСКИЙ порядок (раздел 10). На практике порядок виден
прямо из индексов в формуле перехода
ЧИТАЕМ ПОРЯДОК ИЗ ПЕРЕХОДА

dp[i] = f(dp[i−1], dp[i−2])          → i по возрастанию
dp[i][j] = f(dp[i−1][j], dp[i][j−1]) → строки сверху вниз, в строке слева направо
dp[i][c] = f(dp[i−1][c], dp[i−1][c−w]) → строка i после строки i−1, внутри любой порядок
dp[m][v] = f(dp[меньшая маска][u])   → маски по возрастанию числа
МЕМОИЗАЦИЯ ПОРЯДКА НЕ ТРЕБУЕТ
рекурсия сама спускается к тому, что нужно. Это её главный плюс:
если порядок сложный или неочевидный, мемоизация просто работает.
Плата - стек и невозможность сэкономить память

Правило

Клетка таблицы считается только после всех клеток, на которые ссылается её переход; формально это топологический порядок на графе зависимостей состояний, а практически порядок читается прямо из индексов в формуле. Если переход ссылается на предыдущую СТРОКУ (dp[i−1][...]), строки идут по порядку, а внутри строки - как угодно. Если переход ссылается на ТУ ЖЕ строку (dp[i][...]), порядок внутри строки становится критичным: он определяет, можно ли использовать уже обновлённое значение того же шага. Мемоизация от этой проблемы избавляет полностью - порядок получается сам, - но платит стеком и памятью. Неверный порядок не вызывает ошибки: таблица заполняется, программа отвечает, и ответ неверен.

Разбор: свёрнутый рюкзак и направление обхода

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

dp = [0] * (C + 1)
i = 0
while i < len(ves):
    c = C
    while c >= ves[i]:              # НАЗАД, от C к весу предмета
        if dp[c - ves[i]] + cena[i] > dp[c]:
            dp[c] = dp[c - ves[i]] + cena[i]
        c = c - 1
    i = i + 1

Почему назад. При обходе назад клетка dp[c − вес] ещё не тронута на этом шаге - в ней значение из ПРЕДЫДУЩЕЙ строки, то есть ответ без текущего предмета. Именно это и нужно: предмет берётся не больше одного раза. При обходе вперёд в dp[c − вес] уже лежит значение, УЧИТЫВАЮЩЕЕ текущий предмет, и он берётся второй раз, третий, четвёртый.

ЗАМЕР на движке: предметы (10,60), (20,100), (30,120), вместимость 50

двумерная таблица        220    1116 шагов
одна строка, назад       220     522 шага
одна строка, вперёд      300     480 шагов     ← НЕВЕРНО

Откуда 300: предмет весом 10 и ценой 60 взят ПЯТЬ раз - 5 × 60 = 300, вес 5 × 10 = 50. Обход вперёд превратил задачу в рюкзак С ПОВТОРЕНИЯМИ, а это другая задача (и, кстати, её переход отличается от правильного ровно одним индексом - dp[i] вместо dp[i−1]).

Теперь самое неприятное:

тот же замер на двенадцати предметах весом 28-35 при C = 60:

двумерная таблица        147   4639 шагов
одна строка, назад       147   1639 шагов
одна строка, вперёд      147   1634 шага      ← тоже 147!

Неверный код дал верный ответ. И причина тоньше, чем «предметы не влезают дважды»: при C = 60 предметы весом 28, 28, 29 и 30 влезают по два раза. Дело в том, что повтор здесь НЕ ВЫГОДЕН:

лучший вариант «один предмет дважды»:   28 + 28 = 56, цена 65 + 65 = 130
честный оптимум из РАЗНЫХ предметов:    32 + 28 = 60, цена 82 + 65 = 147

неограниченный рюкзак (то, что считает обход вперёд)  = 147
рюкзак 0/1 (то, что нужно)                            = 147

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

Отсюда правило для тестов: свёрнутый рюкзак проверяют на данных, где повтор ВЫГОДЕН - где есть лёгкий предмет с хорошей удельной ценой, которым можно забить рюкзак целиком. В первом наборе урока это (10, 60): пять штук дают 300 против честных 220.

Разбор: почему у Флойда k снаружи

Напомним переход из одиннадцатого раздела: d[i][j] через вершины с номерами до k.

правильно:                          неправильно:
for k: for i: for j:                for i: for j: for k:
    d[i][j] = min(d[i][j],              d[i][j] = min(d[i][j],
                  d[i][k] + d[k][j])                  d[i][k] + d[k][j])

Смысл внешнего k в том, что он нумерует СЛОИ: после k-й итерации в таблице лежат ответы для путей, которым разрешены промежуточные вершины 1...k. Это и есть параметр состояния, просто сэкономленный по памяти - полная таблица была бы трёхмерной d[k][i][j].

Если k оказывается внутри, то для пары (i, j) перебираются все промежуточные вершины сразу - но значения d[i][k] и d[k][j] в этот момент могут быть ещё не досчитаны: до пар, которые дают короткий путь через две пересадки, цикл просто не дошёл. Путь, состоящий из двух улучшений, при таком порядке теряется.

ПРИМЕР, НА КОТОРОМ ЭТО ВИДНО
граф из трёх вершин: 1 → 2 (вес 1), 2 → 3 (вес 1), прямого 1 → 3 нет

при внешнем k: на итерации k = 2 клетка d[1][3] становится 2 - путь найден
при внутреннем k: для пары (1, 3) перебираются все k, но d[1][2] и d[2][3]
    к этому моменту ещё лежат как «нет пути» - потому что цикл по парам
    до них не дошёл. Ответ остаётся «пути нет»

то же проверено фикстурой раздела 11 на большем графе: код с переставленными
циклами теряет путь. Правильность Флойда - это правильность ПОРЯДКА, а не формулы

Третий типовой случай порядка - ДП по маскам (третий урок этой главы): там переход идёт от маски к маске с одним добавленным элементом, то есть к БОЛЬШЕМУ числу, и достаточно обходить маски по возрастанию их числового значения.

Что ломается без этого

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

Зачем это в работе

Порядок вычисления - то место, где ломаются готовые решения при переносе. Классический сюжет: человек видит в чужом коде свёрнутый в одну строку рюкзак, копирует его в задачу, где предметы можно брать по многу раз (или наоборот), и ответ получается тихо неверным, потому что направление обхода не соответствует задаче. Второй практический слой - отладка. Если динамика отвечает «почти правильно» (на некоторых входах верно, на некоторых нет), первая версия диагноза не «ошибка в формуле», а «ошибка в порядке»: стоит заменить таблицу на мемоизацию и сравнить ответы. Мемоизация порядка не требует, поэтому расхождение сразу показывает, что переход верен, а обход нет. Третье - выбор между подходами в проде: таблица быстрее и экономнее по памяти, мемоизация надёжнее при сложной структуре состояний, и это не вопрос вкуса, а осознанный компромисс.

Теперь сам

Переход задачи: dp[i][j] = dp[i−1][j] + dp[i][j−1] (число путей в таблице). В каком порядке заполнять и что будет при обходе строк снизу вверх?

Ответ. Переход ссылается на клетку СВЕРХУ (i−1) и клетку СЛЕВА (j−1). Значит строки идут сверху вниз, а внутри строки - слева направо.

правильный обход:                   обход снизу вверх:
i = 1..n                            i = n..1
  j = 1..m                            j = 1..m
     dp[i][j] = dp[i−1][j] + dp[i][j−1]

при обходе снизу вверх dp[i−1][j] - это клетка строки ВЫШЕ, которая
при таком порядке ещё не посчитана: в ней ноль. Слагаемое «пришли
сверху» исчезает, и остаются только пути, идущие по строке ВПРАВО

Проверка на таблице 3 × 3 без запретов: правильный обход даёт в углу 6 путей, обход снизу вверх - НОЛЬ. Вот что в таблице при неверном обходе:

1  1  1
1  1  1
0  0  0      ← последняя строка считалась ПЕРВОЙ, когда строка выше была пуста

Ноль - это ещё повезло: такой ответ хотя бы выглядит подозрительно. Хуже, когда неверный порядок даёт правдоподобное число.

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

Главное

Клетка считается после всех, на которые ссылается переход: формально это топологический порядок состояний, практически порядок читают из индексов формулы. Ссылка на предыдущую строку - строки по порядку, внутри как угодно; ссылка на ТУ ЖЕ строку - порядок внутри строки критичен. Свёрнутый в одну строку рюкзак обходят по вместимости НАЗАД: при обходе вперёд dp[c − вес] уже содержит текущий предмет, и он берётся многократно - замер даёт 300 вместо 220 (предмет весом 10 взят пять раз). Но на двенадцати предметах весом 28-35 при C = 60 та же ошибка даёт ВЕРНЫЙ ответ 147: повтор там ВОЗМОЖЕН (28 + 28 = 56), просто не выгоден - 130 против 147. Поэтому тестовые данные обязаны содержать предмет, которым выгодно забить рюкзак целиком. У Флойда-Уоршелла k обязан быть внешним: он нумерует слои «какие вершины разрешены», и при внутреннем k теряются пути через две пересадки. Неверный порядок не даёт ошибки - только неверный ответ, и проверяется он сравнением с мемоизацией, которой порядок не нужен.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.