Динамическое программирование
англ. Dynamic programming
Метод решения задач через перекрывающиеся подзадачи с запоминанием ответов: F(30) наивной рекурсией требует 1 664 079 вызовов, таблицей ДП — 28 сложений.
Рекурсия элегантна, но часто пересчитывает одно и то же тысячи раз. Динамическое программирование — метод решения задач, которые распадаются на перекрывающиеся подзадачи: оптимальный ответ на задачу собирается из оптимальных ответов на её меньшие версии (оптимальная подструктура), а каждая подзадача решается один раз, и результат запоминается в таблице. Метод ввёл Ричард Беллман в 1950-х, и имя «динамическое» было, по его собственному признанию, скорее рабочей вывеской, чем описанием сути. В дискретной математике ступенчатый пересчёт по шагам — это вычисление по рекуррентным соотношениям: значение для шага n выражается через значения меньших шагов. Корректность переходов проверяется индукцией: база таблицы — база индукции, заполнение клетки — индукционный переход; ход такого рассуждения разбирает математическая индукция.
Классика жанра — числа Фибоначчи: определение скрывает ловушку наивной рекурсии — ветви дерева вызовов дублируются, и для счётчик засчитывает 1 664 079 вызовов, хотя уникальных подзадач всего тридцать. Таблица снизу вверх решает всё за 28 сложений, а числа Фибоначчи превращаются из символа экспоненциальной тупости в линейный алгоритм. Та же схема считает число путей по сетке 3×4: из левого нижнего угла в правый верхний ведут путей — значение клетки есть сумма значений соседа слева и соседа снизу. Типичные грабли: применять ДП там, где подзадачи не перекрываются, — хватит обычного «разделяй и властвуй»; путать с жадностью — та берёт локально выгодный шаг без пересмотра, а ДП честно сравнивает варианты; ошибиться в порядке заполнения таблицы и обращаться к ещё не посчитанным клеткам.
Частые вопросы
Чем динамическое программирование отличается от жадного алгоритма и разделяй-и-властвуй?
Разделяй и властвуй делит задачу на независимые части: повторов нет, и таблица не нужна. Динамическое программирование работает, когда части перекрываются: без кэша одни и те же подзадачи решаются заново миллионы раз. Жадный алгоритм делает локально лучший шаг и не пересматривает его: он быстрее, но оптимален лишь в задачах со специальной структурой. ДП честно сравнивает последствия всех вариантов через таблицу и гарантирует оптимум ценой памяти и времени по числу подзадач.
Как понять, что задачу нужно решать динамическим программированием?
Три признака: задача просит оптимум или количество способов, а не сам объект; решение сводится к выбору на последнем шаге — если знать ответы для меньших задач, текущий ответ собирается из них; подзадачи повторяются, так что грубая рекурсия задыхается. Практика: сформулируйте состояние — что достаточно знать о пройденной части, выпишите переход между состояниями и базу, оцените число состояний: таблица должна помещаться в память. Если состояния придумываются легко, ДП почти наверняка сработает.