- PVSM.RU - https://www.pvsm.ru -
Я был крайне удивлён, найдя мало статей про динамическое программирование (далее просто динамика) на хабре. Мне всегда казалось, что эта парадигма довольно сильно распространена, в том числе и за пределами олимпиад по программированию. Поэтому я постараюсь закрыть этот пробел своей статьёй.
# Весь код в статье написан на языке Python
Пожалуй, лучшее описание динамики в одно предложение, которое я когда либо слышал:
Динамическое программирование — это когда у нас есть задача, которую непонятно как решать, и мы разбиваем ее на меньшие задачи, которые тоже непонятно как решать. (с) А. Кумок.
Чтобы успешно решить задачу динамикой нужно:
1) Состояние динамики: параметр(ы), однозначно задающие подзадачу.
2) Значения начальных состояний.
3) Переходы между состояниями: формула пересчёта.
4) Порядок пересчёта.
5) Положение ответа на задачу: иногда это сумма или, например, максимум из значений нескольких состояний.
Существует три порядка пересчёта:
1) Прямой порядок:
Состояния последовательно пересчитывается исходя из уже посчитанных.
2) Обратный порядок:
Обновляются все состояния, зависящие от текущего состояния.
3) Ленивая динамика:
Рекурсивная мемоизированная [1] функция пересчёта динамики. Это что-то вроде поиска в глубину [2] по ацикличному графу состояний, где рёбра — это зависимости между ними.
Элементарный пример: числа Фибоначчи [3]. Состояние — номер числа.
Прямой порядок:
fib[1] = 1 # Начальные значения
fib[2] = 1 # Начальные значения
for i in range(3, n + 1):
fib[i] = fib[i - 1] + fib[i - 2] # Пересчёт состояния i
Обратный порядок:
fib[1] = 1 # Начальные значения
for i in range(1, n):
fib[i + 1] += fib[i] # Обновление состояния i + 1
fib[i + 2] += fib[i] # Обновление состояния i + 2
Ленивая динамика:
def get_fib(i):
if (i <= 2): # Начальные значения
return 1
if (fib[i] != -1): # Ленивость
return fib[i]
fib[i] = get_fib(i - 1) + get_fib(i - 2) # Пересчёт
return fib[i]
Все три варианта имеют права на жизнь. Каждый из них имеет свою область применения, хотя часто пересекающуюся с другими.
Пример одномерной динамики приведён выше, в «порядке пересчёта», так что я сразу начну с многомерной. Она отличается от одномерной, как вы уже наверно догадались, количеством измерений, то есть количеством параметров в состоянии. Классификация по этому признаку обычно строится по схеме «один-два-много» и не особо принципиальна, на самом деле.
Многомерная динамика не сильно отличается от одномерной, в чём вы можете убедиться взглянув на пару примеров:
Раньше, когда у телефонов были кнопки, их клавиатуры выглядели примерно так:
Требуется подсчитать, сколько различных текстовых сообщений множно написать используя не более k
нажатий на такой клавиатуре.
dp[n][m]
— количество различных сообщений длины n
, использующих m
нажатий.Прямой пересчёт:
dp[n][m] = (dp[n - 1][m - 1] + dp[n - 1][m - 2] + dp[n - 1][m - 3]) * 8 + dp[n - 1][m - 4] * 2
Обратный пересчёт:
dp[n + 1][m + 1] += dp[n][m] * 8
dp[n + 1][m + 2] += dp[n][m] * 8
dp[n + 1][m + 3] += dp[n][m] * 8
dp[n + 1][m + 4] += dp[n][m] * 2
4) Порядок пересчёта:
Если писать прямым методом, то надо отдельно подумать о выходе за границу динамики, к примеру, когда мы обращаемся к dp[n - 1][m - 4]
, которого может не существовать при малых m
. Для обхода этого можно или ставить проверки в пересчёте или записать туда нейтральные элементы (не изменяющие ответа).
При использовании обратного пересчёта всё проще: мы всегда обращаемся вперёд, так что в отрицательные элементы мы не уйдём.
5) Ответ — это сумма всех состояний.
Шахматный конь стоит в клетке (1, 1)
на доске размера N
x M
. Требуется подсчитать количество способов добраться до клетки (N, M)
передвигаясь четырьмя типами шагов:
dp[i][j]
— количество способов добраться до (i, j)
.(1, 1)
можно добраться одним способом — ничего не делать.dp[i][j] = dp[i - 2][j - 1] + dp[i - 2][j + 1] + dp[i - 1][j - 2] + dp[i + 1][j - 2]
Для обратного порядка:
dp[i + 1][j + 2] += dp[i][j]
dp[i + 2][j + 1] += dp[i][j]
dp[i - 1][j + 2] += dp[i][j]
dp[i + 2][j - 1] += dp[i][j]
4) А теперь самое интересное в этой задаче: порядок. Здесь нельзя просто взять и пройтись по строкам или по столбцам. Потому что иначе мы будем обращаться к ещё не пересчитанным состояниям при прямом порядке, и будем брать ещё недоделанные состояния при обратном подходе.
Есть два пути:
1) Придумать хороший обход.
2) Запустить ленивую динамику, пусть сама разберётся.
Если лень думать — запускаем ленивую динамику, она отлично справится с задачей.
Если не лень, то можно придумать обход на подобии такого:
Этот порядок гарантирует обработанность всех требуемых на каждом шаге клеток при прямом обходе, и обработанность текущего состояния при обратном.
5) Ответ просто лежит в dp[n][m]
.
Если никогда не умножали матрицы, но хотите понять этот заголовок, то стоит прочитать хотя бы вики [4].
Допустим, есть задача, которую мы уже решили динамическим программированием, например, извечные числа Фибоначчи.
Давайте немного переформулируем её. Пусть у нас есть вектор , из которого мы хотим получить вектор . Чуть-чуть раскроем формулы: . Можно заметить, что из вектора можно получить вектор путем умножения на какую-то матрицу, ведь в итоговом векторе фигурируют только сложенные переменные из первого вектора. Эту матрицу легко вывести, вот она: . Назовём её матрицей перехода.
Это значит, что если взять вектор и умножить его на матрицу перехода n - 1
раз, то получим вектор , в котором лежит fib[n]
— ответ на задачу.
А теперь, зачем всё это надо. Умножение матриц обладает свойством ассоциативности, то есть (но при этом не обладает коммутативностью, что по-моему удивительно). Это свойство даёт нам право сделать так: .
Это хорошо тем, что теперь можно применить метод быстрого возведения в степень [5], который работает за . Итого мы сумели посчитать N
-ое число Фибоначчи за логарифм арифметических операций.
А теперь пример посерьёзнее:
Обозначим пилообразную последовательность длины N
как последовательность, у которой для каждого не крайнего элемента выполняется условие: он или меньше обоих своих соседей или больше. Требуется посчитать количество пилообразных последовательностей из цифр длины N
. Выглядит это как-то так:
1) Состояние динамики: dp[n][last][less]
— количество пилообразных последовательностей длины n
, заканчивающихся на цифру last
. Причём если less == 0
, то последняя цифра меньше предпоследней, а если less == 1
, значит больше.
2) Начальные значения:
for last in range(10):
dp[2][last][0] = 9 - last
dp[2][last][1] = last
3) Пересчёт динамики:
for prev in range(10):
if prev > last:
dp[n][last][0] += dp[n - 1][prev][1]
if prev < last:
dp[n][last][1] += dp[n - 1][pref][0]
4) Порядок пересчёта: мы всегда обращаемся к предыдущей длине, так что просто пара вложенных for
'ов.
5) Ответ — это сумма dp[N][0..9][0..1]
.
Теперь надо придумать начальный вектор и матрицу перехода к нему. Вектор, кажется, придумывается быстро: все состояния, обозначающие длину последовательности N
. Ну а матрица перехода выводится, смотря на формулы пересчёта.
Это класс динамики, в котором состояние — это границы подотрезка какого-нибудь массива. Суть в том, чтобы подсчитать ответы для подзадач, основывающихся на всех возможных подотрезках нашего массива. Обычно перебираются они в порядке увеличения длины, и пересчёт основывается, соответственно на более коротких отрезках.
Вот Развернутое условие [6]. Я вкратце его перескажу:
Определим сжатую строку:
1) Стока состоящая только из букв — это сжатая стока. Разжимается она в саму себя.
2) Строка, являющаяся конкатенацией двух сжатых строк A
и B
. Разжимается она в конкатенацию разжатых строк A
и B
.
3) Строка D(X)
, где D
— целое число, большее 1
, а X
— сжатая строка. Разжимается она в конкатенацию D
строк, разжатых из X
.
Пример: “3(2(A)2(B))C”
разжимается в “AABBAABBAABBC”
.
Необходимо по строке s
узнать длину самой короткой сжатой строки, разжимающийся в неё.
1) Состояние динамики: d[l][r]
— сжатая строка минимальной длины, разжимающаяся в строку s[l:r]
2) Начальные состояния: все подстроки длины один можно сжать только в них самих.
3) Пересчёт динамики:
У лучшего ответа есть какая-то последняя операция сжатия: либо это просто строка из заглавных букв, или это конкатенация двух строк, или само сжатие. Так давайте переберём все варианты и выберем лучший.
dp_len = r - l
dp[l][r] = dp_len # Первый вариант сжатия - просто строка.
for i in range(l + 1, r):
dp[l][r] = min(dp[l][r], dp[l][i] + dp[i][r]) # Попробовать разделить на две сжатые подстроки
for cnt in range(2, dp_len):
if (dp_len % cnt == 0): # Если не делится, то нет смысла пытаться разделить
good = True
for j in range(1, (dp_len / cnt) + 1): # Проверка на то, что все cnt подстрок одинаковы
good &= s[l:l + dp_len / cnt] == s[l + (dp_len / cnt) * j:l + (self_len / cnt) * (j + 1)]
if good: # Попробовать разделить на cnt одинаковых подстрок и сжать
dp[l][r] = min(dp[l][r], len(str(cnt)) + 1 + dp[l][l + dp_len / cnt] + 1)
4) Порядок пересчёта: прямой по возрастанию длины подстроки или ленивая динамика.
5) Ответ лежит в d[0][len(s)]
.
Параметром состояния динамики по поддеревьям обычно бывает вершина, обозначающая поддерево, в котором эта вершина — корень. Для получения значения текущего состояния обычно нужно знать результаты всех своих детей. Чаще всего реализуют лениво — просто пишут поиск в глубину из корня дерева.
Дано подвешенное дерево, в листьях которого записаны однобитовые числа — 0
или 1
. Во всех внутренних вершинах так же записаны числа, но по следующему правилу: для каждой вершины выбрана одна из логических операций: «И» или «ИЛИ». Если это «И», то значение вершины — это логическое «И» от значений всех её детей. Если же «ИЛИ», то значение вершины — это логическое «ИЛИ» от значений всех её детей.
Требуется найти минимальное количество изменений логических операций во внутренних вершинах, такое, чтобы изменилось значение в корне или сообщить, что это невозможно.
d[v][x]
— количество операций, требуемых для получения значения x
в вершине v
. Если это невозможно, то значение состояния — +inf
.+inf
операций.x
, то ноль. Если нет, то есть два варианта: изменить в текущей вершине операцию или нет. Для обоих нужно найти оптимальный вариант и выбрать наилучший.
Если операция «И» и нужно получить «0», то ответ это минимум из значений d[i][0]
, где i
— сын v
.
Если операция «И» и нужно получить «1», то ответ это сумма всех значений d[i][1]
, где i
— сын v
.
Если операция «ИЛИ» и нужно получить «0», то ответ это сумма всех значений d[i][0]
, где i
— сын v
.
Если операция «ИЛИ» и нужно получить «1», то ответ это минимум из значений d[i][1]
, где i
— сын v
.
4) Порядок пересчёта: легче всего реализуется лениво — в виде поиска в глубину из корня.
5) Ответ — d[root][value[root] xor 1]
.
В динамике по подмножествам обычно в состояние входит маска заданного множества. Перебираются чаще всего в порядке увеличения количества единиц в этой маске и пересчитываются, соответственно, из состояний, меньших по включению. Обычно используется ленивая динамика, чтобы специально не думать о порядке обхода, который иногда бывает не совсем тривиальным.
Задан взвешенный (веса рёбер неотрицательны) граф G
размера N
. Найти гамильтонов цикл [8] (цикл, проходящий по всем вершинам без самопересечений) минимального веса.
0
.
1) Состояние динамики: dp[mask][v]
— путь минимального веса из вершины 0
в вершину v
, проходящий по всем вершинам, лежащим в mask
и только по ним.
2) Начальные значения: dp[1][0] = 0
, все остальные состояния изначально — +inf
.
3) Формула пересчёта: Если i
-й бит в mask
равен 1
и есть ребро из i
в v
, то:
dp[mask][v] = min(dp[mask][v], dp[mask - (1 << i)][i] + w[i][v])
Где w[i][v]
— вес ребра из i
в v
.
4) Порядок пересчёта: самый простой и удобный способ — это написать ленивую динамику, но можно поизвращаться и написать перебор масок в порядке увеличения количества единичных битов в ней.
5) Ответ лежит в d[(1 << N) - 1][0]
.
Классическими задачами, решающимися динамикой по профилю, являются задачи на замощение поля какими-нибудь фигурами. Причём спрашиваться могут разные вещи, например, количество способов замощения или замощение минимальным количеством фигур.
Эти задачи можно решить полным перебором за , где a
— количество вариантов замощения одной клетки. Динамика по профилю же оптимизирует время по одной из размерностей до линейной, оставив от себя в экспоненте только коэффициент. Получится что-то такое: .
Профиль — это k
(зачастую один) столбцов, являющиеся границей между уже замощённой частью и ещё не замощённой. Эта граница заполнена только частично. Очень часто является частью состояния динамики.
Почти всегда состояние — это профиль и то, где этот профиль. А переход увеличивает это местоположение на один. Узнать, можно ли перейти из одного профиля в другой можно за линейное от размера профиля время. Это можно проверять каждый раз во время пересчёта, но можно и предподсчитать. Предподсчитывать будем двумерный массив can[mask][next_mask]
— можно ли от одной маски перейти к другой, положив несколько фигурок, увеличив положение профиля на один. Если предподсчитывать, то времени на выполнение потребуется меньше, а памяти — больше.
Найти количество способов замостить таблицу N
x M
с помощью доминошек размерами 1
x 2
и 2
x 1
.
0
— не замощенная клетка столбца, 1
— замощенная. То есть всего профилей .
0) Предподсчёт (опционально): перебрать все пары профилей и проверить, что из одного можно перейти в другой. В этой задаче это проверяется так:
Если в первом профиле на очередном месте стоит 1
, значит во втором обязательно должен стоять 0
, так как мы не сможем замостить эту клетку никакой фигуркой.
Если в первом профиле на очередном месте стоит 0
, то есть два варианта — или во втором 0
или 1
.
Если 0
, это значит, что мы обязаны положить вертикальную доминошку, а значит следующую клетку можно рассматривать как 1
. Если 1
, то мы ставим вертикальную доминошку и переходим к следующей клетке.
Примеры переходов (из верхнего профиля можно перейти в нижние и только в них):
После этого сохранить всё в массив can[mask][next_mask]
— 1
, если можно перейти, 0
— если нельзя.
1) Состояние динамики: dp[pos][mask]
— количество полных замощений первых pos - 1
столбцов с профилем mask
.
2) Начальное состояние: dp[0][0] = 1
— левая граница поля — прямая стенка.
3) Формула пересчёта:
dp[pos][mask] += dp[pos - 1][next_mask] * can[mask][next_mask]
4) Порядок обхода — в порядке увеличения pos
.
5) Ответ лежит в dp[pos][0].
Полученная асимптотика — .
Это очень сильная оптимизация динамики по профилю. Здесь профиль — это не только маска, но ещё и место излома. Выглядит это так:
Теперь, после добавления излома в профиль, можно переходить к следующему состоянию, добавляя всего одну фигурку, накрывающую левую клетку излома. То есть увеличением числа состояний в N
раз (надо помнить, где место излома) мы сократили число переходов из одного состояния в другое с до . Асимптотика улучшилась с до .
Переходы в динамике по изломанному профилю на примере задачи про замощение доминошками (пример №8):
Иногда бывает, что просто знать какую-то характеристику лучшего ответа недостаточно. Например, в задаче «Запаковка строки» (пример №4) мы в итоге получаем только длину самой короткой сжатой строки, но, скорее всего, нам нужна не её длина, а сама строка. В таком случае надо восстановить ответ.
В каждой задаче свой способ восстановления ответа, но самые распространенные:
Зачастую в динамике можно встретить задачу, в которой состояние требует быть посчитанными не очень большое количество других состояний. Например, при подсчёте чисел Фибоначчи мы используем только два последних, а к предыдущим уже никогда не обратимся. Значит, можно про них забыть, то есть не хранить в памяти. Иногда это улучшает асимптотическую оценку по памяти. Этим приёмом можно воспользоваться в примерах №1, №2, №3 (в решении без матрицы перехода), №7 и №8. Правда, этим никак не получится воспользоваться, если порядок обхода — ленивая динамика.
Иногда бывает так, что можно улучшить асимптотическое время, используя какую-нибудь структуру данных. К примеру, в алгоритме Дейкстры [9] можно воспользоваться очередью с приоритетами [10] для изменения асимптотического времени.
В решениях динамикой обязательно фигурирует состояние — параметры, однозначно задающие подзадачу, но это состояние не обязательно одно единственное. Иногда можно придумать другие параметры и получить с этого выгоду в виде снижения асимптотического времени или памяти.
Требуется найти количество разложений числа N
на различные слагаемые. Например, если N = 7
, то таких разложений 5
:
1) Состояние динамики: dp[n][k]
— количество разложений числа n
на числа, меньшие или равные k
. Параметр k
нужен, чтобы брать всегда только большие числа, чем уже имеющиеся.
2) Начальные значения: dp[1][1] = 1
, dp[1][i] = 0
.
3) Формула пересчёта:
for last_summand in range(1, k + 1):
dp[n][k] += dp[n - last_summand][last_summand]
4) Порядок: прямой, в порядке увеличения n
.
5) Ответ — сумма dp[N][1..N]
.
Состояний: , переходов: . Асимптотика: .
1) Поменяем состояние. Теперь dp[n][k] — это количество разложений числа n
на k
различных чисел. Казалось бы незачем, но сейчас будет понятно.
2) Начальные значения: dp[1][1] = 1
, dp[1][i] = 0
.
3) Формула пересчёта:
dp[n][k] = dp[n - k][k] + dp[n - k - 1][k + 1]
Теперь надо пояснить, что значит эта формула. Все состояния можно получить (причём единственным способом) делая поочерёдно два действия:
1
.1
. Добавить число 1
в разложение.Чтобы понять, почему это так нужно посмотреть на таблицы Юнга [11]:
Строки здесь обозначают слагаемые.
Первое решение последовательно добавляет по одной строчке внизу таблицы, а второе — по одному столбцу слева таблицы. Вариантов размера следующей строчки много — главное, чтобы она была больше предыдущей, а столбцов — только два: такой же как предыдущий и на единичку больше.
4) Порядок пересчёта: прямой, в порядке увеличения n
.
Невооруженным взглядом кажется, что у этого решения асимптотика , ведь есть два измерения в состоянии и переходов. Но это не так, ведь второй параметр — k
ограничен сверху не числом N
, а формулой (сумма чисел от 1
до k
меньше или равна разлагаемого числа). Из этой формулы видно, что количество состояний .
5) Ответ — это сумма dp[N][1..k_max]
.
Асимптотика: .
Основным источником была голова, а туда информация попала с лекций в Летней Компьютерной Школе [12] (для школьников), а также кружка Андрея Станкевича и спецкурса Михаила Дворкина (darnley [13]).
Спасибо за внимание, надеюсь эта статья кому-нибудь пригодится! Хотя мне уже пригодилась — оказывается, написание статьи это хороший способ всё вспомнить.
Автор: Frommi
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/algoritmy/42365
Ссылки в тексте:
[1] мемоизированная: http://ru.wikipedia.org/wiki/Мемоизация
[2] поиска в глубину: http://ru.wikipedia.org/wiki/Поиск_в_глубину
[3] числа Фибоначчи: http://ru.wikipedia.org/wiki/Числа_Фибоначчи
[4] вики: http://ru.wikipedia.org/wiki/Умножение_матриц
[5] метод быстрого возведения в степень: http://en.wikipedia.org/wiki/Exponentiation_by_squaring
[6] Развернутое условие: http://acm.timus.ru/problem.aspx?space=1&num=1238
[7] Дубы: http://habrahabr.ru/post/112386
[8] гамильтонов цикл: http://ru.wikipedia.org/wiki/Гамильтонов_цикл
[9] алгоритме Дейкстры: http://ru.wikipedia.org/wiki/Алгоритм_Дейкстры
[10] очередью с приоритетами: http://ru.wikipedia.org/wiki/Очередь_с_приоритетом_(программирование)
[11] таблицы Юнга: http://en.wikipedia.org/wiki/Young_tableau#Diagrams
[12] Летней Компьютерной Школе: http://lksh.ru/
[13] darnley: http://habrahabr.ru/users/darnley/
[14] Источник: http://habrahabr.ru/post/191498/
Нажмите здесь для печати.