Skip to the content.

Введение

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

Обычно этот подход используют в задачах с вопросами:

Можно заметить, что при решении в лоб эти задачи бы имели экспоненциальную асимптотику ( $O(n2^n)$, $O(n!)$ и тд) полного перебора.

Динамическое программирование позволяет решать такие задачи за полином ( $O(n)$, $O(nm)$, $O(n^2)$ и тд)

Числа Фибоначчи

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

Мы знаем, что по определению $f[0] = f[1] = 1$, $f[n] = f[n - 1] + f[n - 2]$.

Введем следующую терминологию:

Напишем код

f = [1, 1] + [0] * (n - 2)
for i in range(2, n):  # пересчитываем, начиная с i = 2, так как f[0] = f[1] = 1 - база динамики
    f[i] = f[i - 1] + f[i - 2]

print(f[-1])

План решения задач на ДП

Есть $n$ клеточек. Чтобы побывать в $i$-ой клетке, нужно заплатить $a[i]$ монет. Кузнечик начинает в первой клетке и хочет попасть в последнюю. Он умеет прыгать на одну или две клетки вперед. За какое минимальное число монет он сможет это сделать?

Задача “Калькулятор”

Теперь мы рассмотрим данный подход на примере задачи “Калькулятор”.

Условие

Дано натуральное число $n \leq 10^6$. Имеется калькулятор с экраном, отображающим одно число, и тремя функциями:

  1. Прибавить $1$ к числу на экране.
  2. Умножить его на $2$.
  3. Умножить его на $3$.

Изначально на его экране написано число $1$.

Требуется узнать, за какое минимальное число операций можно получить $n$.

Решение

Заведём массив $dp[n]$ такой, что $dp[i]$ - ответ на задачу для числа $i$.

Как нам посчитать $dp[n]$?

Нетрудно догадаться, что $dp[n] \leq dp[n - 1] + 1$, потому что мы всегда можем получить $n$, сначала получив $n - 1$, а затем прибавив $1$.

Если $n$ делится на $2$, то из тех же соображений $dp[n] \leq dp \left[ \dfrac{n}{2} \right] + 1$.

Аналогично: если $n$ делится на $3$, то $dp[n] \leq dp \left[ \dfrac{n}{3} \right] + 1$.

Таким образом $dp[n] = \min\left( dp[n - 1], dp \left[ \dfrac{n}{2} \right], dp \left[ \dfrac{n}{3} \right] \right) + 1$, где второе и третье число учитывается только если $n$ делится нацело.

Последний шаг: $dp[1] = 0$, потому что это число у нас уже есть с самого начала и нам не надо делать ничего.

Как же теперь написать код?

Исходя из описанных выше идей возникает желание написать рекурсивный алгоритм.

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

Поэтому мы пойдём другим путём: будем считать динамику с помощью цикла.

Реализация

n = int(input())
dp = [0] * (n + 1)
for i in range(2, n + 1):
    dp[i] = dp[i - 1] + 1
    if i % 3 == 0 and dp[i // 3] + 1 < dp[i]:
        dp[i] = dp[i // 3] + 1
    if i % 2 == 0 and dp[i // 2] + 1 < dp[i]:
        dp[i] = dp[i // 2] + 1
print(dp[n])

Асимптотика

Исходя из реализации очевидно, что асимптотика - $O(n)$.

Восстановление ответа

Пусть теперь нас просят вывести не количество, а оптимальную последовательность операций.

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

В конце пройдёмся по массиву предков, каждый раз переходя из текущего числа в его предка и добавляя соответствующую операцию в ответ, пока не дойдём до $1$.

Реализация

n = int(input())

dp = [0] * (n + 1)
p = [0] * (n + 1)
for i in range(2, n + 1):
    dp[i] = dp[i - 1] + 1
    p[i] = i - 1
    if i % 3 == 0 and dp[i // 3] + 1 < dp[i]:
        dp[i] = dp[i // 3] + 1
        p[i] = i // 3
    if i % 2 == 0 and dp[i // 2] + 1 < dp[i]:
        dp[i] = dp[i // 2] + 1
        p[i] = i // 2

ans = []
cur = n
while cur != 1:
    if p[cur] + 1 == cur:
        ans.append(1)
    elif p[cur] * 2 == cur:
        ans.append(2)
    else:
        ans.append(3)
    cur = p[cur]

print(*reversed(ans), sep='')

Асимптотика

Сначала - $O(n)$ на поиск ответа, потом - в худшем случае $O(n)$ на восстановление, итого - $O(n)$.