Kurssin viimeisimmän version löydät täältä: O1: 2024
- CS-A1110
- Kierros 11
- Luku 11.2: Rekursio
Luku 11.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 neljä tuntia.
Pistearvo: C95.
Oheisprojektit: Recursion (uusi), Viinaharava.
Johdanto
Ikivanha vitsi
rekursio (substantiivi): Katso rekursio.
Rekursion (recursion) 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.2 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 10.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.1).
Voimme kutsua tällaista nimellä rakenteellinen rekursio (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.
Osoittautuu, että rakenteellisen rekursion yhteyteen sopivat usein itseään kutsuvat 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, jne.
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 abstrakti luokka Kurssi
ja sen
kaksi aliluokkaa 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 = new Johdantokurssi("Johdatus opiskeluun", 2)johdOp: o1.kurssi.Johdantokurssi = Johdatus opiskeluun (2op) val ohj1 = new Jatkokurssi("Ohjelmointi 1", 5, johdOp)ohj1: o1.kurssi.Jatkokurssi = Ohjelmointi 1 (5op) val ohj2 = new Jatkokurssi("Ohjelmointi 2", 5, ohj1)ohj2: o1.kurssi.Jatkokurssi = Ohjelmointi 2 (5op) val trak = new Jatkokurssi("Tietorakenteet ja algoritmit", 5, ohj2)trak: o1.kurssi.Jatkokurssi = Tietorakenteet ja algoritmit (5op) val kannat = new Jatkokurssi("Tietokannat", 5, ohj1)kannat: o1.kurssi.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 yliluokalle Kurssi
:
abstract class Kurssi(val nimi: String, val op: Int) {
def kokonaisOp: Int
override def toString = this.nimi + " (" + this.op + "op)"
}
Johdantokurssi
on kurssiluokan äärimmäisen yksinkertainen aliluokka. 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 hyvin 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 kuitenkin 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. Nämä 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 hyvin äskeisestä animaatiosta,
jossa uutta on ainoastaan se, että kutsupinossa on päällekkäin useita samaan metodiin
liittyviä kehyksiä (kohdistuen eri olioihin eli käyttäen 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 täsmälleen 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-projektista 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 toimenpiteiden 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) {
if (merkkijono(alkuindeksi) != merkkijono(loppuindeksi)) {
return false
}
alkuindeksi += 1
loppuindeksi -= 1
}
true
}
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.)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)
true
else if (merkkijono.head != merkkijono.last)
false
else
onPalindromi(merkkijono.substring(1, merkkijono.length - 1))
}
head
-metodilla saadaan ensimmäinen merkki
(luku 4.5), last
-metodilla vastaavasti viimeinen.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 lähtökohtaisesti 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 spesifisempään 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 ratkaistavissa olevan 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 käsittelemättä olevasta 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) {
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) { 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) { 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) { Tulosta kaikkien paitsi viimeisen luvun neliöt tulostaNelioita-funktiolla. Tulosta viimeisenkin luvun neliö. } }
Ja Scalalla:
def tulostaNelioita(ylaraja: Int): Unit = {
if (ylaraja > 0) {
tulostaNelioita(ylaraja - 1)
println(ylaraja * ylaraja)
}
}
Rekursiivista metodia laatiessa on yleensäkin huolellisesti varmistettava, että rekursio etenee tavalla tai toisella kohti perustapausta ja saadaan joskus valmistakin. (Vrt. silmukan eteneminen; luku 8.3.)
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
Laaditaan funktio kertoma
, joka palauttaa parametriksi annetun positiivisen
kokonaisluvun kertoman:
kertoma(3)res5: Int = 6 kertoma(6)res6: Int = 720
Yksi tapa ratkaista ongelma on pohjustettu alle. Siitä kuitenkin puuttuu tärkeä osa.
Miten ???
-merkitty kohta voidaan täydentää yksinkertaisesti rekursiota käyttäen?
// Oletetaan tässä, että n on positiivinen luku.
def kertoma(n: Int): Int = if (n <= 2) n else ???
Löydät vastauksen myös alla olevaa animaatiota tutkimalla. Animaatio kannattaa muutenkin katsoa, jos koet tarvitsevasi lisäesimerkin rekursion toiminnasta.
Kirjoita vastauksesi tähän:
A+ esittää tässä kohdassa tehtävän palautuslomakkeen.
Pikkutehtävä: lahjavero
Ajatellaan hieman yksinkertaistettua versiota suomalaisesta lahjaverojärjestelmästä. Oletetaan, että lahjavero määräytyy seuraavasti.
- Ensimmäisestä 4000 eurosta ei mene veroa.
- 4000 euroa ylittävästä osasta menee veroa 20 %.
- Jos lahjoittaja antaa myös veron maksamiseen käytettävät rahat, niin koko tästä lisäsummasta peritään myös 20 % veroa.
Esimerkki:
- Annetaan 54000 euroa. Lahjoittaja haluaa, että vastaanottaja saa tämän summan "puhtaana käteen". Niinpä hänen on annettava suurempi summa, joka kattaa verotkin.
- 54000 euron verotettava osuus on 50000 euroa. Tästä pitää maksaa 20 % eli 10000 euroa veroa.
- Lahjoittaja haluaa antaa lisäksi myös nämä 10000 euroa, joten veroa tulee lisäksi 2000 euroa.
- Lahjoittaja haluaa antaa lisäksi myös nämä 2000 euroa, joten veroa tulee lisäksi 400 euroa.
- Lahjoittaja haluaa antaa lisäksi myös nämä 400 euroa, joten veroa tulee lisäksi 80 euroa.
- Lahjoittaja haluaa antaa lisäksi myös nämä 80 euroa, joten veroa tulee lisäksi 16 euroa.
- Jne.
- Jotta vastaanottaja saisi 54000 euroaan "verottomana", on lahjoittajan annettava yhteensä 54000 + 10000 + 2000 + 400 + 80 + 16 + ... euroa eli yhteensä 66500 euroa.
Mikä rivi ???
-merkittyyn kohtaan pitäisi kirjoittaa, jotta maksettava
-funktio
laskisi, paljonko lahjoittajan pitää maksaa antaakseen tietynkokoisen lahjan
vastaanottajan kannalta "verottomana"? Käytä rekursiota. Likiarvo riittää.
def maksettava(lahjanSuuruus: Double) = lahjanSuuruus + veronMaara(lahjanSuuruus - 4000)
def veronMaara(verotettava: Double): Double =
if (verotettava < 0.001) {
0
} else {
val vero = verotettava * 0.2
???
}
Kirjoita vastauksesi tähän:
A+ esittää tässä kohdassa tehtävän palautuslomakkeen.
Tehtävä: tehokkaampaa viinaharavointia
Ota esille luvussa 7.4 kohtaamasi Viinaharava-projekti. Muuta Glass
-luokan drink
-metodia
siten, että kun juoduksi tulee vesilasillinen, jonka vaarallisuustaso on nolla (eli
naapurissa ei ole viinoja), niin samalla juontikäskyllä tulevat juoduiksi myös kaikki
naapurivedet. Naapurin ollessa myös vaaraton siemaistaan senkin naapurit, ja niin edelleen.
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-projektin Category
-luokan
suosikkikirjanpito, jota on versioitu pitkin kurssia. Tehdään siitä rekursiivinen versio.
Olet jo nähnyt (esimerkiksi luvussa 9.2) tälle metodille lyhyempiä korkeamman abstraktiotason toteutuksia kuin pian nähtävä rekursiivinen versio. Tämä uusi toteutuskin kannattaa kuitenkin tutkia. 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) {
None
} else {
var fave = this.experiences(0)
for (currentIndex <- 1 until this.items.size) {
fave = this.items(currentIndex).chooseBetter(fave)
}
Some(fave)
}
}
}
Kuten yllä olevan 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) None else Some(this.findBest(0)) }
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) {
this.experiences(start)
} else {
val bestOfRest = findBest(start + 1)
this.experiences(start).chooseBetter(bestOfRest)
}
}
start
ista alkaen on tietysti juuri tuo viimeinen alkio.start
in jälkeiset kokemukset.start
oleva kokemus.Tehtävä: sukupolvia
Tehtävänanto
Tutustu Recursion-projektin 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.
Jos teet testiohjelman, niin varo luomasta käynnistysoliossa perheenjäseniä siten, että viittaat vasta jäljempänä määriteltyyn muuttujaan. Siis ei näin:
object FamilyTest extends App { val maria = new FamilyMember("Maria", Vector(jeesus)) val jeesus = new 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
virheilmoituksen. 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 välittömästi olion luomisen
yhteydessä 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 11.2.
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.2
saarnattiin. Niiltä Scala-koodarikin pelastuu yksin
huolellisuudesta.
Palauttaminen
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.3 oli esimerkki, jossa Container
oli Item
in
aliluokka ja esineitä saattoi laittaa sisäkkäin. Bongaa rekursio.
Muokkaa aiemmin laadittua koodia niin, että esimerkiksi laukun
sisältämiä esineitä laskettaessa myös laukun sisältämien esineiden
sisällöt (ja niiden sisällöt jne.) tulevat laskuun mukaan.
Lisätehtävä: versioita esitietoteemasta
- Yllä käytettiin kahta eri aliluokkaa
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-ohjeisprojektista.) - 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 implicit conversions.
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 = new 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.EURres7: o1.money.Currency = 1,2,5,10,20,50,100,200 Currency.USDres8: 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)res9: 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)
). Projektin Recursion
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 siten, 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 8.4)
sekä operaattoria ++
, jolla laitetaan kokoelmien alkiot peräkkäin
(luku 4.1).
def sort(data: Vector[Int]): Vector[Int] =
if (data.isEmpty) {
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) 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ä kaunis 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)
tamaFibo
else
fiboapu(seuraavaFibo, tamaFibo + seuraavaFibo, montakoEdetaan - 1)
}
fiboapu(0, 1, n)
}
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 virta ja laiska Fibonacci
Luvussa 7.1 esiteltiin virtoja: rajaamattoman kokoisia alkiokokoelmia.
Virroilla voi kuvata myös Fibonaccin lukujonon:
def fibonacciSequence = { def fiboFrom(first: BigInt, second: BigInt): Stream[BigInt] = first #:: fiboFrom(second, first + second) fiboFrom(0, 1) }fibonacciSequence: Stream[BigInt] fibonacciSequence.take(10).toVectorres10: Vector[BigInt] = Vector(0, 1, 1, 2, 3, 5, 8, 13, 21, 34) fibonacciSequence(9)res11: BigInt = 34 fibonacciSequence(100)res12: BigInt = 354224848179261915075 fibonacciSequence(10000)res13: BigInt = 3364476487643178326662161200510754331030214846068006390656476997468008144216666236815559551 363373402558206533268083615937373479048386526826304089246305643188735454436955982749160660209988418393386465 273130008883026923567361313511757929743785441375213052050434770160226475831890652789085515436615958298727968 29875106312005754287834... [Jne. Fibonaccin luku numero 10000 on reilut 2000 numeroa pitkä.]
Tämä ratkaisu on melko elegantti ja tehokkuusominaisuuksiltaankin hyvä. Koska virta on laiska (luku 7.1), 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 kuitenkin 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 mainitsi 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) {
math.max(text1.length, text2.length)
} else if (text1.head == text2.head) {
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
}
}
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.
Tekijät
Tämän oppimateriaalin kehitystyössä on käytetty apuna tuhansilta opiskelijoilta kerättyä palautetta. Kiitos!
Kierrokset 1–13 ja niihin liittyvät tehtävät ja viikkokoosteet on laatinut Juha Sorva.
Kierrokset 14–20 on laatinut Otto Seppälä. Ne eivät ole julki syksyllä, mutta julkaistaan ennen kuin määräajat lähestyvät.
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 Riku Autio, Jaakko Kantojärvi, Teemu Lehtinen, Timi Seppälä, Teemu Sirkiä ja Aleksi Vartiainen.
Lukujen alkuja koristavat kuvat ja muut vastaavat kuvituskuvat on piirtänyt Christina Lassheikki.
Yksityiskohtaiset animaatiot Scala-ohjelmien suorituksen vaiheista ovat suunnitelleet Juha Sorva ja Teemu Sirkiä. Niiden teknisen toteutuksen ovat tehneet Teemu Sirkiä ja Riku Autio käyttäen Teemun toteuttamia Jsvee- ja Kelmu-työkaluja.
Muut diagrammit ja materiaaliin upotetut vuorovaikutteiset esitykset on laatinut Juha Sorva.
O1Library-ohjelmakirjaston ovat kehittäneet Aleksi Lukkarinen ja Juha Sorva. Useat sen keskeisistä osista tukeutuvat Aleksin SMCL-kirjastoon.
Opetustapa, jossa 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+ on luotu Aallon LeTech-tutkimusryhmässä pitkälti opiskelijavoimin. Pääkehittäjänä toimii tällä hetkellä Jaakko Kantojärvi, jonka lisäksi järjestelmää kehittävät useat tietotekniikan ja informaatioverkostojen opiskelijat.
Kurssin tämänhetkinen henkilökunta on kerrottu luvussa 1.1.
Lisäkiitokset tähän lukuun
Rahanrikkomistehtävä on Scala-kielinen variaatio 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.
kokonaisOp
toteutusta ei ole määritelty kursseille yleisesti. Aliluokat toteuttavat sen eri tavoin.