🧮 Programare Dinamică

Capitolul 9 — De la recursivitate la DP: memoizare, tabel, LIS, triunghi

DPMemoizareLISFibonacciTriunghi Pascal
📖 12 min
0
🧮

Fibonacci — de la O(2ⁿ) la O(n)

💡 Ce este programarea dinamică?

Programarea dinamică (DP) rezolvă problemele complexe spărgându-le în subprobleme mai mici și stocând rezultatele pentru a nu le recalcula. Se aplică când există subprobleme suprapuse — adică aceeași subproblemă apare de mai multe ori în recursivitate.

Problema: recursivitate naivă — O(2ⁿ)

// Fibonacci recursiv NaIV — calculează de mii de ori aceleași valori!
int fib(int n) {
    if (n <= 1) return n;          // caz de bază
    return fib(n-1) + fib(n-2);  // O(2^n) apeluri!
}
// fib(40) = ~2 miliarde operații → prea lent!
Problema: fib(5) calculează fib(3) de 2 ori, fib(2) de 3 ori. La n=40, se fac ~2 miliarde de apeluri pentru valori deja cunoscute.

Soluția DP: tabel — O(n)

int dp[100];   // dp[i] = al i-lea număr Fibonacci

void calcFibDP(int n) {
    dp[0] = 0;  dp[1] = 1;   // cazuri de bază
    for (int i = 2; i <= n; i++)
        dp[i] = dp[i-1] + dp[i-2];  // fiecare valoare calculată o singură dată
}
// fib(40) = 8 operații, nu 2 miliarde!
Recursiv naiv

Complexitate: O(2ⁿ)
n=40 → ~2 miliarde ops
Lent, inacceptabil la BAC

DP (tabel)

Complexitate: O(n)
n=40 → 40 operații
Rapid, eficient

Regula de aur DP: Dacă o funcție recursivă recalculează aceleași valori, transformă-o în DP — salvează rezultatele într-un tablou.
💾

Memoizare (DP top-down)

Memoizarea adaugă un cache funcției recursive existente. La primul apel, calculează și salvează. La apelurile ulterioare, returnează direct valoarea salvată.

int memo[10001];   // inițializat cu -1 = "necalculat"

int fib(int n) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n];  // deja calculat → returnează direct
    memo[n] = fib(n-1) + fib(n-2);    // calculează și salvează
    return memo[n];
}

int main() {
    // Inițializare obligatorie!
    fill(memo, memo + 10001, -1);
    cout << fib(100) << endl;  // O(n) apeluri în total
}
Template general memoizare:
int cache[MAXN];
memset(cache, -1, sizeof(cache));  // sau fill()

int solve(int n) {
    if (/* caz de baza */) return /* valoare_baza */;
    if (cache[n] != -1) return cache[n];
    cache[n] = /* calcul bazat pe apeluri recursive */;
    return cache[n];
}
Avantaj

Cod simplu, se pornește de la soluția recursivă existentă

Dezavantaj

Stiva de recursivitate — stack overflow pentru n mare

Complexitate

Timp: O(n) · Spațiu: O(n) pentru cache + O(n) stack

📊

DP tabelar (bottom-up)

DP bottom-up construiește tabelul de la cazuri mici la mari, fără recursivitate. Este mai eficient ca memorie și evită stack overflow.

// Fibonacci bottom-up cu tabel complet — O(n) timp, O(n) spațiu
int dp[100001];
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++)
    dp[i] = dp[i-1] + dp[i-2];
cout << dp[n] << endl;

// Optimizat cu 2 variabile — O(n) timp, O(1) spațiu!
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
    int c = a + b;
    a = b; b = c;
}
cout << b << endl;  // Fibonacci(n)
Număr de modalități de a urca n trepte (1 sau 2 trepte odată) — problemă clasică DP:
// dp[i] = nr. moduri de a ajunge la treapta i
int dp[1001];
dp[0] = 1;  // 0 trepte: 1 mod (nu faci nimic)
dp[1] = 1;  // 1 treaptă: 1 mod (un pas de 1)
for (int i = 2; i <= n; i++)
    dp[i] = dp[i-1] + dp[i-2];  // vin din treapta i-1 (1 pas) sau i-2 (2 pași)
cout << dp[n] << endl;
Structura oricărei soluții DP:
1. Definește dp[i] (ce reprezintă)
2. Cazuri de bază (dp[0], dp[1], ...)
3. Relație de recurență (cum dp[i] depinde de dp[j], j<i)
4. Răspunsul (dp[n] sau max din dp[])
📈

Cel mai lung subșir strict crescător (LIS)

📖 Definiție

Dat un vector v[1..n], găsim cel mai lung subșir de indici i₁ < i₂ < ... < iₖ cu v[i₁] < v[i₂] < ... < v[iₖ]. Elementele selectate nu trebuie să fie consecutive în vector.

Exemplu: v = [3, 1, 4, 1, 5, 9, 2, 6] → LIS = [1, 4, 5, 9] sau [1, 4, 5, 6], lungime = 4

Soluție DP — O(n²)

// dp[i] = lungimea celui mai lung subșir crescător care se termină la v[i]
int v[1001], dp[1001], n;

int lisLength() {
    for (int i = 1; i <= n; i++) {
        dp[i] = 1;  // minim: subșir cu un singur element
        for (int j = 1; j < i; j++)
            if (v[j] < v[i])               // v[j] poate precede v[i]
                dp[i] = max(dp[i], dp[j] + 1);
    }
    int lis = 0;
    for (int i = 1; i <= n; i++)
        lis = max(lis, dp[i]);
    return lis;  // O(n²)
}
Trasare pe exemplu: v = [3, 1, 4, 1, 5]
dp[1]=1 (v[1]=3), dp[2]=1 (v[2]=1), dp[3]=2 (1<4: dp[2]+1),
dp[4]=1 (v[4]=1, nimic mai mic), dp[5]=3 (4<5: dp[3]+1)
→ LIS = max(1,1,2,1,3) = 3
LIS clasic — O(n²)

Două bucle imbricate. Suficient pentru n≤5000 la BAC.

LIS optimizat — O(n log n)

Folosește Binary Search pentru n ≤ 10⁵. Nu e cerut la BAC M-I standard.

🔺

Triunghi de numere — suma maximă

📖 Problema

Dat un triunghi de numere cu n rânduri, se coboară de la vârf (rândul 1) la baza (rândul n), la fiecare pas mișcând-ne pe același indice sau indicele+1. Găsim drumul cu suma maximă.

// Exemplu triunghi (n=4):
//    7
//   3 8
//  8 1 0
// 2 7 4 4
// Drum optim: 7→8→1→7 = 23 (sau 7→3→8→7=25)

int t[101][101], dp[101][101], n;

// DP bottom-up (de la baza spre vârf)
void triunghi() {
    // Copiem baza ca stare inițială
    for (int j = 1; j <= n; j++)
        dp[n][j] = t[n][j];

    // Urcăm de la rândul n-1 spre rândul 1
    for (int i = n-1; i >= 1; i--)
        for (int j = 1; j <= i; j++)
            dp[i][j] = t[i][j] + max(dp[i+1][j], dp[i+1][j+1]);

    cout << "Suma maxima: " << dp[1][1] << endl;  // O(n²)
}
Alternativ (top-down): dp[i][j] = t[i][j] + max(dp[i-1][j-1], dp[i-1][j]), pornind de la vârf. Ambele variante sunt echivalente.
🎓

Probleme tip BAC — Programare Dinamică

Problema 1: Cel mai lung subșir de valori egale consecutive

// Cerință: găsește lungimea maximă a unui bloc de valori egale în șir
int v[1001], n;
int maxLen = 1, cur = 1;
for (int i = 2; i <= n; i++) {
    if (v[i] == v[i-1]) cur++;
    else cur = 1;
    maxLen = max(maxLen, cur);
}
cout << maxLen;

Problema 2: Fibonacci(n) modulo 10⁹+7

const int MOD = 1000000007;
long long dp[1000001];

dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++)
    dp[i] = (dp[i-1] + dp[i-2]) % MOD;
cout << dp[n];

Problema 3: LIS — lungime și reconstituire

int v[1001], dp[1001], prev_idx[1001], n;

void lis_reconstituire() {
    int best_end = 1;
    for (int i = 1; i <= n; i++) {
        dp[i] = 1; prev_idx[i] = -1;
        for (int j = 1; j < i; j++)
            if (v[j] < v[i] && dp[j] + 1 > dp[i]) {
                dp[i] = dp[j] + 1;
                prev_idx[i] = j;
            }
        if (dp[i] > dp[best_end]) best_end = i;
    }
    // Reconstituire (de la coadă spre cap)
    int idx = best_end;
    while (idx != -1) {
        cout << v[idx] << " ";
        idx = prev_idx[idx];
    }
}
Subiecte unde apare DP la BAC

Fibonacci, nr. modalități de dispunere, LIS, suma maximă pe drum în triunghi, probleme de numărare cu restricții

Identificare rapidă

Dacă problema cere „numărul de moduri" sau „cea mai bună/lungă cale" — gândește-te la DP!