kz-maxx

Динамикалық программалауға кіріспе

Рекурсивный метод, memoization және итеративный DP-ға ауыстыру

Динамикалық программалау деген не?

Динамикалық программалау, қысқаша DP — үлкен задачаны бірнеше кіші подзадачаға бөліп шешу әдісі.

Біз кіші подзадачалардың жауабын бір рет есептейміз де, массивте сақтаймыз. Кейін сол жауап керек болса, қайта есептемей, дайын мәнді аламыз.

үлкен задача
↓
кіші подзадачалар
↓
жауаптарды сақтаймыз
↓
финал answer аламыз
DP-ны түсіну үшін алдымен рекурсия арқылы ойлау ыңғайлы. Кейін рекурсияны итеративный DP-ға ауыстырамыз.

DP қашан қолданылады?

  • бірдей подзадачалар көп рет қайталанса
  • бір мәнді бірнеше рет есептеп жатсақ
  • maximum немесе minimum табу керек болса
  • қанша тәсіл бар екенін санау керек болса
  • кішкентай state-тен үлкен state-ке өту болса
  • dp state ойлап табуға болатын болса

DP-ның негізгі идеясы

DP-да көбіне массив қолданамыз:

dp[i]

Бұл i state үшін жауап.

Мысалы:

dp[i] = алғашқы i элемент үшін жауап
dp[i] = i-ге дейін жету тәсілдерінің саны
dp[i] = i-ге дейін минималды cost
dp[i] = i-ге дейін максималды сумма

Рекурсивный метод

DP-ны көбіне рекурсиядан бастаған оңай.

Біз функция жазамыз:

f(i)

Бұл функция i state үшін жауап қайтарады.

f(i) = i state үшін answer

Кейін f(i)-ді кіші мәндер арқылы жазамыз:

f(i) = f(i - 1) + f(i - 2)

Бұл переход деп аталады.

Мысал: Фибоначчи сандары

Фибоначчи сандары:

f(0) = 0
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(5)
fib(4) және fib(3) шақырады
fib(4) тағы fib(3) шақырады
бірдей мәндер көп рет есептеледі

Обычный рекурсия проблемасы

Обычный рекурсия бір state-ті бірнеше рет қайта есептейді.

Мысалы:

fib(6)
fib(5) + fib(4)
fib(4) бірнеше рет есептеледі
fib(3) одан да көп есептеледі
Сондықтан обычный рекурсия Фибоначчи үшін шамамен O(2ⁿ) жұмыс істейді.

Рекурсия + 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) тек бір рет есептеледі.

fib(5) есептелді → dp[5]-ке сақталды
қайта керек болса → дайын dp[5] аламыз
Метод Уақыт Память
Обычный рекурсия O(2ⁿ) O(n)
Рекурсия + memoization O(n) O(n)

Рекурсияны итеративный DP-ға қалай ауыстырамыз?

Жеңіл алгоритм:

1. f(i) нені білдіретінін түсінеміз
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:

fib(n) = n-ші Фибоначчи саны

Қадам 2: f орнына dp

Рекурсивный функция:

fib(n)

массивке айналады:

dp[n]

Яғни:

fib(0) → dp[0]
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 тез және стабильный кейде порядок түсіну қиын
Рекурсивный DP — top-down. Итеративный 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(0) = 1
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 задачада мына план көмектеседі:

1. Нені табу керек?
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[i] және O(1) transition болса, уақыт көбіне O(n).

Қысқа 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];
}