De ce contează?
Imaginează-ți că rezolvi o problemă grea și, după ce ajungi la răspuns, îl notezi pe un bilețel lipit pe perete. Data viitoare când cineva îți cere același lucru, nu mai reiei toată socoteala: arunci o privire pe bilețel și citești răspunsul gata calculat. Memoizarea este exact acest obicei, băgat în recursie: rezolvi o subproblemă o singură dată, o lipești pe „perete” și, dacă reapare, o citești direct în loc să o refaci.
Memoizarea: recursie + cache
Știi deja că programarea dinamică înseamnă recursie + memorie: rezolvi
subproblemele o singură dată și reții rezultatele. Memoizarea este forma cea
mai directă a acestei idei. Pleci de la recursia naturală scrisă după recurență
și îi adaugi un singur lucru: un cache, un tablou memo[] în care ții
rezultatele deja calculate.
Tiparul are mereu aceiași trei pași la intrarea în funcție:
- Verifici cache-ul. Dacă
memo[stare]e deja calculat, îl returnezi pe loc. - Calculezi recursiv, ca într-o recursie obișnuită, dacă nu era în cache.
- Salvezi în cache rezultatul în
memo[stare]înainte să îl returnezi.
Cheia este marca de „necalculat”. La început umpli tot memo[] cu o valoare care
nu poate fi un răspuns real — convenția capitolului este -1. Astfel, când
vezi -1, știi sigur că starea n-a fost încă rezolvată; orice altă valoare e un
rezultat deja gata, pe care îl citești în O(1).
Fără acest cache, recursia ar reface aceleași subprobleme la nesfârșit (exponențial).
Cu el, fiecare stare se calculează o singură dată, exact ca la tabloul dp[],
doar că aici ordinea o stabilește recursia, nu tu.
Top-down (recursiv, lazy) vs bottom-up (iterativ, tablou)
Aceeași recurență poate fi implementată în două stiluri. Ambele dau același rezultat și aceeași complexitate, dar lucrează diferit.
- Top-down (memoizare) — pornești de la starea mare cerută și cobori recursiv spre cazurile de bază. Calculezi lazy: doar stările de care chiar ai nevoie ajung să fie calculate. Codul seamănă izbitor cu recurența scrisă pe hârtie, plus cache-ul.
- Bottom-up (tabulare) — completezi tabloul iterativ, de la stările mici spre cele mari, într-o ordine pe care o fixezi tu. Nu există stivă de apeluri: totul e o buclă.
Avantaje și dezavantaje, pe scurt:
| Stil | Avantaje | Dezavantaje |
|---|---|---|
| top-down (memoizare) | aproape de recurență, ușor de scris; calculează doar stările folosite | stivă de apeluri (risc de stack overflow la adâncime mare); cost mic per apel |
| bottom-up (tabulare) | fără stivă de apeluri; controlezi exact ordinea; ușor de optimizat memoria | trebuie să găsești tu ordinea corectă; uneori calculează stări inutile |
Regula practică: când recurența e ușor de scris dar ordinea de completare e greu de văzut, memoizarea e mai comodă. Când adâncimea recursiei ar fi enormă sau vrei să strângi memoria, tabularea e mai sigură.
Exemplu pas cu pas: Fibonacci memoizat
Recurența o știi: F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2). Pornim cu
memo[] plin de -1 și cerem F(5).
F(5):memo[5]e-1→ trebuie calculat. CereF(4)șiF(3).F(4):-1→ calculează. CereF(3)șiF(2).F(3):-1→ calculează. CereF(2)șiF(1)→ coboară la cazurile de bază, urcă și salveazămemo[2]=1, apoimemo[3]=2.- Înapoi în
F(4): acum cere iarF(2). De data astamemo[2]e1, nu-1→ îl citește instant din cache, fără să mai recalculeze nimic. Salveazămemo[4]=3. - Înapoi în
F(5): cereF(3).memo[3]e2→ instant din cache. Salveazămemo[5]=5.
Observă pasul cheie: al doilea apel pentru F(2) și pentru F(3) nu mai
declanșează nicio recursie — răspunsul e deja pe „bilețel”. Exact asta taie costul
de la exponențial la liniar.
Implementare C++
Memoizare top-down pentru Fibonacci, cu memo[] inițializat la -1:
#include <iostream>
using namespace std;
long long memo[100]; // cache; -1 inseamna necalculat
long long fib(int n) {
if (n <= 1) {
return n; // cazuri de baza: F(0)=0, F(1)=1
}
if (memo[n] != -1) {
return memo[n]; // deja in cache: il citim instant
}
memo[n] = fib(n - 1) + fib(n - 2); // calculam recursiv SI salvam
return memo[n];
}
int main() {
for (int i = 0; i < 100; i++) {
memo[i] = -1; // marcam tot ca necalculat
}
cout << fib(10) << "\n"; // F(10)=55
return 0;
}Cele trei linii din inima funcției sunt întreg tiparul: verifici cache-ul,
calculezi dacă lipsește, salvezi înainte de return. Atât desparte o recursie
exponențială de una liniară.
Complexitate
| Abordare | Timp | Spațiu | De ce |
|---|---|---|---|
| recursiv naiv | O(2^n) | O(n) pe stivă | recalculează aceleași subprobleme la nesfârșit |
| top-down (memoizat) | O(n) | O(n) tablou + O(n) stivă | fiecare F(i) se calculează o dată, apoi se citește din cache |
| bottom-up (tabulat) | O(n) | O(n) tablou, O(1) stivă | umple tabloul iterativ, fără stivă de apeluri |
La Fibonacci, ambele variante DP sunt O(n) ca timp. Diferența e practică:
top-down ține și stiva de apeluri (deci risc de stack overflow la n uriaș și un
cost mic per apel), pe când bottom-up nu folosește stivă deloc și se optimizează
ușor la O(1) memorie ținând doar ultimele două valori.
Reține perechea: top-down = scrii recurența direct și îi lipești un cache, lăsând recursia să decidă ordinea (lazy); bottom-up = controlezi tu ordinea de completare și eviți complet stiva de apeluri. Același rezultat, aceeași complexitate — alegi stilul după cât de ușor vezi ordinea și cât de adâncă ar fi recursia.
- Uiți să salvezi în cache: dacă calculezi recursiv dar nu scrii în
memo[n], cache-ul rămâne gol și recursia rămâne exponențială. Salvarea înainte dereturne obligatorie. - Marca „necalculat” coincide cu un răspuns valid: dacă pui
0ca marcă de gol dar0e și un rezultat posibil, nu mai distingi „necalculat” de „zero” și recalculezi greșit. Alege o valoare imposibilă (ex.-1). memo[]nedimensionat sau neinițializat: dacă tabloul e mai mic decâtnscrii în afara lui, iar dacă nu îl umpli cu-1la început, citești gunoi pe care îl iei drept rezultat valid.- Recursie prea adâncă la top-down: pentru
nfoarte mare, lanțul de apeluri umple stiva → stack overflow. Acolo trece pe bottom-up, care nu folosește stiva de apeluri.
Cache-ul după calcul
După ce fib(10) se termină, memo[] păstrează toate stările atinse — fiecare
calculată o singură dată. La un nou apel pentru orice F(i) deja prezent,
răspunsul vine instant din cache:
| memo | 0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 | 55 |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
citit | salvat | fib(10)=55 |
Recapitulare
- Memoizarea = recursie + cache: la intrarea în funcție verifici
memo[], calculezi doar dacă lipsește (marcat-1) ȘI salvezi rezultatul înainte dereturn, ca să nu refaci niciodată aceeași subproblemă. - Top-down (recursiv, lazy) e aproape de recurență și calculează doar stările folosite, dar ține stiva de apeluri; bottom-up (iterativ, tablou) controlează ordinea și nu folosește stiva — ambele O(n) la Fibonacci.
- Marca „necalculat” trebuie să fie o valoare imposibilă ca răspuns real; altfel confunzi „gol” cu un rezultat valid și cache-ul te induce în eroare.