1 Rekursio Rekursion periaate ja rekursio määrittelyvälineenä Rekursiota käytetään tietotekniikassa ja matematiikassa erilaisiin tarkoituksiin. Eräänä käyttöalueena on asioiden määrittely. Esimerkkinä käsitellään luvun kertoman määrittelyä. Luvun n kertomaa merkitään n!, ja se tarkoittaa lukujen 1, 2, 3,..., n tuloa. Tässä määrittelyssä tarvitaan epämääräistä kolmen peräkkäisen pisteen merkintää. Epämääräisyydestä päästään eroon käyttämällä rekursiivista määritelmää kertomalle. Kertoma määritellään rekursiivisesti seuraavalla tavalla n! = 1, kun n= 0 n*(n-1)!, kun n>0; Aluksi voi tuntua siltä, että määritelmässä pyöritään kehää, koska siinä on käytetty kertoman määritelmää. Määritelmässä sanotaan, että n:n kertoma on n kertaa n-1:n kertoma. Asian ydin piilee ensinnäkin siinä, että määritelmässä on yksi tapaus, jossa kertoma on määritelty yksikäsitteisesti. Se on tapaus n = 0. Määritelmän mukaan luvun 0 kertoma on 1. Tätä osaa määritelmässä kutsutaan rekursion kannaksi. Toinen tärkeä piirre määritelmässä on, että rekursiivisessa osassa kertoma otetaan pienemmästä luvusta kuin alkuperäinen. Tämän seurauksena, kun rekursiota sovelletaan toistuvasti, saavutetaan lopulta rekursion kanta. Kuinka sitten määrittelyä voidaan käyttää käytännössä tietyssä tapauksessa? Yritetään selvittää kertoman määritelmän perusteella, mitä on 5!. Arvo saadaan määritelmän mukaan asteittain seuraavasti: Näin saadaan, että askel 1 Onko 5 > 0? On --> 5! = 5*(5-1)! = 5*4! askel 2 Onko 4 > 0? On --> 4! = 4*(4-1)! = 4*3! askel 3 Onko 3 > 0? On --> 3! = 3*(3-1)! = 3*2! askel 4 Onko 2 > 0? On --> 2! = 2*(2-1)! = 2*1! askel 5 Onko 1 > 0? On --> 1! = 1*(1-1)! = 1*0! askel 6 Onko 0 > 0? Ei -->0! = 1. 5! = 5*(4*(3*(2*(1*1)))) Määritelmän rekursiivista osaa on käytetty aina uudelleen ja uudelleen. Joka kerralla kuitenkin luku, jonka kertomaa ratkaistaan, on pienempi kuin edellisellä kerralla. Lopuksi luku on niin pieni, että määrittelyssä voidaan käyttää sen ei-rekursiivista osaa eli kantaa. Tällöin rekursio päättyy. Vasta rekursion päättymisen jälkeen voidaan kertomalle laskea arvoa. Laskenta tapahtuu tällöin takaperin, kuten lausekkeesta nähdään. Ensin lasketaan siis 1*1 = 1, sitten 2*1 = 2, sitten 3*2=6, sitten 4*6=24 ja lopuksi 5*24= 120. Tässä on esitelty, kuinka rekursiota voidaan käyttää määrittelyssä. Tietotekniikassa esimerkiksi puurakenteiden määrittelyssä käytetään yleensä rekursiivista määrittelyä. Rekursiota käytetään myös ongelman ratkaisumenetelmänä. Rekursio voidaan toteuttaa myös ohjelmoinnissa, jolloin sitä voidaan pitää lisäksi yhtenä ohjelmointitekniikkana. Edellä esitettiin rekursion käyttö määrittelyssä. Siinä tuli samalla esille rekursion tärkeimmät ominaisuudet yleisesti. Rekursiossa asia palautetaan itseensä, kuitenkin niin, että asia pienenee (edellä kertoma otetaan pienemmästä luvusta). Kun palautus itseensä tehdään
2 riittävän monta kertaa, asia on tullut niin pieneksi, että sen ratkaisu on itsestään selvä tai hyvin yksinkertainen. Kun tämä vaihe on saavutettu, rekursio päättyy ja tehtävässä palataan takaperin alkuun siten, että kun kantatapaus on ratkennut, voidaan selvittää kantatapausta edeltänyt vähän suurempi tapaus. Kun tämä on ratkennut, voidaan ratkaista sitä edeltänyt suurempi tapaus. Näin palataan lopulta alkuperäisen asian tai tehtävän ratkaisuun. Toisena esimerkkinä rekursiivisesta määritelmästä käsitellään tunnilla järjestettyjen alkioiden joukko eli sarja (sequence), joka oli perustana lineaarisen listan määrittelyssä. Rekursion käyttö ongelmanratkaisussa On monia sellaisia probleemoita, joiden ratkaiseminen on ilman rekursiota erittäin hankalaa, mutta rekursiivisella ajattelumallilla hyvin yksinkertaista. Perinteisenä esimerkkinä tällaisesta on ns. Hanoin tornien probleema. Siinä on kolme pystyssä olevaa tankoa (a, b ja c).tankoon a on pujotettu n kappaletta erikokoisia levyjä, joissa on reikä keskellä. Levyt ovat kaikki erikokoisia. Levyt on pujotettu tankoon siten, että suurin levy on alimmaisena ja pienin päällimmäisenä ja jokainen levy on aina alla olevaa pienempi. Tilanne on siis alussa alla olevan kuvan mukainen. a b c Tehtävänä on siirtää kaikki levyt tangosta a tankoon c. Tankoa b saadaan käyttää apuna. Siirroissa on noudatettava seuraavia sääntöjä: 1. Vain yksi levy saa olla kerrallaan pois tankoista. 2. Missään tangossa ei saa missään tilanteessa olla levyjä siten, että suurempi levy olisi pienemmän päällä. Probleeman ratkaisu on yleisellä tasolla hankala. Tämä johtuu siitä, että tarvittavien siirtojen määrä kasvaa eksponentiaalisesti levyjen määrän kasvaessa. Rekursiota käyttäen saadaan helposti algoritmi, joka kertoo tarvittavat siirrot. Ratkaisu on yksinkertainen (mutta tarvittavien siirtojen määrä tietysti edelleen suuri). Etsitään ensin sellainen pieni ongelma, joka on yksinkertainen. Tässä sellaisena voidaan pitää tapausta, jossa levyjä on vain yksi. Tällöinhän kaikki säännöt toteuttava ratkaisu on sellainen, että siirretään levy suoraan tangosta a tankoon c. Ratkaisun rekursiivisessa osassa on ilmaistava, miten siirretään n levyä. Rekursiivisessa osassa lähdetään siitä, että tehtävä osataan hoitaa yhtä pienemmälle levymäärälle n-1. Jos siis levyjä olisi n kappaletta, ne voidaan siirtää sääntöjä noudattaen tankoon c seuraavasti siirretään n-1 levyä sääntöjä noudattaen tangosta a tankoon b käyttäen hyväksi tankoa c siirretään tankoon a jäänyt suurin levy tankoon c
3 siirrettään n-1 levyä sääntöjä noudattaen tangosta b tankoon c käyttäen hyväksi tankoa a Kuinka n-1 levyä siirretään sääntöjä noudattaen, on siis edelleen avoin kysymys. Rekursion mukaan n-1 levyn siirtäminen voidaan edelleen jakaa kahteen osaan siten, siirretään n-2 levyä ensin pois päältä sääntöjä noudattaen samalla tavalla kuin edellä. Näin ongelma pienenee jokaisella kerralla, ja lopulta se palautuu yksinkertaisimpaan tapaukseen, jossa siirrettäviä levyjä on yksi. Tämän jälkeen tarvittavat siirrot saadaan selville takaperin. Rekursiivinen funktio Ohjelmointikielissä voidaan toteuttaa rekursiivinen funktio. Rekursiivisella funktiolla tarkoitetaan funktiota, joka kutsuu itse itseään. Yllä kuvatusta Hanoin tornien probleeman ratkaisusta voidaan kirjoittaa funktio, joka noudattaa suoraan ongelmanratkaisussa käytettyä ajattelumallia. Ohjelma näyttää seuraavalta. /* Hanoin tornit. Esimerkki valaisee rekursion käyttöä probleeman ratkaisussa ja vastaavan ohjelman kirjoituksessa.*/ #include <stdio.h> void siirra (int n, char tanko1, char tanko2, char tanko3) { //seuraavilla riveillä voitaisiin testata, montako kertaa funktiossa on käyty //static int kerta = 0; //printf("\nkerta := %d n = %d", kerta++, n); if (n==1) printf("\n Siirrä tangosta %c tankoon %c ", tanko1, tanko3); else { siirra(n - 1, tanko1, tanko3, tanko2); printf("\n Siirrä tangosta %c tankoon %c ", tanko1, tanko3); siirra(n - 1, tanko2, tanko1, tanko3); void main (void) { int n; printf("\n Montako levyä :"); scanf("%d", &n); printf("\n Käytä seuraavia siirtoja "); siirra(n, 'a', 'b', 'c'); Tunnilla käsitellään ohjelman suorituksen yhteydessä syntyvä ns. kutsupuu. Toisena sovellusesimerkkinä käsitellään tunnilla aikaisemmin esitetty tehtävä, jossa dynaamisesti linkatun listan loppuun lisätään uusi alkio, kun listasta tiedetään vain sen alkuosoite. Rekursiivisen funktion suoritusperiaate prosessorissa Rekursiivinen funktio on funktio, joka kutsuu itseään. Kun funktio kutsuu itseään, sen parhaillaan menossa oleva suoritus jää kesken. Kun seuraavalla kierroksella funktio kutsuu taas itseään, jää sen suoritus taas kesken kutsun kohdalla. Kun lopulta saavutetaan rekursion kanta, pääsee funktio tilanteeseen, jossa sillä hetkellä menossa oleva suoritus päästään loppuun. Tämän jälkeen funktio muistaa kaikki kesken jääneet suoritukset. Kannan
4 suorituksen jälkeen suoritetaan kantaa edeltäneen kutsun suoritus loppuun siitä kohdasta eteenpäin, jossa suoritus keskeytyi eli kutsua seuraavasta kohdasta. Tällä periaatteella kaikki kesken jääneet funktion suoritukset viedään loppuun siten, että viimeisenä suoritetaan loppuosa ensimmäisenä tehdystä funktion kutsusta. Kaikki kesken jääneet funktiokutsut suoritetaan siis takaperin loppuun. Rekursiivisen funktion toiminta perustuu siihen, että funktion kutsun yhteydessä parametrit, paluuosoite ja paikalliset muuttujat viedään pinoon.. Näin tapahtuu jokaisella kutsukerralla. Pinossa on siis erikseen kaikki edellä mainitut tiedot kutakin kutsukertaa varten. Koska tiedot ovat pinossa, tapahtuu funktioiden suoritus loppuun takaperin. Tiedothan löytyvät pinosta käänteisessä järjestyksessä pinon luonteen takia. Seuraavassa tarkastellaan esimerkkiä, jossa dynaamisesti varatun linkatun listan loppuun lisätään alkio rekursiivisella funktiolla insert_to_list_end. Kysymys on tapauksesta, jossa listasta on tiedossa vain sen alkuosoite. Tällöin listan loppu on etsittävä. Listan loppu voidaan luonnollisesti etsiä tavallista iteratiivista menettelyä käyttäen, jossa siirrytään listassa eteenpäin kunnes tulee vastaan alkio, jossa next-kentässä on arvo NULL. Iteratiivinen ratkaisu on jopa tehokas ja hyvä ratkaisu. Tässä esitetään kuitenkin rekursiivinen ratkaisu, koska se havainnollistaa erittäin hyvin rekursiivisen ajattelun periaatetta ja sillä voidaan kätevästi demonstroida miten prosessori suorittaa rekursiivista funktiota. Listan loppuun lisäämisen rekursiivinen ajattelumalli voidaan johtaa sarjan rekursiivisesta määrittelystä. Sarjan rekursiivisen määritelmän mukaan tyhjä joukko on sarja (kanta). Jos L on sarja ja a on alkio, niin al on sarja. Lineaarisen listan dynaamisesti linkatussa esityksessä solmu, joka sisältää alkion a, sisältää myös seuraavan solmun osoittimen. Tällöin voidaan ajatella, että ensimmäisen solmun next kenttä esittää listaa L, jossa on kaikki loput alkiot, samalla tavalla kuin ensimmäisen solmun osoite esittää koko listaa. Tämän ajattelumallin avulla alkion lisääminen listan loppuun voidaan esittää rekursiivisella ajattelumallilla seuraavasti: lisää alkio listan ensimmäiseksi alkioksi, jos lista on tyhjä muussa tapauksessa lisää alkio sen listan loppuun, joka seuraa ensimmäistä alkiota Rekursiivinen ratkaisu täyttää rekursion vaatimukset, koska sillä on kanta, jossa ratkaisu on selkeä, alkion lisääminen tyhjään listaan. Toisaalta ratkaisun rekursiivinen osa kohdistuu pienempään ongelmaan kuin alkuperäinen, koska siinä alkion lisäys tapahtuu yhtä alkiota lyhyempään listaan. Tämän takia rekursiivista osaa toistettaessa, lista lyhenee joka kierroksella ja lopuksi siitä tulee tyhjä lista, jolloin ratkaisu on kannan mukainen. Rekursiivinen funktio insert_to_list_end on esitetty dynaamisten rakenteiden käsittelyn yhteydessä (esimerkkitapaus case 5). Seuraavassa on tämä sama funktio. /* Rekursiivinen funktio, jolla lisätään alkio listan loppuun*/ void insert_to_list_end(tlist *list, Titem data) { if (*list == NULL ) { *list = (Tpointer) malloc(sizeof(tnode)); (*list) -> item = data; (*list) -> next = NULL; else insert_to_list_end_1(&((*list)->next), data);
5 Ratkaisu on selkeä, ja se kuvaa rekursiivista ajattelua hyvin. Funktio ei kuitenkaan ole tehokkuuden kannalta paras mahdollinen, sillä kaikkien alkioiden osoitteet jäävät pinoon, kunnes kanta on saavutettu. Pinosta siis kulutetaan tilaa yhtä monelle osoitteelle, kuin listassa on alkioita. Tässä tehtävässä näitä osoitteita ei tarvita. Ainoana tavoitteenahan oli löytää viimeinen alkio listassa. Tunnilla käydään läpi tarkasti eri vaiheet tämän rekursiivisen funktion suorituksessa. Optimaalista rekursiota edustaa funktio, joka tulostaa dynaamisesti linkatun listan alkiot päinvastaisessa järjestyksessä. Lukija voi miettiä tehtävää ja kirjoittaa funktion ja kokeilla sitä aikaisemmin käsitellyllä listaohjelmalla. Rekursion, pinon ja puiden yhteydet Rekursio, pino ja puut ovat kiinteästi yhteen kietoutuneita. Rekursion toteuttamiseen käytetään pinoa. Rekursiivisen funktion, jossa funktio kutsuu itseään kaksi kertaa, kutsuista muodostuu ns. kutsupuu. Tietorakenne puu määritellään rekursiivisesti. Useimmat puita käsittelevät operaatiofunktiot toteutetaan rekursiivisesti. Rekursiolle sopivia probleemoita Rekursiivinen ajattelu soveltuu mitä moninaisimpiin probleemoihin. Muutamia esimerkkejä ovat: Infix-lausekkeen muuntaminen postfix-muotoon Ruudukossa olevan mielivaltaisen yhtenäisen tahran koon laskeminen Puumaisen hakemistorakenteen läpikäynti.