Luku 12.2: Rekursio

../_images/robot_fight.png

Johdanto

Ikivanha vitsi

rekursio (substantiivi): Katso rekursio.

Rekursion perusajatus on, että jokin asia määritellään asian itsensä kautta. Tässä luvussa viittaamme sanalla eritoten siihen, että funktio voidaan määritellä kutsumaan itseään.

Tällainen rekursio on ohjelmointitekniikkana paljon monipuolisempi kuin ensi kuulemalta tulee mieleenkään, mitä voimme havainnollistaa vaikkapa sijoittamalla sen luvun 6.3 kaavioon:

Rekursio on monisyinen aihe, jota voi tarkastella eri näkökulmista. Tässä luvussa se näyttäytyy ennen muuta funktioiden toteutustekniikkana, joka perustuu kutsupinon kehyksiin ja jota voi käyttää toistokäskyjen (silmukoiden) sijaan; toistokäskyjen käyttöön viittaamme termillä iteraatio.

Rekursiivisia funktioita käytetään useimmissa ohjelmoinnin valtaparadigmoissa: imperatiivisessa, funktionaalisessa ja olio-ohjelmoinnissa (luku 11.2). Mainituista paradigmoista rekursion käyttö on erityisen runsasta funktionaalisessa ohjelmoinnissa.

Aloitetaan rekursion muodosta, joka on monen mielestä helpoin ymmärtää:

Rakenteellinen rekursio

Ei ole harvinaista, että tietotyypin määrittelyssä viitataan tietotyyppiin itseensä. Esimerkiksi:

  • Kurssilla voi olla esitietokurssi, joka on myös kurssi(olio).

  • Työntekijällä voi olla alaisia, jotka ovat myös työntekijöitä.

  • Pelialueella voi olla naapureita, jotka ovat myös alueita (tekstipeli; luku 9.3).

Voimme kutsua tällaista rakenteelliseksi rekursioksi (structural recursion). Rakenteellisesti rekursiivisessa olio-ohjelmassa keskenään samantyyppiset oliot sisältävät viittauksia toisiinsa ja muodostavat näin esitietoketjun, yrityksen henkilöstöpuun, alueiden verkoston tai muun vastaavan rakenteen.

Rakenteellisesti rekursiivisen tiedon käsittelyyn sopivat itseään kutsuvat eli rekursiiviset metodit.

Esimerkki: kurssien esitietoja

Ajatellaan tilannetta, jossa kursseja on mallinnettu olioina, joilla kullakin on oma opintopistemääränsä. Lisäksi kursseilla voi olla esitietoja. Oletetaan esimerkin yksinkertaistamiseksi, että kurssilla ei voi olla välittömänä esitietonaan yli yhtä kurssia.

Olkoon kurssioliolla metodi kokonaisOp, joka selvittää kurssin "kokonaisopintopistemäärän" eli opintopistemäärän, johon on laskettu mukaan paitsi kurssin oma pistemäärä myös kaikkien kurssin esitietojen pistemäärä eli kurssin oman esitiedon pisteet plus esitiedon esitiedon pisteet ja niin edelleen.

Tämän metodin suoritus voidaan ajatella toisiinsa liittyvien kurssiolioiden keskinäisenä viestintänä. Tutkitaan asiaa ensin samanlaisen pallurakaavion avulla, jollaisilla havainnollistimme olioita aiemminkin:

Sama Scalalla

Oletetaan, että meillä on kursseja yleisesti kuvaava piirreluokka Kurssi ja sille kaksi alakäsitettä Johdantokurssi ja Jatkokurssi. Johdantokurssilla ei ole esitietovaatimuksia. Jatkokurssilla on tässä tasan yksi välitön esitietokurssi.

Luokista voi luoda ilmentymiä esimerkiksi näin:

val johdOp = Johdantokurssi("Johdatus opiskeluun", 2)johdOp: Johdantokurssi = Johdatus opiskeluun (2op)
val ohj1 = Jatkokurssi("Ohjelmointi 1", 5, johdOp)ohj1: Jatkokurssi = Ohjelmointi 1 (5op)
val ohj2 = Jatkokurssi("Ohjelmointi 2", 5, ohj1)ohj2: Jatkokurssi = Ohjelmointi 2 (5op)
val trak = Jatkokurssi("Tietorakenteet ja algoritmit", 5, ohj2)trak: Jatkokurssi = Tietorakenteet ja algoritmit (5op)
val kannat = Jatkokurssi("Tietokannat", 5, ohj1)kannat: Jatkokurssi = Tietokannat (5op)

kokonaisOp-metodin pitäisi toimia näin:

johdOp.kokonaisOpres0: Int = 2
kannat.kokonaisOpres1: Int = 12
trak.kokonaisOpres2: Int = 17

Tässä toteutus yläkäsitteelle Kurssi:

trait Kurssi(val nimi: String, val op: Int):
  def kokonaisOp: Int
  override def toString = this.nimi + " (" + this.op + "op)"

Abstraktin metodin kokonaisOp toteutusta ei ole määritelty kursseille yleisesti. Alakäsitteeet toteuttavat sen eri tavoin.

Johdantokurssi on kurssiluokan äärimmäisen yksinkertainen alatyyppi. Johdantokurssiolion ei tarvitse ihmeemmin selvitellä esitietojensa pistemäärää, koska esitietoja ei ole. Se voi vain palauttaa oman opintopistemääränsä:

class Johdantokurssi(nimi: String, op: Int) extends Kurssi(nimi, op):
  def kokonaisOp = this.op

Monimutkainen ei ole jatkokurssiluokkakaan. Jatkokurssiolion kokonaisOp-algoritmin voi ilmaista suomeksi yksinkertaisesti: "Laske yhteen esitietokurssisi kokonaispisteet ja omat pisteesi." Käyttämällä rekursiota voi tämän kääntää suoraan Scalaksi:

class Jatkokurssi(nimi: String, op: Int, val esitieto: Kurssi) extends Kurssi(nimi, op):
  def kokonaisOp = this.esitieto.kokonaisOp + this.op

kokonaisOp-metodin toteutuksessa on rekursiivinen metodikutsu: kutsutaan juuri samaista kokonaisOp-metodia.

Rekursion toiminta

Taikuuttako?

Joskus rekursiivisista metoditoteutuksista tulee niin yksinkertaisia ja elegantteja (verrattuna esimerkiksi vastaaviin toistokäskyihin), että ne voivat näyttää lähes yliluonnollisilta. Pidetään silti jalat maassa.

Rekursiivisen metodin koodi voi olla petollisen lyhyt. Vaikkapa kokonaisOp-metodin viaton summalauseke aiheuttaa kurssiolioiden välisen viestinnän ja yhteenlaskennan toistumisen kunkin olion kohdalla. Nuo suoritusvaiheet eivät näy suoraan koodista, vaan niiden hahmottaminen jää ohjelmoijan mielessään tehtäväksi.

Rekursiivisen ohjelman muodostamiseen ei tarvita mitään uusia ohjelmointikielen käskyjä. Se tapahtuu ihan tavallisia metodikutsuja käyttäen. Tämä näkyy äskeisestä animaatiosta, jossa uutta on ainoastaan se, että kutsupinossa on päällekkäin useita samaan metodiin liittyviä kehyksiä. (Ne kohdistuvat eri olioihin, joten kehyksissä on keskenään eriarvoisia this-muuttujia.)

Jo kurssin alussa näit, että metodit voivat kutsua toisiaan. Kun metodi A kutsuu metodia B, niin A jää odottamaan, että B lopettaa toimintansa. Tämän jälkeen A jatkaa siitä mihin jäi. Rekursiiviset metodikutsut toimivat samoin. Erona on vain se, että metodi kutsuu itseään ja yhdestä metodista on yhtaikaisesti kesken useita suorituskertoja.

Kunhan ymmärrät, miten metodikutsut toimivat kutsupinossa, pystyt järkeilemään myös, miten rekursiivinen ohjelma vaiheittain toimii.

Rekursio ilman rekursiivista rakennetta

Esimerkki: palindromit

Tutkitaan funktiolla, onko annettu merkkijono palindromi eli samanlainen etu- ja takaperin.

onPalindromi("saippuakauppias")res3: Boolean = true
onPalindromi("saapas")res4: Boolean = false

Metodin voi toteuttaa silmukkaa käyttämällä (siis iteraatiolla) tai rekursiolla. Laaditaan vertailun vuoksi molemmanlaiset toteutukset.

Palindromeista

Tässä luvussa laadittavat onPalindromi-metodit vaativat, että myös välilyönnit ja -merkit menevät täsmälleen samoin etu- ja takaperin. Tätähän ei yleensä palindromeilla sanaillessa edellytetä. Jos haluat, niin löydät Recursion-moduulista myös metodiversion, joka huomioi vain kirjaimet. Sitä emme käsittele tässä tarkemmin, koska se ei tuo lisäarvoa rekursion opetteluun.

Palindromit iteraatiolla

Tässä eräs palindromiudenselvittämisalgoritmi pseudokoodina:

def onPalindromi(merkkijono: String): Boolean =
  Käy merkkijonoa yhtaikaa alusta ja lopusta läpi, merkkipari kerrallaan.
  Jokaisen merkkiparin kohdalla:
    1. Jos merkit ovat erilaiset, ei kyseessä ole palindromi ja voidaan lopettaa.
    2. Jos kaikki merkkiparit käytiin läpi, on kyseessä palindromi.
        (Jos merkkejä oli pariton määrä, voi keskimmäisen jättää huomiotta.)

Tarkennettu versio:

def onPalindromi(merkkijono: String): Boolean =
  Askeltajamuuttuja alkuindeksi on aluksi 0, ja loppuindeksi on merkkijonon pituus miinus yksi.
  Toistetaan alkuindeksiä aina yhdellä kasvattaen ja loppuindeksiä aina yhdellä vähentäen niin
  kauan kuin alkuindeksi pysyy loppuindeksiä pienempänä. Kullakin kierroksella:
    1. Katsotaan merkkijonosta merkit kohdista alkuindeksi ja loppuindeksi.
    2. Jos ne ovat erilaiset, palautetaan false ja lopetetaan metodin suorittaminen.
  Jos kaikki merkkiparit käytiin läpi (eikä siis palautettu false), niin palautetaan true.

Tämä iteratiivinen algoritmi perustuu ajatukseen käskyjen toistamisesta yhden metodikutsun aikana, kunnes tietty ehto lakkaa olemasta voimassa kasvavan alkuindeksin ja vähenevän loppuindeksin kohdatessa. while-toistokäskyä käyttäen algoritmi kirjoittuu suoraan Scalaksi:

def onPalindromi(merkkijono: String): Boolean =
  var alkuindeksi = 0
  var loppuindeksi = merkkijono.length - 1
  while alkuindeksi < loppuindeksi do
    if merkkijono(alkuindeksi) != merkkijono(loppuindeksi) then
      return false
    alkuindeksi += 1
    loppuindeksi -= 1
  end while
  true

Muista luvusta 9.1: funktioon voi kirjoittaa return-käskyn, joka päättää funktion suorituksen välittömästi ja palauttaa return-sanan perään kirjoitetun lausekkeen arvon. Tässä siis katkaistaan silmukan suoritus siihen paikkaan, jos havaitaan erilaiset merkit. (Jatkaminen on tällöin turhaa.)

Kuten luvussa 9.1 myös jo todettiin, Scalassa on aina return-käskyä käytettäessä kirjoitettava metodille paluuarvon tyyppi erikseen.

true päädytään palauttamaan vain siinä tapauksessa, että silmukan sisältä ei palautettu arvoa false.

Palindromit rekursiolla

Tässä toinen pseudokoodialgoritmi palindromiuden selvittämiseen:

def onPalindromi(merkkijono: String): Boolean =
  Mikä tahansa nollan tai yhden merkin mittainen merkkijono on palindromi.
  Merkkijono, jonka ensimmäinen ja viimeinen merkki ovat erilaiset, ei ole palindromi.
  Muissa tapauksissa merkkijono on palindromi, jos ja vain jos:
    - Se pienempi merkkijono, joka saadaan poistamalla merkkijonosta ensimmäinen
      ja viimeinen kirjain, on palindromi.

Huomaa:

  • Tämä pseudokoodi ei kuvaa toistettavia käskyjä ja suoritusvaiheita suoraan. Se on enemmänkin kuvaus siitä, mikä palindromi on. (Tällaista määrittelyä voi sanoa deklaratiiviseksi.)

  • Esitetty määritelmä on rekursiivinen: se viittaa viimeisessä kohdassa itseensä.

Tämänkin ratkaisumallin voi kyllä kirjoittaa myös "käskymuotoon":

def onPalindromi(merkkijono: String): Boolean =
  Jos merkkijonon pituus on 0 tai 1, palauta true.
  Muuten jos merkkijonon ensimmäinen ja viimeinen merkki ovat erilaiset, palauta false.
  Muissa tapauksissa ota jonon keskiosa, josta puuttuvat ensimmäinen ja viimeinen merkki,
    selvitä rekursiivisella metodikutsulla, onko keskiosa palindromi, ja
    palauta sama totuusarvo, jonka rekursiivinen kutsukin palautti.

Sama Scalalla:

def onPalindromi(merkkijono: String): Boolean =
  if merkkijono.length <= 1 then
    true
  else if merkkijono.head != merkkijono.last then
    false
  else
    onPalindromi(merkkijono.substring(1, merkkijono.length - 1))

Funktiossa on kaksi perustapausta (base case) eli tapausta, jossa tulos saadaan ilman rekursiivista kutsua. (Vrt. esitiedoton johdantokurssi edellisessä esimerkissä.)

Merkkijono-olioiden head-metodilla saadaan ensimmäinen merkki (luku 5.2), last-metodilla vastaavasti viimeinen.

Funktiossa on yksi rekursiivinen "haara", jossa se kutsuu itseään eri parametriarvolla. Funktio huolehtii itse rekursiivisen kutsun parametriarvoksi sopivan merkkijono-olion muodostamisesta.

Tällekin onPalindromi-toteutukselle on pakko kirjata erikseen paluuarvon tyyppi. Paluuarvon tyyppi nimittäin pitää Scalassa kirjata myös rekursiivisille metodeille.

Tutustu animaatioon funktion toiminnasta:

Kuten animaatiosta näit, samaa algoritmia sovelletaan kussakin kehyksessä eri parametriarvoon. Nämä arvot ovat algoritmin itsensä muodostamia: algoritmiin kuuluu, että substring-käskyllä muodostetaan uusi merkkijono ja sovelletaan algoritmia siihen. Merkkijonoilla ei itsessään ole rekursiivista rakennetta, mutta algoritmi luo uusia merkkijonoja — pienempiä ongelmia — ja soveltaa itseään niihin.

Oletetaan, että ohjelma toimii animaatiossa esitetyllä tavalla, mutta suoritetaankin käsky onPalindromi("imaami"). Montako onPalindromi-kutsua vastaavaa kehystä tulee luoduksi kutsupinoon tuota käskyä suoritettaessa? (Alinta kehystä, josta lähdetään liikkeelle, ei lasketa mukaan.)

Entä kutsuttaessa onPalindromi("avustava") — montako kehystä tällöin tulee luoduksi?

Entäs reverse?

Eikö merkkijonoilla ole myös reverse-metodi, jota olisi voinut käyttää?

Totta. Yllä kätevästi unohdimme tämän, jotta pääsimme vertaamaan iteratiivista ja rekursiivista toteutustapaa. Koodiltaan tiiviimmän ja palindromien olemusta suoraan heijastavan ratkaisun olisi saanut näinkin:

def onPalindromi(merkkijono: String) = merkkijono == merkkijono.reverse

Huomaa kuitenkin: reverse ei ole varsinaisesti vaihtoehto iteraatiolle ja rekursiolle vaan korkeamman abstraktiotason työkalu tiettyyn tarkoitukseen (eli varsin korkealla luvun alun kaaviossa). Nurinpäinkääntämismetodin itsensä voi toteuttaa joko iteraatiolla tai rekursiolla.

Vaikka tällä kertaa sattui olemaan tarjolla korkeamman tason työkalu ongelman ratkaisemiseen, näin ei aina ole.

Toistokäskyt vs. rekursio

Rekursion hyödyntäminen ei siis edellytä rakenteellista rekursiota. Minkä tahansa iteratiivisesti ratkeavan ohjelmointiongelman voi ratkaista myös rekursiivisesti — ja toisin päin. Eroja on ratkaisujen kirjoittamisen helppoudessa, luettavuudessa ja tehokkuudessa. Riippuu ongelmasta, käytetyistä työkaluista ja myös ohjelman lukijasta, onko iteratiivinen vai rekursiivinen ratkaisu parempi.

Seuraavassa taulukossa on nostettu esille eräitä iteratiivisten ja rekursiivisten ratkaisujen ominaispiirteitä.

Toistokäsky

Rekursio

Eteneminen:

Käydään ongelma läpi toistamalla jotakin toimenpidettä. Kukin toistokerta jatkaa siitä, mihin edellinen jäi.

Esimerkki: toistetaan kirjainparien tarkastamiskäskyjä ja seuraavaan pariin siirtymistä kullekin merkkijonon osalle.

Esimerkki: toistetaan opintopistemäärän katsomista ja summaamista sekä seuraavaan kurssiin siirtymistä kullekin esitietoketjun kurssille.

Lohkaistaan ongelmasta pieni helposti ratkeava pala pois ja sovelletaan samaa algoritmia näin pienennetylle ongelmalle.

Esimerkki: Otetaan ensimmäinen ja viimeinen merkki (osa ongelmasta, jonka osalta vastaus on selvä). Sovelletaan samaa algoritmia merkkijonon keskiosaan (pienennetty ongelma).

Esimerkki: otetaan kurssin omat opintopisteet (osa ongelmasta) ja sovelletaan samaa algoritmia esitietoketjuun (pienennetty ongelma).

Muuttujista:

Käytetään tarvittaessa myös var-muuttujia ja muuttuvatilaisia kokoelmia tilakirjanpitoon.

Esimerkki: pidetään kirjainparien indeksit askeltajamuuttujissa.

Esimerkki: Pidetään var-muuttujassa tieto siitä, mitä esitietoketjun kurssia parhaillaan käsitellään. Vaihdetaan tämä kurssi sen esitietoon joka kierroksella. Käytetään toista muuttujaa kokoojana, johon päivitetään pistesummaa sitä mukaa kun kursseja käydään läpi.

Parametreilla ja muilla paikallisilla val-muuttujilla pärjää.

Esimerkki: tieto vielä käsittelemättömästä osamerkkijonosta välittyy parametriksi seuraavaan, ylempään kehykseen.

Esimerkki: Käsiteltävät kurssit ovat kunkin kehyksen erillisissä this-muuttujissa. Välituloksiksi saadut pistesummat ovat myös tallessa eri kehyksissä; minkään muuttujan arvoa ei tarvitse vaihtaa.

Lopettaminen:

Päätetään toisto, kun on saatu valmista.

Esimerkki: poistutaan silmukasta, kun alusta päin kasvatettu ja lopusta vähennetty indeksi kohtaavat.

Esimerkki: poistutaan silmukasta, kun käsiteltävällä kurssilla ei ole esitietoa.

Kun ongelmaksi on jäänyt enää pieni murunen alkuperäisestä (perustapaus), on sen ratkaisu ilmeinen eikä tarvita enää rekursiivista kutsua.

Esimerkki: jos kyseessä on korkeintaan merkin mittainen jono, on ratkaisu ilmeinen.

Esimerkki: jos kyseessä on johdantokurssi, on ratkaisu ilmeinen.

Ratkaisun muodostaminen:

Toiston päätyttyä tiedossa on koko ongelman ratkaisu.

Esimerkki: kun jono on käyty läpi, on sen palindromius tiedossa.

Esimerkki: Pidetään kokoojamuuttujassa kirjaa esitietojen pistesummasta ja kasvatetaan sitä jokaisen kurssin kohdalla. Lopuksi muuttujassa on kokonaissumma.

Muodostetaan kokonaisratkaisu lohkaistun palasen ja pienennetyn ongelman ratkaisujen perusteella. Usein se on jonkinlainen yhdistelmä näistä (esim. summa).

Esimerkki: Jos ensimmäinen ja viimeinen merkki eroavat (palasen ratkaisu), niin kyseessä ei ole palindromi. Muuten vastaus on sama kuin keskiosalle (pienennetyn ongelman ratkaisu).

Esimerkki: kurssiketjun pistesumma on kurssin omien pisteiden (palasen ratkaisun) ja esitietojen pisteiden (pienennetyn ongelman ratkaisun) summa.

Rekursio ja induktio

Onko induktio tuttu matematiikasta? Se on todistusmenetelmä, joka on läheistä sukua rekursiolle.

Rekursiivisen metodin laatimisesta

Käsitellään vielä yksi esimerkki siitä, miten rekursiolla ja iteraatiolla voidaan saavuttaa samoja tavoitteita: otetaan pieni silmukkaa käyttävä funktio ja tehdään siitä rekursiivinen versio. Samalla pohdimme, mitä rekursiivista metodia laatiessa pitää muistaa.

Tämä funktio käyttää silmukkaa tulostaakseen positiivisten kokonaislukujen neliöt parametriksi annettuun ylärajaan asti:

def tulostaNelioita(ylaraja: Int) =
  for luku <- 1 to ylaraja do
    println(luku * luku)

Toteutus perustuu taulukossa mainittuun iteraation ideaan: toistetaan neliön laskemista ja tulostamista sekä seuraavaan lukuun siirtymistä luku kerrallaan ykkösestä alkaen, kunnes yläraja on saavutettu.

Entä rekursiivinen versio? Ainakin pitäisi olla sellainen perustapaus, jossa ongelma on niin pieni, että se ratkeaa triviaalisti. Isommasta ongelmasta pitäisi "lohkaista ongelmasta helposti ratkeava palanen" ja "ratkaista pienennetty ongelma samalla tavalla", kunnes päädytään perustapaukseen.

Hahmotellaan ensin pseudokoodina. Aloitetaan perustapauksesta, jossa lukuja ei tarvitse tulostaa lainkaan, koska parametriarvoa pienempiä kokonaislukuja ei ole lainkaan:

def tulostaNelioita(ylaraja: Int) =
  if ylaraja == 0 then
    Perustapaus: ei positiivisia lukuja. Ei tarvitse tehdä mitään.
  else
    Isompi ongelma: Lohkaise ongelmasta palanen ja kutsu
    tulostaNelioita-funktiota pienennetylle ongelmalle.

Koska perustapauksessa ei tässä tarvitse tehdä yhtään mitään, voimme kirjoittaa tämän lyhyemminkin:

def tulostaNelioita(ylaraja: Int) =
  if ylaraja > 0 then
    Lohkaise ongelmasta palanen ja kutsu tulostaNelioita-funktiota pienennetylle ongelmalle.

Ongelmaa "tulosta N neliötä" vähän pienempi ongelma on "tulosta N-1 neliötä". "Tulosta N-1 neliötä" on täsmälleen sama ongelma kuin alkuperäinenkin, mutta siitä on "lohkaistu pois" viimeinen osa eli luvun N neliön tulostaminen. Käytetään tätä ajatusta:

def tulostaNelioita(ylaraja: Int) =
  if ylaraja > 0 then
    Tulosta kaikkien paitsi viimeisen luvun neliöt tulostaNelioita-funktiolla.
    Tulosta viimeisenkin luvun neliö.

Ja Scalalla:

def tulostaNelioita(ylaraja: Int): Unit =
  if ylaraja > 0 then
    tulostaNelioita(ylaraja - 1)
    println(ylaraja * ylaraja)

Rekursiivisessa kutsussa käytetään pienempää parametriarvoa. Riittävän monen rekursiivisen kutsun jälkeen saavutetaan perustapaus, jossa parametriarvo on nolla.

Rekursiivista metodia laatiessa on yleensäkin huolellisesti varmistettava, että rekursio etenee tavalla tai toisella kohti perustapausta ja saadaan joskus valmistakin. (Vrt. silmukan eteneminen; luku 9.1.)

Muistamme taas kirjata rekursiiviselle Scala-metodille paluuarvon tyypin.

Sama animaationa:

Mieti, mitä tapahtuu, kun seuraavaa funktiota kutsutaan vaikkapa parametriarvolla 10.

def tulostaNelioita(ylaraja: Int): Unit =
  if ylaraja > 0 then
    tulostaNelioita(ylaraja)
    println(ylaraja * ylaraja)

Mikä tai mitkä seuraavista kuvaavat tilannetta?

Entä jos koodi on tällainen?

def tulostaNelioita(ylaraja: Int): Unit =
  tulostaNelioita(ylaraja - 1)
  println(ylaraja * ylaraja)

Muista aina perustapaus

Ellei rekursio palaudu johonkin perustapaukseen, seurauksena on ns. päättymätön rekursio kuten tämän luvun aloittaneessa muinaisvitsissä. Sekin määritelmä olisi käytännöllisempi, jos siihen kirjattaisiin perustapaus:

rekursio (substantiivi): Jos tiedät, mitä on rekursio, niin miksi luet tätä? Muussa tapauksessa: katso rekursio.

Pikkutehtävä: kertomafunktio

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Pikkutehtävä: lahjavero

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Tehtävä: tehokkaampaa viinaharavointia

Ota esille luvussa 8.1 kohtaamasi Viinaharava-ohjelma. Muuta GameBoard-luokan drink-metodia: kun juoduksi tulee vesilasillinen, jonka vaarallisuustaso on nolla (eli naapurissa ei ole viinoja), niin samalla juontikäskyllä tulevat juoduiksi myös kaikki naapurivedet. Naapurinkin ollessa vaaraton siemaistaan senkin naapurit ja niin pois päin.

Ohjeita ja vinkkejä:

  • Käytä rekursiota: kutsu drink-metodia sen itsensä sisältä.

  • Varo loputonta rekursiota: juodaan lasin A naapurit, joihin kuuluu lasi B, sitten B:n naapurit mukaan lukien A, sitten A:n naapurit mukaan lukien B, jne.

  • Muutos koskee vain sitä, mitä tapahtuu, kun vaaraton vesilasi juodaan. Muilta osin drink toimii kuin ennenkin.

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Apufunktiomenetelmä ja rekursiivinen favorite

Otetaan vielä viimeisen kerran tarkasteluun GoodStuff-ohjelman Category-luokan suosikkikirjanpito, jota on versioitu pitkin kurssia. Tehdään siitä rekursiivinen versio.

Olet jo nähnyt (esimerkiksi luvussa 10.1) tälle metodille lyhyempiä korkeamman abstraktiotason toteutuksia kuin pian nähtävä rekursiivinen versio. Tutkitaan silti tämä uusi toteutuskin. Paitsi että se toimii lisäesimerkkinä siitä, miten rekursiota voi käyttää silmukoihin vertautuvana suhteellisen matalan abstraktiotason yleisenä toteutustekniikkana, niin tämä esimerkki myös tutustuttaa yleisesti käytettyyn kikkaan: teemme rekursiivisen "apufunktion", jota kutsumme tiety(i)llä lähtöarvo(i)lla.

Kerrataan tavoite. favorite-metodin pitäisi palauttaa kyseisen kategoriaolion experiences-puskurin sisältämistä kokemuksista se, jolla on paras arvosana. Tämä suosikkikokemus palautetaan Option-kääreessä. Jos kokemuksia ei ole, ei ole suosikkiakaan ja palautetaan None.

Tässä ensin vertailukohdaksi yksi iteratiivinen tapa toteuttaa metodi:

class Category(val name: String, val unit: String):

  private val experiences = Buffer[Experience]()

  def favorite =
    if this.experiences.isEmpty then
      None
    else
      var fave = this.experiences(0)
      for currentIndex <- 1 until this.items.size do
        fave = this.items(currentIndex).chooseBetter(fave)
      Some(fave)
    end if

end Category

Kuten tuon iteratiivisen version, myös rekursiivisen toteutuksen olisi käsiteltävä kutakin experiences-puskurin alkiota. Samalla on pystyttävä pitämään kirjaa siitä, mitä puskurin osaa milloinkin käsitellään, sekä siitä, mikä on paras toistaiseksi löydetty kokemus. Iteratiivisessa versiossa, jossa kaikki tapahtuu yhden kutsupinon kehyksen sisällä, näitä asioita varten on määritelty omat paikalliset muuttujansa. Paikallisia muuttujia voi käyttää rekursiivisessakin versiossa, mutta koska kukin rekursiivinen kutsu toimii omassa kehyksessään omine paikallisine muuttujineen, on kunkin kutsun tarvitsemat tiedot syytä välittää metodille parametreiksi.

Kuitenkin favorite-metodi on parametriton, ja sellaiseksi se on tarkoitus jättääkin.

Määritellään siis apufunktio, joka on rekursiivinen, ottaa parametriarvon ja on tarkoitettu nimenomaan favorite-metodin käyttöön. Olkoon sen nimi findBest. Määrittelemme sen paikalliseksi funktioksi favorite-funktion sisään:

class Category(val name: String, val unit: String):

  private val experiences = Buffer[Experience]()

  def favorite =
    def findBest(start: Int): Experience =
      Tutki this.experiences-puskurin sisältö alkaen indeksistä start.
      Palauta paras löydetyistä kokemuksista.
    if this.experiences.isEmpty then None else Some(this.findBest(0))

end Category

Tässä siis favorite huolehtii itse tapauksesta, jossa kokemuksia on nolla; muuten delegoidaan homma findBest-metodille: "Etsi alkaen indeksistä 0, ja palauta paras löydetty."

Tarvitaan vielä toteutus findBestille. Tarkennetaan ensin sen pseudokoodia:

def findBest(start: Int): Experience =
  Jos start on viimeinen indeksi, palauta kyseinen viimeinen kokemus.
  Muussa tapauksessa etsi "paras seuraavista" eli niistä, joiden indeksi on vähintään start + 1.
  Palauta joko startin osoittama alkio tai paras seuraavista, kumpi vain niistä on parempi.

Scalaksi:

def findBest(start: Int): Experience =
  if start == this.experiences.size - 1 then
    this.experiences(start)
  else
    val bestOfRest = findBest(start + 1)
    this.experiences(start).chooseBetter(bestOfRest)

Perustapaus: jos ollaan puskurin viimeisen alkion kohdalla, paras startista alkaen on tietysti juuri tuo viimeinen alkio.

"Pienennetty ongelma", joka ratkaistaan rekursiivisella kutsulla: startin jälkeiset kokemukset.

"Lohkaistu helposti ratkeava pala" eli täsmälleen kohdassa start oleva kokemus.

Edellisten yhdistäminen: valitaan parempi ja palautetaan se.

Mitkä seuraavista pitävät paikkansa, kun yllä kuvattua favorite-metodia kutsutaan siten, että this.experiences sisältää kolme kokemusoliota, joiden arvosanat ovat 3, 7 ja 2 tässä järjestyksessä?

Tehtävä: sukupolvia

Tehtävänanto

Tutustu Recursion-moduulin pakkaukseen o1.family. Siellä on yksi luokka, FamilyMember. Toteuta tuon luokan puuttuvat metodit rekursiota käyttäen.

Ohjeita ja vinkkejä

  • Yksi toteutustapa on yhdistää rekursioon map-metodi: käsittele lapset kussakin kohdassa map-kutsulla, jolla etenet syvemmälle sukupuuhun rekursiivisesti. Muitakin tapoja on.

  • Pienen testiohjelman laatiminen voi olla hyvä ajatus.

Vaara käynnistysoliossa

Jos teet testausta varten käynnistysolion (App), varo luomasta käynnistysoliossa perheenjäseniä niin, että viittaat vasta jäljempänä määriteltyyn muuttujaan. Siis ei näin:

object FamilyTest extends App:
  val maria = FamilyMember("Maria", Vector(jeesus))
  val jeesus = FamilyMember("Jeesus")
  println(maria.numberOfDescendants)

Tämä koodi menee Scala-kääntäjästä läpi (joskin varoituksen saattelemana), mutta se kaatuu ajonaikaiseen virheilmoitukseen NullPointerException. Kun vaihdamme kahden ensimmäisen rivin paikkaa keskenään, Jeesus on olemassa jo Mariaa luotaessa ja homma toimii. Sivuutamme esimerkin biologisen erikoisuuden sekä mahdolliset teologiset ja etiologiset tulkinnat.

Tarkempi selitys mainitusta NullPointerExceptionista

Miten voi olla, että tuo FamilyTest tuottaa ajonaikaisen virhetilanteen? Eikö ollut niin, että muuttuja on määritelty vasta siitä eteenpäin, mihin se valilla kirjataan, joten jeesus-muuttujan määrittelemättömyydestä pitäisi tuossa annetussa koodissakin tulla käännösaikainen ilmoitus?

Tosiaan, jos koodi olisi jonkin metodin sisällä ja muuttujat tuon metodin paikallisia muuttujia, niin saisimme käännösaikaisen virheen. Käynnistysfunktiolla (@main) tuosta tulee ihan kunnon virheilmoitus eikä vain varoitusta.

Mutta kun koodi on kirjoitettu suoraan yksittäisolioon, niin maria ja jeesus ovat tämän olion muuttujia. Ja olion muuttujat ovat määriteltyjä koko olion koodissa; ne saavat oletusarvot heti olion luonnin alussa jo ennen kuin olion tila varsinaisesti alustetaan. (Syyt tälle joskus kiusalliselle toiminnalle liittyvät siihen, miten Scalasta on tehty yhteensopiva Javan kanssa.) Oletusarvot ovat samat kuin taulukon alkioiden oletusarvot, joista mainittiin luvussa 12.1.

Esimerkissämme käy siis niin, että jo silloin, kun FamilyTest-olion sisältämää koodia ryhdytään suorittamaan, ovat olion muuttujat maria ja jeesus olemassa. Niillä ei ole kunnollisia arvoja vaan vain null-arvot. Homma menee päin seimiä siinä vaiheessa, kun Maria-nimistä oliota luodaan: jeesus-muuttujalla on tällöin vielä null-oletusarvo, ja null siis tallentuu Marian jälkeläiseksi. Vaikka jeesus-muuttuja saakin seuraavalla rivillä arvokseen viittauksen toiseen FamilyMember-olioon, niin Maria ei siitä enää miksikään muutu. Niinpä kutsu maria.numberOfDescendants kaatuu ristiriitaiseen osatehtävään: pitäisi selvittää Marian olemattoman lapsen jälkeläisten lukumäärä.

Tämä olkoon teille merkiksi siitä, että Option-tyypin olemassaolosta huolimatta Scala-kieltä käyttäessäkin täytyy olla tietyissä tilanteissa varuillaan välttääkseen NullPointerException-virheet, joita vastaan luvussa 4.3 saarnattiin. Niiltä Scala-koodarikin pelastuu yksin huolellisuudesta.

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Lisää rekursiotehtäviä

Kaikki tämän kappaleen tehtävät ovat täysin vapaaehtoisia.

Lisätehtävä: esineitä sisäkkäin

Luvussa 7.5 oli esimerkki, jossa Container oli Itemin aliluokka ja esineitä oli sisäkkäin. Bongaa rekursio. Muokkaa aiemmin laadittua koodia niin, että vaikkapa laukun sisältämien toisten esineiden sisällöt (ja niiden sisällöt jne.) tulevat laskuun mukaan, kun lasketaan laukun sisältämät esineet.

Lisätehtävä: versioita esitietoteemasta

  1. Yllä käytettiin kahta eri alatyyppiä Jatkokurssi ja Johdantokurssi kuvaamaan tapauksia, joissa kurssilla on ja ei ole esitietoja. Toinen vaihtoehto olisi ollut käyttää vain yhtä konkreettista Kurssi-luokkaa, jolla olisi ilmentymämuuttuja esitieto ja sen tyyppinä Option[Kurssi]. Esitiedottoman kurssiolion esitieto-muuttujan arvo on siis None. Osaatko toteuttaa tällaisen version? Millainen rekursiivisesta kokonaisOp-metodista tulee? (Voit katsoa erään ratkaisutavan Recursion-moduulista.)

  2. Osaatko toteuttaa tämän Option-pohjaisen kurssiluokan silmukalla, ilman rekursiota? Tuliko kaunista jälkeä?

  3. Miten mallintaisit tilanteen, jossa kurssilla voi olla mikä tahansa määrä esitietokursseja?

  4. Mitä jos esitiedoissa esiintyy sama kurssi moneen kertaan? Mitä jos esitietoketjussa on sykli eli A on B:n esitieto ja toisinpäin?

Lisätehtäviä: reverse

  1. (Helpompi:) Millä tavoin ylempänä kuvattu reverse-metodiin perustuva palindromiongelman ratkaisu syyllistyy tarpeettomaankin arvojen vertailemiseen?

  2. (Vaikeampi:) Mieti, miten toteuttaisit reverse-metodia muistuttavan funktion, joka palauttaa parametriksi annetun merkkijonon takaperin. Älä käytä reverse-metodia tai muita vastaavia merkkijonometodeita. Osaatko tehdä sekä iteratiivisen että rekursiivisen toteutuksen?

  3. (Sopii lähinnä sellaisille, joilla on kurssia edeltävää ohjelmointikokemusta ja jotka haluavat perehtyä Scala-kielen toteutuksen syövereihin tarkemmin:) Scala-peruskirjastojen lähdekoodi löytyy kirjastojen Scaladoc-dokumentaation ohesta. Selvitä lähdekoodia penkomalla, onko merkkijonojen reverse-metodi toteutettu iteraatiolla vai rekursiolla. Varoitus: Tämä tehtävä ei ole lainkaan niin yksinkertainen kuin voisi kuvitella. Vinkki: Googleta aluksi, mikä on StringOps ja mitä ovat extension methods.

Lisätehtävä: rahan rikkominen

Käytetään luokkaa Currency, joka kuvaa valuuttoja ja erityisesti valuutan kolikkotyyppejä. Esimerkiksi näin voi luoda valuutan, jossa on 10 pennin, 50 pennin, 1 markan, 5 markan ja 10 markan kolikot:

val mummonmarkat = Currency(Vector(10, 50, 100, 500, 1000))mummonmarkat: o1.money.Currency = 10,50,100,500,1000

Luokan Currency kumppaniolioon on valmiiksi määritelty pari valuuttaa:

Currency.EURres5: o1.money.Currency = 1,2,5,10,20,50,100,200
Currency.USDres6: o1.money.Currency = 1,5,10,25,50

Haluttaisiin laatia luokalle Currency metodi, joka laskee, monellako eri tavalla annettu rahasumma voidaan jakaa kolikoiksi. Esimerkki:

Currency.EUR.waysToSplit(5)res7: Int = 4

Vastaus on neljä, koska viisi eurosenttiä voidaan jakaa neljällä tavalla: 1+1+1+1+1, 1+1+1+2, 1+2+2 tai 5.

Toteuta metodi waysToSplit ja selvitä vaikkapa, monellako tavalla yksi Yhdysvaltain dollari voidaan jakaa kolikoiksi (Currency.USD.waysToSplit(100)). Recursion-moduulin pakkauksesta o1.money löytyy hieman pohjakoodia, jonka voit täydentää.

Ohjeita ja vinkkejä:

  • Laadi rekursiivinen apufunktio, joka ottaa parametreikseen jaettavan rahasumman sekä tiedon siitä, mitä kolikkotyyppejä summan jakamiseen käytetään.

  • Kutsu tätä apufunktiota waysToSplit-metodista niin, että summana on (aluksi) koko jaettava rahasumma ja kaikki kolikkotyypit ovat käytössä.

  • Muista, että rekursiivisen metodin olisi pienennettävä ongelmaa jotenkin. Tässä "pienennettäviä asioita" on kaksi: jaettava rahasumma sekä jakamisessa käytettävät kolikkotyypit.

  • Koeta ensin miettiä itse, ja katso lisävinkkejä alta, jos homma ei etene.

    Vinkki rekursiivisista kutsuista

    Olkoon alkuperäinen summa S. Valitaan mielivaltainen kolikkotyyppi K, jonka arvo on A.

    Mieti:

    • Monellako tavalla S:n voi jakaa, jos K:ta ei käytetä lainkaan?

    • Entä monellako tavalla pienemmän rahamäärän S-A voi jakaa?

    • Onko olemassa muita tapoja jakaa S?

    Jatkovinkki rekursiivisista kutsuista

    S:n voi jakaa täsmälleen näin monella eri tavalla:

    1. Montako kertaa S:n voi jakaa ilman kolikkotyypin K käyttöä, plus...

    2. ... montako kertaa pienemmän rahamäärän S-A voi jakaa käyttäen kaikkia kolikkotyyppejä.

    Muotoile näistä kahdesta tapauksesta rekursiiviset apufunktion kutsut.

    Miten perustapaukset?

    Nollan voi jakaa kolikoiksi tasan yhdellä tavalla.

    Negatiivista lukua ei voi jakaa yhdelläkään tavalla.

    Nollalla kolikkotyypillä ei voi jakaa mitään summaa yhdelläkään tavalla.

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Koodinlukutehtävä: rekursiivinen järjestämisalgoritmi

Seuraava koodi järjestää parametriksi annetun kokoelman. Tutki koodia ja pohdi, miten se toimii.

Koodissa on käytetty pareja ja partition-metodia (luku 9.2) sekä operaattoria ++, jolla saa kokoelmien alkiot peräkkäin (luku 4.2).

def sort(data: Vector[Int]): Vector[Int] =
  if data.isEmpty then
    data
  else
    val (pivot, rest) = (data.head, data.tail)
    val (smaller, larger) = rest.partition( _ <= pivot )
    sort(smaller) ++ Vector(pivot) ++ sort(larger)

Kyseessä on eräs toteutus pikalajittelu-nimiselle algoritmille (quicksort), josta kertoo lisää vaikkapa Wikipedia.

Vähän tehokkuudesta

Fibonacci-luvut

Fibonaccin lukujono on 0, 1, 1, 2, 3, 5, 8, 13 jne. siten, että:

  • Fibonacci-luku numero nolla on 0 ja Fibonacci-luku numero yksi on 1.

  • Loput luvut saadaan kaavalla: Fibonaccin = Fibonaccin-2 + Fibonaccin-1

Määritelmä on rekursiivinen: kukin Fibonacci-luku määräytyy edellisten Fibonacci-lukujen perusteella.

Sivistävää katsottavaa

Eräs tapa määrittää Fibonacci-lukuja Scalalla

Kaunis rekursiivinen Scala-koodi n:nnen Fibonacci-luvun laskemiseen saadaan suoraan määritelmästä:

def fibo(n: Int): Long =
  if n <= 1 then n else fibo(n - 1) + fibo(n - 2)

Tämä ratkaisu toimii siinä mielessä, että paluuarvot ovat parametriarvon mukaisia Fibonacci-lukuja. Se on kuitenkin ongelmallinen.

fibo-funktion ongelma

Jotkut rekursiiviset ratkaisut ovat tehottomia tietokoneen muistin- ja/tai ajankäytön suhteen. (Toki on myös tehottomia iteratiivisia ratkaisuja.) Vaikka tehokkuusasioita käsitellään lähinnä jatkokursseilla, niin tutustutaan nyt esimerkkiimme yleissivistävästi tästäkin näkökulmasta:

Montako kertaa tämä funktio tulee kutsutuksi, kun sillä etsitään Fibonacci-luku numero 7?

Saako funktiolla tietokoneen polvilleen?

Tehokkaampaa rekursiota

Tämä ei tarkoita, että rekursio on väistämättä tehoton tapa toteuttaa fibo-funktio. Yllä esitelty sinisilmäisen yksinkertainen tapa kylläkin on huono. Pari parempaa rekursiivista toteutusta on esitelty alla; voit tutustua niihin, jos haluat.

Eräs parempi ratkaisu ongelmaan

Seuraava koodi ei ole läheskään yhtä aistikas tai tiivis kuin alkuperäinen, mutta ratkaisevasti tehokkaampi se on.

def fibo(n: Int) =
  def fiboapu(tamaFibo: Int, seuraavaFibo: Int, montakoEdetaan: Int): Long =
    if montakoEdetaan <= 0 then
      tamaFibo
    else
      fiboapu(seuraavaFibo, tamaFibo + seuraavaFibo, montakoEdetaan - 1)
  fiboapu(0, 1, n)

Tämä ratkaisu käyttää rekursiivista apufunktiota nimeltä fiboapu. (Vrt. favorite-toteutuksen findBest edellä.)

fiboapu pitää kirjaa peräkkäisistä Fibonacci-luvuista, joten se ei joudu laskemaan samoja lukuja uudestaan. Sille annetaan parametreiksi kaksi peräkkäistä Fibonacci-lukua sekä tieto siitä, montako lukua pitäisi vielä edetä Fibonacci-lukujonossa ennen kuin on saavutettu lopullinen tavoiteluku.

fibo kutsuu fiboapua: "Aloita Fibonacci-luvusta, joka on nolla ja jota seuraava on ykkönen. Etene n Fibonacci-lukua ja palauta luku, johon päädyt."

Tämäntyyppistä ratkaisua, jossa pienten osaongelmien ratkaisuja pannaan muistiin ja hyödynnetään sitten isompien osaongelmien ratkaisuissa sanotaan dynaamiseksi ohjelmoinniksi (dynamic programming). Dynaamista ohjelmointia voi harjoittaa rekursiivisilla funktiokutsuilla (kuten yllä) tai silmukoilla.

Lisäharjoite: Kirjoita iteratiivinen toteutus fibo-funktiolle silmukalla. Käytä inspiraationa tätä parempaa rekursiivista toteutusta.

Rekursiivinen LazyList ja laiska Fibonacci

Luvussa 7.2 esiteltiin laiskalistoja: rajaamattoman kokoisia alkiokokoelmia.

Laiskalistoilla voi kuvata myös Fibonaccin lukujonon:

def fibonacciSequence =
  def fiboFrom(first: BigInt, second: BigInt): LazyList[BigInt] =
    first #:: fiboFrom(second, first + second)
  fiboFrom(0, 1)fibonacciSequence: LazyList[BigInt]
fibonacciSequence.take(10).toVectorres8: Vector[BigInt] = Vector(0, 1, 1, 2, 3, 5, 8, 13, 21, 34)
fibonacciSequence(9)res9: BigInt = 34
fibonacciSequence(100)res10: BigInt = 354224848179261915075
fibonacciSequence(10000)res11: BigInt = 3364476487643178326662161200510754331030214846068006390656476997468008144216666236815559551
363373402558206533268083615937373479048386526826304089246305643188735454436955982749160660209988418393386465
273130008883026923567361313511757929743785441375213052050434770160226475831890652789085515436615958298727968
29875106312005754287834... [Jne. Fibonaccin luku numero 10000 on reilut 2000 numeroa pitkä.]

Apufunktio muodostaa laiskalistan, joka kattaa kaikki Fibonaccin luvut alkaen kahdesta peräkkäisestä luvusta, jotka annetaan parametreina.

Muodostetaan lukujen lista rekursiivisesti laittamalla alkuun parametrin perusteella tiedetty ensimmäinen luku ja perään sellainen Fibonaccin lukujen lista, jonka alussa on toinen tiedetty luku.

Tämä ratkaisu on melko elegantti ja tehokkuusominaisuuksiltaankin hyvä. Koska listamme on laiska, se ei laske aiempia Fibonaccin lukuja toistuvasti, kuten alkuperäinen ratkaisumme, vaan pitää edelliset luvut tallessa sen aikaa, kun niitä tarvitaan. Esimerkiksi kymmenennentuhannennen Fibonaccin luvun laskeminen sujuu pienessä hetkessä.

Häntärekursiosta

Usein kysytty kysymys: eikö rekursio aina syö iteraatiota enemmän resursseja — tarvitaanhan muistia päällekkäisille kehyksille kutsupinossa?

On totta, että rekursiivisuus voi johtaa muistisyöppöihin ohjelmiin. On kuitenkin paljon tilanteita, joissa rekursiivisiin kutsuihin liittyvistä kehyksistä ei aiheudu käytännön haittaa. Lisäksi ongelmaa pienentävät häntärekursioon (tail recursion) nojautuvat optimoinnit. Häntärekursiiviseksi laaditussa funktiossa jokainen rekursiivinen kutsu suoritetaan aivan viimeisenä ennen funktiokutsun päättymistä ja mahdollista arvon palauttamista.

Esimerkiksi yllä laadittu onPalindromi-funktio on häntärekursiivinen, koska rekursiivisen kutsun palauttama totuusarvo palautetaan heti sellaisenaan eteenpäin. Toisaalta esimerkiksi tulostaNelioita ei ole häntärekursiivinen, koska siinä suoritetaan kertolasku ja tulostaminen aina rekursiivisen kutsun jälkeen.

Häntärekursiivisen funktion suorittamiseen ei välttämättä tarvita useita kehyksiä, ja niinpä ohjelmointityökalut (kuten kääntäjät) voivat optimoida kehyksien luonnin pois suoritettavasta ohjelmaversiosta. Alla on esitetty optimoitu esimerkki onPalindromi-metodin suorituksesta:

Esimerkiksi Scala-työkalut mahdollistavat tämänkaltaisen optimoinnin.

Voidaan sanoa: vaikka häntärekursiivisen funktion koodi on rekursiivinen, sen määrittelemä prosessi on iteratiivinen. Tällaiselle iteratiiviselle prosessille on aina olemassa myös silmukkapohjainen funktiototeutus, joka on tuotettavissa automaattisella muunnoksella häntärekursiivisesti toteutetusta ohjelmasta.

Lisäharjoitus: Miksei esimerkiksi yllä laadittu kertomafunktio ole häntärekursiivinen? Mitkä tässä luvussa esiintyneistä rekursiivisista funktioista ovat?

Lisäharjoitus: Laadi häntärekursiivinen versio kertoma-funktiosta. Käytä apufunktiota.

Aiheeseen palataan Ohjelmointi 2 -kurssin materiaalissa. Voit lukea lisää myös Wikipediasta.

Lisätehtävä: Levenšteinin etäisyys ja tehokkuus

Alla on elegantti mutta tehoton Scala-toteutus Levenšteinin etäisyyden laskemiseksi. Luku 1.6 kertoi tämän etäisyyden olevan kokonaisluku, joka kertoo, montako yhden merkin lisäystä, poistoa tai toiseksi merkiksi vaihtamista vähintään tarvitaan muuntamaan tietty merkkijono tietyksi toiseksi merkkijonoksi.

def editDistance(text1: String, text2: String): Int =
  if text1.isEmpty || text2.isEmpty then
    math.max(text1.length, text2.length)
  else if text1.head == text2.head then
    editDistance(text1.tail, text2.tail)
  else
    val deletion     = editDistance(text1.tail, text2     )
    val insertion    = editDistance(text1,      text2.tail)
    val substitution = editDistance(text1.tail, text2.tail)
    Vector(deletion, insertion, substitution).min + 1

Perustapaus: tyhjän merkkijonon etäisyys mistä tahansa toisesta merkkijonosta on tuon toisen merkkijonon pituus.

Rekursiivinen tapaus: samalla merkillä alkavien jonojen etäisyys on sama kuin noiden jonojen loppujen merkkien keskinäinen etäisyys.

Toinen rekursiivinen tapaus: Kun kyse on eri merkillä alkavista merkkijonoista, kokeillaan ensimmäisen merkin poistoa, lisäystä ja vaihtamista kutakin. Lasketaan kolme etäisyyttä näin yhdellä operaatiolla muokattujen merkkijonojen perusteella, ja ...

... valitaan lyhin etäisyys, johon lisätään yksi, koska suoritettiin yksi poisto, lisäys tai vaihto.

Lue Levenšteinin etäisyyttä käsittelevä Wikipedia-artikkeli ja pohdi yllä annetun ratkaisun tehokkuutta. Selvitä, miten ongelmaa voidaan rajata ratkaisun helpottamiseksi sekä mitä tehostuskeinoja voi käyttää joko rekursiivisuuden säilyttäen tai muuten.

Yhteenvetoa

Rekursio tuntuu vähän siltä kuin rakentaisi taloa ja aloittaisi ylimmästä kerroksesta.

  • Rekursiivinen funktio kutsuu itseään.

  • Rekursiivinen algoritmi lohkaisee ongelmasta pienen helposti ratkeavan palan ja soveltaa itseään jäljelle jäävään osaan ongelmasta. Lopulta ongelma palautuu triviaalisti ratkeavaan perustapaukseen.

    • Rekursiota voi käyttää funktioiden toteutustekniikkana vastaavasti kuin toistokäskyjä. Moniin ongelmiin on hyvin elegantteja rekursiivisia ratkaisuja.

  • Rekursiossa kutsupinoon syntyy periaatteessa useita samaan metodiin liittyviä kehyksiä päällekkäin, yksi kullekin kutsukerralle.

    • (Tietyissä tilanteissa ohjelmointityökalut voivat optimoida tätä käyttämällä samaa kehystä toistuvasti.)

  • Ohjelma voi olla rakenteellisesti rekursiivinen: tietotyyppiä käytetään osana itseään. Tällöin myös rekursiivisten funktioiden käyttö on usein perusteltua.

    • Rekursion käyttö ei kuitenkaan edellytä rekursiivista rakennetta: funktio voi luoda uusia arvoja ja soveltaa itseään niihin.

  • Lukuun liittyviä termejä sanastosivulla: rekursio, rakenteellinen rekursio, perustapaus; iteraatio.

Palaute

Huomaathan, että tämä on henkilökohtainen osio! Vaikka olisit tehnyt lukuun liittyvät tehtävät parin kanssa, täytä palautelomake itse.

Mitäs nyt sitten?

Ohjelmointi 1 -kurssi päättyy tähän — melkein. Jäljellä on vielä:

  • luku 12.3, jossa on vapaaehtoinen RoboSpeak-turnaus

  • luku 12.4, jossa on vapaaehtoista lisämateriaalia Swing-käyttöliittymäkirjastosta

  • viimeinen "luento" eli kurssin päätöstilaisuus (tule sinne)

  • pian 12. kierroksen määräajan jälkeen avautuva 13. kierros. Tuolla kierrokselle ei ole tehtäviä vaan pakollinen kurssipalautekysely, johon on vastattava viimeistään 11.12.2024 luvussa 13.1.

  • Lukuna 13.0 julkaistaan kurssin päättävä viikkokooste, jonne tulee monenlaista hyödyllistä ja kivaa.

Tekijät

Tämän oppimateriaalin kehitystyössä on käytetty apuna tuhansilta opiskelijoilta kerättyä palautetta. Kiitos!

Materiaalin luvut tehtävineen ja viikkokoosteineen on laatinut Juha Sorva.

Liitesivut (sanasto, Scala-kooste, usein kysytyt kysymykset jne.) on kirjoittanut Juha Sorva sikäli kuin sivulla ei ole toisin mainittu.

Tehtävien automaattisen arvioinnin ovat toteuttaneet: (aakkosjärjestyksessä) Riku Autio, Nikolas Drosdek, Kaisa Ek, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Onni Komulainen, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Mikael Lenander, Ilona Ma, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, Joel Toppinen, Anna Valldeoriola Cardó ja Aleksi Vartiainen.

Lukujen alkuja koristavat kuvat ja muut vastaavat kuvituskuvat on piirtänyt Christina Lassheikki.

Yksityiskohtaiset animaatiot Scala-ohjelmien suorituksen vaiheista suunnittelivat Juha Sorva ja Teemu Sirkiä. Teemu Sirkiä ja Riku Autio toteuttivat ne apunaan Teemun aiemmin rakentamat työkalut Jsvee ja Kelmu.

Muut diagrammit ja materiaaliin upotetut vuorovaikutteiset esitykset laati Juha Sorva.

O1Library-ohjelmakirjaston ovat kehittäneet Aleksi Lukkarinen, Juha Sorva ja Jaakko Nakaza. Useat sen keskeisistä osista tukeutuvat Aleksin SMCL-kirjastoon.

Tapa, jolla käytämme O1Libraryn työkaluja (kuten Pic) yksinkertaiseen graafiseen ohjelmointiin, on saanut vaikutteita tekijöiden Flatt, Felleisen, Findler ja Krishnamurthi oppikirjasta How to Design Programs sekä Stephen Blochin oppikirjasta Picturing Programs.

Oppimisalusta A+ luotiin alun perin Aallon LeTech-tutkimusryhmässä pitkälti opiskelijavoimin. Nykyään tätä avoimen lähdekoodin projektia kehittää Tietotekniikan laitoksen opetusteknologiatiimi ja tarjoaa palveluna laitoksen IT-tuki; sitä ovat kehittäneet kymmenet Aallon opiskelijat ja muut.

A+ Courses -lisäosa, joka tukee A+:aa ja O1-kurssia IntelliJ-ohjelmointiympäristössä, on toinen avoin projekti. Sen suunnitteluun ja toteutukseen on osallistunut useita opiskelijoita yhteistyössä O1-kurssin opettajien kanssa.

Kurssin tämänhetkinen henkilökunta löytyy luvusta 1.1.

Lisäkiitokset tähän lukuun

Rahanrikkomistehtävä on Scala-kielinen muunnelma ohjelmointiongelmasta, joka on esitetty legendaarisessa tietojenkäsittelyopin oppikirjassa Structure and Interpretation of Computer Programs.

Fibonaccista ja kultaisesta leikkauksesta kertovan videon päätekijä on Beau Janzen.

a drop of ink
Palautusta lähetetään...