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