cphb/luku07.tex

977 lines
30 KiB
TeX

\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<k$
ja $x_i<x_k$.
Pisin nouseva alijono saadaan liittämällä
kohtaan $i$ päättyvän pisimmän nousevan alijonon perään luku $x_k$.
Tällöin $f(k)=f(i)+1$.
\end{enumerate}
Tarkastellaan esimerkkinä arvon $f(7)$ laskemista.
Paras ratkaisu on ottaa pohjaksi kohtaan 5
päättyvä pisin nouseva alijono $[2,5,7]$
ja lisätä sen perään luku $x_7=8$.
Tuloksena on alijono $[2,5,7,8]$ ja $f(7)=f(5)+1=4$.
Suoraviivainen tapa toteuttaa algoritmi on
käydä kussakin kohdassa $k$ läpi kaikki kohdat
$i=1,2,\ldots,k-1$, joissa voi olla alijonon
edellinen luku.
Tällaisen algoritmin aikavaativuus on $O(n^2)$.
Yllättävää kyllä, algoritmin voi toteuttaa myös
ajassa $O(n \log n)$, mutta tämä on vaikeampaa.
\section{Reitinhaku ruudukossa}
Seuraava tehtävämme on etsiä reitti
$n \times n$ -ruudukon vasemmasta yläkulmasta
oikeaan alakulmaan.
Jokaisessa ruudussa on luku, ja reitti
tulee muodostaa niin, että reittiin kuuluvien
lukujen summa on mahdollisimman suuri.
Rajoituksena ruudukossa on mahdollista
liikkua vain oikealla ja alaspäin.
Seuraavassa ruudukossa paras reitti
on merkitty harmaalla taustalla:
\begin{center}
\begin{tikzpicture}[scale=.65]
\begin{scope}
\fill [color=lightgray] (0, 9) rectangle (1, 8);
\fill [color=lightgray] (0, 8) rectangle (1, 7);
\fill [color=lightgray] (1, 8) rectangle (2, 7);
\fill [color=lightgray] (1, 7) rectangle (2, 6);
\fill [color=lightgray] (2, 7) rectangle (3, 6);
\fill [color=lightgray] (3, 7) rectangle (4, 6);
\fill [color=lightgray] (4, 7) rectangle (5, 6);
\fill [color=lightgray] (4, 6) rectangle (5, 5);
\fill [color=lightgray] (4, 5) rectangle (5, 4);
\draw (0, 4) grid (5, 9);
\node at (0.5,8.5) {3};
\node at (1.5,8.5) {7};
\node at (2.5,8.5) {9};
\node at (3.5,8.5) {2};
\node at (4.5,8.5) {7};
\node at (0.5,7.5) {9};
\node at (1.5,7.5) {8};
\node at (2.5,7.5) {3};
\node at (3.5,7.5) {5};
\node at (4.5,7.5) {5};
\node at (0.5,6.5) {1};
\node at (1.5,6.5) {7};
\node at (2.5,6.5) {9};
\node at (3.5,6.5) {8};
\node at (4.5,6.5) {5};
\node at (0.5,5.5) {3};
\node at (1.5,5.5) {8};
\node at (2.5,5.5) {6};
\node at (3.5,5.5) {4};
\node at (4.5,5.5) {10};
\node at (0.5,4.5) {6};
\node at (1.5,4.5) {3};
\node at (2.5,4.5) {9};
\node at (3.5,4.5) {7};
\node at (4.5,4.5) {8};
\end{scope}
\end{tikzpicture}
\end{center}
Tällä reitillä lukujen summa on $3+9+8+7+9+8+5+10+8=67$,
joka on suurin mahdollinen summa vasemmasta yläkulmasta
oikeaan alakulmaan.
Hyvä lähestymistapa tehtävään on laskea
kuhunkin ruutuun $(y,x)$ suurin summa
reitillä vasemmasta yläkulmasta kyseiseen ruutuun.
Merkitään tätä suurinta summaa $f(y,x)$,
jolloin $f(n,n)$ on suurin summa
reitillä vasemmasta yläkulmasta oikeaan alakulmaan.
Rekursio syntyy havainnosta,
että ruutuun $(y,x)$ saapuvan reitin
täytyy tulla joko vasemmalta ruudusta $(y,x-1)$
tai ylhäältä ruudusta $(y-1,x)$:
\begin{center}
\begin{tikzpicture}[scale=.65]
\begin{scope}
\fill [color=lightgray] (3, 7) rectangle (4, 6);
\draw (0, 4) grid (5, 9);
\node at (2.5,6.5) {$\rightarrow$};
\node at (3.5,7.5) {$\downarrow$};
\end{scope}
\end{tikzpicture}
\end{center}
Kun $r(y,x)$
on ruudukon luku kohdassa $(y,x)$,
rekursion pohjatapaukset ovat seuraavat:
\[
\begin{array}{lcl}
f(1,1) & = & r(1,1) \\
f(1,x) & = & f(1,x-1)+r(1,x) \\
f(y,1) & = & f(y-1,1)+r(y,1)\\
\end{array}
\]
Yleisessä tapauksessa valittavana on
kaksi reittiä,
joista kannattaa valita se,
joka tuottaa suuremman summan:
\[ f(y,x) = \max(f(y,x-1),f(y-1,x))+r(y,x)\]
Ratkaisun aikavaativuus on $O(n^2)$, koska jokaisessa
ruudussa $f(y,x)$ saadaan laskettua vakioajassa
viereisten ruutujen arvoista.
\section{Repunpakkaus}
\index{repunpakkaus@repunpakkaus}
\key{Repunpakkaus} on klassinen ongelma,
jossa annettuna on $n$ tavaraa,
joiden painot ovat
$p_1,p_2,\ldots,p_n$ ja arvot ovat
$a_1,a_2,\ldots,a_n$.
Tehtävänä on valita reppuun pakattavat tavarat
niin, että tavaroiden
painojen summa on enintään $x$
ja tavaroiden arvojen summa on mahdollisimman suuri.
\begin{samepage}
Esimerkiksi jos tavarat ovat
\begin{center}
\begin{tabular}{rrr}
tavara & paino & arvo \\
\hline
A & 5 & 1 \\
B & 6 & 3 \\
C & 8 & 5 \\
D & 5 & 3 \\
\end{tabular}
\end{center}
\end{samepage}
ja suurin sallittu yhteispaino on 12,
niin paras ratkaisu on pakata reppuun tavarat $B$ ja $D$.
Niiden yhteispaino $6+5=11$ ei ylitä rajaa 12
ja arvojen summa
on $3+3=6$, mikä on paras mahdollinen tulos.
Tämä tehtävä on mahdollista ratkaista kahdella eri
tavalla dynaamisella ohjelmoinnilla
riippuen siitä, tarkastellaanko ongelmaa
maksimointina vai minimointina.
Käymme seuraavaksi läpi molemmat ratkaisut.
\subsubsection{Ratkaisu 1}
\textit{Maksimointi:} Merkitään $f(k,u)$
suurinta mahdollista tavaroiden yhteisarvoa,
kun reppuun pakataan jokin osajoukko
tavaroista $1 \ldots k$,
jossa tavaroiden yhteispaino on $u$.
Ratkaisu tehtävään on suurin arvo
$f(n,u)$, kun $0 \le u \le x$.
Rekursiivinen kaava funktion laskemiseksi on
\[f(k,u) = \max(f(k-1,u),f(k-1,u-p_k)+a_k),\]
koska kohdassa $k$ oleva tavara joko otetaan tai ei oteta
mukaan ratkaisuun.
Pohjatapauksina on $f(0,0)=0$ ja $f(0,u)=-\infty$,
kun $u \neq 0$. Tämän ratkaisun aikavaativuus on $O(nx)$.
Esimerkin tilanteessa optimiratkaisu on
$f(4,11)=6$, joka muodostuu seuraavan ketjun kautta:
\[f(4,11)=f(3,6)+3=f(2,6)+3=f(1,0)+3+3=f(0,0)+3+3=6.\]
\subsubsection{Ratkaisu 2}
\textit{Minimointi:} Merkitään $f(k,u)$
pienintä mahdollista tavaroiden yhteispainoa,
kun reppuun pakataan jokin osajoukko
tavaroista $1 \ldots k$,
jossa tavaroiden yhteisarvo on $u$.
Ratkaisu tehtävään on suurin arvo $u$,
jolle pätee $0 \le u \le s$ ja $f(n,u) \le x$,
missä $s=\sum_{i=1}^n a_i$.
Rekursiivinen kaava funktion laskemiseksi on
\[f(k,u) = \min(f(k-1,u),f(k-1,u-a_k)+p_k)\]
ratkaisua 1 vastaavasti.
Pohjatapauksina on $f(0,0)=0$ ja $f(0,u)=\infty$, kun $u \neq 0$.
Tämän ratkaisun aikavaativuus on $O(ns)$.
Esimerkin tilanteessa optimiratkaisu on
$f(4,6)=11$, joka muodostuu seuraavan ketjun kautta:
\[f(4,6)=f(3,3)+5=f(2,3)+5=f(1,0)+6+5=f(0,0)+6+5=11.\]
~\\
Kiinnostava seikka on, että eri asiat syötteessä
vaikuttavat ratkaisuiden tehokkuuteen.
Ratkaisussa 1 tavaroiden painot vaikuttavat tehokkuuteen
mutta arvoilla ei ole merkitystä.
Ratkaisussa 2 puolestaan tavaroiden arvot vaikuttavat
tehokkuuteen mutta painoilla ei ole merkitystä.
\section{Editointietäisyys}
\index{editointietxisyys@editointietäisyys}
\index{Levenšteinin etäisyys}
\key{Editointietäisyys} eli
\key{Levenšteinin etäisyys}
kuvaa, kuinka kaukana kaksi merkkijonoa ovat toisistaan.
Se on pienin määrä editointioperaatioita,
joilla ensimmäisen merkkijonon saa muutettua toiseksi.
Sallitut operaatiot ovat:
\begin{itemize}
\item merkin lisäys (esim. \texttt{ABC} $\rightarrow$ \texttt{ABCA})
\item merkin poisto (esim. \texttt{ABC} $\rightarrow$ \texttt{AC})
\item merkin muutos (esim. \texttt{ABC} $\rightarrow$ \texttt{ADC})
\end{itemize}
Esimerkiksi merkkijonojen \texttt{TALO} ja \texttt{PALLO}
editointietäisyys on 2, koska voimme tehdä ensin
operaation \texttt{TALO} $\rightarrow$ \texttt{TALLO}
(merkin lisäys) ja sen jälkeen operaation
\texttt{TALLO} $\rightarrow$ \texttt{PALLO}
(merkin muutos).
Tämä on pienin mahdollinen määrä operaatioita, koska
selvästikään yksi operaatio ei riitä.
Oletetaan, että annettuna on merkkijonot
\texttt{x} (pituus $n$ merkkiä) ja
\texttt{y} (pituus $m$ merkkiä),
ja haluamme laskea niiden editointietäisyyden.
Tämä onnistuu tehokkaasti dynaamisella
ohjelmoinnilla ajassa $O(nm)$.
Merkitään funktiolla $f(a,b)$
editointietäisyyttä \texttt{x}:n $a$
ensimmäisen merkin sekä
\texttt{y}:n $b$:n ensimmäisen merkin välillä.
Tätä funktiota käyttäen
merkkijonojen
\texttt{x} ja \texttt{y} editointietäisyys
on $f(n,m)$, ja funktio kertoo myös tarvittavat
editointioperaatiot.
Funktion pohjatapaukset ovat
\[
\begin{array}{lcl}
f(0,b) & = & b \\
f(a,0) & = & a \\
\end{array}
\]
ja yleisessä tapauksessa pätee kaava
\[ f(a,b) = \min(f(a,b-1)+1,f(a-1,b)+1,f(a-1,b-1)+c),\]
missä $c=0$, jos \texttt{x}:n merkki $a$
ja \texttt{y}:n merkki $b$ ovat samat,
ja muussa tapauksessa $c=1$.
Kaava käy läpi mahdollisuudet lyhentää merkkijonoja:
\begin{itemize}
\item $f(a,b-1)$ tarkoittaa, että $x$:ään lisätään merkki
\item $f(a-1,b)$ tarkoittaa, että $x$:stä poistetaan merkki
\item $f(a-1,b-1)$ tarkoittaa, että $x$:ssä ja $y$:ssä on
sama merkki ($c=0$) tai $x$:n merkki muutetaan $y$:n merkiksi ($c=1$)
\end{itemize}
Seuraava taulukko sisältää funktion $f$ arvot
esimerkin tapauksessa:
\begin{center}
\begin{tikzpicture}[scale=.65]
\begin{scope}
%\fill [color=lightgray] (5, -3) rectangle (6, -4);
\draw (1, -1) grid (7, -6);
\node at (0.5,-2.5) {\texttt{T}};
\node at (0.5,-3.5) {\texttt{A}};
\node at (0.5,-4.5) {\texttt{L}};
\node at (0.5,-5.5) {\texttt{O}};
\node at (2.5,-0.5) {\texttt{P}};
\node at (3.5,-0.5) {\texttt{A}};
\node at (4.5,-0.5) {\texttt{L}};
\node at (5.5,-0.5) {\texttt{L}};
\node at (6.5,-0.5) {\texttt{O}};
\node at (1.5,-1.5) {$0$};
\node at (1.5,-2.5) {$1$};
\node at (1.5,-3.5) {$2$};
\node at (1.5,-4.5) {$3$};
\node at (1.5,-5.5) {$4$};
\node at (2.5,-1.5) {$1$};
\node at (2.5,-2.5) {$1$};
\node at (2.5,-3.5) {$2$};
\node at (2.5,-4.5) {$3$};
\node at (2.5,-5.5) {$4$};
\node at (3.5,-1.5) {$2$};
\node at (3.5,-2.5) {$2$};
\node at (3.5,-3.5) {$1$};
\node at (3.5,-4.5) {$2$};
\node at (3.5,-5.5) {$3$};
\node at (4.5,-1.5) {$3$};
\node at (4.5,-2.5) {$3$};
\node at (4.5,-3.5) {$2$};
\node at (4.5,-4.5) {$1$};
\node at (4.5,-5.5) {$2$};
\node at (5.5,-1.5) {$4$};
\node at (5.5,-2.5) {$4$};
\node at (5.5,-3.5) {$3$};
\node at (5.5,-4.5) {$2$};
\node at (5.5,-5.5) {$2$};
\node at (6.5,-1.5) {$5$};
\node at (6.5,-2.5) {$5$};
\node at (6.5,-3.5) {$4$};
\node at (6.5,-4.5) {$3$};
\node at (6.5,-5.5) {$2$};
\end{scope}
\end{tikzpicture}
\end{center}
Taulukon oikean alanurkan ruutu
kertoo, että merkkijonojen \texttt{TALO}
ja \texttt{PALLO} editointietäisyys on 2.
Taulukosta pystyy myös
lukemaan, miten pienimmän editointietäisyyden
voi saavuttaa.
Tässä tapauksessa polku on seuraava:
\begin{center}
\begin{tikzpicture}[scale=.65]
\begin{scope}
\draw (1, -1) grid (7, -6);
\node at (0.5,-2.5) {\texttt{T}};
\node at (0.5,-3.5) {\texttt{A}};
\node at (0.5,-4.5) {\texttt{L}};
\node at (0.5,-5.5) {\texttt{O}};
\node at (2.5,-0.5) {\texttt{P}};
\node at (3.5,-0.5) {\texttt{A}};
\node at (4.5,-0.5) {\texttt{L}};
\node at (5.5,-0.5) {\texttt{L}};
\node at (6.5,-0.5) {\texttt{O}};
\node at (1.5,-1.5) {$0$};
\node at (1.5,-2.5) {$1$};
\node at (1.5,-3.5) {$2$};
\node at (1.5,-4.5) {$3$};
\node at (1.5,-5.5) {$4$};
\node at (2.5,-1.5) {$1$};
\node at (2.5,-2.5) {$1$};
\node at (2.5,-3.5) {$2$};
\node at (2.5,-4.5) {$3$};
\node at (2.5,-5.5) {$4$};
\node at (3.5,-1.5) {$2$};
\node at (3.5,-2.5) {$2$};
\node at (3.5,-3.5) {$1$};
\node at (3.5,-4.5) {$2$};
\node at (3.5,-5.5) {$3$};
\node at (4.5,-1.5) {$3$};
\node at (4.5,-2.5) {$3$};
\node at (4.5,-3.5) {$2$};
\node at (4.5,-4.5) {$1$};
\node at (4.5,-5.5) {$2$};
\node at (5.5,-1.5) {$4$};
\node at (5.5,-2.5) {$4$};
\node at (5.5,-3.5) {$3$};
\node at (5.5,-4.5) {$2$};
\node at (5.5,-5.5) {$2$};
\node at (6.5,-1.5) {$5$};
\node at (6.5,-2.5) {$5$};
\node at (6.5,-3.5) {$4$};
\node at (6.5,-4.5) {$3$};
\node at (6.5,-5.5) {$2$};
\path[draw=red,thick,-,line width=2pt] (6.5,-5.5) -- (5.5,-4.5);
\path[draw=red,thick,-,line width=2pt] (5.5,-4.5) -- (4.5,-4.5);
\path[draw=red,thick,->,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.