Общие приёмы динамики - Алгоритмика
Общие приёмы динамики

Общие приёмы динамики

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

Рассмотрим такую задачу: требуется найти nn-ое число Фибоначчи fnf_n, определяемое как

f0=0f1=1fn=fn2+fn1 \begin{aligned} f_0 &= 0 \\ f_1 &= 1 \\ f_n &= f_{n-2} + f_{n-1} \end{aligned}

Записав определение выше как функцию, задачу можно решить рекурсивно:

int f(int n) {
    if (n == 0)
        return 0;
    if (n == 1)
        return 1;
    return f(n - 2) + f(n - 1);
}

Однако для nn, превосходящих ~40, такое решение будет работать очень долго. Попытаемся грубо оценить время работы T(n)T(n).

Чтобы посчитать fnf_n, нам нужно рекурсивно посчитать fn1f_{n-1} и fn2f_{n-2}:

T(n)=T(n2)+T(n1)+Θ(1) T(n) = T(n-2) + T(n-1) + \Theta(1) Для подсчета fn1f_{n-1} нужно не меньше действий, чем для fn2f_{n-2}. Объединяя с предыдущим, это значит, что fnf_n нужно хотя бы в два раза больше действий, чем для fn2f_{n-2}: T(n)2T(n2) T(n) \ge 2 \cdot T(n-2) А значит, время работы растет экспоненциально: T(n)Ω(2n/2) T(n) \ge \Omega(2^{n/2})

Упражнение. Какое точное суммарное количество рекурсивных вызовов будет при подсчете fnf_n?

Запоминание результатов

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

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

Удобнее всего завести массив для всех нужных значений fnf_n и, вместо рекурсивной функции, инициализировать его базовые значения, а затем пройтись в цикле по всем остальным fif_i и посчитать их, используя уже известные после предыдущих итераций fi2f_{i-2} и fi1f_{i-1}:

const int n = 100;
int f[n];

f[0] = 0;
f[1] = 1;

for (int i = 2; i < n; i++)
    f[i] = f[i - 2] + f[i - 1]; 

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


Динамическим программированием (англ. dynamic programming, ДП, динамика) называется как раз приведенный выше подход, обычно заключающийся в следующих общих шагах:

  • Свести задачу для nn к задаче для чисел, меньших, чем nn — то есть найти какую-то рекурсивную формулу.
  • Создать массив (или какую-нибудь другую структуру данных) для хранения ответов на подзадачи.
  • Заполнить начало массива вручную (базовые случае, для которых формула не работает).
  • Обойти массив от известных значений в неизвестные и заполнить ответы по формуле.
  • Вывести ответ — обычно просто записанный в последней ячейке массиве.

Чтобы решить задачу динамическим программированием, вы должны ответить на 5 вопросов:

  • Что лежит в массиве? (чаще всего самый важный вопрос)
  • Как инициализировать начало массива?
  • Как обходить массив? (чаще всего слева направо, но не всегда)
  • Какой формулой считать элементы массива?
  • Где в массиве лежит ответ?

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