Tömbök algoritmusai. Rekurzió

1 Halálfejes hibák I. – új nyelvi elemek

A nagy ZH-n, vizsgákon halálfejes hibának nevezzük azokat a hibákat, amik leginkább arról tanúskodnak, hogy az elkövetőjük soha gépen C programot még nem írt. Eléggé meglehetős pontlevonások járnak értük.


include [stdio.h]

world main {
   var $szam, $szorzat;
   
   repeat {
      scanf $szam
      $szam=$szam · $szorzat
   } until (szam<>0)

   printf $szorzat;
}
/* fv-ben fv */
int hányprím(int meddig) {
   int prím(int szám) {
      …
   …

/* „amíg minden elem -1” */
while (for (i=0; i<10; ++i)
         tömb[i]==-1; ) {

/* beolvasás és összegzés */   
scanf("%d", &+sum);

2 Halálfejes hibák II. – sorminta, tömb, fv

/* tíz szám visszafelé */
int a, b, c, d, …;

scanf("%d", &a);
  –||– ("%d", &b);
         –||–    c);
…
printf("%d", c);
  –||–   ("%d", b);
  –||–   ("%d", a);
/* triviális túlindexelés */
int tomb[10];
for (i=1; i<=1000; ++i)
   tomb[i]=1;

/* indefinit méretű tömb */
char s1[];
gets(s1);
char *s2;
gets(s2);

Pl. „térjen vissza a függvény IGAZ-zal, ha páros, HAMIS-sal, ha páratlan a paraméterként kapott szám”

/* felesleges, nem kért input/output művelet */
void fv() {
   int szám;
   scanf("%d", &szám);
   if (szám%2==0) printf("páros");
   else printf("páratlan");
}

3 Spacetelenítőverseny-eredményhirdetés

/* Harsányi János - 49 */
void j(char*s){char*p=s;while(*p=*s)p+=*s++!=32;}

/* Seres Márk Dániel - 46 */
m(char*c){for(int i=0;*c=c[i];*c-32?c++:i++);}

/* Szabó Antal - 46 */
a(char*s){for(char*t=s;*t;(*t=*s++)-32&&t++);}

/* Széll András - 45 */
a(char*s){for(char*k=s;*s=*k;*k++==32?:s++);} // !

/* Marussy Kristóf - 44 */
k(char*a){for(char*b=a;*b=*a++;)*b^32&&++b;}

/* Czirkos Zoltán - 43 */
r(char*s){for(char*p=s;*p=*s;p+=*s++!=32);}

/* Kővágó Zoltán - 42 */
x;z(char*b){for(x=0;b[x]=*b;x-=*b++==32);} // !

Tömbi algoritmusok: keresések


„Tapasztalt C programozó – nagyjából azt jelenti, hogy tömbkezelésből teljesen profi.” – Marshall Cline

5 Keresések általában

Keresés táblázatban – kérdéseink

„Adatbázisok”
tárgy

Generikus algoritmusok

6 „Van-e?” – lineáris keresés

int van_e(double *szamok, int meret, double keresett) {
   int i, van_talalat;
   
   van_talalat=0;
   i=0;
   while (i<meret && !van_talalat) {
      if (szamok[i]==keresett)
         van_talalat=1;
      ++i;
   }
   
   return van_talalat; // miért állt meg a ciklus?
}

A van-e függvényt egyéb formákban is lekódolhatjuk. Ezek mind ugyanolyan jól működnek, mint a fenti változat.

for() összetett feltétellel

van_talalat=0;
for (i=0; i<meret && !van_talalat; ++i)
   if (szamok[i]==keresett)
      van_talalat=1;

return van_talalat;

A C bármilyen kifejezést enged a for() ciklus fejlécében, így ott egy összetett kifejezés is lehet.

Összetett feltétel v2.0

for (i=0; i<meret && szamok[i]!=keresett; ++i)
   ;

return i<meret;

Ez ugyanaz, mint a fenti, csak a van_talalat változó lett benne megspórolva. A ciklus két dolog miatt is megállhat; azért, mert elérjük a tömb végét, vagy azért, mert megtaláltuk a keresett elemet. A visszatérésnél ellenőrizni kell, hogy melyik történt a kettő közül. Ezt a tömbindex vizsgálatával lehet megtenni. (A return szamok[i]==keresett parancs rossz ötlet, hiszen lehet, hogy a tömb vége miatt állt meg; olyankor i==meret, vagyis szamok[i] túlindexelés lenne!)


A keresést megszakítva

for (i=0; i<meret; ++i)
   if (szamok[i]==keresett)
      return 1;

return 0;

Mivel a C-ben szabad a függvény belsejében is visszatérni, egy találat esetén azonnal visszatérünk igaz válasszal a „van-e” kérdésre.

for (i=0; i<meret; ++i)
   if (szamok[i]==keresett)
      break;

return i<meret;

Így is lehetne. Megszakítjuk a kereső ciklust, ha megtaláltuk az elemet. Utána kell egy feltétel, ami igazra értékelődik ki, ha megtaláltuk az elemet – ehhez figyelni kell azt, hogy az i<meret miatt állt meg.

7 Lineáris keresés: hol van?

int hol_van(double *szamok, int meret, double mit)
{
   int i;
   
   for (i=0; i<meret; ++i)
      if (szamok[i]==mit)
         return i;

   return -1;
}

A nincs találatra a -1-et és a tömb méretét is elterjedten használják. Mindkettő a tömb indexeinek tartományán kívül esik (mivel az 0…meret-1), ezért mindkettő egyformán jó lehet. A -1 előnye, hogy szembetűnőbb, a méret előnye, hogy nem negatív szám (mivel a tömb mérete amúgy sem lehet negatív, így elvileg a méreteket, indexeket tárolhatnánk előjel nélküli változókban is).


A találatra mutató pointerrel visszatéri változat:

double *keres(double *szamok, int meret, double mit)
{
   int i;
   
   for (i=0; i<meret; ++i)
      if (szamok[i]==mit)
         return szamok+i;  // &szamok[i]

   return NULL;
}

A függvény a megtalált elem pointerével tér vissza, ha nincs, akkor NULL pointerrel. Ehhez hasonlóan viselkednek a könyvtári strchr(), strstr() stb. függvények.

Pointer aritmetikát használó változat:

double *keres(double *szamok, int meret, double mit)
{
   double *iter;
   
   iter=szamok;
   while (iter!=szamok+meret) { // utolsó utáni
      if (*iter==mit)
         return iter;
      ++iter;                   // pointer lép a következőre
   }

   return NULL;
}

Ebben a pointert a tömb elejére állítjuk: iter=szamok. Utána addig megyünk, amíg nem az utolsó utáni: iter!=szamok+meret. Itt *iter a pointer által mutatott, a vizsgált érték, a ++iter kifejezés pedig a következő elemre lépteti a pointert.

8 A strázsa technika (sentinel)

Niklaus Wirth
találmánya
tomb[elemek]=keresett; // szándékos!
i=0;
while (tomb[i]!=keresett)
   ++i;
if (i!=elemek)
   return i;  // meglett!
else
   return -1; // a strázsát találtuk meg

Lényege: betesszük a tömb végére a keresett elemet.


2 5 7 3 8 6 9 4 8 7 ?
int tomb[10+1]={2, 5, 7, 3, 8, 6, 9, 4, 8, 7}; // +1!
hely=strazsakeres(tomb, 10);

A strázsás keresés egy kicsit gyorsabb, mert tömbelemenként csak egy vizsgálatot kell végezni – arra nem kell figyelni, hogy elértük-e a ciklussal a tömb végét. Mivel a végére betettük a keresett elemet, ezért a ciklus feltétele előbb-utóbb hamis lesz, legkésőbb a strázsánál.

Csak akkor használható, ha

  • tudjuk, hogy van még hely a tömbben (legalább eggyel nagyobb)
  • szabad módosítani a tömböt.

9 Bináris keresés – legyünk okosabbak!

Ha rendezett a tömb…


int binkeres(double *t, int db, double mit) {
   int min=0, max=db-1, // határok
       kozep=(min+max)/2;

   /* amíg nincs meg és el nem fogy a vizsgálandó rész */
   while (t[kozep]!=mit && min<=max) {
      if (t[kozep]<mit)
         min=kozep+1;   // középtől jobbra
      else
         max=kozep-1;   // középtől balra
      kozep=(min+max)/2;
   }

   /* miért állt meg a ciklus? megtaláltuk vagy nem? */
   return t[kozep]==mit ? kozep : -1;
}

10 A keresések időigénye

Lineáris keresés

O(n)

Bináris keresés

O(log2n)

Algoritmusok hatékonysága általában

O(1)konstans:D
O(log n)logaritmikus:)
O(n)lineáris:|
O(n2)négyzetes:(
O(en)exponenciális:C
Algoritmuselmélet
tárgyból lesz

Az O(n) jelöléssel szoktuk leírni azt, hogy egy algoritmus mennyi idő alatt végzi el a feladatát – egészen pontosan azt, hogy az elvégzéshez szükséges idő hogyan függ a bemeneti adatok számától. Ebben a jelölésben általában a konstans szorzókat el szoktuk hanyagolni; O(2n) helyett egyszerűen O(n)-t írunk, hiszen a függés jellegén azok nem változtatnak.

A konstans szorzók amiatt sem számítanak, mert csak az egyes függvénytípusok „erőssége” döntő nagy n-ek esetén. Az n2 „bikább”, mint az n, mivel még 10000n esetén is lehet olyan n-et találni, amelyre n2>10000n. Ilyen értelemben az en a „legbikább” függvény, hiszen megfelelően nagy n esetén az nagyobb bármilyen hatványfüggvénynél (pl. n1000). Ez azt jelenti, hogy az tart leggyorsabban a végtelenhez, míg a logaritmus függvény értéke pedig mindegyik közül a leglassabban.


Exponenciális növekedés: példa

Titkosítás: ha a jelszó (titkosítás kulcsa) 128 bites, a végigpróbálandó lehetőségek száma: 2128=340282366920938463463374607431768211456 darab.

Emiatt részesítjük előnyben az olyan algoritmusokat, amelyek O(1), O(log n) vagy O(n) időben futnak. Az O(n2) nagy n-ek esetén már lassúnak lehet; O(en) pedig valószínűleg lassú már kis n-ek esetén is. Éppen erre épülnek a titkosítások: ha jó az algoritmus, akkor a megfejtés csak próbálkozásra épülhet – ami viszont viszonylag kis kulcs esetén is reménytelenül hosszú ideig tart.

Rendezések



12 Rendezések, helyben rendezés

Rendezett tömb

A „helyben rendezés” fogalma


1 2 3 4 9 5 7 8 6

A rendezések működése

13 Buborékrendezés (bubble sort)



Lényege: egymás melletti elemek összehasonlítása és cseréje.

Egy sor csere által a legnagyobb elem a végére kerül.

A buborékrendezés egymás melletti elemeket hasonlít össze. Lépései:

  • Hasonlítsuk össze az első két elemet. Ha nincsenek jó sorrendben, cseréljük meg.
  • Hasonlítsuk össze a második párt (második és harmadik elem). Esetleg csere.
  • Folytassuk így a tömb végéig.
  • A legnagyobb elem ezáltal a tömb végére kerül, még akkor is, ha legelöl volt. Az már a végleges helye.
  • Csináljuk meg ugyanezt még egyszer, a tömb elejétől az utolsó előttiig. Az utolsóhoz már nem kell nyúlni, hiszen az a legnagyobb.
  • Aztán ugyanezt megint, de az utolsó kettőhöz már nem nyúlunk stb.

Futás közben így a tömb két részre oszlik: egy már rendezett és egy még rendezetlen részletre. A rendezetlen részlet egyre csökken; azon belül kell összehasonlítani és esetleg cserélni a párokat. Ezért az algoritmust két ciklust tartalmaz. A külső ciklus az egyre kisebb rendezetlen részt határozza meg; a belsejében lévő pedig az egymás melletti párok összehasonlítását vezérli.

void buborek(double t[], int db) {
   int i, j;

   /* egyre rövidebb tömbrészletek ciklusa */
   for (i=db-1; i>0; --i)
      /* egymás utáni párok ciklusa */
      for (j=0; j<i; ++j)
         if (t[j+1]<t[j]) {      // összehasonlítás
            double temp=t[j];    
            t[j]=t[j+1];         // csere
            t[j+1]=temp;
         }
}

14 Buborékrendezés – variációk

Keverő rendezés (cocktail sort)

nyulak (rabbits)
nagy értékű elemek, amelyek a hamar a helyükre kerülnek
teknősök (turtles)
kicsi értékűek, amelyek lassan vándorolnak a tömb elejére

Ötlet: a rendezést felváltva egyik-másik irányba végezzük.


Javított buborékrendezés (improved bubble sort)

Figyeli, hogy egy fésülés során volt-e csere. Ha nem, leállítható a rendezés.

15 Közvetlen kiválasztással (selection sort)



Lényege: megkeresi a rendezetlen tömbrészlet legkisebb elemét, és az elejére rakja.

Ezt az algoritmust szélsőértékkeresős, vagy minimumkeresős rendezésnek is szokták nevezni. A működéséhez a buborék algoritmusnál tett megfigyelés adja az ötletet: ott a belső ciklus minden futása után a legnagyobb elem a rendezetlen részlet végére került, és ezáltal a rendezett rész elejére. Az ötlet lényege, hogy ne cserékkel toljuk el odáig a legnagyobb elemet, hanem inkább keressük meg a tömbben azt, és végezzük el egy lépésben a cserét. Vagyis tegyük egyből a helyére a kérdéses elemet. Itt a legkisebb elemekkel történik ez.

A közvetlen kiválasztásos algoritmus előnye a buborékrendezéshez képest, hogy jóval kevesebb cserét csinál a tömbben. Itt mindegyik tömbelem egy lépésben a helyére kerül, vagyis legrosszabb esetben is a cserék száma db-1, ahol db a tömb mérete.

void kozvetlen(double t[], int db) {
   int i, j, minindex;

   for (i=0; i<db-1; ++i) {
      minindex=i;                // minimum keresése
      for (j=i+1; j<db; ++j)
         if (t[j]<t[minindex])
            minindex=j;
      if (minindex!=i) {         // csere?
         double temp=t[minindex];
         t[minindex]=t[i];       // csere.
         t[i]=temp;
      }
   }
}

A fenti megvalósítás nem a legnagyobbat, hanem a legkisebb elemet keresi meg a tömbből, és azt rakja az elejére, ahogyan az előbbi dián látható animáció is. A kód szerkezete hasonló az előzőéhez, itt is ciklusban ciklus kell. A külső ciklus i változója éppen azt az indexet tárolja mindig, amelyik helyre az odavaló elemet keressük. Első futásnál ez 0, vagyis az egész tömb (t[0]…t[db-1]) legkisebb elemét keresi meg a j-s, belső ciklus. A keresés után a legkisebbnek talált elem ide kerül, és később már nem is mozdul el innen.

16 Rendezések hatékonysága – cserék száma

Buborék


Közvetlen kiválasztás


A fenti animáció kicsit csal, nem túl igazságos… Ugyanis az összehasonlítások idejére nem figyel, hanem csak a helycseréket animálja. Ugyanakkor a lényeg látszik: a buborékrendezés nagyon sok ideig bíbelődik a cserékkel, míg a közvetlen kiválasztásos módszer hamarabb végez a tömbbel.

Rendezések hatékonysága cserék alapján, n elemű tömbre
rendezésösszehasonlításcserék
maxátlagminmaxátlagmin
javított buborék n2n2nn2n20
közvetlen kiválasztásn2n2n2nn0
gyorsrendezés n2n·lognn·lognn2n·logn0
kupacrendezés n·lognn·lognn·lognn·lognn·lognn·logn

Speciális esetek?

  • Pont fordítva van az egész
  • Csak néhány van rossz helyen
  • Csak egy van rossz helyen: ilyenkor pl. a buborék sokkal jobb, mint a kiválasztásos

17 Az indexelő tömbök és használatuk

Ötlet: egy indexelő tömböt rendezzünk, ne az eredetit!

 

Így bármilyen nagy a tömbelem, ugyanolyan gyorsak a cserék!

Ember nevsor[100];
int index[100];   // ugyanakkora

printf("%s", nevsor[index[5]].nev);
/* nevsor[nev_index[i]]             → név szerinti sorrend */
/* nevsor[szuletesi_datum_index[i]] → dátum szerinti sorrend */

Ha nem az eredeti tömböt rendezzük, hanem az indexelő tömböt, akkor sokkal gyorsabbak a rendezések, és ezáltal a csere is. További előny, hogy egyszerre akár többféle rendezettséget is fenntarthatunk, mert több indexelő tömbünk is lehet!

18 Kertitörpe-rendezés (gnome sort)



Lényege: ha az egymás mellettiek jó sorrendben vannak, léphetünk egyet előre. Ha rossz sorrendben, akkor csere.

Ha a csere által rossz sorrend keletkezik, az csak a csere előtt lehet, ezért visszafelé kell lépni egyet.

void torperendez(double t[], int db) {
    int i;
    
    i = 0;
    while (i < db) {
        if (i == 0 || t[i-1] <= t[i]) // jó sorrend?
            i++;                /* előre */
        else {
            double tmp = t[i];  /* csere */
            t[i] = t[i-1];
            t[i-1] = tmp;
            i--;                /* vissza */
        }
    }
}

Rekurzió


Az elv, hogy meg lehet hívni egy függvényből egy másikat, rögtön felveti a kérdést: vajon saját magát is?

Pl. mi lenne, ha egy faktoriálist számító masina így nézne ki belülről:

     ┌
     │ 1,        ha n=0
n! = ┤
     │ n·(n-1)!, ha n>0
     └

Ez helyes, hiszen ha N=0, a faktoriálisa 1, ha nem 0, akkor pedig N faktoriálisa N szorozva N-1 faktoriálisával.

Ha beteszünk a nagy faktoriális gépbe egy miniatűr faktoriális gépet (működjön az bárhogy is), egy jó megoldást kapunk.

20 Függvényhívás még egyszer: a verem (stack)

A verem nevű memóriaterületre kerülnek függvényhíváskor a paraméterek és a visszatérés adatai. Ide kerülnek a lokális változók is.


Ezt a memóriaterületet azért nevezik veremnek, mivel ugyanúgy telik meg, mint egy verem (gödör). Amit legutoljára betettünk, azt látjuk legfelül, és kivenni is azt tudjuk legelőször. (Érdekesség: a vermet is Alan Turing találta ki. Amikor egy értéket az általa tervezett gép betett a verembe, azt a műveletet BURY-nek, azaz eltemetésnek nevezte. A kivétel pedig az UNBURY, vagyis a kiásás.)

A paramétereket a hívó programrész helyezi el a verembe. A visszatérési értéknek a helyét is a hívó foglalja le. Ezért a függvényhívás után ennek a dolga ezeket a területeket felszabadítani is. A hívott függvény foglal helyet a saját lokális változói számára, és így ennek a dolga az is, hogy felszabadítsa azt. Mindezzel nekünk semmi dolgunk nincsen, a fordító hozza létre ezeket a programrészeket a háttérben. Ezért ezeket automatikus kezelésű változóknak is szokták nevezni.

Minden függvényhíváskor létrejön tehát egy rész a veremben, amely az adott híváshoz tartozik, és visszatéréskor megszűnik. Ennek neve: keret (stack frame). Ha a függvényből egy másik függvényt is meghívunk, akkor egy ahhoz tartozó keret is létrejön a veremben – mindig legfelül, természetesen.

Alább egy függvényhívás látható, a hozzá kialakuló veremszerkezettel.

int fakti(int mie) { 
   int szorzat, i;
   szorzat=1;    
   for (i=2; i<=mie; i+=1)    
      szorzat*=i;    
   return szorzat;    
}

int main() {
   int x=fakti(5);
   printf("%d", x);
   
   return 0;
}

A függvényhívás előtt a következő történik:

  • A hívó main() beteszi a verembe a paramétereket.
  • Helyet csinál a visszatérési értéknek is.
  • Meghívja a függvényt, ami által bekerül a verembe a visszatérés címe (vagyis hogy hol kell folytatni a programot a függvényből visszatérvén).

A fakti() függvényben a működés:

  • Létrehozza magának a lokális változókat a veremben.
  • A paramétereit a veremben találja.
  • A visszatérési értéket a verembe teszi, a megfelelő memóriaterület felülírásával.
  • Amikor visszatér, akkor a hívóhoz ugrik vissza – a cím a veremben.

A függvényhívás után a hívó:

  • A veremben megtalálja a visszatérési értéket. Ezt felhasználja, ha szeretné.
  • Kitörli a veremből az általa betett függvényparamétereket, hiszen azokra már nincsen szükség.

21 Faktoriális rekurzív függvénnyel

Rekurzív függvény az, amely meghívja saját magát.


     ┌
     │ 1,        ha n=0
n! = ┤
     │ n·(n-1)!, ha n>0
     └

int fakt(int n)
{
   if (n==0)
      return 1;
   else
      return n*fakt(n-1);
}

Ezt a működést a verem teszi lehetővé!

22 Faktoriális: a függvényhívás menete

int fakt(int n) { 
   if (n<=1)   
      return 1;   
   else 
      return n*    
              fakt(n-1);   
} 
 
int main() { 
   int eredm; 
   
   eredm=fakt(3);   
   printf("3!=%d", eredm);   
   return 0;   
}
main()
eredm: 

A lokális változók csak addig léteznek, amíg a faktoriálist számoló függvény belsejében van a végrehajtás. Amint visszatér abból a main()-be, azok megszűnnek.

Gondoljunk bele: most használjuk ki igazán, hogy a függvény után a gép onnan folytatja a végrehajtást, ahonnan meg lett hívva! Ha ez sok függvényhívással odébb volt, akkor is. Ha sok rekurzív függvényhívással beljebb (lejjebb) volt, akkor is! Ezért mindig tudja a gép, hogy épp a fakt(n-1) kiszámítása ért véget, és visszaugrik abba a példányba, ahol a fakt(n) kiszámítása folyik.

23 Klasszikus rekurzió példa: Fibonacci

Leállási feltétel



     ┌
     │ n, ha n<2   
F(n) ┤
     │ F(n-2)+F(n-1) amúgy
     └

int fib(int n)
{
  if (n<2)  // báziskritérium
    return n;
  else
    return fib(n-2)+fib(n-1);
}

laboron még
lesz róla szó

Ez nem túl hatékony megvalósítás. Vegyük észre a rajzon: pl. a fib(2) értékét többször is kiszámoljuk.

24 A rekurzív hívás helye a függvényben

void elore(char *sztring) {
   if (*sztring=='\0')
      return;
   putchar(*sztring);
   elore(sztring+1);
}
előre("InfoC") // InfoC
   putchar('I')
   előre("nfoC")
      putchar('n')
      előre("foC")
         putchar('f')
         előre("oC")
            putchar('o')
            előre("C")
               putchar('C')
               előre("")
                  /* semmi */
void hatra(char *sztring) {
   if (*sztring=='\0')
      return;
   hatra(sztring+1);
   putchar(*sztring);
}
hátra("InfoC") // CofnI
   hátra("nfoC")
      hátra("foC")
         hátra("oC")
            hátra("C")
               hátra("")
                  /* semmi */
               putchar('C')
            putchar('o')
         putchar('f')
      putchar('n')
   putchar('I')

Természetesen az előre függvény a sztringet előrefelé, a hátra pedig a sztringet hátrafelé, vagyis megfordítva írja ki. Ez mindössze két utasítás felcserélésén múlik. Az előre függvény ugyanis kiírja az első karaktert, utána pedig a sztring többi részét. A hátra függvény ezzel szemben kiírja a sztring többi részét, utána pedig az első karaktert – de mivel a „többi részét” is ugyanilyen módon jeleníti meg, ezért az a „többi rész” is fordítva lesz.

A rekurzív hívás során az átadott sztring viszont mindkét esetben ugyanaz lépésenként, hiszen az eredeti sztring nem fordul meg! Ez látható a lenti táblázatban, amely azt mutatja be, hogy mi történik a rekurzív hívás előtt és után az egyes esetekben.

A függvények működése az "InfoC" sztringen
előrehátra
hívás előttputchar('I')-
rekurzív híváselore("nfoC")hatra("nfoC")
hívás után-putchar('I')

Érdemes ezt kipróbálni nyomkövetőben!

Tömböket (és később: listákat) C-ben ciklusokkal dolgozunk fel, hiszen az a természetesen adódó eszköz erre a feladatra. Az itt bemutatott rekurzív sztringfeldolgozás célja kizárólag az, hogy a rekurzió működésére rávilágítson, és mindehhez egyszerű példát adjon. A sztring előre- és hátrafelé történő kiírása olyan egyszerű, magától értetődő iteratív feladat, amelyet C-ben rekurzív módon megvalósítani pazarlás (a sok függvényhívás mind időbe telik).

25 Iterációval vagy rekurzióval?

void kiir(char *sztring) {
   while (*sztring!='\0') {
      putchar(*sztring);
      sztring+=1;
   }
}

iteratív

void kiir(char *sztring) {
   if (*sztring=='\0')
      return;
   putchar(*sztring);
   kiir(sztring+1);
}

rekurzív

int fib(int n) {
   int eloz=1, f=0, kov, i;
   for (i=0; i<n; ++i) {
      kov=f+eloz;
      eloz=f; f=kov;
   }
   return f;
}

iteratív

int fib(int n) {
   if (n<2)
      return n;
   else
      return fib(n-2)
             +fib(n-1);
}

rekurzív

Némely rekurzív függvények egészen egyszerűen átírhatók ciklusra. Ugyanis ha a rekurzív hívás után már nem csinál semmit a függvény, hanem egyből visszatér, az végeredményben csak egy ciklus. Az kiir() pont ilyen, ún. jobbrekurzív függvény. Itt az átalakítás a másik irányban látható; iteratív függvényből rekurzív lett.

Tétel: minden rekurzív probléma megoldható iteratívan is, és minden iteráció átalakítható rekurzióvá.

Mikor használjuk a rekurziót?

  • Sokszor egyszerű és szemléletes a rekurzív megoldás, pl. fib(n)
    • Egyszerűbb a helyességét is bizonyítani… pl. fib(n)
    • De nem biztos, hogy a leghatékonyabb… pl. fib(n)
  • Nem érdemes indokolatlanul használni ciklusok helyett
  • Leginkább rekurzív jellegű problémák esetén
    • Pl. 5+2*3 kifejezés értelmezése
  • Rekurzív adatszerkezetek esetén (erről később lesz szó)

26 Hanoi tornyai játék

Szabályok. Rakjuk át a korongokat az elsőről a harmadik oszlopra!
Egyszerre csak egyet, és kicsire nagyot nem tehetünk.

Ötlet. Rakjunk félre n-1 korongot… Akkor az alsó korong mozgatható!
Na de arról volt szó, hogy egyszerre csak egy korong mozoghat…

27 Hanoi tornyai – megoldás C-ben

#include <stdio.h>

void hanoi(int n, char honnan, char seged, char hova) {
   if (n==0) // ha nincs tennivaló
      return;

   hanoi(n-1, honnan, hova, seged); // varázslat
   printf("rakj 1-et: %c->%c\n", honnan, hova);
   hanoi(n-1, seged, honnan, hova);
}

int main() {
   hanoi(4, 'A', 'B', 'C');

   return 0;
}

Mit jelent a varázslat? Hogy n-1 korongot helyezünk át; erre viszont az algoritmus saját magát használhatja. Innen jön a rekurzió. Vagyis ha szeretnénk honnan, hova pakolni a korongokat a seged oszlop használatával: A lépések:

  1. Varázsoljunk n-1 korongot a kiindulási (honnan) oszlopról a segédoszlopra. Eközben a cél, „hova” oszlop lehet az ideiglenes tároló.
  2. Ha ezt megoldottuk, akkor a legalsó korongot csak át kell rakni.
  3. És az átrakott legalsó, legnagyobb korongra a félretett n-1 korongot varázsoljuk. Vagyis a segédoszlopról (mert oda tettük őket félre) a céloszlopra (végleges helyükre), közben a kiindulási oszlop (honnan) lehet az ideiglenes tároló.

A rekurzió tervezésénél a következő két dolgot kell végiggondolni:

  • Melyik az a legegyszerűbb eset, amelynél a megoldás egyértelmű?
    Jelen esetben: egy korong mozgatása.
  • Ha bonyolultabb, hogyan lehet visszavezetni az előbbire egyszerűsítésekkel?
  • Jelen esetben: n-1 korong mozgatása, egy korong mozgatása, n-1 korong mozgatása.

28 Gyorsrendezés



Feltalálója:
Tony Hoare

Lényege: egy elemet vezérelemnek választva két részre osztjuk a tömböt: annál kisebbekre és nagyobbakra.
Ezután az így keletkező két részt rendezzük.

A gyorsrendezés az „oszd meg és uralkodj” elven működik. Lépései a következők:

  • Kiválasztunk a tömbből egy tetszőleges elemeket. Ez lesz az ún. vezérelem (pivot).
  • Az ennél kisebbeket a tömb elejére, az ennél nagyobbakat a tömb végére rendezzük. (Ez a sima szétválogatási feladat. A vezérelemmel megegyező elemek mehetnek bármelyik oldalra.)
  • Ezután az így keletkező két tömbrészletet külön rendezzük, az algoritmus rekurzív hívásával.

Érdemes megfigyelni a következőt: ha a vezérelem elé rendeztük a kisebbeket, mögé a nagyobbakat, az azt jelenti, hogy a vezérelem már a végleges helyére került! Ugyanis ha nála kisebből van emennyi (előtte), nála nagyobból meg amannyi (utána), akkor ezek a számok egyben a vezérelem helyét is meghatározzák. Hiszen ezek nem fognak már változni.

Az algoritmus hatékonysága azon múlik, hogy sikerül-e jó vezérelemet választani. Akkor lehet minden lépésben a kisebbekre és nagyobbakra szedett tömbrészeket egyenlő nagyságúvá tenni (vagyis felezni a tömböt), ha a vezérelem éppen a tömb mediánja, azaz a rendezett tömb középső eleme. Sajnos a mediánt nem tudjuk megmondani, hiszen ahhoz rendezve kellene legyen a tömb… Ezért leginkább azt szokták csinálni, hogy találomra választanak egyet, akár éppen az elsőt vezérelemnek, és kész. Ez persze nem optimális. Pár „start” gomb klikkelés után ez látszik is, ha kijön egy olyan tömb, ahol 1 vagy 9 az első elem. Ilyenkor az első körben szinte semmi nem történik. Emiatt van az, hogy bár átlagos esetben ez az algoritmus O(log n) időben tud teljesíteni, de legrosszabb esetben ugyanúgy O(n2) időben fut le, mint az előbbiek. Olyankor ugyanis pont a buborékrendezést kapjuk vissza.

Érdekesség: jól optimalizálható az algoritmus, ha számokat kell rendezni. Mivel minden összehasonlítás a vezérelemet vizsgálja, azt körönként csak egyszer kell kiolvasni a memóriából. (Ez persze a C kódban nem látszik, csak a fordító által optimalizáltban.) C. A. R. Hoare angol programozó, matematikus. Legismertebb eredménye a gyorsrendezés algoritmusa, amelyet 26 évesen dolgozott ki.

29 Szétválogatás – kékek előre, pirosak hátra



Lényege: a tömb két végéről indulnak indexek.
Megkeressük balról az első pirosat, jobbról kéket, és megcseréljük őket.
Ezt folytatjuk, amíg a két index nem találkozik.

Az algoritmus működésének a lényege:

  1. Indítunk két indexet, egyiket a tömb elejéről (bal), másikat a végéről (jobb).
  2. Balt addig növeljük, amíg kék golyóra mutat. Így találunk egy piros golyót.
  3. Jobbat addig csökkentjük, amíg piros golyóra mutat. Így
  4. Megcseréljük a talált pirosat a kékkel, és így folytatjuk, amíg a két index „össze nem ér”.

Minden csere után természetesen növelhetjük a balt és csökkenthetjük a jobbat eggyel, hiszen a csere hatására a bal index alá kék, a jobb index alá pedig piros golyó kerül. Fontos, hogy a keresések során is figyeljük, hogy nem értek-e össze az indexek; ez előfordulhat ugyanis bármelyik pillanatban. (Ezzel azt is ellenőrizzük, hogy nem érünk-e a tömb végére valamelyik indexszel. Az is lehetséges, ha a tömb csak kék vagy csak piros golyót tartalmaz.)

30 szetvalogatas.c

void szetvalogat(int *tomb, int meret) {
   int bal=0, jobb=meret-1;
   
   while (bal<jobb) {
      while (bal<jobb && tomb[bal]==0)  // pirosat keresünk
         bal++;
      while (bal<jobb && tomb[jobb]==1) // kéket keresünk
         jobb--;
         
      if (bal<jobb) {
         int temp=tomb[bal];   // csere
         tomb[bal]=tomb[jobb];
         tomb[jobb]=temp;
         bal++; jobb--;        // egyből a következőkre
      }
   }
}

Vagyis pl. a kereső ciklusok megállhatnak így:

0 0 1 1 0 1 0 1

Ha megcseréljük a tomb[bal] és a tomb[jobb] elemet:

0 0 0 1 0 1 1 1

Akkor a csere után a két indexet gondolkodás nélkül növelhetjük és csökkenthetjük:

0 0 0 1 0 1 1 1

És innen folytatjuk megint piros-kék kereséssel.

31 gyorsrendez.c

void gyorsrendez(double tomb[], int min, int max) {
   int i=min, j=max;
   double vezer=tomb[(min+max)/2]; // vezérelem: középső

   while (i<=j) {                  // piros/kék válogatás
      while (tomb[i]<vezer) i++;
      while (tomb[j]>vezer) j--;
      if (i<=j) {
         double tmp=tomb[i];
         tomb[i]=tomb[j];
         tomb[j]=tmp;
         i++; j--;
      }
   }

   if (min<j) gyorsrendez(tomb, min, j); // rekurzió
   if (i<max) gyorsrendez(tomb, i, max);
}

Az i<=j ciklus végefelé az i és a j index is a már helyre került vezérelemnél áll. (A belső, piros-kék keresős ciklusok megállnak a megtalált vezérelemnél is, hiszen elem<vezér és elem>vezér a feltételeik.) Vagyis így:

min       i,j       max

Ezután az i++ és j-- utasítással még módosítjuk az indexeket (lásd a szétválogatási feladatot). Így i és j a rendezendő két részintervallum széleit is jelzik, és az eddigiektől eltérően j<i igaz:

min     j   i     max

Ezért van az, hogy a két rendezendő tömbrészlet a [min,j] és az [i,max] indexű részek.

Az előzőektől eltérően ennek a függvénynek nem a tömb méretét kell megadni, hanem a rendezendő intervallum alsó és felső határát. De ez nem gond, hiszen egy egysoros függvénnyel ugyanolyan formában használhatjuk ezt is:

void gyorsrendez_indit(double tomb[], int meret)
{
   gyorsrendez(tomb, 0, meret-1);
}

32 Zárt terület kifestése (boundary fill)

Adott pont kifestése

  • Ha fekete, azt nem lehet festeni
  • Ha már ki van festve, nincs teendő
  • Amúgy ki kell festeni, a szomszédait is!

Miért rekurzív?

  • Mert ugyanaz a teendő minden pontnál
  • A konkáv alakzatoknál elágazik

33 Labirintus generálása

Adott pontban…

  • Termet építeni
  • Mind a négy irányba véletlenszerűen:
    • Ha lehet, új járatot
    • És abból a pontból indulva: labirintus!

Rekurzió?

  • Ha visszatért egy irányból…
  • … akkor a többi irányt is meg kell próbálni
  • Emlékezni kell, melyeket!
  • „Bejárni a területet”

Ez nagyon hasonlít a zárt terület kifestéséhez – itt is a téglalap alakú terület minden pontjába el kell jutni. Annyi a különbség, hogy itt véletlenszerűen kell megválasztani azt, hogy merre megyünk tovább.

A labirintus generálása mellett a megfejtése is megoldható rekurzívan. Ha elérkezünk az út során egy terembe, ahol egy elágazás van, akkor meg kell próbálni mind a négy irányt. Ha az egyikből visszatérünk, mert az zsákutca, akkor a másik irányba is meg kell próbálni. Az útvonalkereső algoritmusok, amelyek egy térképen megkeresik A és B város között a legrövidebb utat, általában is így működnek.