Programare dinamică pe intervale (range DP)

În acest articol vom prezenta modul în care aplicăm tehnica programării dinamice atunci când vine vorba de a afla diverse răspunsuri minime sau maxime pentru șiruri cu o lungime relativ mică (de regulă, cel mult 500), folosind drept stări anterioare subsecvențe de lungime mai mică ale șirului dat.

Această tehnică este numită în specialitate range dp, fiind găsită în articolele românești și sub numele de metoda mixtă a programării dinamice.

Condiții necesare

Această tehnică apare preponderent în probleme care respectă cât mai multe din condițiile menționate aici, implementarea și modul de gândire al stărilor și tranzițiilor fiind foarte similar de la o problemă la alta:

  • Se pot găsi soluții greedy destul de ușor, dar fără succes
  • Dacă știm răspunsurile pentru subsecvențele de lungime mai mică, putem calcula răspunsul pentru orice subsecvență [L,R][L, R] de o lungime mai mare
  • Putem combina subsecvențe disjuncte în mod independent
  • De regulă, nn (dimensiunea șirului) este cel mult 500.

Cum aplicăm tehnica?

Pe scurt, ceea ce vrem să facem este să avem o stare de tip dp[i][j]dp[i][j] care să ne țină răspunsul pentru intervalul [i,j][i, j] și să calculăm valoarea acestei stări fixând poziția kk pe care o vom folosi pentru a împărți subsecvența noastră în două subsecvențe disjuncte (de regulă, [i,k][i, k] și [k+1,j][k+1, j]), a căror reuniune să fie subsecvența [i,j][i, j].

O altă particularitate des întâlnită va fi să iterăm subsecvențele în ordine crescătoare a lungimilor pentru a avea răspunsurile calculate în ordine. Alternativ, vom putea implementa această soluție și recursiv, făcând calculele mai ușoare, așa cum veți putea vedea în exemplele ulterioare.

Un prim exemplu - Problema Rectangle Cutting

Această problemă ne cere să aflăm numărul minim de tăieturi pe care le putem face astfel încât să împărțim dreptunghiul inițial cu dimensiunile aa și bb în pătrate.

Mai întâi, ne-am putea gândi la soluții de tip backtracking care sunt foarte încete sau soluții greedy care încearcă să obțină pătrate cât mai mari, dar aceste abordări eșuează pe diverse teste.

Deoarece dimensiunile dreptunghiurilor sunt cel mult 500, putem să ne gândim la o dinamică de două dimensiuni, unde dp[i][j]dp[i][j] va ține numărul minim de tăieturi necesare pentru a ajunge la un dreptunghi de dimensiunile ii și jj.

Un caz de bază evident este acela când i=ji = j, unde dp[i][j]=0dp[i][j] = 0. Acum, trebuie să ne gândim la tăieturile pe care le putem face, acestea pot fi fie orizontale, fie verticale, atâta timp cât păstrăm laturile drept numere întregi.

Cu alte cuvinte, vom putea fixa punctul în care vom face tăietura și de acolo, vom folosi rezultatele calculate anterior, sau în cazul soluției recursive, le vom calcula la rândul lor până când toate valorile au fost calculate. Complexitatea acestei soluții va fi O(n3)\mathcal{O}(n^3), unde nn este maximul dintre aa și bb.

Mai jos puteți găsi implementările iterative și recursive ale acestei probleme.

#include <iostream>
#include <vector>
using namespace std;

vector<vector<int> > dp;

int solve(int n, int m) {
    if (n == m) {
        return 0;
    }
    if (dp[n][m] != (1 << 20)) {
        return dp[n][m];
    }
    for (int side = 1; side < n; side++) {
        dp[n][m] = min(dp[n][m], solve(side, m) + solve(n - side, m) + 1);
    }
    for (int side = 1; side < m; side++) {
        dp[n][m] = min(dp[n][m], solve(n, side) + solve(n, m - side) + 1);
    }
    return dp[n][m];
}

int main() {
    int n, m;
    cin >> n >> m;

    dp.resize(n + 1, vector<int>(m + 1, (1 << 20)));

    cout << solve(n, m) << '\n';
    return 0;
}

Un exemplu mai dificil - Greedy Pie Eaters

În această problemă, vrem să ne asigurăm că putem folosi vaci cu o greutate totală cât mai mare, știind că fiecare vacă va mânca toate prăjiturile din intervalul corespunzător.

Vom începe prin a precalcula pentru fiecare mod posibil de a tăia un interval, cea mai bună variantă de a face asta, folosind o dinamică de tipul bstcut[i][j][p]bstcut[i][j][p], unde această stare reprezintă suma maximă pe care o putem obține din intervalul [i,j][i, j] dacă tăiem intervalul în poziția pp.

După ce avem această dinamică calculată, vom putea rezolva această problemă folosind o altă dinamică pe două dimensiuni, unde dp[i][j]dp[i][j] reprezintă răspunsul optim pentru intervalul [i,j][i, j]. Această dinamică va putea fi implementată recursiv, fixând pozițiile în care asignăm o vacă sau fixând intervalele pe care le vom împărți, acestea fiind calculate independent.

#include <fstream>
#include <vector>

using namespace std;

ifstream cin("pieaters.in");
ofstream cout("pieaters.out");

vector<vector<int>> mx, dp;
vector<vector<vector<int>>> bstcut;

int solve(int st, int dr) {
    if (st > dr) {
        return 0;
    }
    if (dp[st][dr] != -1) {
        return dp[st][dr];
    }
    dp[st][dr] = 0;
    for (int i = st; i < dr; ++i) {
        dp[st][dr] = max(dp[st][dr], solve(st, i) + solve(i + 1, dr));
    }
    for (int cut = st; cut <= dr; ++cut) {
        dp[st][dr] = max(dp[st][dr], bstcut[st][dr][cut] + solve(st, cut - 1)
                                         + solve(cut + 1, dr));
    }
    return dp[st][dr];
}
int main() {
    int n, m;
    cin >> n >> m;
    mx.resize(n + 2, vector<int>(n + 2));
    dp.resize(n + 2, vector<int>(n + 2, -1));
    bstcut.resize(n + 2, vector<vector<int>>(n + 2, vector<int>(n + 2, 0)));

    for (int i = 1; i <= m; ++i) {
        int am, l, r;
        cin >> am >> l >> r;
        mx[l][r] = max(mx[l][r], am);
    }
    for (int len = 1; len <= n; ++len) {
        for (int j = 1; j + len - 1 <= n; ++j) {
            for (int x = j; x <= j + len - 1; ++x) {
                bstcut[j][j + len - 1][x] =
                    max(bstcut[j][j + len - 2][x], mx[j][j + len - 1]);
                bstcut[j][j + len - 1][x] = max(bstcut[j][j + len - 1][x],
                                                bstcut[j + 1][j + len - 1][x]);
            }
        }
    }

    cout << solve(1, n) << '\n';
    return 0;
}

Un alt tip de range dp - Atcoder DP Contest Deque

Deși această problemă nu se încadrează complet în definiția dată mai sus, modul relativ facil pe care îl avem pentru calcularea rezultatelor stărilor o va putea încadra în această categorie.

Din nou, o soluție greedy ușor de găsit nu este fezabilă, majoritatea greedy-urilor cunoscute având cazuri pe care soluția pică. Astfel, suntem limitați la a avea o abordare bazată pe metoda programării dinamice, unde dp[i][j]dp[i][j] reprezintă răspunsul maxim dacă începem din subsecvența [i,j][i, j]. La fiecare pas, avem de fixat una din cele două valori pe care o folosim, răspunsul fiind preluat din secvențele de lungime mai mică, așa cum se poate vedea în codul de mai jos.

Observație

Există totuși o soluție greedy corectă pentru această problemă, fiind una destul de dificilă, în ciuda codului scurt, detaliile fiind explicate în acest paper, pe care îl puteți găsi aici.

#include <iostream>
#include <vector>

using namespace std;

int main() {
    int n;
    cin >> n;

    vector<int> v(n + 1);
    for (int i = 1; i <= n; ++i) {
        cin >> v[i];
    }

    vector<vector<int>> dp(n + 1, vector<int>(n + 1));
    for (int i = n; i >= 1; --i) {
        for (int j = i + 1; j <= n + 1; ++j) {
            dp[i][j] = max(v[i] - dp[i + 1][j], v[j - 1] - dp[i][j - 1]);
        }
    }
    cout << dp[1][n + 1];
    return 0;
}

Concluzii

După cum se poate vedea, Range DP este o tehnică care se dovedește a fi foarte utilă în multe contexte, iar faptul că implementările vor fi similare de la problemă la problemă va face înțelegerea și aplicarea principiului mai ușoară decât în cazul altor dinamici, unde este necesară o experiență mai mare pentru a modifica stările și modul de calcul.

Probleme suplimentare

Lectură suplimentară