Rekursio yleisesti

Rekursiolla tarkoitetaan jonkin asian määrittelyä itsensä avulla. Esimerkiksi matematiikasta tuttu luonnollisen luvun kertoma voidaan määritellä näin:

\[n\,! = n \cdot (n - 1) \cdot (n - 2) \cdot \ldots \cdot 3 \cdot 2 \cdot 1\]

Lisäksi määritellään, että  \(0\,! =  1\).

Nollaa suuremman luvun kertoma on siis sen ja kaikkien sitä pienempien positiivisten kokonaislukujen tulo. Esimerkiksi  \(5\,! = 5\cdot4\cdot3\cdot2\cdot1 = 120\).

Toisaalta, kertoma voidaan määritellä myös rekursiivisesti:

\[\begin{split}n\,! = \left\{ \begin{array}{ll} 1 & ~~\mbox{mikäli}~n~\mbox{on}~0 \\ n \cdot (n - 1)\,! & ~~\mbox{mikäli}~n > 0 \end{array} \right.\end{split}\]

Soveltamalla rekursiivista määritelmää kertoman \(5\,!\) laskemiseksi saadaan:

\[\begin{split}\begin{array}{ll} 5! & = 5 \cdot 4! \\ & = 5 \cdot 4 \cdot 3! \\ & = 5 \cdot 4 \cdot 3 \cdot 2! \\ & = 5 \cdot 4 \cdot 3 \cdot 2 \cdot 1! \\ & = 5 \cdot 4 \cdot 3 \cdot 2 \cdot 1 \cdot 0! \\ & = 5 \cdot 4 \cdot 3 \cdot 2 \cdot 1 \cdot 1 \\ & = 120 \end{array}\end{split}\]

Rekursion idea on pyrkiä esittämään ongelman ratkaisu alkuperäisen ongelman kanssa samanmuotoisina mutta ratkaisultaan yksinkertaisempina osaongelmina. Saatuihin osaongelmiin voidaan sitten soveltaa rekursiivista sääntöä uudelleen, kunnes lopulta päädytään niin yksinkertaiseen tapaukseen, että sen ratkaisu nähdään suoraan.

Rekursion päämäärä on hajoittaa ja hallita.

Rekursiiviset funktiot: kertomaesimerkki

Kun puhutaan rekursiosta ohjelmoinnin yhteydessä, niin yleensä aihe liittyy funktioihin. [1] Rekursiivinen funktio on määritelty itsensä avulla eli se kutsuu itseään.

Toteutetaan edellä esitelty kertoman laskeminen rekursiivisella C++-funktiolla:

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

joka siis laskee ja palauttaa parametrinsa n kertoman.

Periaatteessa funktion voi kirjoittaa suoraan kertoman rekursiivisen määritelmän pohjalta, mutta hyödyllisempää olisi ymmärtää, miksi funktio toimii ja vieläpä oikein.

Oletetaan, että kertoma-funktiota kutsutaan näin:

int main() {
    cout << kertoma(5) << endl;
}

ja tutkitaan vaihe vaiheelta, kuinka suoritus etenee. Pääohjelman funktiokutsun seurauksena päädytään suorittamaan edellä määritellyn kertoma-funktion runkoa. Tilanne voidaan kuvata seuraavasti:

Funktioiden aktivoinnit, kun ``kertoma``-funktiota on kutsuttu pääohjelmasta

Kuvassa peitettynä oleva main-laatikko kuvaa sitä, että main-funktion suoritus on kesken, eli vasta kun sen peittävä kertoma-funktio suorittaa return-käskyn, jatkuu main-funktion suoritus siitä kohdasta, jossa kertoma-funktiota kutsuttiin.

Koska n:n arvo on 5, suoritetaan else-haara.

Ennen kuin kertoma kuitenkaan voi palauttaa arvon, sen on laskettava arvo lausekkeelle n * kertoma(n - 1), eli sen oma suoritus jää kesken niin pitkäksi aikaa, että rekursiivinen kutsu kertoma(n - 1) saa laskettua 4:n kertoman.

Itse asiassa suoritus etenee tällä rekursiokierroksella täysin samoin kuin edelliselläkin, paitsi että parametrin n arvo on nyt 4:

Funktioiden aktivoinnit ensimmäisen rekursiivisen kutsun jälkeen

Suoritus ajautuu siis jälleen else-haaraan ja rekursio etenee aina vain syvemmälle. Huomaa, kuinka taustalle alkaa kertyä myös keskeneräisiä kertoma-funktion suorituksia.

Näin ohjelma etenee, kunnes lopulta saavutaan tilanteeseen, jossa parametrin n arvo on 0:

Funktioiden aktivoinnit, kun on saavuttu tilanteeseen, jossa parametrin arvo on 0

Enää ei tapahdukaan rekursiivista kutsua, vaan kertoma palauttaa suoraan arvon 1 ja keskeneräisten funktiokutsujen pino alkaa purkautua.

Toiseksi viimeiselle kertoma-funktion kutsulle palautuu siis viimeiseltä rekursiiviselta kutsulta arvo 1, joka kerrotaan parametrin n arvolla 1:

Funktioiden aktivoinnit, kun on palattu syvimmästä funktiokutsusta

Huomaa että jokaisella kertoma-funktion kutsukerralla (instanssilla) on oma versionsa parametrimuuttujasta n, jonka arvo toki on säilynyt ennallaan, kun rekursiivisesti kutsutusta funktiosta palataan takaisin suorittamaan kutsuneen instanssin käskyjä. Miksi?

Sama pätee kaikkiin paikallisiin muuttujiin.

Seuraavaksi vuorossa oleva keskeneräinen kertoma-funktio jatkuu samaan tapaan:

Funktioiden aktivoinnit, kun on palattu toiseksi syvimmästä funktiokutsusta

Näin rekursiiviset kutsut purkautuvat ja saavuttavat lopulta ensimmäisen kertoma-funktion instanssin:

Funktioiden aktivoinnit juuri ennen paluuta pääohjelmaan

Tämä palauttaa pääohjelmalle luvun 5 kertoman arvon:

Lopulta saadaan kutsun ``kertoma(5)`` arvoksi 120

Lisää esimerkkejä

Tämän kierroksen materiaaleista löytyy rekursiivinen toteutus lukusarjapelille: examples/06/recursive_number_series_game/.

Esimerkkikoodissa on toinenkin rekursiivinen funktio. Se laskee kolmella ja seitsemällä jaollisten lukujen lukumäärän halutulla välillä.

Yhteenveto

Rekursiossa ei ole ohjelmointikielen kannalta mitään erikoista: rekursiiviset funktiot ovat funktioita siinä missä kaikki muutkin funktiot. Rekursion vaikeaselkoisuus piilee siinä, että se vaatii ohjelmoijalta ei-luontaista (?) tapaa hahmottaa ongelma ja sen ratkaisu.

Rekursiivisten funktioiden suunnittelua ja ymmärtämistä helpottaa, kun pitää mielessään seuraavat tosiseikat:

  • Funktio on rekursiivinen, jos se kutsuu itseään (suoraan tai epäsuoraan).
  • Suorituksen kuluessa rekursiivisesta funktiosta on “käynnissä” yhtä monta instanssia, kuin mikä on keskeneräisten rekursiivisten kutsujen lukumäärä.
  • Jokaisella instanssilla on omat arvonsa parametreille ja paikallisille muuttujille.

Rekursiivisella funktiolla on kaksi olennaista ominaisuutta, jotka on syytä muistaa:

  1. Sillä on oltava lopetusehto (tai ehtoja), joka tunnistaa ongelman triviaalitapaukset ja reagoi niihin ilman uuden rekursiivisen kutsun tarvetta.
  2. Jokaisen rekursiivisen kutsun on yksinkertaistettava ratkaistavaa ongelmaa niin, että lopulta päädytään triviaalitapaukseen.

Esimerkin kertoma-funktiossa näitä kohtia edustivat:

  1. Ehtolauseen ehto n == 0 on triviaalitapaus: Nollan kertoman arvo tiedetään suoraan.
  2. Jokaisella rekursiokierroksella selvitettiin yhtä pienemmän luvun kertomaa: Kutsuparametri pieneni yhdellä.

Jos jommankumman unohtaa, niin funktiota kutsuttaessa koko ohjelma kaatuu (Unixissa segmentation fault). Tämä johtuu siitä, että funktio päätyy kutsumaan itseään loputtomiin (päättymätön rekursio): Aiempien kutsukertojen muuttujat [2] kuluttavat muistia, kunnes se yksinkertaisesti loppuu.

Rekursiolla saa aikaan toistoa. Periaatteessa mikä tahansa toistorakenne (silmukka) voidaan korvata rekursiolla. Parhaiten rekursio sopii kuitenkin sellaisten ongelmien ratkaisuun, jotka ovat luonteeltaan rekursiivisia. Niitä voidaan “luonnollisesti” yksinkertaistaa siten, että jäljelle jäävät osaongelmat ovat alkuperäisen ongelman yksinkertaistettuja tapauksia.

Usein rekursiivinen ratkaisu on lyhyempi ja siistimpi kuin vastaava silmukkarakenteella toteutettu ratkaisu. Toisaalta, yleensä rekursiivinen ratkaisu on ainakin hiukan hitaampi ja kuluttaa enemmän muistia kuin silmukkarakenne.

[1]Myös tietorakenteet voivat olla rekursiivisia.
[2]Ja muu aiempiin rekursiokierroksiin liittyvä informaatio.