Динамикалық программалау деген не?
Динамикалық программалау, қысқаша DP — үлкен задачаны бірнеше кіші подзадачаға бөліп шешу әдісі.
Біз кіші подзадачалардың жауабын бір рет есептейміз де, массивте сақтаймыз. Кейін сол жауап керек болса, қайта есептемей, дайын мәнді аламыз.
↓
кіші подзадачалар
↓
жауаптарды сақтаймыз
↓
финал answer аламыз
DP қашан қолданылады?
- бірдей подзадачалар көп рет қайталанса
- бір мәнді бірнеше рет есептеп жатсақ
- maximum немесе minimum табу керек болса
- қанша тәсіл бар екенін санау керек болса
- кішкентай state-тен үлкен state-ке өту болса
- dp state ойлап табуға болатын болса
DP-ның негізгі идеясы
DP-да көбіне массив қолданамыз:
dp[i]
Бұл i state үшін жауап.
Мысалы:
dp[i] = i-ге дейін жету тәсілдерінің саны
dp[i] = i-ге дейін минималды cost
dp[i] = i-ге дейін максималды сумма
Рекурсивный метод
DP-ны көбіне рекурсиядан бастаған оңай.
Біз функция жазамыз:
f(i)
Бұл функция i state үшін жауап қайтарады.
Кейін f(i)-ді кіші мәндер арқылы жазамыз:
f(i) = f(i - 1) + f(i - 2)
Бұл переход деп аталады.
Мысал: Фибоначчи сандары
Фибоначчи сандары:
f(1) = 1
f(n) = f(n - 1) + f(n - 2)
Яғни әр сан екі алдыңғы санның суммасына тең.
Обычный рекурсия
int fib(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
Бұл код дұрыс, бірақ өте баяу.
Неге?
fib(4) және fib(3) шақырады
fib(4) тағы fib(3) шақырады
бірдей мәндер көп рет есептеледі
Обычный рекурсия проблемасы
Обычный рекурсия бір state-ті бірнеше рет қайта есептейді.
Мысалы:
fib(5) + fib(4)
fib(4) бірнеше рет есептеледі
fib(3) одан да көп есептеледі
Рекурсия + memoization
Бірдей мәнді қайта есептемеу үшін жауапты массивте сақтаймыз.
int dp[N];
Егер dp[n] бұрын есептелген болса, оны бірден қайтарамыз.
if (dp[n] != -1) {
return dp[n];
}
Бұл әдіс memoization деп аталады.
Фибоначчи memoization арқылы
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int dp[N];
int fib(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
if (dp[n] != -1) {
return dp[n];
}
dp[n] = fib(n - 1) + fib(n - 2);
return dp[n];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 0; i < N; i++) {
dp[i] = -1;
}
cout << fib(n);
return 0;
}
Не өзгерді?
Енді әр fib(i) тек бір рет есептеледі.
қайта керек болса → дайын dp[5] аламыз
| Метод | Уақыт | Память |
|---|---|---|
| Обычный рекурсия | O(2ⁿ) | O(n) |
| Рекурсия + memoization | O(n) | O(n) |
Рекурсияны итеративный DP-ға қалай ауыстырамыз?
Жеңіл алгоритм:
2. f(i)-ді dp[i]-ге ауыстырамыз
3. Базалық жағдайларды dp-ға жазамыз
4. Рекурсивный переходты циклге айналдырамыз
5. dp-ны кіші мәннен үлкен мәнге қарай есептейміз
Қадам 1: рекурсия болды
Басында былай болды:
int fib(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
Мұнда state:
Қадам 2: f орнына dp
Рекурсивный функция:
fib(n)
массивке айналады:
dp[n]
Яғни:
fib(1) → dp[1]
fib(i) → dp[i]
Қадам 3: базалық жағдайлар
Рекурсияда:
if (n == 0) return 0;
if (n == 1) return 1;
Итеративный DP-да:
dp[0] = 0;
dp[1] = 1;
Қадам 4: переход
Рекурсияда:
fib(n) = fib(n - 1) + fib(n - 2)
DP массивте:
dp[i] = dp[i - 1] + dp[i - 2];
Енді циклмен санаймыз:
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
Фибоначчи итеративный DP арқылы
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
long long dp[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
cout << dp[n];
return 0;
}
Рекурсивный DP vs итеративный DP
| Метод | Қалай жұмыс істейді? | Плюс | Минус |
|---|---|---|---|
| Рекурсия + memoization | top-down | ойлап табу оңай | stack overflow болуы мүмкін |
| Итеративный DP | bottom-up | тез және стабильный | кейде порядок түсіну қиын |
Мысал 2: баспалдақ
n саты бар. Бір қадамда 1 немесе 2 сатыға көтерілуге болады. n-ші сатыға жетудің қанша тәсілі бар?
State:
dp[i] = i сатысына жету тәсілдерінің саны
i сатысына келуге болады:
- i - 1 сатысынан
- i - 2 сатысынан
Сондықтан:
dp[i] = dp[i - 1] + dp[i - 2];
Баспалдақ: рекурсивный метод
int ways(int n) {
if (n == 0) {
return 1;
}
if (n == 1) {
return 1;
}
return ways(n - 1) + ways(n - 2);
}
Мұнда:
ways(1) = 1
ways(n) = ways(n - 1) + ways(n - 2)
Баспалдақ: рекурсия + memoization
const int N = 100010;
long long dp[N];
long long ways(int n) {
if (n == 0) {
return 1;
}
if (n == 1) {
return 1;
}
if (dp[n] != -1) {
return dp[n];
}
dp[n] = ways(n - 1) + ways(n - 2);
return dp[n];
}
Баспалдақ: итеративный DP-ға ауыстыру
Рекурсияда:
ways(n) = ways(n - 1) + ways(n - 2)
Итеративный DP-да:
dp[i] = dp[i - 1] + dp[i - 2];
Базалық жағдайлар:
dp[0] = 1;
dp[1] = 1;
Баспалдақ: итеративный реализация
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
long long dp[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
cout << dp[n];
return 0;
}
Мысал 3: минималды cost
Баспалдақ бар. Әр сатының cost[i] бағасы бар. Бір қадамда 1 немесе 2 сатыға жүруге болады. n-ге жету үшін минималды cost табу керек.
State:
dp[i] = i сатысына дейінгі минималды cost
Переход:
dp[i] = cost[i] + min(dp[i - 1], dp[i - 2]);
Минималды cost: рекурсия
int solve(int i) {
if (i == 1) {
return cost[1];
}
if (i == 2) {
return cost[2];
}
return cost[i] + min(solve(i - 1), solve(i - 2));
}
Бұл обычный рекурсия. Мұнда бірдей мәндер көп рет есептелуі мүмкін.
Минималды cost: memoization
int solve(int i) {
if (i == 1) {
return cost[1];
}
if (i == 2) {
return cost[2];
}
if (dp[i] != -1) {
return dp[i];
}
dp[i] = cost[i] + min(solve(i - 1), solve(i - 2));
return dp[i];
}
Минималды cost: итеративный DP
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n;
int cost[N];
int dp[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> cost[i];
}
dp[1] = cost[1];
dp[2] = cost[2];
for (int i = 3; i <= n; i++) {
dp[i] = cost[i] + min(dp[i - 1], dp[i - 2]);
}
cout << dp[n];
return 0;
}
DP-да қалай ойлау керек?
Көп DP задачада мына план көмектеседі:
2. dp state нені білдіреді?
3. Базалық жағдайлар қандай?
4. dp[i]-ге қалай өтеміз?
5. Қандай тәртіппен есептейміз?
6. Жауап қай жерде жатыр?
DP-ның негізгі типтері
| Тип | State мысалы | Мағынасы |
|---|---|---|
| Тәсіл саны | dp[i] | i-ге жетудің қанша тәсілі бар |
| Минимум | dp[i] | i-ге дейін минималды cost |
| Максимум | dp[i] | i-ге дейін максималды сумма |
| 2D DP | dp[i][j] | екі параметр бойынша answer |
Рекурсия → итерация: қысқа схема
| Рекурсия | Итеративный DP |
|---|---|
| f(i) | dp[i] |
| if (i == 0) return x | dp[0] = x |
| f(i - 1) | dp[i - 1] |
| f(i - 2) | dp[i - 2] |
| return f(i - 1) + f(i - 2) | dp[i] = dp[i - 1] + dp[i - 2] |
| f(n) шақыру | answer = dp[n] |
Типтік қателер
- dp[i] нені білдіретінін түсінбеу
- базалық жағдайларды ұмыту
- dp[i - 1] дайын болмай тұрып dp[i] санау
- minimum мен maximum шатастыру
- dp массивті дұрыс инициализация жасамау
- рекурсияда memoization қоспау
- итерацияда цикл тәртібін қате таңдау
Асимптотика
| Метод | Уақыт | Память |
|---|---|---|
| Обычный рекурсия | көбіне exponential | рекурсия тереңдігіне байланысты |
| Рекурсия + memoization | state саны × transition | state саны |
| Итеративный DP | state саны × transition | state саны |
Қысқа DP шаблон
dp[0] = base_value;
for (int i = 1; i <= n; i++) {
dp[i] = transition_from_previous_states;
}
cout << dp[n];
Memoization қысқа шаблон
int solve(int i) {
if (base_case) {
return base_value;
}
if (dp[i] != -1) {
return dp[i];
}
dp[i] = transition;
return dp[i];
}