cphb/luku07.tex

983 lines
29 KiB
TeX

\chapter{Dynamic programming}
\index{dynamic programming}
\key{Dynamic programming}
is a technique that combines the correctness
of complete search and the efficiency
of greedy algorithms.
Dynamic programming can be used if the
problem can be divided into subproblems
that can be calculated independently.
There are two uses for dynamic programming:
\begin{itemize}
\item
\key{Findind an optimal solution}:
We want to find a solution that is
as large as possible or as small as possible.
\item
\key{Couting the number of solutions}:
We want to calculate the total number of
possible solutions.
\end{itemize}
We will first see how dynamic programming can
be used for finding an optimal solution,
and then we will use the same idea for
counting the solutions.
Understanding dynamic programming is a milestone
in every competitive programmer's career.
While the basic idea of the technique is simple,
the challenge is how to apply it for different problems.
This chapter introduces a set of classic problems
that are a good starting point.
\section{Coin problem}
We first consider a problem that we
have already seen:
Given a set of coin values $\{c_1,c_2,\ldots,c_k\}$
and a sum of money $x$, our task is to
form the sum $x$ using as few coins as possible.
In Chapter 6.1, we solved the problem using a
greedy algorithm that always selects the largest
possible coin for the sum.
The greedy algorithm works, for example,
when the coins are the euro coins,
but in the general case the greedy algorithm
doesn't necessarily produce an optimal solution.
Now it's time to solve the problem efficiently
using dynamic programming, so that the algorithms
works for any coin set.
The dynamic programming
algorithm is based on a recursive function
that goes through all possibilities how to
select the coins, like a brute force algorithm.
However, the dynamic programming
algorithm is efficient because
it uses memoization to
calculate the answer for each subproblem only once.
\subsubsection{Recursive formulation}
The idea in dynamic programming is to
formulate the problem recursively so
that the answer for the problem can be
calculated from the answers for the smaller
subproblems.
In this case, a natural problem is as follows:
what is the smallest number of coins
required for constructing sum $x$?
Let $f(x)$ be a function that gives the answer
for the problem, i.e., $f(x)$ is the smallest
number of coins required for constructing sum $x$.
The values of the function depend on the
values of the coins.
For example, if the values are $\{1,3,4\}$,
the first values of the function are as follows:
\[
\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}
\]
First, $f(0)=0$ because no coins are needed
for sum $0$.
Moreover, $f(3)=1$ because the sum $3$
can be formed using coin 3,
and $f(5)=2$ because the sum 5 can
be formed using coins 1 and 4.
The essential property in the function is
that the value $f(x)$ can be calculated
recursively from the smaller values of the function.
For example, if the coin set is $\{1,3,4\}$,
there are three ways to select the first coin
in a solution: we can choose coin 1, 3 or 4.
If coin 1 is chosen, the remaining task is to
form the sum $x-1$.
Similarly, if coin 3 or 4 is chosen,
we should form the sum $x-3$ or $x-4$.
Thus, the recursive formula is
\[f(x) = \min(f(x-1),f(x-3),f(x-4))+1\]
where the function $\min$ returns the smallest
of its parameters.
In the general case, for the coin set
$\{c_1,c_2,\ldots,c_k\}$,
the recursive formula is
\[f(x) = \min(f(x-c_1),f(x-c_2),\ldots,f(x-c_k))+1.\]
The base case for the function is
\[f(0)=0,\]
because no coins are needed for constructing
the sum 0.
In addition, it's a good idea to define
\[f(x)=\infty,\hspace{8px}\textrm{jos $x<0$}.\]
This means that an infinite number of coins
is needed to create a negative sum of money.
This prevents the situation that the recursive
function would form a solution where the
initial sum of money is negative.
Now it's possible to implement the function in C++
directly using the recursive definition:
\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}
The code assumes that the available coins are
$\texttt{c}[1], \texttt{c}[2], \ldots, \texttt{c}[k]$,
and the value $10^9$ means infinity.
This function works but it is not efficient yet
because it goes through a large number
of ways to construct the sum.
However, the function becomes efficient by
using memoization.
\subsubsection{Memoization}
\index{memoization}
Dynamic programming allows to calculate the
value of a recursive function efficiently
using \key{memoization}.
This means that an auxiliary array is used
for storing the values of the function
for different parameters.
For each parameter, the value of the function
is calculated only once, and after this,
it can be directly retrieved from the array.
In this problem, we can use the array
\begin{lstlisting}
int d[N];
\end{lstlisting}
where $\texttt{d}[x]$ will contain
the value $f(x)$.
The constant $N$ should be chosen so
that there is space for all needed
values of the function.
After this, the function can be efficiently
implemented as follows:
\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}
The function handles the base cases
$x=0$ and $x<0$ as previously.
Then the function checks if
$f(x)$ has already been calculated
and stored to $\texttt{d}[x]$.
If $f(x)$ can be found in the array,
the function directly returns it.
Otherwise the function calculates the value
recursively and stores it to $\texttt{d}[x]$.
Using memoization the function works
efficiently because it is needed to
recursively calculate
the answer for each $x$ only once.
After a value $f(x)$ has been stored to the array,
it can be directly retrieved whenever the
function will be called again with parameter $x$.
The time complexity of the resulting algorithm
is $O(xk)$ when the sum is $x$ and the number of
coins is $k$.
In practice, the algorithm is usable if
$x$ is so small that it is possible to allocate
an array for all possible function parameters.
Note that the array can also be constructed using
a loop that calculates all the values
instead of a recursive function:
\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}
This implementation is shorter and somewhat
more efficient than recursion,
and experienced competitive programmers
often implement dynamic programming solutions
using loops.
Still, the underlying idea is the same as
in the recursive function.
\subsubsection{Constructing the solution}
Sometimes it is not enough to find out the value
of the optimal solution, but we should also give
an example how such a solution can be constructed.
In this problem, this means that the algorithm
should show how to select the coins that produce
the sum $x$ using as few coins as possible.
We can construct the solution by adding another
array to the code. The array indicates for
each sum of money the first coin that should be
chosen in an optimal solution.
In the following code, the array \texttt{e}
is used for this:
\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}
After this, we can print the coins needed
for the sum $x$ as follows:
\begin{lstlisting}
while (x > 0) {
cout << e[x] << "\n";
x -= e[x];
}
\end{lstlisting}
\subsubsection{Counting the number of solutions}
Let us now consider a variation of the problem
that it's like the original problem but we should
count the total number of solutions instead
of finding the optimal solution.
For example, if the coins are $\{1,3,4\}$ and
the required sum is $5$,
there are a total of 6 solutions:
\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}
The number of the solutions can be calculated
using the same idea as finding the optimal solution.
The difference is that when finding the optimal solution,
we maximize or minimize something in the recursion,
but now we will sum together all possible alternatives to
construct a solution.
In this case, we can define a function $f(x)$
that returns the number of ways to construct
the sum $x$ using the coins.
For example, $f(5)=6$ when the coins are $\{1,3,4\}$.
The function $f(x)$ can be recursively calculated
using the formula
\[ f(x) = f(x-c_1)+f(x-c_2)+\cdots+f(x-c_k)\]
because to form the sum $x$ we should first
choose some coin $c_i$ and after this form the sum $x-c_i$.
The base cases are $f(0)=1$ because there is exactly
one way to form the sum 0 using an empty set of coins,
and $f(x)=0$, when $x<0$, because it's not possible
to form a negative sum of money.
In the above example the function becomes
\[ f(x) = f(x-1)+f(x-3)+f(x-4) \]
and the first values of the function are:
\[
\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}
\]
The following code calculates the value $f(x)$
using dynamic programming by filling the array
\texttt{d} for parameters $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}
Often the number of the solutions is so large
that it is not required to calculate the exact number
but it is enough to give the answer modulo $m$
where, for example, $m=10^9+7$.
This can be done by changing the code so that
all calculations will be done in modulo $m$.
In this case, it is enough to add the line
\begin{lstlisting}
d[i] %= m;
\end{lstlisting}
after the line
\begin{lstlisting}
d[i] += d[i-c[j]];
\end{lstlisting}
Now we have covered all basic
techniques related to
dynamic programming.
Since dynamic programming can be used
in many different situations,
we will now go through a set of problems
that show further examples how dynamic
programming can be used.
\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.