Kurssin viimeisimmän version löydät täältä: O1: 2024
- CS-A1110
- Kierros 12
- Luku 12.2: Rekursio
Luku 12.2: Rekursio
Tästä sivusta:
Pääkysymyksiä: Mitä jos funktio kutsuu itseään? Ja miksi kutsuisi?
Mitä käsitellään? Rekursiivisesti määritelty eli itseensä viittava tieto. Rekursio funktioiden toteutustekniikkana. Sivuamme myös algoritmien tehokkuutta.
Mitä tehdään? Useita tehtäviä ja varsin paljon luettavaa.
Suuntaa antava työläysarvio:? Noin viisi tuntia.
Pistearvo: C150.
Oheismoduulit: Recursion (uusi), Viinaharava.
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) asemesta; 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, joita aiemminkin käytimme olioiden havainnollistamiseen:
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)"
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
alkuindeksi
n ja vähenevän loppuindeksi
n 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 palautusarvon
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
palautusarvon tyyppi. Palautusarvon 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.
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 helppo selvittää). 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 Esimerkki: pidetään kirjainparien indeksit askeltajamuuttujissa. Esimerkki: Pidetään |
Parametreilla ja muilla paikallisilla 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ä
|
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 palautusarvon tyypin.
Sama animaationa:
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.
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 findBest
ille. 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
start
ista alkaen on tietysti juuri tuo viimeinen alkio.
"Pienennetty ongelma", joka ratkaistaan rekursiivisella kutsulla:
start
in jälkeiset kokemukset.
"Lohkaistu helposti ratkeava pala" eli täsmälleen kohdassa
start
oleva kokemus.
Edellisten yhdistäminen: valitaan parempi ja palautetaan se.
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 kohdassamap
-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 NullPointerException
ista
Miten voi olla, että tuo FamilyTest
tuottaa ajonaikaisen
virhetilanteen? Eikö ollut niin, että muuttuja on määritelty
vasta siitä eteenpäin, mihin se val
illa 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 Item
in
aliluokka ja esineitä saattoi laittaa 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
Yllä käytettiin kahta eri alatyyppiä
Jatkokurssi
jaJohdantokurssi
kuvaamaan tapauksia, joissa kurssilla on ja ei ole esitietoja. Toinen vaihtoehto olisi ollut käyttää vain yhtä konkreettistaKurssi
-luokkaa, jolla olisi ilmentymämuuttujaesitieto
ja sen tyyppinäOption[Kurssi]
. Esitiedottoman kurssiolionesitieto
-muuttujan arvo on siisNone
. Osaatko toteuttaa tällaisen version? Millainen rekursiivisestakokonaisOp
-metodista tulee? (Voit katsoa erään ratkaisutavan Recursion-moduulista.)Osaatko toteuttaa tämän
Option
-pohjaisen kurssiluokan silmukalla, ilman rekursiota? Tuliko kaunista jälkeä?Miten mallintaisit tilanteen, jossa kurssilla voi olla mikä tahansa määrä esitietokursseja?
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
(Helpompi:) Millä tavoin ylempänä kuvattu
reverse
-metodiin perustuva palindromiongelman ratkaisu syyllistyy tarpeettomaankin arvojen vertailemiseen?(Haastavampi:) 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?(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ä onStringOps
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:
Montako kertaa S:n voi jakaa ilman kolikkotyypin K käyttöä, plus...
... 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 laitetaan 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ä palautusarvot ovat parametriarvon mukaisia Fibonacci-lukuja. Se on kuitenkin ongelmallinen.
fibo
-funktion ongelma
Jotkut rekursiiviset ratkaisut ovat hyvin 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:
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 fiboapu
a: "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 yksinkertaisella 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 sen helpottamiseksi sekä mitä tehostuskeinoja voi käyttää joko rekursiivisuuden säilyttäen tai muuten.
Yhteenvetoa
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 hyvin 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 14.12.2022 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, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, 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 ja Juha Sorva. 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. Pääkehittäjänä on nyt Markku Riekkinen, jonka lisäksi A+:aa 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.
Abstraktin metodin
kokonaisOp
toteutusta ei ole määritelty kursseille yleisesti. Alakäsitteeet toteuttavat sen eri tavoin.