InfoC adventi naptár
Dinamikus programozás
A laboron azt tapasztaltuk, hogy a rekurzióval működő Fibonacci számsor kiszámító program borzalmasan lassú: a negyvenedik szám előállításán már több másodpercet gondolkodik a gép. Ez azonban nem azt jelenti, hogy a rekurzív megvalósítás hibás, azt pedig főleg nem, hogy általában a rekurzió lassú lenne.
Tartalom
1 A Fibonacci számsor
Szedjük elő egy kicsit az említett programot! A rekurzív fib() függvény így
nézett ki:
#include <stdio.h>
int fib(int n) {
if (n<2)
return n;
else
return fib(n-1)+fib(n-2);
}
int main() {
int i;
for (i=0; i<45; ++i)
printf("fib(%d) = %d\n", i, fib(i));
return 0;
}
Ez a nagy számokra a program egyre lassabb lesz. Bár minden sorban csak az előző két kiírt szám összegét kellene kiszámolni és kiírni, mégis egyre többet kell várni a megjelenő számokra. Ennek egyik oka az, hogy a kiírt nagy számok tulajdonképpen nullák és egyek összegeként állnak elő. Vegyük észre: csak n=0 és n=1 esetén tér vissza a függvény konkrét értékkel, minden más érték számítás eredményeként jön ki:
#include <stdio.h>
void fibkiir(int n) {
if (n<2)
printf("%d", n);
else {
fibkiir(n-1);
printf("+");
fibkiir(n-2);
}
}
int main() {
fibkiir(10);
return 0;
}
Másik oka pedig – vagy ugyanez az ok, másképpen megközelítve –
az, hogy nem használja fel az algoritmus a már kiszámolt
részeredményeket. Amikor a kérdés fib(45), akkor
fib(44) és fib(43) újra kiszámolódik, hiába
történt meg ez a kettő éppen az előbb. Sőt fib(44)-hez
kell fib(43) is, úgyhogy a fib(43) értéke
is többször kérdés, sőt kisebb számokra még rosszabb a helyzet.
Vagyis az oszd meg és uralkodj módszerünk, miszerint a
fib(45) problémát egyszerűbb, fib(44) és
fib(43) részfeladatokra bontottuk, itt nem túl
hatékony. Mégpedig azért nem, mert a részfeladatoknak közös
részfeladatai is vannak, és ezt nem veszi figyelembe.
Mi itt a az ötlet? Megtehetjük azt például, hogy eltároljuk a már kiszámolt értékeket. Nem csak azért, mert esetleg újra kérdezhetik ugyanazt (és a számsor 44. tagja mindig ugyanannyi lesz), hanem mert a rekurzió során a függvény magától is mindig ugyanazokat a számokat kérdezi. A tároló itt lehet egyszerűen egy tömb, amely kezdetben 0 értékekkel van kitöltve. Természetesen ez nem lehet a függvény automatikus, lokális változója, hiszen akkor elveszne az értéke. Globális kell legyen, vagy ami a legjobb: egy statikus lokális.
#include <stdio.h>
#include <assert.h>
int fib(int n) {
/* statikus: megőrzi az értékét! */
static int fibszam[46] = {0};
/* ha triviális */
if (n<2)
return n;
/* a tároló véges, bár lehetne mallocolni */
assert(n<46);
/* amúgy már kiszámoltuk? ha nem, számoljuk ki! */
if (fibszam[n]==0)
fibszam[n] = fib(n-1)+fib(n-2); // eltárolódik!
/* adjuk vissza */
return fibszam[n];
}
int main() {
int i;
for (i=0; i<45; ++i)
printf("fib(%d) = %d\n", i, fib(i));
return 0;
}
A lényeg a jelölt sorban van: amit egyszer már kérdeztek a függvénytől, az arra kapott választ eltárolja a tömbben, és legközelebb már számítás nélkül visszatér vele. Látszik, hogy ez így ugyanolyan gyors, mint az iteratív megoldás.
A vázolt technikát dinamikus programozásnak nevezik (dynamic programming – memoization, tabulation). Ezt az „elágazó” problémáknál, ahol a részfeladatok közösek, gyakran alkalmazzák, mivel legtöbbször egyszerűbb a részfeladatok eredményeit egy táblázatban eltárolni, és onnan kikeresni, mintsem egy új, sokkal bonyolultabb algoritmust tervezni a megoldásra, amely más úton gondolkozik, és elkerüli a részfeladatok ismétlését.
2 Pénzváltás
Nézzük meg újra a rekurziós gyak pénzváltós feladatát is ilyen szempontból! Ott az volt a kérdés, hányféleképpen fizethetünk ki 100 forintot úgy, hogy 5, 10, 20, 50 forintosokat használunk. Ha egy kicsit átírjuk ezt, hogy 2000 forintot kelljen fizetni, 100-asokkal és 200-asokkal is, máris látjuk, hogy nagyon lassú a program:
#include <stdio.h>
#include <stdlib.h>
int penzvalto(int mennyit, int *fajtak, int hanyadiktol) {
if (mennyit==0)
return 1;
if (fajtak[hanyadiktol]==-1 || mennyit<0)
return 0;
return penzvalto(mennyit-fajtak[hanyadiktol], fajtak, hanyadiktol)
+ penzvalto(mennyit, fajtak, hanyadiktol+1);
}
int main() {
int fajtak[]={5, 10, 20, 50, 100, 200, -1};
printf("Összesen: %d lehetőség\n", penzvalto(2000, fajtak, 0));
return 0;
}
Ennek ugyanaz a gondja, mint az első, fibonaccis programnak. Egy csomóféle úton eljut oda, hogy már csak a maradék 100 forint a kérdés, és újra meg újra megoldja ugyanezt a részfeladatot. A válasz viszont mindig ugyanaz, így gyorsabb lenne eltárolni!
Tekintsük fixnek a használható pénzérméket: ilyenkor a függvénynek
csak két paramétere marad, hogy mennyit kell fizetni, és hogy a
használható érmék közül hányfélével dolgozhat még. A kiszámolt
válaszokat betehetnénk pl. egy láncolt listába, vagy valami más,
trükkösebb adatszerkezetbe is. Az egyszerűség kedvéért maradjunk egy
egyszerű tömbnél: a megoldasok[][] kétdimenziós tömb
tárolja azt, hogy mennyit pénzt hanyadiktol
fajtákkal hányféleképpen lehet kifizetni:
megoldasok[mennyit/5][hanyadiktol]. (Azért /5, mert
úgyis 5 forint a legkisebb egység.)
#include <stdio.h>
#include <stdlib.h>
int fajtak[]={5, 10, 20, 50, 100, 200};
int penzvalto(int mennyit, int hanyadiktol) {
/* megoldasok[mennyit/5][hanyadiktol] */
static int megoldasok[2000/5+1][6];
static int elso = 1;
/* triviális megoldások */
if (mennyit==0)
return 1;
if (hanyadiktol==6 || mennyit<0)
return 0;
/* első futtatáskor inicializáljuk a tömböt csupa -1-re,
* mert itt nem jó a 0-s kezdeti érték */
if (elso) {
int m, h;
for (m = 0; m<2000/5+1; m+=1)
for (h = 0; h<6; h+=1)
megoldasok[m][h] = -1;
elso = 0;
}
if (megoldasok[mennyit/5][hanyadiktol]==-1) // kiszámoltuk már ezt?
megoldasok[mennyit/5][hanyadiktol] =
penzvalto(mennyit-fajtak[hanyadiktol], hanyadiktol)
+ penzvalto(mennyit, hanyadiktol+1);
return megoldasok[mennyit/5][hanyadiktol];
}
int main() {
printf("Összesen: %d lehetőség\n", penzvalto(2000, 0));
return 0;
}
Látszik, hogy az átalakítás szinte triviális, és az eredeti algoritmus is változatlan formában szerepelhet a programban. Ez más algoritmusoknál sincs így: csak létre kell hozni egy (kérdés;válasz) elemeket tartozó táblázatot, és a számítás előtt mindig megnézni, hogy kiszámoltuk-e már valamikor azt, ami éppen most is a kérdés.
