Hajde da budemo brutalno iskreni. Dinamičko programiranje ima zastrašujuću reputaciju. Za mnoge programere, fraza “dinamičko programiranje” izaziva PTSP zurenja u praznu belu tablu tokom intervjua, dok intervjuer agresivno pročišćava grlo.
Mnogi misle da se DP svodi na zurenje u 2D Excel tabelu dok se matematička jednačina magično ne pojavi u viziji. Ili još gore, pokušavaju da formule nauče napamet!
Žao mi je što moram da vam saopštim loše vesti, ali to je najgori način za učenje!
Kako znam? Zato što su nas upravo tako učili na fakultetu, a ja sam urado loše ispit iz algoritama. Nedeljama sam pokušavao da zapamtim različite formule za tranziciju stanja, ubeđen da je moj mozak fundamentalno nekompatibilan sa računarstvom.
Dinamičko programiranje nije ništa više od pametne rekurzije. Bukvalno je samo začinjena rekurzija sa keširanjem. Ako možete da napišete rekurzivno brute force rešenje (a apsolutno možete), možete napisati i DP rešenje. To je mehanički prevod, korak po korak, a ne magični trik.
U ovom postu ćemo rešiti LeetCode 188: Best Time to Buy and Sell Stock IV. Nećemo početi crtanjem tabele. Počećemo sa jednostavnim stablom odlučivanja, naterati naš kod da shvati da ima amneziju i evoluirati naše rešenje u munjevito brzo, prostorno optimizovano remek-delo :D!
Postavka problema
Dat vam je niz celih brojeva prices gde je prices[i] cena akcije na i-ti dan, i ceo broj k.
Pronađite maksimalni profit koji možete ostvariti. Možete izvršiti najviše k transakcija.
Napomena: Ne možete učestvovati u više transakcija istovremeno (tj. morate prodati akciju pre nego što je ponovo kupite).
Primer:
| |


Klasična Rekurzija (Brute Force)
Zaboravite na performanse na trenutak. Zamislite da ste putnik kroz vreme koji stoji na nultom danu. Koje izbore zapravo imate?
Poput Dr. Strejndža koji gleda u 14 miliona mogućih budućnosti, vaši izbori u potpunosti zavise od vašeg trenutnog stanja u vremenskoj liniji. Šta definiše vašu tačnu situaciju u bilo kom trenutku?
- Trenutni dan (
day): Koji je dan? - Preostalo transakcija (
txLeft): Koliko puta još mogu da kupim? - Stanje držanja (
holding): Da li trenutno posedujem akciju? (0 = Ne, 1 = Da).
Na osnovu ovog stanja, stablo odlučivanja je jednostavno:
- Ako nemamo akciju (Holding = 0):
- Preskoči: Ne radi ništa. Pređi na sledeći dan.
- Kupi: Potroši novac (
-prices[day]), pređi na sledeći dan i zabeleži da sada držimo akciju. Ovo troši 1 token za transakciju.
- Ako imamo akciju (Holding = 1):
- Preskoči: Zadrži akciju i pređi na sledeći dan.
- Prodaj: Zaradi novac (
+prices[day]), pređi na sledeći dan i zabeleži da više ne držimo akciju.

Svakog dana donosimo binarni izbor. Primetite kako se stanje menja između držanja i nedržanja akcije.
Pretvorimo ovu logiku u čistu, rekurzivnu C++ implementaciju.
| |
Ovo rešenje je 100% logički ispravno. Takođe će 100% dovesti do greške Time Limit Exceeded.
Ima vremensku složenost od otprilike O(2^n). Za niz od 50 dana, ovo stablo će se razgranati u kvadrilione rekurzivnih poziva. Toplotna smrt univerzuma će nastupiti pre nego što se kod završi.

Rekurzija izračunava svaku moguću opciju, što rezultira ogromnim, eksponencijalno rastućim stek pozivom.
Memoizacija (Top-Down pristup)
Zašto je rekurzivno rešenje tako sporo? Zato što vaš kod ima ozbiljnu amneziju. Izračunava potpuno iste budućnosti iznova i iznova.
Zamislimo da stignete do 5. dana sa 1 preostalom transakcijom i posedujete akciju kroz dve potpuno različite vremenske linije:
- Kupi 1. dana -> Prodaj 2. dana -> Kupi 5. dana.
- Kupi 3. dana -> Prodaj 4. dana -> Kupi 5. dana.
Rekurzija ne zna da je već bila u ovom tačnom stanju. Ponovo izračunava celu budućnost od 5. dana do kraja niza, i to dva puta.

Različite istorije mogu rezultirati istim trenutnim stanjem. Ne treba da predviđamo budućnost dva puta.
Da bismo ovo popravili, uvodimo koncept Keša (Memoizacija). Kada izračunamo odgovor za određeno stanje, zapišemo ga u našu beležnicu. Sledeći put kada dođemo u to isto stanje, samo pročitamo odgovor iz beležnice umesto da ga ponovo računamo.

Zlatno pravilo: Pamti budućnost, ignoriši prošlost
Ovo je deo koji zbunjuje skoro sve (uključujući i mene na fakultetu). Šta tačno čuvamo u ovoj beležnici?
Čuvamo budućnost, a ne prošlost.
Zamislite to kao da ste ubačeni u kazino u Las Vegasu. Funkcija solve(day, txLeft, holding) izračunava maksimalni profit koji možete ostvariti od danas do zatvaranja berze.
Berzu ne zanima kako ste stigli do 5. dana. Nije je briga da li ste zaradili bogatstvo 2. dana, ili izgubili životnu ušteđevinu 4. dana. Ako stojite 5. dana, sa jednim tokenom za transakciju, bez akcija, vaš budući potencijalni profit je potpuno isti bez obzira na prošlost.
Pošto prošlost nije bitna, možemo bezbedno keširati rezultat. Ako nas vremenska linija dovede do (dan 5, 1 token, 0 akcija), pogledamo u našu beležnicu: “Aha, ovo sam već izračunao.
Iz ove tačne situacije, najviše novca koji mogu zaraditi do kraja igre je $10.” Bum. Pod-stablo je odsečeno.
Zašto 3D Keš?
Da bi naša beležnica radila, svako jedinstveno stanje treba svoj poseban slot za čuvanje odgovora.
Pogledajte potpis naše rekurzivne funkcije: solve(day, txLeft, holding).
Niz prices se nikada ne menja, ali ostale tri promenljive da. Dakle, da bismo jedinstveno identifikovali našu tačnu situaciju, potreban nam je 3-dimenzionalni niz.
Zamislite to kao pronalaženje određenog sedišta u bioskopu:
- Blok (
day): Koji je dan? (Vrednosti od0doN) - Red (
txLeft): Koliko tokena imamo? (Vrednosti od0doK) - Sedište (
holding): Da li imamo akciju? (Vrednosti0ili1)

Naša memoizaciona tabela je u suštini 3D mreža gde svaki blok čuva maksimalni budući profit za to specifično stanje.
Evo kako C++ kod izgleda kada mu damo memoriju:
| |
Složenost:
- Vreme: O(N * K). Svako stanje računamo tačno jednom!
- Prostor: O(N * K * 2) za 3D keš, plus memorija rekurzivnog steka.
Ovo će proći na LeetCode-u, ali možemo i bolje.
Tabelarni pristup (Bottom-Up pristup)
Rekurzija je super sve dok vam ulaz nije ogroman, stek poziva eksplodira i obori aplikaciju.
Tabelarni pristup (Tabulation) jednostavno znači popunjavanje te memo tabele pomoću for petlji umesto rekurzije.
U rekurziji smo počeli od 0. dana i gledali unapred. U tabelarnom pristupu, putujemo kroz vreme na kraj niza i radimo unazad do 0. dana.
Zašto unazad? Zato što da bismo znali najbolju odluku za 10. dan, moramo već znati optimalne budućnosti za 11. dan.

Iteriramo unazad od poslednjeg dana do nultog da bismo osigurali da su buduće zavisnosti već izračunate.
Prevedimo rekurzivnu logiku direktno u petlju:
| |
Optimizacija prostora
Pogledajte pažljivo petlju u tabelarnom kodu. Primetili ste nešto zanimljivo? Da bismo izračunali vrednosti za dan, gledamo samo vrednosti sa dan + 1. Ne zanimaju nas dan + 2, dan + 5 ili dan + 100.
Zašto držimo ceo 3D kalendar u memoriji kada nas zanimaju samo danas i sutra?
Možemo potpuno ukloniti dimenziju Dan iz našeg niza! Potrebna su nam samo 2D niza koji predstavljaju sledeciDan i trenutniDan. Dok se krećemo unazad kroz vreme, izračunavamo trenutniDan na osnovu sledecegDana, a zatim prepisujemo sledeciDan sa trenutnimDanom.

Pošto uvek gledamo samo jedan korak unapred, ne treba nam cela 3D kocka. Možemo svesti dimenziju vremena na dve 2D mreže koje se smenjuju.
| |
Zaključak
Vidite kako smo potpuno izbegli zurenje u praznu belu tablu pokušavajući da izvlačimo matematičke formule iz vazduha?
Počeli smo sa osnovnom ljudskom logikom: Koji su moji izbori trenutno?
Napisali smo sporu rekurzivnu funkciju, dali joj beležnicu da pamti stvari (Memoizacija), pretvorili je u petlju (Tabelarni pristup) i odbacili delove beležnice koji nam više nisu bili potrebni (optimizacija prostora).
Tako se savladava dinamičko programiranje. Nije potrebna magija.