Линейная и не очень динамика
Оглавление
Запись занятия
То же самое написано в формате юпитер-ноутбука в соседнем файле. Скорее всего это более удобно.
Что такое динамическое программирование?
Динамическое программирование в теории управления и теории вычислительных систем — способ решения сложных задач путём разбиения их на более простые подзадачи. Он применим к задачам с оптимальной подструктурой, выглядящим как набор перекрывающихся подзадач, сложность которых чуть меньше исходной. В этом случае время вычислений, по сравнению с «наивными» методами, можно значительно сократить.
Ключевая идея в динамическом программировании достаточно проста. Как правило, чтобы решить поставленную задачу, требуется решить отдельные части задачи (подзадачи), после чего объединить решения подзадач в одно общее решение. Часто многие из этих подзадач одинаковы. Подход динамического программирования состоит в том, чтобы решить каждую подзадачу только один раз, сократив тем самым количество вычислений. Это особенно полезно в случаях, когда число повторяющихся подзадач экспоненциально велико.
Звучит сложно. Давайте на примерах.
Кузнечик
Есть полоска из клеток длины n. Кузнечик находится в клетке номер 1 и хочет попасть в клетку номер n. Каждый раз кузнечик может прыгать лишь вперёд на расстояние от 1 до 3. Сколько различных маршрутов существует у кузнечика?
Очень подробное решение
Если подходить к задаче в лоб с помощью перебора, то всё выглядит очень грустно. Да и работать будет долго. Разобьем задачу на более простые подзадачи:
Пусть dp[k]
- ответ на задачу, если полоска была длины k. Будем называть это состоянием динамики.Предположим, мы умеем вычислять dp для всех k от 1 до n-1. Как же вычислить dp[n]
? В клетку номер n кузнечик мог попасть лишь из трёх: n-1, n-2, n-3. При этом все три типа маршрута будут разными. Поэтому количество маршрутов до клетки n можно вычислить по рекурсивной формуле:
Это замечательно, мы почти решили задачу! Осталось обговорить пару моментов, а именно: базу динамики и порядок обхода.
База динамики, либо начальное состояние динамики - это то состояние, от которого можно оттолкнуться при вычислении рекурсивной формулы. Например, в нашем случае это будет dp[1] = 1
. Все остальные значения можно вычислить по рекурсивной формуле в порядке увеличения n.
Код
1
2
3
4
5
6
7
8
9
10
int main() {
int n;
cin >> n;
vector<int> dp(n);
dp[0] = 1;
for (int i = 1; i < n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
}
cout << dp[n - 1];
}
На примере выше мы разобрались, что для решения задачи на ДП нужно 4 вещи:
- Состояние динамики. В примере выше состояние было очень простым: dp[k] - ответ на задчу, если бы полоска была длины k.
- Переходы динамики. В примере выше это рекурсивный переход dp[k] = dp[k - 1] + dp[k - 2] + dp[k - 3].
- Начальное состояние. В примере выше это dp[1] = 1.
- Порядок обхода. В нашем случае это порядок по возрастанию n.
Кузнечик на максималках
Усложним предыдущую задачу, сохранив при этом асимптотику решения, то есть хочется получить решение за O(n).
Есть полоска из клеток длины n. Некоторые клетки недоступны. Кузнечик находится в клетке номер 1 и хочет попасть в клетку номер n. Каждый раз кузнечик может прыгать лишь вперёд на расстояние от 1 до k. Сколько различных маршрутов существует у кузнечика?
Решение за квадрат
Решение будет очень похожим. Для начала попробуем решить ровно так же как и прошлую задачу:
Пусть dp[m] - ответ на задачу, если бы полоска была длины m. Тогда переходы для доступных будут иметь вид:
\[dp[m] = dp[m - 1] + dp[m - 2] + ... + dp[m - k]\]Если клетка недоступна, то dp[m] = 0.
К сожалению, мы решили задачу за $O(nk)$, что в худшем случае ведёт себя как квадрат. Но рано отчаиваться, решение можно улучшить!
Решение за линию
Будем хранить $pref[k] = dp[k] + dp[k - 1] + \ldots + dp[1]$. Это называется префиксной суммой на массиве динамики. И префиксные суммы прекрасно помогают оптимизировать динамику:
\[dp[m] = dp[m - 1] + dp[m - 2] + \ldots + dp[m - k] = pref[m - 1] - pref[m - k - 1]\]То есть достаточно лишь одной операции разности на массиве префиксных сумм, которые очень легко поддерживать при нашем подсчёте динамики.
Из этой задачи стоит понять, что иногда можно ускорить вашу динамику с помощью разных интересных техник. Эти техники приходят лишь с опытом, так что стоит решать как можно больше задач на ДП.
Самая длинная подпоследовательность (из контеста по ТЧ)
Вам задан массив a из n элементов и число m. Рассмотрим некоторую подпоследовательность a и значение наименьшего общего кратного (НОК) её элементов. Обозначим этот НОК буквой l. Найдите самую длинную подпоследовательность массива a со значением $l \leq m$.
Решение
Задача очень сложная. Необходимо додуматься до нескольких идей.
Идея 1. Задача так сформулирована, что вы начинаете думать о подпоследовательности, а нужно на самом деле по сути искать подмножество, так как порядок для взятия НОКа не важен.
Идея 2. Разобьем числа на группы раных элементов. Пусть чисел, равных x в точности cnt[x]. Не сложно заметить, что если мы взяли число x, то можно взять все cnt[x] чисел, что только увеличит размер подмножества.
Идея 3. Пусь dp[i][j] - это максимальное количество чисел, если НОД равен i и мы рассмотрели первые j групп чисел.
Идея 4. Переходы. Если $t=x \cdot i$ - НОД чисел, то мы можем взять группу чисел $i$. То есть dp[xi][j + 1] = dp[xi][j] + cnt[x], где текущая группа состоит из чисел, равных x, а i - любое число.
Идея 5. Можно считать dp в одном и том же массиве (не двумерном, а одномерном).
Идея 6. Решение работает за $O(n \log n)$.
НОП
Даны две последовательности a и b. Необходимо найти наибольшую подпоследовательность, которая содержится в каждой из последовательснотей a и b.
Решение за квадрат
Пусть состояние динамики dp[i][j] - это размер НОП, если бы a состояло из префикса размера i, а b - из префикса размера j.
Не сложно понять, что бывает 2 вида переходов:
Если a[i] = b[j], то можно удлинить последовательность dp[i-1][j-1] числами a[i] и b[j]. В противном случае нужно взять “лучший ответ”, который лежит либо в dp[i-1][j], либо в dp[i][j - 1].
Базу динамики и порядок обхода остаётся на вас.
Расстояние Левенштейна
Определите минимальное количество односимвольных операций (а именно вставки, удаления, замены), необходимых для превращения одной последовательности символов в другую.
Решение за квадрат
НВП
Дана последовательность a1, a2, …, an. Необходимо найти такую подпоследовательность в ней, что все элементы идут строго по возрастанию.
Код с восстановлением ответа
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>
#include <algorithm>
#include <vector>
#include <string>
using namespace std;
const int INF = 2e9;
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int& x : a)
cin >> x;
vector<int> p(n, -1);
vector<int> dp(n + 1, INF);
vector<int> ind_dp(n + 1, -1);
dp[0] = -INF;
for (int i = 0; i < n; ++i) {
int l = 0, r = n + 1;
// dp[l]: dp[l] < a[i] и l - max
while (r - l > 1) { // [l; r)
int m = (r + l) / 2;
if (dp[m] < a[i])
l = m;
else
r = m;
}
dp[l + 1] = a[i];
ind_dp[l + 1] = i;
p[i] = ind_dp[l];
}
int i = 1;
while (i < n && dp[i + 1] != INF)
++i;
i = ind_dp[i]; // индекс ( из a) последнего элемента самой длинной ВП
vector<int> ans;
ans.push_back(a[i]);
while (p[i] != -1) {
i = p[i];
ans.push_back(a[i]);
}
reverse(ans.begin(), ans.end());
for (int x : ans)
cout << x << ' ';
}
Рюкзак
Дано N предметов, ni предмет имеет массу wi > 0 и стоимость pi > 0. Необходимо выбрать из этих предметов такой набор, чтобы суммарная масса не превосходила заданной величины W (вместимость рюкзака), а суммарная стоимость была максимальна.