Coin problem

This commit is contained in:
Antti H S Laaksonen 2017-01-02 19:16:30 +02:00
parent 12d094ac8a
commit 9b75316ad6
1 changed files with 223 additions and 217 deletions

View File

@ -1,87 +1,86 @@
\chapter{Dynamic programming} \chapter{Dynamic programming}
\index{dynaaminen ohjelmointi@dynaaminen ohjelmointi} \index{dynamic programming}
\key{Dynaaminen ohjelmointi} \key{Dynamic programming}
on tekniikka, joka yhdistää täydellisen haun is a technique that combines the correctness
toimivuuden ja ahneiden algoritmien tehokkuuden. of complete search and the efficiency
Dynaamisen ohjelmoinnin käyttäminen edellyttää, of greedy algorithms.
että tehtävä jakautuu osaongelmiin, Dynamic programming can be used if the
jotka voidaan käsitellä toisistaan riippumattomasti. problem can be divided into subproblems
that can be calculated independently.
Dynaamisella ohjelmoinnilla on kaksi käyttötarkoitusta: There are two uses for dynamic programming:
\begin{itemize} \begin{itemize}
\item \item
\key{Optimiratkaisun etsiminen}: \key{Findind an optimal solution}:
Haluamme etsiä ratkaisun, joka on We want to find a solution that is
jollakin tavalla suurin mahdollinen as large as possible or as small as possible.
tai pienin mahdollinen.
\item \item
\key{Ratkaisuiden määrän laskeminen}: \key{Couting the number of solutions}:
Haluamme laskea, kuinka monta mahdollista We want to calculate the total number of
ratkaisua on olemassa. possible solutions.
\end{itemize} \end{itemize}
Tutustumme dynaamiseen ohjelmointiin ensin We will first see how dynamic programming can
optimiratkaisun etsimisen kautta ja käytämme sitten be used for finding an optimal solution,
samaa ideaa ratkaisujen määrän laskemiseen. and then we will use the same idea for
counting the solutions.
Dynaamisen ohjelmoinnin ymmärtäminen on yksi merkkipaalu Understanding dynamic programming is a milestone
jokaisen kisakoodarin uralla. in every competitive programmer's career.
Vaikka menetelmän perusidea on yksinkertainen, While the basic idea of the technique is simple,
haasteena on oppia soveltamaan sitä sujuvasti the challenge is how to apply it for different problems.
erilaisissa tehtävissä. This chapter introduces a set of classic problems
Tämä luku esittelee joukon that are a good starting point.
perusesimerkkejä, joista on hyvä lähteä liikkeelle.
\section{Kolikkotehtävä} \section{Coin problem}
Aloitamme dynaamisen ohjelmoinnin tutun tehtävän kautta: We first consider a problem that we
Muodostettavana on rahamäärä $x$ have already seen:
käyttäen mahdollisimman vähän kolikoita. Given a set of coin values $\{c_1,c_2,\ldots,c_k\}$
Kolikoiden arvot ovat $\{c_1,c_2,\ldots,c_k\}$ and a sum of money $x$, our task is to
ja jokaista kolikkoa on saatavilla rajattomasti. form the sum $x$ using as few coins as possible.
Luvussa 6.1 ratkaisimme tehtävän ahneella algoritmilla, In Chapter 6.1, we solved the problem using a
joka muodostaa rahamäärän valiten mahdollisimman greedy algorithm that always selects the largest
suuria kolikoita. possible coin for the sum.
Ahne algoritmi toimii esimerkiksi silloin, The greedy algorithm works, for example,
kun kolikot ovat eurokolikot, when the coins are the euro coins,
mutta yleisessä tapauksessa ahne algoritmi but in the general case the greedy algorithm
ei välttämättä valitse pienintä määrää kolikoita. doesn't necessarily produce an optimal solution.
Nyt on aika ratkaista tehtävä tehokkaasti Now it's time to solve the problem efficiently
dynaamisella ohjelmoinnilla niin, using dynamic programming, so that the algorithms
että algoritmi toimii millä tahansa kolikoilla. works for any coin set.
Algoritmi perustuu rekursiiviseen funktioon, The dynamic programming
joka käy läpi kaikki vaihtoehdot rahamäärän algorithm is based on a recursive function
muodostamiseen täydellisen haun kaltaisesti. that goes through all possibilities how to
Algoritmi toimii kuitenkin tehokkaasti, koska select the coins, like a brute force algorithm.
se tallentaa välituloksia muistitaulukkoon, However, the dynamic programming
minkä ansiosta sen ei tarvitse laskea samoja algorithm is efficient because
asioita moneen kertaan. it uses memoization to
calculate the answer for each subproblem only once.
\subsubsection{Rekursiivinen esitys} \subsubsection{Recursive formulation}
\index{rekursioyhtxlz@rekursioyhtälö} 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$?
Dynaamisessa ohjelmoinnissa on ideana esittää Let $f(x)$ be a function that gives the answer
ongelma rekursiivisesti niin, for the problem, i.e., $f(x)$ is the smallest
että ongelman ratkaisun voi laskea number of coins required for constructing sum $x$.
saman ongelman pienempien tapausten ratkaisuista. The values of the function depend on the
Tässä tehtävässä luonteva ongelma on seuraava: values of the coins.
mikä on pienin määrä kolikoita, For example, if the values are $\{1,3,4\}$,
joilla voi muodostaa rahamäärän $x$? the first values of the function are as follows:
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} \begin{array}{lcl}
@ -99,44 +98,46 @@ f(10) & = & 3 \\
\end{array} \end{array}
\] \]
Nyt $f(0)=0$, koska jos rahamäärä on 0, First, $f(0)=0$ because no coins are needed
ei tarvita yhtään kolikkoa. for sum $0$.
Vastaavasti $f(3)=1$, koska rahamäärän 3 Moreover, $f(3)=1$ because the sum $3$
voi muodostaa kolikolla 3, can be formed using coin 3,
ja $f(5)=2$, koska rahamäärän 5 and $f(5)=2$ because the sum 5 can
voi muodostaa kolikoilla 1 ja 4. be formed using coins 1 and 4.
Oleellinen ominaisuus funktiossa on, The essential property in the function is
että arvon $f(x)$ pystyy laskemaan that the value $f(x)$ can be calculated
rekursiivisesti käyttäen pienempiä recursively from the smaller values of the function.
funktion arvoja. For example, if the coin set is $\{1,3,4\}$,
Esimerkiksi jos kolikot ovat $\{1,3,4\}$, there are three ways to select the first coin
on kolme tapaa alkaa muodostaa rahamäärää $x$: in a solution: we can choose coin 1, 3 or 4.
valitaan kolikko 1, 3 tai 4. If coin 1 is chosen, the remaining task is to
Jos valitaan kolikko 1, täytyy form the sum $x-1$.
muodostaa vielä rahamäärä $x-1$. Similarly, if coin 3 or 4 is chosen,
Vastaavasti jos valitaan kolikko 3 tai 4, we should form the sum $x-3$ or $x-4$.
täytyy muodostaa rahamäärä $x-3$ tai $x-4$.
Niinpä rekursiivinen kaava on Thus, the recursive formula is
\[f(x) = \min(f(x-1),f(x-3),f(x-4))+1,\] \[f(x) = \min(f(x-1),f(x-3),f(x-4))+1\]
missä funktio $\min$ valitsee pienimmän parametreistaan. where the function $\min$ returns the smallest
Yleisemmin jos kolikot ovat $\{c_1,c_2,\ldots,c_k\}$, of its parameters.
rekursiivinen kaava on 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.\] \[f(x) = \min(f(x-c_1),f(x-c_2),\ldots,f(x-c_k))+1.\]
Funktion pohjatapauksena on The base case for the function is
\[f(0)=0,\] \[f(0)=0,\]
koska rahamäärän 0 muodostamiseen ei tarvita because no coins are needed for constructing
yhtään kolikkoa. the sum 0.
Lisäksi on hyvä määritellä In addition, it's a good idea to define
\[f(x)=\infty,\hspace{8px}\textrm{jos $x<0$}.\] \[f(x)=\infty,\hspace{8px}\textrm{jos $x<0$}.\]
Tämä tarkoittaa, että negatiivisen rahamäärän This means that an infinite number of coins
muodostaminen vaatii äärettömästi kolikoita, is needed to create a negative sum of money.
mikä estää sen, että rekursio muodostaisi This prevents the situation that the recursive
ratkaisun, johon kuuluu negatiivinen rahamäärä. function would form a solution where the
initial sum of money is negative.
Nyt voimme toteuttaa funktion C++:lla suoraan Now it's possible to implement the function in C++
rekursiivisen määritelmän perusteella: directly using the recursive definition:
\begin{lstlisting} \begin{lstlisting}
int f(int x) { int f(int x) {
@ -150,41 +151,42 @@ int f(int x) {
} }
\end{lstlisting} \end{lstlisting}
Koodi olettaa, että käytettävät kolikot ovat The code assumes that the available coins are
$\texttt{c}[1], \texttt{c}[2], \ldots, \texttt{c}[k]$, $\texttt{c}[1], \texttt{c}[2], \ldots, \texttt{c}[k]$,
ja arvo $10^9$ kuvastaa ääretöntä. and the value $10^9$ means infinity.
Tämä on toimiva funktio, mutta se ei ole vielä tehokas, This function works but it is not efficient yet
koska funktio käy läpi valtavasti erilaisia tapoja because it goes through a large number
muodostaa rahamäärä. of ways to construct the sum.
Seuraavaksi esiteltävä muistitaulukko tekee However, the function becomes efficient by
funktiosta tehokkaan. using memoization.
\subsubsection{Muistitaulukko} \subsubsection{Memoization}
\index{muistitaulukko@muistitaulukko} \index{memoization}
Dynaaminen ohjelmointi tehostaa Dynamic programming allows to calculate the
rekursiivisen funktion laskentaa value of a recursive function efficiently
tallentamalla funktion arvoja \key{muistitaulukkoon}. using \key{memoization}.
Taulukon avulla funktion arvo This means that an auxiliary array is used
tietyllä parametrilla riittää laskea for storing the values of the function
vain kerran, minkä jälkeen sen voi for different parameters.
hakea suoraan taulukosta. For each parameter, the value of the function
Tämä muutos nopeuttaa algoritmia ratkaisevasti. is calculated only once, and after this,
it can be directly retrieved from the array.
Tässä tehtävässä muistitaulukoksi sopii taulukko
In this problem, we can use the array
\begin{lstlisting} \begin{lstlisting}
int d[N]; int d[N];
\end{lstlisting} \end{lstlisting}
jonka kohtaan $\texttt{d}[x]$ where $\texttt{d}[x]$ will contain
lasketaan funktion arvo $f(x)$. the value $f(x)$.
Vakio $N$ valitaan niin, että kaikki The constant $N$ should be chosen so
laskettavat funktion arvot mahtuvat taulukkoon. that there is space for all needed
values of the function.
Tämän jälkeen funktion voi toteuttaa After this, the function can be efficiently
tehokkaasti näin: implemented as follows:
\begin{lstlisting} \begin{lstlisting}
int f(int x) { int f(int x) {
@ -200,32 +202,34 @@ int f(int x) {
} }
\end{lstlisting} \end{lstlisting}
Funktio käsittelee pohjatapaukset $x=0$ The function handles the base cases
ja $x<0$ kuten ennenkin. $x=0$ and $x<0$ as previously.
Sitten funktio tarkastaa, Then the function checks if
onko $f(x)$ laskettu jo taulukkoon $\texttt{d}[x]$. $f(x)$ has already been calculated
Jos $f(x)$ on laskettu, and stored to $\texttt{d}[x]$.
funktio palauttaa sen suoraan. If $f(x)$ can be found in the array,
Muussa tapauksessa funktio laskee arvon rekursiivisesti the function directly returns it.
ja tallentaa sen kohtaan $\texttt{d}[x]$. Otherwise the function calculates the value
recursively and stores it to $\texttt{d}[x]$.
Muistitaulukon ansiosta funktio toimii Using memoization the function works
nopeasti, koska sen tarvitsee laskea efficiently because it is needed to
vastaus kullekin $x$:n arvolle recursively calculate
vain kerran rekursiivisesti. the answer for each $x$ only once.
Heti kun arvo $f(x)$ on tallennettu muistitaulukkoon, After a value $f(x)$ has been stored to the array,
sen saa haettua sieltä suoraan, it can be directly retrieved whenever the
kun funktiota kutsutaan seuraavan kerran parametrilla $x$. function will be called again with parameter $x$.
Tuloksena olevan algoritmin aikavaativuus on $O(xk)$, The time complexity of the resulting algorithm
kun rahamäärä on $x$ ja kolikoiden määrä on $k$. is $O(xk)$ when the sum is $x$ and the number of
Käytännössä ratkaisu on mahdollista toteuttaa, coins is $k$.
jos $x$ on niin pieni, että on mahdollista varata In practice, the algorithm is usable if
riittävän suuri muistitaulukko. $x$ is so small that it is possible to allocate
an array for all possible function parameters.
Huomaa, että muistitaulukon voi muodostaa Note that the array can also be constructed using
myös suoraan silmukalla ilman rekursiota a loop that calculates all the values
laskemalla arvot pienimmästä suurimpaan: instead of a recursive function:
\begin{lstlisting} \begin{lstlisting}
d[0] = 0; d[0] = 0;
for (int i = 1; i <= x; i++) { for (int i = 1; i <= x; i++) {
@ -238,31 +242,29 @@ for (int i = 1; i <= x; i++) {
} }
\end{lstlisting} \end{lstlisting}
Silmukkatoteutus on lyhyempi ja This implementation is shorter and somewhat
hieman tehokkaampi kuin rekursiototeutus, more efficient than recursion,
minkä vuoksi kokeneet kisakoodarit and experienced competitive programmers
toteuttavat dynaamisen ohjelmoinnin often implement dynamic programming solutions
usein silmukan avulla. using loops.
Kuitenkin silmukkatoteutuksen taustalla Still, the underlying idea is the same as
on sama rekursiivinen idea kuin ennenkin. in the recursive function.
\subsubsection{Ratkaisun muodostaminen} \subsubsection{Constructing the solution}
Joskus optimiratkaisun arvon selvittämisen lisäksi Sometimes it is not enough to find out the value
täytyy muodostaa näytteeksi yksi mahdollinen optimiratkaisu. of the optimal solution, but we should also give
Tässä tehtävässä tämä tarkoittaa, an example how such a solution can be constructed.
että ohjelman täytyy antaa esimerkki In this problem, this means that the algorithm
tavasta valita kolikot, should show how to select the coins that produce
joista muodostuu rahamäärä $x$ the sum $x$ using as few coins as possible.
käyttäen mahdollisimman vähän kolikoita.
Ratkaisun muodostaminen onnistuu lisäämällä We can construct the solution by adding another
koodiin uuden taulukon, joka kertoo array to the code. The array indicates for
kullekin rahamäärälle, each sum of money the first coin that should be
mikä kolikko siitä tulee poistaa chosen in an optimal solution.
optimiratkaisussa. In the following code, the array \texttt{e}
Seuraavassa koodissa taulukko \texttt{e} is used for this:
huolehtii asiasta:
\begin{lstlisting} \begin{lstlisting}
d[0] = 0; d[0] = 0;
@ -279,8 +281,8 @@ for (int i = 1; i <= x; i++) {
} }
\end{lstlisting} \end{lstlisting}
Tämän jälkeen rahamäärän $x$ muodostavat After this, we can print the coins needed
kolikot voi tulostaa näin: for the sum $x$ as follows:
\begin{lstlisting} \begin{lstlisting}
while (x > 0) { while (x > 0) {
@ -289,14 +291,15 @@ while (x > 0) {
} }
\end{lstlisting} \end{lstlisting}
\subsubsection{Ratkaisuiden määrän laskeminen} \subsubsection{Counting the number of solutions}
Tarkastellaan sitten kolikkotehtävän muunnelmaa, Let us now consider a variation of the problem
joka on muuten samanlainen kuin ennenkin, that it's like the original problem but we should
mutta laskettavana on mahdollisten ratkaisuiden yhteismäärä count the total number of solutions instead
optimaalisen ratkaisun sijasta. of finding the optimal solution.
Esimerkiksi jos kolikot ovat $\{1,3,4\}$ ja rahamäärä on 5, For example, if the coins are $\{1,3,4\}$ and
niin ratkaisuja on kaikkiaan 6: the required sum is $5$,
there are a total of 6 solutions:
\begin{multicols}{2} \begin{multicols}{2}
\begin{itemize} \begin{itemize}
@ -309,29 +312,30 @@ niin ratkaisuja on kaikkiaan 6:
\end{itemize} \end{itemize}
\end{multicols} \end{multicols}
Ratkaisujen määrän laskeminen tapahtuu melko samalla tavalla The number of the solutions can be calculated
kuin optimiratkaisun etsiminen. using the same idea as finding the optimal solution.
Erona on, että optimiratkaisun etsivässä rekursiossa The difference is that when finding the optimal solution,
valitaan pienin tai suurin aiempi arvo, we maximize or minimize something in the recursion,
kun taas ratkaisujen määrän laskevassa rekursiossa lasketaan but now we will sum together all possible alternatives to
yhteen kaikki vaihtoehdot. construct a solution.
Tässä tapauksessa voimme määritellä funktion $f(x)$, In this case, we can define a function $f(x)$
joka kertoo, monellako tavalla rahamäärän $x$ that returns the number of ways to construct
voi muodostaa kolikoista. the sum $x$ using the coins.
Esimerkiksi $f(5)=6$, kun kolikot ovat $\{1,3,4\}$. For example, $f(5)=6$ when the coins are $\{1,3,4\}$.
Funktion $f(x)$ saa laskettua rekursiivisesti kaavalla The function $f(x)$ can be recursively calculated
\[ f(x) = f(x-c_1)+f(x-c_2)+\cdots+f(x-c_k),\] using the formula
koska rahamäärän $x$ muodostamiseksi pitää \[ f(x) = f(x-c_1)+f(x-c_2)+\cdots+f(x-c_k)\]
valita jokin kolikko $c_i$ ja muodostaa sen jälkeen rahamäärä $x-c_i$. because to form the sum $x$ we should first
Pohjatapauksina ovat $f(0)=1$, koska rahamäärä 0 syntyy choose some coin $c_i$ and after this form the sum $x-c_i$.
ilman yhtään kolikkoa, The base cases are $f(0)=1$ because there is exactly
sekä $f(x)=0$, kun $x<0$, koska negatiivista rahamäärää one way to form the sum 0 using an empty set of coins,
ei ole mahdollista muodostaa. and $f(x)=0$, when $x<0$, because it's not possible
to form a negative sum of money.
Yllä olevassa esimerkissä funktioksi tulee In the above example the function becomes
\[ f(x) = f(x-1)+f(x-3)+f(x-4) \] \[ f(x) = f(x-1)+f(x-3)+f(x-4) \]
ja funktion ensimmäiset arvot ovat: and the first values of the function are:
\[ \[
\begin{array}{lcl} \begin{array}{lcl}
f(0) & = & 1 \\ f(0) & = & 1 \\
@ -347,9 +351,9 @@ f(9) & = & 40 \\
\end{array} \end{array}
\] \]
Seuraava koodi laskee funktion $f(x)$ arvon The following code calculates the value $f(x)$
dynaamisella ohjelmoinnilla täyttämällä taulukon using dynamic programming by filling the array
\texttt{d} rahamäärille $0 \ldots x$: \texttt{d} for parameters $0 \ldots x$:
\begin{lstlisting} \begin{lstlisting}
d[0] = 1; d[0] = 1;
@ -361,27 +365,29 @@ for (int i = 1; i <= x; i++) {
} }
\end{lstlisting} \end{lstlisting}
Usein ratkaisujen määrä on niin suuri, että sitä ei tarvitse Often the number of the solutions is so large
laskea kokonaan vaan riittää ilmoittaa vastaus that it is not required to calculate the exact number
modulo $m$, missä esimerkiksi $m=10^9+7$. but it is enough to give the answer modulo $m$
Tämä onnistuu muokkaamalla koodia niin, where, for example, $m=10^9+7$.
että kaikki laskutoimitukset lasketaan modulo $m$. This can be done by changing the code so that
Tässä tapauksessa riittää lisätä rivin all calculations will be done in modulo $m$.
\begin{lstlisting} In this case, it is enough to add the line
d[i] += d[i-c[j]];
\end{lstlisting}
jälkeen rivi
\begin{lstlisting} \begin{lstlisting}
d[i] %= m; d[i] %= m;
\end{lstlisting} \end{lstlisting}
after the line
\begin{lstlisting}
d[i] += d[i-c[j]];
\end{lstlisting}
Nyt olemme käyneet läpi kaikki dynaamisen Now we have covered all basic
ohjelmoinnin perusasiat. techniques related to
Dynaamista ohjelmointia voi soveltaa monilla dynamic programming.
tavoilla erilaisissa tilanteissa, Since dynamic programming can be used
minkä vuoksi tutustumme seuraavaksi in many different situations,
joukkoon tehtäviä, jotka esittelevät we will now go through a set of problems
dynaamisen ohjelmoinnin mahdollisuuksia. that show further examples how dynamic
programming can be used.
\section{Pisin nouseva alijono} \section{Pisin nouseva alijono}