"Информатика (базовый и углублённый уровни) (в 2 частях)", Поляков К.Ю., Еремин Е.А.
Динамическое программирование помогает решать задачи, разбивая их на более простые подзадачи и сохраняя найденные ответы. Этот подход особенно полезен, когда одни и те же подзадачи встречаются снова, а общий ответ можно получить из результатов для меньших случаев. Чтобы применить метод, нужно определить состояние, вывести рекуррентное соотношение, задать начальные значения и выбрать порядок вычислений. Так можно находить число способов или оптимальное решение без лишних повторных расчётов.
Текст подготовлен нейронной сетью по теме параграфа и не заменяет учебник — важные факты и формулировки сверяйте с ним.
Динамическое программирование — способ решения задачи, при котором ответ строят из ответов на более простые подзадачи, сохраняя уже найденные результаты. Это удобно, когда решение можно постепенно собирать из меньших случаев. Сохранение результатов не даёт заново выполнять одинаковые расчёты.
Подзадача — часть исходной задачи, для которой можно определить отдельный ответ и использовать его при решении более крупной задачи. Например, при поиске пути до клетки подзадачей будет поиск пути до одной из клеток перед ней. Ответы для таких частей помогают получить результат для всей таблицы.
Состояние динамического программирования — набор параметров, однозначно описывающий подзадачу, например номер шага или доступный вес. По состоянию понятно, какой именно ответ нужно хранить в таблице. Правильно выбранные параметры позволяют не учитывать подробности уже пройденного пути.
Рекуррентное соотношение — формула, задающая ответ для текущего состояния через ответы для предыдущих состояний. Оно описывает, как получить новый результат из уже найденных. По этой формуле затем последовательно заполняют таблицу состояний.
Сначала выясняют, можно ли разбить задачу на повторяющиеся подзадачи. Для этого ищут способ получить ответ для большого случая из ответов для меньших случаев. Например, число способов попасть на ступеньку зависит от способов попасть на две предыдущие. Метод особенно полезен, если одни и те же меньшие случаи возникают неоднократно: их ответы можно вычислить один раз и сохранить.
Затем выбирают состояние, то есть решают, какие параметры будут отличать одну подзадачу от другой. Параметры должны содержать всю информацию, нужную для дальнейшего расчёта: если ответ зависит от номера предмета и оставшейся вместимости, одного номера предмета недостаточно. Избыточные сведения тоже не нужны: состояние не должно запоминать весь путь к нему, если для продолжения достаточно его параметров. Хорошая проверка — можно ли по состоянию однозначно понять, какое значение в нём хранится.
Рекуррентное соотношение выводят, перечисляя варианты, которыми можно получить текущее состояние. Если нужно посчитать количество способов, ответы для разных вариантов складывают, потому что каждый вариант даёт отдельные способы. Если требуется минимум или максимум, среди подходящих вариантов выбирают наименьшую или наибольшую стоимость. Так формула отражает именно условие задачи, а не просто удобный арифметический приём.
Для запуска вычислений задают начальные значения — ответы для самых простых подзадач. Они известны непосредственно из условия или очевидны по смыслу задачи; например, способ попасть на стартовую позицию часто ровно один. Если начальный ответ выбран неверно, ошибка перейдёт в состояния, рассчитанные из него. После этого таблицу заполняют от простых состояний к сложным, чтобы все нужные значения уже были готовы.
Можно было бы каждый раз рекурсивно вычислять ответ для нужной подзадачи, но тогда одни и те же вычисления часто повторялись бы. Динамическое программирование сохраняет полученные ответы и обращается к ним при следующих расчётах. Поэтому число вычислений обычно заметно сокращается, хотя для хранения таблицы требуется память. Табличный вариант выполняет вычисления по порядку, без повторного запуска уже решённых подзадач.
Таблица помогает проверить, что для решения определены все необходимые части: от смысла ячейки до способа получить итог. Заполнять её следует в порядке, при котором зависимости ведут к уже рассчитанным состояниям. После заполнения нужно отдельно указать, где именно находится ответ задачи.
| Элемент | Что определить | Зачем нужен |
|---|---|---|
| Состояние | Параметры подзадачи | Однозначно задаёт, какой ответ вычисляется |
| Рекуррентное соотношение | Связь текущего ответа с ответами для меньших состояний | Позволяет последовательно вычислить все ответы |
| Начальные значения | Ответы для простейших состояний | Запускают вычисление по рекуррентному соотношению |
| Порядок вычисления | Последовательность заполнения таблицы состояний | Гарантирует, что нужные предыдущие ответы уже найдены |
| Итоговый ответ | Состояние или комбинация состояний, дающая ответ задачи | Позволяет извлечь решение из заполненной таблицы |
Задача 1. Подъём по ступенькам. Нужно найти число способов подняться на n-ю ступеньку, если за один ход можно пройти одну или две ступени. Состоянием выберем номер ступеньки, а f(n) обозначим число способов попасть на неё. Последний ход на ступеньку n мог быть сделан с n − 1 или с n − 2.
Ответ: число способов подняться на n-ю ступеньку равно f(n), полученному по этой формуле. Например, f(2) = 2, а f(3) = 3. Начальные значения здесь описывают условие, что подъём может начинаться с основания.
Задача 2. Минимальный путь в таблице. В клетках стоят числа; требуется пройти из левого верхнего угла в правый нижний, двигаясь только вправо или вниз, и получить наименьшую сумму чисел посещённых клеток. Пусть a[i,j] — число в клетке строки i и столбца j, а d[i,j] — минимальная стоимость пути до неё. Войти в клетку можно только сверху или слева, поэтому других вариантов перехода нет.
Ответ: искомая минимальная сумма находится в d[r,c], где r и c — число строк и столбцов таблицы. Число текущей клетки прибавляется к меньшей из стоимостей подхода к ней. Такой выбор верен, потому что любой допустимый путь обязан прийти сверху либо слева.
Задача 3. Рюкзак. Есть предметы с массами и ценностями; вместимость рюкзака ограничена, а каждый предмет разрешено взять не более одного раза. Нужно найти наибольшую суммарную ценность среди наборов, масса которых не превышает вместимость. Пусть dp[i,w] — наибольшая ценность при рассмотрении первых i предметов и вместимости w.
Ответ: максимальная ценность равна dp[N,W], где N — число предметов, а W — вместимость рюкзака. В каждом состоянии проверены оба допустимых решения по текущему предмету. Если требуется не только ценность, но и сам набор, можно дополнительно восстановить выбор по таблице.
Состояние может не учитывать параметр, от которого зависит ответ, например вместимость рюкзака в задаче о выборе предметов. Тогда одной ячейке таблицы придётся соответствовать нескольким разным подзадачам, и формула даст неверный результат. Ошибку допускают, когда выбирают состояние слишком поспешно; проверьте, можно ли по его параметрам однозначно вычислить следующий ответ.
Неверные или забытые начальные значения портят все состояния, которые рассчитываются на их основе. Часто начало выбирают по привычке, не сверяя его с точным смыслом задачи. Проверьте каждое простейшее состояние напрямую: совпадает ли его значение с тем, что получилось бы без применения рекуррентной формулы?
Если заполнять таблицу в неподходящем порядке, нужные предыдущие ответы могут ещё отсутствовать; похожий результат возникает при ошибке в индексах. Это случается, когда переносят формулу, не разбирая, от каких именно клеток или состояний она зависит. Перед вычислением проследите каждую ссылку в формуле и убедитесь, что указанное значение уже найдено и относится к нужной подзадаче.
Динамическое программирование не означает, что обязательно нужно писать рекурсивную функцию. Такое заблуждение возникает из-за того, что формулы связывают текущие ответы с предыдущими. На деле таблицу можно заполнить циклами; проверка простая — если вычисление идёт по порядку и ответы сохраняются, рекурсивные вызовы не обязательны.
Для динамического программирования полезны циклы, массивы и таблицы: с их помощью хранят ответы и задают порядок вычисления. Также нужно уметь читать условия и разбивать их на случаи, чтобы правильно вывести рекуррентную формулу. Связь с рекурсией помогает увидеть зависимость между меньшей и большей задачами, но не обязывает использовать рекурсивную программу. Метод применяют для поиска оптимального пути, выбора набора предметов, подсчёта способов и обработки последовательностей.
Как понять, что задачу можно решить динамическим программированием?
Проверьте, можно ли разбить её на более простые подзадачи и выразить ответ для большой задачи через ответы для меньших. Метод особенно подходит, если одинаковые подзадачи возникают многократно и их результаты можно сохранить.
Как правильно выбрать состояние динамического программирования?
Определите, какую подзадачу описывает один элемент таблицы, и запишите его параметры. Их должно хватать, чтобы вычислить ответ, но не должно быть лишней информации о пути, которым вы пришли к этому состоянию.
Чем динамическое программирование отличается от рекурсии?
Рекурсия — это способ организации вычислений через вызов функции самой себя, а динамическое программирование — метод решения с сохранением ответов для подзадач. Динамическое программирование можно реализовать таблицей и циклами, без рекурсивных вызовов.
Как определить начальные значения рекуррентного соотношения?
Найдите простейшие подзадачи, ответы на которые известны напрямую из условия. Проверьте, что выбранные значения соответствуют смыслу состояния и позволяют начать вычисление остальных ответов.
В каком порядке заполнять таблицу состояний?
Заполняйте таблицу так, чтобы перед вычислением состояния уже были найдены все ответы, от которых оно зависит. Например, если ответ для n зависит от n − 1 и n − 2, считайте состояния по возрастанию n.
§ 45. Динамическое программирование