Tietorakenteet ja algoritmit I - Tietojenkäsittelytieteen laitos
Transcription
Tietorakenteet ja algoritmit I - Tietojenkäsittelytieteen laitos
TRAI 31.8.2012/SJ Tietorakenteet ja algoritmit I Luentomuistiinpanoja Simo Juvaste Asko Niemeläinen Itä-Suomen yliopisto Tietojenkäsittelytiede Alkusanat TRAI 31.8.2012/SJ Tämä moniste perustuu valtaosaltaan aiemman Tietorakenteet ja algoritmit -kurssin luentomonisteeseen, joka taas perustui valtaosaltaan Askon mainioon tekstiin. Edellisen version kantava ajatus oli käyttää toteuttamaamme tietrakennekirjastoa konkreettistamaan algoritmejamme ja mahdollistamaan esimerkkien ja harjoitusten suorittaminen tietokoneella. Nyt uudessa tutkintorakenteessa kurssi jakautuu kahteen osaan. Tässä ensimmäisessä osassa keskitytään aikavaativuuden analysointiin, perustietorakenteisiin, joihinkin järjestämisalgoritmeihin sekä perustietorakenteiden toteuttamiseen. Toiseen osaan jäävät aiemmin kurssiin kuuluneet verkkoalgoritmit, algoritmistrategiat, ulkoinen muisti sekä mahdollisuuksien mukaan hieman vaativammat algoritmit. Toinen merkittävä uudistus on ohjelmointikielen vaihtuminen proseduraalisista Pascalista ja C:stä oliopohjaiseen Java 1.5:een. Vaikka kieli ja sen kirjastot vaikeammalta tuntuvatkin, tuovat ne myös paljon hyvää tietorakenteiden käyttäjälle ja toteuttajalle. Itse tietorakenteet ja algoritmit ovat kuitenkin muuttumattomia työvälineen vaihtumisesta huolimatta Simo Juvaste 31.8.2012 Asko Niemeläisen alkuperäiset alkusanat Kokosin nyt käsillä olevat luentomuistiinpanot Joensuun yliopistossa syksyllä 1996 luennoimaani Tietorakenteiden ja algoritmien kurssia varten. Muistiinpanot pohjautuvat vuonna 1993 järjestämäni samannimisen kurssin luentoihin, jotka puolestaan noudattelivat pitkälti Alfred V. Ahon, John E. Hopcroftin ja Jeffrey D. Ullmanin ansiokasta oppikirjaa Data Structures and Algorithms (Addison-Wesley 1983). Kurssin laajuus oli vuonna 1993 vielä 56 luentotuntia, mutta nyttemmin kurssi on supistunut 40 luentotuntia käsittäväksi, minkä vuoksi jouduin karsimaan osan aiemmin järjestämäni kurssin asiasisällöstä. Muutenkaan nämä muistiinpanot eivät enää täysin noudattele mainitsemaani oppikirjaa, sillä käsittelen asiat oppikirjaan nähden eri järjestyksessä, osin eri tavoinkin. Radikaalein muutos aiempaan on se, että pyrin kuvailemaan abstraktit tietotyypit mieluummin käyttäjän kuin toteuttajan näkökulmasta. Lähestymistavan tarkoituksena on johdattaa kurssin kuulijat käyttämään abstrakteja tietotyyppejä liittymän kautta, toisin sanoen toteutusta tuntematta. Toteutuskysymyksiin paneudun vasta kurssin loppupuolella, silloinkin enimmäkseen kuvailevasti. Abstraktien tietotyyppien toteuttamisen ei näet mielestäni pitäisi Ohjelmoinnin peruskurssin jälkeen muodostua ongelmaksi, mikäli tietotyypin käyttäytyminen ja toteutusmallin keskeisimmät ideat ymmärretään. Käyttäjän näkökulman korostaminen on perusteltua myös siksi, että tietorakenne- ja algoritmikirjastojen käytön odotetaan lähivuosina merkittävästi kasvavan. Näiden kirjastojen myötä ohjelmoijat välttyvät samojen rakenteiden toistuvalta uudelleentoteuttamiselta, mutta joutuvat samalla sopeutumaan valmiina tarjolla olevien toteutusten määräämiin rajoituksiin. Tällaisen uuden ohjelmointikulttuurin omaksuminen ei käy hetkessä, vaan siihen on syytä ryhtyä sopeutumaan hyvissä ajoin. Painotan kurssilla algoritmien vaativuusanalyysiä, vaikka hyvin tiedänkin monien tietojenkäsittelytieteen opiskelijoiden aliarvioivan vaativuuden analysoinnin merkitystä. Algoritmiikan tutkimuksessa ei vaativuusanalyysiä voi välttää. Analysointitaitoa on myös helppo hyödyntää jopa hyvin yksinkertaisilta vaikuttavissa ohjelmointitehtävissä. Tahdon lausua kiitokseni kärsivällisille kuulijoilleni, jotka joutuivat keräämään nämä muistiinpanot osa osalta, joskus jopa sivu sivulta, muistiinpanojen valmistumisen myötä. Samoin kiitän perhettäni, joka on muistiinpanojen kirjoittamisen aikana tyytynyt toissijaiseen osaan. Kiitokset myös Tietojenkäsittelytieteen laitoksen kansliahenkilökunnalle sekä Yliopistopainolle tehokkaasta toiminnasta. Erityiset kiitokset ansaitsee vielä muistiinpanot tarkastanut FL Pirkko Voutilainen. Joensuussa 14. elokuuta 1997 Asko Niemeläinen TRAI 31.8.2012/SJ Sisällysluettelo Luku 1: Algoritmiikasta · · · · · · · · · · · 1.1 Ongelmasta ohjelmaksi · · · · · · · 1.2 Abstraktit tietotyypit · · · · · · · · · 1.3 Suorituksen vaativuus · · · · · · · · 1.4 Suoritusajan laskeminen käytännössä Luku 2: Abstraktit listatyypit · · · · · · · · 2.1 Lista · · · · · · · · · · · · · · · · · 2.2 Pino · · · · · · · · · · · · · · · · · 2.3 Jono · · · · · · · · · · · · · · · · · 2.4 Pakka · · · · · · · · · · · · · · · · 2.5 Rengas · · · · · · · · · · · · · · · · 2.6 Taulukko · · · · · · · · · · · · · · · 2.7 Yhteenveto · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·1 ·1 ·7 10 18 22 23 29 31 33 34 34 36 Luku 3: Puut · · · · · · · · · · · · · 3.1 Puiden peruskäsitteistö · · · · 3.2 Puu abstraktina tietotyyppinä 3.3 Binääripuu · · · · · · · · · · Luku 4: Joukot · · · · · · · · · · · · 4.1 Määritelmiä · · · · · · · · · 4.2 Sanakirja · · · · · · · · · · · 4.3 Relaatio ja kuvaus · · · · · · 4.4 Monilista · · · · · · · · · · · 4.5 Prioriteettijono · · · · · · · · 4.6 Laukku · · · · · · · · · · · · Luku 5: Verkot · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · 38 38 43 46 49 49 53 54 55 56 58 59 Luku 6: Lajittelu eli järjestäminen · · · · · · · · 6.1 Sisäinen lajittelu · · · · · · · · · · · · · · 6.2 Yksinkertaisia lajittelualgoritmeja · · · · · 6.3 Pikalajittelu (Quicksort) · · · · · · · · · · 6.4 Kasalajittelu · · · · · · · · · · · · · · · · 6.5 Lomituslajittelu · · · · · · · · · · · · · · 6.6 Kaukalolajittelu · · · · · · · · · · · · · · Luku 7: Abstraktien tietotyyppien toteuttaminen 7.1 Kotelointi ja parametrointi · · · · · · · · · 7.2 Listan toteuttaminen · · · · · · · · · · · · 7.3 Listan erikoistapaukset · · · · · · · · · · · 7.4 Puiden toteuttaminen · · · · · · · · · · · Luku 8: Joukkojen toteuttaminen · · · · · · · · 8.1 Yksinkertaiset joukkomallit · · · · · · · · 8.2 Joukkojen erikoistapaukset · · · · · · · · 8.3 Etsintäpuut · · · · · · · · · · · · · · · · · 8.4 Joukon läpikäynti · · · · · · · · · · · · · 8.5 Verkot · · · · · · · · · · · · · · · · · · · Kirjallisuutta · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · 62 62 63 64 67 67 67 69 69 72 82 83 86 86 87 91 94 95 97 · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · Luku 1 TRAI 31.8.2012/SJ Algoritmiikasta Annetun ongelman ratkaisevan tietokoneohjelman laatiminen on monivaiheinen prosessi, josta valitettavan usein huomataan ainoastaan lopputulos eli valmis ohjelma. Sana "ohjelmointi" luo helposti mielikuvan yksin ohjelmakoodin kirjoittamisesta jättäen huomiotta koko ohjelmointiprosessin kannalta merkittävämmät työvaiheet. Luonnehditaan tämän luvun aluksi näitä työvaiheita, minkä jälkeen esitellään kurssilla tarvittavat perustyövälineet. 1.1 Ongelmasta ohjelmaksi Ongelman ratkaisemiseksi on välttämätöntä ymmärtää, mikä ongelma onkaan tarkoitus ratkaista. Tehtävänmäärittely on monasti kovin ylimalkainen ja epätäsmällinen, joten aivan aluksi on paikallaan määritellä ongelma riittävän yksityiskohtaisesti, jottei ratkaisulle asetetuista vaatimuksista jää epäselvyyttä. Esimerkiksi päällisin puolin yksinkertaiselta näyttävä tehtävä "Laadi ohjelma, joka laskee lukujen summan" on tarkemmin katsoen kovin puutteellisesti määritelty. Määrittelystä ei käy ilmi, minkä tyyppisiä lukuja on tarkoitus summata, kuinka monta lukuja tulee olemaan, mistä summattavat luvut saadaan ja mitä lasketulle summalle tehdään. Tehtävän täsmällisempi määrittely voisi olla vaikkapa "Laadi ohjelma, joka laskee ja tulostaa näppäimistöltä syötettävien kokonaislukujen summan. Syötteet loppuvat, kun näppäillään luku nolla." Kun ongelma on täsmennetty, voidaan ryhtyä suunnittelemaan ratkaisuperiaatetta eli ratkaisualgoritmia. Algoritmi määritellään eri lähteissä toisiinsa nähden hieman eri tavoin. Tällä kurssilla algoritmi tarkoittaa äärellistä käskyjonoa, jonka jokaisella käskyllä on yksiymmärteinen merkitys ja jonka jokainen käsky voidaan suorittaa äärellisellä työmäärällä äärellisessä ajassa. Lisäksi vaaditaan, että algoritmin suorituksen tulee aina päättyä, toisin sanoen algoritmi ei saa koskaan jäädä ikuiseen suoritukseen, ei edes odottamattomia syötteitä saadessaan. Vaatimus suorituksen päättymisestä rajaa joitakin kelvollisia ohjelmia algoritmien joukon ulkopuolelle. Esimerkiksi seuraava Java-menetelmä ei kelpaa algoritmiksi: public static void infinite() { while (true); } 1 2 3 1 TRAI 31.8.2012/SJ 1. ALGORITMIIKASTA 2 Toisaalta algoritmien joukkokaan ei ole ohjelmien joukon osajoukko, sillä algoritmia ei yleensä esitetä millään ohjelmointikielellä, vaan käytetään enemmän luonnollista kieltä muistuttavaa pseudokieltä. Pseudokielinen algoritmi on yksittäistä ohjelmaa yleisempi, koska pseudokielen yksityiskohdat toteutetaan eri ohjelmointikielillä eri tavoin. Pseudokielen käyttäminen algoritmien esittämisessä on perusteltua siksi, ettei algoritmin suunnitteluvaiheessa välttämättä edes tiedetä, millä kielellä ja millaisessa ympäristössä algoritmi tullaan toteuttamaan. Algoritmin tulisikin aina olla suuremmitta vaikeuksitta toteutettavissa millä tahansa ohjelmointikielellä. Tämä vaatimus on kohtuullinen, sillä rakenteisen ohjelmoinnin perustekniikat — peräkkäisyys, toisto ja valinta — luontuvat sellaisinaan pseudokieleen ja riittävät minkä tahansa (peräkkäis)algoritmin esittämiseen. Itse asiassa algoritmi edustaa korkeampaa abstraktiotasoa kuin ohjelma. Tällä kurssilla esiteltävien välineiden ja tekniikoiden yhtenä motiivina onkin algoritmien samoin kuin tiedon esittämisen abstraktiotason kohottaminen. Koska esimerkit ja harjoitukset on tarkoitus pystyä myös kääntämään ja suorittamaan, on kuitenkin käytännöllisintä käyttää jotakin ohjelmointikieltä algoritminotaation pohjana. Tällä kurssilla algoritmit esitetään pääosin Java-kielellä, kuitenkin käyttäen abstraktimpaa algoritminotaatiota (pseudokieleltä) apuna selkeyttämään algoritmeja. Ennen kuin algoritmia ryhdytään toteuttamaan eli koodaamaan ohjelmointikielelle, on syytä vakuuttua algoritmin oikeellisuudesta ja tehokkuudesta. Oikeellisuusnäkökohtiin ei tällä kurssilla puututa syvällisesti. Sen sijaan algoritmien tehokkuuden analysointi on yksi tämän kurssin keskeisimmistä aihepiireistä. Jos suunniteltu algoritmi todetaan tehottomaksi, on ratkaistava, kannattaako yrittää löytää tehokkaampi algoritmi, jos sellaista ylipäätään on edes olemassa, vai riittääkö tehotonkin algoritmi ratkaisuksi käsillä olevaan ongelmaan. Algoritmia vastaavaa ohjelmakoodia tuotettaessa täytyy pseudokielen käskyt ja rakenteet muuntaa ohjelmointikieliseen muotoon. Matalan abstraktiotason algoritmin koodaaminen on suoraviivainen tehtävä, mutta mitä korkeammalta abstraktiotasolta lähdetään liikkeelle, sitä monimutkaisempiin kysymyksiin täytyy toteutettaessa löytää vastaukset. Myös tehokkuusnäkökohtia joudutaan usein pohtimaan vielä toteutusvaiheessakin silloin kun tehdään valintoja erilaisten toteutusvaihtoehtojen välillä. Toteutusvaiheessa voidaan soveltaa esimerkiksi asteittaista tarkentamista, jonka avulla ehkä hyvinkin laaja toteutusprosessi kyetään pitämään hallitusti koossa. Algoritmin suunnitteluvaihe on tärkeä myös koodausvaiheen onnistumisen kannalta. Mitä tarkempi algoritmin suunnitelma on, sitä paremmin se ohjaa toteutusta oikeaan suuntaan. Erityisesti tarkan algoritmin pitäisi varmistaa, ettei toteutukseen lipsahda tehottomia osatoteutuksia. Erityisesti käytettäessä valmiita tietorakenne- ja aliohjelmakirjastoja, kuten Java API:a, on vielä varmistuttava niiden operaatioiden toiminnasta ja aikavaativuuksista. Jos valmiiden kirjastojen toteutukset tai operaatiot eivät olekaan aivan yhteensopivia algoritmimme kanssa, voi aikavaativuuteen helposti lipsahtaa kertaluokan lisä. Tälläistä on usein vaikea havaita pienimuotoisessa testauksessa, jolloin tehottomuus voi jäädä ohjelmistoon piileväksi, ja se havaitaan vasta myöhemmin käytettäessä suurempia aineistoja. Tätä ongelmaa pyrimme tällä kurssilla välttämään paneutumalla tarkemmin kirjastojen toteutukseen, sekä jossain määrin kokeellisella aikavaativuuden testaamisella TRA2-kurssilla. Koodauksen valmistuttua on muodostunut ohjelma vielä testattava mahdollisten koodaus- ja muiden virheiden havaitsemiseksi. Jos ongelma on alkujaan määritelty täsmällisesti ja algoritmi todettu oikeelliseksi, ovat mahdolliset virheet syntyneet toteutusai- 1. ALGORITMIIKASTA 3 TRAI 31.8.2012/SJ kana ja ne pystytään toivottavasti korjaamaan käymättä koko raskasta prosessia läpi uudelleen. Toki virheet voivat silti olla hankalasti korjattavia, joten huolellisuus on toteutusvaiheessakin välttämätöntä. Selkeän algoritmin selkeä toteutus johtaa selkeään ohjelmaan, johon ehkä myöhemmin tarvittavien muutostenkin tekeminen onnistuu kohtuullisella työmäärällä, mutta sekavan ohjelman vähäinenkin muuttaminen voi osoittautua hankalaksi tehtäväksi. Joskus on jopa järkevämpää aloittaa työ uudelleen alkutekijöistään. Käytännössä muutostarpeita ilmenee varsin usein, joten muutosten mahdollisuuteen on pyrittävä varautumaan jo algoritmin suunnittelu- ja toteutusvaiheissa. Muutostarpeet aiheutuvat esimerkiksi itse ongelman määrittelyn muuttumisesta tai tehokkuuden lisäämisen vaatimuksesta. Näistä jälkimmäiseen puolestaan voidaan yrittää vaikuttaa joko algoritmia tehostamalla tai toteuttamalla algoritmin kriittiset yksityiskohdat entistä tehokkaammalla tavalla. Kaikkiin muutoksiin on luonnollisesti mahdotonta varautua, mutta huolellisesti rakennetun ohjelman osittainen muuttaminen ei aiheuta koko rakennelman romahtamista. Esimerkki 1-1: Tarkastellaan aiheeseen johdattelevana tehtävänä liikennevalojen vaiheistuksen suunnittelevan ohjelman rakentamista. Ohjelman tarkoituksena on ryhmitellä risteyksessä sallitut ajoreitit siten, että samaan ryhmään kuuluvat ajoreitit eivät leikkaa toisiaan — toisin sanoen samassa liikennevalojen vaiheessa sääntöjen mukaisia reittejä ajettaessa ei voi sattua yhteentörmäystä — ja että ryhmiä on mahdollisimman vähän, jolloin tarvittavien vaiheiden määrä minimoituu. Ohjelma saa syötteenään mahdolliset ajoreitit ja ohjelma tulostaa reittien optimaalisen ryhmittelyn. Havainnollistetaan ongelmaa kuvan 1-1 esittämällä risteyksellä, jossa kadut A ja C ovat yksisuuntaisia, kadut B ja D puolestaan kaksisuuntaisia. Mahdollisia ajoreittejä on kaikkiaan seitsemän erilaista. Niistä vaikkapa reitit AB ja DC voidaan ajaa samanaikaisesti, mutta reittien AC ja DB yhtäaikainen käyttäminen aiheuttaa yhteentörmäyksen vaaran. A A D B C D * B C Kuva 1-1: Katujen risteys Kuvataan ongelma verkkona eli graafina, joka koostuu joukosta solmuja ja joukosta näitä solmuja yhdistäviä kaaria. Verkkojen käsitteistö esitellään tarkemmin luvussa 5 ja TRA2-kurssilla. Esittäkööt solmut ajoreittejä ja olkoon verkossa kaari kahden solmun välillä vain siinä tapauksessa, ettei näitä kahta reittiä voida ajaa samanaikaisesti. Kuvan 1-1 risteystä vastaava verkko nähdään kuvassa 1-2. Taulukko 1-3 esittää saman verkon toisessa muodossa, taulukkona, jossa ykköset ilmaisevat kaaren olemassaolon ja tyhjät alkiot kaaren puuttumisen. Näistä esitysmuodoista kuva 1-1 1. ALGORITMIIKASTA 4 AB DB AC AD BC BD DC Kuva 1-2: Yhteentörmäävien reittien verkko. on ilman muuta ihmiselle ymmärrettävin ja taulukko 1-3 puolestaan tietokoneelle ymmärrettävin. Kuvan 1-2 esitys ei ole paras mahdollinen kummallekaan, mutta ongelmaa verkkona tarkasteltaessa se antaa tyhjentävän kuvan tilanteesta. Taulukko 1-3: Verkon matriisiesitys. TRAI 31.8.2012/SJ AB AC AD AB BC BD 1 1 AC 1 DB DC 1 AD BC 1 BD 1 DB 1 1 1 1 DC Väritetään nyt verkon solmut niin, ettei minkään kaaren molemmissa päissä käytetä samaa väriä. Alkuperäinen ongelma on ratkaistu, kun löydetään pienin määrä värejä, jolla verkon kaikki solmut saadaan väritetyksi rikkomatta väritysehtoa. Tällöin keskenään samanvärisiä solmuja vastaavat ajoreitit voidaan ajaa yhtaikaa eli ne muodostavat yhden vaiheen. Ohjelman tuloste saadaan suoraan solmujen värien mukaisesta ryhmittelystä. AB DB AC AD BC BD DC Kuva 1-4: Eräs mahdollinen ryhmittely. 1. ALGORITMIIKASTA 5 TRAI 31.8.2012/SJ Ratkaisun keskeinen idea on siis muodostaa syötettä vastaava verkko, etsiä verkon optimaalinen väritys ja palauttaa värityksen tulos varsinaisen ongelman ratkaisuksi. Idean toteuttaminen ei valitettavasti ole aivan yksinkertaista. Miten esimerkiksi etsitään yhteentörmäyksen aiheuttavat reittiparit, kun syötteenä annetaan vain sallitut reitit? Tämä osaongelma voidaan onneksi ratkaista kohtuullisella työmäärällä (miten?), mutta väritysongelma osoittautuu erittäin vaikeaksi: kyseessä on niin sanottu NP-täydellinen ongelma, joka ei ratkea polynomisessa ajassa! Tämä algoritmitutkimuksen teoreettinen tulos on nyt hyödyllinen, koska sen ansiosta vältytään tuhlaamasta aikaa tehokkaan algoritmin turhaan etsimiseen — tehokasta algoritmiahan ei ole olemassakaan. Minimaalisen värityksen tuottava tehoton algoritmi toki löytyy (millainen?), mutta sen asemesta lienee hyödyllisempää yrittää löytää heuristinen algoritmi, joka tuottaa nopeasti lähes optimaalisen värityksen, muttei välttämättä parasta väritystä. Hyvällä onnella heuristisen algoritmin antama tulos on jopa yhtä hyvä kuin optimaalinen tuloskin, eikä tulos huonommassakaan tapauksessa toivottavasti ole aivan surkea. Varsin kelvollinen heuristiikka verkon väritysongelmaan on aloittaa värittämällä yhdellä värillä niin monta solmua kuin väritysehtoa rikkomatta on mahdollista, jatkaa värittämällä toisella värillä jäljelle jääneistä solmuista niin monta kuin väritysehtoa rikkomatta on mahdollista ja niin edelleen, kunnes kaikki solmut on väritetty. Tässä on kyseessä niin sanottu ahne menetelmä, joka ei ota huomioon väritettävän verkon erityispiirteitä, vaan käsittelee verkosta kerrallaan niin suuren osan kuin suinkin pystyy. Verkon rakenteen lisäksi ahneen menetelmän antamaan tulokseen voi vaikuttaa se, mistä solmusta värittäminen aloitetaan, sekä se, missä järjestyksessä vielä värittämättömät solmut käydään läpi. Onkin helppo nähdä, ettei ahneen algoritmin tuottama tulos aina ole optimaalinen edes yksinkertaisen verkon tapauksessa. Eräs ahneen menetelmän tuottama kuvan 1-2 verkon väritys nähdään taulukossa 1-5 (joka vastaa kuvan 1-4 väritystä). Kyseinen verkko värittyy kolmella värillä, kun tarkastelu aloitetaan solmusta AB ja solmut käydään läpi kuvan 1-2 mukaisessa järjestyksessä vasemmalta oikealle ja ylhäältä alas. Voidaan jopa osoittaa, ettei tämän verkon värittäminen onnistu ainakaan vähemmällä kuin kolmella värillä: solmut AB, BC, DB, AC ja BD muodostavat niin sanotun kehän eli nämä solmut yhdistyvät kaarten välityksellä yksinkertaiseksi renkaaksi, ja koska tässä renkaassa on solmuja pariton määrä, tarvitaan sen värittämiseen kolme väriä. Koska kolmivärinen ratkaisu löytyi, on ongelma ratkennut optimaalisesti: kuvan 1-1 liikennevaloihin tarvitaan kolme vaihetta, yksi kutakin taulukossa 1-5 samalla värillä väritettyä reittijoukkoa kohden. Muitakin vaiheiden määrään nähden yhtä hyviä ratkaisuja on olemassa. Taulukko 1-5: Eräs mahdollinen ryhmittely. Väri Reitit sininen AB, AC, AD, DC punainen BC, BD vihreä DB 1. ALGORITMIIKASTA 6 Edellisessä esimerkissä nähtiin algoritmien suunnittelussa usein käytetty lähestymistapa, jossa ongelma muunnetaan toiseksi ongelmaksi, jonka ratkaisumenetelmä tunnetaan. Näin saatu ratkaisu on lopuksi osattava palauttaa alkuperäisen ongelman ratkaisuksi. Kyse on siis reaalimaailman ongelman abstrahoimisesta algoritmisesti käsiteltäväksi ongelmaksi, algoritmisen ongelman ratkaisusta ja ratkaisun muuntamisesta takaisin reaalimaaliman käsitteisiin. Jatketaan äskeisen ahneen menetelmän tarkastelua pseudokielen tasolla. Pseudokielen käskyiltä ja rakenteilta ei vaadita täsmällistä muotoa, vaan asiat ilmaistaan kulloinkin tarkoituksenmukaisella tarkkuudella. Liiallista ohjelmointikieleen tai tiettyyn toteutukseen johdattelevaa tarkkuutta on vältettävä, koska liiallisessa tarkkuudessa vaanii vaara abstraktion katoamisesta, mikä puolestaan voi estää hyvän lopputuloksen muotoutumisen. Esimerkki 1-2: Olkoon G verkko, jonka solmuista osa on ehkä jo väritetty. Seuraava algoritmi greedyColor värittää uudella värillä sellaiset solmut joiden värittäminen ei riko väritysehtoa. TRAI 31.8.2012/SJ public static void greedyColor(Graph G, Color newColor) { for each uncolored vertex v of G { if (v not adjacent to any vertex with color newColor) v.setColor(newColor); 1 2 3 4 } 5 public static int greedyColorStart(Graph G) { mark all vertices non-colored; int numOfColors = 0; while (not all vertices colored) greedyColor(G, ++numOfColors); return numOfColors; } 6 7 8 9 10 11 12 Väritysongelma ratkaistaan suorittamalla greedyColor-algoritmia toistuvasti (yllä greedyColorStart, rivit 9-10), kunnes verkon kaikki solmut on väritetty, ja laskemalla samalla algoritmin suorituskertojen lukumäärä. Algoritmin ensimmäinen versio sisältää monia vielä tarkennettavia kohtia, kuten tyyppien Graph ja Vertex määrittelyn, joukkomuuttujan tyhjäksi alustamisen, solmujoukon yli toistamisen ja solmujen välisen naapuruuden tutkimisen. Tarkennetaan näistä rivin 2 toisto käyttämällä Java:n kokoelman yli toistoa sekä ohittamalla jo väritetyt solmut. Tarkennetaan samoin rivin 3 ehtolause käymällä läpi solmun v naapurisolmut ja tutkimalla, onko yksikään niistä jo väritetty nyt käytössä olevalla värillä. Ellei näin ole, voidaan solmu v nyt värittää. Tarkennettuna algoritmi näyttää seuraavanlaiselta: public static void greedyColor(Graph G, Color newColor) { for (Vertex x : G.vertices()) { if (x.getColor() != noColor) continue; 1 2 3 4 1. ALGORITMIIKASTA 7 boolean found = false; for (Vertex w : v.neighbors()) { if (w.getColor() == newColor) found = true; } if (! found) v.setColor(newColor); 5 6 7 8 9 10 11 } 12 } 13 Vastaavasti olisi tarkennettava algoritmin käynnistysaliohjelmaa greedyColorStart. Jotta ratkaisusta saataisiin todella tehokas, on syytä ennen toistojen tarkentamista tarkistaa, miten verkkotyyppi on toteutettu. Se puolestaan edellyttää TRA2 kurssilla nähtävien tietorakenteiden tuntemusta, joten päätetään esimerkin käsittely tähän. Esimerkeissä 1-1 ja 1-2 käytetty asteittaisen tarkentamisen idea on tuttu jo aiemmilta ohjelmointikursseilta, mutta verkko- ja joukkotyyppien toteuttamiseen ei muilla kursseilla ole vielä paneuduttu. Kaikkia näitä monimutkaisia tietotyyppejä ei Java-kirjastoon sisälly valmiina. Erityisesti Java-kirjaston kokoelmat ovat jossain määrin rajoittuneempia kuin mitä tällä kurssilla ajatellaan. Algoritmin suunnittelun kannalta olisi hyödyllistä jos esimerkiksi voitaisiin käyttää käsitettä "joukko" sekä tavanomaisia joukko-operaatioita kuten "yhdiste" ja "leikkaus" ikään kuin ne olisivat todella olemassa. Näin johdutaan abstrakteihin tietotyyppeihin, jotka ovat tiedon esittämisen ja käsittelyn malleja. Abstraktin tietotyypin määrittelyssä kuvataan aina kokoelma operaatioita, joilla tietotyypin esittämää tietoa käsitellään. Tämän operaatiokokoelman kuvaus muodostaa abstraktin tietotyypin liittymän. Liittymä yksin määrää sen, miten abstraktia tietotyyppiä saadaan käyttää. ADT voidaan ajatella kokoelmien hallinnan apuvälineenä. Hyötytietoelementit Hyötytieto ❁❂❃ ❅❉❂ ❊❋● ADT ❆❇❈ TRAI 31.8.2012/SJ 1.2 Abstraktit tietotyypit Looginen järjestys Kuva 1-6: ADT kokoelman ylläpidon apuvälineenä. "ripustetaan" ADT:n ylläpidettäväksi, jolloin meidän ei tarvitse huolehtia kokoelman ylläpitämisestä, vaan voimme keskittyä itse elementteihin liittyvään tietojenkäsittelytehtävään. Kuva 1-6 esittää ADT:n ja hyötytiedon suhdetta. ADT:n toteutusrakenne (kuvassa neliöt katkoviivan sisällä) on käyttäjän kannalta (lähes) yhdentekevä. Parhaimmillaan 1. ALGORITMIIKASTA 8 unohdamme koko ADT:n ja käsittelemme hyötytietoa (elementtejä) kuten ne itse osaisivat säilyttää itsensä ja järjestyksensä. Useimmiten tehtävämme elementeillä on jokin looginen järjestys jonka mukaan haluamme niitä käsitellä. Erilaisia järjestystarpeita varten määrittelemme erilaisia ADT:tä. Kuhunkin tarpeeseen on osattava valita oikeanlainen abstrakti tietotyyppi. Esimerkki 1-3: Joukko (abstraktina tietotyyppinä) on kokoelma keskenään samantyyppisiä alkioita, vaikkapa verkon solmuja. Joukkomallille tyypillinen piirre on se, että sama alkio voi sisältyä joukkoon vain yhtenä esiintymänä kerrallaan. Joukkoja käsitellään esimerkiksi muodostamalla kahden joukon yhdiste tai tutkimalla, kuuluuko jokin alkio joukkoon. Joukkotyypin liittymä voi sisältää vaikkapa seuraavankaltaisen osan: TRAI 31.8.2012/SJ // returns union of this Set and Set B public Set<E> union(Set<E> B); // returns whether Object x is a member of this Set or not public boolean member(<E> x); 1 2 3 4 Tässä esiintyvä tyyppi <E> on joukon alkioiden tyyppi, joka luonnollisesti on eri ohjelmissa erilainen. Joukon alkiothan voivat itsekin olla joukkoja, kuten on laita esimerkiksi potenssijoukkojen tapauksessa. Liittymä antaa abstraktin tietotyypin käyttäjälle kaiken tarpeellisen tiedon tyypin käyttämiseksi, nimittäin sallitut operaatiot parametreineen ja tulostyyppeineen. Lisäksi liittymässä tulee mainita operaatioiden oikeellista käyttöä mahdollisesti rajoittavat ehdot. Jos käyttäjä noudattaa näitä ehtoja, on hänellä oikeus odottaa operaatioiden toimivan asianmukaisella tavalla. Ehtojen vastainen käyttö puolestaan voi johtaa virhetilanteeseen tai aiheuttaa muuten kummallisen toiminnan. Edelleen liittymän kuvauksen tulisi kertoa kunkin operaation aika- ja tilavaativuus. Java-kirjastojen dokumentaatiossa tämä on useimmiten kerrottu implisiittisesti tai ei lainkaan, mikä onkin niiden ehkä suurin puute. Abstrakti tietotyyppi voitaisiin määritellä aksiomaattisesti, jolloin liittymä ehtoineen saataisiin miltei sellaisenaan tietotyypin määrittelystä. Sivuutetaan tällä kurssilla aksiomaattinen lähestymistapa ja tarkastellaan abstrakteja tietotyyppejä pikemminkin intuitiivisesti. Olkoon tarkastelukulma mikä hyvänsä, on selvää, ettei pelkkä liittymä vielä mahdollista abstraktin tietotyypin konkreettista käyttämistä, vaan käyttämisen edellytyksenä on tietotyypin toteuttaminen. Toteutus voi pohjautua toisiin abstrakteihin tietotyyppeihin, jotka on edelleen toteutettava kukin erikseen. Lopulta toteutus palautuu ohjelmointikielen tarjoamiin valmiisiin välineisiin. Toteutus sisältää ainakin liittymässä kuvattujen operaatioiden ohjelmakoodin sekä abstraktia mallia vastaavan todellisen tietorakenteen määrittelyn. Toteutus on usein operaatioiltaankin liittymässä kuvattua laajempi, koska esimerkiksi todellisen tietorakenteen käsittelemiseksi saatetaan tarvita välineitä, joista käyttäjän ei tarvitse tietää mitään. Käyttäjä ei luonnollisesti näe todellista tietorakennettakaan, vaan käyttäjän mielikuvassa abstrakti tietotyyppi on sellaisenaan olemassa. Vastaavasti toteuttaja ei tiedä, millaisiin sovelluksiin toteutusta tullaan käyttämään, vaan ainoastaan sen, millaisia operaatioita käyttäjät tuntevat. Tällainen abstraktin tietotyypin koteloinnin idea helpottaa sekä käyttäjän että toteuttajan työtä. Käyttäjä näet välttyy toteutuksen yksityiskohtiin tutustumiselta ja voi sen sijaan paneutua tehokkaammin varsinai- 1. ALGORITMIIKASTA 9 sen ongelmansa ratkaisuun. Toteuttaja puolestaan voi keskittyä etsimään tehokasta toteutusta liittymän kuvaamalle abstraktille mallille. Kotelointi helpottaa myös tietotyypin toteutuksen muuttamista, jos se on tarpeen. Toteutusta voidaan näet muuttaa miten hyvänsä ilman, että käyttäjän tarvitsee edes tietää muutoksista, kunhan liittymä säilyy ennallaan. Toisaalta saman tietotyypin eri toteutuksia kyetään vaivattomasti vaihtelemaan esimerkiksi empiirisessä tutkimustyössä. Tähän saakka on käytetty tietotyypin ja tietorakenteen käsitteitä esittämättä niiden täsmällistä merkitystä. Määritellään nyt nämä kaksi käsitettä: Määritelmä 1-4: Muuttujan tietotyyppi on kyseisen muuttujan sallittujen arvojen joukko. Esimerkki 1-5: Kokonaislukujen tyyppi sisältää periaatteessa äärettömän monta arvoa: 0, 1, –1, 2, –2, 3, –3, … Käytännössä tietokoneen sananpituus rajaa mahdollisten arvojen joukon aina äärelliseksi. Esimerkiksi 32 bitillä voidaan esittää 4294967296 eri lukua. TRAI 31.8.2012/SJ Pascal-kielen tyypin set of 0..9 arvoja ovat joukot { }, {0}, {1}, {2}, {3}, … , {9}, {0, 1}, {0, 2}, … , {8, 9}, {0, 1, 2}, … , {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}. Kaikkiaan näitä arvoja on 210 eli 1024 erilaista. Boolen tyypissä erilaisia arvoja on vain kaksi, false ja true. Määritelmä 1-6: Tietorakenne on kokoelma toisiinsa kytkettyjä muuttujia. Kyseessä on siis ohjelmointikielen rakenne josta bitit voidaan periaatteessa piirtää näkyviin. Joskin Javan tapauksessa bittien täsmällinen piirtäminen vaatisi hieman enemmän paneutumista Javan toteutukseen. Pelkkä kielen dokumentaatio ei riitä. Esimerkki 1-7: Java-kielen taulukot, tietueet ja tiedostot ovat tietorakenteita samoin kuin viitteiden avulla toisiinsa kytkettyjen dynaamisten muuttujien muodostamat monimutkaisemmatkin rakenteet. Ääritapauksessa yhtäkin muuttujaa voidaan sanoa tietorakenteeksi — tällainen on esimerkiksi yhden kokonaisluvun sisältävä tietue. Abstraktin tietotyypin kuvaaman mallin toteutuksessa määritellään aina jokin todellinen tietorakenne. Käyttäjä ei näe tämän todellisen rakenteen sisältöä, vaan abstraktin mallin mukaisia arvoja, esimerkiksi tyhjän joukon. Toteuttaja puolestaan näkee todellisen rakenteen kaikki osaset, kuten esimerkiksi muuttujien välisten kytkentöjen toteuttamisessa käytettävät osoittimet. Toteuttajan on silti kyettävä hahmottamaan myös se abstraktin mallin mukainen arvo, jota todellinen rakenne kuvaa. Tietorakenteiden ja tietotyyppien käsittelyyn sekä niiden määrittelytapoihin palataan esimerkkien kera luvussa 2. Samalla pohditaan miten abstrakteja rakenteita pitää konkretisoida jotta ne olisi toteutettavissa oikeilla ohjelmointikielillä. 1. ALGORITMIIKASTA 10 1.3 Suorituksen vaativuus Kun saman ongelman ratkaisemiseksi tunnetaan useita erilaisia algoritmeja, on näistä osattava valita kulloiseenkin sovellukseen tarkoituksenmukaisin. Valinta perustuu usein seuraaviin kahteen kriteeriin: TRAI 31.8.2012/SJ 1) Algoritmin tulee olla helppo ymmärtää ja toteuttaa. 2) Algoritmin tulee olla tietokoneen muistitilan ja ajankäytön suhteen tehokas. Ikävä kyllä nämä kriteerit ovat usein keskenään ristiriidassa, sillä ihmisen ymmärrettäväksi helppo algoritmi saattaa olla kovin hidas, mutta tehokkaan algoritmin toiminnan ymmärtäminen voi tuottaa suuria vaikeuksia, toteuttamisen vaikeuksista puhumattakaan. Jollei sama algoritmi täytä molempia kriteerejä, kannattaa valinta tavallisesti perustaa käyttötarpeeseen. Muutaman kerran käytettävältä ohjelmalta ei ole järkevää vaatia äärimmäistä tehokkuutta, kun taas toistuvasti tarvittavan ohjelman on paras olla tehokas. Samoin odotettavissa oleva syötteen koko vaikuttaa algoritmin valintaan. Jos voidaan esimerkiksi varmasti ja perustellusti olla varmoja, ettei syötteen koko koskaan tule olemaan enempää kuin muutamia satoja, eikä sekunti suoritusaikana ole liikaa, meille riittää hieman hitaampi algoritmi, eikä ole perusteltua tuhlata aikaa tehokkaamman mahdollisen algoritmin toteuttamiseen. Yleisesti kuitenkin meidän voi olla vaikea nähdä kaikkia mahdollisia tulevaisuudessa käytettäviä syötekokoja. Algoritmien tehokkuutta arvioitaessa käytetään erilaisia vaativuuden käsitteitä. Käytännössä useimmin tarkastellaan algoritmin aikavaativuutta eli arvioidaan algoritmin suoritusaikaa. Joskus ollaan kiinnostuneita myös tilavaativuudesta, jolloin arvioidaan algoritmin suorituksen vaatiman aputilan tarvetta. Aputila tarkoittaa tässä algoritmin syötteiden ja tulosteiden tallettamisen lisäksi tarvittavaa muistitilaa. Joskus joudutaan paneutumaan myös laitteistovaativuuteen, kuten esimerkiksi selvitettäessä, kuinka monta ulkoista muistivälinettä algoritmin tehokas toteuttaminen edellyttää tai kuinka nopeasti tietoja on pystyttävä vaihtamaan eri laitteiden kesken. Muisti- ja muut resurssit ovat tosin nykyisin yhä harvemmin tehokkuuden pullonkauloja. Tällä kurssilla arvioidaan lähinnä aikavaativuutta, mutta paikoin tarkastellaan muitakin vaativuuskysymyksiä. Aikavaativuutta arvioitaessa on tärkeä ymmärtää, että vaativuudeltaan erilaistenkin algoritmien todellisten suoritusaikojen erot ovat merkityksettömiä pienten ongelmien käsittelyssä. Vasta kooltaan suuret ongelmat paljastavat algoritmin hitauden. Erot voivat tällöin olla dramaattiset. Ongelman pienuus ja suuruus puolestaan ovat suhteellisia käsitteitä. Esimerkiksi lajitteluongelma on pieni, kun lajiteltavana on kymmeniä alkioita, mutta joillekin verkkoalgoritmeille kymmensolmuinen verkkokin voi olla suuri käsiteltävä. Ohjelman suoritusaikaa voitaisiin mitata kellolla. Tällainen mittaaminen ei kuitenkaan tuottaisi vertailukelpoisia tuloksia, koska kulloiseenkin suoritukseen kuluvan ajan pituuteen vaikuttavat monet sellaiset tekijät, joita on vaikea tai jopa mahdoton kontrolloida. Näitä ovat esimerkiksi käytetty kääntäjä ja laitteisto sekä laitteiston hetkelliset kuormituserot. Absoluuttisen suoritusajan mittaamisen asemesta onkin mielekkäämpää arvioida suhteellista suoritusaikaa, jonka arviointi onnistuu paitsi ohjelmille, myös algoritmeille. Tämä on algoritmin suunnittelijan kannalta merkittävä etu, koska sen ansiosta voidaan keskenään vertailla vielä toteuttamattomiakin algoritmeja ja siten välttää tehottomaksi todetun algoritmin toteuttamisesta aiheutuva turha työ. Suoritusajan yksikkönä 1. ALGORITMIIKASTA 11 käytetään joustavaa termiä "askel". Kuva 1-7 havainnollistaa erään tulkinnan, myöhemmin tarkennamme käsitettä edelleen. a = 1; b = 3; 1 for (i = 0; i < 1000; i++) a = a + 1; 1 2 2 (n.) kaksi askelta (n.) 1000 askelta Kuva 1-7: Suoritusaskel. TRAI 31.8.2012/SJ Suoritusaika suhteutetaan tavallisesti algoritmin syötteen kokoon. Syöte on tässä ymmärrettävä laajasti: algoritmi voi saada syötteensä suoraan käyttäjältä, tiedostoista tai vaikkapa parametrien välityksellä. Joissakin tapauksissa varsinaista syötettä ei ole lainkaan olemassa. Silloin suorituksen kesto määräytyy esimerkiksi käytettyjen vakioiden arvoista. Satunnaisuuteen perustuvien algoritmien aikavaativuuden arvioinnissa puolestaan sovelletaan todennäköisyyslaskennan menetelmiä (satunnaisalgoritmeja käsitellä lyhyesti TRA2 kurssilla). Syötteestäkään eivät kaikki osat aina ole aikavaativuutta arvioitaessa mielenkiintoisia, kuten seuraavassa esimerkissä todetaan: Esimerkki 1-8: Lukekoon algoritmi ensiksi sata kokonaislukua ja sen jälkeen vielä mielivaltaisen määrän muita syötteitä, joihin kaikkiin sovelletaan jotakin sadan ensimmäisen syötteen määräämää laskentaa. Koska algoritmin jokaisella suorituskerralla luetaan mainitut sata lukua, ei niiden lukeminen vaikuta suhteelliseen suoritusaikaan. Sen sijaan syötteen loppuosan vaikutus on olennainen: viisi syötettä luetaan ja käsitellään varmasti nopeammin kuin miljoona syötettä. Siirräntään kuluvaa aikaa ei vaativuusanalyysissä useinkaan oteta lukuun, koska siirrännän nopeuteen vaikuttavat algoritmista riippumattomat tekijät. Syötteen oletetaankin tavallisesti olevan jollakin tavoin valmiina saatavilla, esimerkiksi lajiteltavien alkioiden taulukossa, josta ne saadaan vaivattomasti esiin. Tällä oletuksella ei yleensä ole merkitystä suoritusaikaa määrättäessä, mutta joskus syötteiden käsittelyyn kuluva aika on erityisesti huomattava ottaa aika-arvioon mukaan. Näin on laita seuraavan esimerkin tapauksessa: Esimerkki 1-9: Algoritmi lukee kokonaislukuja, kunnes syötteenä annetaan sama luku viisi kertaa peräkkäin. Lopuksi algoritmi tulostaa neljännen lukemistaan luvuista. Mikä on algoritmin suoritusaika? Koska algoritmi tulostaa neljännen syötteensä, joka saadaan selville, kun on ensin luettu kolme muuta syötettä, syntyy helposti käsitys, että algoritmin suoritukseen kuluva aika on "4" eli vakio. Tämä käsitys paljastuu asiaa tarkemmin pohdittaessa virheelliseksi: Algoritmin suoritus päättyy vasta sitten, kun kaikki syötteet on saatu luetuksi. Mielivaltaista määrää kokonaislukuja ei mitenkään pystytä lukemaan vakioajassa, vaan aikaa kuluu sitä enemmän, mitä useampia lukuja algoritmille syötetään. Sen vuoksi suoritusajan määrääkin nyt syötteen lukemiseen kuluva aika, toisin sanoen syötteen todellinen koko. 1. ALGORITMIIKASTA 12 Syötteen tai sen vastineen suoritusajan arvioimisen kannalta merkityksellisen osan tunnistamista varten ei voida antaa täsmällisiä ohjeita. Taito tähän tunnistamiseen kehittyy harjoituksen myötä. Varsin usein tunnistaminen itse asiassa onkin triviaali tehtävä. Syötteen koko saattaa vaihdella suorituskerrasta toiseen, mutta aikavaativuus tulisi pystyä arvioimaan yleisesti algoritmin mielivaltaiselle suoritukselle. Sen vuoksi suoritusaika esitetään syötteen koon funktiona. Jos syötteen kokoa merkitään n:llä, on luontevaa käyttää suoritusaikafunktiolle merkintää T(n). Näinollen askelten määrä ilmaistaan syötteen funktiona, esimerkiksi: TRAI 31.8.2012/SJ for (i = 0; i < n; i++) a = a + 1; 1 2 (n.) n askelta Mikäli syöte koostuu useista toisistaan riippumattomista osasista, jotka kaikki ovat aikaarvioiden kannalta merkittäviä, käytetään syötteen koon kuvaamiseen useita muuttujia ja vastaavasti suoritusaikakin ilmaistaan usean muuttujan funktiona. Aina on muistettava varmistaa, että suoritusaikafunktiossa käytettävien tunnusten (esim. n) merkitys on selkeä, eli kertoa mitä syötteen ominaisuutta ne kuvaavat. Vastaavasti, jos annetun ohjelmanosan syötteen kokoa merkitään jollain muulla kirjaimella, myös aikavaativuusfunktio annetaan sitä käyttäen. Jos sitten muuttuja korvataan toisella, muutos on tehtävä myös aikavaativuusfunktioon. Esimerkki 1-10: Suoritusaikafunktio T(n) = cn2 + b, missä b ja c ovat vakioita, ilmaisee suoritusajan olevan neliöllinen suhteessa syötteen kokoon n. Tämä merkitsee, että syötteen koon kymmenkertaistuessa suoritusaika suurin piirtein satakertaistuu. Esimerkki 1-11: Esimerkiksi merkkijonon etsinnän toisesta merkkijonosta aikavaativuus riippuu sekä etsittävästä avaimesta, että läpikäytävästä kohdetekstistä. Kuvataan avaimen pituutta m:llä ja kohdetekstin pituutta n:llä. Erään yksinkertaisen etsintäalgoritmin suoritusaikafunktio on T(n, m) = cnm, missä c on vakio. Funktion T mittayksikköä ei kiinnitetä. Voidaan ajatella, että lasketaan algoritmin suorittamien käskyjen tai muiden keskeisten perustoimintojen lukumäärä. Taito nähdä, minkä toimintojen lukumäärä kulloinkin on mielekästä laskea, kehittyy harjoituksen myötä samoin kuin syötteen koon tunnistamisen taitokin. Funktion lausekkeessa esiintyvien vakioiden todelliset arvot taas riippuvat käytettävästä kääntäjästä ja laitteistosta, joiden vaikutusta ei voida ennakoida. Sen vuoksi näiden vakioiden merkitykselle ei pidä antaa liian suurta painoa. Tärkeimpiä ovat syötteen kokoa sisältävät termit. Suoritusaika ei aina riipu pelkästään syötteen koosta, vaan myös syötteen laadusta. Kun tämä otetaan huomioon, voidaan tarkastelu eriyttää seuraaviin kolmeen tapaukseen: 1) T(n) tarkoittaa pahimman tapauksen suoritusaikaa eli pisintä mahdollista n:n kokoisen syötteen vaatimaa suoritusaikaa. 2) Tavg(n) tarkoittaa keskimääräistä suoritusaikaa eli kaikkien n:n kokoisten syötteiden vaativien suoritusaikojen keskiarvoa. 3) Tbest(n) tarkoittaa parhaan tapauksen suoritusaikaa eli lyhintä mahdollista n:n kokoisen syötteen vaatimaa suoritusaikaa. 1. ALGORITMIIKASTA 13 Kuten jo merkinnöistäkin nähdään, tarkastellaan yleensä aina pahinta tapausta, ellei erityisesti mainita jostakin muusta tapauksesta. Paras tapaus ei useinkaan ole edes mielenkiintoinen. Esimerkiksi lajittelussa paras tapaus voisi olla valmiiksi lajiteltu kokoelma. Tämä tuskin kertoo lajittelualgoritmin hyvyydestä mitään. Keskimääräisen suoritusajan arviointi saattaa puolestaan osoittautua erittäin hankalaksi tehtäväksi, koska kaikki samankokoiset syötteet voidaan vain harvoin olettaa keskenään yhtä todennäköisiksi. Pahimman tapauksen tarkka analysointikin voi tosin joskus olla vaivalloista. Jos esimerkiksi sama syöte ei ole pahin algoritmin kaikille osille, joudutaan ensin etsimään kokonaisvaikutukseltaan pahin syöte. Seuraavassa esitettävä kertaluokkatarkastelu kuitenkin helpottaa analysointia melkoisesti. Kertaluokat TRAI 31.8.2012/SJ Kun algoritmien suoritusajat ilmaistaan syötteen koon funktioina, voidaan aikoja vertailla toisiinsa funktioiden kasvunopeuksia vertailemalla. Vakiokerrointen todellisten arvojen häilyvyyden vuoksi tarkastelua ei viedä äärimmilleen, yksittäisten funktioiden tasolle, vaan tarkastellaan funktioiden kertaluokkia. Kertaluokkatarkastelussa käytetään apumerkintöjä O, Ω, Θ ja o, joiden merkitys määritellään seuraavasti: Määritelmä 1-12: Kertaluokkamerkinnät O, Ω, Θ ja o. 1) T(n) = O(f(n)), jos on olemassa positiiviset vakiot c ja n0 siten, että T(n) ≤ cf(n), kun n ≥ n0. [Lue: T(n) on kertaluokkaa f(n), iso-o, ordo; "rajoittaa ylhäältä"] 2) T(n) = Ω(g(n)), jos on olemassa positiiviset vakiot c ja n0 siten, että T(n) ≥ cg(n), kun n ≥ n0. [omega; "rajoittaa alhaalta"] 3) T(n) = Θ(h(n)), jos T(n) = O(h(n)) ja T(n) = Ω(h(n)). [theta; "rajoittaa ylhäältä ja alhaalta"] 4) T(n) = o(p(n)), jos T(n) = O(p(n)) ja T(n) ≠ Θ(p(n)). [pikku-o; "rajoittaa aidosti ylhäältä"] Ensimmäinen määritelmistä antaa suoritusaikafunktion T kasvunopeudelle ylärajan: kyllin suurilla n:n arvoilla funktio T kasvaa enintään yhtä nopeasti kuin vakiolla c kerrottu funktio f. Toinen määritelmä antaa vastaavasti kasvunopeuden alarajan: kyllin suurilla n:n arvoilla funktio T kasvaa vähintään yhtä nopeasti kuin vakiolla c kerrottu funktio g. Kolmas määritelmä sitoo funktion T kasvunopeuden samaksi kuin on funktion h kasvunopeus. Viimeinen määritelmä rajaa funktion T kasvunopeuden aidosti hitaammaksi kuin funktion p kasvunopeus, eli kaikilla vakiolla c on T(n) < cp(n), kun n on kyllin suuri. Määritelmät esitetään joissakin lähteissä hieman eri muotoisina, mutta olennaisesti samaa tarkoittavina. Tällä kurssilla käytetään lähinnä vain ylärajan ilmaisevaa O-merkintää, vaikka usein voitaisiin sen asemesta käyttää täsmällisempää Θ-merkintää. Ylärajaominaisuus on näet transitiivinen, toisin sanoen jos f(n) = O(g(n)) ja g(n) = O(h(n)), niin silloin myös f(n) = O(h(n)). Tämä merkitsee, että ylärajoja on aina useita. Θ-merkinnän määräämä rajafunktio sen sijaan on yksiymmärteinen: mahdollisimman tiukka. Ylärajoistakin pyritään aina löytämään tiukin, jotta kertaluokkavertailut vastaisivat tarkoitustaan. 1. ALGORITMIIKASTA 14 Esimerkki 1-13: Olkoon T(n) = 5n+2. Silloin T(n) = O(n), mikä nähdään vaikkapa valitsemalla c = 7 ja n0 = 1: 5n+2 ≤ 5n+2n = 7n, kun n ≥ 1. Ylärajan n rinnalle kelpaisivat myös ylärajat n2, n3 ja niin edelleen. Koska T(n) = Ω(n), mikä nähdään esimerkiksi valitsemalla c = 5 ja n0 = 1, on n myös alaraja, joten n on tiukin yläraja ja itse asiassa T(n) = Θ(n). Määritelmässä 1-12 esiintyvien epäyhtälöiden ei tarvitse päteä arvoilla n < n0. Usein funktioiden keskinäinen järjestys pienillä n:n arvoilla poikkeaakin siitä järjestyksestä, joka vallitsee tarkasteltaessa suuria n:n arvoja. Esimerkiksi 5n+2 > 6n, kun n = 1, ja vasta kun n ≥ 2, on 5n+2 ≤ 6n. Aikavaativuustarkastelussa pienet n:n arvot ovat merkityksettömiä, koska vasta suuret syötteet paljastavat algoritmien suoritusaikojen kertaluokkien erot. Kertaluokkaa arvioitaessa voidaan määritelmässä 1-12 esiintyvät vakiot c ja n0 valita vapaasti, kunhan valinta vain toteuttaa määritelmän epäyhtälön. Esimerkin 1-13 yläraja n olisi löytynyt myös valitsemalla c = 6 ja n0 = 12, c = 70 ja n0 = 1 tai jollakin muulla tavoin. Ylärajatarkastelussa negatiiviset termit on helppo pudottaa pois ja positiiviset on siis saatava sulautumaan merkitsevimpään termiin. TRAI 31.8.2012/SJ Esimerkki 1-14: Ylärajatarkastelu: T(n) = 2n2–4n+5. 2n2–4n+5 ≤ 2n2 +5 ≤ 2n2+5×n2 = 7n2, eli 2n2–4n+5 = O(n2). Alarajatarkastelussa on päinvastoin positiiviset termit helppo pudottaa pois ja negatiiviset termit on saatava sulautumaan merkitsemimpään termiin. Sulauttamisessa on varottava ettei merkitsevin termi mene negatiiviseksi (negatiivisia suoritusaikoja ei vielä ole keksitty!). Esimerkki 1-15: Alarajatarkastelu: T(n) = 2n2–4n+5. 2n2–4n+5 ≥ 2n2–4n ≥ 2n2–4n 1 ≥ 2n2–4n× --- n [pitää paikkansa kun n≥4] = 1n2, eli 2n2–4n+5 = Ω(n2). 4 1 Alarajatarkastelussa voidaan lisätä kerroin --- n negatiiviseen termiin, sillä kun n≥4 4 (siis n0 = 4), niin negatiivinen termi on entistä suurempi ja siten pienentää kokonaisuutta. Esimerkiksi kerroin n olisi sensijaan tehnyt koko funktion negatiiviseksi ja siten mahdottomaksi. Esimerkki 1-16: Esimerkkien 1-14 ja 1-15 nojalla 2n2 –4n+5 = Θ(n2). Ellei epäyhtälön toteuttavia vakioita laisinkaan löydy, on kertaluokkayrite tarkasteltavaan funktioon nähden väärä. Näytettäessä ettei funktio f(n) ole kertaluokkaa g(n) on toisin sanoen osoitettava, ettei ole olemassa sellaisia positiivisia vakioita c ja n0, että epäyhtälö f(n) ≤ cg(n) pätisi kaikilla arvoilla n ≥ n0. Esimerkki 1-17: Näytetään, ettei funktio T(n) = 5n+2 ole kertaluokkaa 1 eli vakio: Jos olisi T(n) = O(1), niin määritelmän 1-12 nojalla olisi olemassa positiiviset vakiot c ja n0 siten, että 5n+2 ≤ c, kun n ≥ n0. Epäyhtälöt 5n+2 ≤ c ja n ≤ (c–2)/5 ovat yhtäpitävät. Koska c on vakio, on myös (c–2)/5 vakio. Tämä merkitsee, ettei epäyhtälö 5n+2 ≤ c toteudu ainakaan silloin, kun n > max{n0, (c–2) /5}, mikä on vastoin oletusta. Ristiriita, eli väite on väärä. 1. ALGORITMIIKASTA 15 Kertaluokkatarkastelussa ei suoritusaikafunktioiden lausekkeissa esiintyvillä vakiokertoimilla ja vakiotermeillä ole merkitystä. Esimerkiksi funktiot 2n ja 1000000n ovat molemmat O(n), mikä nähdään esimerkiksi valitsemalla c = 1000001 ja n0 = 1. Näiden funktioiden arvot toki poikkeavat toisistaan huomattavasti, mutta ne kasvavat samaa vauhtia. Sen sijaan esimerkiksi kertaluokat n ja n2 ovat olennaisesti erilaiset: kun n kasvaa, kasvaa n2 yhä vauhdikkaammin. Kahdesta eri kertaluokkaa olevasta algoritmista kertaluokaltaan pienempi on yleensä myös tehokkaampi. Pienillä syötteillä tilanne tosin voi kääntyä päinvastaiseksi, mutta pienillä syötteillä suoritusajalla ei yleensä ole merkitystä. Esimerkki 1-18: Kuva 1-8 esittää kymmenen aikavaativuudeltaan eri kertaluokkaa ole- g 2n n3 2n n lo Suoritusaika (T(n)) 350 TRAI 31.8.2012/SJ n2 400 300 250 200 n√n 150 n log 100 50 0 n 3n n + 10 n logn 0 5 10 15 20 25 Syötteen koko (n) 30 Kuva 1-8: Eri funktioiden kasvunopeuksia. Huomaa, että suurinkin kuvassa näkyvä n on varsin pieni, 30. Suuremmilla n:n arvoilla jotkin funktiot vielä vaihtavat järjestystä. van ohjelman suoritusajat syötteen koon funktioina. Ajat on mitattu millisekunteina ja kaikki mittaukset on tehty samalla laitteistokokoonpanolla. Graafisesti eri funktioiden kasvunopeutta on vaikea tarkastella, varsinkaan suuremmilla n:n arvoilla. Esimerkiksi kuvasta on vaikea uskoa, että nlog2 n = o( n n ). Sensijaan taulukosta 1-9 näemme saman asian paremmin numeerisessa muodossa. Taulukossa on esitetty kymmenen eri ohjelman aikavaativuuksien vaikutus käsiteltävissä olevaan syötteeseen. Oletamme, että yhden operaation viemä aika on yksi millisekunti. Kullekkin ohjelmalle on laskettu se syötteen koko, jonka ne ehtivät käsitellä yhdessä sekunnissa, minuutissa, jne. Esimerkiksi aikavaativuudeltaan n oleva ohjelma ehtii minuutissa käsittelemään syötteen, jossa on 60000 alkiota. Näemme, että logaritmista aikavaativuutta oleva ohjelma ehtii varmasti käsitellä sekunnissa kaiken tarvittavan tiedon. (Tosin logaritmista aikavaativuuttahan ei peräkkäisalgoritmeissa 1. ALGORITMIIKASTA 16 TRAI 31.8.2012/SJ Taulukko 1-9: Aikavaativuuserot numeroina. Aikavaativuus T(n), ms Ohjelman käsittelemän syötteen koko n annetussa ajassa: Sekun nissa Minuutissa Tunnissa Päivässä Vuodessa logn 10301 1018 061 101 083 707 1026 008 991 109 493 281 943 n 1 000 60 000 3 600 000 86 400 000 31 536 000 000 n+10 990 59 990 3 599 990 86 399 990 31 535 999 990 3n 333 20 000 1 200 000 28 800 000 10 512 000 000 nlogn 140 4 895 204 094 3 943 234 1 052 224 334 nlog2 n 36 678 18 013 266 037 48 391 041 n n 100 1 532 23 489 195 438 9 981 697 n2 31 244 1 897 9 295 177 583 n3 10 39 153 442 3 159 2n 9 15 21 26 34 voi koko algoritmilla ollakaan. Algoritmin osa, kuten binäärihaku, voi olla logaritminen aikavaativuudeltaan). Aikavaativuudeltaan O(n) ja O(nlogn) olevat ohjelmat hyötyvät lisääntyneestä ajasta edelleen hyvin. Sensijaan O(n2) ja O(n3) hyötyvät jo huomattavasti vähemmän. Eksponentiaalinen O(2n) ohjelma ei kykene käsittelemään suuria tietomääriä, vaikka sitä suoritettaisiin vuosia. Samanlaisia tuloksia voitaisiin havaita vaikka yksi operaatio kestäisi millisekunnin sijasta nanosekunnin. Aikavaativuuksien luokittelu Käytännössä aikavaativuusfunktioiden vertailu on melko helppoa. Tärkeintä on havaita suoritusaikafunktiosta merkittävin tekijä, käytännössä siis nopeimmin kasvava osa. Perusmuotoisen funktion lisäksi aikavaativuudessa voi olla jokin osa peruslaskutoimituksella (×, /, +, –) mukaan liitettynä. Tällöin sen aiheuttama muutos on tietysti otettava huomioon. Nopeimmin kasvavia funktioita ovat eksponenttifunktiot, kuten, esimerkiksi 2n, 3n, 2n/n. Näissä syötteen koko on siis ykköstä suuremman luvun eksponenttina. Käytännössä useimmat aikavaativuudet ovat polynomisia, esimerkiksi n, n2, n5, n12345, n 3 n . Mitä suurempi eksponentti, sitä suurempi aikavaativuus. Polynomisiin kuuluvat myös aikavaativuudet muotoa nc, missä 0<c<1 on. Nämä ovat alilineaariasia, ts. o(n). Näitäkin hitaammin kasvavia ovat logaritmiset aikavaativuudet, kuten logn, loglogn, (logn)2. Vakiofunktiot (O(1)) eivät kasva lainkaan. Eri kertaluokkafunktioiden keskinäisen järjestyksen selvittäminen voi joskus olla pulmallista. Tämäntyyppinen ongelma ratkeaa lähes aina niin sanottua L'Hôspitalin sääntöä soveltaen (derivoimalla osamäärän molemmat puolet): jos limn→∞f(n) = ∞ ja limn→∞g(n) = ∞, niin limn→∞(f(n)/g(n)) = limn→∞(f'(n)/g'(n)). Sääntöä sovelletaan toistuvasti, kunnes raja-arvo saadaan selville. Jos raja-arvo on (1-1) 1. ALGORITMIIKASTA 17 0, on f(n) = o(g(n)) c ≠ 0, on f(n) = Θ(g(n)) ∞, on g(n) = o(f(n)). (1-2) Ellei raja-arvo ole yksiymmärteinen, ei kertaluokkia voida asettaa järjestykseen. Esimerkki 1-19: nlogn vs. n1,5 g(n) = n1,5 f(n) = nlogn 1 f ’(n) = logn+ -------ln2 1 f ’’(n) = ----------nln2 TRAI 31.8.2012/SJ (1-3) 1 --2 3 g’(n) = --- n 2 3 g’’(n) = ---------4 n (1-4) (1-5) 1 ----------nln2 4 n 4 lim ----------- = lim -------------- = lim ------------------ → 0 n→∞ 3 n → ∞3nln2 n → ∞3ln2 n ---------4 n (1-6) ∴ f ( n ) = o ( g ( n ) ) , ts. nlogn = o(n1,5) (1-7) Sama voidaan todeta epämuodollisemmin päättelemällä: nlogn ?? n3/2 | :n (1-8) logn ?? n1/2 | ( )2 (1-9) log2 n ?? n (1-10) Kun muistetaan, että log kn = o(n), niin voimme todeta, että n3/2 kasvaa nopeammin. Usein kertaluokkien järjestys voidaan päätellä yksinkertaisemminkin kuin laskemalla derivaattoja ja raja-arvoja. Esimerkiksi nk = o(nk+1) kaikilla vakioilla k. Edellisen tarkastelun perusteella voidaan todeta, että laitteiston tehokkuuden lisäyksen asemesta olisikin tuottoisampaa pyrkiä nopeuttamaan ohjelmia, toisin sanoen tulisi pyrkiä suunnittelemaan entistä tehokkaampia algoritmeja. Tämä on haasteellinen tehtävä, sillä moniin usein esiintyviin ongelmiin tunnetaan toistaiseksi vain tehottomia ratkaisuja, vaikka tehokkaitakin ratkaisuja saattaa olla olemassa. Joillekin ongelmille taas on osattu todistaa vaativuuden alaraja eli on näytetty, ettei ongelmaa voida ratkaista alarajaa nopeammin. Yksi näistä ongelmista on yleinen lajitteluongelma, jonka aikavaativuus on O(nlogn). Tämä alaraja on jo saavutettu, mutta jotkin muut todistetut alarajat ovat vielä teoreettisia. On myös olemassa joukko ongelmia, joita ei lainkaan voida ratkaista algoritmisesti, kuten esimerkiksi pysähtymisongelma. Algoritmiikan piirissä onkin vielä runsaasti tutkittavaa ja tässä tutkimustyössä vaativuusanalyysin rooli on keskeinen. Vaativuusanalyysiä tarvitaan jopa yksittäistä algoritmia tehostettaessakin, sillä algoritmin tehottomat osat täytyy löytää ennen kuin tehostamisyrityksiä kannattaa edes aloittaa. Johtopäätöksenä voitaisiin sanoa, että prosessorin kellotaajuuden kymmenkertaistamisen hyöty on aika pieni verrattuna algorimin kehittämiseen vaikkapa O(n2):sta O(nlogn):een. 1. ALGORITMIIKASTA 18 TRAI 31.8.2012/SJ 1.4 Suoritusajan laskeminen käytännössä Mielivaltaisesti valitun ohjelman suoritusajan laskeminen voi joskus osoittautua erittäin vaativaksi matemaattiseksi tehtäväksi. Onneksi useiden käytännössä esiintyvien ohjelmien suoritusaika on varsin helppo arvioida. Yleensä riittää tuntea aritmeettisen ja geometrisen sarjan summauskaavat, logaritmilaskennan peruslaskusäännöt sekä joitakin kertaluokkalaskennan periaatteita. Rekursiivisia ohjelmia analysoitaessa on lisäksi osattava ratkaista rekursioyhtälöitä. Esitetään seuraavaksi muutamia kertaluokkalaskennan sääntöjä. Niitä ei tässä todisteta, vaikka todistukset ovatkin yksinkertaisia, määritelmään 1-12 perustuvia. Jos ohjelmanosan P1 suoritusaika T1(n) on O(f(n)) ja ohjelmanosan P2 suoritusaika T2(n) vastaavasti O(g(n)), niin osien P1 ja P2 peräkkäiseen suoritukseen kuluva aika T1(n)+T2(n) on O(max{f(n), g(n)}). Tätä sääntöä kutsutaan summasäännöksi ja se merkitsee, että peräkkäissuorituksen aikavaativuuden määrää hitain osasuoritus. Summasäännön välittömänä seurauksena nähdään, että jos T(n) on astetta k oleva polynomi, niin T(n) = O(nk). Itse asiassa pätee jopa T(n) = Θ(nk). Summasääntö pätee lähes aina. Vain joissakin harvoissa tapauksissa funktiot f ja g ovat sellaiset, ettei niistä kumpikaan kelpaa maksimiksi. Siinä tapauksessa peräkkäiseen suoritukseen kuluvan ajan kertaluokka määrätään tarkemman analyysin avulla. Monimutkaiset suoritusaikafunktion lausekkeet voidaan kertaluokkatarkastelussa usein sieventää lyhyemmiksi juuri summasäännön nojalla, koska koko lausekkeen asemesta riittää tarkastella vain eniten merkitseviä termejä. Toinen kertaluokkalaskennan perussäännöistä on niin sanottu tulosääntö: äskeisiä merkintöjä käyttäen tulo T1(n)T2(n) = O(f(n)g(n)). Tätä sääntöä sovelletaan laskettaessa sisäkkäisten ohjelmanosien suoritusaikaa. Säännön välitön seuraus on se, että positiiviset vakiokertoimet voidaan kertaluokkatarkastelussa aina jättää huomiotta, minkä ansiosta tarkasteltavat lausekkeet entisestään yksinkertaistuvat. Esimerkki 1-20: Jos T(n) = 3n2+5n+8, niin T(n) = O(n2). Summasäännön ja tulosäännön lisäksi on joskus hyötyä tiedosta, että kaikille vakioille k pätee logk n = O(n). Tämä sääntö vahventaa jo aiemmin mainittua lineaarisen ja logaritmisen aikavaativuuden välillä vallitsevaa suhdetta. Tarkastellaan seuraavaksi, miten äskeisiä sääntöjä sovelletaan, kun lasketaan yksinkertaisen algoritmin suoritusaika: Algoritmi 1-21: Kuplalajittelualgoritmi voidaan esittää muodossa public static void bubbleSort(Comparable A[]) { for (int i = 0; i < A.length–1; i++) { for (int j = A.length–1; j > i; j––) { if (A[j–1].compareTo(A[j]) > 0) { Comparable tmp = A[j–1]; A[j–1] = A[j]; A[j] = tmp; } } } } 1 2 3 4 5 6 7 8 9 10 TRAI 31.8.2012/SJ 1. ALGORITMIIKASTA 19 Algoritmi lajittelee n kokonaislukua sisältävän taulukon A kasvavaan järjestykseen. Tässä tapauksessa algoritmin syöte on parametrina saatava lajiteltava taulukko ja syötteen koko on lajiteltavien alkioiden lukumäärä n. Analyysi tehdään sisältä ulos. Niinpä todetaan aluksi, että kukin sijoituksista riveillä 5-7 suoritetaan syötteen koosta riippumatta vakioajassa. Näin voidaan olettaa, koska taulukko on kooltaan kiinteä tietorakenne, jonka jokainen alkio saadaan indeksoinnin avulla esiin yhtä vaivattomasti. Summasääntöä soveltaen saadaan rivien 5-7 suoritusajaksi O(max{1, 1, 1}) = O(1). Koska taulukkoviittaukset onnistuvat vakioajassa, saadaan myös rivin 4 ehto arvotetuksi vakioajassa O(1). Jos ehto on voimassa, suoritetaan rivit 5-7. Tämä on pahin tapaus. "Parhaassa" tapauksessa ehto ei ole voimassa eikä rivejä 5-7 suoriteta. Valinta näiden tapausten välillä eli rivin 4 ehdon arvon tutkiminen tapahtuu vakioajassa. Summasäännön mukaan rivien 4-8 suoritusaika on sekä pahimmassa että parhaassa tapauksessa O(1). Riviltä 3 alkavan toiston hallintaan tarvittavan askelmuuttujan käsittely sujuu vakioajassa, ja koska toistokertoja on (n–1)–(i+1)+1 = n–i–1, on rivin 3 oma aikavaativuus O(n–i–1). Toistettavan osan suoritusajan laskettiin äsken olevan pahimmassakin tapauksessa O(1), joten rivien 3-9 suoritus vie tulosäännön nojalla pahimmillaan aikaa O((n–i–1)×1) = O(n–i–1). Koska algoritmin tarkasteleminen aloitettiin sisimmältä tasolta, ei vielä tiedetä, miten muuttujan i arvo määräytyy, joten lauseketta n–i–1 ei voida ainakaan toistaiseksi sieventää. Ulompi toisto suoritetaan kaikkiaan n–1 kertaa ja tällöinkin askelmuuttuja käsitellään vakioajassa, mutta toistettavan osan eli rivien 3-9 suoritusajan lausekkeessa esiintyy nyt i, jonka arvo muuttuu koko ajan toiston edistyessä: i saa vuorotellen arvot 0, … , n–2. Tulosääntöä ei tällaisessa tilanteessa voida perustelematta soveltaa, vaan tarkan tuloksen saamiseksi on summattava yksittäisten kierrosten suoritusajat eli laskettava summa (n–1)+(n–2)+(n–3)+…+(n–i)+…+(1). (1-11) Tämä on aritmeettinen sarja, jonka summa on (n–1)× (n) / 2 = (n2 –n)/2. (1-12) Näin ollen koko algoritmin suoritusaika on T(n) = (n2 –n)/2, joka on O(n2). (1-13) Saatu tulos on sekä pahimman että parhaan tapauksen aikavaativuus. Tämä merkitsee, että kuplalajittelu vaatii aina neliöllisen ajan. (Mitkä ovat kuplalajittelun paras ja pahin tapaus?) Edellinen esimerkki oli laskennallisesti helpohko. Vastedes aikavaativuuksia laskettaessa analyysiä ei esitetä aivan yhtä yksityiskohtaisesti, vaan yksinkertaisimmat kohdat oletetaan ilmeisiksi ja vain vaativimpiin kohtiin paneudutaan tarkemmin. Laskenta on näet yleensä pääosin suoraviivaista ja sisältää algoritmista riippumatta monasti kovin samankaltaisia osia. Kaikkein vaikeimmat kohdat puolestaan ratkaistaan tapaus tapaukselta erikseen, sillä joka tilanteeseen sopivaa laskentasääntöjen kokoelmaa on mahdoton esittää. Seuraava ohjeisto kattaa tavallisimmat analysoinnissa kohdattavat tilanteet: Sijoitus-, luku- ja tulostustoiminnot ovat yleensä O(1). Kokonaisen taulukon tai muun suuren tietorakenteen käsittelyyn kuluu kuitenkin enemmän aikaa. Niinpä esimer- 1. ALGORITMIIKASTA 20 kiksi n alkiota sisältävän taulukon sijoittaminen toiseen taulukkoon vie aikaa O(n). Tämä on syytä muistaa arvoparametrien välittämisen yhteydessä. Myös lausekkeen arvottaminen onnistuu vakioajassa, ellei lauseke sisällä aikaa vaativia funktiokutsuja. Tavanomaiset taulukkoviittauksetkin ratkeavat vakioajassa. Toimintojonon eli peräkkäin suoritettavien toimintojen suoritusaika lasketaan summasäännön avulla. Tästä poiketaan vain silloin, kun sama syöte ei koskaan voi olla pahin tapaus kaikille toimintojonon osille. Ehdollisen toiminnon suoritusaikaa laskettaessa on otettava huomioon sekä ehdon arvottamiseen että valittavan osan suorittamiseen kuluva aika. Vaihtoehtoisista osista tarkastellaan aina aikavaativuudeltaan pahinta, ellei ole perusteltua syytä jonkin vähemmän vaativan osan huomioon ottamiseen. Jos pahin tapaus on hyvin harvinainen, on niiden käsittely irroitettava muista ja laskettava erikseen. Esimerkiksi: TRAI 31.8.2012/SJ for (i = 0; i < n; i++) if (i == n–1) for (j = 0; j < n; j++) a = a + 1; else x = x + 1; 1 2 // tämä suoritetaan vain kerran, O(n) 3 4 5 // tämä vakioaikainen suoritetaan useasti6 Tässä esimerkissä ensimmäiset n–1 iteraatiota suorittavat else-osan, ja vain viimeinen then -osan. Näinollen aikavaativuus on laskettava then ja else -haarat erikseen., eli (n–1)×O(1) + 1×O(n) = O(n). Itse valinta voidaan katsoa vakioajassa tapahtuvaksi myös monihaaraisessa ehtorakenteessa, koska rakenne sisältää joka tapauksessa kiinteän määrän eri vaihtoehtoja. Toiston vaativuus lasketaan joko tulosäännöllä tai summaamalla toistettavan osan yksittäiset suoritusajat yli koko toiston. Jälkimmäistä menettelyä sovelletaan silloin, kun toistettavan osan aikavaativuus riippuu jostakin toistoon nähden ulkopuolisesta tekijästä. Säännöllisen toiston vaativuus voidaan usein laskea aivan suoraan kertomalla keskenään toistokertojen lukumäärä ja toistettavan osan pahimman tapauksen suoritusaika, mutta tässä on oltava tarkka, sillä pahin tapaus ei ehkä voi esiintyä joka kerralla saman toiston kuluessa. Toisto voidaan jakaa kahteen eri osaan ja analysoida ne erikseen. Toistokertojen todellisen lukumäärän selvittäminenkin voi joskus olla vaikea. Toiston hallinta vie myös oman aikansa, joka täytyy muistaa ottaa laskelmissa huomioon. Algoritmeihin ei rakenteisen ohjelmoinnin periaatteita noudatettaessa sisällytetä lainkaan hyppyjä. Jos hypyn vaikutusta aikavaativuuteen joudutaan analysoimaan, on huolellisesti tutkittava, mikä on suorituksen pahin tapaus ja täytyykö pahimmassa tapauksessa hypätä. Mikäli koko algoritmin toiminta perustuu hyppyihin, tulee analyysistä todennäköisesti erittäin hankala. Kunkin aliohjelman suoritusaika lasketaan erikseen parametrien tai muun mielekkään syötteen koon funktiona. Aliohjelmakutsua analysoitaessa otetaan aliohjelman oman aikavaativuuden lisäksi huomioon parametrien välityksen vaatima aika. Jos aliohjelma saadaan käyttöön valmiina eikä sen aikavaativuutta saada selville, on tämä erikseen mainittava analyysissä, mikäli kyseisellä ajalla oletetaan olevan merkitystä kokonaisuuden kannalta. 1. ALGORITMIIKASTA 21 Rekursiivisten aliohjelmien tapauksessa joudutaan ratkaisemaan rekursioyhtälöitä. Tarkastellaan tämän luvun lopuksi esimerkinomaisesti helpon rekursioyhtälön ratkaisun etsinnässä käytettävää päättelyketjua. Algoritmi 1-22: Kertoma voidaan laskea seuraavalla algoritmilla: TRAI 31.8.2012/SJ public static int factorial(int i) { if (i <= 1) return 1; else return i * factorial(i–1); } 1 2 3 4 5 6 Algoritmin suoritukseen kuluva aika riippuu nyt parametrin n arvosta: mitä suurempi on parametrin arvo, sitä useampia rekursiivisia kutsuja aiheutuu ja sitä enemmän aikaa kuluu. Rivien 1-4 suoritusaika on selvästi O(1). Rivillä 5 suoritetaan kertolasku ja sijoitus, joiden aikavaativuus on myös O(1), mutta kertolaskun suorittaminen edellyttää molempien operandien arvon tuntemista. Nyt jälkimmäisen operandin arvo lasketaan rekursiivisesti, mihin kulunee aikaa enemmän kuin O(1). Merkitköön T(n) koko algoritmin suoritusaikaa. Perustapauksessa n ≤ 1 suoritetaan rivit 1-3, mihin kuluu vakioaika. Sovitaan, että T(n) = d, kun n ≤ 1. Vakion todellista arvoahan ei voida tietää, joten symbolinen merkintä d riittää. Kun n > 1, on T(n) = c + T(n–1). Tässä T(n–1) kuvaa rekursiivisesta kutsusta aiheutuvaa suoritusaikaa eli algoritmin suoritusaikaa n:ää yhtä pienemmällä parametrin arvolla. Vakio c taas kuvaa rivin 5 kertolaskuun ja sijoitukseen kuluvaa aikaa, jota ei saa unohtaa suoritusaikaa laskettaessa. Vakiothan väistyvät vasta kertaluokkatarkastelussa. Suoritusaikafunktio T on nyt määritelty kaikilla kelvollisilla parametrin n arvoilla: T(n) = d, kun n ≤ 1 T(n) = c+T(n–1), kun n > 1. (1-14) Koska funktio määritellään rekursiivisesti itsensä ja perustapauksen avulla, on kyseessä rekursioyhtälö. Funktio olisi toki tarkoituksenmukaisempaa esittää suljetussa muodossa, jonka löytämiseksi rekursioyhtälö täytyy ratkaista. Ryhdytään etsimään ratkaisua korvaamalla funktion lausekkeen rekursiivinen osa toistuvasti määrittelynsä mukaisella vastineellaan ja tarkkailemalla, esiintyykö tällöin jonkinlaista säännönmukaisuutta: Aloitetaan olettamalla, että n > 2. Silloin on määrittelyn mukaan T(n–1) = c+T(n–2), joten T(n) = c+T(n–1) = c+(c+T(n–2)) = 2c+T(n–2). Vastaavaan tapaan olettamalla n yhä suuremmaksi havaitaan pian, että T(n) = ic+T(n–i), kun n > i. Asettamalla i = n–1 saadaan vihdoin ratkaisu T(n) = c(n–1)+T(1) = c(n–1)+d. Tämä merkitsee, että T(n) = O(n). Äskeinen rekursioyhtälö ratkesi varsin vaivattomasti. Päätellyn ratkaisun oikeellisuus tosin jäi todentamatta. Täydelliseen ratkaisuun tarvittaisiin vielä vaikkapa induktiotodistus, jota ei tässä esitetä. Monimutkaisempien rekursioyhtälöiden ratkaisemisessa päädytään usein geometrisiin sarjoihin ja logaritmilaskentaan, joihin palataan TRA2-kurssilla. Luku 2 TRAI 31.8.2012/SJ Abstraktit listatyypit Kaikkein perustavanlaatuisin abstrakti tietotyyppi on lista useine eri muunnelmineen. Tässä luvussa tarkastellaan listaa ja sen joitakin tärkeitä erikoistapauksia sekä esitellään näiden avulla, miten abstraktia tietotyyppiä käytetään. Esityksen tavoitteena on tuottaa oivallus erilaisten listojen luonteen, käyttäytymisen ja käyttökohteiden eroista. Kaikkien abstraktien tietotyyppien perimmäisenä ideana on hallita jonkinlaista alkiokokoelmaa. Tällaiseen kokoelmaan täytyy tavallisesti pystyä lisäämään uusia alkioita ja kokoelmaan sisältyviä alkioita on voitava yksitellen hakea esiin. Alkioiden poistaminen ja muuttaminenkin on usein sallittu. Nämä neljä perusoperaatiotyyppiä toistuvat miltei kaikissa abstrakteissa tietotyypeissä. Kokoelman alkiot eivät itsessään ole hallinnan kannalta kiinnostavia muuten kuin alkioiden keskinäiseen järjestykseen liittyvien tietojen selvittämisen osalta. Puhtaimmillaan abstrakti tietotyyppi on sellainen, ettei tietotyypin sisältämällä tiedolla itsellään ole mitään merkitystä tietotyypin liittymän kannalta, toisin sanoen abstaktin tietotyypin operaatiot eivät mitenkään riipu siitä, millaisia alkioita kukin tietotyypin ilmentymä sisältää. Tällainen riippumattomuus saavutetaan parhaiten, jos abstrakti tietotyyppi parametroidaan, toisin sanoen jos alkioiden tyyppi esitetään tietotyypin määrittelyssä muodollisen parametrin avulla. Parametroidun tietotyypin ilmentymää muodostettaessa on silloin mainittava todellinen alkiotyyppi. Tästedes abstraktit tietotyypit oletetaan erikseen mainitsematta aina parametroiduiksi. Esimerkki 2-1: Listatyypin liittymä alkaa tekstillä: public class List<E> { 1 Missä E kuvaa alkioiden tyyppiä eräänlaisena paikanpitäjänä kuten muodollinen parametri aliohjelmissa/metodeissa. Tyyppi kiinnitetään listoja luotaessa kuten todelliset parametrit aliohjelmissa/metodeissa: List<Integer> lukuLista = new List<Integer>(); List<List<Integer>> listaLista = new List<List<Integer>>(); List<Henkilo> hloLista = new List<Henkilo>(); 1 2 3 Jollemme ole kiinnostuneita alkioista, voimme luoda myös "perusmuotoisen" kokoelman. Tällöin alkiotyyppi on Object: List olioLista = new List(); 1 22 2. ABSTRAKTIT LISTATYYPIT 23 Tehtäessä geneerisiä algoritmeja kokoelmille, voimme rajoittaa tarvittaessa mahdollisten alkioiden tyyppiä: static void vertailua(List<? extends Comparable>) { … 1 Tähän palaamme kurssin lopussa. Toistaiseksi tyydymme vakiomuotoisiin listoihin vaikka kääntäjä niiden käytöstä hieman varoittaakin. 2.1 Lista Lista abstraktina tietotyyppinä on keskenään samantyyppisten alkioiden kokoelma, jonka alkiot ovat toisiinsa nähden peräkkäissuhteessa. Lista voi olla tyhjä, jolloin se ei sisällä yhtään alkiota. Epätyhjä lista puolestaan sisältää mielivaltaisen määrän alkioita. Listan pituus on listan sisältämien alkioiden lukumäärä. Listan pituus voi listan elinkaaren aikana vaihdella huomattavasti, koska listaan voidaan lisätä ja siitä voidaan poistaa alkioita kulloisenkin käyttötarpeen edellyttämällä tavalla. TRAI 31.8.2012/SJ Esimerkki 2-2: Lista voidaan esittää vaikkapa luettelemalla listaan sisältyvät alkiot peräkkäin. Viisialkioinen lista L kuvataan esimerkiksi seuraavalla tavalla: L = a1, a2, a3, a4, a5. (2-1) Tyhjä lista esitetään tällöin muodossa L=. (2-2) Listan esitysmuoto valitaan aina tilanteen mukaisesti. Varsin usein on tapana kirjoittaa listan alkiot sulkulausekkeeksi, jolloin tyhjä lista kuvataan tyhjänä sulkulausekkeena. Tällöin edellisten listojen esitykset olisivat L = (a1, a2, a3, a4, a5) ja L = (). Tyhjä lista voidaan esittää myös käyttäen jotakin erikoissymbolia, kuten Λ tai ⊥. Epätyhjän listan alkioista kaksi on erityisasemassa, nimittäin listan ensimmäinen ja listan viimeinen alkio, jotka yksialkioisen listan tapauksessa pelkistyvät samaksi alkioksi. Listan alkioiden välinen peräkkäissuhde merkitsee, että näitä kahta erityisasemassa olevaa alkiota lukuunottamatta jokaisella listan alkiolla on yksiymmärteiset edeltäjä- ja seuraajaalkiot: alkion ai edeltäjä on alkio ai–1 ja seuraaja vastaavasti alkio ai+1. Listan ensimmäisellä alkiolla ei ole edeltäjää eikä listan viimeisellä alkiolla seuraajaa. Listan i:nnen alkion etäisyys listan alusta on i–1. Kullakin listan alkiolla on oma asemansa (position) listassa. Listaan L sisältyvien alkioiden asemien lisäksi on joskus tarpeen viitata listan viimeisen alkion asemaa seuraavaan (tyhjään) asemaan, merkitään L.EOL. Listan sisällön muuttuessa alkioiden asemat ja samalla myös etäisyydet listan alusta voivat muuttua. Listan asemien käytös listan sisällön eläessä riippuu paljolti listan toteutustavasta. Jotta lista voitaisiin toteuttaa tehokkaasti eri tavoin, emme valitettavasti voi kiinnittää tarkasti asemien käytöstä. Esimerkki 2-3: Toteutettaessa lista suoraviivaisesti Java-taulukossa, asemana käytetään luonnollisesti taulukon indeksiä. Tällöin poistettaessa alkio asemasta i, siirretään kaikki poistettua alkiota seuranneet alkiot yhtä asemaa lähemmäs listan alkua. Vas- 2. ABSTRAKTIT LISTATYYPIT 24 taavasti lisättäessä asemaan i, siirretään aiemmin asemassa i ollut alkio asemaan i+1 ja niin edelleen. Toisaalta, toteutettaessa lista linkitettynä listana, asemana käytetään yleensä alkion osoitetta. Tällöin poistettaessa alkio asemasta i, häviää kyseinen asema kokonaan olemasta, ja i:tä seuraavan aseman alkio vain linkitetään i:tä edeltävän aseman alkion kanssa. Vastaavasti lisättäessä uusi alkio, luodaan myös uusi asema (osoite). Toteutustavan mukaan vaihtuvasta käytöksestä johtuen Java API:n listan molemmat versiot käyttävät samoja operaatioita (kuten taulukkototeutuksen mukaisia). Linkitetyn toteutuksen operaatiot eivät siten ole tehokkaita kaikissa tilanteissa, minkä vuoksi esittelemme oman versiomme listan operaatioista. Abstraktin tietotyypin käyttäjän tarvitsemat operaatiot vaihtelevat sovelluksesta toiseen, mutta abstrakti tietotyyppi on silti pyrittävä määrittelemään sillä tavoin, että liittymän operaatiot kattaisivat kyseisen tietotyypin kaiken käyttötarpeen. Tämä on hyvin vaikea tehtävä. Varmuuden vuoksi liittymä sisältää usein jopa tarpeettomilta vaikuttavia operaatioitakin. TRAI 31.8.2012/SJ java.util.LinkedList Javan vakiokirjasto tarjoaa listan valmiina luokkana LinkedList. Tämän listan operaatiot on lähisukua Vector-luokan operaatioille, molemmat ovat itseasiassa saman AbstractList:n aliluokkia. LinkedList ei tarjoa käyttäjälle pääsyä listasolmuun, vaan asemana käytetään 0-alkuista indeksiä listan alusta lukien kuten taulukolla ja Vector:llakin. Vector:sta poiketen kuitenkin itse lisäykset ja poistot onnistuvat vakioajassa kun alkioita ei tarvitse siirrellä. Kuitenkin alkion haku annetusta indeksistä vaatii listan läpikäyntiä alusta tai lopusta koska suoraa pääsyä annettuun indeksiin ei ole. Aikavaativuudet ovat siten täysin päinvastaiset kuin Vector-luokalle. Suoran asemaviitteen puuttuminen tekee myös abstraktin listan tavalliset operaatiot tehottomiksi. Indekseihin viittaamalla vain jotkin indeksit (tarkemmin alku ja loppu) ovat tehokkaita: LinkedList<Integer> L = new LinkedList<Integer>(); for (int i = 0; i < N; i++) // yhteensä O(N) L.add(i); // lisäys loppuun O(1) for (int i = 0; i < N; i++) // yhteensä O(N2) L.add(i/2, i); // lisäys keskelle (indeks haku) O(N) 1 2 3 4 5 for (int i = 0; i < N; i++) ... = L.get(i); // yhteensä O(N2) // indeksin haku O(N) 6 7 Käydäksemme LinkedList-kokoelmaa läpi tehokkaasti, meidän onkin käytettävä sen tarjoamaa toistomekanismia joka hyödyntää listan sisäistä toteutusta (asemaa, eli viitettä listasolmuun). Toistomekanismina (kuten kaikissa Javan kokoelmissa) käytetään Iteratorluokkaa, tässä erityisesti sen aliluokkaa ListIterator. Alempana abstraktille listalle esittelemästämme asemasta poiketen Javan iteraattori on osoitin listan alkioiden väliin. Asemia ei kuitenkaan suoraan käsitellä, vaan osoitin pysyy piilossa, ja saamme aina vain alkioita osoittimen siirtelyn seurauksena. 2. ABSTRAKTIT LISTATYYPIT 25 Määritelmä 2-4: java.util.ListIterator operaatiot (tärkeimmät). (esim. LinkedList L) 1) ListIterator i = L.listIterator() Luo ja alustaa uuden iteraattorin. 2) boolean i.hasNext() kertoo onko seuraavaa alkiota olemassa vai ei. 3) E i.next() siirtää iteraattoria seuraavaan väliin ja palauttaa yli hyppäämänsä alkion. 4) E i.previous() siirtää iteraattoria edelliseen väliin ja palauttaa yli hyppäämänsä alkion. 5) void i.add(x) lisää elementin x iteraattorin osoittamaan väliin. Uusi elementti on seuraava previous jos sitä kutsutaan, ei next. 6) void i.remove() poistaa elementin joka viimeksi hypättiin yli next:llä tai previous:lla. TRAI 31.8.2012/SJ Esimerkki 2-5: Listan läpikäynti iteraattorilla ja alkuehtoisella toistolla. LinkedList L; ... ListIterator i = L.listIterator(); while (i.hasNext()) ... = i.next() 1 2 3 // yhteensä O(N) 4 5 Listaa saa muuttaa kesken läpikäynnin vain tämän iteraattorin add/remove -menetelmillä. Muu listan muuttaminen johtaa virhepoikkeukseen (ConcurrentModificationException). Myöskään kaksi sisäkkäistä läpikäyntiä eivät saa muuttaa listaa (vrt. yllä purge). Iteraattorin käyttö tehokkaaseen läpikäyntiin on varsin näppärää jos sen joustavuus riittää. Monimutkaisemmat tapaukset ovat vaikeita tai joskus jopa mahdottomia. Katso esimerkit TraListMerge.java ja JavaListMerge.java. Esimerkki 2-6: Alkion x kaikkien esiintymien poisto. ListIterator i = L.listIterator(); while (i.hasNext()) if (x.equals(i.next())) i.remove(); 1 // O(N) 2 3 4 Samanlainen Iterator-toisto toimii kaikille Javan kokoelmille. Jos listaa ei muuteta, itse iteraattorikin jää käyttäjälle tarpeettomaksi. Niinpä Javan versiosta 1.5 lähtien kaikille kokoelmille on myös "tee kaikille alkioille" (foreach) toisto. Tämä rakenne käyttää "piilossa" iteraattoreita toteuttaakseen ylläolevan kaltaisen toiston kullekin alkiolle. for(E x : L) x.foo(); 1 2 2. ABSTRAKTIT LISTATYYPIT 26 Abstraktin listan operaatiot (TRA-kirjasto) TRAI 31.8.2012/SJ Määritelmä 2-7: Seuraava luettelo kuvaa tällä kurssilla käytettävän tietorakennekirjaston listaoperaatiot. Asemana käytetään viitettä listasolmuun (ListNode). (parametrien tyypit: E x, ListNode p, TraLinkedList L) 1) TraLinkedList<E> TraLinkedList<E>() Muodostaa ja palauttaa uuden tyhjän listan. 2) ListNode L.first() Palauttaa listan L ensimmäisen alkion aseman. Jos L on tyhjä lista, palauttaa aseman L.EOL. 3) ListNode L.last() Palauttaa listan L viimeisen alkion aseman. Jos L on tyhjä lista, palauttaa aseman L.EOL. 4) void L.insert(p, x) Lisää alkion x listaan L asemaan p (tarkemmin: asemassa p olleen alkion eteen). Jos p = L.EOL, kohdistuu lisäys listan loppuun. Jos taas asemaa p ei listassa L lainkaan ole, on vaikutus määrittelemätön. 5) void L.remove(p) Poistaa listasta L asemassa p olevan alkion. Jos p = L.EOL tai asemaa p ei listassa L lainkaan ole, on vaikutus määrittelemätön. 6) ListNode p.next() Palauttaa asemaa p seuraavan aseman listassa. Jos p on listan viimeisen alkion asema, palauttaa p.getNext aseman L.EOL ja jos p = L.EOL (tai listassa ei ole asemaa p) on p.getNext määrittelemätön. 7) ListNode p.previous() Palauttaa asemaa p edeltävän aseman listassa. Jos p on listan ensimmäisen alkion asema (tai listassa ei ole asemaa p), on p.getPrevious määrittelemätön. 8) E p.getElement() Palauttaa asemassa p olevan alkion. Jos p = L.EOL on tulos määrittelemätön. Kuva 2-1 esittää muutaman lisäysoperaation ja niiden vaikutuksen listaan. Esimerkki 2-8: Olkoon L lista, jonka alkioiden tyyppiä ei tunneta. Tehtävänä on laatia algoritmi, joka poistaa listasta L kaikki saman alkion toistuvat esiintymät. Algoritmi voidaan kirjoittaa listaoperaatioiden avulla, vaikkei alkioista tiedetä mitään, jos oletetaan alkioiden pystyvän vertailemaan toisiaan. Varsinainen toistuvien esiintymien poistoalgoritmi purge on seuraavanlainen: 2. ABSTRAKTIT LISTATYYPIT L.first() 27 L.EOL L = a1 a2 a3 a4 p = L.first(); q = p.next(); L.insert(q, ’b’); L.insert(L.EOL, ’c’); L.EOL L = a1 b a2 a3 a4 c L.insert(L.first(), ’d’); L.insert(L.first().next(), ’e’); TRAI 31.8.2012/SJ L = d e a1 b a2 a3 a4 c L.insert(L.last(), ’f’); L = d e a1 b a2 a3 a4 f c Kuva 2-1: Listan asemat. public static void purge(TraLinkedList L) { ListNode p = L.first(); while (p != L.EOL ) { ListNode q = p.next(); while (q != L.EOL ) { if (q.getElement().equals(p.getElement())) { ListNode r = q.next(); L.remove(q); q = r; } else q = q.next(); } p = p.next(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Algoritmi käy läpi listan alkiot yksitellen (while p) ja vertaa kulloinkin vuorossa olevaan alkioon kaikkia kyseisen alkion seuraajia (while q). Jos seuraaja on vuorossa olevan alkion kanssa samanlainen, poistaa algoritmi tarkasteluvuorossa olevan seuraajan. Jotta algoritmi ei hukkaisi sisemmän toiston käsittelykohtaa poistossa, se siirtää q:ta pykälän eteenpäin ennen poistoa. Tämä versio siis olettaa, että poistettaessa alkio listasta myös ko. asema poistuu käytöstä, mutta muihin asemiin poisto ei vaikuta. Jos lista olisi toteutettu toisin, voitaisiin sama purge-algoritmi tehdä yksinkertaisemminkin. 2. ABSTRAKTIT LISTATYYPIT 28 TRAI 31.8.2012/SJ Listaoperaatioiden aikavaativuutta voidaan listan peräkkäisyysluonteen ansiosta arvioida, vaikkei listan toteutustapaa tunnettaisikaan. Aikavaativuuden etukäteisarviointi on hyödyksi aikanaan myös operaatioita toteutettaessa. Jos näet operaatioita ei kyetä toteuttamaan vähintään yhtä tehokkaina kuin arvioitiin, on syytä ryhtyä pohtimaan valitun toteutusperiaatteen vaihtamista. Operaatioiden first, next, getElement ja listan luonnin aikavaativuuksien tulisi aina olla O(1) jotta läpikäynti olisi tehokasta. Operaatiot insert, remove, previous, last operaatiot saattavat edellyttää ainakin listan alkuosan ja ehkä koko listan läpikäyntiä, minkä vuoksi operaatioiden aikavaativuus voi olla jopa O(listan pituus). Mutta ne voidaan helposti toteuttaa vakioajassakin, mikäli toteutusperiaate valitaan tällaista tavoitetta silmällä pitäen. Funktion EOL aikavaativuus on toivottavasti O(1), mutta joillakin toteutustavoilla EOL vie aikaa jopa O(listan pituus). Esimerkki 2-9: Esimerkin 2-8 algoritmin aikavaativuuden arvioimiseksi täytyy tuntea paitsi listaoperaatioiden aikavaativuus, myös samuusfunktion aikavaativuus. Kahden mielivaltaista tyyppiä olevan alkion keskenään vertaileminen voi olla raskas toimenpide. Alkiothan voivat olla vaikkapa listoja, jolloin samuuden varmistamiseksi täytyy molemmat listat käydä vertailemassa alkio alkiolta! Yksinkertaisemmassa tapauksessa vertailtavat alkiot ovat muutamasta kentästä koostuvia tietueita tai yksittäisiä muuttujia, joiden vertailu onnistuu vakioajassa. Oletetaan aluksi, että alkioiden vertailuoperaatio equals on O(1) ja että esimerkissä 2-8 käytetyt listaoperaatiot ovat myös O(1). Silloin rivit 6-11 vaativat aikaa O(1). Molemmissa toistoissa käydään listaa läpi alkio kerrallaan. Sisemmässä toistossa käsiteltävän listanosan pituus tosin lyhenee kierros kierrokselta, koska listan alkuosaa ei enää tarkastella, ja lisäksi lista lyhenee aina toistuvia alkioita havaittaessa. Pahimmassa tapauksessa ei löydetä yhtään toistuvaa esiintymää, joten ulompi toisto suoritetaan O(listan pituus) kertaa ja koko toiston aikavaativuus on O(listan pituus 2). Jos equals ei ole vakioaikainen, aikavaativuus voidaan kirjoittaa muodossa O(Teq ×listan pituus 2). Tulos on sama, käytetäänpä tulosääntöä tai aritmeettisen sarjan summaa. Algoritmi on siis aikavaativuudeltaan listan pituuteen nähden neliöllinen, jos kaikki tarvittavat osaset ovat O(1). Jos equals-funktion aikavaativuus on O(alkion koko), saadaan koko algoritmin aikavaativuudeksi O((alkion koko)∗(listan pituus)2). Esimerkki 2-10: Esimerkki: listojen samuuden vertailu. Listat ovat samat, jos ne ovat yhtä pitkät ja niissä on samat alkiot samassa järjestyksessä. public static boolean compareLists(TraLinkedList L1, TraLinkedList L2) { ListNode p1 = L1.first(); ListNode p2 = L2.first(); while ((p1 != L1.EOL) && (p2 != L2.EOL)) { if (! p1.getElement().equals(p2.getElement())) return false; p1 = p1.next(); p2 = p2.next(); } 1 2 3 4 5 6 7 8 9 10 2. ABSTRAKTIT LISTATYYPIT if (p1 == L1.EOL && p2 == L2.EOL) return true; else return false; } 29 11 12 13 14 15 Lista sellaisenaan on varsin yleiskäyttöinen tietorakenne. Mitä tahansa, jota käsitellään peräkkäisjärjestyksessä, kannattaa käsitellä listoina, erityisesti, jos kokoelman keskelle kohdistuu lisäys- tai poisto-operaatioita. Useimpien ohjelmointikielten tarjoama peruskokoelma, taulukko, tarjoaa kyllä hyvän peräkkäiskäsittelyn (ja suorahaun), mutta lisäys ja poisto keskeltä mielivaltaisesta paikasta on tehotonta ainakin suoraviivaisissa ratkaisuissa. 2.2 Pino ♦ A TRAI 31.8.2012/SJ A Rajoittamalla yleisen listan käsittelyoperaatioita eri tavoin saadaan joukko abstrakteja tietotyyppejä, jotka ovat käytännöllisiä tietyntyyppisissä sovelluskohteissa. Erityisesti rajoittamisen tarkoitus on yksinkertaistaa tarvittavien operaatioiden käyttöä poistamalla tarpeettomat. Paitsi lyhentämällä operaatioluetteloa, tämä usein myös yksinkertaistaa operaatioiden parametreja ja/tai tehostaa toteutusta. Rajoittaminen voi kohdistua niin lisäys-, poisto- kuin saantioperaatioihinkin. Näissä listan erikoistapauksissa jotkin yleisen listan käsittelyoperaatioista menettävät merkityksensä eikä kyseisiä operaatioita rajoitetuissa tyypeissä enää tunneta. Perinteisesti myös operaatioiden nimet on rajoitettaessa muutettu, mutta nyttemmin esiintyy jo pyrkimyksiä monimutkaisen nimistön yhdenmukaistamiseen. Kun listatyypin lisäys-, poisto- ja hakuoperaatioiden sallitaan kohdistua vain listan yhteen päähän — joko vain listan alkuun tai vain listan loppuun — johdutaan abstraktiin tietotyyppiin pino. Listan päistä käytetään tällöin nimityksiä pinon pinta ja pinon pohja. Kaikki pino-operaatiot kohdistuvat aina pinon pinnalle. Tämä merkitsee, että kaikista pinon alkioista vain päällimmäisin eli pinon pinnalla oleva alkio on käsiteltävissä. Pinon toiseksi päällimmäisimpään alkioon päästään käsiksi vain poistamalla pinosta ensin päällimmäisin alkio. Tyhjässä pinossa päällimmäistä alkiota ei ole lainkaan. Epätyhjään pinoon taas voi päällimmäisen alkion lisäksi sisältyä mielivaltainen määrä muita alkiota, joiden lukumäärää tosin ei tunneta. Pinon ominaisuuksiin ei näet kuulu tietää, kuinka 2. ABSTRAKTIT LISTATYYPIT 30 monta alkiota pino kullakin hetkellä sisältää. Pinoa kutsutaan toisinaan myös LIFO-listaksi, jolloin viitataan pinon alkioille tyypilliseen ominaisuuteen "last-in-first-out". TRAI 31.8.2012/SJ Esimerkki 2-11: Luonnollinen esimerkki pinosta on pöydälle sijoitettu korttipakka, jonka päältä nostetaan kortteja yksi kerrallaan. Kulloinkin päällimmäisenä oleva kortti peittää allaan olevan kortin ja samalla kaikki muutkin korttipakassa jäljellä olevat kortit. Jos kortti viedään takaisin pakkaan, peittyy myös viimeksi päällimmäisenä ollut kortti pakkaan vietävän kortin alle. Pakassa jäljellä olevien korttien lukumäärääkään ei kyetä ainakaan kovin tarkasti sanomaan. Koska kaikkien pino-operaatioiden toiminta kohdistuu aina pinon pinnalle, ei operaatioita käytettäessä tarvitse yksilöidä käsittelykohtaa. Tästä syystä aseman käsite menettää pinojen tapauksessa merkityksensä. Vastaavasti ei puhuta myöskään pinon alkioiden etäisyydestä pinon huipulta (tai pohjalta): ainoan mahdollisen käsiteltävissä olevan alkion etäisyys pinon pinnalta on joka tapauksessa nolla. Useimmilla pino-operaatioilla ei itseasiassa ole parametreja ollenkaan. Yleisen listan operaatioista ainoastaan insert(), getElement(), remove() sekä pinon luominen ovat pinon käyttäytymistä ajatellen mielekkäitä. Listaoperaatioina näistä kolmella ensimmäisellä on asemaparametri, jota vastaavissa pino-operaatiossa ei tarvita. Näiden neljän operaation lisäksi pinotyypissä tarvitaan operaatio, jolla tutkitaan pinon tyhjyyttä. Pinon tyhjyyden selvittäminen on pinoja käyttävissä algoritmeissa usein välttämätöntä. Listojen EOL-operaation vastinetta pinoilla ei ole, koskei alkioilla ole asemiakaan. Määritelmä 2-12: Pino-operaatiot määritellään perinteisesti seuraavien, yleisen listan operaatioiden nimiin nähden pinon luonteeseen paremmin sopivien nimien mukaisiksi: (LinkedStack S, E x) 1) LinkedStack<E> LinkedStack<E>() muodostaa tyhjän pinon. 2) void S.push(x) vie pinoon S päällimmäiseksi alkion x. [insert] 3) E S.peek() (yleensä nimi on top) palauttaa pinon S päällimmäisen alkion arvon. Jos pino on tyhjä, antaa poikkeuksen. [getElement] 4) E S.pop() poistaa pinosta S päällimmäisen alkion. Tyhjään pinoon kohdistettaessa antaa poikkeuksen. [remove] 5) boolean S.isEmpty() palauttaa boolen arvon true, mikäli pino S on tyhjä, muuten palauttaa arvon false. 2. ABSTRAKTIT LISTATYYPIT 31 Esimerkki 2-13: Sulkujen tarkastus: TRAI 31.8.2012/SJ public static boolean sulkuParit(String m) { LinkedStack<String> S = new LinkedStack<String>(); for (int i = 0; i < m.length(); i++) { char c = m.charAt(i); switch (c) { case '(' : S.push(")"); break; case '[' : S.push("]"); break; case '{' : S.push("}"); break; case ')' : case ']' : case '}' : if (! m.substring(i, i+1).equals(S.pop())) return false; } } return S.isEmpty(); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Pinon käyttäytymisen yksinkertaisuuden ansiosta pino voidaan aina toteuttaa tehokkaasti: kaikkien pino-operaatioiden aikavaativuuksien tulisi olla O(1). Tämä merkitsee, etteivät pino-operaatiot vaikuta niitä käyttävän algoritmin aikavaativuuteen. Varsinaisen pinon asemesta käytetään joskus niin sanottua avopinoa, jonka operaatiot ovat top -operaatiota lukuunottamatta samat kuin tavallisella pinolla. Avopinon S.top(k)-operaatiolla parametrina kokonaisluku k. Operaatio palauttaa pinon pinnalta lukien k:nnen pinoon sisältyvän alkion arvon. Jos pinossa on vähemmän kuin k alkiota, jää operaation tulos määrittelemättömäksi. push- ja pop -operaatiot kohdistuvat avopinossakin aina pinon pinnalle. Avopinon käyttäminen on perusteltua silloin, kun pinon muutaman päällimmäisen alkion arvoja tarvitaan toistuvasti. Listan toiminnallisuutta korvaamaan sitä ei kuitenkaan tule käyttää. Avopinonkaan tapauksessa pinon sisältämien alkioiden lukumäärää ei tunneta. 2.3 Jono Rajoittamalla listan käyttöä siten, että sallitaan alkioiden lisääminen vain listan loppuun ja alkioiden poistaminen ja hakeminen vain listan alusta, päädytään abstraktiin jonojen tietotyyppiin. Jono voi olla tyhjä tai sisältää mielivaltaisen määrän alkioita. Epätyhjään jonoon sisältyvien alkioiden lukumäärää ei tunneta. Jonoa kutsutaan joskus FIFO-listaksi, koska jonon alkioilla on ominaisuus "first-in-first-out". Esimerkki 2-14: Kaupan kassajono on tyypillinen jono: uudet jonottajat asettuvat aina jonon loppuun ja jonottajia palvellaan saapumisjärjestyksessä. Kaupan jonosta tosin voi poistua kesken kaiken, mikä ei abstraktissa jonossa ole mahdollista. 2. ABSTRAKTIT LISTATYYPIT 32 Jono-operaatioissa ei käsittelykohtaa tarvitse yksilöidä, koska käsittelykohta on aina ilmeinen. Jonoille mielekkäät listaoperaatiot ovat samat kuin pinoillakin: insert, getElement, remove ja jonon luonti, joiden lisäksi tarvitaan jonon tyhjyyden selvittävä operaatio. TRAI 31.8.2012/SJ Määritelmä 2-15: Jono-operaatiot TR-kirjastossa (suluissa perinteinen nimeäminen): (LinkedQueue Q, E x) 1) LinkedQueue<E> LinkedQueue<E>() muodostaa tyhjän jonon Q. 2) void Q.offer(x) (enqueue) vie jonon Q loppuun alkion x. [insert] 3) E Q.peek() (front) palauttaa jonon Q ensimmäisen alkion arvon. Jos jono on tyhjä, antaa poikkeuksen. [getElement] 4) E Q.poll() (dequeue) poistaa ja palauttaa jonosta Q ensimmäisen alkion. Tyhjään jonoon kohdistettaessa antaa poikkeuksen. [remove] 5) boolean Q.isEmpty() palauttaa arvon true, mikäli jono Q on tyhjä, muuten palauttaa arvon false. Kaikkien jono-operaatioiden toteutuksen aikavaativuuksien tulisi olla O(1). Pinon ja jonon varsin vähäiseltä näyttävästä erosta huolimatta nämä kaksi tietotyyppiä käyttäytyvät aivan eri tavoin, eikä niitä yleensä voi korvata toisillaan muuten kuin tilanteessa jossa alkioiden välivaraston käsittelyn järjestyksessä ei ole merkitystä. Esimerkiksi puiden tai verkkojen läpikäynnissä (myöhemmin tällä kurssilla) pinon ja jonon käytöllä samassa algoritmilla voidaan vaihtaa algoritmin käyttämää puun tai verkon läpikäyntijärjestystä. Esimerkiksi seuraavaa algoritmia ei voitaisi kirjoittaa yhtä pinoa käyttäen, koska pinoa ei kyetä kokonaisena kopioimaan. Esimerkki 2-16: Pinon sisältö voidaan helposti kääntää jonon avulla ja päinvastoin: public static void reverse(LinkedStack S) { LinkedQueue Q = new LinkedQueue(); while (! S.isEmpty()) Q.offer(S.pop()); while (! Q.isEmpty()) S.push(Q.poll()); } public static void reverse(LinkedQueue Q) { LinkedStack S = new LinkedStack(); while (! Q.isEmpty()) S.push(Q.poll()); while (! S.isEmpty()) Q.offer(S.pop()); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 2. ABSTRAKTIT LISTATYYPIT 33 2.4 Pakka Pino ja jono saadaan listasta rajoittamalla lisäys-, poisto- ja hakuoperaatiot kohdistuviksi aina vain listan jompaankumpaan päähän. Sallimalla nämä kolme operaatiota listan molemmissa päissä, muttei muualla, saadaan listasta abstrakti tietotyyppi pakka. Esimerkki 2-17: Pakkatyypin luonnollinen ilmentymä on kädessä pidettävä korttipakka, josta sekä päällimmäinen että alimmainen kortti voidaan poistaa. Pakkaan vietävä kortti voidaan myös asettaa joko päällimmäiseksi tai alimmaiseksi. TRAI 31.8.2012/SJ Pakan operaatiot voivat kohdistua kahteen eri kohtaan pakassa, joten operaatioissa täytyy ilmaista haluttu käsittelykohta. Listojen aseman käsite on tähän tarkoitukseen tarpeettoman vahva, koska mahdollisia käsittelykohtia on vain kaksi. Lisäys- ja poisto- operaatioilla on pakan tapauksessa joko kaksiarvoinen parametri, joka määrää, kumpaan pakan päähän operaatio kohdistuu, tai operaatioita on kaksittain. Käytämme tässä eri operaatioita, joskin erottava parametri olisi ehkä joustavampi. Määritelmä 2-18: Pakan operaatiot: (LinkedDeque D, E x) 1) LinkedDeque<E> LinkedDeque<E>() muodostaa tyhjän pakan. 2) void D.addFirst(x) void D.addLast(x) lisää pakan D alkuun tai loppuun alkion x. [insert] 3) E D.removeFirst() E D.removeLast() poistaa ja palauttaa pakasta D ensimmäisen tai viimeisen alkion. Tyhjään pakkaan kohdistettaessa antaa poikkeuksen. [remove] 4) E D.getFirst() E D.getLast() palauttaa pakan D ensimmäisen tai viimeisen alkion arvon. Jos pakka on tyhjä, antaa poikkeuksen. [getElement] 5) boolean D.isEmpty() palauttaa boolen arvon true, mikäli pakka D on tyhjä, muuten palauttaa arvon false. Usein operaatiot nimetään kuten jonon operaatiot. Erillisten First ja Last -operaatioiden sijaan voidaan yhtä hyvin esitellä vain yhdet operaatiot ja käyttää lisäparametria joka osoittaa kumpaan päähän operaation halutaan kohdistuvan. Pinon ja jonon tavoin kaikkien pakan operaatioiden aikavaativuuksien tulisi olla O(1). 2. ABSTRAKTIT LISTATYYPIT 34 2.5 Rengas TRAI 31.8.2012/SJ 1 3 9 Viimeisenä listan erikoistapauksena esitellään ren11 p gas, joka muodostetaan listasta kiinnittämällä vii5 42 meisen alkion seuraajaksi ensimmäinen alkio ja 4 vastaavasti ensimmäisen alkion edeltäjäksi viimei5 nen alkio, jolloin jokaisella alkiolla on sekä edel12 7 2 täjä että seuraaja. Rengas on toisin sanoen päätty- p.next() mätön lista. Vaikka renkaassa ei erityistä ensimmäistä alkiota olekaan, täytyy renkaaseen jollakin tavoin pystyä tarttumaan. Tätä varten renkaille määritellään renkaan jonkin alkion aseman palauttava start-operaatio, joka korvaa listaoperaation first(). Muut listaoperaatiot sopivat sellaisinaan renkaiden operaatioiksi ja operaatioiden aikavaativuudet ovat renkailla samat kuin listoillakin. last()-operaatiota renkaat eivät luonnollisestikaan tunne. Esimerkki 2-19: Tietokonejärjestelmässä keskusyksikkö tarkkailee järjestelmään liitettyjä oheislaitteita vuoron perään sopivin väliajoin havaitakseen, tapahtuuko oheislaitteilla jotakin sellaista, mikä edellyttää keskusyksiköltä toimenpiteitä. Jotta kaikki oheislaitteet tulisivat varmasti tarkkailluksi säännöllisesti, on oheislaitteet — oikeastaan laitteiden kuvaajat — kätevä liittää renkaaksi. Kullakin tarkkailuhetkellä keskusyksikkö ensin siirtää tarkkailukohtaa renkaassa yhden aseman eteenpäin ja sen jälkeen selvittää vuorossa olevan oheislaitteen tilan. Uudet oheislaitteet voidaan lisätä mihin tahansa kohtaan rengasta, eikä oheislaitteiden poistaminenkaan ole kovin vaikeaa. Koska toiminta jatkuu periaatteessa loputtomiin, on rengas parempi ratkaisu kuin lista tai jono, sillä listaa käsiteltäessä täytyisi varautua listan loppumiseen ja jonoratkaisussa laitteet jouduttaisiin toistuvasti ensin poistamaan jonosta ja heti sen jälkeen viemään jonoon takaisin. Tyhjä rengas ja yksialkioinen rengas ovat renkaita siinä missä suuremmatkin renkaat aivan vastaavasti kuin tyhjät ja yksialkioiset pinotkin ovat pinoja. 2.6 Taulukko Listatyyppiin ei sisältynyt operaatiota listassa jo olevan alkion muuttamista varten. Muutosoperaation puuttuminen ei estä alkion muuttamista, mutta muutoksen tekeminen on hieman vaivalloista: Aluksi selvitetään muutettavan alkion asema ja haetaan kyseisessä asemassa oleva arvo esiin. Tällä tavoin saadaan muodostetuksi listan alkion kopio, johon halutut muutokset tehdään. Lopuksi alkuperäinen alkio poistetaan listasta ja viedään muutettu kopio samaan asemaan, mistä alkio poistettiin. Yhden alkion muuttaminen toisin sanoen muuttaakin koko listan. Pinossa ja pakassa saatavilla oleva alkio muutetaan aivan vastaavalla tavalla. Jonoon ei muutosta sen sijaan kannata tehdä, koska ainoaa saatavilla olevaa alkiota ei voida viedä takaisin jonon alkuun kierrättämättä kaikkia muitakin alkioita. Alkion muuttamisen vaivalloisuus selittyy sillä, että lista on luonteeltaan peräkkäisrakenne, jonka alkioita on tarkoitus käsitellä vain tietyssä järjestyksessä. Jo nähdyt listan erikoistapaukset määräävät käsittelyjärjestyksen lisäksi myös käsittelytavan. Esimerkiksi TRAI 31.8.2012/SJ 2. ABSTRAKTIT LISTATYYPIT 35 jonoon on tarkoitus viedä alkioita, jotka otetaan jonosta pois vientijärjestyksessä, mutta jonossa oleviin alkioihin ei ole tarkoituskaan koskea! Silloin, kun kokoelman alkioihin on tärkeää päästä käsiksi mielivaltaisessa järjestyksessä, mutta alkioita halutaan silti käsitellä tehokkaasti myös peräkkäin, tarvitaan abstraktia taulukkotyyppiä. Taulukon alkioiden välillä vallitsee peräkkäisyyssuhde, mutta alkiot ovat saatavilla toisistaan riippumattakin, koska alkioiden asemat saadaan selville indeksoimalla eli ilmaisemalla alkioiden suhteelliset etäisyydet taulukon alusta lukien. Yleisessä listassahan täytyy alkion aseman selvittämiseksi käydä listaa läpi alkio alkiolta, sillä vaikka alkion etäisyys listan alusta tunnettaisiinkin, ei tämä tieto yleensä riitä alkion aseman välittömään paikallistamiseen. (Lähes) kaikissa ohjelmointikielissä on käytettävissä jonkinlainen taulukkokokoelma. Miksi siis tarvitaan abstraktia taulukkotyyppiä? Lähinnä yhdenmukaisuuden vuoksi. Tälläkin kurssilla tarvitsemme taulukoita mm. lajittelun yhteydessä. Jotta pystyisimme esittämään taulukon lajittelun selkeästi, määrittelemme tässä abstraktin taulukon operaatiot. Kurssilla käyttämämme tietorakennekirjasto ei kuitenkaan sisällä abstraktia taulukkoa (ainakaan vielä), joten käytännössä joudumme tyytymään käyttämämme ohjelmointikielen tarjoamiin taulukoihin. Abstraktin taulukkotyypin operaatiot ovat samat kuin listan operaatiotkin, mutta operaatioita on ainakin yksi enemmän: Operaatio A.set(i, x) vie taulukon A indeksiä i vastaavaan asemaan alkion x hävittäen samalla asemassa i ennestään olleen alkion. Ellei asemaa i ole olemassakaan, on operaation tulos määrittelemätön. Joskus määritellään myös operaatio A.force(i, x), jonka vaikutus on muuten sama kuin set:n, mutta force onnistuu aina; tarvittaessa force laajentaa taulukkoa A niin, että siinä varmasti on indeksiä i vastaava asema. Vielä voidaan määritellä operaatio A.last, joka palauttaa taulukon A viimeisen alkion asemaa vastaavan indeksin. Taulukko-operaatioista add, remove ja force ovat aikavaativuudeltaan O(taulukon koko), koska näissä operaatioissa joudutaan käymään ainakin osa taulukon alkioista yksitellen läpi. Muut taulukko-operaatiot tulisi pystyä toteuttamaan tehokkaasti, toisin sanoen niiden tulisi olla aikavaativuudeltaan O(1). Tosin vakioaikaisena toteutettu taulukon luonti jättää muodostamansa taulukon alkioiden arvot määrittelemättömiksi, mikä ei aina ole suotavaa. Määritellyt alkuarvot (kuten Javassa) tuottavan taulukon luonnin aikavaativuus on luonnollisesti O(taulukon koko). Jollei taulukon operaatioita millään tavoin rajoiteta, voi taulukko listan tavoin kasvaa mielivaltaisen laajaksi. Käytännössä taulukko tosin usein määrätään kooltaan kiinteäksi, jolloin add, remove ja force -operaatiot menettävät merkityksensä. Aivan yhtä hyvin taulukon koko voidaan jättää avoimeksi, jolloin taulukko kasvaa käyttötarpeen myötä. Avoimuus voi olla tois- tai molemminpuoleista, toisin sanoen avoimen taulukon koon voidaan sallia kasvavan yksin taulukon lopusta, yksin taulukon alusta tai molemmista päistä. Viimemainitussa tapauksessa taulukkoa kutsutaan joskus hyllyksi. Avoimen taulukon laajentuessa taulukossa jo olevien alkioiden indeksit säilyvät ennallaan laajennoksen indeksien mukautuessa näihin. A.first ja A.last -operaatiot ovatkin tarpeen etenkin avoimia taulukoita käsiteltäessä, jotta taulukon indeksialue ja koko kyetään aina saamaan selville. Erityisesti, jos taulukko saadaan parametrina, on rajoihin päästävä käsiksi tavalla tai toisella. Taulukkojen hahmottaminen listan erikoistapauksina ei aina ole aivan vaivatonta. Yksiulotteinen taulukko on toki helppo mieltää listan erikoistapauksena, mutta moniulot- 2. ABSTRAKTIT LISTATYYPIT 36 teisen taulukon rinnastaminen listaan ei ehkä ensi kuulemalta vaikuta luontevalta. Rinnastus tulee ymmärrettäväksi, kun muistetaan, että listan alkiot voivat olla listoja. Kaksiulotteinen taulukko voidaan toisin sanoen katsoa yksiulotteiseksi taulukoksi, siis listaksi, jonka alkiot ovat yksiulotteisia taulukoita eli listoja. Taulukoista puhuttaessa on huomattava erottaa toisistaan abstrakti taulukkotyyppi ja miltei kaikissa ohjelmointikielissä valmiina tarjolla oleva taulukoiden käyttömahdollisuus. Ohjelmointikielten taulukot ovat abstraktin taulukkotyypin toteutuksia, joissa indeksointi ilmaistaan esimerkiksi hakasulkuja käyttämällä ja yleinen getElement(i) -operaatio korvataan indeksoidulla taulukkoviittauksella. Vastaavasti set(i, x) -operaation korvaa sijoituslause. Muita taulukon operaatioita valmiit toteutukset eivät yleensä tue laisinkaan. Javassa varsinaisen taulukkotyypin lisäksi on käytössä Vector-luokka joka tarjoaa dynaamisen taulukon toiminnallisuuden, joskin osittain listamaisin operaatioin. TRAI 31.8.2012/SJ java.util.Vector Javan Vector ja ArrayList -luokat tarjoavat abstraktin taulukon toiminnallisuuden ja operaatioiden aikavaativuudet vaikka rajapinta näyttää listan rajapinnalta. Alkioihin viitataan indekseillä taulukon alusta lukien (0..size()–1). Kun varsinainen talletusrakenne on taulukko, niin operaatiot get(i) ja set(i) ovat vakioaikaisia, samoin kuin taulukon loppuun lisääminen (V.add(x), V.add(V.size(), x)). Alkuun tai keskelle lisääminen/poistaminen sensijaan vaatii lopputaulukon alkioiden siirtämistä, eli on aikavaativuudeltaan lineaarinen. Vector- luokkaa käytettäessä on erotettava taulukossa kulloinkin olevien varsinaisten alkioiden määrä (size()) ja tallennusalueen kapasiteetti (getCapacity()). Aluksi Vector on tyhjä ja sillä on jokin tallennusalus (esim 10). Alkioita lisätään normaalisti järjestyksessä (add()) tai korvataan olemassaoleva alkio (set()). Tallennusalue (kapasiteetti) kasvaa automaattisesti. Jos halutaan, niin taulukon alkioiden seassa voi olla null:eja, tällöin alkioiden määrä voidaan asettaa setSize() -operaatiolla. Vastaavasti kapasiteetti voidaan haluttaessa varmistaa ensureCapacity() -operaatioilla. Käytännössä automaattinen kasvattaminen riittää, sillä kun taulukon koko aina kaksinkertaistetaan, pysyy lisäyksen keskimääräienen aikavaativuus vakiona (miksi?). Vaikka Vector -luokalla ja LinkedList -luokalla on samat operaatiot, on käytettäessä erittäin tärkeää muistaa käyttää niitä joko taulukkona tai listana. Väärin käytettäessä molemmilla seuraa ylimääräinen O(n) -kerroin aikavaativuuteen joka tuhoaa minkä tahansa ohjelman tehokkuuden jos alkioita on edes tuhansia. 2.7 Yhteenveto Käyttäytymisensä eroista huolimatta kaikkien listojen keskeisin ominaisuus on peräkkäisyys: alkiot sijaitsevat listassa peräkkäin ja useimmiten alkiot myös käsitellään peräkkäisjärjestyksessä. Käsittelysuunta tosin voi muuttua etenevästä takenevaksi, mutta kumpaankin suuntaan siirrytään alkio alkiolta. Muista listoista poiketen taulukot tukevat myös hajakäsittelyä, vaikka ovatkin sinänsä ilmiselvästi peräkkäisrakenteita. Vaikka listojen muunnelmia onkin useita erilaisia, on valinta eri muunnelmien välillä yleensä hämmästyttävän helppo tehdä. Jos algoritmi käsittelee tietokokoelman alkioita jollakin tavoin peräkkäin, löytyy listoista yleensä varsin luonteva ratkaisu kokoelman koossa pitämiseksi. Mikäli listojen käyttäminen kuitenkin johtaa sekavaan ja tehot- 2. ABSTRAKTIT LISTATYYPIT 37 tomaan algoritmiin, on syytä analysoida ongelmaa tarkemmin ja vaihtaa lista johonkin muuhun tietotyyppiin, joka paremmin vastaa ratkaistavana olevan ongelman maailmaa. Esimerkki 2-20: Listatyypit (taulukkoa lukuunottamatta) ovat keskenään hyvin lähisukulaisia. Yleisen listan päälle voidaan toteuttaa muut varsin helposti ja suoraviivaisesti. TRAI 31.8.2012/SJ Seuraavassa esimerkkinä pino-operaatioiden toteutus listaoperaatioita käyttäen. Operaatioista voitaisiin kokonaan poistaa yläluokalta sellaisenaan perityt Stackkonstruktori ja isEmpty. Samoin super -yläluokkaviittaukset ovat tarpeettomia, mutta ne ehkä parantavat koodin luettavuutta. public class Stack<E> extends java.util.LinkedList<E> { public Stack() { super(); } public void push(E x) { super.addFirst(x); } public E pop() { return super.removeFirst(); } public E top() { return super.getFirst(); } public boolean isEmpty() { return super.isEmpty(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Luku 3 TRAI 31.8.2012/SJ Puut Lista määrää alkiokokoelmalle peräkkäisen rakenteen. Näinollen kokoelmaa käsiteltäessä on aina käytävä koko lista läpi. Jotta käsittely (erityisesti etsintä) nopeutuisi, listaa on lyhennettävä. Tämä voidaan ajatella haaroittamalla lista moneen kertaan kunnes mikään haara ei ole liian pitkä. Puu määrää alkiokokoelmalle hierarkkisen rakenteen. Puurakenteen käytännön ilmentymiä ovat sukupuut ja yritysten organisaatiokaaviot. Puun avulla voidaan myös havainnollistaa vaikkapa aritmeettisen lausekkeen rakennetta. Tietojenkäsittelytieteen piirissä puita tarvitaan esimerkiksi tietojen organisoinnin ja ohjelmointikielten kääntämisen yhteydessä. Lisäksi monet ohjelmointimenetelmät hyödyntävät puurakennetta. Tässä luvussa esitellään puihin liittyviä peruskäsitteitä ja nostetaan esiin yksi tärkeimmistä puiden erikoistapauksista, binääripuu. 3.1 Puiden peruskäsitteistö Määritellään puu kokoelmaksi solmuja, jotka muodostavat hierarkkisen rakenteen. Solmujen hierarkia eli solmujen keskinäinen sijainti rakenteessa määräytyy solmujen välisen isä-relaation perusteella. Puun solmuista yksi, niin sanottu juuri (juurisolmu), on erityisasemassa: juurisolmulla ei ole isää. Puun kaikilla muilla solmuilla sen sijaan on yksiymmärteinen isäsolmu. Puun kukin solmu sisältää hallittavan alkiokokoelman yhden alkion, joka luonnollisesti voi olla minkä tahansa tyyppinen. Määritelmä 3-1: Puu voidaan määritellä myös rekursiivisesti: Yksittäinen solmu muodostaa puun, jonka juuri on puun ainoa solmu. Jos n on solmu ja T1, T2, … , Tk ovat puita, joiden juuret ovat vastaavasti n1, n2, … , nk, voidaan muodostaa uusi puu asettamalla solmu n solmujen n1, n2, … , nk isäksi. Uuden puun juuri on n ja T1, T2, … , Tk ovat n:n alipuut. Solmuja n1, n2, … , nk sanotaan solmun n lapsiksi. 38 3. PUUT 39 Tyhjässä puussa ei ole yhtään solmua, ei edes juurta. Tyhjää puuta merkitään Λ-merkillä. Joskus tyhjä puu rinnastetaan tyhjään solmuun, jota myös merkitään Λ-merkillä. Esimerkki 3-2: Kuvan 3-1 puun juuri on solmu 1. Juuren alipuiden juurisolmut ovat 2, 3 ja 4, solmun 3 alipuiden juuret ovat 5 ja 6 ja niin edelleen. Solmu 5 on solmun 9 isä ja solmu 9 solmun 5 lapsi. Solmulla 9 ei ole lasta lainkaan, toisin sanoen solmu 9 ei ole minkään solmun isä. Kuten listojenkin alkiot, myös puiden solmuissa säilytettävä tieto voi olla minkätyyppistä tahansa. Esimerkkipuissa käytämme yleensä numeroita tai kirjaimia. 1 2 3 5 TRAI 31.8.2012/SJ 8 4 6 9 7 10 Kuva 3-1: Eräs kymmensolmuinen yleinen puu. 4 3 2 5 8 6 9 7 10 Kuva 3-2: Kuvan 3-1 juurisolmun kolme alipuuta. Kuten äskeisestä esimerkistäkin nähdään, puu yleensä piirretään niin, että juuri on ylinnä. Piirtäminen on näet helpompi aloittaa paperin yläosasta kuin alempaa, jolloin pitäisi osata arvioida tilantarve. Puun solmujen n1, n2, … , nk jono on polku, jos solmu ni on solmun ni+1 isä kaikille i = 1, … , k–1. Tällöin polun pituus on k–1, mikä kuvaa polun solmujen välisten kaarten lukumäärää. Solmusta itseensä johtavan (kaarettoman) polun pituus on toisin sanoen nolla. Joskus poluksi sanotaan myös jonoa nk, nk–1, … , n1. Asiayhteydestä käy ilmi, kumpaan suuntaan kulkevaa polkua kulloinkin tarkoitetaan. Jos solmusta a on polku solmuun b (isä-poika-suunnassa), on a solmun b esi-isä ja vastaavasti b solmun a jälkeläinen. Jos polun pituus on positiivinen, sanotaan esi-isää ja jälkeläistä aidoksi. Solmu on lehti, ellei sillä ole aitoja jälkeläisiä. Muut kuin lehtisolmut ovat haarautumissolmuja. Solmua sanotaan haarautumissolmuksi, vaikka sillä olisi vain yksi aito jälkeläinen. Puun juuri voi olla joko haarautumissolmu tai lehti. Solmun korkeus on pisimmän solmusta lehteen johtavan polun pituus. Puun korkeus on puun juurisolmun korkeus. Solmun syvyys puolestaan on juuresta kyseiseen solmuun johtavan polun pituus. Lehtisolmun korkeus ja juuren syvyys ovat molemmat aina nolla. 3. PUUT 40 Esimerkki 3-3: Kuvan 3-1 puussa solmun 3 korkeus on kaksi, solmun 4 korkeus on yksi ja solmun 2 korkeus on nolla. Koko puun korkeus on kolme. Solmun 6 syvyys puolestaan on kaksi. Solmun kaikkia poikia kutsutaan yhdessä veljeksiksi. Veljekset järjestetään yleensä vasemmalta oikealle. Tällöin on kyse järjestetystä puusta. Esimerkiksi kuvan 3-3 järjeste- A B A C C B TRAI 31.8.2012/SJ Kuva 3-3: Kaksi erilaista järjestettyä puuta. tyt puut ovat keskenään eri puita, vaikka niissä onkin samat solmut ja puiden rakennekin on sama. Ellei veljesten keskinäisellä järjestyksellä haluta olevan merkitystä, on puu järjestämätön. Järjestämättömiksi tulkittuina äskeiset kaksi puuta olisivat keskenään sama puu. Järjestetyssä puussa veljeksistä vasemmanpuoleisinta sanotaan vanhimmaksi ja oikeanpuoleisinta nuorimmaksi veljekseksi, isästä katsoen vanhimmaksi ja nuorimmaksi pojaksi. Vanhinta ja nuorinta veljestä lukuunottamatta kaikilla veljeksillä on sekä lähinnä vanhempi että lähinnä nuorempi veli, joita kutsutaan myös solmun vasemmanpuoleiseksi ja oikeanpuoleiseksi veljeksi. Veljesten pojat ovat keskenään serkuksia, ja myös setärelaatio voidaan tarvittaessa määritellä luonnollisella tavalla. Vasemman- ja oikeanpuoleisuuden käsitteet voidaan yleistää minkä tahansa kahden solmun välille, vaikkeivät solmut ole toisiinsa nähden veljessuhteessa. Jos esimerkiksi solmut a ja b ovat veljeksiä ja a on b:n vasemmalla puolella, niin solmun a kaikki jälkeläiset ja esi-isät sekä esi-isien kaikki vanhemmat veljet jälkeläisineen ovat b:n vasemmalla puolella. Käsitteet laajennetaan vastaavalla tavalla koskemaan myös polkuja. Puun solmut voidaan luetella eli asettaa järjestykseen monin eri tavoin. Käytännössä tavallisimmin esiintyvät järjestykset ovat esi-, sisä- ja jälkijärjestys, jotka määritellään rekursiivisesti seuraavalla tavalla: a) Tyhjän puun esi-, sisä- ja jälkijärjestys ovat tyhjiä. b) Yksisolmuisen puun esi-, sisä- ja jälkijärjestys ovat puun ainoa solmu. c) Jos puun juuri on n ja juuren alipuut ovat vasemmalta lukien T1, T2, … , Tk , on 1) solmujen esijärjestys n, T1:n solmut esijärjestyksessä, T2:n solmut esijärjestyksessä, …, Tk:n solmut esijärjestyksessä; 2) solmujen sisäjärjestys T1:n solmut sisäjärjestyksessä, n, T2:n solmut sisäjärjestyksessä, …, Tk:n solmut sisäjärjestyksessä; 3. PUUT 41 3) solmujen jälkijärjestys T1:n solmut jälkijärjestyksessä, T2:n solmut jälkijärjestyksessä, …, Tk:n solmut jälkijärjestyksessä, n. Puun läpikäynnillä tarkoitetaan puun solmujen käsittelemistä jossakin järjestyksessä. Edellisten kolmen järjestyksen lisäksi mainittakoon vielä puun tasoittainen läpikäynti, joka etenee seuraavasti: TRAI 31.8.2012/SJ 1) 2) 3) 4) 5) juuri, syvyydellä 1 olevat solmut vasemmalta oikealle, syvyydellä 2 olevat solmut vasemmalta oikealle, …, puussa syvimmällä olevat solmut vasemmalta oikealle. Puuta tasoittain käsiteltäessä on huomattava, ettei puun kaikkien haarojen korkeus aina ole sama, joten edettäessä puussa juurta syvemmälle voi käsiteltävien haarojen lukumäärä paitsi kasvaa, myös vähentyä. Esimerkki 3-4: Kuvan 3-1 puun solmut ovat 1) esijärjestyksessä 1, 2, 3, 5, 8, 9, 6, 10, 4, 7; 1 2 3 5 8 4 6 9 7 10 Kuva 3-4: Kuvan 3-1 puun solmut esijärjestyksessä. 2) sisäjärjestyksessä 2, 1, 8, 5, 9, 3, 10, 6, 7, 4; 1 2 3 5 4 6 7 8 9 10 Kuva 3-5: Kuvan 3-1 puun solmut sisäjärjestyksessä. 3. PUUT 42 3) jälkijärjestyksessä 2, 8, 9, 5, 10, 6, 3, 7, 4, 1; 1 2 3 5 8 4 6 9 7 10 Kuva 3-6: Kuvan 3-1 puun solmut jälkijärjestyksessä. 4) tasoittain järjestettynä 1, 2, 3, 4, 5, 6, 7, 8, 9, 10. 1 TRAI 31.8.2012/SJ 2 3 5 4 6 7 8 9 10 Kuva 3-7: Kuvan 3-1 puun solmut tasoittaisessa järjestyksessä. Puun solmuun liitetään usein jokin sisältö, niin sanottu nimiö eli arvo, joka talletetaan solmuun. Solmun nimiö voidaan vaihtaa toiseksi solmua itseään siirtämättä, toisin sanoen nimiön vaihtuessa solmu ja sen asema puussa säilyvät ennallaan. Muutenkin puun solmun sisältämä tieto on puuta käsiteltäessä yleensä monin verroin tärkeämpi kuin solmu itse. Esimerkki 3-5: Kuvan 3-8 nimiöity puu kuvaa aritmeettista lauseketta (a+b)∗(a+c). Puussa on seitsemän solmua. Kustakin solmusta puuhun on merkitty vain sen nimiö. Solmut ja solmujen nimiöt voidaan siis piirrettäessä aivan hyvin samaistaa. * + + a c a b Kuva 3-8: Aritmeettisen lausekkeen jäsennyspuu. Mielivaltaista (aritmeettista) lauseketta kuvaava puu muodostetaan seuraavia kahta yksinkertaista sääntöä noudattaen: 3. PUUT 43 1) Jokainen lehti nimiöidään operandilla. 2) Jokainen haarautumissolmu nimiöidään operaattorilla siten, että haarautumissolmun vasen alipuu kuvaa operaattorin vasemmanpuoleista operandia ja vastaavasti oikea alipuu oikeanpuoleista operandia. TRAI 31.8.2012/SJ Ellei operaattori ole binäärinen, on operaattoria vastaavalla solmulla alipuita jokin muu määrä kuin kaksi. Alipuut kuvaavat joka tapauksessa kyseisen operaattorin operandeja järjestyksessä vasemmalta oikealle. Sulkumerkkejä ei lauseketta kuvaavassa puussa tarvita lainkaan, sillä puun rakenne määrää lausekkeen laskentajärjestyksen yksiymmärteisesti: Jotta haarautumissolmun tarkoittama operaatio voidaan suorittaa, on ensin tunnettava kaikki operandit. Nämä puolestaan saadaan selville laskemalla vastaavien alipuiden kuvaamien lausekkeen osien arvot. Yksiymmärteisyys merkitsee itse asiassa sitä, että kutakin erilaista lauseketta vastaa täsmälleen yksi puu ja kutakin erilaista puuta täsmälleen yksi lauseke. Lauseketta kuvaavan puun läpikäynti esi-, sisä- tai jälkijärjestyksessä tuottaa vastaavasti lausekkeen esi-, sisä- tai jälkimerkintäisen esityksen. Esimerkintäisessä muodossa operaattori edeltää aina operandejaan, sisämerkintäisessä muodossa operaattori kirjoitetaan operandiensa väliin ja jälkimerkintäisessä muodossa operaattori mainitaan vasta operandiensa jälkeen. Esimerkki 3-6: Esimerkin 3-5 lauseke on esimerkintäisenä ∗+ab+ac, sisämerkintäisenä a+b∗a+c, ja jälkimerkintäisenä ab+ac+∗. Näistä kolmesta esitysmuodosta esi- ja jälkimerkintäisen laskentajärjestys on yksiymmärteinen. Vain sisämerkintäiseen muotoon tarvitaan sulkuja laskentajärjestyksen osoittamiseksi. 3.2 Puu abstraktina tietotyyppinä Puu on monikäyttöinen rakenne, jota voidaan hyödyntää sekä itsenäisenä abstraktina tietotyyppinä että muiden abstraktien tietotyyppien toteuttamisessa. Määritelmä 3-7: Tavallisimmin tarvitaan seuraavanlaisia puuoperaatioita: (Tree T, TreeNode n, m, E x) 1) Tree<E> Tree<E>() muodostaa tyhjän puun. 2) TreeNode<E> TreeNode<E>(x) luo uuden kytkemättömän puusolmun jonka nimiönä on x. 3) TreeNode T.getRoot() palauttaa puun T juurisolmun. Jos T on tyhjä puu, palauttaa tyhjän solmun. 4) TreeNode T.setRoot(n) asettaa puun T juureksi solmun n. Puussa aiemmin olleet solmut menetetään. 5) TreeNode n.getParent() palauttaa solmun n isän. Jos n on juuri, palauttaa tyhjän solmun (null). 6) TreeNode n.getLeftChild() palauttaa solmun n vanhimman lapset. Jos n on lehtisolmu, palauttaa tyhjän solmun (null). 3. PUUT 44 7) TreeNode n.getRightSibling() palauttaa solmun n lähinnä nuoremman sisaruksen. Jos n on juuri tai isänsä nuorin (oikeanpuoleisin) sisarus, palauttaa tyhjän solmun (null). 8) E n.getElement() palauttaa solmun n nimiön (hyötytiedon). 9) void n.setLeftChild(m) asettaa solmun n vasemmanpuoleiseksi lapseksi solmun m. Solmun n aiempi vasemmanpuoleinen lapsi (ja sen sisarukset!) menetetään. 10) void n.setRightSibling(m) asettaa solmun n lähinnä oikeanpuoleiseksi sisareksi solmun m. Solmun n aiempi lähinnä oikeanpuoleinen sisarus menetetään. 11) void T.killNode(n) tuhoaa ja vapauttaa puusta T solmun n ja kaikki sen lapset ja oikeanpuoleiset sisarukset. TRAI 31.8.2012/SJ Esimerkki 3-8: Puuoperaatioiden käytöstä: Algoritmi, joka tuottaa parametriensa yksilöimän (ali)puun solmujen nimiöiden listauksen käymällä puun solmut läpi esijärjestyksessä. Rekursiivisena algoritmi on seuraavanlainen: public static void preorderPrint(Tree T) { if (T.getRoot() != null) preorderPrintBranch(T.getRoot()); System.out.println(); } public static void preorderPrintBranch(TreeNode n) { System.out.print(n.getElement() + " "); // tms hyödyllistä TreeNode child = n.getLeftChild(); while (child != null) { preorderPrintBranch(child); child = child.getRightSibling(); } } 1 2 3 4 5 6 7 8 9 10 11 12 Itse preorderPrint on vain kuorrutus joka kutsuu varsinaista rekursiivista läpikäyntiä puun juurella. Algoritmin rivillä 7 voidaan luonnollisesti suorittaa muitakin operaatioita kuin tulostamista. Jos puu toteutetaan sopivasti valitulla tavalla, päästään kaikkien muiden operaatioiden kohdalla aikavaativuuteen O(1). Jollei toteutus ole tehokas, voivat joidenkin operaatioiden aikavaativuudet olla jopa O(puun solmujen lukumäärä). Puiden rakentamisesta Puurakenteen muodostamistapa riippuu aina siitä, mihin puuta sovelluksessa käytetään. Usein puun rakenne elää sovelluksen suorituksen aikana. Edelläesitetyt puun rakentamisoperaatiot setRoot, setLeftChild, setRightSibling soveltuvat parhaiten puun rakentamiseen juuresta alaspäin. Lehtisolmuista juureen rakentaminen onnistuu parhaiten oksa kerrallaan. Tasoittain alhaalta ylös rakennettaminen onnistuu listoja tms. käyttäen. Rakennettaessa kokonainen puu kerralla kannattaa puu rakentaa taulukosta tai listasta rekursiivisesti 3. PUUT 45 siten, ettei lisäysoperaatioiden tarvitse selata jo rakennettua puuta. Palaamme tähän joukon toteutuksen yhteydessä. Kirjallisuudessa esitetään joskus puun rakentamisen operaationa construct(n, T1, T2, ..., Tk). Tämän käyttö on kuitenkin varsin jäykkää, erityisesti puun muuttaminen suorituksen aikana vaatii kohtuuttomasti uudelleenjärjestelyä. Solmuista ja puista TRAI 31.8.2012/SJ Usein tulee tarve käsitellä jonkin puun jotakin oksaa itsenäisenä puuna. Myös kokonaisten puiden yhdistämistä tarvitaan usein. Jos Tree ja TreeNode ovat eri tyyppejä, tarvittaisiin esimerkiksi setLeftChild -operaatiosta erilliset versiot yhden solmun sijoittamiseksi ja kokonaisen puun sijoittamiseksi. Valitsemalla toteutus sopivasti, on mahdollista, että Tree ja TreeNode ovat samaa tyyppiä (kääntäjän näkökulmasta). Tällöin solmuja ja puita voidaan käsitellä yhteensopivasti kaikissa operaatioissa ja algoritmeissa. Puiden toteutukseen palaamme kurssin lopussa. Esimerkki 3-9: Polku puun alkioon x. Algoritmi etsii polun järjestämättömän puun T juuresta solmuun, jonka nimiö on x. Algoritmi myös tulostaa löytämänsä polun solmujen nimiöt juuresta alkaen. Algoritmi etsii aluksi oikean solmun puusta. Etsinnän aikana algoritmi pitää yllä pakkaa, jossa on tallessa polku juuresta siihen solmuun, jota ollaan juuri tutkimassa. Katso esimerkkisivulta PuuEsim.searchPath(). Esimerkki 3-10: Haku esijärjestetystä puusta. Aloitetaan juuresta, edetään lapsiin, oikeisiin veljiin kunnes löytyy, tai löytymättömyysehto täyttyy, tai törmätään tyhjään solmuun. Jos haettava alkio on pienempi kuin käsittelyssä oleva solmu, ei sitä puusta löydy, jos pienempi kuin oikea veli, niin vaihdetaan käsittelykohta vasempaan lapseen, muuten vaihdetaan käsittelykohta ko oikeaan veljeen. HT. Esimerkki 3-11: Läpikäynti tasoittain (leveyssuuntaisesti). Viedään juuri jonoon. Toistetaan kunnes jono tyhjä. Otetaan alkio jonosta, käsitellään alkio, viedään kaikki lapset jonoon. public static void printByLevel(Tree T) { LinkedQueue<TreeNode> Q = new LinkedQueue<TreeNode>(); if (T.getRoot() != null) Q.offer(T.getRoot()); while (! Q.isEmpty()) { TreeNode n = Q.poll(); System.out.print(n.getElement() + " "); // tms hyödyllistä n = n.getLeftChild(); while (n != null) { Q.offer(n); n = n.getRightSibling(); } } System.out.println(); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 3. PUUT 46 TRAI 31.8.2012/SJ 3.3 Binääripuu Tähän saakka on käsitelty yleisiä puita, joiden jokaisella solmulla voi olla mielivaltainen määrä alipuita. Rajoittamalla solmun alipuiden lukumäärää saadaan joitakin tärkeitä puiden erikoistapauksia. Äärimmillään voidaan alipuiden määrä rajoittaa enintään yhteen, mutta tällainen puu on itse asiassa lista. Enintään kahden alipuun salliminen puolestaan johtaa binääripuun käsitteeseen, joka on yksi tietojenkäsittelytieteen keskeisimmistä peruspilareista. Seuraavaksi tarkastellaan lähemmin binääripuita. Joihinkin muihin alipuiden lukumäärärajoituksiin palataan kurssilla tuonnempana. Binääripuu on joko tyhjä puu tai puu, jonka kullakin solmulla on yksi, kaksi tai ei yhtään poikaa. Solmun pojat erotetaan toisistaan kutsumalla toista vasemmaksi pojaksi ja toista oikeaksi pojaksi. Näistä kahdesta pojasta kumpi tahansa voi puuttua. Olennainen ero binääripuun ja sellaisen yleisen puun, jonka jokaisella solmulla on enintään kaksi poikaa, välillä on seuraava: Jos yleisen puun solmulla on vain yksi poika, on se ilman muuta isänsä vanhin eli vasemmanpuoleisin poika. Sen sijaan binääripuun yksipoikaisen solmun ainoa poika on joko vasen tai oikea poika sen mukaan, kumpi pojista puuttuu. Binääripuun solmun ainoa poika ei toisin sanoen ilman muuta ole vasemmanpuoleinen poika. Yleensä binääripuita käsiteltäessä ajetellaankin, että poikia on aina kaksi ja pojista jompikumpi tai molemmat voivat olla tyhjiä poikia. Algoritmeissa on useimmiten kätevä huomioida, että aliohjelmaa voidaan kutsua myös tyhjällä solmulla. Binääripuu on tapana piirtää siten, että vasen poika asetetaan isästään alavasem1 1 2 3 2 4 3 4 5 5 Kuva 3-9: Kaksi erilaista viisisolmuista binääripuuta. 1 1 2 3 2 4 5 3 4 5 Kuva 3-10: Binääripuut tyhjine solmuineen. malle ja oikea poika isästään alaoikealle. Niinpä esimerkiksi kuvan 3-9 binääripuut ovat eri puita, vaikka niissä onkin keskenään samat solmut vieläpä samoilla tasoilla. Ero havai- 3. PUUT 47 taan välittömästi, jos tyhjät alipuut (kuva 3-10) merkitään näkyviin tai solmut luetellaan sisäjärjestyksessä. Esi- ja jälkijärjestys on näillä kahdella puulla kuitenkin aivan sama. Binääripuun operaatiot Binääripuiden operaatiot ovat pitkälle samat kuin yleisten puiden operaatiotkin. Operaation getRightSibling sijaan käytetään getRightChild operaatiota ja getLeftChild palauttaa nimenomaan vasemman lapsen jos se on olemassa oikeasta lapsesta riippumatta. Set-operaatiot vastaavasti. Binääripuiden operaatiot voidaan toteuttaa aikavaativuudeltaan luokkaan O(1). Hakupuut TRAI 31.8.2012/SJ Sisäjärjestetystä binääripuusta hakeminen, lisääminen ja poistaminen onnistuvat helposti O(puun korkeus) ajassa. Puun korkeus on parhaimmillaan O(logn), mutta pahimmillaan O(n). Myöhemmin tällä ja TRA2 -kurssilla esitetään tekniikoita joilla voidaan varmistaa puun korkeuden pysyminen O(logn):ssa riippumatta alkioiden lisäysjärjestyksestä. Näinollen kaikki perusoperaatiot saadaan O(logn) aikaiseksi. Samoin läpikäyntioperaatiot voidaan toteuttaa (keskimäärin) vakioaikaisiksi. Esimerkki 3-12: Haku järjestetystä binääripuusta. Ylläpidetään sisäjärjestettyä binääripuuta, ts. kaikki solmun vasemman alipuun alkiot edeltävät solmun alkiota ja kaikki oikean alipuun alkiot seuraavat solmun alkiota. Haku puusta: jollei haettavaa vielä löydetty tästä solmusta, niin jos haettava edeltää tämän solmun alkiota, haetaan vasemmasta alipuusta, muuten haetaan oikeasta alipuusta. public static boolean inorderMember(BTree T, Comparable x) { BTreeNode n = T.getRoot(); while (n != null) { if (x.compareTo(n.getElement()) == 0) return true; else if (x.compareTo(n.getElement()) < 0) n = n.getLeftChild(); else n = n.getRightChild(); } return false; } 1 2 3 4 5 6 7 8 9 10 11 12 Aikavaativuus: kullakin tasolla: 2 vertailua ja linkin seuraaminen (O(1)). Yhteensä O(puun korkeus). Puun korkeus voi vaihdella välillä logn .. n, missä n = puun solmujen määrä. Esimerkki 3-13: Lisäys sisäjärjestettyyn binääripuuhun s.e. sisäjärjestys säilyy: Suoritetaan etsintä puussa, kunnes törmätään tyhjään solmuun jossa ko. uusi alkio voisi olla. Lisätään alkio ko. tyhjän solmun paikalle. 3. PUUT 48 Esimerkki 3-14: Annetun lähtösolmun seuraaja sisäjärjestyksessä. Jos solmulla on oikea lapsi, seuraaja on ko. oikean lapsen vasemmanpuoleisin jälkeläinen. Muuten, seuraaja on se esivanhempi jonka vasemmassa alipuussa lähtösolmu on. [HT] TRAI 31.8.2012/SJ Muita puita Paitsi vakioon 2, voidaan solmujen lasten lukumäärä rajoittaa muuhunkin vakioon. Jos puuhun tallennetaan merkkijonoja (merkistö esimerkiksi ’a’..’ö’) , voi jokainen solmu sisältää taulukon a:sta ö:hön, siten, että kutakin merkkiä varten on viite seuraavan tason alipuuhun. Merkkijonon haku tällaisesta puusta onnistuu siten seuraamalla oikeaa linkkiä merkkijonon pituus kertaa. Oikean linkin valinta onnistuu vakioajassa sillä se voidaan tehdä taulukkoviittauksena. Leveiden veljessarjojen hyöty on myös puun madaltuminen. Tasapainoisen puun korkeus on O(logk n), missä k on keskimääräinen veljessarjan leveys. Tätä hyödynnetään erityisesti massamuistissa, jossa pyritään minimoimaan levyhakujen määrä. B-puussa kussakin solmussa voi olla jopa tuhansia avaimia ja aina yksi enemmän lapsia kuin avaimia. Koko solmu haetaan ensin keskusmuistiin, minkä jälkeen haluttua avainta haetaan solmun avainten joukosta binäärihaulla. Jollei avainta löytynyt, haetaan binäärihaun "umpikujan" osoittamasta kohdasta lapsisolmu. Jos kussakin puussa on esimerkiksi 1000 avainta, voidaan kolmella levyhaulla osoittaa miljardi (109) alkiota. Myös B-puuhun palataan TRA2-kurssilla. Luku 4 TRAI 31.8.2012/SJ Joukot Joukkoja käytetään erittäin laajalti algoritmeissa. Usein ei tosin päällisin puolin näytä siltä, että kyseessä olisi joukko. Esimerkiksi tietokanta on pohjimmiltaan joukko, jossa jokin tieto on määritelty — kuuluu joukkoon — tai ei ole määritelty. Verkko-ongelmissa käsitellään verkon solmujen joukkoa, ohjelmointikielen kääntäjissä ohjelman tunnusten tai avainsanojen joukkoa ja niin edelleen. Tässä luvussa palautetaan mieleen joukko-opin käsitteitä ja tutustutaan muutamiin joukkopohjaisiin abstrakteihin tietotyyppeihin. 4.1 Määritelmiä Joukko on kokoelma mielivaltaisen tyyppisiä alkioita. Alkiotyyppi voi siis olla yksinkertainen (kuten kokonaisluku tai merkkijono), tai esimerkiksi joukko (kokonaisuus on siis joukkojen joukko). Joukon kaikki alkiot ovat keskenään erilaisia, toisin sanoen sama alkio ei voi esiintyä joukossa samanaikaisesti kahtena eri ilmentymänä. Mikäli joukon alkiot ovat joukkoja, voi alkiojoukkoihin toki sisältyä keskenään samojakin alkioita, mutta saman joukon kaksi eri alkiojoukkoa eivät voi olla keskenään täysin samat. Monimutkaisia alkiotyyppejä käsiteltäessä samuusfunktio jää käyttäjän tehtäväksi. Joukon alkiot ovat yleensä keskenään samaa tyyppiä. Atomeille oletetaan usein lineaarinen järjestys < [lue: "pienempi kuin" tai "edeltää"], joka täyttää seuraavat ehdot: 1) Jos a ja b ovat joukon S alkioita, vain yksi väittämistä a < b, a == b, tai b < a on tosi. 2) Jos a, b ja c ovat joukon S alkioita siten, että a < b ja b < c, niin a < c. Ehdoista jälkimmäinen on transitiivisuusehto. Lineaarinen järjestys voidaan määritellä paitsi yksinkertaisille alkioille kuten merkeille tai kokonaisluvuille, myös merkkijonoille, ja yleisemmin mielivaltaisille järjestettyjen alkioiden yhdistelmille, kuten kokonaislukujoukoille. Joukkojen {1, 4} ja {2, 3} välillä voi vallita vaikkapa järjestys {1, 4} < {2, 3}, koska min{1, 4} < min{2, 3}. Järjestys voisi yhtä hyvin olla päinvastainenkin sillä perusteella, että max{2, 3} < max{1, 4}. Java-kielen merkkityypin char lineaarinen järjestys on ASCII-aakkoston mukainen. Sen vuoksi on esimerkiksi 'A' < 'a' ja jopa 'Z' < 'a', mistä aiheutuu joskus hankaluuksia. 49 4. JOUKOT 50 Alkioiden järjestyksen ei tarvitse olla käyttäjälle näkyvä. Jos käyttäjä ei tarvitse järjestystä, toteutus voi määritellä sen sisäisesti sillä monet joukkojen toteutustavoista vaativat yksikäsitteisen järjestyksen. Tämä siksi, että etsiminen täysin järjestämättömästä kokoelmasta (taulukko, puu) on varsin hidasta. Ellei järjestystä ole mahdollista määritellä joukon kaikkien alkioiden välille, voidaan ehkä määritellä osittainen järjestys. Esimerkiksi yliopistossa suoritettavien tutkintojen joukossa on luontevaa määritellä FM < FT, mutta tutkintojen FM ja TM välille järjestystä ei voitane asettaa. Toisaalta jossakin mielessa (mutta ei aina) voidaan ajatella, että TM < FT. Joukko-operaatiot TRAI 31.8.2012/SJ Seuraavat joukko-opin käsitteet ja merkinnät oletetaan tällä kurssilla tunnetuiksi ilman yksityiskohtaista määrittelyä: äärellinen joukko, ääretön joukko, tyhjä joukko ∅, joukkoonkuulumisrelaatio ∈, osajoukkorelaatio ⊆, joukkojen yhdiste ∪, joukkojen leikkaus ∩ ja joukkojen erotus \. Määritelmä 4-1: Abstraktin joukkotyypin tavanomaisimmat operaatiot merkityksineen ovat seuraavat: (Set A, B; E x) 1) Set<E> Set() muodostaa tyhjän joukon. 2) Set<E> Set(java.util.Collection<? extends E> C) muodostaa uuden joukon kopioiden siihen kokoelman C alkiot. 3) boolean A.isEmpty() palauttaa arvon true, jos A == ∅, muuten arvon false. 4) boolean A.equals(B) palauttaa arvon true, jos joukkojen A ja B sisällöt ovat samat, muuten arvon false. 5) Set A.union(B) palauttaa joukon A ∪ B (yhdiste). 6) Set A.intersection(B) palauttaa joukon A ∩ B (leikkaus). 7) Set A.difference(B) palauttaa joukon A \ B (erotus). 8) boolean A.contains(x) palauttaa arvon true, jos x ∈ A, muuten arvon false. 9) boolean A.add(x) vie joukkoon A alkion x säilyttäen joukon A muilta osin ennallaan, palauttaa true jos lisäys onnistui. Jos x oli jo joukossa A, lisäystä ei tehdä, ja palauttaa false. 10) boolean A.remove(x) poistaa joukosta A alkion x säilyttäen joukon A muilta osin ennallaan, palauttaa true jos alkio löydettiin ja poistettiin, false jollei alkiota joukosta löydetty. 4. JOUKOT 51 11) A.clone() palauttaa kopion joukosta A. Uusi joukko sisältää samat alkiot (viittaukset) kuin A, mutta itse alkioita ei kopioida. 12) A.first() palauttaa joukon A pienimmän alkion arvon. Operaation tulos on määrittelemätön, jos A == ∅ tai jos joukon A alkioille ei ole määritelty lineaarista järjestystä. 13) A.last() palauttaa joukon A suurimman alkion arvon. Operaation tulos on määrittelemätön, jos A == ∅ tai jos joukon A alkioille ei ole määritelty lineaarista järjestystä. Useimpiin joukkosovelluksiin riittää vain osa tässä mainituista operaatioista. Operaatioiden aikavaativuuteen palataan myöhemmin. TRAI 31.8.2012/SJ Joukon alkioiden läpikäynti Ylläolevat varsinaiset joukon operaatiot eivät tarjoa kunnollista mahdollisuutta joukon alkioiden läpikäyntiin (paitsi S.remove(S.first())). Useissa joukkojen käyttötarkoituksessa (kuten verkoissa) tarvitaan kuitenkin läpikäyntiä siten, että jokin operaatio suoritetaan kerran kutakin joukon alkiota kohti. Algoritmiteksteissä joukon kaikkien alkioiden läpikäynti esitetään yleensä seuraavaan tapaan: for each x in S do toimenpide alkiolle x (4-1) Javan versiossa 1.5 ja myöhemmin tämä foreach -toisto on toteutettu osaksi abstrakteja kokoelmia (Collection framework). Kaikki Collection -rajapinnan toteuttavat kokoelmat voidaan läpikäydä seuraavasti: for (x : S) toimenpide alkiolle x (4-2) missä x on muuttuja kokoelman S:n alkiotyyppiä. Tämä merkintätapa on varsin selkeä ja havainnollinen kun tarkoituksena on käydä kaikkia alkiot läpi kokoelmaa muuttamatta. Jos haluamme vaikuttaa läpikäyntiin (esimerkiksi käydä läpi kahta kokoelmaa yhtä aikaa) tai haluamme muuttaa kokoelmaa, tämä ei ole riittävän ilmaisuvoimainen. Vaihtoehtona onkin käyttää tavallista toistolausetta (while) ja kokoelman tarjoamaa iteraattoria ja sen operaatioita. Määritelmä 4-2: Joukon läpikäyntioperaatiot (Set S; Iterator i; E x) 14) Iterator<E> S.iterator() Alustaa joukon S läpikäynnin iterointimuuttujaan i. 15) boolean i.hasNext() palauttaa arvon true, jos joukon S läpikäynti i on vielä kesken, ts. next -operaatiolla saataisiin uusi alkio, muuten palauttaa arvon false. 16) E i.next() palauttaa joukon S jonkin läpikäynnissä i vielä käsittelemättömän alkion. Jos 4. JOUKOT 52 joukon S kaikki alkiot on jo käsitelty läpikäynnissä i (eli hasNext olisi palauttanut epätoden), aiheuttaa ajonaikaisen poikkeuksen. 17) void i.remove() Poistaa kokoelmasta S edellisen next() -operaation antaman alkion. Läpikäytävää joukkoa ei luonnollisestikkaan saa muuttua läpikäynnin aikana muuten kuin iteraattorin remove -operaatiolla. Myöskään läpikäyntimuuttujan arvoa ei tule omatoimisesti muuttaa. Tämä on tietysti vaikeaakin koska emme edes tiedä sen todellista tyyppiä. Sisäkkäiset läpikäynnit kahdella eri iteraattorilla. Tällöin ei kuitenkaan kokoelmaa saa muuttaa edes remove -operaatiolla (sillä se toinen iteraattori ei sitä salli). Määritelmän 4-2 operaatioita käyttäen lause (4-1) onnistuu nyt seuraavasti: TRAI 31.8.2012/SJ import java.util.Iterator; ... Iterator i = S.iterator(); while (i.hasNext()) { Object x = i.next(); // tai joku muu tyyppi toimenpide alkiolle x; } 1 2 3 4 5 6 7 Kaikki kolme joukon läpikäynnissä tarvittavaa operaatiota tulisi pystyä toteuttamaan tehokkaasti vakioaikaisiksi, jolloin pelkkään läpikäyntiin kuluva aika on O(läpikäytävän joukon koko). Esimerkki 4-3: Taulukon V alkioiden kaikki permutaatiot eli erilaiset järjestykset, joita on kaikkiaan |V|! kappaletta, saadaan kerätyksi listaan: public static LinkedList<Vector<?>> permutations(Vector<?> V) { TreeSet S = new TreeSet(V); LinkedList<Vector<?>> L = new LinkedList<Vector<?>>(); permute(V, 0, S, L); return L; } public static void permute(Vector V, int i, TreeSet<?> S, LinkedList<Vector<?>> L) { if (S.isEmpty()) L.add(new Vector(V)); else { for (Object x : S) { V.set(i, x); TreeSet R = new TreeSet(S); R.remove(x); permute(V, i+1, R, L); } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Jos taulukon V alkiot ovat osittain samoja, ei tuloste ole mielekäs. Kustakin alkiosta on näet joukossa vain yksi ilmentymä, joten saman alkion monista esiintymistä viedään taulukkoon takaisin vain yksi. Taulukkoon toisin sanoen viedään vähemmän 4. JOUKOT 53 alkioita kuin taulukko alunperin sisälsi, jolloin taulukon loppuosan alkiot jäävät ennalleen eikä tuloste enää olekaan alkuperäisten alkioiden permutaatio. java.util.Set, java.util.SortedSet TRAI 31.8.2012/SJ Java API:n kokoelmaliittymä joka määrittelee joukko-operaatiot kuten yllä, mutta ei kuitenkaan useaa joukkoa yhdistäviä operaatioita kuten yhdistettä, erotusta. java.util.TreeSet on Set -liittymän toteuttava todellinen luokka. Kuten nimi antaa ymmärtää, se on toteutettu tasapainotetulla binääripuulla, joten add, remove ja contains ovat aikavaativuudeltaan O(logn), missä n on joukossa olevien alkioiden lukumäärä. Meidän tietorakennekirjastomme Set on itseasiassa rakennettu TreeSet:n päälle lisäämällä useaa joukkoa käyttävät operaatiot. java.util.Set on määritelty liittymäksi, jotta sen voisi toteuttaa useampi luokka eri tavoin. Tämähän on ollut alkujaankin yksi tavoitteistamme — absrakti tietotyyppi kuvataan vain liittymänä, toteutus vaikuttaa lähinnä aikavaativuuksiin. java.util.HashSet toteuttaa Set -liittymän hajautustaulua käyttäen, joten sen add, remove ja contains -operaatioiden aikavaativuudet ovat yleensä O(1). Jos kuitenkin hajautus on huono, aikavaativuus kasvaa, samoin tilan loppuessa uudelleenhajautus vie aikaa. Talletusrakenteesta johtuen joukon läpikäynti onnistuu vain satunnaisessa järjestyksessä. 4.2 Sanakirja Joukkojen sovelluksissa voidaan käytettävien joukko-operaatioiden määrää usein rajata huomattavasti. Usein riittävät vain operaatiot insert, remove, contains sekä joukon luonti. Näillä kyetään pitämään yllä yksittäisten alkioiden osalta muuttuvaa joukkoa. Joskus joukkoon ainoastaan lisätään alkioita, jolloin jopa remove käy tarpeettomaksi. Tällä tavoin rajoitettua joukkoa kutsutaan sanakirjaksi. Esimerkkejä sanakirjatyypin käyttökohteista ovat tavanomaisten sanakirjojen lisäksi puhelinluettelot ja jäsenluettelot. Ohjelmointikielten kääntäjät käsittelevät symbolitaulua sanakirjan tavoin. Tietokantakin on yksinkertaisimmillaan sanakirja — tietokantaan tosin sovelletaan usein muitakin kuin pelkkiä sanakirjaoperaatioita. contains- ja remove-operaatioiden x-parametria sanotaan yleensä avaimeksi. Avain on se osa sanakirjaan talletetun alkion tiedoista, jonka avulla alkio voidaan erottaa kaikista muista sanakirjan alkioista, toisin sanoen avain on alkion yksilöllinen tunniste. Tämä tunniste on harvemmin sama kuin koko alkio. Koska contains -operaatio selvittää, sisältyykö parametrin tarkoittama alkio sanakirjaan, riittää parametriksi pelkkä avain. Sama pätee remove -operaatioon, sillä välttämätön ja riittävä edellytys alkion poistamiseksi on poistettavan alkion tunnistaminen. insert -operaatio sen sijaan tarvitsee parametrikseen kaiken alkiosta talletettavan tiedon. Sanakirjan member -operaation sijaan monesti käytetään get -operaatiota, jonka totuusarvon palauttamisen sijaan palauttaa avainta vastaavan alkion. Esimerkiksi puhelinluettelosovelluksen get voisi tilaaja-asiakkaan tiedot löytäessään palauttaa asiakkaan puhelinnumeron. Monimutkaisia alkioita käsiteltäessä get -operaatioita voi jopa olla useita erilaisia. Tällä tavoin laajennetulla operaatiolla on useampia parametreja kuin perusversiolla. 4. JOUKOT 54 4.3 Relaatio ja kuvaus Jos A ja B ovat joukkoja, tuottaa karteesinen tulo A×B joukon, jonka alkiot ovat järjestettyjä pareja (a, b), missä a ∈ A ja b ∈ B. Tällaisen tulojoukon osajoukkoa M sanotaan relaatioksi ja siihen sisältyvän parin jäsenten sanotaan olevan keskenään relaatiossa M. Joukkoa A sanotaan relaation lähtöjoukoksi ja joukkoa B maalijoukoksi. Jos relaatioon ei sisälly kahta paria, joiden ensimmäiset jäsenet olisivat samat, on kyseinen relaatio kuvaus. Parin jälkimmäistä jäsentä kutsutaan tällöin ensimmäisen jäsenen kuvaksi ja käytetään merkintää M(a) = b. Kuvauksessa lähtöjoukon alkion kuva on joko määrittelemätön tai yksiymmärteisesti määritelty. Alkion kuva voidaan joskus laskea kuvattavasta alkiosta. Esimerkiksi neliöimiskuvauksessa sqr(a) = a2. Tällaisen kuvauksen esittämiseksi ei tarvita erityisiä keinoja. Mielivaltaista kuvausta ei sitä vastoin voida yleensä esittää muuten kuin tallettamalla kuvauksen muodostavat järjestetyt parit yksitellen. Esimerkki tällaisesta kuvauksesta on opiskelijanumeron ja opiskelijan henkilötietojen välinen yhteys. TRAI 31.8.2012/SJ Kuvaus abstraktina tietotyyppinä Koska kuvaus on joukko, voitaisiin kuvausta käsitellä joukko-operaatioiden avulla, mutta yleisen joukkomallin operaatiot ovat kuvauksen kannalta aivan liian monipuolisia. Itse asiassa sanakirjaoperaatiot riittävät, eikä niistäkään remove -operaatiota välttämättä tarvita muuten kuin mahdollisten virheiden varalta. Lisäksi, kun alkiot ovat pareja, on niitä kätevämpi käsitellä kahdella parametrilla esim. lisäysoperaatiossa. Kuvausoperaatiot nimetään perinteisesti eri tavoin kun sanakirjaoperaatiot ja operaatioiden parametrit sovitetaan käyttötarkoitusta tukeviksi. Operaatiot voidaan määritellä esimerkiksi seuraavaan tapaan: Määritelmä 4-4: Kuvaus abstraktina tietotyyppinä (Map M, K k, E x) 1) Map<K, E> Map<K, E>() muodostaa tyhjän kuvauksen. 2) E M.put(k, x) määrittelee avaimen k kuvaksi kuvauksessa M arvon x. Saman alkion kuvan uudelleen määritteleminen kumoaa aiemman määrittelyn (ja palauttaa sen operaation arvona). [vertaa taulukon set] 3) boolean M.containsKey(k) palauttaa arvon true jos avaimen k kuva on määritelty, muuten palauttaa arvon false. 4) E M.get(k) palauttaa avaimen k kuvan jos se on määritelty, muuten null. 5) E M.remove(k) poistaa avaimen k kuvan kuvauksesta ja palauttaa sen jos se on määritelty, muuten null. Tässä esitetty abstrakti kuvaustyyppi on mielekäs vain äärellisiä kuvauksia käsiteltäessä. Äärettömän kuvauksen määritteleminenhän edellyttäisi set-operaation suorittamista kul- 4. JOUKOT 55 lekin lähtöjoukon alkiolle yksitellen, mikä on mahdotonta äärellisessä ajassa, jos alkioita on ääretön määrä. Ne äärettömät kuvaukset, joita käytännössä tarvitaan, voidaan onneksi esittää muullakin tavoin kuin kuvauksen sisältämät alkioparit tallettamalla. 4.4 Monilista Henkilöt: 1 Matti 3 Pekka Maija 2 2 3 3 Suoritukset: TRAI 31.8.2012/SJ Kurssit: Ohj TRA1 TRA2 Kuva 4-1: Monilista (selkeyden vuoksi paluulinkit jätetty piirtämättä). Tähän mennessä nähdyt joukkojen erikoistapaukset sopivat huonosti tietokannoissa tyypillisten monta-moneen -suhteiden kuvaamiseen. Esimerkki tällaisesta suhteesta on yliopiston opiskelijoiden ja kurssien välinen yhteys: Yksittäiselle kurssille osallistuu todennäköisesti useita opiskelijoita, mutteivät suinkaan kaikki yliopiston opiskelijat. Vastaavasti yksittäinen opiskelija osallistuu todennäköisesti useille kursseille, muttei varmasti kaikille yliopistossa järjestettäville kursseille. Tällaiseen tietokantaan kohdistetaan esimerkiksi seuraavanlaisia toimenpiteitä ja kyselyjä: "Lisää opiskelija x kurssille y." "Ketkä osallistuvat kurssille y?" "Mille kursseille opiskelija x osallistuu?" Tiedot voitaisiin tietokantaan tallettaa (opiskelija, kurssi)-pareina, mutta tällöin jouduttaisiin saman opiskelijan ja saman kurssin tiedot tallettamaan toistuvasti, mikä tuhlaisi tilaa. Kyseessähän ei ole kuvaus, koska relaatio opiskelijan ja kurssin välillä ei ole yksiymmärteinen kumpaankaan suuntaan. On syytä huomata myös se, että todellisissa tietokannoissa käsitellään erittäin suuria tietomääriä, jolloin tietojen läpikäymiseen kuluu aikaa runsaasti ilman samojen tietojen toistuvaa käsittelyäkin. Kyselyihin tehokkaasti vastaaminen edellyttää keinoa poimia samaan asiaan liittyvät tiedot nopeasti esiin koko tietokannan sisältämästä valtavasta tietomäärästä. Vastauksen muodostaminen edellyttää toisin sanoen jonkinlaista peräkkäiskäsittelyä, mutta käsiteltävät tiedot ovat tietokannassa hajallaan. Jotta tämänkaltainen peräkkäiskäsittely sujuisi tehokkaasti, voidaan tiedot järjestää rinnakkaisiksi listoiksi siten, että sama tieto sisältyy samanaikaisesti moneen eri listaan. Tällaista rinnakkaisten listojen muodostamaa rakennetta sanotaan monilistaksi. Kyselyihin vastaamiseksi riittää monilistaratkaisussa usein vain muutaman listan käsitteleminen, eivätkä listatkaan aina ole kovin pitkiä. 4. JOUKOT 56 Esimerkki 4-5: Opiskelija-kurssi -tietokannassa voitaisiin muodostaa listat kunkin opiskelijan valitsemista kursseista ja kunkin kurssin opiskelijoista, jolloin jokainen pari (opiskelija, kurssi) sisältyisi täsmälleen kahteen listaan. Itse asiassa opiskelijan tietoja ei tarvitse tallettaa toistuvasti jokaiseen samaan opiskelijaan liittyvän listan alkioon, vaan kerran tallettaminen riittää. Sama pätee myös kurssin tietojen tallettamiseen. Tällä tavoin supistettuihin listoihin jää jäljelle vain viitteitä listojen seuraaviin alkioihin. Esimerkissä mainitut viitteet vaativat toki jonkin verran tilaa, mutta tilantarve ei kasvane olennaisesti suuremmaksi kuin kaikkien parien täydellisen tallettamisen tilantarve. Kyselyihin vastaamiseen kuluva aika puolestaan toivon mukaan lyhenee merkittävästi, joten ratkaisu on tietokannan käyttäjän näkökulmasta katsoen miellyttävä. Ainoastaan tietoja lisättäessä aiheutuu jonkin verran lisätyötä siitä, että kaikki lisättäviin tietoihin liittyvät listat on muistettava saattaa ajan tasalle. TRAI 31.8.2012/SJ 4.5 Prioriteettijono Joukko-operaatioita voidaan rajoittaa vieläkin voimakkaammin kuin sanakirjaa ja kuvausta määriteltäessä, ja silti saadaan käyttökelpoinen abstrakti tietotyyppi. Jos nimittäin luovutaan tyystin contains -operaatiosta ja sallitaan alkioiden poistaminen ainoastaan tietyssä järjestyksessä, päädytään prioriteettijonoon. Alkioiden prioriteettijonoon viemistä ei mitenkään rajoiteta. Nimitys prioriteettijono perustuu siihen, että alkioille määritellään tärkeysjärjestys eli prioriteetti, joka määrää alkioiden joukostapoistamisjärjestyksen: paremman prioriteetin omaava alkio poistetaan joukosta ennen huonomman prioriteetin omaavaa alkiota. Kyseessä ei toisin sanoen ole puhdas jono, josta alkiot poistetaan saapumisjärjestyksessä, vaan prioriteettijonoon vietävä alkio sijoittuu jonossa jo ennestään oleviin alkioihin nähden oman prioriteettinsa mukaisesti. Prioriteettijonosta saadaan tavallinen jono kiinnittämällä prioriteetti samaksi kuin saapumisjärjestys. Esimerkki 4-6: Ensiapupoliklinikan potilaat muodostavat prioriteettijonon: sydänkohtauksen saanut potilas ohittaa jonossa potilaan, jonka sormi on poikki — riippumatta siitä, kuinka kauan sormipotilas on jo joutunut odottamaan. Prioriteetin määrääminen Prioriteettijonon alkioiden prioriteetin määräämiseksi on tunnettava prioriteettifunktio, joka on kussakin sovelluksessa yksilöllinen. Prioriteettifunktio on aiemmissa esimerkeissä nähdyn compareTo -funktion kaltainen sikäli, että prioriteettifunktio liittyy nimenomaan käsiteltäviin alkioihin eikä prioriteettijonoon itseensä: prioriteettifunktiota ei määritellä prioriteettijonoa kuvattaessa, vaan prioriteettijonon alkioita kuvattaessa. Funktio liittää alkioon prioriteetin, joka on jonkin lineaarisesti järjestetyn joukon alkion arvo. Useimmiten prioriteetit ovat kokonais- tai reaalilukuja, jolloin prioriteettien keskinäinen järjestys on helppo ratkaista. Prioriteettifunktion vaihtoehto on jättää prioriteettien laskenta ohjelmoijan vastuulle. Tällöin jonoon lisättäessä alkiolle annetaan erillinen reaaliarvoinen prioriteetti. TRAI 31.8.2012/SJ 4. JOUKOT 57 Tälläinen toteutus on yleiskäyttöisempi, eikä prioriteettifunktion välittämisestä tarvitse huolehtia. Ongelmana on kuitenkin esimerkiksi merkkijonojen prioriteettien ilmaiseminen reaalilukuina Meidän tietorakennekirjastossamme luokka AssignablePriorityQueue edellyttää käyttäjän antavan kokonaislukuarvoisen prioriteetin jokaisen lisäysoperaation yhteydessä. Javan PriorityQueue taas edellyttää joko alkioiden toteuttavan Comparable -rajapinnan tai erillisen vertailijan (Comparator) antamista prioriteettijonon luonnin yhteydessä. Prioriteettijonon remove -operaatiosta käytetään perinteisesti nimitystä deleteMin ja usein operaatio paitsi poistaa alkion, myös palauttaa poistamansa alkion arvon. Javassa operaatio on nimetty poll:ksi jonon operaation mukaan.. Koska poistettava alkio määräytyy prioriteettifunktion perusteella, ei deletemin -operaatiolle tarvitse edes antaa poistettavan alkion tunnistetta parametrina! Yhtenäiseen nimeämiskäytäntöön pyrittäessä pelkkä remove tai poll sopii nimeksi aivan hyvin, koska todellinen poistamisjärjestys ei välttämättä aina olekaan "pieniä" alkioita suosiva. Joissakin sovelluksissa "pienintä" alkiota ei haluta heti poistaa prioriteettijonosta, vaan aluksi riittää saada selville alkion arvo ja poistaminen tapahtuu vasta myöhemmin. Tällaisessa sovelluksessa erillinen min -operaatio on paikallaan, jottei alkiota tarvitse ensin ottaa jonosta pois ja heti sen jälkeen palauttaa jonoon. Javassa tämä on nimetty peek:ksi jonon tapaan. Käytännössä tarvitaan aina myös isEmpty -operaatiota, jotta tiedetään, koska jonon kaikki alkiot on käsitelty. Määritelmä 4-7: Prioriteettijonon operaatiot java.util.PriorityQueue:ssa: (PriorityQueue<E> P, E x. Alkiotyyppi E toteuttaa liittymän Comparable) 1) PriorityQueue<E> PriorityQueue<E>() Luo uuden tyhjän prioriteettijonon. 2) boolean P.isEmpty() Palauttaa true jos prioriteettijonossa P ei ole yhtään alkiota, muuten false. 3) void P.add(x) Lisää prioriteettijonoon P alkion x. 4) E P.poll() Poistaa ja palauttaa prioriteettijonosta P pieniprioriteettisimman alkion. 5) E P.peek() Palauttaa prioriteettijonon P pieniprioriteettisimman alkion. Prioriteettijonoa käytetään monen algoritmin aputalletusrakenteena. Sovelluksia ovat myös esimerkiksi optimointitehtävät, painotettu haku ja valinta. Prioriteettijonoa voidaan hyödyntää myös lajittelutehtävissä, koska jonosta saadaan helposti esiin pienin alkio. Jos prioriteettijono toteutetaan tehokkaasti, on prioriteettijonon käyttöön perustuva lajittelualgoritmi jopa yksi tehokkaimmista. Prioriteettijonon toteuttamista tarkastellaan kurssilla myöhemmin, mutta jo nyt voidaan jo todeta, että add- ja poll-operaatiot pystytään molemmat suorittamaan ajassa O(logn), kun n on jonon sisältämien alkioiden lukumäärä. peek ja isEmpty ovat O(1). Esimerkki 4-8: Niin sanottu kasalajittelu (heapsort) toteutetaan prioriteettijonon avulla. Jos lajiteltavat alkiot ovat aluksi listassa L, jonka halutaan lajittelun päätyttyä sisäl- 4. JOUKOT 58 tävän alkiot järjestyksessä, on lajittelualgoritmi suoraviivainen: alkiot prioriteettijonoon ja takaisin. [HeapSortEsim.java] public static void heapSort(Collection A) { Iterator i = A.iterator(); PriorityQueue P = new PriorityQueue(); // alkiot prioriteettijonoon while (i.hasNext()) { P.add(i.next()); i.remove(); } // alkiot takaisin kokoelmaan while (! P.isEmpty()) A.add(P.poll()); TRAI 31.8.2012/SJ } 1 2 3 4 5 6 7 8 9 10 11 12 Mikäli kokoelman A operaatiot, erityisesti remove ja add loppuun lisääminen, ovat tehokkaita, on algoritmin aikavaativuus O(nlogn), kun lajiteltavana on n alkiota. Ellei kokoelman loppuun lisääminen onnistu tehokkaasti, voidaan prioriteettifunktio määritellä niin, että jonosta poistetaankin suurin alkio, jolloin alkioita takaisin listaan vietäessä lisäyskohta on aina kokoelman alussa. Kasalajittelun tehokkaaseen (vakioaputila) toteutukseen palaamme ehkä myöhemmin. Esimerkki 4-9: (Listan) k:nneksi suurin alkio. Ryhdytään viemään alkioita prioriteettijonoon. Kun jonossa on k+1 alkiota, niistä pienin ei voi olla k:nneksi suurin, joten se poistetaan (poll). Jatketaan add+poll operaatioita kunnes lista läpikäyty. Nyt k:nneksi suurin alkio on prioriteettijonossa keulilla (peek). Aikavaativuus? 4.6 Laukku Yleinen joukkomalli sallii saman alkion esiintyvän joukossa vain yhtenä ilmentymänä kerrallaan. Sanakirja, kuvaus ja monilista noudattavat tässä samaa periaatetta. Sen sijaan prioriteettijonoissa on usein sallittua viedä sama alkio jonoon useita kertoja. Kasalajittelu ei edes olisi mahdollinen, jos sama alkio ei voisi sisältyä jonoon useita kertoja. Jos alkion toistuvien ilmentymien salliminen on välttämätöntä, ei tavallinen joukkomalli ole enää riittävä, vaan tarvitaan monijoukkoa. Monijoukossa sama alkio saa esiintyä useita kertoja samanaikaisesti. Esimerkiksi monijoukossa A = {2, 5, 2, 8, 6, 5, 2} on kaikkiaan seitsemän eri alkioita, joista alkiot 2 ja 5 esiintyvät useammin kuin kerran. Alkioiden luettelemisjärjestyksellä ei monijoukossakaan ole väliä. Abstraktina tietotyyppinä monijoukosta käytetään usein nimitystä laukku. Laukun operaatiot ovat samat kuin yleisen joukon operaatiot, mutta operaatiot on sovitettu vastaamaan monijoukon luonnetta. Esimerkiksi add vie lisättävän alkion laukkuun riippumatta siitä, sisältyykö alkio laukkuun ennestään vai ei. Vastaavasti remove poistaa toistuvasti esiintyvän alkion esiintymistä vain yhden ja jättää muut vielä laukkuun. Saman alkion kaikkien esiintymien poistamiseksi voidaan määritellä removeAll -operaatio tai suoritettava remove useasti. Luku 5 TRAI 31.8.2012/SJ Verkot Monia ongelmia ratkaistaessa joudutaan esittämään tietoalkioiden keskinäisiä suhteita. Aiemmin nähdyissä listoissa ja puissa alkioiden väliset suhteet perustuvat peräkkäisyyteen tai hierarkiaan: listan alkiolla voi olla yksi edeltäjä ja yksi seuraaja, puun solmulla puolestaan voi olla yksi isä ja useita poikia. Tällaiset suhteet eivät aina riitä, vaan tarvitaan malli paitsi useiden seuraajien, myös useiden edeltäjien esittämiseen. Jos solmujen yhteyksien suunta on merkittävä, kyseessä on suunnattu verkko (directed graph, digraph). Jos taas yhteydet ovat symmetrisiä, suuntaamaton verkko (graph) on parempi abstraktio. Monet erilaiset reaalimaailman ongelmat voidaan kuvata verkkoina. Verkko-ongelmien (ja siten algoritmienkin) kenttä on erittäin laaja, ehkä kaikkein laajin ja monipuolisin. Tällä kurssilla vain lyhyesti esittelemme verkkojen peruskäsitteet ja hahmottelemme abstraktia tietotyyppiä. Tietorakenteet ja algoritmit II -kurssilla esittelemme verkot tarkemmin ja opimme joitakin perusalgoritmeja. Käsitteitä Suunnattu verkko G = (V, E) muodostuu solmujen (vertex, node) joukosta V ja suunnattujen kaarten (edge) joukosta E, jota kutsutaan joskus myös nuolten joukoksi. Solmut kuvaavat verkon perusalkioita ja solmujen väliset suhteet esitetään suunnattujen kaarten avulla. Suunnattu kaari on kahden solmun u ja v järjestetty pari (u, v), jota merkitään myös u → v. Parin ensimmäinen solmu on suunnatun kaaren lähtösolmu ja jälkimmäinen solmu päätesolmu. Suunnattu kaari — lyhyesti kaari, jos on selvää, että on kyse suunnatusta verkosta — johtaa lähtösolmusta päätesolmuun. Kaari kuvaa parin jäsenten välistä naapuruussuhdetta: päätesolmu on lähtösolmun naapuri. Suhde on yksisuuntainen; päinvastainen naapuruussuhde vallitsee vain, jos verkossa on myös päinvastaiseen suuntaan suunnattu kaari. Solmulla voi olla mielivaltainen määrä naapureita ja vastaavasti solmu voi olla mielivaltaisen monen solmun naapuri. Solmu voi olla itsensä naapuri, ja naapuruus voi olla jopa moninkertaista, toisin sanoen lähtösolmusta päätesolmuun voi johtaa useita kaaria. Solmujen ja kaarten joukot ovat aina äärellisiä, joten verkkokin on äärellinen. Suunnatun verkon solmujen jono (v1, v2, …, vn) on polku (path), jos (vi, vi+1) on verkon kaari kaikille i = 1, …, n–1. Polun pituus on tällöin n–1. Polku on yksinkertainen, jos sen kaikki solmut, paitsi ehkä ensimmäinen ja viimeinen, ovat keskenään eri solmuja. Yksinkertainen polku, jonka ensimmäinen ja viimeinen solmu on sama, on kehä, jos 59 5. VERKOT 60 polun pituus on vähintään yksi. Tämä merkitsee, että solmujono (u, u) on kehä vain, jos solmusta u on kaari itseensä. Kehää nimitetään usein myös sykliksi. Esimerkki 5-1: Nelisolmuinen suunnattu verkko v1 v2 v3 v4 Solmujen joukko V = {v1, v2, v3, v4}. Kaarten joukko E = { (v1, v2), (v2, v2), (v2, v3), (v3, v1), (v3, v4) }. Kehät (v1, v2, v3, v1) ja (v2, v2). Polkuja mm. (v2, v2, v3, v1, v2, v3, v4) ja (v3, v1). TRAI 31.8.2012/SJ Suuntaamaton verkko Suuntaamaton verkko eroaa suunnatusta verkosta vain siinä, ettei verkon kaarilla ole suuntaa. Suunnan puuttumisen ansiosta suuntaamattomalla verkolla voidaan kuvata solmujen välisiä symmetrisiä suhteita. Tässä luvussa tarkastellaan lähinnä muutamia suuntaamattomien verkkojen algoritmeja. Erityisesti meidän on varmistuttava, ettemme vahingossa palaa samaa kaarta takaisin. Suuntaamaton verkko G = (V, E) koostuu solmuista ja kaarista kuten suunnattu verkkokin, mutta suuntaamattoman verkon kaaria ei varusteta suunnalla. Tämä merkitsee, että kaari (u, v) on järjestämätön pari ja se voidaan yhtä hyvin esittää muodossa (v, u). Koska kaarella ei ole suuntaa, voidaan molempia kaareen liittyviä solmuja kutsua kaaren päätesolmuiksi. Kaaren päätesolmut ovat toistensa naapureita, toisin sanoen naapuruussuhde on symmetrinen. Suunatusta verkosta poiketen, suuntaamattoman verkon kaaren ei sallita johtavan solmusta itseensä ja kahden eri solmun välillä saa olla vain yksi kaari. Polku ja polun pituus määritellään samoin kuin suunnatussa verkossa. Polku voidaan symmetrisen naapuruuden ansiosta kulkea kumpaan tahansa suuntaan. Yksinkertainen polku ei saa sisältää samaa solmua eikä samaa kaarta kahdesti. Ainoa poikkeus on kehä, jota vastaavan polun ensimmäinen ja viimeinen solmu ovat sama. Kehän pituuden tosin täytyy olla vähintään kolme, koska kaari ei saa johtaa solmusta itseensä eikä sama kaari saa sisältyä polkuun kahdesti. Tästä seuraa, että kehään tarvitaan ainakin kolme solmua. Suuntaamaton verkko — lyhyemmin verkko, jos on selvää, että tarkoitetaan suuntaamatonta verkkoa — on kehäinen, jos siinä on ainakin yksi kehä, muuten kehätön. 5. VERKOT 61 TRAI 31.8.2012/SJ Verkko abstraktina tietotyyppinä Koska verkko koostuu kahdesta joukosta, on verkko luonnollista määritellä abstraktien joukkojen avulla. Verkonkäsittelyoperaatiot liittyvät verkon solmujoukon ja kaarijoukon käsittelemiseen alkioita lisäämällä ja poistamalla sekä alkioiden nimiöitä tutkimalla. Nämä operaatiot saadaan suoraan joukkotyypeistä. Koska joukkoja on kaksi, tulee operaatiot olla erikseen solmujen ja kaarten käsittelyä varten. Lisäksi esiintyy usein tarve käydä yksitellen läpi tietyn solmun kaikki naapurit, verkon kaikki solmut tai kaikki kaaret. Jotta läpikäynti onnistuisi tehokkaasti, ei joukkototeutusta kannata valita umpimähkään, vaan kuhunkin sovellukseen on etsittävä sopivin joukkomalli. Etenkin tietyn solmun naapureiden läpikäynnin jouduttamiseksi on yleensä tarkoituksenmukaisinta esittää kullekin solmulle oma naapureiden joukkonsa. Kaarten joukkoa puolestaan ei aina tarvitse erillisenä toteuttaakaan, koska kaaret voidaan selvittää naapuruussuhteita tarkastelemalla. Sekä verkon solmu että verkon kaari voidaan varustaa nimiöllä. Esimerkiksi äärellinen automaatti on suunnattu verkko, jonka solmuilla (tiloilla) on ominaisuudet "alkutila?" ja "lopputila?" sekä kaarilla (siirtymillä) ominaisuus "aakkonen". Usein solmun nimenkin katsotaan sisältyvän nimiöön. Tyystin nimeämättä solmuja ei voida jättää, koska silloin menetettäisiin väline kaarten yksilöimiseksi. Luonnollisesti myös kaaret voitaisiin nimetä, mutta tavallisimmin kaaret tunnistetaan lähtö- ja päätesolmu mainitsemalla. Verkko-ongelmia/algoritmeja Verkkoteoria on eräs monipuolisimmista algoritmiikan alueista — ongelmia ja algoritmeja on "loputtomasti". Ongelmia ja algoritmeja löytyy sekä (matematiikan, tilastotieteen ja muiden tieteenalojen) verkkoteorian kirjoista, että myös tietojenkäsittelytieteen algoritmikirjallisuudesta. Tyypillisiä esimerkkejä ongelmista ovat polkujen pituudet ja olemassaolot, maksimaalisen virtauksen laskeminen, erillisten polkujen hakeminen, jne kahden solmun tai kaikkien solmujen välille. Koko verkosta voidaan myös tarkastella erilaisia yhtenäisyysominaisuuksia, hakea klikkejä, virittäviä puita, erilaisia polkuja (esim. kauppamatkustajan reittiä). Annettua verkkoa voidaan myös täydentää halutun ominaisuuden saavuttamiseksi tai osittaa tai ryhmitellä verkon pienentämiseksi. Verkkoa voidaan käyttää myös mm. osittaiseen lajitteluun ja erilaisiin sovituksiin. Kaikkia näitä voidaan soveltaa erilaisiin verkkoihin (suunnattu, suuntaamaton, syklitön, syklinen, erilaiset painometriikat, jne). Luku 6 TRAI 31.8.2012/SJ Lajittelu eli järjestäminen Lajittelun tarkoituksena on järjestää alkiokokoelma lineaariseen järjestykseen. Lajiteltavat alkiot saadaan useimmiten peräkkäisrakenteessa ja lajittelun tulos tuotetaan samanlaiseen peräkkäisrakenteeseen, mutta muitakin esitysvaihtoehtoja on olemassa. Varsinaisessa lajittelussa erotetaan kaksi olennaisesti erilaista tapausta, sisäinen lajittelu ja ulkoinen lajittelu. Sisäisessä lajittelussa kaikki lajiteltavat alkiot sopivat samanaikaisesti keskusmuistiin, kun taas ulkoisessa lajittelussa lajiteltavia alkioita on niin paljon, että vain osa kokoelmasta voidaan kerrallaan säilyttää keskusmuistissa. Tarkastellaan tässä luvussa vain sisäisen lajittelun algoritmeja ja aikavaativuutta. 6.1 Sisäinen lajittelu Sisäisessä lajittelussa lajiteltavat alkiot annetaan yleensä joko taulukossa tai listassa. Alkioita on joka tapauksessa niin "vähän", että ne kaikki sopivat yhtaikaa keskusmuistiin. Sen ansiosta voidaan olettaa, ettei yksittäisen alkion saantia hidasta mikään käsiteltävän rakenteen ulkopuolinen tekijä. Taulukkoviittaukset ratkeavat toisin sanoen vakioajassa ja myös listasta alkio saadaan haetuksi vakioajassa, mikäli lista käsitellään järjestyksessä ensimmäisestä alkiosta viimeiseen. Listan mielivaltaisessa asemassa olevan alkion saantiin kuluu luonnollisesti aikaa O(listan pituus). Lajittelun kuluessa alkiot järjestetään jonkin ominaisuutensa perusteella lineaariseen järjestykseen. Tämä järjestys voi olla joko kasvava, jolloin alkiot järjestetään pienimmästä suurimpaan, tai vähenevä, jolloin järjestys on päinvastainen. Järjestäminen perustuu alkioiden johonkin ominaisuuteen, niin sanottuun lajitteluavaimeen. Yksinkertaisimmillaan lajitteluavain on esimerkiksi alkion nimi tai numero. Monimutkaisemmassa tapauksessa lajitteluavaimeksi ei sen sijaan riitä mikään alkion yksittäinen ominaisuus, vaan joudutaan käyttämään näiden yhdelmää. Esimerkiksi puhelinluettelossa asiakkaat järjestetään ensisijaisesti sukunimen mukaan aakkosjärjestykseen, mutta koska useilla asiakkailla on sama sukunimi, käytetään niin sanottuna toissijaisena lajitteluavaimena asiakkaan etunimeä. Mikäli kahden alkion lajitteluavaimet ovat keskenään täysin samanlaiset, ovat nämä alkiot lajitellussa kokoelmassa välittömästi peräkkäin jossakin järjestyksessä. Tällöin pitäisi esimerkiksi kasvavan järjestyksen asemesta puhua ei-vähenevästä järjestyksestä, mutta käytännössä näiden molempien yleensä ymmärretään tarkoittavan 62 6. LAJITTELU ELI JÄRJESTÄMINEN 63 samaa. Jollei lajiteltavien alkioiden kokoelmassa vallitse täydellinen lineaarinen järjestys, voidaan lajittelu ehkä perustaa johonkin osittaiseen järjestykseen. Näin tehdään aiemmin nähdyssä topologisessa lajittelussa. Lajittelualgoritmin aikavaativuutta arvioitaessa valitaan mitattavaksi usein jokin algoritmiin sisältyvistä perustoiminnoista. Esimerkiksi tarvittavien vertailujen määrä on luonteva mittayksikkö etenkin silloin, kun on kyse monimutkaisesta vertailusta, joka ei onnistu vakioajassa. Kookkaita alkioita lajiteltaessa sopii mittayksiköksi myös alkion siirto, koska kokonaisen alkion kopioiminen saattaa olla huomattavasti raskaampaa kuin lajitteluavaimen vertaileminen. TRAI 31.8.2012/SJ 6.2 Yksinkertaisia lajittelualgoritmeja Muutamat yksinkertaisimmat lajittelualgoritmit ovat tuttuja jo kurssin alkuosasta. Esimerkiksi kuplalajittelun ideana on tarkastella taulukkoa toistuvasti eri kohdista ja tarvittaessa vaihtaa tarkastelukohdassa oleva alkio vieressään olevan alkion kanssa. Kun näin jatketaan tietyn säännön mukaisesti riittävän kauan, saadaan alkiot lopulta haluttuun järjestykseen. Kuplalajittelun aikavaativuudeksi todettiin jo aiemmin O(n2), kun lajitellaan n alkiota sisältävää taulukkoa. Upotuslajittelussa alkiot viedään yksi kerrallaan alun perin tyhjään taulukkoon, jossa alkiot pidetään järjestyksessä siirtämällä lisäyskohdassa oleva alkio seuraajineen taulukon loppua kohden ennen uuden alkion paikalleen viemistä. Myös upotuslajittelun aikavaativuudeksi on todettu O(n2). Kolmas yksinkertainen lajitteluperiaate on niin sanottu valintalajittelu, joka etenee seuraavan algoritmin mukaisesti: for (int i = 0; i < n–1; i++) swap(A[i], min(A[i], …, A[n])). 1 2 Algoritmi toisin sanoen etsii k:nnella kierroksella taulukon vielä lajittelemattoman loppuosan n–k+1 alkiosta pienimmän ja vie tämän suoraan lopulliseen paikkaansa taulukon k:nneksi alkioksi. Tämänkin algoritmin aikavaativuus on O(n2). Kaikkien kolmen algoritmin aikavaativuus on O(n2) paitsi pahimmassa tapauksessa, myös keskimäärin. Sekä kupla- että valintalajittelu ovat O(n2) jopa parhaimmassakin tapauksessa. Upotuslajittelu voidaan sen sijaan toteuttaa siten, että parhaan tapauksen aikavaativuus on vain O(n). (Miten?) Jos alkioiden siirtely on raskasta, on valintalajittelu näistä kolmesta paras, koska sen kuluessa tehdään siirtoja O(n), kun taas kahdessa muussa algoritmissa siirtojakin tehdään O(n2). Kookkaita alkioita lajiteltaessa voidaan alkioiden toistuvalta siirtelemiseltä välttyä ottamalla käyttöön aputaulukko, johon talletetaan varsinaisten alkioiden indeksejä. Lajittelun aluksi siirrellään vain aputaulukon alkioita, ja kun nämä on järjestetty, voidaan varsinaiset alkiot siirtää suoraan aputaulukon ilmaisemaan järjestykseen. Esitetyt O(n2) lajittelualgoritmit ovat käyttökelpoisia vain pieniä kokoelmia lajiteltaessa. Seuraavaksi tarkastellaan tehokkaampia O(nlogn) algoritmeja. Jos esimerkiksi n = 1000, on n2 = 1 000 000 ja nlogn = 9 966. Vastaavasti jos n = 10 000, on n2 = 100 000 000, mutta nlogn = 132 877. 6. LAJITTELU ELI JÄRJESTÄMINEN 64 6.3 Pikalajittelu (Quicksort) C.A.R. Hoaren keksimä Quicksort, jota joskus kutsutaan pikalajitteluksi, on niin sanottu hajota-ja-hallitse -menetelmä: Lajiteltava taulukko jaetaan kahteen osaan ja ennen jakoa alkioita siirrellään siten, että taulukon alkuosassa on vain "pieniä" ja loppuosassa vain "suuria" alkioita. Kun jako on tehty, sovelletaan quicksort-menetelmää erikseen molempiin osataulukoihin ja näin koko taulukko saadaan lajitelluksi. Jaon jälkeen jakokohdassa oleva alkio, keskusalkio, on valmiiksi oikeassa paikassaan, joten sitä ei enää myöhemmin tarvitse siirtää. i A: k alkion X:n edeltäjät quicksort(A, i, k–1) X j alkion X seuraajat quicksort(A, k+1, j) TRAI 31.8.2012/SJ Kuva 6-1: Pikalajittelun rekursio partition-aliohjelman jälkeen. Menetelmän vaikein kohta on keskusalkion valinta. Optimitilanteessa molemmat osataulukot olisivat keskenään samankokoiset, jolloin keskusalkio olisi lajiteltavien alkioiden mediaani. Valitettavasti mediaanin etsiminen on melko työlästä, kts. alempaa, joten käytännössä keskusalkio on valittava arvaamalla. Hyvällä onnella osataulukot tulevat miltei samankokoisiksi, mutta huonoimmillaan toinen osataulukoista surkastuu tyhjäksi. Valintaa voidaan hieman parantaa ottamalla keskusalkioksi esimerkiksi mediaani kolmesta ehdokkaasta, mutta tämäkään menettely ei takaa osataulukkojen samankokoisuutta. Opetuskäytössä voidaan keskusalkioksi valita vaikkapa käsiteltävän osataulukon ensimmäinen alkio, mutta todellisille syötteille se on usein katastrofi. Lähes mikä tahansa muu valinta on turvallisempi. Käytännössä arvottu alkio tai kolmen arvotun (tai edustavasti valitun) mediaani ovat parhaat. Itse quicksort-algoritmi esitetään yksinkertaisimmillaan rekursiivisessa muodossa, mikä onkin tyypillistä hajota-ja-hallitse -menetelmille. Algoritmi 6-1: Pikalajittelu. public static void quicksort(Comparable A[], int i, int j) { if (i < j) { int k = partition(A, i, j); quicksort(A, i, k–1); quicksort(A, k+1, j); } } 1 2 3 4 5 6 7 Koko taulukko lajitellaan suorittamalla quicksort(A, 0, A.length–1). Jakoalgoritmissa partition tarkastellaan osataulukkoa A[i..j] vuorotellen eri päistä. Jos loppupäästä löytyy valittua keskusalkiota pienempi alkio tai alkupäästä vastaavasti keskusalkiota suurempi alkio, siirretään loppupään alkio osataulukon alkupäähan tai päinvastoin. Siirron jälkeen vaihdetaan tarkastelusuuntaa ja näin jatketaan, kunnes koko osa- 6. LAJITTELU ELI JÄRJESTÄMINEN 65 taulukko on käyty läpi. Lopuksi "pienet" alkiot ovat taulukon alkupäässä ja "suuret" loppupäässä. Keskusalkio viedään "pienten" ja "suurten" alkioiden väliin. TRAI 31.8.2012/SJ Algoritmi 6-2: Pikalajittelun jaottelu. public static int partition(Comparable A[], int i, int j) { Comparable jakoalkio = A[i]; while (i < j) { // toistetaan kunnes i ja j törmäävät // etsitään lopusta jakoalkiota pienempi while ((i < j) && (jakoalkio.compareTo(A[j]) < 0)) j––; A[i] = A[j]; // etsitään alusta jakoalkiota suurempi tai yhtäsuuri while ((i < j) && (jakoalkio.compareTo(A[i]) >= 0)) i++; A[j] = A[i]; } // jakoalkio paikalleen ja palautetaan sijainti A[i] = jakoalkio; return i; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Quicksort-algoritmin aikavaativuuden analysointi ei ole aivan suoraviivaista. Partition aliohjelmassa jokaisella askeleella joko j vähenee tai i kasvaa, joten aikavaativuus on selvästi O(n), missä n = j –i+1. Jos jako osuisi aina puoleen väliin, saisimme aikavaativuudeksi n O ( n ) + 2 × T --- , kun n > 1 2 ⇒ T best ( n ) = O ( 1 ) , kun n ≤ 1 T best ( n ) = O ( n log n ) . (6-1) [Tarkemmin TRA2] Jos taas jako osuisi aina huonosti, aikavaativuus olisi O ( n ) + T ( n – 1 ) + O ( 1 ) , kun n > 1 T (n) = ⇒ O ( 1 ) , kun n ≤ 1 2 T (n) = O(n ) . (6-2) [Tarkemmin TRA2] Keskimääräisen aikavaativuuden analysointi on varsin monimutkainen tehtävä, joten sivuutetaan analyysi tällä kurssilla ja todetaan aikavaativuuden olevan keskimäärin O(nlogn). Vaikka pahimmassa tapauksessa aikavaativuus on sama kuin esimerkiksi kuplalajittelulla, paremman keskimääräisen aikavaativuutensa ansiosta pikalajittelu on yleensä yksinkertaisia lajittelualgoritmeja tehokkaampi. Toisaalta on syytä panna merkille myös pikalajittelualgoritmin tilavaativuus eli sen tarvitseman aputilan määrä. Tilaa 6. LAJITTELU ELI JÄRJESTÄMINEN 66 näet kuluu joko rekursion hallintaan tai — rekursiottomassa versiossa — lepäämässä olevan osataulukon yksilöimiseksi tarvittavien tietojen tallettamiseen. Pahimmillaan tilavaativuus on O(n) ja parhaimmillaankin O(logn). (Miksi?) Yksinkertaisten lajittelualgoritmien aputilantarve on sen sijaan aina O(1). Käytännössä pienten osataulukkojen lajitteleminen rekursiivisesti on tehotonta. Siksi kannattaa rekursion asemesta soveltaa jotakin yksinkertaista lajittelumenetelmää siinä vaiheessa, kun osataulukko sisältää enintään noin yhdeksän alkiota. Yleensäkin pienet taulukot on aina järkevintä lajitella jollakin tehottomalla algoritmilla. Tehokkaissa algoritmeissa on näet tehokkuuden saavuttamiseksi sellaisia piirteitä, jotka tosiasiassa hidastavat lajittelua, jos lajiteltavia alkioita on vähän. Sen sijaan suurten taulukoiden lajittelussa tehokkaat algoritmit ovat ylivoimaisia. TRAI 31.8.2012/SJ Valinta (mediaani) Valinnassa (selection) tehtävänä etsiä kokoelman k:nneksi suurin (tai pienin) alkio. Helposti se saadaan selville lajittelemalla kokoelma ja valitsemalla k:s alkio. Prioriteettijonosta on myös hyötyä, erityisesti jos k on pieni (kts. harjoitukset prioriteettijonoon liittyen). Tärkeä erikoistapaus valinnasta on keskimmäinen alkio eli mediaani. Pikalajittelun jakoalgoritmia voidaan hyödyntää keskimäärin nopean valinnan toteuttamiseksi. Olkoon valittava alkio k:nneksi pienin. Jakoalgoritmihan sijoittaa yhden alkion oikealle paikalleen (olkoon se indeksi j), ja muut alkiot oikealle puolelle ko. jakoalkioon nähden. Näinollen, jos k = j valittava alkio on löytynyt, jos k < j, valittava alkio löytyy vasemmasta (1..j–1) osataulukosta, jos taas k > j, valittava alkio löytyy oikeasta (j+1..n) osataulukosta. Hakua jatketaan rekursiivisesti kunnes oikea kohta löytyy. Aikavaativuuden analyysi sujuu kuten pikalajittelussakin, mutta nyt kahden rekursiivisen kutsun sijaan tehdään vain yksi. Jos jako osuu aina puoleenväliin, niin n O ( n ) + T --- , kun n > 1 2 T best ( n ) = ⇒ O ( 1 ) , kun n ≤ 1 (6-3) n n T best ( n ) = n + --- + --- + … + 1 2 4 T best ( n ) = O ( n ) . Pahimman tapauksen aikavaativuus (ja syötteet joissa se esiintyy) taas on identtinen pikalajittelun kanssa, eli O(n2). Tämän valinta-algoritmin käyttäminen sellaisenaan on siis melkoinen riski. Mikäli jakoalkioksi valitaan esimerkiksi kolmen valitun alkion mediaani, riski pienenee oleellisesti. Näytekoon kasvattaminen (esimerkiksi kokoon O(n/logn), joka voidaan lajitella lineaarisessa ajassa) pienentää riskiä edelleen, mutta ei poista sitä. Sensijaan valitsemalla näyte älykkäästi, voidaan pikavalinta-algoritmi saada luotettavasti toimimaan lineaarisessa ajassa. "Älykäs" näyte on hakea mediaani alijoukkojen mediaanien joukosta. Tarkempi kuvaus esim. Weiss:n kirjasssa [6]. 6. LAJITTELU ELI JÄRJESTÄMINEN 67 6.4 Kasalajittelu TRAI 31.8.2012/SJ Äskeiseen quicksort-algoritmiin nähden täysin erilainen O(nlogn) lajittelualgoritmi on prioriteettijonoa hyödyntävä kasalajittelualgoritmi johon viittasimme jo prioriteettijonon yhteydessä esimerkissä 4-8 (s. 57). Kasalajittelualgorimissa kaikki lajiteltavat alkiot viedään ensin prioriteettijonoon, josta alkiot poistetaan suuruusjärjestyksessä. Kasalajittelu on itse asiassa valintalajittelun toteutus: vielä lajittelemattomista alkioista pienin saa käsittelyvuoron ennen suurempia alkioita. Koska sekä lisäyksen että poiston aikavaativuus on prioriteettijonossa O(logn), on algoritmin aikavaativuus ilmeinen: kasalajittelu on pahimmassakin tapauksessa O(nlogn). Voidaan tosin osoittaa, että quicksort on keskimäärin hieman kasalajittelua tehokkaampi. Kasalajittelu on paikallaan silloin, kun koko taulukon asemesta riittää lajitella k pienintä alkiota (k << n). Jos k ≤ n/logn, voidaan aikavaativuuden todistaa olevan O(n). Kasalajittelun perusversion tilavaativuus on O(n), sillä kasaan vietäessä alkiot kopioidaan. Kasalajittelu on mahdollista toteuttaa myös siten, ettei erillistä prioriteettijonon talletusaluetta lainkaan tarvita, jolloin tilavaativuus on vakio. Tämän version esitämme myöhemmin prioriteettijonon toteutuksen yhteydessä. 6.5 Lomituslajittelu Lomituslajittelun ideana on lomittaa kahta valmiiksi lajiteltua osakokoelmaa. Lomitus on helpompi ja nopeampi (O(n)) kuin lajittelu. Lajittelualgoritmissa pilkotaan syöte rekursiivisesti yhä pienempiin paloihin, kunnes jäljellä on vain yhden alkion kokoisia syötteitä. Nämä ovat valmiiksi lajiteltuja, joten niitä voidaan alkaa lomittaa. Lomituksen tulokset taas lomitetaan keskenään ja lopulta koko syöte on lajiteltu. Aikavaativuudeksi saadaan aina O(nlogn), mutta suoraviivainen ratkaisu vaatii aputilaa. Lomituslajitteluun palataan ulkoisen muistin käsittelyn yhteydessä TRA2 kurssilla. public static void mergesort(Comparable A[], int alku, int loppu) { if (alku < loppu) { int k = (alku + loppu) / 2; mergesort(A, alku, k); // puoliskot rekursiiv. mergesort(A, k+1, loppu); merge(A, alku, k, k+1, loppu); // lomitetaan puoliskot } } 1 2 3 4 5 6 7 6.6 Kaukalolajittelu Jos lajiteltavien alkioiden avaimet rajoittuvat välille 1…m, voidaan alkiot lajitella vielä äsken esitettyjä algoritmejakin tehokkaammin, mikäli m on kohtuullisen kokoinen: lajittelun alkaessa muodostetaan m kaukaloa, kukin alkio viedään numeronsa mukaiseen kaukaloon ja lopuksi kaukaloiden sisällöt yhdistetään järjestyksessä aloittaen pieninumeroisimmasta kaukalosta. Yksinkertaisimmillaan alkiot ovat kokonaislukuja 1…m, jolloin itse asiassa riittää laskea kuhunkin kaukaloon vietävien alkioiden lukumäärä (ns. laskentala- 6. LAJITTELU ELI JÄRJESTÄMINEN 68 jittelu). Monimutkaisempien alkioiden tapauksessa alkiot on todella siirrettävä kaukaloihin, mutta silloinkin päädytään toteutusrakenne sopivasti valitsemalla hyvin tehokkaaseen ratkaisuun. Tarvittavat kaukalot voidaan esittää taulukkona, jonka indeksit vastaavat kaukaloiden numeroita. Kaukalon sisältö puolestaan esitetään listana, johon alkiot on helppo lisätä. Listat yhdistetään viemällä ne lopuksi alkuperäisen taulukon loppuun. Oletetaan, että alkiot ovat objekteja ja alkion avain saadaan getKey -menetelmällä. Algoritmi 6-3: Kaukalolajittelu. public static binsort(E A[], int maxkey) { Vector<List<E>> kaukalot = new Vector<List<E>>(maxkey+1); for (int i = 0; i <= maxkey; i++) kaukalot[i] = new LinkedList<E>(); for (int i = 0; i < A.length; i++) (kaukalot.get(A[i].getKey())).add(A[i]); A.clear(); for (int i = 0; i <= maxkey; i++) A.addAll(kaukalot.get(i)); TRAI 31.8.2012/SJ } 1 2 3 4 5 6 7 8 9 10 Mikäli kaikki lista- ja taulukko-operaatiot, myös addAll, ovat tehokkaita ja taulukossa on n alkiota, on kaukalolajittelun aikavaativuus O(m + n). Käytännössä m < n, jolloin aikavaativuus on O(n), toisin sanoen vielä kasalajittelun aikavaativuuttakin parempi. Algoritmi jopa säilyttää keskenään sama-avaimisten alkioiden alkuperäisen järjestyksen; tällaista algoritmia sanotaan stabiiliksi. Kaikki lajittelualgoritmit eivät ole stabiileja. Kaukalolajittelua voidaan soveltaa myös tilanteessa m = pk, kun k vakio. Silloin lajitteluun riittää p kaukaloa, mutta lajittelu etenee vaiheittain: Vaiheessa i alkio viedään kaukaloon, jonka numero on sama kuin avaimen i:nneksi vähiten merkitsevä numero, kun avain tulkitaan p-järjestelmän luvuksi. Kaukaloiden sisällöt yhdistetään samoin kuin algoritmin perusversiossakin, minkä jälkeen siirrytään vaiheeseen i + 1. Vaiheen k päättyessä alkiot on lajiteltu. Aikavaativuus on O(k(p + n)), mikä sievenee muotoon O(n), koska k on vakio ja käytännössä p < n. Usean vaiheen sijaan voidaan myös ylläpitää kaukaloita joissa kussakin on usean avainarvon mukaisia alkioita. Tällöin joudutaan lisäys listaan tekemään kaukalon listan järjestys säilyttäen, eli listaa joudutaan läpikäymään loppuun lisäämisen sijaan. Tämä luonnollisesti huonontaa aikavaativuutta, mutta voi toimia hyvin tasaisesti jakautuneelle avainjoukolle. Kaukaloiden perustaminen ja listojen ylläpitäminen on hieman raskasta ja aiheuttaa ylimääräisiä satunnaisia muistiviittauksia. Suoraviivaisempaan taulukkojen läpikäyntiin päästään laskemalla (osa)avainten määrät laskentalajittelun tapaan ja laskemalla kullekin alkiolle uusi sijainti alkusummalla. Tähän kantalukulajitteluun palaamme TRA2 kurssilla. Luku 7 TRAI 31.8.2012/SJ Abstraktien tietotyyppien toteuttaminen Jotta abstraktia tietotyyppiä voitaisiin todella käyttää, on abstrakti malli toteutettava. Toteuttaminen edellyttää mallia vastaavan todellisen talletusrakenteen valintaa ja mallin liittymässä kuvattujen operaatioiden toteuttamista sellaisina, että operaatiot käyttäytyvät määrittelynsä mukaisella tavalla. Toteutuksessa on lisäksi pyrittävä mahdollisimman hyvään tai ainakin riittävään tehokkuuteen, jotta toteutus olisi käytännössäkin hyödyllinen. Esitellään nyt joidenkin kurssilla kuvattujen abstraktien tietotyyppien toteutusvaihtoehtoja. Toteutuskielenä käytetään Java-kieltä, joka pääosin soveltuu varsin hyvin tietorakenteiden toteuttamisen opetuskieleksi. 7.1 Kotelointi ja parametrointi Abstraktia tietotyyppiä toteutettaessa on tärkeimpänä ohjeena tietotyypin liittymä. Liittymä määrää toteutettavasta tyypistä ja operaatioista käytettävät nimitykset samoin kuin parametrien lukumäärän, järjestyksen ja tyypin. Operaatioiden merkityksen ja operaatioiden käyttämistä mahdollisesti koskevien rajoitusten tunteminen on myös välttämätöntä, kun abstraktia tietotyyppiä ryhdytään toteuttamaan. Liittymässä mainittujen operaatioiden lisäksi toteuttaja voi luonnollisesti laajentaa tietotyyppiä muillakin operaatioilla, mutta mahdolliset uudet operaatiot eivät saa näkyä toteutuksen ulkopuolelle, ellei myös liittymää kyetä laajentamaan. Liittymässä kuvatut operaatiot ovat julkisia ja tietotyypin muut operaatiot yksityisiä. Yksityiset operaatiot laaditaan julkisten operaatioiden toteuttamisen helpottamiseksi, mutta yleensä niitä ei ole edes tarpeen paljastaa tietotyypin käyttäjälle. Operaatioiden julkisiksi ja yksityisiksi erottelussa on kyse koteloinnista: tietotyypin käyttäjälle tarjotaan vain tietotyypin käyttämiseksi tarpeelliset välineet, kaikki muu pidetään poissa käyttäjän ulottuvilta. Kotelointia tarvitaan paitsi yksityisten operaatioiden, myös tietotyypin todellisen talletusrakenteen kätkennässä. Käyttäjänhän ei ole tarkoituskaan tietää, miten abstrakti tietotyyppi on toteutettu, puhumattakaan siitä että käyttäjä pääsisi suoraan käsiksi talletusrakenteeseen. Ainoa keino, jolla käyttäjä pystyy operoimaan toteutusta, on liittymän mukaisten operaatioiden käyttäminen. Koteloinnin sinänsä kauniista periaatteista joudutaan käytännössä valitettavan usein tinkimään sen takia, että monet ohjelmointikielet eivät tue kotelointia. Esimerkiksi Pascal- kielen standardiversio ei tarjoa mitään mahdollisuutta kotelointiin, eivätkä kielen 69 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 70 laajennoksetkaan (Turbo Pascal) tosiasiassa mahdollista onnistunutta kotelointia, vaikka toisin monasti väitetään. Siitäkin huolimatta abstrakti tietotyyppi tulisi aina toteuttaa — ja tyyppiä käyttää — koteloinnin periaatteita mahdollisimman pitkälti noudattaen. Java-kielikään ei tue varsinaista kotelointia kuin liittymä (interface) mekanismin kautta, eikä sekään oikeastaan ole tarkoitettu tässä tarkoitettuun liittymän ja toteutuksen erottamiseen. Sensijaan varsinaisen lähdekoodin jakamisen sijaan Java tukee liittymän automaattista generointia sopivasti dokumentoidusta lähdekoodista Javadoc -välineen avulla. Javadoc poimii lähdekoodista luokan (julkisen) liittymän, ts. yleensä metodit, niiden parametrit ja palautusarvot tyyppeineen. Lisäksi dokumentointiin poimitaan lähdekoodista poimittuja kommentteja kunhan ne on muotoiltu oikealla tavalla. Jos kommentit on oikein tehty, generoitu dokumentointi kaiken tarvittavan ko. luokan käyttämiseen. Esimerkki Javadoc:n käytöstä on mm. Java API:n dokumentaatio, joskin siinäkin mm. aikavaativuuksien esittämisessä on melkoista kirjavuutta. TRAI 31.8.2012/SJ Parametrointi Jotta toteuttamamme kokoelma olisi mahdollisimman monikäyttöinen, siihen olisi pystyttävä tallettamaan mitä tahansa alkiotyyppejä, mieluiten itse kokoelmaa muuttamatta ja kääntämättä. Näin kokoelman käyttäjä (ohjelmoija) voisi käyttää samaa toteutusta yhä uudelleen vaikka hänellä ei välttämättä olisi edes toteutuksen lähdekoodia käytettävissään. Tälläinen hyvin joustava parametrointi voidaan toteuttaa esimerkiksi ylläpitämällä kokoelmassa tyypittömiä osoittimia tai Object -viittauksia. Vielä yleisempi vaihtoehto olisi ylläpitää kokoelmassa vain tavutaulukkoa jota ohjelmoija voisi vapaasti käyttää haluamallaan tavalla. Tämä ei kuitenkaan ole kovin kätevää, eikä varsinkaan turvallista. Jotta parametroidun kokoelman käyttö olisi turvallista, tulisi mieluiten kääntäjän pystyä tarkistamaan kokoelmaan vietävät ja sieltä tuotavat tyypit jo käännösaikana. Tämän ja toisaalta hyvän koteloinnin yhdistäminen on kuitenkin melko haastavaa, eikä onnistu kauniisti monellakaan ohjelmointikielellä. Javan (versio 1.5–) geneerisillä kokoelmilla päästään melko lähelle hyvää tulosta, joskin hieman ylimääräistä työtä sen toteuttaminen vaatii. (Javan) geneeriset kokoelmat Javan versioon 1.5 lisätty geneeristen (parametroitujen) kokoelmien mekanismi on hieman sukua toisaalta tekstuaaliselle parametroinnille, toisaalta C++:n template -mekanismille, mutta kääntäjän ja ajonaikaisen linkkerin työnjako on Javassa hieman erilainen. Javassa kokoelman muodollinen alkiotyyppi on mukana kokoelmaluokan käännöksessä, mutta poistetaan käännöksen lopuksi. Kokoelmaa käyttävän luokan käännöksessä on siten alkiotyyppi mainittava lähes jokaisessa kohdassa jotta kääntäjä pystyisi tyyppitarkastukset tekemään. Jos näin ei tehdä, joudutaan käyttämään pakotettuja tai implisiittisiä tyypinmuunnoksia (cast) jotka virtuaalikone tarkastaa ajonaikana. Lievänä rajoituksena (hidasteena) voi pitää sitä, että parametroinnin voi tehdä ainoastaan objekti(viittaukse)lle, ei yksinkertaiselle tyypille. Tämä ei kuitenkaan ole kovin merkittävä muuten kuin yksinkertaisissa esimerkeissä. Geneerisen kokoelman parametrointimekanismia voi itseasiassa käyttää minkä tahansa luokan parametrointiin, mutta keskitytään tässä vain kokoelmiin. Javassa geneerinen kokoelma parametroidaan yhdellä tai useammalla tyypillä: 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN Kokoelma<Alkiotyyppi> Kokoelma<Alkiotyyppi1, Alkiotyyppi2> 71 (7-1) Kokoelmaa (luokkaa) esiteltäessä käytetään muodollista tyyppiparametria, merkitään sitä yleensä tunnuksella E: public class BTreeNode<E> { private E element; ... public E setElement(E element) { ... (7-2) Jos alkiotyyppiä halutaan jotenkin rajoittaa (yleensä tarvittaessa jotain ominaisuuksia), voidaan käyttää esittelyä <E extends HaluttuYläluokka> tai <E super HaluttuAliluokka>, esimerkiksi TRAI 31.8.2012/SJ public class LajittuvaLista<E extends Comparable> { (7-3) Näin kokoelman alkioilta voidaan olettaa (ilman pakotettua tyypinmuunnosta) compareTo -metodin toteuttaminen. Kokoelmaa käyttöönotettaessa annetaan todellinen tyyppiparametri: Kokoelma<Alkio> oma = new Kokoelma<Alkio>(); (7-4) Esimerkiksi: LinkedList<String> mjonoLista = new LinkedList<String>(); LinkedList<Set<Integer>> lukuJoukkojenlista = new LinkedList<Set<Integer>>() (7-5) Jos kokoelmalla on (toteutuksessa) komponentteina muita luokkia, molemmille määritellään erikseen (samat) alkiotyypit: List<Integer> lista; ... ListNode<Integer> p = lista.getFirst(); Integer x = p.getElement(); (7-6) Geneeriset metodit public class Esim { public static <E> boolean samoja(Collection<E> kokoelma) { for (E : kokoelma) ... } LinkedList<String> L = new LinkedList<Sting>(...); boolean onko = Esim.<String>samoja(L); (7-7) 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 72 TRAI 31.8.2012/SJ Javan kokoelmajärjestelmä (Collection framework) Javan vakiokirjaston kokoelmien määrittelyssä on pyritty yhtenäistämään kokoelmien käyttöä. Näinollen käyttäminen on helpommin opittavissa ja käytettävää kokoelmaa voidaan helpohkosti vaihtaa. Varjopuolena vastaavasti eri kokoelmien erikoisominaisuuksia ei välittämättä ole huomioitu, joten niiden käyttö ei aina ole optimaalista. Toinen sotkeva tekijä on usean Javan versiosukupolven jäännökset metodiluetteloissa. Uusia ominaisuuksia on rakennettu pyrkien säilyttämään yhteensopivuus vanhoihin versioihin. Kokoelmat on rakennettu hierarkkiseksi järjestelmäksi, jossa toiminnallisuus on määritelty liittyminä, perustoiminnallisuus on toteutettu runkototeutuksiin ja lopulliset käyttökelpoiset toteutukset erilaisina liittymät toteuttavina versioina. Runkototeutus sisältää mahdollisuuksien mukaan pääosan monimutkaisemmasta toiminnallisuudesta (yksinkertaisina, geneerisinä versioina). Esimerkiksi addAll(Collection C) -metodi käy läpi kokoelman C ja lisää alkiot tähän kokoelmaan yksi kerrallaan. Esimerkiksi binääripuulla toteutettu joukko voi(isi) sitten toteuttaa tämän tehokkaammin. Jotta luokka (yleensä kokoelma) toteuttaa Iterable -liittymän, sillä on oltava metodi Iterator<E> iterator() joka palauttaa Iterator -liittymän toteuttavaa luokkaa olevan objektin jolla kokoelma voidaan käydä läpi. Iterator -liittymä edellyttää operaatiot hasNext() ja next() (ja remove(), jonka tosin ei tarvitse toimia). Iterator -liittymän toteuttava luokka on näppärintä sijoittaa kokoelman sisäluokaksi, joten sitä ei voi muualta suoraan kutsua. 7.2 Listan toteuttaminen Abstrakti lista voidaan Java-kielellä toteuttaa monin eri tavoin. Yksinkertaisin toteutusmalli lienee listan alkioiden tallettaminen taulukkoon siten, että listan ensimmäinen alkio on taulukon ensimmäisenä alkiona, listan toinen alkio taulukon toisena alkiona jne. Koska Java-kielen taulukot ovat kiinteän kokoisia, on talletusalueen koko alun alkaen varattava riittävän suureksi, eikä talletusalue yleensä ole kokonaan käytössä. Sen vuoksi toteutuksessa täytyy listan alkioiden tallettamisen lisäksi pitää yllä tietoa listan todellisesta pituudesta, jottei vahingossa käsitellä olemattomia alkioita. Vaihtoehtoisesti voidaan käyttää Vector -luokkaa, mutta silloinkaan listan talletusalueen kasvattaminen alkio kerrallaan ei ole järkevää. Abstraktin tietotyypin operaatioita toteutettaessa on muistettava, että jotkin operaatiot saattavat palauttaa arvonaan kokonaisen tietorakenteen. Näin on laita esimerkiksi listan getElement-operaatiolla, joka palauttaa listan alkion arvon. Alkioiden tyyppiä ei millään tavoin rajoiteta, joten alkio voi olla mikä tahansa objekti. Itse objekteja ei tietenkään listaan kopioida, vaan listaan tallennetaan viittauksia objekteihin. Toteutettaessa lista taulukossa asema on kokonaisluku (int). Javassa sen nimeäminen esimerkiksi Position:ksi olisi sen verran hankalaa (ja tehokkuuttakin haittaavaa), että käytetään suosiolla kokonaislukua asemana. Esimerkki 7-1: Lista taulukossa (Vector:lla, tehokkaampi olisi tehdä suoraan taulukolla, kts. ArrayList2.java). 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 73 public class ArrayLista<E> { private Vector<E> data; private int n; public ArrayLista() { data = new Vector<E>(); data.setSize(20); n = 0; } public ArrayLista(int size) { data = new Vector<E>(size); data.setSize(size); n = 0; } 1 // talletusalue // alkioiden määrä TRAI 31.8.2012/SJ 3 4 5 // alkuarvoiksi null 6 7 8 9 10 // alkuarvoiksi null public int EOL() { return n; } public void insert(int p, E x) { if (p > n || p < 0) throw new RuntimeException("Invalid position"); if (p >= data.size() ) // tila ei riitä data.setSize(2 * data.size()); // kasvatetaan talletusaluetta for (int i = n; i > p; i––) // siirretään loppupäätä eteen data.set(i, data.get(i–1)); data.set(p, x); n++; } public int first() { return 0; } public int last() { if (n == 0) return n; else return n–1; } public int next(int p) { if (p >= n || p < 0) throw new RuntimeException("Invalid position"); return p+1; } public E getElement(int p) { return data.get(p); } } 2 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 Lisäys- ja poisto-operaatioiden aikavaativuus on O(n), missä n on listan todellinen pituus, koska pahimmillaan lisäys/poisto kohdistuu listan alkuun, jolloin kaikki listan alkujaan sisältämät alkiot joudutaan siirtämään. Käyttämämme Vector-taulukko saattaa myös jou- 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 74 tua kasvattamaan talletusaluetta, mutta se ei muuta aikavaativuuden kertaluokkaa. Poistooperaatio toteutetaan lisäysoperaatioon nähden symmetrisesti: poistettavan alkion seuraajat siirretään kukin lähinnä edeltävään asemaansa. Operaatiot getElement, next, previous ja first on taulukkopohjaisessa mallissa helppo toteuttaa niin, että operaatioiden aikavaativuus on vakio. Listan luonti olisi muuten vakioaikainen, mutta Java alustaa taulukon, eli se vie aikaa O(talletusalueen koko). TRAI 31.8.2012/SJ Esimerkki 7-2: Listan kopiointi vaikuttaa päällisin puolin hyvin yksinkertaiselta tehtävältä, mutta asiaa lähemmin tarkasteltaessa osoittautuu, ettei kopionti onnistukaan aivan vaivattomasti. Jos muuttujat L2 ja L1 ovat äsken esitetyllä tavalla toteutettuja listoja (ArrayLista L1, L2;), aiheuttaa sijoitus L2 = L1 pelkän objektiviitteen kopioitumisen. Listan talletusrakenne ei toisin sanoen kopioidu, vaan sijoituksen jälkeen molemmat muuttujat tarkoittavat samaa fyysistä listaa. Jos sijoituksen jälkeen esimerkiksi poistetaan alkio jommastakummasta listasta, häviää sama alkio toisestakin listasta, mikä ei todennäköisesti ole tarkoitus! Java kieltä käytettäessä käyttäjän toki pitäisi tämä arvata, sillä Javassa kaikki ovat viittauksia. Jotta kopiointi onnistuisi, tulisi kopioida kukin listasolmu erikseen. Turvallisin kopiointikeino on muodostaa tyhjä L2-lista, johon listan L1 sisältö kopioidaan alkio alkiolta listaoperaatioiden avulla. Tämäkään ei vielä kopioi itse alkioita, vaan listat sisältävät viitteet samoihin alkio-objekteihin. Äskeisen esimerkin kaltainen ongelma esiintyy hyvin usein tietorakenteiden käsittelyn yhteydessä. Kyseessä on pohjimmiltaan semanttinen ongelma: Mitä "sijoittaminen" itse asiassa merkitsee? Muiden kuin viitteiden käsittelyn yhteydessä merkitys on ilmeinen: sijoittaminen tarkoittaa sijoitusoperaattorin oikealla puolella olevan lausekkeen arvon viemistä sijoitusoperaattorin vasemmalla puolella olevan muuttujan arvoksi. Objektiviite ja sen osoittama arvo ovat fyysisesti kaksi eri asiaa, joten viitteen sijoittaminen on luontevaa tulkita niin, ettei osoitettua arvoa kopioida. Tällaista tulkintaa sanotaan osoitinsemantiikaksi. Yhtä hyvin voitaisiin soveltaa kopiointisemantiikkaa, jonka mukaan osoittimen sijoittaminen merkitsee paitsi osoittimen, myös osoitetun arvon kopioimista. Molemmilla tulkinnoilla on etunsa ja haittansa. Kopiointisemantiikka mahdollistaa rakenteiden turvallisen kopioinnin, mutta aiheuttaa kopioinnin silloinkin, kun sitä ei ehkä haluta. Esimerkiksi listan läpikäynnin yhteydessä ei yleensä ole tarkoitus kopioida listaa. Rakenteen täydellinen kopiointihan on raskas toimenpide, joka kuluttaa paitsi aikaa, myös muistitilaa. Osoitinsemantiikan mukainen kopiointi puolestaan säästää aikaa ja tilaa, mutta voi johtaa vaikeasti paljastettaviin virheisiin. Eri kielissä osoittimen sijoittamisen semantiikka on ratkaistu eri tavoin. Pascal-kieli noudattaa aina osoitinsemantiikkaa, mutta antaa mahdollisuuden myös osoitetun arvon kopioimiseen (L1^ := L2^). Tämä laajempi kopiointikaan ei tosin kopioi monimutkaista dynaamista rakennetta täydellisesti, vaan ainoastaan rakenteen ylimmän tason; alemmat tasot täytyy kopioida käymällä rakenne kokonaan läpi. C++-kieli taas noudattaa joissakin tapauksissa osoitinsemantiikkaa, joissakin tapauksissa kopiointisemantiikkaa. Java kieli noudattaa aina osoitinsemantiikkaa. Ohjelmoijan kannalta olisi miellyttävää, jos sijoituksen molemmat tulkinnat olisivat aina valittavissa. Koska näin ei käytännössä ole, eikä abstraktia tietotyyppiä käytettäessä voida tietää, perustuuko toteutus osoittimiin vai ei, on varminta olettaa, ettei tietora- 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 75 kenteen sijoittaminen tarkoita rakenteen täydellistä kopiointia. Silloin, kun todella halutaan koko rakenne kopioitavan, on toisin sanoen muodostettava kopio alusta alkaen itse. Esimerkki 7-3: Listan kopiointi käy sinänsä varsin vaivattomasti listaoperaatioiden avulla, jolloin sijoittamisen semantiikasta ei tarvitse välittää — paitsi jos listan alkioiden kopioiminen on vaikeaa! Jollei viimeksi mainittua mahdollisuutta oteta huomioon, käy kopiointi seuraavalla listan menetelmällä, jonka aikavaativuus on O(|listan pituus|): public List<E> Copy() { List<E> L = new List<E>(); ListNode<E> p = this.first(); while (p != this.EOL) { L.insert(L.EOL, p.getElement()); p = p.getNext(); } return L; } 1 2 3 4 5 6 7 8 9 TRAI 31.8.2012/SJ Listan ketjutettu taulukkototeutus ArrayLinkedList2.javaListan toteutus siten, että listan alkiot talletetaan taulukon peräkkäisiin alkioihin siinä järjestyksessä, missä alkiot listassa esiintyvät, edellyttää lisäys- ja poisto-operaatioissa muutoskohdassa olevan alkion seuraajien siirtelyä. Pahimmillaan listan jokainen alkio täytyy siirtää. Jos tämä on liian raskasta, voidaan lista toteuttaa taulukossa ketjuttamalla: kuhunkin alkioon liitetään tieto alkion seuraajan asemasta. Kun vielä erikseen mainitaan listan ensimmäisen alkion asema, päästään mihin tahansa alkioon käsiksi etenemällä listaa alusta lähtien alkio alkiolta — toisin sanoen listan perusidean mukaisesti — eikä alkioita tarvitse talletusalueella siirrellä. Poistojen myötä talletusalueelle tosin jää vapaita paikkoja, jotka vieläpä sijaitsevat hajallaan. Nämä vapaat paikatkin täytyy ketjuttaa, jottei alkiota lisättäessä tarvitse ensin etsiä vapaata talletuspaikkaa. Käytännössä kaikki vapaat paikat täytyy listaa muodostettaessa ketjuttaa, mutta listaa myöhemmin käsiteltäessä riittää operoida pelkästään vapaiden paikkojen ketjun alkuun. Asematyyppi määritellään täsmälleen samoin kuin aiemmin nähdyn taulukkoon peräkkäin talletetun listan tapauksessa. Jos vielä sovitaan, että EOL-asema ilmaistaan vakioarvolla –1, ovat asemaan liittyvät määrittelyt yksinkertaiset. Listan toteuttaminen taulukossa ketjuttamalla edellyttää yksinkertaisimmillaan seuraavanlaista talletusrakennetta: public class ArrayLinkedList<E> { private Vector<E> data; private Vector<Integer> next; private int first, last, firstFree; public static final int EOL = –1; 1 2 3 4 5 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN TRAI 31.8.2012/SJ public ArrayLinkedList(int size) { data = new Vector<E>(size); next = new Vector<Integer>(size); for (int i = 0; i < size–1; i++) next.set(i, i+1); next.set(size–1, EOL); first = EOL; last = EOL; firstFree = 0; } public void insert(int p, E x) { if (p >= data.size() || p < 0) throw new ListException("Invalid position"); if (firstFree == –1) throw new ListException("List full"); int i = firstFree; firstFree = next.get(firstFree); if (p != EOL) { // lisäys muualle kuin loppuun data.set(i, data.get(p)); // siirretään aiempi alkio next.set(i, next.get(p)); // vapaaseen paikkaan data.set(p, x); // uusi tilalle next.set(p, i); // seuraajaksi siirretty alkio } else { // lisäys listan loppuun data.set(i, x); next.set(i, EOL); if (first == EOL) // tyhjään listaan myös ensimmäiseksi first = i; else next.set(last, i); // viimeisen seuraajaksi last = i; } } public int first() { return first; } public int next(int p) { if (p >= data.size() || p < 0) throw new ListException("Invalid position"); return next.get(p); } } 76 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 Nyt on huomattava, että vaikka asema yhä esitetäänkin indeksin avulla, ei aseman tulkinta enää ole sama kuin alkion etäisyys listan alusta lukien. Lisäysten ja poistojen seurauksena voi näet käydä niin, että listan ensimmäinen alkio onkin talletusalueen lopussa. Vakio EOL sopii myös ilmaisemaan sekä listan että vapaiden paikkojen ketjun tyhjyyden. TRAI 31.8.2012/SJ 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 77 Talletusrakenteen next-taulukko sisältää toisin sanoen sekä listan alkioiden ketjun että vapaiden paikkojen ketjun, mutta näissä kahdessa ketjussa ei ole yhteisiä jäseniä eivätkä ketjut voi sekoittua keskenään, jos kaikki operaatiot toteutetaan huolellisesti. Konstruktorin aikavaativuus on O(talletusalueen koko). Lisäys- ja poisto-operaatioita toteutettaessa on muistettava, että näiden operaatioiden määrittelyn mukaisesti muutoskohdassa olevan alkion seuraajien asematkin muuttuvat. Tämän vaatimuksen toteuttaminen täydellisenä edellyttäisi listan loppuosan alkioiden siirtämistä yksitellen joko lähimmän seuraajansa tai lähimmän edeltäjänsä paikalle, jolloin molempien operaatioiden aikavaativuus olisi O(n) eikä alkuperäistä tavoitetta alkioiden siirtelyn tarpeettomuudesta saavutettaisi! Jotta alkioiden ylettömältä siirtelyltä todella vältyttäisiin, voidaan esimerkiksi sopia, että insert kopioi lisäyskohdassa ennestään olleen alkion vapaaseen paikkaan ja vie lisättävän alkion lisäyskohtaan, muttei siirrä muita alkioita. Lisätty alkio sijoittuu silloin siihen asemaan, johon oli tarkoituskin, lisäyskohdassa ennestään olleesta alkiosta tulee lisätyn alkion seuraaja ja kaikki muutkin seuraajasuhteet säilyvät. Ainoastaan seuraajia mahdollisesti tarkoittavat asemamuuttujat eivät pysy mukana muutoksessa: ne tarkoittavat nyt asemia, joiden etäisyys listan alusta lukien on yhtä suurempi kuin oikeastaan pitäisi. Useimmat tällä kurssilla esitetyt algoritmit kuitenkin olettavat erilaisen aseman käytöksen. Koska lisäyksestä näin selvitään enintään kaksi alkiota sijoittamalla, on operaation toteutuksen aikavaativuus O(1) kaikissa muissa tapauksissa paitsi listan loppuun lisättäessä: EOL-asemaan lisääminen nimittäin edellyttäisi listan ennestään viimeisen alkion tunnistamista, mikä onnistuu vain käymällä lista alusta alkaen läpi. Jotta listan loppuunkin voidaan lisätä tehokkaasti, on talletusrakenteessa jäsen last, joka sisältää listan viimeisen alkion aseman. Listaa muodostettaessa kyseisen kentän arvoksi asetetaan EOL. next-operaatio voidaan toteuttaa vakioaikaisena, kun muistetaan, että L.next(p) palauttaa määrittelemättömän arvon, jos listassa L ei ole asemaa p. Toteutuksessa ei tämän ansiosta tarvitse varmistaa, vastaako taulukon indeksiä p listan alkio vai vapaa paikka. Sama koskee hakuoperaatioita getElement, joka sekin voidaan toteuttaa vakioaikaisena. Sen sijaan operaatio previous on nyt raskas, sillä asemassa p olevan alkion edeltäjän etsimiseksi on pakko etsiä listan alusta lukien se alkio, jonka seuraajan asema on p. Tämä merkitsee, että edeltäjäoperaation aikavaativuus on O(n). Lisäysoperaation kohdalla sovittuun muutokseen nähden symmetrisesti voidaan menetellä myös poisto-operaatiota toteutettaessa: kopioidaan mahdollinen poistettavan alkion seuraaja poistettavan alkion asemaan, poistetaan seuraajan alunperin sisältänyt asema listasta ja viedään seuraajan sisältänyt taulukon alkio vapaiden paikkojen ketjuun ensimmäiseksi. Näin poistokohtaa tarkoittanut asemamuuttuja säilyttää arvonsa, mutta poistokohdan seuraajia tarkoittavat muuttujat eivät pysy ajan tasalla. Poisto onnistuu muilta osin vakioajassa, mutta listan viimeisen alkion poistaminen johtaa vaikeuksiin: lisäysoperaation tehostamiseksi käyttöön otettu last-kenttä pitää listan viimeisen alkion poistamisen yhteydessä asettaa tarkoittamaan jäljelle jääneen listan viimeistä alkiota, joka löytyy ainoastaan käymällä koko lista alusta lähtien läpi. Tilanne vastaa täysin previousoperaation tehottomuuden perussyytä: alkiot ovat hajallaan eri puolilla talletusaluetta eikä edeltäjää kyetä löytämään vaivattomasti. Jos myös operaatiot remove ja previous halutaan toteuttaa tehokkaasti, täytyy talletusrakennetta vielä hieman laajentaa: next-taulukon rinnalla käytetään taulukkoa prev, 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 78 jonka alkio prev[i] ilmaisee listan asemassa i olevan alkion edeltäjän aseman. Koko listan ensimmäisellä alkiolla ei edeltäjää ole, mutta prev-taulukkoon voidaan vastaavaksi arvoksi asettaa EOL, joka muutenkin tässä toteutusmallissa kuvaa olematonta asemaa. Listaa muodostettaessa voidaan prev-arvot jättää määrittelemättä, mutta alkion lisäyksen ja poiston yhteydessä prev-arvot on aina muistettava asettaa. Kaikkien esitettyjen muutosten jälkeen listan toteuttaminen taulukkoon ketjuttamalla vaatii seuraavanlaiset Java-kieliset määrittelyt: public class ArrayDoublyLinkedList<E> { TRAI 31.8.2012/SJ private Vector<E> data; private Vector<Integer> next; private Vector<Integer> prev; private int first, last, firstFree; public static final int EOL = –1; public ArrayDoublyLinkedList(int size) { data = new Vector<E>(size); next = new Vector<Integer>(size); prev = new Vector<Integer>(size); for (int i = 0; i < size–1; i++) next.set(i, i+1); next.set(size–1, EOL); for (int i = 1; i < size; i++) prev.set(i, i–1); prev.set(0, EOL); first = EOL; last = EOL; firstFree = 0; } ... 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Listaoperaatioista miltei kaikki kyetään nyt toteuttamaan vakioaikaisina. Vain konstruktori vie ajan O(talletusalueen koko). Huomataan myös, ettemme enään voi hyödyntää Vector-luokan automaattista laajennusmekanismia, sillä jotta uudet alkiot tulisivat käyttöön, on ne vietävä vapaiden alkioiden listaan. Laajentaminen on mahdollista toteuttaa jos itse kasvatamme vektorin kokoa ja muodostamme uusista asemista lisäosan vapaiden alkioiden listaan. Taulukon kasvattaminen vie valitettavasti O(n) ajan. Listan dynaaminen linkitetty toteutus Koska kiinteän kokoinen taulukko on äärellinen ja Vector-taulukon kasvattaminen hitaahkoa, ei taulukkototeutus kovin hyvin sovellu tilanteeseen jossa listan maksimikokoa ei tiedetä etukäteen. Jos listalle toisaalta aina varataan jokin "riittävän suuri" talletusalue, on alueesta suuri osa useimmiten turhaan varattuna, toisin sanoen muistitilaa tuhlaantuu. Tällaisten ongelmien välttämiseksi lista kannattaakin monasti toteuttaa dynaamisena, toisin sanoen dynaamisten muuttujien ja osoittimien muodostamana rakenteena. Dynaamisen listan etu on joustavuus: listaan voidaan lisätä alkioita niin paljon kuin kulloinkin on tar- 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 79 peen, mutta silti aputilaa ei tarvita enempää kuin osoittimien tallettamiseksi on välttämätöntä. Dynaaminen lista on pohjimmiltaan yllättävän samanlainen kuin taulukkoon ketjuttamalla toteutettu lista. Jopa monet dynaamisen listan toteuttamiseen liittyvät ongelmat ovat samanlaisia kuin äsken tarkastellut ketjuttamisen vaikeudet — ongelmat vieläpä ratkaistaan hyvin samankaltaisin keinoin. Samoin operaatioiden aikavaativuudet ovat näissä kahdessa toteutusmallissa yhdenmukaiset. Kaikkein yksinkertaisin listan dynaaminen toteutus on yksisuuntainen linkitetty lista: TRAI 31.8.2012/SJ Esimerkki 7-4: Yksisuuntainen linkitetty lista ilman tunnussolmua. public class SingleLinkedList<E> { 1 private E element; 2 private SingleLinkedList<E> next; 3 public SingleLinkedList(E x) { 4 this.element = x; 5 this.next = null; 6 } 7 public void insertAfter(E x) { 8 SingleLinkedList<E> n = 9 new SingleLinkedList<E>(x); 10 n.next = this.next; 11 this.next = n; 12 } 13 public E deleteNext() { 14 if (next == null) 15 throw new NullPointerException("Cannot delete null node");16 E x = next.getElement(); 17 this.next = this.next.next; 18 return x; 19 } 20 } 21 Tässä mallissa asema toteutetaan viitteenä listan alkion sisältävään objektiin. Tyhjä lista puolestaan on yksinkertaisesti null. Listan alkuun lisääminen merkitsee yksinkertaisessa mallissa listaviittauksen arvon muuttamista. Alkio vieläpä lisätään tyhjän listan loppuun eri tavalla kuin epätyhjään listaan, ja vastaavasti alkio poistetaan yksialkioisesta listasta eri tavoin kuin useampia alkioita sisältävästä listasta. Tällaisten vaikeuksien välttämiseksi lista yleensä varustetaan niin sanotulla tunnussolmulla, joka on listaa muodostettaessa listan alkuun vietävä ylimääräinen rakenneosa. Tunnussolmu sisältää osoittimen listan ensimmäiseen varsinaiseen alkioon, mutta tunnussolmuun itseensä ei koskaan talleteta listan alkiota. Tunnussolmu voi lista-alkion asemesta sisältää vaikkapa osoittimen listan viimeiseen alkioon, jolloin listan loppuun lisääminen käy vaivattomasti. Tunnussolmun olemassaolo on listaoperaatioita toteutettaessa luonnollisesti otettava huomioon. Käytännössä tunnussolmun toteutusmahdollisuuksia on kaksi sen mukaan käytetäänkö tunnussolmun ja listasolmujen talletukseen samaa vai eri tietuetyyppiä. Saman 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 80 tyypin käyttö mahdollistaa listan ja listasolmun käsittelyn keskenään ristiin, tosin listan tapauksessa sille ei ole juurikaan sovelluksia. Erityyppisen tunnussolmun etu on, että kääntäjä pystyy paremmin tarkastamaan tyyppien oikean käytön. Toteutusvaihtojen selkeydestä voidaan olla montaa mieltä, mutta vaikka saman tyypin käyttö vähentää esittelyjen määrää, aiheuttaa saman luokan käyttäminen kahdella tavalla varmasti hankaluuksia useammin. Erityisesti kenttien nimien valinta aiheuttaa väistämättä kompromisseja. Tunnussolmussa next -kenttään talletetaan listan ensimmäinen alkio. Emme käsittele yhteisen tyypin käyttöä tässä enempää. Sensijaan puun toteutuksen yhteydessä kohdassa 7.4 (s. 83) esittelemme tätä hieman enemmän. Listaoperaatiot remove ja previous ovat yksisuuntaisen dynaamisen listan tapauksessa raskaita aivan samasta syystä kuin taulukkoon ketjutettaessakin: alkion edeltäjän asemaa ei saada selville muuten kuin etsimällä alkio listan alusta lähtien. Raskaan etsimisen välttämiseksi dynaaminen lista kannattaa toteuttaa kaksisuuntaisena, jolloin toteutusrakenteen jokaisesta alkiosta on osoitin sekä alkion seuraajaan että edeltäjään. Kuva 7-1 TRAI 31.8.2012/SJ first last L : List prev ele- next ment Kuva 7-1: Kaksisuuntaisesti linkitetyn tunnussolmulla varustetun listan toteutus. esittää talletusrakenteen graafisesti. Kuvassa näkyvä tyyppiä List oleva muuttuja L ei kuulu talletusrakenteeseen, vaan on esimerkki tilanteesta listan luonnin ja neljän insert-operaation. Tunnussolmulla varustetun kahteen suuntaan ketjutetun dynaamisen listan talletusrakenne määritellään kahtena luokkana listana ja listasolmuna. Seuraavassa List ja ListNode on laitettu samaan pakkaukseen (package) jotta listan operaatiot voivat viitata suoraan listasolmun suojattuihin (protected) jäseniin. Ilman tätä menettelyä toteutuksen luettavuus kärsisi jatkuvasta setX- ja getX -operaatioiden käytöstä, kts.. DoubleLinkedList.java. Algoritmi 7-5: Kaksisuuntaisesti linkitetty dynaaminen lista erillisellä tunnussolmulla samassa pakkauksessa. 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN package List; public class DoubleLinkedList3<E> { private DoubleLinkedList3Node<E> first; private DoubleLinkedList3Node<E> last; public static final DoubleLinkedList3Node EOL = null; public DoubleLinkedList3() { first = EOL; last = EOL; } public void insert(DoubleLinkedList3Node<E> p, E x) { DoubleLinkedList3Node<E> n = new DoubleLinkedList3Node<E>(x); TRAI 31.8.2012/SJ if (p != EOL) { // muualle kuin loppuun n.next = p; n.prev = p.prev; p.prev = n; if (p == this.first) this.first = n; else n.prev.next = n; } else { // listan loppuun n.next = EOL; n.prev = this.last; if (this.last == EOL) this.first = n; else n.prev.next = n; this.last = n; } } public E delete(DoubleLinkedList3Node<E> p) { if (p == EOL) throw new NullPointerException("Nonexisting node"); E x = p.element; if (p.next != EOL) p.next.prev = p.prev; else this.last = p.prev; if (p.prev != EOL) p.prev.next = p.next; else this.first = p.next; p.next = EOL; p.prev = EOL; return x; } } 81 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 TRAI 31.8.2012/SJ 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN package List; public class DoubleLinkedList3Node<E> { protected DoubleLinkedList3Node<E> prev; protected DoubleLinkedList3Node<E> next; protected E element; public static final DoubleLinkedList3Node EOL = null; protected DoubleLinkedList3Node(E x) { prev = EOL; next = EOL; element = x; } public DoubleLinkedList3Node<E> prev() { return prev; } public DoubleLinkedList3Node<E> next() { return next; } public E getElement() { return element; } } 82 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Näitä tietorakenteita käyttäen on aina selvää, milloin kyseessä on lista, milloin listasolmu. Itseasiassa listasolmu voisi olla käyttäjälle näkymätön luokka, mutta koska sitä tarvitaan asemana, niin se jätetään tyyppinä näkyviin. Mm. konstruktori sensijaan jätetään listatoteutuksen sisäiseksi (protected). Tämä toteutustapa on varsin tehokas, kaikki operaatiot ovat O(1). Toteutuksen varjopuolena on epäselvä aseman käsite. Esimerkiksi lisättäessä alkio asemaan p (jossa on ennestään jokin alkio), käykin itseasiassa niin, että uudelle alkiolle luodaan uusi asema joka sijoitetaan asemassa p pysyvän alkion edelle. Tämäkin käytös ehkä kuitenkin on selkeämpi kuin taulukkototeutuksessa. Myöskään kahden aseman keskinäistä järjestystä listassa ei ole mahdollista selvittää vakioajassa. Nämä rajoitukset eivät yleensä ole mitenkään haitallisia, kunhan ne käyttäjälle selvitetään. 7.3 Listan erikoistapaukset Pinon, jonon, pakan ja renkaan toteuttamiseen voidaan soveltaa listan toteutusmalleja. Silloin kun rakenteen (kohtuullinen) enimmäiskoko tunnetaan etukäteen, riittää toteutusmalliksi yksinkertaisesti peräkkäistalletus taulukkoon. Esimerkki 7-6: Pinon alkiot voidaan tallettaa taulukkoon siten, että pohjimmainen alkio on indeksiä 1 (tai 0) vastaavassa talletuspaikassa ja pino kasvaa suurempia indeksin arvoja kohden. Taulukon lisäksi tarvitaan silloin myös pinon pinnan ilmaiseva tieto, mutta alkioita ei koskaan tarvitse siirrellä talletusalueella, koska pinon päällimmäi- 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 83 TRAI 31.8.2012/SJ sen alkion "yläpuolella" on aina tilaa — siihen saakka, kunnes koko talletusalue täyttyy. Jono ja pakka ovat pinoa hankalammat toteutettavat, kun talletusrakenne on taulukko, sillä jono siirtyy vähitellen talletusalueen loppua kohden ja pakka puolestaan voi ennalta arvaamattomasti kasvaa kumpaan suuntaan hyvänsä. Nämä ongelmat voidaan tosin kiertää tulkitsemalla talletusalue renkaaksi, jolloin talletusalueen viimeisen ja ensimmäisen paikan katsotaan sijaitsevan vierekkäin. Operaatiot voidaan tällöin toteuttaa varsin vaivattomasti vakioaikaisina ja koko talletusalue saadaan tehokkaaseen käyttöön. Renkaan taulukossa toteuttaminen edellyttää ilman muuta talletusalueen renkaaksi tulkitsemista. Listan dynaamiset mallit sopivat erinomaisen hyvin myös pinon, jonon, pakan ja renkaan toteuttamiseen. Dynaaminen toteutus antaa mahdollisuuden kasvattaa rakennetta mielin määrin ja käsittelykohdat kyetään helposti paikantamaan osoittimin. Pinolle ja jonolle riittää yksisuuntainen linkitys, pakka ja rengas vaativat kaksisuuntaisen linkityksen. Kiinteän kokoinen taulukko voidaan toteuttaa Java-kielen tarjoaman taulukkovälineen avulla ja yleisen "rajattoman" taulukon toteuttamiseksi voidaan käyttää Vectorluokkaa. Jos kuitenkin tarvittava indeksialue on suuri ja taulukko harva, kannattaa taulukko toteuttaa indeksinä ja varsinainen talletusalue paloina. Indeksi sisältää siis viitteitä alkiotaulukoihin. Viittaus on muotoa indeksi[i/m][i%m], missä i on haluttu taulukon asema ja m osataulukoiden koko. Taulukkoa luotaessa indeksin sisältö alustetaan null:ksi. Uusia alkioita taulukkoon vietäessä tarkastetaan onko ko. osataulukko varattu, varataan tarvittaessa ja viedään vasta sitten alkio paikalleen. Jos ylimmän tason taulukko edelleen kasvaa liian suureksi, voidaan käyttää kolmea (tai jopa useampaa) tasoa. Näin lähestytään B-puun rakennetta. Erityisesti tämä talletusrakenne sopii massamuistille kun ylimmän tason indeksi pidetään keskusmuistissa, onnistuu haku massamuistista yhdellä levyhaulla. Silloin kun avoimen taulukon indekseille ei voida asettaa mitään kohtuullisia rajoja, kannattaa käyttää mieluummin kuvausta ja toteuttaa se hajautuksella. Javassa voidaan esimerkiksi luoda HashMap<BigInteger, E>. Hajautus ei tietenkään ole aivan yhtä tehokas kuin taulukot, mutta yleensä kuitenkin vakioaikainen (paitsi uudelleenhajautus). 7.4 Puiden toteuttaminen Puun toteutusmallit saadaan listan toteutusmalleista kehittelemällä. Puun solmut voidaan esimerkiksi tallettaa taulukkoon. Juurisolmu yksilöidään erikseen, ja jokaisen solmun poikien lukumäärä sekä vanhimman pojan talletuspaikan indeksi talletetaan toiseen taulukkoon. Veljekset vanhimmasta nuorimpaan talletetaan taulukkoon peräkkäin ja minkä tahansa solmun jälkeläiset löytyvät kohtuullisella työmäärällä. Myös isätiedot voidaan tallettaa, jos niitä tarvitaan usein. Dynaamisessa ratkaisussa solmuun liitetään osoitin vanhimpaan poikaan ja lähinnä nuorempaan veljeen Binääripuu on yleistä puuta helpompi toteutettava, ainakin taulukkototeutuksessa. Koska binääripuun solmulla on aina kaksi poikaa, joista vasen ja oikea on aina kyettävä erottamaan, riittää dynaamisessa mallissa solmuun liittää osoitin vasempaan ja oikeaan poikaan, tarvittaessa myös isään. Taulukkototeutuksessa poikien lukumäärää ei tarvitse tallettaa, vaan pelkkä talletuspaikkojen indeksien esittäminen riittää. Puuttuvaa poikaa ilmaisee jokin sovittu arvo. 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 84 Jos binääripuu on riittävän säännöllinen, toisin sanoen sen kaikilla tasoilla on suurin mahdollinen määrä solmuja — alin taso saa olla oikeasta reunastaan vajaa — voidaan toteutus rakentaa jopa tallettamatta poikien indeksejä: poikien samoin kuin isänkin indeksit saadaan selville kohdesolmun indeksistä yksinkertaisilla laskutoimituksella (miten?). Etenkin rekursiivisissa algoritmeissa usein on tarpeen käyttää samaa aliohjelmaa sekä kokonaiselle puulle että alipuulle. Tämä edellyttää, että puun ja puun solmun tyypit ovat yhteensopivat. Tämä on sikälikin hyödyllistä, että tällöin kokonainen puu voidaan sijoittaa toisen puun alipuuksi. Yhteensopivuuden saavuttamiseen on kaksi vaihtoehtoa: TRAI 31.8.2012/SJ 1) Toteutetaan puu ilman tunnussolmua, jolloin puun juurisolmu edustaa koko puuta. 2) Käytetään tunnussolmun esittämiseen samaa tietuetyyppiä kuin puun varsinaisten solmujen esittämiseen. 3) Määritellään yläluokka puukomponentti jota puu ja solmu laajentavat • Hyvää: Selkeämpi tyyppien tarkastus. • Huonoa: Kaikissa metodeissa on tarkastettava kumpi on kyseessä. Ensimmäisen vaihtoehdon etuna on yksinkertaisuus, mutta ongelmana on, että puun juurisolmu voi vaihtua kesken suorituksen. Tämä edellyttää muuttujaparametrien käyttöä ja estää käyttämästä useaa muuttujaa saman puun viitteenä. Toinen vaihtoehto on luonnollisesti hieman monimutkaisempi toteuttaa, sillä kaikkien operaatioiden toteutuksessa tulee tarkistaa, onko kyse tunnussolmusta, vai puusolmusta. Jälkimmäistä voi pitää yleisempänä ratkaisuna. Algoritmi 7-7 esittää siitä tärkeimmät osat, loput harjoitustehtävänä. Kuva 7-2 esittää tietorakenteen graafisesti. Kuvan alareunassa olevilla lauseilla syntyy kuvan kaltainen puu. Samanrakenteisen puun toki voisi rakentaa muutenkin. Algoritmi 7-7: Binääripuu samantyyppisellä tunnussolmulla. public class BTree1<E> { private BTree1<E> left; private BTree1<E> right; private BTree1<E> parent; private E element; public BTree1() { left = null; parent = this; right = null; element = null; } public BTree1(E x) { left = null; parent = null; right = null; element = x; } 1 2 3 4 5 // uuden puun luonti // juuri // tunnussolmun merkki 6 7 8 9 10 11 // uusi vielä kytkemätön puusolmu 12 13 14 15 16 17 7. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN 85 BTree T parent left right B A C B parent left right TRAI 31.8.2012/SJ D A parent left right BTree<String> T = new BTree<String>(); BTree<String> m = new BTree<String>("B"); T.setRoot(m); m.setLeftChild(new BTree<String>("A")); m.setRightChild(new BTree<String>("C")); BTree<String> n = m.getRightChild(); n.setRightChild(new BTree<String>("D")); C parent left right 1 2 D parent 3 left right 4 5 6 7 Kuva 7-2: Tunnussolmulla varustettu binääripuun toteutus. Esimerkkinä rakennettu neljäsolmuinen puu. public BTree1<E> getRoot() { if (parent != this) throw new TreeException("Not a BTree1"); return left; } public void setRoot(BTree1<E> n) { if (parent != this) throw new TreeException("Not a BTree1"); left = n; n.parent = null; } public BTree1<E> getLeftChild() { if (parent == this) throw new TreeException("Not a BTree1 node"); return left; } ... 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 Luku 8 TRAI 31.8.2012/SJ Joukkojen toteuttaminen Joukon toteutustapaa valittaessa on tärkeää selvittää, millaisesta joukosta on kyse ja millaisia operaatioita joukkoon tullaan kohdistamaan useimmiten. Jos lisäksi tiedetään, ettei joitakin operaatioita koskaan tarvita, voidaan nämä operaatiot ehkä jättää kokonaan toteuttamatta. Tarvittavien joukko-operaatioiden toteutuksetkin saattavat yksinkertaistua, mikäli osaa operaatioista ei toteuteta. Saman operaation erilaisten toteutusten aikavaativuus voi vaihdella huomattavastikin, joten umpimähkäinen toteutus ei yleensä ole tehokas. Käyttäjällä pitäisi puolestaan aina olla valittavissaan useita erilaisia joukkototeutuksia, jotta kuhunkin sovellukseen löytyisi mahdollisimman sopiva ja tehokas joukkomalli. 8.1 Yksinkertaiset joukkomallit Joukon alkioiden tyyppi, niin sanottu perusjoukko tai kantatyyppi, on yksinkertaisimmillaan kokonaislukujen suppea osaväli, ASCII-merkistö tai sen osaväli tai jokin muu vastaava yhtenäinen, suppeahko kokoelma. Tällaisessa tapauksessa joukko voidaan toteuttaa Java-kielen omien joukkotyyppien avulla ja operaatiot oletetaan mahdollisimman tehokkaasti toteutetuiksi (vaikkei asia näin välttämättä olekaan). Joukko voidaan toki myös toteuttaa itse niin sanottua bittivektoria käyttäen. Bittivektori on boolen taulukko B, jota indeksoidaan perusjoukon arvoilla. Jos alkio B[x] on tosi, sisältää bittivektorin kuvaama joukko alkion x, muuten x ei sisälly kyseiseen joukkoon. Bittivektoritoteutuksessa operaatioiden contains, insert ja remove aikavaativuus on O(1) ja muiden operaatioiden aikavaativuus on O(|perusjoukko|). Kun perusjoukko on äsken tarkoitettua hieman monimutkaisempi, esimerkiksi kokonaislukujen epäyhtenäinen osaväli, saattaa bittivektoritoteutus silti tulla kyseeseen. Jos perusjoukon pienin ja suurin arvo nimittäin ovat kyllin lähellä toisiaan, voidaan joukko kuvata yhtenäisenä välinä, kunhan huolehditaan siitä, ettei perusjoukkoon sisältymättömiä alkioita koskaan vahingossakaan käsitellä joukon alkioina. Esimerkiksi ASCIImerkistön suur- ja pienaakkoset saadaan näin kätevästi samaan joukkoon, vaikka pienaakkoset eivät ASCII-merkistössä seuraakaan välittömästi suuraakkosia. Vieläkin monimutkaisemman perusjoukon tapauksessa ei auta muu kuin tallettaa joukon alkiot yksitellen ja pitää muodostuvaa alkiokokoelmaa yllä sopivassa rakenteessa. Esimerkiksi lista sopii tällaiseksi rakenteeksi. Alkiot voidaan listassa joko pitää järjestyksessä tai jättää järjestämättä. Operaatiot contains, insert, remove, max ja clone ovat 86 8. JOUKKOJEN TOTEUTTAMINEN 87 molemmissa tapauksissa O(|joukon koko|), mutta muiden operaatioiden aikavaativuus vaihtelee sen mukaan, onko joukko järjestetty vai ei. 8.2 Joukkojen erikoistapaukset Yleisen joukon toteuttaminen on suhteellisen monimutkainen tehtävä. Toteutustavasta riippuen eri operaatioiden aikavaativuus vaihtelee huomattavasti. Kompromissitoteutuksen tehokkuus ei välttämättä riitä kaikkiin sovelluksiin. Niinpä esittelemme seuraavassa erilaisten joukkojen erikoistapausten toteuttamista eri tavoin. TRAI 31.8.2012/SJ Sanakirja Sanakirjan toteuttaminen listana johtaisi käytännössä erittäin tehottomaan ratkaisuun, sillä sanakirjan koko on yleensä hyvin suuri. Jokainen lisäys- ja hakuoperaatio edellyttäisi tällöin huomattavan suuren alkiomäärän tarkastelua, vaikka lista olisikin järjestetty. Mikäli lista toteutettaisiin taulukossa ja alkioiden järjestys pidettäisiin yllä, voitaisiin hakua tehostaa binäärihakua soveltamalla, mutta lisäysoperaatio olisi siitä huolimatta raskas. Usein sanakirjan toteutustavaksi valitaan hajautus. Molemmat hajautusmallit, suljettu ja avoin hajautus, voidaan toteuttaa sekä keskusmuistissa että ulkoisessa muistissa. Suljetussa hajautuksessa talletusalueen kaikki paikat ovat mahdollisia kotiosoitteita ja yhteentörmäykset käsitellään viemällä toinen samaan kotiosoitteeseen kuluva alkio kotiosoitettaan lähinnä seuraavaan vapaaseen talletuspaikkaan. Talletusalue tulkitaan tällöin renkaaksi. Talletusalueen on käytännössä oltava niin suuri, ettei se pääse kokonaan täyttymään. Silloin sinne tänne talletusaluetta jää vapaita paikkoja, jollaisen löytyessä sanakirjaan sisältymättömän alkion etsiminen voidaan lopettaa. Jos talletusalue olisi aivan täysi, edellyttäisi alkion hakeminen pahimmillaan koko talletusalueen läpikäyntiä. Kun sanakirjan koko aikaa myöten kasvaa, on joskus paikallaan hajauttaa koko sanakirjan sisältö uudelleen entistä suuremmalle talletusalueelle. Kuvassa 8-1 suljetun hajautuksen talletusalueen kukin lokero voi sisältää yhden nimen. Hajautusfunktiona on käytetty (huonoa mutta havainnollista) "nimen etukirjain" funktiota. Nimiä alueelle vietäessä Antti, Erja ja Heikki on voitu viedä aluksi suoraan paikalleen. Vietäessä Aapelia on tullut törmäys, ja Aapeli on jouduttu sijoittamaan lähinnä seuraavaan vapaaseen paikkaan, siis B:hen. Cecilia on taas mennyt kivuttomasti, mutta Bertan kotiosoitteessa B on jo Aapeli! Seuraava vapaa paikka Bertalle löytyy vasta D:stä. Huomaamme, että kun alue alkaa täyttyä, etäisyydet kotiosoitteeseen voivat kasvaa. Samalla myös hakuajat kasvavat, erityisesti haettaessa alkiota, jota sanakirjassa ei edes ole. Esimerkiksi haettaessa nimeä Arja, joudutaan tutkimaan kaikki paikat A-F ennenkuin varmistuu, ettei Arjaa ole talletettu. Avoin hajautus perustuu niin sanotun solutaulun käyttöön. Solutaulussa on alkio kutakin mahdollista kotiosoitetta kohden. Kuhunkin solutaulun alkioon liittyy lista, joka sisältää kyseiseen kotiosoitteeseen kuuluvat alkiot. Varsinainen talletusalue on solutaulusta erillään. Ulkoiseen muistiin hajautettaessa voidaan esimerkiksi menetellä niin, että solutaulu sijaitsee keskusmuistissa ja listat ulkoisessa muistissa, kukin lista omana jaksonaan, jolloin hakuun riittää yhden jakson siirto. Keskusmuistissa listat toteutetaan dynaamisina. Kuvassa 8-2 avoin hajautus käyttää samaa hajautusfunktiota kuin kuvan 8-1 sul- 8. JOUKKOJEN TOTEUTTAMINEN 88 Cecilia E F G H Heikki Aapeli D Gabriel C Sanakirjaan viedyt nimet vientijärjestyksessä Erja B Bertta A Antti Talletusalue Ö ... Antti Erja Heikki Aapeli Cecilia Bertta Gabriel Kuva 8-1: Suljettu hajautus. Sanakirjaan viedyt nimet vientijärjestyksessä Talletusalue A B C D E F G Ö H ... Heikki TRAI 31.8.2012/SJ Cecilia Aapeli Gabriel Bertta Antti Antti Erja Heikki Aapeli Cecilia Bertta Gabriel Erja Kuva 8-2: Avoin hajautus. jetun hajautus, samoin talletettava tieto on samaa. Nyt törmäykset talletusalueen "täyttyessä" hallitaan paremmin. Itseasiassa talletusalue ei lainkaan täyty. Jos kuitenkin listat kasvavat pitkiksi, kannattaa kasvattaa "lokeroiden" määrää ja vaihtaa hajautusfunktiota. Oikeita h(key)%m hajautusfunktioita (missä m on talletusalueen koko, yleensä sopiva alkuluku) käytettäessä, riittää usein valita uusi m, varata uusi tila ja kopioida tiedot sinne. Toki edelleen pitää varmistaa, että kaikki uuden hajautusalueen arvot ovat mahdollisia ja suunnilleen yhtä todennäköisiä. Hajautuksessa tarvittava hajautusfunktio valitaan kulloisenkin aineiston mukaisesti niin, että hajautus onnistuu hyvin. Yleistä hajautusfunktiota, joka tuottaisi hyvän lopputuloksen kaikissa mahdollisissa tapauksissa, ei ole olemassakaan: mikä tahansa hajautusfunktio epäonnistuu, kun aineisto valitaan sopivasti. Ellei hajautettavasta aineistosta tiedetä etukäteen muuta kuin avainten mahdollinen arvoalue, joudutaan kaikki avaimet olettamaan keskenään yhtä todennäköisiksi, vaikkei näin tosiasiassa olisikaan. Joka tapauksessa hajautusfunktion tulee aina tuottaa kelvollisesta avaimesta kelvollinen kotiosoite. Kun hajautus onnistuu hyvin, aiheutuu kutakin kotiosoitetta kohden vain muutamia yhteentörmäyksiä. Suljetussa hajautuksessa tyhjäksi jäävät kotiosoitteet jakautuvat tasaisesti yli koko talletusalueen, joten alkion joukkoon kuulumisen selvittämiseksi riittää käytännössä muutaman kotiosoitteen tutkiminen, toisin sanoen haku onnistuu keskimää- 8. JOUKKOJEN TOTEUTTAMINEN 89 rin miltei vakioajassa. Vastaavasti avoimessa hajautuksessa solujen listoista tulee vain muutaman alkion sisältäviä, joten haku onnistuu silloinkin nopeasti. Tarvittaessa voidaan listan asemesta käyttää muutakin rakennetta solun alkioiden tallettamiseen, jolloin haku on vieläkin tehokkaampaa. Jotta näin kannattaisi tehdä, täytyy samaan kotiosoitteeseen kuuluvia alkioita tosin olla selvästi useampia kuin "muutama". TRAI 31.8.2012/SJ Prioriteettijonon toteutus Prioriteettijono voidaan toteuttaa listana, mutta silloin toinen lisäys- ja poisto-operaatioista on tehokas ja toinen tehoton. Jos nimittäin lista pidetään prioriteetin mukaan järjestyksessä, on poisto O(1), mutta lisäys O(alkioiden määrä). Jos taas järjestyksestä ei välitetä, niin lisäys on O(1), mutta poisto O(alkioiden määrä). Koska molemmat operaatiot ovat yhtä tarpeellisia kaikissa prioriteettiojonon sovelluksissa, olisi toisin sanoen sama käyttää prioriteettijonon asemasta listaa. Kurssilla on useaan otteeseen todettu priori1 teettijono voitavan toteuttaa niin, että molempien operaatioiden aikavaativuus on logaritminen. Tähän päästään toteuttamalla prioriteettijono tasapainoi2 4 sena osittain järjestettynä binääripuuna. Tasapainoisuus tarkoittaa sitä, että puun ylimmillä tasoilla on solmuja niin monta kuin mahdollista ja alimman 5 9 6 tason solmut sijaitsevat mahdollisimman vasem3 malla. Silloin puun korkeus on O(logn), missä n on puun solmujen lukumäärä. Osittainen järjestys puolestaan määritellään seuraavasti: puun jokaiselle solmulle w pätee, ettei solmun w prioriteetti ole ainakaan huonompi kuin minkään w:n jälkeläisen prioriteetti. Tasapainoisessa osittain järjestetyssä puussa prioriteetiltaan paras alkio on juuressa. Juurialkion poistamisen jälkeen puu korjataan osittain järjestetyksi viemällä puun alimman tason oikeanpuoleisin lehtialkio juureen ja tarvittaessa vaihtamalla juurialkio prioriteetiltaan paremman poikansa kanssa. Vaihdon tuloksena juurialkion prioriteetti on paras puun kaikkien alkioiden prioriteeteista, mutta vaihtoon osallistunut alipuu ei ehkä ole osittain järjestetty. Alipuu saadaan osittaiseen järjestykseen vaihtamalla alipuun juurialkio prioriteetiltaan paremman poikansa kanssa ja käsittelemällä mahdollisesti epäjärjestyksessä oleva pienempi alipuu vastaavalla tavalla. Järjestyksen saavuttamiseksi tehdään kullakin tasolla enintään kaksi prioriteettivertailua sekä mahdollinen alkioiden keskenään vaihto, joten poiston aikavaativuus on O(logn), mikäli prioriteetit selviävät vakioajassa. Lisäysoperaatiossa alkio viedään puun alimmalle (vajaalle) tasolle niin vasemmalle kuin mahdollista. Jos lisätyn alkion prioriteetti on isänsä prioriteettia parempi, vaihdetaan isä ja poika keskenään, ja tarvittaessa vaihtamista jatketaan ylemmille tasoille, kunnes osittainen järjestys on saavutettu. Pahimmassa tapauksessa lisätty alkio päätyy puun juureksi, jolloin lisäyksen aikavaativuus on O(logn). Tämä merkitsee, että molempien prioriteettijono-operaatioiden aikavaativuudeksi on saatu O(logn). 8. JOUKKOJEN TOTEUTTAMINEN 90 TRAI 31.8.2012/SJ Tasapainoisen binääripuun alimman tason oikeanpuoleisimman lehden löytäminen on hieman vaivalloista, jos puu toteutetaan dynaamisena. Käytännössä solmut tulisi ketjuttaa tasoittain oikealta vasemmalle alkaen alimmalta tasolta, mikä vaatisi tavanomaisesta poikkeavan binääripuutoteutuksen. Ketjutuksen ylläpito ei tosin olennaisesti hidastaisi suoritusta, joten tällaiselle toteutukselle ei sinänsä ole mitään estettä. Yksinkertaisemmin tasapainoinen binääripuu toteutetaan taulukossa A siten, että puun juuri on alkiona A[1] ja yleisesti alkion A[i] vasen poika alkiona A[2*i], oikea poika alkiona A[2*i+1] ja isä alkiona A[i/2]. Solmut toisin sanoen esitetään taulukossa tasoittain vasemmalta oikealle, jolloin pojat samoin kuin isäkin löytyvät yksinkertaisten laskutoimitusten avulla. Talletusalueen lisäksi on pidettävää yllä tietoa puun solmujen lukumäärästä, jottei vahingossa käsitellä olemattomia solmuja. Taulukkototeutus on mahdollinen, mikäli puuhun vietävien solmujen lukumäärän yläraja osataan arvioida, toisin sanoen mikäli prioriteettijonon enimmäiskoko tunnetaan etukäteen. Esimerkki 8-1: Kasalajittelu onnistuu aputilatta, kun taulukon alkioita prioriteettijonoon vietäessä taulukon alkuosaa käsitellään tasapainoisena osittain järjestettynä puuna ja taulukon loppuosaa jäljellä olevien alkioiden taulukkona, jonka koko pienenee sitä mukaa kun alkuosan koko kasvaa. Kun kaikki alkiot ovat prioritettijonossa, on koko taulukko puun talletusaluetta. Vastaavasti alkioiden prioriteettijonosta poistamisen myötä puun talletusalue pienenee ja järjestyksessä oleva taulukon osa kasvaa. Alkiot kannattaa tällöin poistaa prioriteettijonosta suurimmasta pienimpään (miksi?). Joissakin prioriteettijonoa käyttävissä sovelluksissa prioriteettijonossa jo olevan alkion prioriteetti saattaa muuttua kesken kaiken. Näin on laita esimerkiksi Primin algoritmissa, jota suoritettaessa prioriteettijonon ei välttämättä tarvitse edes sisältää verkon kaikkia kaaria samanaikaisesti. Yksittäisen kaaren prioriteetin muuttuminen saattaa vaatia samantapaisen uudelleenjärjestelyn kuin kaaren poisto tai lisääminenkin, mutta nyt järjestely voi alkaa mistä kohden prioriteettijonon toteuttavaa puuta hyvänsä, ei välttämättä puun juuresta eikä äärimmäisestä lehdestä. Samoin mielivaltaisen kaaren prioriteettijonosta poistaminen voi aiheuttaa muutoksia puun keskelle. Jotteivät tällaiset muutokset vaatisi koko talletusrakenteen läpikäyntiä muutoskohdan etsimiseksi, voidaan järjestetyn puun rinnalle rakentaa kuvaus, jonka avulla minkä tahansa alkion asema puussa saadaan tehokkaasti selville. Alkioita puussa siirreltäessä on muistettava huolehtia myös asemakuvauksen ajan tasalla pysymisestä. Äsken mainittu rinnakkaisen joukkomallin rakentaminen on kätevä tekniikka silloin, kun mikään yksittäinen joukkomalli ei riitä haluttujen toimintojen tehokkaaseen toteuttamiseen. Rinnakkaisen mallin edellyttämien lisärakenteiden kunnossapito vie toki oman aikansa, mutta se ei haittaa, jos kahdesta erilaisesta lähestymistavasta on todellista hyötyä. On näet turha monimutkaistaa yhtä mallia liikaa silloin, kun kahta yksinkertaisempaa mallia yhdessä käyttäen saavutetaan yhtä hyvä lopputulos. Kuvaus Kuvauksen toteuttamiseen on erilaisia mahdollisuuksia. Riittävän säännöllinen kuvaus kannattaa toteuttaa tuloksen laskevana funktiona, jolloin vältytään kaikkien kuva-alkioiden yksittäiseltä tallettamiselta. Tämä luonnollisesti edellyttää, että kuva kyetään muo- 8. JOUKKOJEN TOTEUTTAMINEN 91 dostamaan alkukuvasta kohtuullisen vaivattomasti. Ellei tämä onnistu, voidaan kuvaus suppean lähtöjoukon tapauksessa toteuttaa taulukossa, mikäli lähtöjoukko sellaisenaan sopii taulukon indeksointiin. Jollei näin ole, voi taulukkototeutus silti tulla kyseeseen, jos lähtöjoukosta saadaan indeksikelpoinen jollakin yksinkertaisella muunnoksella — toisin sanoen tarvitaan yksiymmärteinen apukuvaus eli kyseessä on itse asiassa hajautus. Hankalimmassa tapauksessa edes yksiymmärteistä apukuvausta ei voida muodostaa. Silloin kuvaus joudutaan toteuttamaan yleisen hajautuksen keinoin eikä yhteentörmäyksiltä vältytä. Laukku TRAI 31.8.2012/SJ Laukun toteuttamiseen sopii mikä tahansa tavanomainen joukkototeutus, kunhan muistetaan laukun monijoukko-ominaisuus: saman alkion toistuvat esiintymät sallitaan. Ellei laukun alkioiden keskinäisestä järjestyksestä tarvitse välittää, on lisäysoperaatio O(1). Muiden operaatioiden aikavaativuus on perustoteutuksissa vähintään O(laukun koko). Joidenkin operaatioiden merkitys saattaa olla tarpeen tarkentaa, jotta toteutus varmasti vastaa vaatimuksia. Esimerkiksi kahta laukkua yhdistettäessä on tärkeä tietää, viedäänkö yhdisteeseen saman alkion esiintymiä se määrä, mikä on molemmissa laukuissa yhteensä, vai se määrä, mikä esiintymiä on enimmillään yhdessä laukussa. Jälkimmäisen vaihtoehdon mukaan yhdistäminen on hidasta, ellei alkioita ole järjestetty. 8.3 Etsintäpuut Järjestettävissä oleva joukko, johon kohdistetaan runsaasti haku-, poisto- ja lisäysoperaatioita, mutta vähän muita operaatioita, kannattaa useimmin toistuvien operaatioiden tehostamiseksi toteuttaa etsintäpuuna. Etsintäpuun yksinkertainen malli on binääripuu, jonka juurisolmun vasemman alipuun sisältämät alkiot edeltävät juuren sisältämää alkiota, joka puolestaan edeltää kaikkia oikean alipuunsa sisältämiä alkioita. Lisäksi molemmat alipuut ovat yksinään etsintäpuita. On huomattava, ettei osittain järjestetty puu ole etsintäpuu, sillä osittain järjestetyssä puussa melko pieniä arvoja voi olla oikeassa alipuussakin. Binäärisen etsintäpuun hakuoperaatiossa riittää etsintäpuun järjestysominaisuuden perusteella tutkia jokin puun juuresta lehteen johtava polku — haun onnistuessa ei aina edes koko polkua. Vastaavasti lisäys- ja poisto-operaatioissakin yhden polun tutkiminen riittää sen selvittämiseksi, onko lisättävä alkio puussa jo ennestään tai onko poistettavaa alkiota puussa lainkaan. Ellei lisättävää alkiota puussa ennestään ollut (tai kyseessä on monijoukko), alkio lisätään sen solmun pojaksi, jonka kohdalle etsintäpolku päättyi. Lisäys tehdään vasemmalle tai oikealle sen mukaan, kummalla tavalla alkioiden järjestys saadaan säilymään. Lisättävästä solmusta tulee joka tapauksessa aina lehti. Poisto sen sijaan voi kohdistua haarautumissolmuunkin, jolloin poistettavan solmun tilalle on nostettava jokin kyseisen solmun jälkeläisistä siten, että järjestys säilyy. Poisto-operaatiota ei tosin läheskään kaikissa etsintäpuun sovelluksissa edes tarvita, vaan käsiteltävään joukkoon sovelletaan käytännössä vain lisäys- ja hakuoperaatioita. Etsintäpuusta muodostuu helposti vino: jos alkiot viedään puuhun esimerkiksi suuruusjärjestyksessä, jäävät puun kaikki vasemmat alipuut tyhjiksi ja rakenteesta tulee käytännössä lista, kuten kuvassa 8-3. Tällöin operaatioiden aikavaativuuskin on sama kuin 8. JOUKKOJEN TOTEUTTAMINEN 92 1 Hakupuuhun viedyt alkiot vientijärjestyksessä 2 1 2 32 42 59 ... 32 42 59 ... TRAI 31.8.2012/SJ Kuva 8-3: Listaksi vinoutunut etsintäpuu listaa käsiteltäessä. Vinoutumisen estämiseksi on kehitetty erilaisia puun tasapainoisuuden säilyttämiseen tähtääviä menetelmiä. Täydellistä tasapainoa ei tosin kannata tavoitella, sillä täydellisen tasapainon ja järjestyksen yhtaikainen saavuttaminen vaatii liikaa työtä. Tilanne luonnollisesti muuttuu, jos pystymme ennustamaan solmujen lisäysjärjestyksen, tai pystymme vaikuttamaan siihen. Yleensä näin ei kuitenkaan ole, ja joudumme tyytymään vinoutumisen kohtuullisena pitämiseen. Käytännössä aivan riittävä on puu, jonka korkeus ei ole koskaan enempää kuin puolitoista- tai kaksinkertainen optimaaliseen verrattuna. AVL-puu [Adelson-Vel'skij & Landis] Etsintäpuun tasapainoisuus voidaan määritellä esimerkiksi seuraavasti: minkään solmun vasemman ja oikean alipuun korkeusero ei saa olla yhtä suurempi. Jos tasapaino uuden solmun lisäyksen myötä menetetään, täytyy puun rakennetta korjata siten, että tasapaino palautuu. Korjaaminen tehdään puun jotakin alipuuta kiertämällä. Kiertomahdollisuuksia on neljä erilaista: Kierto vasemmalle nostaa solmun oikean pojan isänsä paikalle ja kierto oikealle vastaavasti vasemman pojan isänsä paikalle. Näitä hieman monimutkaisempi kaksoiskierto vasemmalle nostaa solmun oikeanpuoleisista pojanpojista vasemman isoisänsä paikalle ja vastaavasti kaksoiskierto oikealle solmun vasemmanpuoleisista pojanpojista oikean isoisänsä paikalle. Paikkaansa vaihtavien solmujen samoin kuin niihin liittyvien alipuidenkin uusi sijainti määräytyy joka kierrossa säännöllisesti. Erilaisia tasapainovirheitä korjattaessa voidaan kulloinkin soveltaa vain yhtä neljästä kiertomahdollisuudesta. Kyseeseen tuleva kierto valitaan solmujen tasapainoilmaisimien arvojen perusteella. Tasapainoilmaisin onkin ainoa tämän mallin vaatima lisä puun solmun rakenteeseen. Solmun tasapaino voi olla jokin seuraavista: –– jolloin solmun vasen alipuu on kahta korkeampi kuin oikea alipuu; – jolloin vasen alipuu on yhtä korkeampi kuin oikea alipuu; 0 jolloin solmun alipuut ovat yhtä korkeat (• ); + jolloin solmun oikea alipuu on yhtä korkeampi kuin vasen alipuu; ++ jolloin solmun oikea alipuu on kahta korkeampi kuin vasen alipuu. Tasapaino on rikkoutunut silloin, kun solmun tasapainoilmaisimen arvo on –– tai ++. Tapauksessa –– on solmun vasemman alipuun juuren tasapaino joko –, jolloin sovelletaan kiertoa oikealle, tai +, jolloin tehdään kaksoiskierto oikealle. Näille symmetrisesti teh- 8. JOUKKOJEN TOTEUTTAMINEN 93 A ++ A ++ B + α h h h β h+1 TRAI 31.8.2012/SJ h h h h B • γ β C δ β h–1 γ B A • α B γ • − α h+1 Kierto vasemmalle C A α β γ δ h Kaksoiskierto vasemmalle Kuva 8-4: AVL-puun tasapainotusoperaatiot [4]. dään kierto vasemmalle, mikäli solmun ja sen oikean alipuun juuren tasapainot ovat ++ ja +, tai kaksoiskierto vasemmalle, kun vastaavat tasapainot ovat ++ ja –. Korjaus tehdään vain yhteen kohtaan puussa, minkä jälkeen puu on taas tasapainossa. Korjattava kohta löytyy vieläpä samalla kun tasapainon rikkovan lisäyksen kohdesolmua vasta etsitään. Solmun poistaminen on hieman hankalampaa, sillä poistettava solmu voi olla haarautumissolmu, jolloin alipuun korjaamisen lisäksi puuhun täytyy ehkä tehdä muutoksia ylemmäskin. Kuva 8-4 esittää kierrot kaavakuvana, joissa mukana olevat (muuttuvat) solmut on merkitty ympyröillä ja kierrossa muuttumattomat alipuut suorakaiteilla. Kaksoiskierron tapauksessa katkoviivalla merkityistä alipuista β ja γ toinen on h:n korkuinen ja toinen h–1:n korkuinen. Alkutilanteessa solmun B tasapaino riippuu tästä, samoin lopputilanteessa solmujen A ja C tasapaino riippuu tästä. Kierrot oikealle (––) tilanteessa ovat peilikuvia näistä kierroista. Muita puita Paitsi vakioon 2, voidaan solmujen lasten lukumäärä rajoittaa muuhunkin vakioon. Jos puuhun tallennetaan merkkijonoja (merkistö esimerkiksi ’a’..’ö’) , voi jokainen solmu sisältää taulukon a:sta ö:hön, siten, että kutakin merkkiä varten on viite seuraavan tason alipuuhun. Merkkijonon haku tällaisesta puusta onnistuu siten seuraamalla oikeaa linkkiä merkkijonon pituus kertaa. Oikean linkin valinta onnistuu vakioajassa sillä se voidaan tehdä taulukkoviittauksena. Leveiden veljessarjojen hyöty on myös puun madaltuminen. Tasapainoisen puun korkeus on O(logk n), missä k on keskimääräinen veljessarjan leveys. Tätä hyödynnetään erityisesti massamuistissa, jossa pyritään minimoimaan levyhakujen määrä. B-puussa on 8. JOUKKOJEN TOTEUTTAMINEN 94 kussakin solmussa voi olla jopa tuhansia avaimia ja aina yksi enemmän lapsia kuin avaimia. Koko solmu haetaan ensin keskusmuistiin, minkä jälkeen haluttua avainta haetaan solmun avainten joukosta binäärihaulla. Jollei avainta löytynyt, haetaan binäärihaun "umpikujan" osoittamasta kohdasta lapsisolmu. Jos kussakin puussa on esimerkiksi 1000 avainta, voidaan kolmella levyhaulla osoittaa miljardi (109) alkiota. B-puu esitellään tarkemmin TRA2-kurssilla. TRAI 31.8.2012/SJ 8.4 Joukon läpikäynti Läpikäyntioperaatioiden iterator, hasNext ja next tehokas toteuttaminen edellyttää läpikäytävän joukon talletusrakenteen tuntemista. Siksi nämä operaatiot tulisikin aina tarjota käyttäjälle joukkorakenteen rinnalla eikä erillisinä. Joukon läpikäyntiin tarvitaan erillinen läpikäyntimuuttuja, joka ylläpitää tietoa missä kohtaa kokoelmaan läpikäynti on menossa. Tavan määrää joukon toteutus: taulukkoon perustuvassa toteutuksessa alkio yksilöidään talletuspaikan indeksillä, kun taas dynaamisessa toteutuksessa käytetään viitettä solmuun. Javan Iterator -luokan ajatus on viitata aina alkioiden väliin. Näinollen operaatio iterator vain alustaa läpikäynnin osoittamaan ensimmäisen alkion eteen. Tämän jälkeen next-operaatio aina siirtää viittauksen seuraavan alkion yli ja palauttaa ylihypätyn alkion. Näin next:n toistuvien suoritusten myötä kaikki muutkin alkiot saavat vuoron. Operaatio hasNext tutkii, vieläkö viite on mielekäs, toisin sanoen onko joukossa vielä käsittelemättömiä alkioita jäljellä. Käytännössä alkioiden väliin ei voi viitata, joten iterator viittaa joko viimeksi palautettuun, tai seuraavaan alkioon. Esimerkiksi listan läpikäynnin ohjaaminen käy helposti, mutta jo puun läpikäynti on vaikempaa, sillä vaikka puu käsiteltäisiinkin jossakin tavanomaisessa järjestyksessä, ei seuraavaksi vuoron saavan solmun selvittäminen ole aina vaivatonta. Binääripuun sisäjärjestykssä läpikäynnissä iterator hakee puun vasemmanpuoleisimman solmun. next palauttaa iteraattorin viittaaman solmun ja hakee sen solmun seuraajan sisäjärjestyksessä odottamaan käsittelyä (katso esimerkki 3-14 s. 48). Viimeisellä alkiolla ei ole seuraajaa, joten viite on null, joka on helppo tunnistaa hasNext -operaatiossa. Näin toteutettuna koko läpikäynti onnistuu ajassa O(n) (n = puun solmujen lukumäärä). Yksittäisen läpikäyntioperaation suoritusaika on pahimmillaan O(logn), mutta koska pääosa operaatioista on nopeita, on keskimääräinen aikavaativuus O(1). Tämä on helposti nähtävissä myös siitä, että jokainen isä-lapsi -suhde käydään läpikäynnissä kerran alas ja kerran ylös.. Puuta käsiteltäessä voidaan yksinkertaisen läpikäyntimuuttujan sijaan käyttää myös tietuetta, johon voi sisällyttää esimerkiksi jonon, jonka avulla solmut kyetään käsittelemään tehokkaasti leveyssuuntaisen etsinnän mukaisessa järjestyksessä. Etenkin binääripuuta käsiteltäessä solmujen jonoon vieminenkin käy nopeasti, koska jokaisella solmulla on enintään kaksi epätyhjää poikaa, mutta myös yleisen puun läpikäynti onnistuu ajassa O(n). Yksittäisen läpikäyntioperaation suoritusaika on keskimäärin vakio, sillä vaikka puun joillakin solmuilla olisikin paljon poikia, on puussa myös runsaasti pojattomia lehtisolmuja, ja koko läpikäynnin aikana jokainen solmu viedään jonoon täsmälleen yhden kerran. Toisenlainen lähestymistapa läpikäynnin toteuttamiseen on varautua läpikäyntiin jo varsinaisessa talletusrakenteessa. Esimerkiksi puuksi järjestettävät alkiot voidaan puura- TRAI 31.8.2012/SJ 8. JOUKKOJEN TOTEUTTAMINEN 95 kenteen rinnalla ketjuttaa myös listaksi, jolloin läpikäyntiä varten ei tarvita omaa jonoa. Listarakenteen kunnossapito solmujen lisäämisen ja poistamisen myötä vaatii tosin hieman lisää työtä, mutta koska listan alkioiden järjestys on läpikäynnin kannalta merkityksetön, ei ylimääräistä työtä aiheudu kovin paljon. Läpikäyntihän edellyttäisi joka tapauksessa jonkin verran lisätyötä! Listarakenne kannattaa toteuttaa kaksisuuntaisena, jotta puun solmujen poistaminenkin kävisi vaivattomasti. Vaikka talletusrakenteen alkiot ketjutettaisiinkin, on läpikäyntimuuttuja silti tarpeen. Läpikäyntejähän saattaa samanaikaisesti olla meneillään useita, eivätkä eri läpikäynnit saa mennä keskenään sekaisin. Jos on tarpeen sallia alkioiden lisääminen ja/tai poistaminen kesken läpikäynnin, tulee tämä ottaa huolellisesti huomioon toteutuksessa. Siltä varalta, että rakenteeseen lisätään alkioita kesken läpikäynnin, on lisättävät alkiot vietävä ketjun loppuun, jotta uudetkin alkiot tulisivat käsitellyiksi vielä keskeneräisissä läpikäynneissä. Alkioiden lisääminen ei ole vaikeaa, mutta alkioiden poistaminen läpikäynnin aikana voi aiheuttaa vaikeita ongelmia, jos meneillään on useita läpikäyntejä yhtaikaa. Poistettava alkiohan saattaa olla käsittelyvuorossa jossakin läpikäynnissä, jolloin alkion poistaminen veisi kyseisen läpikäynnin määrittelemättömään tilaan eikä läpikäynti enää voisi jatkua. Jotta tällainen yllättävä tilanne voitaisiin estää, pitäisi käsiteltävästä rakenteesta olla yhteys läpikäyntimuuttujiin, mutta tällaista yhteyttä ei voida rakentaa silloin, kun läpikäyntimuuttujat ovat erillisiä! Käytännössä ainoa ratkaisu on liittää läpikäyntimuuttuja läpikäytävään rakenteeseen, jolloin poistettavan alkion kohdalla meneillään olevat läpikäynnit kyetään kunnostamaan. Yleensä mieluummin kielletään (tai ainakin rajoitetaan) muuttaminen läpikäynnin aikana muuten kuin käyttämällä toiston operaatioita. Javan Iterator -rajapinta sisältää remove -metodin jolla viimeksi palautettu alkio poistetaan joukosta. Tämä on suoraviivainen toteuttaa, sillä meneillään oleva läpikäynti ei enää häiriinny. Tätä ei tietenkään voi käyttää useassa sisäkkäisessä läpikäynnissä. 8.5 Verkot Verkko muodostuu solmujen ja kaarten joukoista, joten verkon toteuttaminen palautuu joukkojen toteuttamiseen, joskin yleensä varsin yksinkertainen joukkototeutus riittää. Käytännössä järjestämätön lista tai taulukko. Sillä ei toteutustavan valinnan kannalta ole merkitystä, onko kyseessä suunnattu vai suuntaamaton verkko, mutta verkon käsittelyoperaatioita toteutettaessa tämä ero on luonnollisesti muistettava ottaa huomioon. Verkko, jossa on n solmua, voidaan yksinkertaisimmillaan esittää niin sanottuna vierusmatriisina. Vierusmatriisi on n×n-matriisi G, jonka alkio G(i, j) kuvaa kaarta (i, j). Painottamattoman verkon tapauksessa vierusmatriisin alkiot voivat olla boolen arvoja, jolloin arvo true ilmaisee kaaren olemassaolon ja arvo false kaaren puuttumisen. Painotettua verkkoa esittävässä vierusmatriisissa alkiot ovat vastaavasti kaarten painoja ja puuttuvat kaaret ilmaistaan esittämällä paino ∞ jollakin sovitulla tavalla. Suuntaamattomat kaaret joudutaan vierusmatriisissa esittämään kahdesti, kaaret (i, j) ja (j, i) erikseen, ellei matriisista toteuteta esimerkiksi vain alakolmiota, jolloin taas kaarten joukon operaatioiden toteutus hieman monimutkaistuu. Vierusmatriisin rinnalla tarvitaan kuvaus solmujen numeroiden ja muiden tietojen välillä, mikäli solmuihin liittyy muitakin ominaisuuksia kuin pelkkä numero. 8. JOUKKOJEN TOTEUTTAMINEN 96 TRAI 31.8.2012/SJ Vieruslistatoteutuksessa solmut tallennetaan listana (tai taulukkona) ja kustakin solmusta lähtevät kaaret listana. Kaarista on viite maalisolmuun. Suuntaamattomassa verkossa on kätevintä tallettaa kaari kahtena kopiona, yksi kumpaakin solmua varten. Kopiot kannattaa linkittää yhteen mm. värittämisen helpottamiseksi. Kirjallisuutta [1] Aho A. V., Hopcroft J. E., Ullman J.D.: Data Structures and Algorithms. AddisonWesley, 1983. [2] Cormen T. H., Leiserson C. E., Rivest R. L.: Introduction to Algorithms. MIT Press 1990. TRAI 31.8.2012/SJ [3] Hämäläinen A.: Tietorakennekirjasto Javalla. Joensuun Yliopisto, Tietojenkäsittelytieteen laitos, 2005. Ilmestyy pian. [4] Knuth D. E.: The Art of Computer Programming, Volumes 1-3, (2-3ed). AddisonWesley, 1997-1998. [5] Sun Microsystems: JavaTM 2 Platform Standard Edition 5.0 API Specification. http://java.sun.com/j2se/1.5.0/docs/api/. [6] Weiss M. A.: Data Structures and Algorithm Analysis in C. Addison-Wesley, 1997. [7] Weiss M. A.: Data Structures and Algorithm Analysis in Java. Addison-Wesley, 1999. 97