\chapter{Dynamic programming} \index{dynaaminen ohjelmointi@dynaaminen ohjelmointi} \key{Dynaaminen ohjelmointi} on tekniikka, joka yhdistää täydellisen haun toimivuuden ja ahneiden algoritmien tehokkuuden. Dynaamisen ohjelmoinnin käyttäminen edellyttää, että tehtävä jakautuu osaongelmiin, jotka voidaan käsitellä toisistaan riippumattomasti. Dynaamisella ohjelmoinnilla on kaksi käyttötarkoitusta: \begin{itemize} \item \key{Optimiratkaisun etsiminen}: Haluamme etsiä ratkaisun, joka on jollakin tavalla suurin mahdollinen tai pienin mahdollinen. \item \key{Ratkaisuiden määrän laskeminen}: Haluamme laskea, kuinka monta mahdollista ratkaisua on olemassa. \end{itemize} Tutustumme dynaamiseen ohjelmointiin ensin optimiratkaisun etsimisen kautta ja käytämme sitten samaa ideaa ratkaisujen määrän laskemiseen. Dynaamisen ohjelmoinnin ymmärtäminen on yksi merkkipaalu jokaisen kisakoodarin uralla. Vaikka menetelmän perusidea on yksinkertainen, haasteena on oppia soveltamaan sitä sujuvasti erilaisissa tehtävissä. Tämä luku esittelee joukon perusesimerkkejä, joista on hyvä lähteä liikkeelle. \section{Kolikkotehtävä} Aloitamme dynaamisen ohjelmoinnin tutun tehtävän kautta: Muodostettavana on rahamäärä $x$ käyttäen mahdollisimman vähän kolikoita. Kolikoiden arvot ovat $\{c_1,c_2,\ldots,c_k\}$ ja jokaista kolikkoa on saatavilla rajattomasti. Luvussa 6.1 ratkaisimme tehtävän ahneella algoritmilla, joka muodostaa rahamäärän valiten mahdollisimman suuria kolikoita. Ahne algoritmi toimii esimerkiksi silloin, kun kolikot ovat eurokolikot, mutta yleisessä tapauksessa ahne algoritmi ei välttämättä valitse pienintä määrää kolikoita. Nyt on aika ratkaista tehtävä tehokkaasti dynaamisella ohjelmoinnilla niin, että algoritmi toimii millä tahansa kolikoilla. Algoritmi perustuu rekursiiviseen funktioon, joka käy läpi kaikki vaihtoehdot rahamäärän muodostamiseen täydellisen haun kaltaisesti. Algoritmi toimii kuitenkin tehokkaasti, koska se tallentaa välituloksia muistitaulukkoon, minkä ansiosta sen ei tarvitse laskea samoja asioita moneen kertaan. \subsubsection{Rekursiivinen esitys} \index{rekursioyhtxlz@rekursioyhtälö} Dynaamisessa ohjelmoinnissa on ideana esittää ongelma rekursiivisesti niin, että ongelman ratkaisun voi laskea saman ongelman pienempien tapausten ratkaisuista. Tässä tehtävässä luonteva ongelma on seuraava: mikä on pienin määrä kolikoita, joilla voi muodostaa rahamäärän $x$? Merkitään $f(x)$ funktiota, joka antaa vastauksen ongelmaan, eli $f(x)$ on pienin määrä kolikoita, joilla voi muodostaa rahamäärän $x$. Funktion arvot riippuvat siitä, mitkä kolikot ovat käytössä. Esimerkiksi jos kolikot ovat $\{1,3,4\}$, funktion ensimmäiset arvot ovat: \[ \begin{array}{lcl} f(0) & = & 0 \\ f(1) & = & 1 \\ f(2) & = & 2 \\ f(3) & = & 1 \\ f(4) & = & 1 \\ f(5) & = & 2 \\ f(6) & = & 2 \\ f(7) & = & 2 \\ f(8) & = & 2 \\ f(9) & = & 3 \\ f(10) & = & 3 \\ \end{array} \] Nyt $f(0)=0$, koska jos rahamäärä on 0, ei tarvita yhtään kolikkoa. Vastaavasti $f(3)=1$, koska rahamäärän 3 voi muodostaa kolikolla 3, ja $f(5)=2$, koska rahamäärän 5 voi muodostaa kolikoilla 1 ja 4. Oleellinen ominaisuus funktiossa on, että arvon $f(x)$ pystyy laskemaan rekursiivisesti käyttäen pienempiä funktion arvoja. Esimerkiksi jos kolikot ovat $\{1,3,4\}$, on kolme tapaa alkaa muodostaa rahamäärää $x$: valitaan kolikko 1, 3 tai 4. Jos valitaan kolikko 1, täytyy muodostaa vielä rahamäärä $x-1$. Vastaavasti jos valitaan kolikko 3 tai 4, täytyy muodostaa rahamäärä $x-3$ tai $x-4$. Niinpä rekursiivinen kaava on \[f(x) = \min(f(x-1),f(x-3),f(x-4))+1,\] missä funktio $\min$ valitsee pienimmän parametreistaan. Yleisemmin jos kolikot ovat $\{c_1,c_2,\ldots,c_k\}$, rekursiivinen kaava on \[f(x) = \min(f(x-c_1),f(x-c_2),\ldots,f(x-c_k))+1.\] Funktion pohjatapauksena on \[f(0)=0,\] koska rahamäärän 0 muodostamiseen ei tarvita yhtään kolikkoa. Lisäksi on hyvä määritellä \[f(x)=\infty,\hspace{8px}\textrm{jos $x<0$}.\] Tämä tarkoittaa, että negatiivisen rahamäärän muodostaminen vaatii äärettömästi kolikoita, mikä estää sen, että rekursio muodostaisi ratkaisun, johon kuuluu negatiivinen rahamäärä. Nyt voimme toteuttaa funktion C++:lla suoraan rekursiivisen määritelmän perusteella: \begin{lstlisting} int f(int x) { if (x == 0) return 0; if (x < 0) return 1e9; int u = 1e9; for (int i = 1; i <= k; i++) { u = min(u, f(x-c[i])+1); } return u; } \end{lstlisting} Koodi olettaa, että käytettävät kolikot ovat $\texttt{c}[1], \texttt{c}[2], \ldots, \texttt{c}[k]$, ja arvo $10^9$ kuvastaa ääretöntä. Tämä on toimiva funktio, mutta se ei ole vielä tehokas, koska funktio käy läpi valtavasti erilaisia tapoja muodostaa rahamäärä. Seuraavaksi esiteltävä muistitaulukko tekee funktiosta tehokkaan. \subsubsection{Muistitaulukko} \index{muistitaulukko@muistitaulukko} Dynaaminen ohjelmointi tehostaa rekursiivisen funktion laskentaa tallentamalla funktion arvoja \key{muistitaulukkoon}. Taulukon avulla funktion arvo tietyllä parametrilla riittää laskea vain kerran, minkä jälkeen sen voi hakea suoraan taulukosta. Tämä muutos nopeuttaa algoritmia ratkaisevasti. Tässä tehtävässä muistitaulukoksi sopii taulukko \begin{lstlisting} int d[N]; \end{lstlisting} jonka kohtaan $\texttt{d}[x]$ lasketaan funktion arvo $f(x)$. Vakio $N$ valitaan niin, että kaikki laskettavat funktion arvot mahtuvat taulukkoon. Tämän jälkeen funktion voi toteuttaa tehokkaasti näin: \begin{lstlisting} int f(int x) { if (x == 0) return 0; if (x < 0) return 1e9; if (d[x]) return d[x]; int u = 1e9; for (int i = 1; i <= k; i++) { u = min(u, f(x-c[i])+1); } d[x] = u; return d[x]; } \end{lstlisting} Funktio käsittelee pohjatapaukset $x=0$ ja $x<0$ kuten ennenkin. Sitten funktio tarkastaa, onko $f(x)$ laskettu jo taulukkoon $\texttt{d}[x]$. Jos $f(x)$ on laskettu, funktio palauttaa sen suoraan. Muussa tapauksessa funktio laskee arvon rekursiivisesti ja tallentaa sen kohtaan $\texttt{d}[x]$. Muistitaulukon ansiosta funktio toimii nopeasti, koska sen tarvitsee laskea vastaus kullekin $x$:n arvolle vain kerran rekursiivisesti. Heti kun arvo $f(x)$ on tallennettu muistitaulukkoon, sen saa haettua sieltä suoraan, kun funktiota kutsutaan seuraavan kerran parametrilla $x$. Tuloksena olevan algoritmin aikavaativuus on $O(xk)$, kun rahamäärä on $x$ ja kolikoiden määrä on $k$. Käytännössä ratkaisu on mahdollista toteuttaa, jos $x$ on niin pieni, että on mahdollista varata riittävän suuri muistitaulukko. Huomaa, että muistitaulukon voi muodostaa myös suoraan silmukalla ilman rekursiota laskemalla arvot pienimmästä suurimpaan: \begin{lstlisting} d[0] = 0; for (int i = 1; i <= x; i++) { int u = 1e9; for (int j = 1; j <= k; j++) { if (i-c[j] < 0) continue; u = min(u, d[i-c[j]]+1); } d[i] = u; } \end{lstlisting} Silmukkatoteutus on lyhyempi ja hieman tehokkaampi kuin rekursiototeutus, minkä vuoksi kokeneet kisakoodarit toteuttavat dynaamisen ohjelmoinnin usein silmukan avulla. Kuitenkin silmukkatoteutuksen taustalla on sama rekursiivinen idea kuin ennenkin. \subsubsection{Ratkaisun muodostaminen} Joskus optimiratkaisun arvon selvittämisen lisäksi täytyy muodostaa näytteeksi yksi mahdollinen optimiratkaisu. Tässä tehtävässä tämä tarkoittaa, että ohjelman täytyy antaa esimerkki tavasta valita kolikot, joista muodostuu rahamäärä $x$ käyttäen mahdollisimman vähän kolikoita. Ratkaisun muodostaminen onnistuu lisäämällä koodiin uuden taulukon, joka kertoo kullekin rahamäärälle, mikä kolikko siitä tulee poistaa optimiratkaisussa. Seuraavassa koodissa taulukko \texttt{e} huolehtii asiasta: \begin{lstlisting} d[0] = 0; for (int i = 1; i <= x; i++) { d[i] = 1e9; for (int j = 1; j <= k; j++) { if (i-c[j] < 0) continue; int u = d[i-c[j]]+1; if (u < d[i]) { d[i] = u; e[i] = c[j]; } } } \end{lstlisting} Tämän jälkeen rahamäärän $x$ muodostavat kolikot voi tulostaa näin: \begin{lstlisting} while (x > 0) { cout << e[x] << "\n"; x -= e[x]; } \end{lstlisting} \subsubsection{Ratkaisuiden määrän laskeminen} Tarkastellaan sitten kolikkotehtävän muunnelmaa, joka on muuten samanlainen kuin ennenkin, mutta laskettavana on mahdollisten ratkaisuiden yhteismäärä optimaalisen ratkaisun sijasta. Esimerkiksi jos kolikot ovat $\{1,3,4\}$ ja rahamäärä on 5, niin ratkaisuja on kaikkiaan 6: \begin{multicols}{2} \begin{itemize} \item $1+1+1+1+1$ \item $1+1+3$ \item $1+3+1$ \item $3+1+1$ \item $1+4$ \item $4+1$ \end{itemize} \end{multicols} Ratkaisujen määrän laskeminen tapahtuu melko samalla tavalla kuin optimiratkaisun etsiminen. Erona on, että optimiratkaisun etsivässä rekursiossa valitaan pienin tai suurin aiempi arvo, kun taas ratkaisujen määrän laskevassa rekursiossa lasketaan yhteen kaikki vaihtoehdot. Tässä tapauksessa voimme määritellä funktion $f(x)$, joka kertoo, monellako tavalla rahamäärän $x$ voi muodostaa kolikoista. Esimerkiksi $f(5)=6$, kun kolikot ovat $\{1,3,4\}$. Funktion $f(x)$ saa laskettua rekursiivisesti kaavalla \[ f(x) = f(x-c_1)+f(x-c_2)+\cdots+f(x-c_k),\] koska rahamäärän $x$ muodostamiseksi pitää valita jokin kolikko $c_i$ ja muodostaa sen jälkeen rahamäärä $x-c_i$. Pohjatapauksina ovat $f(0)=1$, koska rahamäärä 0 syntyy ilman yhtään kolikkoa, sekä $f(x)=0$, kun $x<0$, koska negatiivista rahamäärää ei ole mahdollista muodostaa. Yllä olevassa esimerkissä funktioksi tulee \[ f(x) = f(x-1)+f(x-3)+f(x-4) \] ja funktion ensimmäiset arvot ovat: \[ \begin{array}{lcl} f(0) & = & 1 \\ f(1) & = & 1 \\ f(2) & = & 1 \\ f(3) & = & 2 \\ f(4) & = & 4 \\ f(5) & = & 6 \\ f(6) & = & 9 \\ f(7) & = & 15 \\ f(8) & = & 25 \\ f(9) & = & 40 \\ \end{array} \] Seuraava koodi laskee funktion $f(x)$ arvon dynaamisella ohjelmoinnilla täyttämällä taulukon \texttt{d} rahamäärille $0 \ldots x$: \begin{lstlisting} d[0] = 1; for (int i = 1; i <= x; i++) { for (int j = 1; j <= k; j++) { if (i-c[j] < 0) continue; d[i] += d[i-c[j]]; } } \end{lstlisting} Usein ratkaisujen määrä on niin suuri, että sitä ei tarvitse laskea kokonaan vaan riittää ilmoittaa vastaus modulo $m$, missä esimerkiksi $m=10^9+7$. Tämä onnistuu muokkaamalla koodia niin, että kaikki laskutoimitukset lasketaan modulo $m$. Tässä tapauksessa riittää lisätä rivin \begin{lstlisting} d[i] += d[i-c[j]]; \end{lstlisting} jälkeen rivi \begin{lstlisting} d[i] %= m; \end{lstlisting} Nyt olemme käyneet läpi kaikki dynaamisen ohjelmoinnin perusasiat. Dynaamista ohjelmointia voi soveltaa monilla tavoilla erilaisissa tilanteissa, minkä vuoksi tutustumme seuraavaksi joukkoon tehtäviä, jotka esittelevät dynaamisen ohjelmoinnin mahdollisuuksia. \section{Pisin nouseva alijono} \index{pisin nouseva alijono@pisin nouseva alijono} Annettuna on taulukko, jossa on $n$ kokonaislukua $x_1,x_2,\ldots,x_n$. Tehtävänä on selvittää, kuinka pitkä on taulukon \key{pisin nouseva alijono} eli vasemmalta oikealle kulkeva ketju taulukon alkioita, jotka on valittu niin, että jokainen alkio on edellistä suurempi. Esimerkiksi taulukossa \begin{center} \begin{tikzpicture}[scale=0.7] \draw (0,0) grid (8,1); \node at (0.5,0.5) {$6$}; \node at (1.5,0.5) {$2$}; \node at (2.5,0.5) {$5$}; \node at (3.5,0.5) {$1$}; \node at (4.5,0.5) {$7$}; \node at (5.5,0.5) {$4$}; \node at (6.5,0.5) {$8$}; \node at (7.5,0.5) {$3$}; \footnotesize \node at (0.5,1.4) {$1$}; \node at (1.5,1.4) {$2$}; \node at (2.5,1.4) {$3$}; \node at (3.5,1.4) {$4$}; \node at (4.5,1.4) {$5$}; \node at (5.5,1.4) {$6$}; \node at (6.5,1.4) {$7$}; \node at (7.5,1.4) {$8$}; \end{tikzpicture} \end{center} pisin nouseva alijono sisältää 4 alkiota: \begin{center} \begin{tikzpicture}[scale=0.7] \fill[color=lightgray] (1,0) rectangle (2,1); \fill[color=lightgray] (2,0) rectangle (3,1); \fill[color=lightgray] (4,0) rectangle (5,1); \fill[color=lightgray] (6,0) rectangle (7,1); \draw (0,0) grid (8,1); \node at (0.5,0.5) {$6$}; \node at (1.5,0.5) {$2$}; \node at (2.5,0.5) {$5$}; \node at (3.5,0.5) {$1$}; \node at (4.5,0.5) {$7$}; \node at (5.5,0.5) {$4$}; \node at (6.5,0.5) {$8$}; \node at (7.5,0.5) {$3$}; \draw[thick,->] (1.5,-0.25) .. controls (1.75,-1.00) and (2.25,-1.00) .. (2.4,-0.25); \draw[thick,->] (2.6,-0.25) .. controls (3.0,-1.00) and (4.0,-1.00) .. (4.4,-0.25); \draw[thick,->] (4.6,-0.25) .. controls (5.0,-1.00) and (6.0,-1.00) .. (6.5,-0.25); \footnotesize \node at (0.5,1.4) {$1$}; \node at (1.5,1.4) {$2$}; \node at (2.5,1.4) {$3$}; \node at (3.5,1.4) {$4$}; \node at (4.5,1.4) {$5$}; \node at (5.5,1.4) {$6$}; \node at (6.5,1.4) {$7$}; \node at (7.5,1.4) {$8$}; \end{tikzpicture} \end{center} Merkitään $f(k)$ kohtaan $k$ päättyvän pisimmän nousevan alijonon pituutta, jolloin ratkaisu tehtävään on suurin arvoista $f(1),f(2),\ldots,f(n)$. Esimerkiksi yllä olevassa taulukossa funktion arvot ovat seuraavat: \[ \begin{array}{lcl} f(1) & = & 1 \\ f(2) & = & 1 \\ f(3) & = & 2 \\ f(4) & = & 1 \\ f(5) & = & 3 \\ f(6) & = & 2 \\ f(7) & = & 4 \\ f(8) & = & 2 \\ \end{array} \] Arvon $f(k)$ laskemisessa on kaksi vaihtoehtoa, millainen kohtaan $k$ päättyvä pisin nouseva alijono on: \begin{enumerate} \item Pisin nouseva alijono sisältää vain luvun $x_k$, jolloin $f(k)=1$. \item Valitaan jokin kohta $i$, jolle pätee $i,line width=2pt] (4.5,-4.5) -- (1.5,-1.5); \end{scope} \end{tikzpicture} \end{center} Merkkijonojen \texttt{PALLO} ja \texttt{TALO} viimeinen merkki on sama, joten niiden editointietäisyys on sama kuin merkkijonojen \texttt{PALL} ja \texttt{TAL}. Nyt voidaan poistaa viimeinen \texttt{L} merkkijonosta \texttt{PAL}, mistä tulee yksi operaatio. Editointietäisyys on siis yhden suurempi kuin merkkijonoilla \texttt{PAL} ja \texttt{TAL}, jne. \section{Laatoitukset} Joskus dynaamisen ohjelmoinnin tila on monimutkaisempi kuin kiinteä yhdistelmä lukuja. Tarkastelemme lopuksi tehtävää, jossa laskettavana on, monellako tavalla kokoa $1 \times 2$ ja $2 \times 1$ olevilla laatoilla voi täyttää $n \times m$ -kokoisen ruudukon. Esimerkiksi ruudukolle kokoa $4 \times 7$ yksi mahdollinen ratkaisu on \begin{center} \begin{tikzpicture}[scale=.65] \draw (0,0) grid (7,4); \draw[fill=gray] (0+0.2,0+0.2) rectangle (2-0.2,1-0.2); \draw[fill=gray] (2+0.2,0+0.2) rectangle (4-0.2,1-0.2); \draw[fill=gray] (4+0.2,0+0.2) rectangle (6-0.2,1-0.2); \draw[fill=gray] (0+0.2,1+0.2) rectangle (2-0.2,2-0.2); \draw[fill=gray] (2+0.2,1+0.2) rectangle (4-0.2,2-0.2); \draw[fill=gray] (1+0.2,2+0.2) rectangle (3-0.2,3-0.2); \draw[fill=gray] (1+0.2,3+0.2) rectangle (3-0.2,4-0.2); \draw[fill=gray] (4+0.2,3+0.2) rectangle (6-0.2,4-0.2); \draw[fill=gray] (0+0.2,2+0.2) rectangle (1-0.2,4-0.2); \draw[fill=gray] (3+0.2,2+0.2) rectangle (4-0.2,4-0.2); \draw[fill=gray] (6+0.2,2+0.2) rectangle (7-0.2,4-0.2); \draw[fill=gray] (4+0.2,1+0.2) rectangle (5-0.2,3-0.2); \draw[fill=gray] (5+0.2,1+0.2) rectangle (6-0.2,3-0.2); \draw[fill=gray] (6+0.2,0+0.2) rectangle (7-0.2,2-0.2); \end{tikzpicture} \end{center} ja ratkaisujen yhteismäärä on 781. Tehtävän voi ratkaista dynaamisella ohjelmoinnilla käymällä ruudukkoa läpi rivi riviltä. Jokainen ratkaisun rivi pelkistyy merkkijonoksi, jossa on $m$ merkkiä joukosta $\{\sqcap, \sqcup, \sqsubset, \sqsupset \}$. Esimerkiksi yllä olevassa ratkaisussa on 4 riviä, jotka vastaavat merkkijonoja \begin{itemize} \item $\sqcap \sqsubset \sqsupset \sqcap \sqsubset \sqsupset \sqcap$, \item $\sqcup \sqsubset \sqsupset \sqcup \sqcap \sqcap \sqcup$, \item $\sqsubset \sqsupset \sqsubset \sqsupset \sqcup \sqcup \sqcap$ ja \item $\sqsubset \sqsupset \sqsubset \sqsupset \sqsubset \sqsupset \sqcup$. \end{itemize} Tehtävään sopiva rekursiivinen funktio on $f(k,x)$, joka laskee, montako tapaa on muodostaa ratkaisu ruudukon riveille $1 \ldots k$ niin, että riviä $k$ vastaa merkkijono $x$. Dynaaminen ohjelmointi on mahdollista, koska jokaisen rivin sisältöä rajoittaa vain edellisen rivin sisältö. Riveistä muodostuva ratkaisu on kelvollinen, jos rivillä 1 ei ole merkkiä $\sqcup$, rivillä $n$ ei ole merkkiä $\sqcap$ ja kaikki peräkkäiset rivit ovat \emph{yhteensopivat}. Esimerkiksi rivit $\sqcup \sqsubset \sqsupset \sqcup \sqcap \sqcap \sqcup$ ja $\sqsubset \sqsupset \sqsubset \sqsupset \sqcup \sqcup \sqcap$ ovat yhteensopivat, kun taas rivit $\sqcap \sqsubset \sqsupset \sqcap \sqsubset \sqsupset \sqcap$ ja $\sqsubset \sqsupset \sqsubset \sqsupset \sqsubset \sqsupset \sqcup$ eivät ole yhteensopivat. Koska rivillä on $m$ merkkiä ja jokaiselle merkille on 4 vaihtoehtoa, erilaisia rivejä on korkeintaan $4^m$. Niinpä ratkaisun aikavaativuus on $O(n 4^{2m})$, koska joka rivillä käydään läpi $O(4^m)$ vaihtoehtoa rivin sisällölle ja jokaista vaihtoehtoa kohden on $O(4^m)$ vaihtoehtoa edellisen rivin sisällölle. Käytännössä ruudukko kannattaa kääntää niin päin, että pienempi sivun pituus on $m$:n roolissa, koska $m$:n suuruus on ratkaiseva ajankäytön kannalta. Ratkaisua on mahdollista tehostaa parantamalla rivien esitystapaa merkkijonoina. Osoittautuu, että ainoa seuraavalla rivillä tarvittava tieto on, missä kohdissa riviltä lähtee laattoja alaspäin. Niinpä rivin voikin tallentaa käyttäen vain merkkejä $\sqcap$ ja $\Box$, missä $\Box$ kokoaa yhteen vanhat merkit $\sqcup$, $\sqsubset$ ja $\sqsupset$. Tällöin erilaisia rivejä on vain $2^m$ ja aikavaativuudeksi tulee $O(n 2^{2m})$. Mainittakoon lopuksi, että laatoitusten määrän laskemiseen on myös yllättävä suora kaava \[ \prod_{a=1}^{\lceil n/2 \rceil} \prod_{b=1}^{\lceil m/2 \rceil} 4 \cdot (\cos^2 \frac{\pi a}{n + 1} + \cos^2 \frac{\pi b}{m+1}).\] Tämä kaava on sinänsä hyvin tehokas, koska se laskee laatoitusten määrän ajassa $O(nm)$, mutta käytännön ongelma kaavan käyttämisessä on, kuinka tallentaa välitulokset riittävän tarkkoina lukuina.