9. gyakorlat: rekurzió
Rekurzív algoritmusoknál két dolgot kell meggondolnunk:
- A triviális eseteket, amelyeknél a megoldás magától értetődik. (Pl. üres sztring hossza nulla.)
- Nem triviális esetben azt, hogy hogyan tudjuk a kapott problémát visszavezetni egyszerűbb problémákra. (Pl. nem üres sztring hossza 1 + a sztring hossza a második betűtől kezdve.)
Az előbbi a báziskritérium, az utóbbi pedig az egyszerűsítési lépés a rekurzióban, amellyel minden rekurzív függvényhívásnál a báziskritérium felé haladunk. Keressük meg ezeket az alábbi problémáknál!
1 Alakzat kitöltése
Adott egy két dimenziós tömb, amelyik egy képet reprezentál. A képen egy adott színnel el van kerítve egy terület – vagyis meg van rajzolva egy alakzat. Színezzük ki ennek az alakzatnak a belsejét! Az alakzat lehet konkáv is, abban az esetben is be kell menni minden zugba!
char z4qqq[20][32] = {
" ",
" xx xx ",
" xxxx x xxx x xxxx ",
" xx x x x x xx ",
" xxx xx x x xx xxx ",
" xxx x x x x xxx ",
" x x x x x x ",
" xx xx xx xx ",
" x x ",
" x x ",
" x x ",
" x x ",
" x x ",
" xx xx xx xx ",
" x x x xx xx x x x ",
" xxx x x x x x x x x xxx ",
" xx x xxx xx xx xxx x xx ",
" xxx x x xxx ",
" xxx ",
" "
};
Megoldás
A rekurzió nélkül lehetetlennek tűnő feladat rekurzív megoldása végtelenül egyszerű. Az aktuális pontot kifestjük, és utána mind a négy irányba megpróbálunk menni. Ha valamerre sikerül, ott ugyanaz lesz a feladat: az aktuális pontot kifesteni, és mind a négy irányba megpróbálni menni. Ha a festés ilyen módon végül egy zsákutcában elakad, a függvények visszatérnek – és a próbálkozás megy tovább a másik három lehetséges irányba.
A kifestő működése. A videóban minden függvényhívás elején pirosra festi a program az adott helyet; és miután az összes irányból visszatértek a hívott függvények, csak akkor kékre. Ezen is, és a bejelölt útvonalon is látszik az, hogy melyek a befejezetlen, nem teljesen lefutott függvénytörzsek.
#include <stdio.h>
void kifest(char kep[20][32], int y, int x)
{
kep[y][x]='x';
if (y>0 && kep[y-1][x]!='x') kifest(kep, y-1, x);
if (y<19 && kep[y+1][x]!='x') kifest(kep, y+1, x);
if (x>0 && kep[y][x-1]!='x') kifest(kep, y, x-1);
if (x<30 && kep[y][x+1]!='x') kifest(kep, y, x+1);
}
int main()
{
/* IDE KELL BERAKNI A RAJZOT */
int y;
kifest(z4qqq, 10, 10);
for (y=0; y<20; ++y)
puts(z4qqq[y]);
return 0;
}
2 Rendezés közvetlen kiválasztással
Egy tömb rendezve: a legelejére rakjuk a legkisebb elemet, utána rendezzük a tömb fennmaradó részét. (Minden iteráció átírható rekurzióvá!)
Megoldás
#include <stdio.h>
void kozvetlen(double t[], int db) {
int j, minindex;
/* ha a tömbben nincs két elem, akkor csak rendezett lehet */
if (db < 2)
return;
minindex=0; /* minimum keresése */
for (j=1; j<db; ++j)
if (t[j]<t[minindex])
minindex=j;
if (minindex!=0) { /* csere? */
double temp=t[minindex];
t[minindex]=t[0]; /* csere. */
t[0]=temp;
}
/* a tömb fennmaradó részének rendezése */
kozvetlen(t+1, db-1);
}
int main()
{
double tomb[]={6, 9, 3, 7, 8, 5, 5.76};
int i;
kozvetlen(tomb, 7);
for (i=0; i<7; ++i)
printf("%g ", tomb[i]);
return 0;
}
3 Anagrammák
Írjuk ki egy szónak az összes anagrammáját! Pl. abc anagrammái: abc acb bac bca cab cba.
Megoldás
A megoldás lényege, hogy egy-egy betűpárt megcserél, és a fennmaradó részeket is permutálja. A start változóban azt kapja a függvény, hogy hányadik karaktertől kezdve kell kezdeni a permutálást. Ha ez megegyezik a sztring hosszával, akkor már nem permutál (hiszen már nincs mit), hanem egyszerűen kiírja az aktuális állapotot. Ez a báziskritérium, így lesz vége a rekurzió „mélységének” – a rekurziónál mindig kell lennie egy feltételnek, amikor többé már nem hívja meg magát a függvény. Minden hívásnál közelít efelé a bázis felé, hiszen mindig egyre rövidebb fennmaradó sztringrészletet permutál.
Tegyük fel, hogy kezdetben a sztring „abcd”. Ennek úgy néznek ki az anagrammái, hogy azok kezdődhetnek a-val, b-vel, c-vel és d-vel. Az a-val kezdődőek: „abcd”, „abdc”, „acbd”, … ezek végülis egy fix „a”, és utána a „bcd” sztring anagrammái, az összes lehetséges sorrendben. A b-vel kezdődőek: „bacd”, „badc”, „bcad”, …, vagyis egy „b” betű, és az „acd” sztring anagrammái. Itt van ebben a rekurzió: az „abcd” sztring anagrammái azok kezdődnek az „abcd” sztring valamelyik (egyik) karakterével, és utána fennmaradó karakterek (de már csak három betűs sztring) anagrammái kellenek.
A permutáló függvény ezt úgy valósítja meg, hogy a kapott sztring mindegyik karakterét berakja az első helyre (vagyis mindegyik karakterét megcseréli az elsővel), és utána képezi a fennmaradó rész anagrammáit. Ha nincs fennmaradó rész, akkor pedig kiírja az addigra már összekevert sztringet. Pl. a kiindulási sztring „abcd”, akkor:
- először az a[bcd]-ket írja ki (csere nélkül)
- utána meg cserékkel: b[acd] (i=1), c[abd] (i=2), d[abc] (i=3), és mindenhol a [] részre rekurzívan hívódik.
Az összes hívás az első mélységben így, ahol a [] jelenti a további rekurzív hívásokat:
- a[bcd] (nincs csere)
- b[acd] (a↔b)
- c[bad] (a↔c)
- d[bca] (a↔d)
A rekurzió itt azért is jó, hogy miután visszajöttünk a fennmaradó rész anagrammáinak képzéséből, akkor emlékezzünk rá, hogy melyik karaktereket kell visszacserélni (ez lokális változó, eltárolódik a veremben).
#include <stdio.h>
#include <string.h>
/* megcsereli az adott indexu karaktereket */
void betucserel(char* str, int a, int b)
{
char t=str[a];
str[a]=str[b];
str[b]=t;
}
void permutal(char* str, int start)
{
int hossz=strlen(str);
if (start==hossz-1)
printf("%s ", str); /* ha mar nincs mit permutalni */
else {
int i;
/* permutaljuk a string hatralevo reszet, tovabba minden
* karaktert megcserelunk a startadikkal, es ugy is
* permutalunk. az elso esetet, amikor i=start lenne, kulon
* is lehet hivni, mivel olyankor nem kell csere. */
permutal(str, start+1);
for (i=start+1; i<hossz; ++i) {
betucserel(str, start, i); /* csere */
permutal(str, start+1);
betucserel(str, start, i); /* visszacsere */
}
}
}
int main()
{
char s[]="abcd";
permutal(s, 0);
printf("\n");
return 0;
}
A fenti megoldás nem veszi figyelembe azt, ha egyforma betűk vannak a sztringben, és azokat is cseréli. Pl. az „abba” sztringnek többször is megjelennek ugyanazon permutációi. Ehhez a cserék előtt meg kell vizsgálnunk azt, hogy a cserélendő karakter szerepelt-e már a cserések között. Mert ha igen, akkor azt a lépést nem kell újra elvégezni, hanem ki kell hagyni:
permutal(str, start+1);
for (i=start+1; i<hossz; ++i) {
int k, kell_csere;
kell_csere=1;
for (k=start; k<i; ++k) /* volt mar ez a betu? */
if (str[k]==str[i])
kell_csere=0;
if (kell_csere) {
betucserel(str, start, i); /* csere */
permutal(str, start+1);
betucserel(str, start, i); /* visszacsere */
}
}
4 Pénzváltás I.
Adott egy zsák 5, 10, 20 és 50 forintos érménk. Hányféleképpen lehet ezekkel kifizetni 100 forintot?
Megoldás
A probléma könnyen megoldható rekurzívan. Tekintsünk pl. egy 20 forintost! Két lehetőségünk van: ha a 20 forintos része a megoldásnak (vagyis adunk egy 20-ast), akkor a maradék 100-20=80 forintot még valahányféleképpen (x) ki lehet fizetni. A másik lehetőség, ha azt mondjuk, hogy nem adunk 20-ast, és a 100 forint kifizetését a többi érmével oldjuk meg (y lehetőség). A megoldás ezek összege: x+y.
Így közeledünk a rekurzióban a báziskritérium felé: vagy az összeg csökken, vagy a felhasználható érmefajtákat csökkentjük. Figyelembe kell még vennünk három extrém esetet. Ezek lesznek a báziskritériumok:
- Ha 0 forintot kell kifizetnünk, azt egyféleképpen tehetjük: nem adunk pénzt.
- Ha 0-nál többet kell fizetnünk, de nincs semmilyen fajta érménk, akkor nulla lehetőség.
- Ha negatív összeget kell fizetnünk, azt sem tudjuk megtenni: nulla lehetőség.
Az utóbbi feltételt az algoritmus egyszerűségéhez használjuk ki. Jelentése: ha 5 forintot kell kifizetni, és megpróbáljuk egy 20-assal, akkor még -15 forintot kellene – de ez nem megoldás. (Lehetne az első rekurzív hívás előtt is ellenőrizni.)
#include <stdio.h>
#include <stdlib.h>
int penzvalto(int mennyit, int *fajtak, int hanyadiktol)
{
/* báziskritérium */
if (mennyit==0) // 1. pont
return 1;
if (fajtak[hanyadiktol]==-1 || mennyit<0) // 2, 3. pont
return 0;
/* egyszerűsítés */
return penzvalto(mennyit-fajtak[hanyadiktol], fajtak, hanyadiktol)
+ penzvalto(mennyit, fajtak, hanyadiktol+1);
}
int main()
{
int fajtak[]={5, 10, 20, 50, -1};
printf("Összesen: %d lehetőség\n", penzvalto(100, fajtak, 0));
return 0;
}
Az érmék névértékeit egy tömbbe tettük, amely egy lezáró -1-et is
tartalmaz (a római számos példákhoz hasonlóan). A függvény ezt
a tömböt kapja, és egy indexet, hogy hányadik elemtől kell a tömböt
figyelembe vegye. Az utolsó sorában ezt növeli, és úgy hívja meg magát
– így maradnak ki a tömb eleji érmék a rekurzív hívásban, és így fogy
el végül a tömb, amikor fajtak[hanyadiktol]==-1.
Ez megoldható lenne pointer aritmetikával is: fajtak+1
a tömb belsejébe mutat (a hátsó részére), és így fogyhat el a tömb.
5 Pénzváltás II.
A feladat ugyanaz, mint az előbb – csak úgy, hogy írjuk is ki a lehetőségeket, pl. 2 db 50 forintos, 10 db 10 forintos stb.
Megoldás
Ehhez azt kell észrevennünk, hogy a mennyit==0
báziskritérium jelenti azt, hogy megtaláltunk egy megoldást. Mire
oda eljutunk a rekurzióban, már valamilyen érmekombinációval megoldottuk
a feladatot. Ha útközben feljegyezzük az érmék számát egy segédtömbben,
akkor ezen a ponton kiírva a tömb tartalmát, meg tudjuk adni a megoldásokat.
(A második, harmadik báziskritérium olyan próbálkozást jelent, ami nem
vezetett megoldásra.) A darab[] segédtömbben egy adott indexű elem azt tárolja, hogy az
ugyanannyiadik indexű fajtak[] érméből mennyit adunk.
Kérdés még, hogy hol változik a tömb tartalma. A
mennyit-fajtak[hanyadiktol] paraméterű rekurzív hívásnál
próbálkozunk egy érme kiadásával. Ezért a rekurzív hívás előtt
a megfelelő darabszámot megnöveljük eggyel (hogy a hívásban a
darabszám tömb már megfelelő tartalmú legyen, és azt írjuk ki), és a hívás
után pedig csökkentjük.
#include <stdio.h>
#include <stdlib.h>
void penzvalto(int mennyit, int *fajtak, int hanyadiktol, int *darab)
{
if (mennyit==0) {
int i;
for (i=0; fajtak[i]!=-1; ++i)
if (darab[i]!=0)
printf("%dx(%d Ft), ", darab[i], fajtak[i]);
printf("\n");
return;
}
if (mennyit<0 || fajtak[hanyadiktol]==-1)
return;
darab[hanyadiktol]++;
penzvalto(mennyit-fajtak[hanyadiktol], fajtak, hanyadiktol, darab);
darab[hanyadiktol]--;
penzvalto(mennyit, fajtak, hanyadiktol+1, darab);
}
int main()
{
int fajtak[]={5, 10, 20, 50, -1};
int darab[]={0, 0, 0, 0};
penzvalto(100, fajtak, 0, darab);
return 0;
}
6 Zárójelek párjai
Írjunk programot, amelyik egy nyitó zárójellel kezdődő sztringben megtalálja a zárójel bezáró párját. (A zárójelek (egymásba () lehetnek)) ágyazva.
Megoldás
Írjunk először egy függvényt, amelyik egy sztringet kap paraméterként, és egy kezdő pozíciót. Térjen vissza a függvény azzal az indexszel, ahol először bezáró zárójelet talál. Ez könnyű, egyszerűen csak egy ciklust kell futtatnunk addig, amíg meg nem lesz a keresett karakter:
FÜGGVÉNY bezárót_keres(sztring, pozíció)
AMÍG sztring[pozíció] != ')'
pozíció=pozíció+1
CIKLUS VÉGE
VISSZA: pozíció
FÜGGVÉNY VÉGE
De ez még nem tudja a zárójelek egymásba ágyazását. Mit kell tenni, ha a bezárójel keresése közben egy nyitó zárójelet találunk? Akkor a következő bezárójel még nem az lesz, amit keresünk, hanem a mostani nyitónak a párja. Ez a belső zárójelpár közrefog egy sztringrészletet, amit át kéne ugranunk a keresés közben:
eredeti nyitó az elején ezt a bezárót keressük
↓ ↓
(A zárójelek (egymásba lehetnek) ) ágyazva.
↑ ↑
ezt a részt ki kell hagyni, mintha ott sem lenne
Hogy találjuk meg, hogy hol van ennek a nyitó zárójelnek a párja? Nagyon egyszerűen, hiszen már az előbb megírtuk azt a függvényt, ami ezt tudja! Ott van fent a pszeudokódja. Azt kiegészítve kapjuk a lenti függvényt. Az első utasítás a pozíció növelése; azzal az első nyitó zárójelet egyből átugorja.
FÜGGVÉNY bezárót_keres(sztring, pozíció)
pozíció=pozíció+1
CIKLUS AMÍG sztring[pozíció] != ')'
FELTÉTEL HA sztring[pozíció] == '('
pozíció = bezárót_keres(sztring, pozíció)
FELTÉTEL VÉGE
pozíció=pozíció+1
CIKLUS VÉGE
VISSZA: pozíció
FÜGGVÉNY VÉGE
#include <stdio.h>
/* Kap egy sztringet, es azon belul egy poziciot; ott egy nyito
zarojelnek kell lennie. Annak a nyito zarojelnek megkeresi
a bezaro parjat, es annak az indexet (poziciojat) adja vissza. */
int bezarot_keres(char *sztring, int pozicio)
{
/* elso nyitot egybol atugorjuk */
pozicio++;
/* es keressuk a bezaro parjat */
while (sztring[pozicio]!=')') {
/* ha kozben nyitot talalunk, akkor azt a reszt,
az azutan kovetkezo bezaroig, atugorjuk */
if (sztring[pozicio]=='(')
pozicio=bezarot_keres(sztring, pozicio);
pozicio++;
}
return pozicio;
}
int main()
{
char szoveg[]="(Ez egy (zarojeles) () sztring), aminek itt van vege a pontnal.";
int pozicio=bezarot_keres(szoveg, 0);
/* kiirjuk a keresett bezarotol kezdve */
printf("%s\n", szoveg+pozicio);
return 0;
}
Minden rekurzív problémának létezik iteratív megoldása. Ennek például nagyon egyszerű. Ha a keresés közben találunk egy nyitó zárójelet, akkor eggyel több bezáró zárójelig kell futtatni a keresést:
A (zárójelek (egymásba () lehetnek) ) ágyazva. 0011111111111222222222233222222222211000000000
#include <stdio.h>
/* Az elozo problema iterativ megoldasat adja ez a fuggveny. */
int bezarot_keres(char *sztring, int pozicio)
{
/* most elvileg egy nyito zarojelen allunk;
* vagyis egy bezaro zarojelet keresunk. */
int hany_kell=1;
while (hany_kell>0) {
/* nezzuk a kovetkezo karaktert */
pozicio++;
if (sztring[pozicio]=='(')
hany_kell++;
if (sztring[pozicio]==')')
hany_kell--;
}
return pozicio;
}
int main()
{
char szoveg[]="(Ez egy (zarojeles) () sztring), aminek itt van vege a pontnal.";
int pozicio=bezarot_keres(szoveg, 0);
/* kiirjuk a keresett bezarotol kezdve */
printf("%s\n", szoveg+pozicio);
return 0;
}
