Перейти до основного вмісту

Вступ до динамічного програмування

Суть динамічного програмування полягає в тому, щоб уникати повторних обчислень. Часто задачі динамічного програмування природно розв'язуються через рекурсію. У таких випадках найпростіше написати рекурсивний розв'язок, а потім зберігати повторювані стани в таблиці попередніх обчислень. Цей процес відомий як динамічне програмування згори вниз із мемоїзацією. Читається саме «мемоїзація» (так, наче ми пишемо в записничку — memo), а не «меморизація».

Чи задача взагалі на динамічне програмування?
  • Чи має задача перекривні підзадачі — тобто наївна рекурсія раз за разом перераховує ті самі стани (як f(n1)+f(n2)f(n-1) + f(n-2) для Фібоначчі)?
  • Чи виконується оптимальна підструктура — оптимальний розв'язок будується з оптимальних розв'язків підзадач?
  • Чи кількість унікальних станів поліноміальна (досить мала), щоб усі їх можна було зберегти в таблиці й обчислити кожен один раз?

Один із найбазовіших, класичних прикладів цього процесу — послідовність Фібоначчі. Її рекурсивне формулювання: f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2), де n2n \ge 2, f(0)=0f(0)=0 і f(1)=1f(1)=1

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

Час роботи цієї рекурсивної функції експоненційний — приблизно O(2n)O(2^n), оскільки один виклик функції ( f(n)f(n) ) породжує 2 виклики приблизно такого ж розміру (f(n1)f(n-1) та f(n2)f(n-2) ).

Прискорення Фібоначчі за допомогою динамічного програмування (мемоїзації)

Наразі наша рекурсивна функція розв'язує Фібоначчі за експоненційний час. Це означає, що ми можемо обробляти лише невеликі вхідні значення, поки задача не стане надто складною. Наприклад, f(29)f(29) породжує понад 1 мільйон викликів функції!

Щоб пришвидшити роботу, ми зауважимо, що кількість підзадач становить лише O(n)O(n). Тобто, щоб обчислити f(n)f(n), нам потрібно знати тільки f(n1),f(n2),,f(0)f(n-1),f(n-2), \dots ,f(0). Тому замість того, щоб перераховувати ці підзадачі, ми розв'язуємо їх один раз, а потім зберігаємо результат у таблиці попередніх обчислень. Подальші виклики використовуватимуть цю таблицю й одразу повертатимуть результат, тим самим прибираючи експоненційну роботу!

Кожен рекурсивний виклик перевірятиме таблицю попередніх обчислень, щоб дізнатися, чи значення вже було обчислене. Це робиться за час O(1)O(1). Якщо ми вже обчислили його, повертаємо результат, інакше — обчислюємо функцію звичайним чином. Загальний час роботи становить O(n)O(n). Це величезне покращення порівняно з нашим попереднім алгоритмом експоненційного часу!

const int MAXN = 100;
bool found[MAXN];
int memo[MAXN];

int f(int n) {
if (found[n]) return memo[n];
if (n == 0) return 0;
if (n == 1) return 1;

found[n] = true;
return memo[n] = f(n - 1) + f(n - 2);
}

З нашою новою мемоїзованою рекурсивною функцією f(29)f(29), яка раніше породжувала понад 1 мільйон викликів, тепер призводить лише до 57 викликів — майже у 20 000 разів менше викликів функції! За іронією, тепер нас обмежує наш тип даних. f(46)f(46) — це останнє число Фібоначчі, яке вміщається в знаковий 32-бітний цілий тип.

Зазвичай ми намагаємося зберігати стани в масивах, якщо це можливо, оскільки час доступу становить O(1)O(1) з мінімальними накладними витратами. Утім, більш узагальнено, ми можемо зберігати стани в будь-який зручний для нас спосіб. Серед інших прикладів — бінарні дерева пошуку (map у C++) чи хеш-таблиці (unordered_map у C++).

Приклад цього міг би виглядати так:

unordered_map<int, int> memo;
int f(int n) {
if (memo.count(n)) return memo[n];
if (n == 0) return 0;
if (n == 1) return 1;

return memo[n] = f(n - 1) + f(n - 2);
}

Або аналогічно:

map<int, int> memo;
int f(int n) {
if (memo.count(n)) return memo[n];
if (n == 0) return 0;
if (n == 1) return 1;

return memo[n] = f(n - 1) + f(n - 2);
}

Обидва ці варіанти майже завжди будуть повільнішими за варіант на основі масиву для звичайної мемоїзованої рекурсивної функції. Ці альтернативні способи збереження стану корисні насамперед тоді, коли в простір станів входять вектори або рядки.

Простий, «на пальцях», спосіб аналізу часу роботи мемоїзованої рекурсивної функції такий:

робота на одну підзадачукількість підзадач\text{робота на одну підзадачу} * \text{кількість підзадач}

Використання бінарного дерева пошуку (map у C++) для збереження станів технічно дасть O(nlogn)O(n \log n), оскільки кожен пошук та вставка займатимуть O(logn)O(\log n) роботи, а з O(n)O(n) унікальними підзадачами ми отримуємо час O(nlogn)O(n \log n).

Цей підхід називається згори вниз, оскільки ми можемо викликати функцію зі значенням-запитом, і обчислення починає рухатися згори (запитаного значення) вниз (до базових випадків рекурсії), роблячи по дорозі скорочення через мемоїзацію.

Динамічне програмування знизу вгору

Досі ви бачили лише динамічне програмування згори вниз із мемоїзацією. Однак ми також можемо розв'язувати задачі динамічним програмуванням знизу вгору. Знизу вгору — це точна протилежність підходу згори вниз: ви починаєте знизу (з базових випадків рекурсії) і розширюєте його на дедалі більше значень.

Щоб створити підхід знизу вгору для чисел Фібоначчі, ми ініціалізуємо базові випадки в масиві. Потім ми просто застосовуємо рекурсивне означення до масиву:

const int MAXN = 100;
int fib[MAXN];

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

return fib[n];
}

Звісно, у такому вигляді це трохи безглуздо з двох причин: По-перше, ми виконуємо повторну роботу, якщо викликаємо функцію більше одного разу. По-друге, для обчислення поточного елемента нам потрібно використати лише два попередні значення. Тому ми можемо зменшити витрати пам'яті з O(n)O(n) до O(1)O(1).

Приклад розв'язку для Фібоначчі за допомогою динамічного програмування знизу вгору, який використовує O(1)O(1) пам'яті, міг би виглядати так:

const int MAX_SAVE = 3;
int fib[MAX_SAVE];

int f(int n) {
fib[0] = 0;
fib[1] = 1;
for (int i = 2; i <= n; i++)
fib[i % MAX_SAVE] = fib[(i - 1) % MAX_SAVE] + fib[(i - 2) % MAX_SAVE];

return fib[n % MAX_SAVE];
}

Зверніть увагу, що ми змінили константу з MAXN на MAX_SAVE. Це тому, що загальна кількість елементів, до яких нам потрібен доступ, дорівнює лише 3. Вона більше не масштабується з розміром вхідних даних і, за означенням, є O(1)O(1) пам'яті. Крім того, ми використовуємо поширений трюк (за допомогою операції остачі від ділення), зберігаючи лише ті значення, які нам потрібні.

Ось і все. Це і є основи динамічного програмування: не повторюй роботу, яку ти вже зробив.

Один зі способів стати кращим у динамічному програмуванні — вивчити деякі класичні приклади.

Класичні задачі динамічного програмування

НазваОпис/Приклад
0-1 KnapsackДано NN предметів із вагами wiw_i та цінностями viv_i і максимальну вагу WW. Яким є максимальне значення i=1kvi\sum_{i=1}^{k} v_i для кожної підмножини предметів розміру kk (1kN1 \le k \le N) за умови, що i=1kwiW\sum_{i=1}^{k} w_i \le W?
Subset SumДано NN цілих чисел та TT. Визначити, чи існує підмножина даної множини, елементи якої в сумі дають TT.
Longest Increasing Subsequence (LIS)Вам дано масив із NN цілих чисел. Ваше завдання — визначити LIS у масиві, тобто підпослідовність, у якій кожен елемент більший за попередній.
Counting Paths in a 2D ArrayДано NN та MM. Підрахувати всі можливі різні шляхи з (1,1)(1,1) до (N,M)(N, M), де кожен крок — це або з (i,j)(i,j) до (i+1,j)(i+1,j), або до (i,j+1)(i,j+1).
Longest Common SubsequenceВам дано рядки ss та tt. Знайти довжину найдовшого рядка, що є підпослідовністю одночасно для ss і tt.
Longest Path in a Directed Acyclic Graph (DAG)Пошук найдовшого шляху в орієнтованому ациклічному графі (DAG).
Longest Palindromic SubsequenceПошук найдовшої паліндромної підпослідовності (LPS) заданого рядка.
Rod CuttingДано стрижень довжиною nn одиниць. Дано цілочисловий масив cuts, де cuts[i] позначає позицію, у якій слід виконати розріз. Вартість одного розрізу дорівнює довжині стрижня, який розрізають. Якою є мінімальна сумарна вартість розрізів?
Edit DistanceВідстань редагування між двома рядками — це мінімальна кількість операцій, потрібних, щоб перетворити один рядок на інший. Операції — це ["Add", "Remove", "Replace"]

Звісно, найважливіший трюк — це практика.

Задачі для практики

Контести з ДП

Відеоматеріали