Tietojenkäsittelytieteen laitos

Transcription

Tietojenkäsittelytieteen laitos
TRAII 4.3.2011 UEF/tkt/SJ
Tietorakenteet ja algoritmit II
Luentomuistiinpanoja
Simo Juvaste
Asko Niemeläinen
Itä-Suomen yliopisto
Tietojenkäsittelytiede
Alkusanat
Tämä uuden TRAII kurssin luentomateriaali on kutakuinkin edellisen TRA2 kurssin
mukainen. Koska uusi kurssi on laajuudeltaan hieman pienempi, osa asioista jätetään
käsittelemättä. Alustava suunnitelma näistä ylimääräisistä asioista on merkitty monisteeseen tähdillä (*). Tarkempi sisältö täsmentyy kurssin edetessä. Materiaalin alkupäässä on
runsaasti kertausta TRAI -kurssin asioista.
Simo Juvaste
TRAII 4.3.2011 UEF/tkt/SJ
Asko Niemeläisen alkuperäiset TRA-kurssin luentomonisteen 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
TRAII 4.3.2011 UEF/tkt/SJ
Sisällysluettelo (moniste 4.3.2011)
Luku 1: Kertausta algoritmiikasta ja aikavaativuudesta · · ·
1.1 Kertausta algoritmien ja tietorakenteiden käsitteistä
1.2 Suorituksen vaativuus · · · · · · · · · · · · · · ·
1.3 Rekursiivisten algoritmien aikavaativuus · · · · ·
1.4 Aikavaativuuden rajojen todistaminen · · · · · · ·
1.5 Kokeellinen aikavaativuuden määrääminen · · · ·
1.6 Abstraktit tietotyypit (*) · · · · · · · · · · · · · ·
1.7 Kertaus abstrakteihin tietotyyppeihin (*) · · · · · ·
1.8 Lista (*) · · · · · · · · · · · · · · · · · · · · · · ·
1.9 Puu (*) · · · · · · · · · · · · · · · · · · · · · · ·
1.10 Hakupuut · · · · · · · · · · · · · · · · · · · · · ·
1.11 Joukot (*) · · · · · · · · · · · · · · · · · · · · · ·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·1
·2
·3
·8
14
16
19
21
22
27
28
31
Luku 2: Suunnatut verkot · · · · · · · · · · · · · · · ·
2.1 Käsitteitä · · · · · · · · · · · · · · · · · ·
2.2 Suunnattu verkko abstraktina tietotyyppinä
2.3 Lyhimmät polut · · · · · · · · · · · · · · ·
2.4 Verkon läpikäynti · · · · · · · · · · · · · ·
2.5 Muita verkko-ongelmia · · · · · · · · · · ·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
35
35
37
39
43
45
Luku 3: Suuntaamattomat verkot · · · · · · · · · · · · · · ·
3.1 Määritelmiä · · · · · · · · · · · · · · · · · · · ·
3.2 Pienin virittävä puu · · · · · · · · · · · · · · ·
3.3 Suuntaamattoman verkon läpikäynti · · · · · · ·
3.4 Leikkaussolmut ja 2-yhtenäiset komponentit (*) ·
3.5 Verkon kaksijakoisuus, sovitus · · · · · · · · · ·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
51
51
52
56
58
59
Luku 4: Algoritmistrategioita · · · · · · · · · · · · ·
4.1 Ahne eteneminen · · · · · · · · · · · · ·
4.2 Hajoita-ja-hallitse · · · · · · · · · · · · ·
4.3 Dynaaminen ratkaiseminen (ohjelmointi)
4.4 Tasoitettu aikavaativuus · · · · · · · · ·
4.5 Haun rajoittaminen · · · · · · · · · · · ·
4.6 Satunnaistetut algoritmit · · · · · · · · ·
4.7 Kantalukulajittelu · · · · · · · · · · · · ·
4.8 Merkkijonon haku · · · · · · · · · · · ·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
61
61
62
62
63
64
64
65
67
Luku 5: Ulkoisen muistin käsittely · · · · · · · ·
5.1 Tiedostokäsittely · · · · · · · · · · ·
5.2 Onko keskusmuisti ulkoista muistia?
5.3 Ulkoinen lajittelu (*) · · · · · · · · ·
5.4 Tiedosto kokoelmana (*) · · · · · · ·
5.5 B-puu · · · · · · · · · · · · · · · · ·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
72
72
75
77
79
83
Luku 6: Abstraktien tietotyyppien toteuttaminen
6.1 Kotelointi ja parametrointi · · · · · ·
6.2 Verkot · · · · · · · · · · · · · · · ·
Kirjallisuutta · · · · · · · · · · · · · · · · · · · ·
(*)
· ·
· ·
· ·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
85
85
87
90
Luku 1
TRAII 4.3.2011 UEF/tkt/SJ
Kertausta algoritmiikasta ja
aikavaativuudesta
Tietorakenteet ja algoritmit I -kurssin pääteemana oli toisaalta erilaisten abstraktien tietotyyppien käyttö ohjelman rakenteen yksinkertaistamiseksi ja toisaalta ohjelmien tehokkuuden arviointi. Tämä Tietorakenteet ja algoritmit II -kurssi kertaa, täydentää ja syventää
näitä taitoja. Painotus on hieman enemmän algoritmeissa kuin tietorakenteissa. Uusina
abstrakteina tietotyyppeinä esitellään verkot eli graafit ja vastaavasti esitellään muutamia
niihin liittyviä algoritmeja. Toisena uutena kokonaisuutena ovat algoritmistrategiat, jotka
toimivat myös johdantona Algoritmien suunnittelu ja analysointi -kurssille. Algoritmien
aikavaativuuden puolella syvennetään rekursiivisten algoritmien analysoinnin osaamista,
tutustutaan kokeellisen aikavaativuuden määrämisen periaatteisiin ja opitaan miten massamuistin käyttö on huomioitava aikavaativuutta analysoitaessa, ts. miten massamuistia
kannattaa käyttää.
Abstrakteja tietotyyppejä käytetään ohjelman suunnittelussa (ja tietorakenteita
ohjelman toteutuksessa) helpottamaan ja selkeyttämään ohjelman toteutusta ja siten
rakennetta. Kun tunnemme reaalimaailman kohteet (tiedon), niiden suhteet ja sen, miten
niitä käsitellään, meidän tulisi pystyä valitsemaan vastaava abstrakti tietotyyppi johon tiedot ohjelmassa tallennamme. Valitun tietotyypin tulisi tukea tehokkaasti niitä operaatioita
(esimerkiksi tietynlainen läpikäynti tai haku) joita tarvitsemme algoritmissamme. Oikeiden tietorakenteiden käyttö myös selkeyttää ja lyhentää ohjelmakoodia kun itse ohjelmassa keskitytään varsinaisen sovellustiedon käsittelyyn tarvitsematta huolehtia tietorakenteen toteutuksesta. Tämä modulaarisuus vähentää virheitä ja helpottaa testausta sekä
aikavaativuuden analyysiä. Edelleen, valmiiden tietorakenteiden käyttö vähentää ohjelmointityötä (lisää komponenttien uudelleenkäyttöä).
Nykyään näkee usein väitettävän, ettei algoritmien tehokkuus ole enää tärkeää prosessoreiden toimiessa GHz taajuuksilla. Tämä päteekin jos kyseessä ovat vain pienet syötteet joita ajetaan henkilökohtaisella tehokkaalla tietokoneella. Kuitenkin, jokainen meistä
on vielä käyttänyt tarpeettoman hitaita ohjelmia ja/tai laitteita — nopeutusmahdollisuuksia siis vielä on. Lisääntynyt laskentateho myös usein ulosmitataan käyttämällä suurempaa tai tarkempaa syötettä. Yhteiskäyttöisellä tietokoneella (esimerkiksi palvelimella)
kaikki ajansäästö on lisäaikaa muille käyttäjille (ja siten palvelin voi palvella enemmän
käyttäjiä). Tähän luokkaan kuuluvat myös erilaiset reitittimet, palomuurit, tukiasemat ja
muuta vastaavat laitteet joissa kaikki nopeutus hyödyttää käyttäjiä. Ohjelmia tehdään yhä
1
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
2
enemmän myös muille alustoille kuin perinteisille tietokoneille, erityisesti kannettaville,
akkukäyttöisille laitteille. Akkukäyttöisellä laitteella jokainen säästetty kellojakso merkitsee paitsi käyttömukavuutta (parempaa vasteaikaa), myös pidempää käyttöaikaa.
Useimmissa akkukäyttöisissä laitteissa (ja nykyään myös PC:ssä) suoritusteho on (automaattisesti) säätyvä, jolloin nopeampaa algoritmia käyttäen virransäästötilaan päästään
nopeammin ja huipputehoa tarvitaan harvemmin. Näin ollen nopeammalla algoritmilla
saadaan pidempi akun kesto.
Käytännössä arkipäivän ohjelmoinnissa valinnat ovat melko yksinkertaisia. Vähänkään suuremmalla syötteellä O(n) tai O(nlogn) on huomattavasti parempi kuin O(n2) ja
O(logn) tai O(1) on huomattavasti parempi kuin O(n). Erot luonnollisesti korostuvat
jos/kun algoritmia suoritetaan toistuvasti.
TRAII 4.3.2011 UEF/tkt/SJ
1.1 Kertausta algoritmien ja tietorakenteiden käsitteistä
Algoritmi on toimintaohjeisto (käskyjono) joka ratkaisee annetun ongelman äärellisessä
ajassa. Yleensä käsittelemme deterministisiä algoritmeja (ts. algoritmeja, jotka toimivat
samalla syötteellä aina samalla tavalla). Tällä kurssilla kohdassa 4.6 (s. 64) esittelemme
kuitenkin myös satunnaistettujen algoritmien käsitteet ja käyttöä.
Algoritmien toivotaan olevan toisaalta tehokkaita (ts. suorittuvan kohtuullisessa
ajassa ja kohtuullisella laitteistolla) ja toisaalta niiden tulisi olla toteutettavissa kohtuullisella ohjelmointityöllä. Tällä kurssilla opitaan algoritmien suunnittelumenetelmiä, esimerkkialgoritmeja sekä lukemaan valmiita algoritmikuvauksia.
Se, miten ongelma määritellään, on paljolti muiden kurssien asia. Tällä kurssilla
keskitytään pienehköihin, erillisiin ongelmiin. Tärkeää olisi oppia näkemään reaalimaailman ongelmasta se/ne yksinkertainen algoritminen ongelma joka voidaan ratkaista tällä
kurssilla annettavalla opilla. Samoin tärkeää on oppia näkemään reaalimaailman ongelmasta se/ne abstraktit tietotyypit joita kannattaa käyttää.
Esimerkki 1-1: Kokoelmaan lisätään (runsaasti) alkioita joilla on jokin lukuarvo. Alkiota
lisättäessä pitäisi saada selville ko. alkion sijoitus lisäyshetkellä. Samoin pitäisi pystyä hakemaan nopeasti esimerkiksi sadas alkio. Minkälaista tietorakennetta kannattaa käyttää?
Tietorakenne
Abstrakti tietotyyppi on tapa järjestää tietoa. Tietorakenne on kokoelma toisiinsa kytkettyjä tietoja (muuttujia). Käytännössä näitä usein käytetään sekaisin. Tällä kurssilla käsittelemme erityisesti alkioiden välisiä suhteita ja näiden suhteiden ominaisuuksia. Tämän
luvun lopuksi kertaamme TRAI -kurssilta tutut tietotyypit: taulukon, listan, puun ja joukon. Luvuissa 2 ja 3 käsittelemme verkkoja eli graafeja.
1.2 Suorituksen vaativuus
Aikavaativuutta arvioitaessa on tärkeä ymmärtää, että vaativuudeltaan erilaistenkin algoritmien todellisten suoritusaikojen erot ovat merkityksettömiä pienten ongelmien käsitte-
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
3
TRAII 4.3.2011 UEF/tkt/SJ
lyssä. 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 vain kymmeniä alkioita, mutta
joillekin verkkoalgoritmeille 20-solmuinen verkkokin voi olla suuri käsiteltävä.
Ohjelman suoritusaikaa voitaisiin mitata seinäkelloajalla. 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 vertailla keskenään vielä toteuttamattomiakin algoritmeja ja siten välttää tehottomaksi todetun algoritmin toteuttamisesta aiheutuva turha työ. Suoritusajan yksikkönä
käytetään joustavaa termiä "askel". Kuva 1-1 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-1: Suoritusaskel.
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ä.
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äin ollen askelten määrä ilmaistaan syötteen funktiona, esimerkiksi:
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 (esimerkiksi 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
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
4
annetaan sitä käyttäen. Jos sitten muuttuja korvataan toisella, muutos on tehtävä myös
aikavaativuusfunktioon.
Esimerkki 1-2: Esimerkiksi merkkijonon etsinnän toisesta merkkijonosta vaativuus 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. Kertaluokkana
O(nm).
TRAII 4.3.2011 UEF/tkt/SJ
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 aiheuttamaa suoritusaikaa.
2) Tavg(n) tarkoittaa keskimääräistä suoritusaikaa eli kaikkien n:n kokoisten syötteiden aiheuttamien suoritusaikojen keskiarvoa.
3) Tbest(n) tarkoittaa parhaan tapauksen suoritusaikaa eli lyhintä mahdollista n:n
kokoisen syötteen aiheuttamaa suoritusaikaa.
Kuten jo merkinnöistäkin nähdään, tarkastellaan yleensä aina pahinta tapausta, ellei erityisesti mainita jostakin muusta tapauksesta. Paras tapaus ei yleensä ole edes mielenkiintoinen. Esimerkiksi lajittelussa paras tapaus on yleensä 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 analysointikin voi tosin olla vaivalloista. Jos esimerkiksi sama syöte
ei ole pahin algoritmin kaikille osille, joudutaan ensin etsimään kokonaisvaikutukseltaan
pahin syöte. Tällä kurssilla sivutaan hieman myös tasoitettua aikavaativuutta (kohta 4.4
(s. 63)).
Kertaluokat
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:
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
5
Määritelmä 1-3: Kertaluokkamerkinnät O, Ω, Θ ja o
TRAII 4.3.2011 UEF/tkt/SJ
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 jollakin 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. Useimmissa lähteissä ja tällä kurssilla käytetään lähinnä vain ylärajan ilmaisevaa O-merkintää, vaikka usein tarkoitetaan täsmällisempää Θ-merkintää. Ylärajaominaisuuden käyttöä helpottaa sen transitiivisuus, 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 yksikäsitteinen: mahdollisimman tiukka. Ylärajoistakin pyritään aina löytämään tiukin, jotta kertaluokkavertailut vastaisivat tarkoitustaan.
Joskus määritellään myös aito alarajaominaisuus ω (pikku-omega). Siis
T(n) = ω(p(n)), jos T(n) = Ω(p(n)) ja T(n) ≠ Θ(p(n)). Tätä voidaan käyttää esimerkiksi
ilmaisemaan, että jokin algoritmi on hitaampi kuin esittämämme alaraja.
Esimerkki 1-4: 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-3 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-3 esiintyvät vakiot c ja n0 valita
vapaasti, kunhan valinta vain toteuttaa määritelmän epäyhtälön. Esimerkin 1-4 yläraja n
olisi löytynyt myös valitsemalla c = 6 ja n0 = 12, c = 70 ja n0 = 1 tai jollakin muulla
tavoin. 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
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
6
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-5: Näytetään, ettei funktio T(n) = 5n+2 ole kertaluokkaa n : Jos olisi
T(n) = O( n ), niin määritelmän 1-3 nojalla olisi olemassa positiiviset vakiot c ja n0
siten, että 5n+2 ≤ c n , kun n ≥ n0. Tällöin olisi myös 5n ≤ c n . Tämä epäyhtälö
2
voidaan järjestellä uudelleen: 5n ≤ c n ⇔ 5 n ≤ c ⇔ n ≤ ( c ⁄ 5 ) . Koska c on
vakio, on myös (c/5)2 vakio. Tämä merkitsee, ettei epäyhtälö 5n+2 ≤ c n toteudu
ainakaan silloin, kun n > max{n0, (c /5)2 }, mikä on vastoin oletusta. Törmäsimme
ristiriitaan, eli väite on väärä.
TRAII 4.3.2011 UEF/tkt/SJ
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ä.
Aikavaativuuksien luokittelu:
TRAI -kurssilla opittiin vertailemaan ja järjestämään kertaluokkafunktioita. Tärkeintä
onkin usein pystyä valitsemana kahdesta kertaluokkafunktiosta nopeampi ja hahmottaa
niiden nopeuseron merkittävyys. Vertailujen ja algoritmien luokittelun helpottamiseksi
funktiot jaetaan luokkiin, joista seuraavat ovat yleisimmät:
• Eksponentiaaliset aikavaativuudet
• esim 2n, 3n, 2n/n, n!
• Käyttökelpoisia vain hyvin pienille syötteille
• Polynomiset aikavaativuudet
n, n2, n5, n12345, n n , nlogn
Käytännössä yleisimpiä
tehokkaita .. kohtuullisen tehokkaita
n lineaarinen
• n2 neliöllinen
•
•
•
•
n alilineaarinen (mutta polynominen)
• Logaritmiset aikavaativuudet
• logn, loglogn, jne
• huom: logn on tällä kurssilla log2 n
• kaikki o(n) on alilineaarista aikavaativuutta
• ei kokonaisissa peräkkäisalgoritmeissa
• Vakioaikainen: O(1)
•
Luokkien erot ovat selkeitä, kaikille vakioille k pätee logk n = o(n) ja vastaavasti kaikille
vakioille a > 1, b > 0 pätee nb = o(an). Aikavaativuusluokkia käytettäessä on toki muistettava varmistaa funktion merkittävin tekijä. Esimerkiksi nlogn ei suinkaan ole logaritminen tai alilineaarinen, vaan nopeammin kasvava kuin n.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
7
TRAII 4.3.2011 UEF/tkt/SJ
Suoritusajan laskeminen
Mielivaltaisesti valitun ohjelman suoritusajan laskeminen voi 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-3 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-6: Jos T(n) = 3n2+5n+8, niin T(n) = O(n2).
Aikavaativuuden laskeminen algoritmista
Sijoitus-, luku- ja tulostustoiminnot ovat yleensä O(1). Kokonaisen taulukon tai muun
suuren tietorakenteen käsittelyyn kuluu kuitenkin enemmän aikaa. Niinpä esimerkiksi 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. Itse valinta voidaan katsoa
vakioajassa tapahtuvaksi myös monihaaraisessa ehtorakenteessa, koska rakenne sisältää
joka tapauksessa kiinteän määrän eri vaihtoehtoja. 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 sen käsittely irrotettava muista ja laskettava erikseen, esimerkiksi:
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
TRAII 4.3.2011 UEF/tkt/SJ
for (i = 0; i < n; i++)
if (i == n–1)
for (j = 0; j < n; j++)
a = a + 1;
else
x = x + 1;
8
1
2
// tämä suoritetaan vain kerran, O(n)
3
4
5
// tämä vakioaikainen suoritetaan useasti6
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 vaihtelee merkittävästi tai 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 analysoidaan ne erikseen kuten ylläolevassa esimerkissä. 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 tai olio-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.3 Rekursiivisten algoritmien aikavaativuus
Kuten aiemmin todettiin, kukin aliohjelma lasketaan erikseen ja "lisätään" aliohjelmakutsun paikalle. Rekursiivisia aliohjelmia tarkasteltaessa, aikavaativuuden kaavastakin tulee
rekursiivinen.
Esimerkki 1-7: Kertoma voidaan laskea seuraavalla rekursiivisella algoritmilla:
public static int factorial(int n) {
if (n <= 1)
return 1;
else
return i * factorial(n–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 sijoi-
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
9
tus, 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:
TRAII 4.3.2011 UEF/tkt/SJ
T(n) = d, kun n ≤ 1
T(n) = c+T(n–1), kun n > 1.
(c,dvakioita)(1-1)
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ä.
Erilaisia rekursioyhtälöitä voi periaatteessa olla äärettömästi erilaisia. Muuttuvia
osia on kolme, kutsujen määrä, kutsujen koko ja työ rekursion lisäksi. Rekursiivisia kutsuja voi olla esimerkiksi 1, 2, ..., logn, n , ..., n/2, n kappaletta. Rekursion koko voi olla
esimerkiksi n–1, n–2, n/2, logn, n . Rekursion lisäksi työtä voidaan tehdä esimerkiksi
O(1), O(logn), O( n ), O(n), O(n2). Rekursioyhtälö on siten esimerkiksi muotoa
T(n) = cT(n/d) + O(na)
tai
T(n) = n T( n ) + O( n ).
(1-2)
Useimmissa tapauksissa ylläesitetty purkamalla ja kasaamalla päätteleminen toimii riittävän hyvin, kunhan emme tee hätäisiä johtopäätöksiä liian pienellä aineistolla.
Muotoa
T(n) = aT(n/b) + f(n)
(1-3)
oleviin rekursioyhtälöihin löytyy valmis ratkaisu helpohkolla säännöstöllä (ns. Master
theorem), mutta sitä ei käsitellä tällä kurssilla. Säännöstö löytyy mm. Cormet&al kirjasta
ja mahdollisesti esitellään Algoritmien suunnittelu ja analysointi -kurssilla.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
10
Esimerkki 1-8: Lomituslajittelun (ja monen muun hajoita-ja-hallitse -algoritmin) aikavaativuus:
T(n) = 2T(n/2) + d, missä d = O(n)
T(n/2) = 2T(n/4) + d/2
T(n/4) = 2T(n/8) + d/4
T(n/8) = 2T(n/16) + d/8
…
T(n/4) = 2T(n/8) + d/4 = 2 ( 2T(n/16) + d/8 ) + d/4
= 4T(n/16) + d/4 + d/4 = 4T(n/16) + d/2
T(n/2) = 2T(n/4) + d/2 = 2 ( 4T(n/16) + d/2 ) + d/2
= 8T(n/16) + d + d/2 = 8T(n/16) + 3d/2
T(n) = 2T(n/2) + d = 2 ( 8T(n/16) + 3d/2 ) + d
= 16T(n/16) + 3d + d/2 = 16T(n/16) + 4d
(1-4)
TRAII 4.3.2011 UEF/tkt/SJ
Rekursioyhtälön tulosta ei ole enää yhtä helppo nähdä, mutta ylläolevasta voidaan
päätellä vaiheita olevan logaritminen määrä, ja kullakin tasolla tehtävän lineaarinen
määrä työtä, eli yhteensä O(nlogn).
Esimerkki 1-9: Fibonaccin lukujen laskenta määritelmän mukaan
public int fib(int n) {
// Tehoton ratkaisu!!!
if (n < 2)
return 1;
else
return fib(n–1) + fib(n–2);
}
1
2
3
4
5
6
Huomaa, että tämä on tehoton tapa laskea Fibonaccin lukuja. Rekursiokutsuja on
kaksi, muuta työtä vain vakioaika. Rekursioyhtälö on helppo muodostaa:
T(n) = T(n–1) + T(n–2) + c, missä c on vakio. Analysointi on kuitenkin hieman
hankalampaa. Rekursioyhtälöä purettaessa hieman enemmän, nähdään kutsujen
määrän noudattavan Fibonaccin lukuja, mutta sen todistaminen on vaikeaa ja työläämpää.
Tarkan tuloksen sijaan tarkastelemme haarukkaa johon aikavaativuus jää. Yläraja
on helppo arvioida yksinkertaistamalla kaavaa:
T(n–2) ≤ T(n–1)
(miksi?)
⇒
T(n) ≤ 2T(n–1) + c = 2(2T(n–2) + c) + c
= 2(2(2T(n–3) + c) + c) + c = 2i T(n–i) + c(2i–1)
= O(2n).
(1-5)
Mutta onko tämä yläraja-arviointi liian väljä? Arviointi kertautuu joka kierroksella!
Vastaavasti voidaan todistaa alaraja arvioimalla termiä T(n–2) alaspäin verrattuna
1
--- T(n–1):een:
2
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
1
T(n–2) ≥ --- T(n–1)
2
("arvaus")
11
(1-6)
TRAII 4.3.2011 UEF/tkt/SJ
1
T(n–3) + T(n–4) + c ≥ --- (T(n–2) + T(n–3) + c)
2
1
1
1
--- T(n–3) + T(n–4) + --- c ≥ --- T(n–2)
2
2
2
1
1
1
--- T(n–3) + T(n–4) + --- c ≥ --- (T(n–3) + T(n–4) + c)
2
2
2
1
1
--- T(n–4) ≥ --- c
2
2
T(n–4) ≥ c,
mikä varmasti pitää paikkansa kunhan n kasvaa riittävän suureksi.Näin ollen alara1
jatarkastelussa T(n–2) voidaan korvata --- T(n–1):lla.
2
3
3 3
T(n) ≥ --- T(n–1) + c = --- ( --- T(n–2) + c) + c
(1-7)
2
2 2
i
i
3
3 3 3
3
= --- ( --- ( --- T(n–3) + c) + c) + c =  --- T(n–i) + c(  --- –1)
 2
 2
2 2 2
n
3
= Ω(  --- ).
 2
3 n
Aikavaativuus on siis Ω(  --- ) ja O(2n), joka tapauksessa eksponentiaalinen. Arva 2
tenkin tarkka kantaluku on kultaisen leikkauksen suhdeluku ϕ ≈ 1.618…
Rekursiopuu
Jos/kun ylläesitetty rekursioyhtälön purkaminen tuntuu hankalalta, (tätäkin) asiaa voidaan havainnollistaa piirtämällä. Rekursiopuuhun piirretään kullekin tasolle solmu kutakin rekursiokutsua kohti ja solmuun siinä käytetty työ. Kullakin tasolla lasketaan solmujen työt yhteen. Koko työ on kaikkien tasojen töiden summa. Piirtoteknisesti solmuja joutuu yhdistelemään ja käyttämään "…" yms. merkintöjä, mutta kunhan kukin merkittävä
osa jollain tavalla huomioidaan, algoritmin aikavaativuus saadaan laskettua. Kuvan 1-2
puu lomituslajittelusta havainnollistaa rekursiota yksinkertaisessa tapauksessa. Kuvan 1-3
Fibonaccin funktiolle kuva ei ole aivan yhtä selkeä, mutta näyttää selvästi aikavaativuuden eksponentiaalisuuden.
Sanapituuden vaikutus
Jos käsittelemme tietokoneen sanaan (yleensä 32 tai 64 bittiä) mahtuvia lukuja, voimme
olettaa yksinkertaisten operaatioiden (yhteenlasku, kertolasku, jne) olevan vakioaikaisia.
Jos käsittelemme hyvin suuria lukuja, on luvun kokokin huomioitava. Suuren kokonaisluvun a esittämiseen tarvitaan loga bittiä. Kahden suuren luvun a ja b yhteenlaskuun
menee siten aikaa O(log(a + b)).
Esimerkki 1-10: Potenssiin korotus suurilla luvuilla xn. (*)
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
12
Työ
T(n): n
1×n
T(n/2): n/2
logn
T(n/2): n/2
2×n/2
T(n/4): n/4
T(n/4): n/4
T(n/4): n/4
… …
… …
… …
… …
1 1 1 1
…
1 1 1 1
…
1 1 1 1
…
4×n/4
…
T(n/4): n/4
n×1
1 1 1 1
Yhteensä Θ(nlogn)
Kuva 1-2: Rekursiopuu yhtälölle T(n) = 2T(n/2) + n.
TRAII 4.3.2011 UEF/tkt/SJ
T(n)
T(n–1)
n
T(n–2)
T(n–2)
T(n–3):
T(n–3):
T(n–4)
……
……
……
……
1
1
…
…
1
1
…
1
1
1
n/2
1
Kuva 1-3: Rekursiopuu Fibonaccin funktiolle T(n) = T(n–1) + T(n–2).
BigInteger pow(BigInteger x, int n) {
// suoraviivainen algoritmi
BigInteger r = new BigInteger("1");
for (int i = 0; i < n; i++)
r = r.multiply(x);
return r;
}
1
2
3
4
5
6
Aikavaativuus olisi O(n) jos BigInteger.multiply():n aikavaativuus olisi vakio, mutta
se ei luonnollisestikaan voi olla vakio hyvin suurille luvuille. Java API:n dokumentaatio ei valitettavasti (tässäkään) anna mitään vihjettä aikavaativuudesta. Koulualgoritmia ("yhteenlasku alekkain") käyttäen yksittäisen kertolaskun aikavaativuus
on helppo arvioida. Olkoot keskenään kerrottavat luvut a ja b ja lukujen pituudet
siten loga ja logb bittiä. Kertolaskun aikavaativuus alekkainlaskussa on selkeästi
O(loga × logb). Lähdekoodista tarkistettuna Java API käyttää tätä algoritmia.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
13
Yllä algoritmissa pow, r kasvaa ja on (lopussa) pituudeltaan O(nlogx) bittiä, x:n
pituus on kiinteästi logx bittiä. Koko aikavaativuus on siten n×O(nlogx)×O(logx)
O(n2 log2x). Jos x on pieni vakio, niin aikavaativuus on O(n2).
Rekursiivinen hajoita-ja-hallitse -algoritmi:
• xn = ( xx ) n ⁄ 2 jos n on parillinen
• xn = x ( xx )
n⁄2
jos n on pariton
TRAII 4.3.2011 UEF/tkt/SJ
BigInteger pow2(BigInteger x, int n) {
1
if (n == 0)
2
return new BigInteger("1");
3
if (n == 1)
4
return x;
5
if (n%2 == 0)
// n parillinen
6
return (pow2(x.multiply(x), n/2 ) );
// pow2(x*x, n/2)
7
else
8
return (x.multiply(pow2(x.multiply(x), n/2 )));// x*pow2(x*x, n/2)9
}
10
T(n) = T(n/2) + 2Tm (pahin tapaus kun n = 2k–1)
T(n) = T(n/2) + Tm (paras tapaus kun n = 2k).
Taas aikavaativuus riippuu vahvasti kertolaskun aikavaativuudesta Tm . Jos kertolasku olisi O(1), aikavaativuus olisi O(logn), jos taas kertolasku olisi O(n) koko
aikavaativuus olisi mukava O(nlogn). Selvittääksemme aikavaativuuden, kirjoitetaan kukin osa (rekursiokutsu, molemmat kertolaskut) tarkemmin:
T(n, x) = T(n/2, x2) + logxn × logx + logx × logx
= T(n/2, x2) + logx (nlogx + logx)
= T(n/2, x2) + (n + 1)log2 x
Kun rekursiossa edetään, x kasvaa ja n pienenee. Tasolla i on xi = x
Näin ollen koko aikavaativuus on
(1-8)
2
i
n
ja ni = ----i .
2
log n
i
n
 ( log x 2 ) 2  --- + 1  .
(1-9)
∑
 i

2
i=0
Kun summan termeistä viimeinen on suurempi kuin muut yhteensä, tarkastelemme
vain sitä:
T(n, x) =
2
n
)  ---------- + 1
log
n


2
n 2 n
2 n
= 2 ( log x )  --- + 1 = 2 ( n log x )  --- + 1
n

n

T(n, x) ≤ 2 ( log x
2
log n
(1-10)
(1-11)
= O(n2 log2 x)
Jos/kun kantaluku x on pienehkö vakio, on aikavaativuus tuttu O(n2). Käytännössä
kertolaskuja tulee hieman vähemmän kuin edellisessä suoraviivaisessa algoritmissa,
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
14
pahin tapaus ei aina toteudu, ja kaavan 1-9 summa ei mene aivan logn:ään asti.
Toteutuksesta mitattuna rekursiivinen algoritmi on pienellä kantaluvulla 70-200
kertaa nopeampi, riippuen potenssista ja alustasta. Suurilla kantaluvuilla ero hieman
pienenee. Myös Java API (1.6) käyttää tätä algoritmia.
TRAII 4.3.2011 UEF/tkt/SJ
1.4 Aikavaativuuden rajojen todistaminen
Hankalassa tapauksessa voi olla helpompaa todeta (todistaa) algoritmille tai ongelmalle
aikavaativuudelle erilliset ala- ja ylärajat. Esimerkiksi yllä Fibonaccin funktion aikavaativuudesta saatiin erilliset ala- ja ylärajat (molemmat eksponentiaalisia). Olisimme myös
voineet halutessamme todistaa tarkemmatkin ala- ja ylärajat.
Paitsi algoritmeja, voimme analysoida myös ongelmia. Tällöin tarkoitetaan parhaan
mahdollisen ongelman ratkaisevan algoritmin aikavaativuuden rajoja. Hyödyllisintä on
analysoida ongelman vaativuutta analysoimalla aikavaativuuden alaraja, jota nopeammin
ongelmaa ei voi yleisessä tapauksessa ratkaista (deterministisesti). Toisaalta voidaan
todistaa, että annettu ongelma on mahdollista ratkaista korkeintaan jonkin ylärajan
mukaista algoritmia käyttäen. Tämä voidaan helpointen todistaa rakentamalla ko. algoritmi. Yleisesti ongelmien vaativuuden todistaminen on vaativampaa kuin algoritmien
analysointi, eikä kovinkaan tarpeellista normaalissa ohjelmoinnissa. Näin ollen esitetään
tässä vain esimerkkinä yksi tekniikka yhteen ongelmaan.
Päätöspuut
Osoitetaan, ettei pelkästään avainten vertailuun perustuvan lajittelun aikavaativuus voi
olla parempi kuin O(nlogn). Todistuksessa tarvitaan apuna päätöspuun käsitettä, joka esitetään seuraavaksi.
Päätöspuu on binääripuu, jolla kuvataan jonkin ongelman ratkaisun etsimistä. Puun
juurisolmuun liitetään ongelman kaikki erilaiset tapaukset sekä jokin ehto, jonka avulla
nämä tapaukset ositetaan kahteen ryhmään: tapauksiin, joissa ehto pätee, ja tapauksiin,
joissa ehto ei päde. Molemmista ryhmistä muodostetaan erikseen omat päätöspuunsa,
jotka asetetaan juurisolmun alipuiksi esimerkiksi siten, että juurisolmun ehdon täyttävien
tapausten alipuusta tehdään juurisolmun vasen alipuu ja ehtoa täyttämättömien tapausten
alipuusta oikea alipuu. Päätöspuun jokaiseen haarautumissolmuun liittyy vähintään kaksi
eri tapausta ja ehto, lehtisolmuihin puolestaan täsmälleen yksi tapaus eikä laisinkaan
ehtoa. Juuresta johonkin lehteen johtavaan polkuun liittyvistä ehdoista saadaan selville,
millaisten ehtojen täytyy olla voimassa nimenomaan kyseisen lehden sisältämässä tapauksessa.
Esimerkki 1-11: Kuva 1-4 esittää upotuslajittelun päätöspuun syötteen koolla n = 3.
Alussa kaikki kuusi vaihtoehtoa ovat mahdollisia. Ensimmäisen vertailun ▼ < ◆ jälkeen vaihtoehdot jakautuvat kahtia. Jos vertailun tulos oli tosi, jäljelle jäävät vain ne
vaihtoehdot, joissa ▼ edelsi ◆:tä. Epätoden puolelle jäivät loput. Vertailuja jatketaan kunnes jäljellä on vain yksi vaihtoehto.
Vertailuihin perustuvaa lajittelua kuvaavan päätöspuun haarautumissolmuun liittyvä ehto
on aina kahden alkion keskinäinen vertailu. Koska lajittelun aikavaativuuden arviointikin
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
15
▼<◆<●
▼<●<◆
◆<▼<●
◆<●<▼
●<▼<◆
●<◆<▼
▼<◆
ä
kyll
ei
▼<◆<●
▼<●<◆
●<▼<◆
▼<●
llä
ky
llä
ky
TRAII 4.3.2011 UEF/tkt/SJ
▼<◆<●
◆<●
ei
▼<◆<●
▼<●<◆
◆<●
◆<▼<●
◆<●<▼
●<◆<▼
ei
▼<●<◆
llä
ky
●<▼<◆
ei
◆<▼<●
◆<●<▼
▼<●
ä
ll
ky
◆<▼<●
●<◆<▼
ei
◆<●<▼
Kuva 1-4: Upotuslajittelun päätöspuu, n = 3, n! = 6.
useimmiten perustuu tarvittavien vertailujen lukumäärään, on päätöspuulla ja aikavaativuudella jotakin yhteistä: päätöspuun juuresta lehteen johtavan polun eli päätöspolun
pituus on sama kuin kyseiseen lehteen liittyvän tapauksen lajittelemiseksi tarvittavien vertailujen lukumäärä. Jotta lajittelu onnistuisi mahdollisimman nopeasti, pitäisi vertailujen
lukumäärän — toisin sanoen päätöspolun pituuden — olla aina mahdollisimman pieni.
Lisäksi on muistettava, että mikä tahansa tapaus on lajittelussa yhtä todennäköinen, joten
eri päätöspolkujen tulisi olla mahdollisimman tasapituisia. Jos näet jokin päätöspolku on
kovin lyhyt, on toisaalla päätöspuussa todennäköisesti useita varsin pitkiä päätöspolkuja.
Päätöspolkujen optimaalisen pituuden arvioimiseksi on ensiksi arvioitava päätöspuun lehtien lukumäärää. Lajiteltaessa n:ää alkiota on erilaisia mahdollisia järjestyksiä
kaikkiaan peräti n! kappaletta. Vaikka osa lajiteltavista alkioista olisi keskenään samoja,
on lajittelun kannalta silti olemassa n! erilaista järjestystä. Siksi lajittelua kuvaavassa päätöspuussa on aina n! lehteä. Tämä merkitsee, että haarautumissolmuja on n!–1 ja solmuja
yhteensä 2n!–1. Näin monta solmua sisältävän päätöspuun korkeus on vähintään
log(2n!–1) eli likimain sama kuin log(n!). Ollakseen näin matala täytyy puun kaikkien
lehtisolmujen olla puun alimmalla tai kahdella alimmalla tasolla (miksi?), jolloin pisimmän samoin kuin lyhimmänkin päätöspolun pituus on O(log(n!)). Kertoman logaritmin
arvioimiseksi annetaan [todistamatta] approksimaatio
n! ≈ (n/e)n,
(1-12)
missä e ≈ 2.7183… on luonnollisen logaritmijärjestelmän kantaluku eli niin sanottu
Neperin luku. Äskeinen approksimaatio on puolestaan yksinkertaistettu muoto hieman
monimutkaisemmasta Stirlingin kaavasta kertoman arvioimiseksi. Nyt voidaan arvioida
seuraavasti:
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
log(n!) ≈ log((n/e)n) = nlogn – nloge
log(n!) = O(nlogn)
16
(1-13)
Kertoman logaritmia voidaan arvioida myös alaspäin:
TRAII 4.3.2011 UEF/tkt/SJ
log(n!) ≥ (n/2)log(n/2) = (n/2)logn – (n/2)
log(n!) = Ω(nlogn)
(1-14)
Optimaalisessa päätöspuussa jokaisen päätöspolun pituus on toisin sanoen Θ(nlogn),
joten vertailuihin perustuvan lajittelun aikavaativuuskaan ei pahimmassa tapauksessa voi
olla tätä parempi. On vielä syytä huomata, ettei aikavaativuudeltaan O(n) tehokkaampaa
(peräkkäistä) lajittelualgoritmia voi olla olemassakaan. Jos tällainen algoritmi näet olisi
olemassa, ei sen suoritusaikana ennätettäisi edes käsitellä kaikkia alkioita. Lajittelu perustuisi tällöin osittain arvaukseen eikä tuloksen oikeellisuutta voitaisi taata!
Esimerkiksi kaukalolajittelun aikavaativuudeksi todettiin TRAI kurssilla O(n),
mutta kaukalolajittelua voidaan soveltaa vain tilanteessa, jossa lajiteltavien alkioiden
avainten joukko on rajoitettu. Topologista lajittelua lukuunottamatta kaikki muut näillä
kursseilla esitettävistä lajittelualgoritmeista perustuvat oletukseen avainten lineaarisesta
järjestyksestä, mutta esimerkiksi avainten suuruusluokasta ei näissä algoritmeissa oleteta
mitään.
1.5 Kokeellinen aikavaativuuden määrääminen
Joskus algoritmin asymptoottisen analyysin keinot eivät ole mahdollisia, järkeviä tai riittävän tarkkoja. Mahdollisia analyyttisen tuloksen saamisen esteitä voivat olla esimerkiksi:
emme osaa analysoida algoritmia, emme tiedä tarpeeksi syötteen rakenteesta (tai sen vaikutuksesta), lähdekoodi (tai sen osa) ei ole käytettävissä (tai ymmärrettävissä). Joskus
myös tarvitsemme tarkempia tuloksia aikavaativuuden vakiokertoimista. Tällaisissa tilanteissa voimme toteuttaa algoritmimme, suorittaa sitä ja mitata suorituksen aikavaativuuden tietokoneella. Se, mitä mitataan ja missä muodossa tuloksen haluamme, riippuu
omista tarpeistamme. Yleensä voimme tehdä sivistyneen arvauksen aikavaativuusfunktiosta ja sitä käyttäen esimerkiksi ekstrapoloida aika-arviomme suuremmille syötteille.
Tulos on yleensä varsin hyvä jos (ja vain jos) aikavaativuusfunktioarvauksemme osui
oikeaan.
Miten aikavaativuutta mitataan kokeellisesti?
Kuten analyyttisessäkin arvioinnissa, aikavaativuus on helpointa tehdä pienehkölle ohjelmanosalle kerrallaan. Jotta tulokset olisivat vertailukelpoisia, on käytettävä vakioitua tai
useaa satunnaista syötettä, riippuen algoritmista. Erityisesti on varmistettava, ettei syöte
ole mikään erikoistapaus. Luotettavuuden parantamiseksi, kullakin syötekoolla algoritmi
suoritetaan useamman kerran ja aikana käytetään ajojen keskiarvoa/mediaania (jos satunnainen syöte) tai minimiä (jos vakiosyöte). Syötteen kokoa kasvatetaan aluksi eksponentiaalisesti (esimerkiksi 2 tai 10). Kun aika alkaa kasvaa, käytetään tarkempia askeleita, erityisesti jos aikavaativuus on suuri (eksponentiaalinen). Jotta mittaustarkkuus olisi riittävä,
kasvatetaan syötettä niin, että ajoaika on ainakin useita sekunteja. Nopeille algoritmeille
tämä ei välttämättä ole mahdollista (syöte kasvaa liian suureksi). Tällöin varsinaista mitat-
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
17
tavaa algoritmia on suoritettava toistuvasti mittauksen aloituksen ja lopetuksen välissä
riittävän tarkan mittauksen saavuttamiseksi.
Mittauksissa tarkkaillaan peräkkäisten syötekokojen suoritusaikojen suhdetta. Esimerkiksi kaksinkertaistetaan syöte kullakin askeleella. Jos suoritusaika kaksinkertaistuu,
kyseessä on todennäköisesti lineaarinen aikavaativuus. Jos aika hieman yli kaksinkertaistuu, kyseessä voi olla esimerkiksi O(nlogn) (voi olla vaikea erottaa lineaarisesta, vaatii
pidemmän jänteen). Jos aika nelinkertaistuu: neliöllinen. Jos aika 8-kertaistuu: kuutiollinen. Jos syötteen kasvattaminen yhdellä kasvattaa aikavaativuuden kaksinkertaiseksi:
eksponentiaalinen.
Tulosten esittäminen käyränä ja vertailufunktioiden piirtäminen samaan asteikkoon
sopivilla kertoimilla auttaa näkemään oleelliset muutokset (kts. esimerkit alla). Logaritminen asteikko on yleensä parempi kuin lineaarinen. Joissakin mittaustuloksissa esiintyy
heittoja, lähinnä välimuistien ja roskienkeruun takia. Kun syöte kasvaa yli välimuistista,
aikavaativuus alkiota kohti usein kaksin- kolminkertaistuu (katso esimerkki 5-5 s. 76).
Tämän vuoksi pidempi sarja kuin 2-3 mittausta on ehdoton vaatimus.
TRAII 4.3.2011 UEF/tkt/SJ
Ajan mittaaminen
Varsinaisen algoritmin toteuttamisen lisäksi meidän on toteutettava ajan mittaaminen.
Helpoin ratkaisu on käyttää käyttöjärjestelmän prosessin ajanmittausta (Unix ja sukulaiset: time java Ohjelma). Tämä on sikäli hyvä, että se mittaa erikseen käytetyn reaaliajan ja juuri tämän prosessin käyttämän ajan (jättäen huomiotta muiden prosessien ajan).
Toisaalta tätä voidaan käyttää vain kokonaisen ohjelman mittaamiseen ja mukaan tulevat
myös Java virtuaalikoneen oma alustus- ja lopetustyö, joka tosin on melko vakio (kymmeniä..satoja millisekunteja) yksinkertaisessa ohjelmassa. Tämä ei myöskään ole alustariippumaton mittaustapa. Helpompi olisi, jos ajanmittaus voitaisiin tehdä ohjelman
sisällä. Valitettavasti JavaAPI ei suoraan mahdollista prosessin/säikeen ajankäytön mittaamista. C:ssä clock() palauttaa prosessin käyttämän ajan mikrosekunteina. Java-ohjelmaan on mahdollista sisällyttää kutsuja erillisiin mittaohjelmiin (verkosta löytyy valmiita
HOWTO -ohjeita), mutta tekniikat eivät ole alustariippumattomia.
Helpompaa on käyttää reaaliaikakelloa java.util.Date -luokan avulla ja pyrkiä minimoimaan siitä aiheutuvat virheet. Date palauttaa "seinäkelloajan" millisekunteina. Jotta
ajanmittaus antaisi järkeviä tuloksia, meidän on minimoitava muut samassa koneessa suorittuvat prosessit, emmekä voi tulostaa juuri mitään mittauksen aikana. Ohjelma ajetaan
useaan kertaan samalla syötteellä ja suoritusajoista otetaan minimi. Mittaustarkkuuden
vuoksi on käytettävä riittävän suuria syötteitä tai saman operaation toistamista useasti
(suoritusaika vähintään 0,1s). Esimerkki aikamittarista: Ajastin.java kurssin wwwsivulla.
Esimerkki 1-12: String vrt. StringBuffer (StringTest.java)
Toteutetaan Vector<Integer>.toString() eri tavoin.
• java.util.Vector<Integer>.toString() (valmis toteutus: AbstractCollection)
• String: s = s + v.get(i).toString() + ", ";
• StringBuffer: sb.append(v.get(i).toString() + ", ")
• StringBuilder: sb.append(v.get(i).toString() + ", ")
• StringBuilder2: sb.append(v.get(i).toString); sb.append(", ");
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
18
T(ms)
• StringBuilder3: new StringBuilder(n*12).
1M
32768
1M/s
n^2
Vector.tStr
SBuffer
SBuilder
SBuilder2
SBuilder3
String
1024
32
256
1024
4096
16384
65536 262144
1M
n
Kuva 1-5: Merkkijonon kokoamisen aikavaativuus, logaritminen x ja y.
30000
T(ms)
TRAII 4.3.2011 UEF/tkt/SJ
1
1M/s
n^2
25000 Vector.tStr
SBuffer
SBuilder
SBuilder2
20000
SBuilder3
String
15000
10000
5000
0
256
1024
4096
16384
65536
262144
Kuva 1-6: Merkkijonon kokoamisen aikavaativuus, lineaarinen y.
1M
n
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
19
TRAII 4.3.2011 UEF/tkt/SJ
Taulukko 1-7: Merkkijonon kokoamisen aikavaativuus (ms)
n
Vector.
toString
StringBuffer
StringBuilder
StringBuilder2
StringBuilder3
String
50
0
0
0
0
0
1
100
0
0
0
0
0
2
200
0
0
0
0
0
3
400
0
0
0
0
0
11
800
0
0
0
0
0
49
1600
1
2
1
0
1
207
3200
3
4
3
2
1
1039
6400
6
8
8
9
5
8026
12800
15
44
20
41
12
90140
25000
83
95
94
80
45
50000
152
172
169
146
96
100000
233
262
259
215
144
200000
393
439
442
355
234
400000
699
798
786
650
420
800000
1318
1520
1494
1232
809
1600000
1909
2307
2328
1964
1359
3200000
3851
5207
5543
4022
2732
1.6 Abstraktit tietotyypit (*)
Java API sisältää kohtuullisen joukon valmiita tietotyyppejä, ja niillä pärjää useassa elävän elämän ongelmassa. Puuttuvia kokoelmia ovat lähinnä puut ja graafit. Joihinkin tilanteisiin Javan valmiin kokoelmat ovat kuitenkin joko tarpeettoman hankalia tai tehottomia.
Erityisesti useamman kokoelman samanaikaiset läpikäynnit ja useamman joukon tehokkaat yhdisteoperaatiot puuttuvat. Yhdenmukaisuuden ja selkeyden vuoksi käytämme pääosin oman tietorakennekirjastomme kokoelmia läpi tämäkin kurssin. Seuraavissa kohdissa 1.7-1.11 kerrataan TRAI:n esittelemät abstraktit tietotyypit (kokoelmat). Tämän
kohdan materiaali on toistaiseksi (hieman) tiivistetty versio TRAI-kurssin luvuista 2-4,
eikä uutta asiaa ole lainkaan (paitsi AVL-puut kohdassa 1.10). Näin ollen TRAII luennoilla tämä kohta käsitellään varsin vauhdilla, ja materiaali jätetään lähinnä kertausta varten ja referenssiksi.
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
"ripustetaan" ADT:n ylläpidettäväksi, jolloin meidän ei tarvitse huolehtia kokoelman
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
20
Hyötytieto
❁❂❃
❅❉❂
❊❋●
❆❇❈
ADT
Looginen järjestys
TRAII 4.3.2011 UEF/tkt/SJ
Kuva 1-8: ADT kokoelman ylläpidon apuvälineenä.
ylläpitämisestä, vaan voimme keskittyä itse elementteihin liittyvään tietojenkäsittelytehtävään. Kuva 1-8 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
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-13: 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:
// returns union of this set and set B
public static Set<E> union(Set<E> B);
// returns whether object x is a member of this set or not
public static boolean member(<E> x);
1
2
3
4
Tässä esiintyvä tyyppi <E> on joukon alkioiden tyyppi, joka luonnollisesti on eri
joukoilla 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 dokumentaatiosta tämä puuttuu,
mikä onkin niiden ehkä suurin puute.
Abstrakteja tietotyyppejä käytetään liittymän kautta, mutta luonnollisesti ohjelman
suorituksen aikana tarvitaan tietotyypin toteutuskin. Toteutus voi pohjautua toisiin abst-
TRAII 4.3.2011 UEF/tkt/SJ
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
21
rakteihin 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 varsinaisen 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-14: Muuttujan tietotyyppi on kyseisen muuttujan sallittujen arvojen
joukko.
Määritelmä 1-15: 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-16: 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.
1.7 Kertaus abstrakteihin tietotyyppeihin (*)
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
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
22
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 abstraktin 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 1-17: Listatyypin liittymä alkaa tekstillä:
public class List<E> {
1
TRAII 4.3.2011 UEF/tkt/SJ
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
Tehtäessä geneerisiä algoritmeja kokoelmille, voimme rajoittaa tarvittaessa mahdollisten
alkioiden tyyppiä:
public boolean vertailuaTms(List<? extends Comparable>)
1
1.8 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.
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 seuraaja-alkiot: 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, joka selvitetään funktiolla L.EOL. Listan sisällön muuttuessa alkioiden asemat ja samalla myös etäi-
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
23
syydet 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ä.
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.
Abstraktin listan operaatiot
TRAII 4.3.2011 UEF/tkt/SJ
Määritelmä 1-18: Seuraava luettelo kuvaa tällä kurssilla käytettävän tietorakennekirjaston listaoperaatiot. Asemana käytetään viitettä listasolmuun (ListNode). Elementtityyppinä käytämme geneeristä E:tä. (parametrien tyypit: E x, ListNode p,
TraLinkedList L)
1) TraLinkedList<Alkiotyyppi> TraLinkedList<Alkiotyyppi>()
Muodostaa tyhjän listan L.
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.next aseman L.EOL ja jos p = L.EOL (tai listassa ei ole asemaa p) on p.next 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.previous määrittelemätön.
8) E p.getElement()
Palauttaa asemassa p olevan alkion. Jos p = L.EOL on tulos määrittelemätön.
Listan läpikäynti
Yleensä lista läpikäydään käyttäen listasolmuja ja alkuehtoista toistoa. Jollei listaa
muuteta, eikä tarvita useaa limittäistä toistoa, myös foreach -toisto toimii, kts. alla
LinkedList:n yhteydestä.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
24
Listaoperaatioiden aikavaativuus
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 saattavat edellyttää ainakin listan alku/loppuosan 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. Vakion (tai jossain toteutuksissa funktion) EOL aikavaativuus on toivottavasti
O(1), mutta joillakin toteutustavoilla EOL vie aikaa jopa O(listan pituus).
TRAII 4.3.2011 UEF/tkt/SJ
Esimerkki 1-19: Esimerkki: listojen samuuden vertailu. Listat ovat samat, jos ne ovat
yhtä pitkät ja niissä on samat alkiot samassa järjestyksessä.
public boolean compareLists(TraLinkedList L1, TraLinkedList L2) {
ListNode p1 = L1.first();
ListNode p2 = L2.first();
while ((p1 != L.EOL) && (p2 != L.EOL)) {
if (! p1.getElement().equals(p2.getElement()))
return false;
p1 = p1.next();
p2 = p2.next();
}
if (p1 == L1.EOL && p2 == L2.EOL)
return true;
else
return false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.
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äysi
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
25
päinvastaiset kuin Vector-luokalle. Suoran asemaviitteen puuttuminen tekee myös ylläesittämämme listan operaatiot tehottomiksi. Indekseihin viittaamalla vain jotkin indeksit
(tarkemmin alku ja loppu) ovat tehokkaita:
LinkedList<Integer> L = new LinkedList<Integer>();
1
for (int i = 0; i < N; i++)
L.add(i, i);
for (int i = 0; i < N; i++)
L.add(i/2, i);
for (int i = 0; i < N; i++)
... = L.get(i);
2
// yhteensä O(N)
// lisäys loppuun O(1)
// yhteensä O(N2)
// lisäys keskelle O(N)
// yhteensä O(N2)
// indeksin haku O(N)
3
4
5
6
7
TRAII 4.3.2011 UEF/tkt/SJ
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. 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.
Määritelmä 1-20: java.util.ListInterator operaatiot (tärkeimmät).
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 ei tule mukaan
läpikäyntiin next-operaatioilla, mutta jos seuraavaksi kutsutaan previous-operaatioita, se palauttaa tämän uuden alkion..
6) void i.remove()
poistaa elementin joka viimeksi hypättiin yli next:llä tai previous:lla.
Esimerkki 1-21: Listan läpikäynti iteraattorilla ja alkuehtoisella toistolla.
LinkedList L;
...
ListIterator i = L.listIterator();
while (i.hasNext())
... = p.next()
1
2
3
// yhteensä O(N)
4
5
Listaa saa muuttaa kesken läpikäynnin vain tämän iteraattorin add/remove -metodeilla.
Muu listan muuttaminen johtaa virhepoikkeukseen (ConcurrentModificationException).
Myöskään kaksi sisäkkäistä läpikäyntiä eivät saa muuttaa listaa (vrt. yllä purge). Iteraat-
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
26
torin käyttö tehokkaaseen läpikäyntiin on varsin näppärää jos sen joustavuus riittää.
Monimutkaisemmat tapaukset ovat vaikeita, tai mahdottomia. Katso esimerkit
TraListMerge.java ja JavaListMerge.java.
Esimerkki 1-22: Alkion x kaikkien esiintymien poisto.
ListIterator i = L.listIterator();
while (i.hasNext())
if (x.equals(p.next()))
i.remove();
1
// O(N)
2
3
4
Samanlainen Iterator-toisto toimii kaikille Javan kokoelmille (Collection). Jos listaa ei
muuteta, itse iteraattorikin jää käyttäjälle tarpeettomaksi. Niinpä Javan versiosta 1.5 lähtien kaikille kokoelmille (itse asiassa Iterable, kts. alempana) on myös "tee kaikille alkioille" (foreach) toisto. Tämä rakenne käyttää "piilossa" iteraattoreita toteuttaakseen ylläolevan kaltaisen toiston kullekin alkiolle.
TRAII 4.3.2011 UEF/tkt/SJ
for(E x : L)
x.foo();
// E on alkiotyyppi, L kokoelma
1
2
Taulukko
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. Javan Vector (ja ArrayList) toteuttavat abstraktin taulukon tärkeimmät operaatiot.
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 yksi-
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
27
TRAII 4.3.2011 UEF/tkt/SJ
tellen 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 laajennuksen 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.
1.9 Puu (*)
Lista määrää alkiokokoelmalle peräkkäisen rakenteen.
Näin ollen 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.
Binääripuu
Yleisessä puussa jokaisella solmulla voi olla mielivaltainen määrä lapsia (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.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
28
Binääripuu on joko tyhjä puu tai puu, jonka kullakin solmulla on yksi, kaksi tai ei
yhtään lasta. Solmun lapset erotetaan toisistaan kutsumalla toista vasemmaksi lapseksi ja
toista oikeaksi lapseksi. Näistä kahdesta lapsesta kumpi tahansa voi puuttua. Olennainen
ero binääripuun ja sellaisen yleisen puun, jonka jokaisella solmulla on enintään kaksi
lasta, välillä on seuraava: Jos yleisen puun solmulla on vain yksi lapsi, on se ilman muuta
vanhempansa vanhin eli vasemmanpuoleisin lapsi. Sen sijaan binääripuun yksilapsisen
solmun ainoa lapsi on joko vasen tai oikea lapsi sen mukaan, kumpi lapsista puuttuu.
Binääripuun solmun ainoa lapsi ei toisin sanoen ilman muuta ole vasemmanpuoleinen
lapsi. Yleensä binääripuita käsiteltäessä ajatellaankin, että lapsia on aina kaksi ja lapsista
jompikumpi tai molemmat voivat olla tyhjiä lapsia. Algoritmeissa on useimmiten kätevä
huomioida, että aliohjelmaa voidaan kutsua myös tyhjällä solmulla.
TRAII 4.3.2011 UEF/tkt/SJ
1.10 Hakupuut
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). Seuraavassa esitettävällä tavalla puun korkeudeksi voidaan varmistaa O(logn) riippumatta alkioiden lisäysjärjestyksestä. Näin ollen kaikki perusoperaatiot saadaan O(logn)
aikaiseksi. Samoin läpikäyntioperaatiot voidaan toteuttaa (keskimäärin) vakioaikaisiksi.
Esimerkki 1-23: 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ä.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
1
29
Hakupuuhun viedyt alkiot
vientijärjestyksessä
2
1
2
32
42
59
...
32
42
59
...
Kuva 1-9: Listaksi vinoutunut etsintäpuu
TRAII 4.3.2011 UEF/tkt/SJ
Esimerkki 1-24: 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.
Esimerkki 1-25: 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.
Puun korkeuden rajoittaminen
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 1-9. Tällöin operaatioiden aikavaativuuskin on sama kuin 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 lapsen vanhempansa paikalle
ja kierto oikealle vastaavasti vasemman lapsen vanhempansa paikalle. Näitä hieman
monimutkaisempi kaksoiskierto vasemmalle nostaa solmun oikeanpuoleisista lastenlasta
vasemman isovanhempansa paikalle ja vastaavasti kaksoiskierto oikealle solmun vasemmanpuoleisista lastenlapsen oikean isovanhempansa paikalle. Paikkaansa vaihtavien solmujen samoin kuin niihin liittyvien alipuidenkin uusi sijainti määräytyy joka kierrossa
säännöllisesti.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
A ++
A ++
B
+
α
h
h
h+1
B
TRAII 4.3.2011 UEF/tkt/SJ
β
C
δ
β h–1 γ
h
h
B •
γ
h
B
γ
A •
α
−
α
h
β
•
h
30
h+1
Kierto vasemmalle
C
A
α
β
γ
δ
h
Kaksoiskierto vasemmalle
Kuva 1-10: AVL-puun tasapainotusoperaatiot [6].
Erilaisia tasapainovirheitä korjattaessa voidaan kulloinkin soveltaa 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 tehdää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 tasapainoja päivitetään lehtisolmun lisäyksen jälkeen puussa
ylöspäin noustaessa. Kierron jälkeen ylempien solmujen tasapainoa ei tarvitse enää korjata. 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 1-10 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
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
31
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.
Äskeistä tasapainoisen etsintäpuun mallia kutsutaan AVL-puuksi (keksijöidensä
Adelson-Vel'skij ja Landis mukaan). Toisenlainen tasapainoisen etsintäpuun malli on
punamusta puu, jossa minkään juuresta lehteen johtavan polun pituus ei ylitä muun juuresta lehteen johtavan polun kaksinkertaista pituutta. 2–3-puussa haarautumissolmulla on
joko kaksi tai kolme poikaa, ja myöhemmin esitettävässä B-puussa haarautumissolmun
poikien lukumäärä on vähintään t ja enintään 2t. Viimeksimainituissa malleissa kaikkien
lehtien syvyys on sama.
TRAII 4.3.2011 UEF/tkt/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 on
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 tällä kurssilla kohdassa 5.5 (s. 83).
1.11 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.
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
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
32
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:
TRAII 4.3.2011 UEF/tkt/SJ
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.
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 mielessä (mutta ei aina) voidaan ajatella, että
TM < FT.
Joukko-operaatiot
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ä 1-26: 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.
TRAII 4.3.2011 UEF/tkt/SJ
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
33
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.
11) Set A.clone()
palauttaa kopion joukosta A. Uusi joukko sisältää samat alkiot kuin A, mutta itse
alkioita ei kopioida.
12) E 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) E 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.
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
(1-15)
Javan versiossa 1.5 ja myöhemmin tämä foreach -toisto on toteutettu osaksi abstrakteja
kokoelmia (Collection framework). Kaikki Collection -rajapinnan (itseasiassa Iterable)
toteuttavat kokoelmat voidaan läpikäydä seuraavasti:
for (x : S)
toimenpide alkiolle x
(1-16)
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.
1. KERTAUSTA ALGORITMIIKASTA JA AIKAVAATIVUUDESTA
34
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ä 1-27: Joukon läpikäyntioperaatiot
(Set S; Iterator i; E x)
TRAII 4.3.2011 UEF/tkt/SJ
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
joukon S kaikki alkiot on jo käsitelty läpikäynnissä i (eli hasNext olisi palauttanut toden), aiheuttaa ajonaikaisen poikkeuksen.
17) void i.remove()
Poistaa kokoelmasta S edellisen next() -operaation antaman alkion.
Läpikäytävää joukkoa ei luonnollisestikaan 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 1-27 operaatioita käyttäen lause (1-15) onnistuu nyt seuraavasti:
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).
Joukkojen erikoistapauksia
• Sanakirja: vain haku (ja ehkä lisäys, poisto).
• Relaatio: alkio-toinen_alkio -pari (tai tai useampiosainen monikko).
• Kuvaus: avain-alkio -pari, avaimella yksiselitteinen kuva.
• Laukku (monijoukko): usea samanarvoinen alkio.
• Prioriteettijono: tarkasteltavissa/poistettavissa vain pieniprioriteettiarvoisin ("tärkein") alkio.
Luku 2
TRAII 4.3.2011 UEF/tkt/SJ
Suunnatut 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 — suunnattu verkko (directed graph, digraph). Monet
erilaiset reaalimaailman ongelmat voidaan kuvata verkkoina.
Verkko-ongelmien (ja siten algoritmienkin) kenttä on erittäin
laaja, ehkä kaikkein laajin ja monipuolisin. Ongelma-alakohtaisia algoritmeja emme luonnollisesti tässä esittele. Sensijaan tarkastelemme nyt suunnattujen verkkojen käsitteistöä ja joitakin yleisiä verkkoalgoritmeja.
Esimerkki 2-1: Tkt:n LuK:n kurssijärjestys:
DiskrRak
Ohjelm.
TRAI
OhjTyö
TKJ
JTKT
TiedonH
ProsedOhj
HAJ Parityö
TRAII
JärjSuunn.
LaTe
Projektityö
JärjKehitys
2.1 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
35
2. SUUNNATUT VERKOT
36
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 (maalisolmu). 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
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.
TRAII 4.3.2011 UEF/tkt/SJ
Esimerkki 2-2: Nelisolmuinen 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).
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 yksilöimä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. Käyttämässämme tietorakennekirjastossa "hyötytieto" on merkkijono, paino on reaaliluku ja
väri on lueteltu tyyppi (kokonaisluku). Lisäksi on käytössä kokonaislukutyyppinen
indeksi jota voidaan käyttää esimerkiksi solmujen numerointiin. Jos tarvitaan muuta tietoa, voidaan periyttämällä lisätä jäseniä.
Esimerkki 2-3: Äärellinen automaatti (nimiöt kaarissa).
b
b
v1
Alkutila
a
v2
a
v3
a
v4
Lopputila
2. SUUNNATUT VERKOT
37
2.2 Suunnattu 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.
TRAII 4.3.2011 UEF/tkt/SJ
Määritelmä 2-4: Suunnatun verkon operaatiot:
(DiGraph G, Vertex vertex, Edge edge, String label, int color, float weight,
int index)
1) DiGraph DiGraph()
Luo tyhjän suunnatun verkon.
2) Vertex G.addVertex(label, color, weight, index)
Lisää verkkoon G kaarettoman solmun. Operaatio palauttaa solmun (viittauksen) todennäköistä jatkokäyttöä varten. Tietorakennekirjastossa on lisäksi sama
operaatio kuormitettu vähemmillä (ja ilman) kaikkia parametreja.
3) Edge vertex.addEdge(vertex2, label, color, weight)
Lisää verkkoon kaaren solmusta vertex solmuun vertex2. Operaatio palauttaa
kaaren (viittauksen) todennäköistä jatkokäyttöä varten. Tietorakennekirjastossa
on lisäksi sama operaatio kuormitettu vähemmillä (ja ilman) parametreilla.
4) void G.removeVertex(vertex)
Poistaa verkosta G solmun vertex. Samalla poistetaan solmuun mahdollisesti
liittyvät kaaret.
5) vertex.removeEdge(edge)
Poistaa verkosta G solmusta vertex lähtevän kaaren edge. Kaareen liittyviä solmuja ei poisteta.
6) Edge vertex.getEdge(vertex2)
Palauttaa solmujen vertex ja vertex2 välillä olevan kaaren, tai null jollei kaarta ole.
7) boolean vertex.isAdjacent(vertex2)
Palauttaa toden, jos solmusta vertex on kaari solmuun vertex2, muuten epätoden.
Operaatioiden aikavaativuudet riippuvat toteutuksesta. Usein käytetyllä vieruslistatoteutuksella useimmat operaatiot ovat O(1), jotkut O(naapurien määrä). Esimerkiksi removeVertex poistaa myös kaaret, ja getEdge ja isAdjacent joutuvat tutkimaan
koko naapurien listan.
Ylläolevat operaatiot ovat suunnattujen verkkojen perusoperaatioita. Niillä pystymme esimerkiksi rakentamaan mielivaltaisen verkon. Käytännössä haluamme kuitenkin tallettaa
tietoa solmuihin ja kaariin, eli verkon solmujen ja kaarten nimiöiden käsittelyä varten tar-
2. SUUNNATUT VERKOT
38
vitaan myös operaatiot. Usein verkon käsittelyssä tarvitaan myös solmujen ja kaarten painoja ja/tai värejä.
Määritelmä 2-5: Verkon väritys-, paino-, nimiö- ja indeksi -operaatiot:
8)
9)
10)
11)
(Vertex|Edge).(set|get)Label(String)
(Vertex|Edge).(set|get)Weight(float)
(Vertex|Edge).(set|get)Color(int)
Vertex.(set|get)Index(int)
Läpikäynnit
TRAII 4.3.2011 UEF/tkt/SJ
Kun verkko on saatu rakennettua (tai se on saatu algoritmiin valmiina), tarvitaan yleensä
verkon solmujen tai kaarten läpikäyntiä. Joukkojen yhteydessä esittelimme for each -läpikäynnin sekä sen toteutuksen läpikäyntimuuttujien ja toiston avulla määritelmässä 1-27
(s. 34). Verkkojen läpikäyntiin käytetään samaa menetelmää. Tarvitsemme kaikkien solmujen läpikäynnin, joskus myös kaikkien kaarten läpikäynnin. Verkon rakenteen mukaisiin läpikäynteihin tarvitsemme myös yhden solmun kaikkien naapurien läpikäynnin,
sekä joskus myös yhden solmun kaikkien kaarten läpikäynnin. Solmuun johtavien kaarten
läpikäynti suunnatussa verkossa kuitenkin edellyttäisi erikoisjärjestelyjä toteutuksessa.
Määritelmä 2-6: Täydennämme siis edellä esitettyä suunnattujen verkkojen operaatioluetteloa läpikäynneillä:
12) Iterable<Vertex> G.vertices()
Palauttaa verkon G kaikkien solmujen läpikäynnin.
13) Iterable<Edge> G.edges()
Palauttaa verkon G kaikkien kaarien läpikäynnin.
14) Iterable<Vertex> vertex.neighbors()
Palauttaa solmun vertex kaikkien naapurisolmujen läpikäynnin.
15) Iterable<Edge> vertex.edges()
Palauttaa kaikkien solmusta vertex lähtevien kaarien läpikäynnin.
Nämä läpikäynnit on toteutettu Iterator-rajapinnan avulla, jota voi käyttää myös
suoraan:
16) Iterator<Vertex> G.vertexIterator()
Palauttaa kaikki solmut läpikäyvän iteraattorin.
17) Iterator<Edge> G.edgeIterator()
Palauttaa kaikki kaaret läpikäyvän iteraattorin.
18) Iterator<Vertex> vertex.neighborsIterator()
Palauttaa solmun kaikki naapurisolmut läpikäyvän iteraattorin.
19) Iterator<Edge> vertex.edgeIterator()
Palauttaa kaikki solmusta lähtevät kaaret läpikäyvän iteraattorin.
Iteraattoreita käytetään normaalisti hasNext(), next() ja remove() -operaatioilla.
2. SUUNNATUT VERKOT
39
Lisäksi kaaresta päästään käsiksi sen päihin operaatioilla:
20) Vertex edge.getEndPoint()
Palauttaa kaaren edge kohdesolmun.
21) Vertex edge.getStartPoint()
Palauttaa kaaren edge lähtösolmun.
22) Vertex edge.getEndPoint(Vertex vertex)
Palauttaa kaaren kaaren edge toisen pään (eri kuin vertex). Tämä on hyodyllinen
lähinnä seuraavassa luvussa esitettävälle suuntaamattomalle verkolle.
Esimerkki 2-7: Solmun v naapurien läpikäynti epämuodollisella algoritminotaatiolla:
for each w adjacent to v in G do
toimenpide solmulle w;
1
2
sama läpikäynti kääntäjälle kelpaavana foreach -toistona:
TRAII 4.3.2011 UEF/tkt/SJ
for (Vertex w : v.neighbors())
toimenpide solmulle w;
1
2
tai iteraattoria käyttäen:
Iterator<Vertex> i = v.neighborIterator();
while (i.hasNext()) {
Vertex w = i.next();
toimenpide solmulle w;
}
1
2
3
4
5
2.3 Lyhimmät polut
Suunnatun verkon kaaren paino voi kuvata vaikkapa kaaren lähtö- ja päätesolmujen välisen suhteen kustannusta. Verkon sanotaan tällöin olevan painotettu. Kaaren paino on
tavallisesti ei-negatiivinen luku, mutta joskus sallitaan myös negatiivinen paino. Jos verkon solmut kuvaavat esimerkiksi paikkakuntia, voi kaaren paino ilmaista vaikkapa lähtöja päätepaikkakunnan välisen matkan pituuden, matkan hinnan tai matka-ajan. Paino voidaan yhden kaaren asemesta määrätä kokonaiselle polullekin laskemalla polkua kuljettaessa kohdattavien kaarten painot yhteen.
Verkon solmusta a solmuun b johtavia polkuja on tavallisesti useita. Polut ovat
yleensä myös keskenään eri painoisia. Lyhimmän polun ongelmassa etsitään eri poluista
kustannuksiltaan pienintä. Ongelmasta on lukuisia erilaisia versioita. Tarkastellaan
ensiksi ongelmaa, jossa etsitään lyhimmät polut annetusta solmusta kaikkiin muihin solmuihin. Oletetaan merkintöjen yksinkertaistamiseksi, että solmuja on n ja kaaria e kappaletta, solmut numeroidaan 0, 1, 2, …, n–1, aloitussolmu on solmu numero 0 ja kaaren
paino saadaan selville funktiolla cost(i, j). Jos kaarta (i, j) ei verkossa lainkaan ole, on
cost(i, j) = ∞.
2. SUUNNATUT VERKOT
40
Dijkstran algoritmi
Seuraava Dijkstran algoritmi pitää taulukossa D yllä aloitussolmusta muihin solmuihin
johtavien polkujen pituuksia. Algoritmi alustaa taulukon D painofunktion mukaisilla
arvoilla, toisin sanoen tarkastelu aloitetaan aloitussolmusta lähtevistä yhden kaaren mittaisista poluista. Prioriteettijonoon viedään aluksi kaikki muut solmut paitsi aloitussolmu.
Prioriteettifunktiona käytetään taulukkoa D. Kun solmu poistetaan prioriteettijonosta, tarkistetaan jäljelle jääneiden solmujen prioriteetit tutkimalla, päästäisiinkö johonkin solmuun aiempaa edullisemmin, jos kuljettaisiin viimeksi poistetun solmun kautta. Sitä
mukaa kun solmut poistetaan prioriteettijonosta tullaan toisin sanoen tarkistaneeksi aloitussolmusta lähtevät polut, joilla on 1, 2, …, n–1 kaarta. Kuva 2-1 esittää vaihetta jossa
solmu 2 on jo aiemmin valittu, ja jossa seuraavana valitaan solmu 1 (paino 9). Solmun 1
naapureista, solmuun 3 löydetään nyt lyhyempi (9 + 2 = 11 < 12) polku, joten se päivitetään taulukkoon D.
1
2
TRAII 4.3.2011 UEF/tkt/SJ
2
5
4
5
3 10
5
5
4
0 10
7
14
0
4
1
2
3
4
5
4
Q: 3
5
D: 0 9 5 12 19 ∞
11
Kuva 2-1: Dijkstran algoritmin toinen valinta.
Algoritmi 2-8: Dijkstran algoritmi (kts. tarkempi toteutus: Dijkstra.java, rivillä 3 luotu
prioriteettijono vaatii tarkennusta toimiakseen):
float[] Dijkstra(DiGraph G) {
1
float[] D = new float[G.size()];
2
PriorityQueue<Vertex> Q = new PriorityQueue<Vertex>((Comparator)D);3
D[0] = 0.0;
4
for (int i = 1; i < n–1; i++) {
5
D[i] = cost(0, i);
6
Q.offer(Vertex_i);
// prioriteettina D[i]
7
}
8
while (! Q.isEmpty()) {
9
Vertex v = Q.poll();
// lähin jäljelläoleva solmu 10
for (Vertex w : v.neighbors())
11
D[w] := min(D[w], D[v]+cost(v,w))
12
}
13
return D;
14
}
15
Aloitussolmu voitaisiin Dijkstran algoritmia hieman muuttamalla (miten?) valita
vapaasti. Koska solmutkin voidaan tarvittaessa nimetä uudelleen, eivät merkinnällisistä
syistä tehdyt yksinkertaistukset tosiasiassa rajoita algoritmin yleisyyttä.
2. SUUNNATUT VERKOT
41
Dijkstran algoritmin oikeellisuus
Dijkstran algoritmi on ahne, koska solmuista valitaan aina hetkellisesti paras eli jo poistettuihin solmuihin nähden lähin vielä jäljellä oleva solmu. Algoritmi voidaan osoittaa
parhaan ratkaisun löytäväksi, jos kaarten painot ovat ei-negatiiviset.
Muistetaan etteivät painot ole negatiivisia. Ensimmäisen solmun valinta on varmasti oikein, sillä se on aloitussolmua lähin solmu (lyhyin kaari), eikä muuta kautta pääse
suorempaan. Ajatellaan tilannetta jossa joukko S solmuja on jo valittu ja ollaan juuri valitsemassa solmua w.
Voiko w:en olla suorempi polku jonkin valitsew
mattoman solmun x kautta (siten, että x ko. polun
p2
ensimmäinen S:n ulkopuolinen solmu)? Ei, sillä jos
0
p1
p3
p3
polku 0
x
w olisi lyhyempi kuin polku
p2
p1
0
w, olisi sen osapolku 0
x vielä lyhyempi,
p1
x
eli solmu x olisi valittu ennen solmua w. Siis, kun
S
solmu valitaan, ei siihen voi enää löytyä suorempaa
reittiä.
TRAII 4.3.2011 UEF/tkt/SJ
Dijkstran algoritmin aikavaativuus
Riveillä 5-8 muodostetaan n–1 alkiota sisältävä prioriteettijono, mihin kuluu aikaa
O(nlogn) sillä rivi 7 on O(logn). Algoritmin hankalin analysoitava on rivien 11-12 suoritus. Ensi silmäyksellä näyttää, että taulukon D sisällön tarkistaminen edellyttää koko
prioriteettijonon läpikäymistä, mihin kuluu aikaa O(n). Sen lisäksi on huomattava, että
alkion prioriteetin muuttuminen voi vaikuttaa prioriteettijonon sisältöön. Jokainen näistä
muutoksista vaatii aikaa O(logn), minkä takia rivit 9-13 voivat viedä aikaa jopa
O(n2 logn), koska toisto jatkuu prioriteettijonon tyhjenemiseen saakka. Koko algoritmin
aikavaativuus olisi siten O(n2 logn).
Tarkempi analyysi paljastaa, että rivejä 11-12 suoritettaessa riittää prioriteettijonoon vielä jääneistä solmuista käydä läpi vain juuri poistetun solmun w naapurit v, sillä
vain nämä naapurit voivat aiheuttaa muutoksia taulukon D sisältöön. Koska solmu poistetaan prioriteettijonosta vain kerran, käydään riviltä 9 alkavan toiston kuluessa kukin
naapurijoukoista läpi enintään kerran. Naapuruussuhteita on koko verkossa yhtä monta
kuin on kaariakin, joten rivien 9-13 aikavaativuus olisi O(nlogn + e), elleivät prioriteetit
suorituksen aikana lainkaan muuttuisi.
Valitettavasti prioriteetit voivat muuttua jopa jokaista kaarta tarkasteltaessa
(miksi?), joten suoritusaika on tosiasiassa O(nlogn + elogn). Tämäkin on yleensä
parempi kuin O(n2 logn), sillä useimmiten on n < e < n2.
Aikavaativuuteen O(nlogn + e) on mahdollista päästä toteuttamalla prioriteettijono
niin sanotun Fibonaccin kasan avulla, jolloin prioriteetin muuttumisen vaikutus suoritusaikaan on O(1). Fibonaccin kasaa ei kuitenkaan tällä kurssilla esitetä.
Kaikkien annetusta solmusta muihin solmuihin johtavien lyhimpien polkujen
pituuksien selvittäminen on sinänsä turhaa, jos halutaan saada selville kahden annetun
solmun välisen lyhimmän polun pituus. Tähän rajoitettuun ongelmaan ei silti kannata ryhtyä suunnittelemaan uutta ratkaisualgoritmia, sillä rajoitetussakin tapauksessa täytyy
pahimmillaan käydä läpi verkon kaikki solmut ja kaaret. Aikavaativuus on tällöin olennaisesti sama kuin Dijkstran algoritmin aikavaativuuskin.
2. SUUNNATUT VERKOT
42
Kaikki lyhimmät polut
Lyhimmän polun ongelman toisessa versiossa on etsittävä kaikki lyhimmät polut eli
lyhimmät polut verkon jokaisen solmuparin välillä. Tämä voidaan tehdä soveltamalla
Dijkstran algoritmia toistuvasti aloittaen vuorotellen kustakin verkon solmusta, jolloin
aikavaativuus on tehokkaimmillaankin O(n 2logn + ne). Jos verkossa on runsaasti kaaria,
toisin sanoen e ≈ n2, on aikavaativuus käytännössä O(n3), ja tehottomammin toteutettuna
peräti O(n3 logn).
Seuraavassa esimerkissä nähtävä Floydin algoritmi tuottaa kaikkien lyhimpien polkujen ongelmaan ratkaisun suoraviivaisemmin kuin Dijkstran algoritmi. Floydin algoritmi pitää n-alkioisen taulukon asemesta yllä n×n-matriisia D, jonka alkio D[i][j] sisältää
algoritmin suorituksen päättyessä lyhimmän solmusta i solmuun j johtavan polun pituuden. Suoritus etenee dynaamisesti (katso “Dynaaminen ratkaiseminen (ohjelmointi)”,
s. 62): solmut otetaan mukaan yksitellen ja tarkistetaan kaikille solmupareille, josko nyt
mukaan otetun solmun kautta löytyisi lyhyempi polku. Kukin osaratkaisu tallennetaan
välimatkataulukkoon josta niitä käytetään myöhemmin hyödyksi.
TRAII 4.3.2011 UEF/tkt/SJ
Algoritmi 2-9: Floyd:
1
float[][] Floyd(DiGraph G) {
int n = G.size();
2
float[][] D = new float[n][n];
3
for (int i = 0; i < n–1; i++)
// matriisin alustus
4
for (int j = 1; j < n–1; j++)
5
D[i][j] = cost(i, j);
6
for (int i = 0; i < n–1; i++)
7
D[i][i] = 0.0;
8
for (int k = 0; k < n–1; k++)
// solmu k mukaan tarkasteluun9
for (int i = 0; i < n–1; i++)
10
for (int j = 1; j < n–1; j++)
11
if (D[i][k]+D[k][j] < D[i][j]) // onko i→k→j < i→j ? 12
D[i][j] = D[i][k] + D[k][j];
13
return D;
14
}
15
Rivejä 4-8 ei luonnollisesti tarvita, jos verkko saadaan valmiiksi painojen matriisina.
Algoritmin aikavaativuus on selvästi O(n3). Floydin algoritmin oikeellisuus on helppo
todentaa induktiolla jo tarkasteltujen solmujen joukon suhteen.
Suoraviivaisuutensa vuoksi Floydin algoritmi on Dijkstran algoritmin sovellusta
suositeltavampi silloinkin, kun verkossa on suhteellisen vähän kaaria. Käytännössä
Dijkstran algoritmi on ylivoimainen vasta hyvin suuria verkkoja käsiteltäessä. Onhan esimerkiksi log 21024 = 10, joten vielä tuhatsolmuisella verkollakaan eivät näiden kahden
algoritmin aikavaativuudet poikkea toisistaan merkittävästi.
Sekä Dijkstran että Floydin algoritmit laskevat vain edullisimman kustannuksen,
mutteivät tuota edullisinta polkua. Molemmat algoritmit voidaan helposti täydentää siten,
että lyhimmän polun pituuden lisäksi myös itse polku voidaan saada selville. Ylläpidetään
taulukkoa jonka alkiot osoittavat kunkin solmun edeltäjäsolmun matkalla kohdesolmuun.
2. SUUNNATUT VERKOT
43
Polkujen olemassaolo
Joskus riittää polun pituuden asemesta selvittää, onko ylipäätään olemassa polkua solmusta i solmuun j. Tämä käy luonnollisesti päinsä esimerkiksi Floydin algoritmilla, joka
antaa lyhimmän polun pituudeksi äärettömän, ellei polkua ole olemassakaan, toisin
sanoen äärellinen pituus ilmaisee implisiittisesti polun olemassaolon. Pelkkä polkujen
olemassaolo saadaan selville myös seuraavaksi esitettävää Warshallin algoritmia käyttäen. Algoritmi on muunnelma Floydin algoritmista ja se tuottaa n×n totuusarvomatriisin
A, jonka alkion A[i][j] arvo true merkitsee, että solmusta i on polku solmuun j. Arvo false
ilmaisee vastaavasti, ettei kyseistä polkua ole.
TRAII 4.3.2011 UEF/tkt/SJ
Algoritmi 2-10: Warshall
boolean[][] Warshall(boolean[][] G) {
int n = G.size();
boolean[][] D = new boolean[n][n];
for (int i = 0; i < n–1; i++)
for (int j = 1; j < n–1; j++)
D[i][j] = G[i][j];
// isAdjacent()
for (int i = 0; i < n–1; i++)
D[i][i] = true;
for (int k = 0; k < n–1; k++)
for (int i = 0; i < n–1; i++)
for (int j = 1; j < n–1; j++)
if (! D[i][j])
D[i][j] = D[i][k] && D[k][j];
return D;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Näin muodostettua matriisia sanotaan verkon yhteysmatriisiksi, koska se ilmaisee, minkä
solmuparien välillä on suora yhteys eli kaari. Algoritmi kehittää yhteysmatriisin matriisiksi, josta käytetään nimitystä verkon transitiivinen sulkeuma. Transitiivisesta sulkeumasta nähdään suoraan, onko solmuparin välillä polku vai ei. Warshallin algoritmin
aikavaativuus on, samoin kuin Floydin algoritminkin, selvästi kuutiollinen verkon solmujen lukumäärään nähden.
2.4 Verkon läpikäynti
Verkko-ongelman ratkaiseminen edellyttää usein verkon kaikkien solmujen tai kaarten
tarkastelemista. Tämä sujuu vaivattomimmin käyttämällä kohdassa 2.2 esiteltyjä läpikäyntioperaatioita. Näiden operaatioiden puutteeksi voidaan katsoa se, ettei alkioiden
läpikäyntijärjestystä voida kiinnittää etukäteen, vaan alkiot käsitellään jossakin ennalta
arvaamattomassa järjestyksessä. Edes saman joukon kaksi peräkkäistä läpikäyntiä ei välttämättä käy alkioita läpi samassa järjestyksessä.
Silloin kun esimerkiksi verkon solmut halutaan käsitellä nimenomaan tietyssä järjestyksessä, voidaan soveltaa jotakin tunnetuista verkon läpikäyntialgoritmeista, joita
TRAII 4.3.2011 UEF/tkt/SJ
2. SUUNNATUT VERKOT
44
kutsutaan etsintä- tai hakualgoritmeiksi. Nämä algoritmit rinnastuvat luonnostaan joihinkin TRAI:ssä esitellyistä puun läpikäyntialgoritmeista. Useimmin esiintyvä on verkon
syvyyssuuntainen etsintä, joka puuhun sovellettuna tuottaa solmujen esijärjestyksen (tai
jälkijärjestyksen, riippuen käsitelläänkö solmu ennen vai jälkeen rekursiokutsujen). Toinen yleisesti esiintyvä algoritmi on verkon leveyssuuntainen etsintä, joka puolestaan vastaa verkon tasoittaista läpikäyntiä. Tarkastellaan seuraavaksi syvyyssuuntaista etsintää.
Syvyyssuuntaisen etsinnän alkaessa oletetaan, että verkon G kaikki solmut on merkitty käsittelemättömiksi vaikkapa värittämällä solmut valkoisiksi. Etsinnän aluksi valitaan verkon jokin solmu v aloitussolmuksi ja merkitään tämä solmu käsitellyksi värittämällä v harmaaksi. Sen jälkeen valitaan jokin v:n vielä käsittelemätön naapuri, johon
sovelletaan syvyyssuuntaista etsintää rekursiivisesti. Rekursion päättyessä verkkoon jää
ehkä vielä käsittelemättömiä solmuja, joista taas valitaan jokin lähtösolmuksi ja etsintää
jatketaan samaan tapaan, kunnes vihdoin kaikki solmut on käsitelty.
Etsintää kutsutaan syvyyssuuntaiseksi siitä syystä, että etsinnän aikana edetään
mahdollisimman syvälle siihen suuntaan, mikä on valittu. Kun syvemmälle ei enää
päästä, palataan hieman taaksepäin ja jatketaan johonkin vielä käsittelemättömään suuntaan. Vähän kerrassaan koko verkko tulee näin läpikäydyksi. Etsintäalgoritmi on miltei
sama, käydäänpä läpi solmuja tai kaaria — kaarten tapauksessa käsitellyksi merkitään
solmun asemesta kaari.
Algoritmi 2-11: Syvyyssuuntaisen läpikäynnin käynnistys:
void dfsstart(DiGraph g) {
for (Vertex v : g.vertices())
// kaikki solmut valkoisiksi
v.setColor(DiGraph.WHITE);
for (Vertex v : g.vertices())
// aloitus vielä käymättömistä solm.
if (v.getColor() == DiGraph.WHITE)
dfs(v);
}
1
2
3
4
5
6
7
Väritysoperaatioiden merkitys on tässä ilmeinen. Jollei verkkoon jostakin syystä saada
kohdistaa väritysoperaatioita, voidaan solmujen värit pitää yllä erillisessä taulukossa, joka
välitetään rekursiiviselle dfs-algoritmille parametrina. Monimutkaisemmassa tapauksessa
läpikäyntejä voi yhtaikaa olla käynnissä useita, jolloin kutakin läpikäyntiä varten tarvitaan oma väritys. Silloin on kätevintä pitää eri läpikäyntien väritykset eri taulukoissa.
Algoritmi 2-12: Varsinainen rekursiivinen syvyyssuuntainen läpikäynti:
void dfs(Vertex start) {
// toimenpide solmulle start (esijärjestys)
start.setColor(DiGraph.GRAY);
for (Vertex vertex : start.neighbours())
if (vertex.getColor() == DiGraph.WHITE)
dfs(vertex);
}
1
2
3
// vielä käymättömät
// naapurit
4
5
6
7
Se, mitä käsittelyvuorossa olevalle solmulle on tarkoitus tehdä, voidaan sisällyttää tähän
algoritmiin, jolloin algoritmi joudutaan kirjoittamaan uudelleen kutakin sovellusta varten.
2. SUUNNATUT VERKOT
45
Vaihtoehtoisia menettelyjä ovat solmun käsittelyalgoritmin välittäminen parametrina ja
läpikäyntioperaatioiden toteuttaminen syvyyssuuntaista etsintää tukevina.
Syvyyssuuntaisen etsinnän aikavaativuus ilman varsinaista solmun käsittelyä on
O(e) silloin, kun n ≤ e, sillä dfs suoritetaan kerran kullekin solmulle ja naapuruussuhteita
on verkossa kaikkiaan e kappaletta. Joillakin verkoilla, kuten tähdillä, puilla ja listoilla,
on tosin n > e, joten varminta on ilmaista aikavaativuus muodossa O(n + e).
Kun syvyyssuuntaisen etsinnän kuluessa poimitaan erilleen ne kaaret, jotka johtavat
vielä käsittelemättömiin solmuihin, muodostuu näistä kaarista verkon virittävä metsä eli
joukko suunnattuja puita, jotka sisältävät verkon kaikki solmut. Virittävän metsän kaaria
sanotaan puukaariksi. Yleisemmin, syvyyssuuntaisessa haussa kaaret luokitellaan seuraavasti
TRAII 4.3.2011 UEF/tkt/SJ
1)
2)
3)
4)
puukaaria ovat ne kaaret, jotka johtavat vielä käsittelemättömiin solmuihin,
paluukaaria, jotka johtavat solmusta esi-isään (virittävässä puussa),
etenemiskaaria, jotka johtavat solmusta jälkeläiseen (virittävässä puussa), tai
ristikkäiskaaria, jotka eivät johda esi-isään eivätkä jälkeläiseen (virittävässä
puussa).
Verkon virittävä metsä ei välttämättä ole yksiymmärteinen, sillä aloittamalla etsintä verkon eri solmuista voidaan usein saada keskenään erilaisia virittäviä metsiä.
2.5 Muita verkko-ongelmia
Erilaisia verkkoja:
Puu
Verkon kehättömyyden selvittäminen
DAG
Kehäinen
1
1
Silloin tällöin joudutaan selvittämään, onko suunnattu
verkko kehätön. Tämä ongelma ratkeaa helposti syvyyssuuntaisen etsinnän myötä: jos verkosta löytyy yksikin
2
2
4
paluukaari, on verkossa kehä, muuten verkko on kehätön.
Paluukaarten tunnistaminen ei valitettavasti onnistu suoraan, vaan tarvitaan jokin apuneuvo. Tällaiseksi sopii väri3
4
3
tys: käytetään läpikäynnissä kahden värin asemesta kolmea väriä siten, että aluksi kaikki solmut ovat valkoisia ja kun solmu ensimmäisen kerran
kohdataan, se väritetään harmaaksi. dfs-algoritmin päätteeksi (kun naapurit on läpikäyty
2. SUUNNATUT VERKOT
46
ja rekursioista palattu) solmu väritetään mustaksi. Jos etsinnän aikana kohdataan harmaa
solmu, on löydetty paluukaari, toisin sanoen kehä (oheisen kuvan alaosa). Mustan solmun
kohtaaminen taas merkitsee joko etenemis- tai ristikkäiskaaren löytämistä (ylempi
tilanne). Mikäli nämäkin kaksi tapausta täytyy pystyä erottamaan toisistaan, voidaan solmut numeroida käsittelyjärjestyksessä, jolloin etenemiskaari johtaa lähtösolmusta suurinumeroisempaan ja ristikkäiskaari pieninumeroisempaan solmuun.
Osittainen (topologinen) lajittelu (*)
Verkon avulla voidaan kuvata monimutkaisempia suhteita kuin puita käyttäen on mahdollista. Verkkojen ja puiden välimuoto on suunnattu kehätön verkko (Directed Acyclic
Graph, DAG) , jonka avulla voidaan esimerkiksi kuvata osittainen järjestys. Relaatio R
joukossa S on osittainen järjestys, jos
1) a R a on epätosi kaikille a ∈ S ja
2) a R b & b R c ⇒ a R c kaikille a, b, c ∈ S (transitiivisuus).
TRAII 4.3.2011 UEF/tkt/SJ
Ehdoista ensimmäinen on irrefleksiivisyysehto, jälkimmäinen puolestaan transitiivisuusehto. Osittaisen järjestyksen ei tarvitse olla määritelty kaikkien alkioparien välillä.
Esimerkki 2-13: Relaatio ⊂ (aito osajoukko) tuottaa osittaisen järjestyksen joukon
S = {1, 2, 3} potenssijoukkoon P(S) = {∅, {1}, {2}, {3}, {1,2}, {1,3}, {2,3},
{1,2,3}}.
Suunnatun kehättömän verkon kuvaaman osittaisen järjestyksen mukainen topologinen
lajittelu voidaan tehdä helposti syvyyssuuntaisella haulla. Kyseessä on itseasiassa käänteinen jälkijärjestys. Rekursiivinen dfs muokataan (kts. DiGraphEsimerkki.topoSort())
siten, että kunkin solmun kohdalla, syvyyssuuntaisen haun päättyessä ko. solmu viedään
tuloslistan alkuun. Tällöin kaikki ko. solmun seuraajat (osittaisessa järjestyksessä) on jo
viety listaan, joten ne tulevat järjestykseen tämän solmun jälkeen. Algoritmin rekursio
haluttaessa voidaan poistaa käyttäen samaa tuloslistaa pakkana. Kohdattaessa solmu
ensimmäisen kerran, viedään se pakan pinnalle. Dfs-algoritmin päätteeksi solmu siirretään pakan pinnalta pohjalle. Etsinnän päätyttyä pakka sisältää pohjalta lukien solmut
topologisessa järjestyksessä.
Vahvasti yhtenäiset komponentit (*)
Suunnatun verkon vahvasti yhtenäinen komponentti on sellainen verkon solmujen maksimaalinen osajoukko, jonka jokaisesta solmusta on polku osajoukon kaikkiin muihin solmuihin. Koko verkko on vahvasti yhtenäinen, jos verkko koostuu yhdestä vahvasti yhtenäisestä komponentista, toisin sanoen jos verkon jokaisesta solmusta on polku verkon
kaikkiin muihin solmuihin. Verkon jokainen solmu kuuluu aina täsmälleen yhteen vahvasti yhtenäiseen komponenttiin, mutta osa verkon kaarista ei välttämättä sisälly mihinkään vahvasti yhtenäiseen komponenttiin. Joissain sovelluksissa verkko voidaan pelkistää
korvaamalla jokainen vahvasti yhtenäinen komponentti yhdellä solmulla, jolloin pelkistetyn verkon kaariksi jäävät alkuperäisen verkon vahvasti yhtenäisiin komponentteihin
sisältymättömät kaaret. Tällä voidaan joissakin tapauksissa pienentää verkkoa huomattavasti, jolloin NP-täydelliset ongelmat ovat askelta helpommin ratkeavia.
2. SUUNNATUT VERKOT
47
Suunnatun verkon vahvasti yhtenäiset komponentit saadaan selville seuraavalla
algoritmilla [DiGraphEsimerkki.vahvastiYhtenaisetKomponentit()]:
TRAII 4.3.2011 UEF/tkt/SJ
1) Käydään verkon G solmut
2
5
6
läpi
syvyyssuuntaisella
etsinnällä ja numeroidaan
1
solmut sitä mukaa kun niihin liittyvät rekursiiviset
kutsut päättyvät (jälkijär3
jestys). Haun aloitusjärjes7
8
tyksellä
ei
ole
väliä.Oheisessa kuvassa
4
haku alkoi ensin alimmasta
solmusta (numeroksi 4) ja sitten sen vasemmalla yläpuolella olevasta solmusta
(numeroksi 8).
2) Muodostetaan uusi suun2
5
6
nattu verkko Gr kääntämällä G:n jokaisen kaaren
1
suunta päinvastaiseksi.
7
3
8
4
3) Käydään verkon Gr solmut läpi syvyyssuuntaisella etsinnällä aloittaen
aina
suurinumeroisimmasta jäljellä olevasta solmusta. Oheisessa kuvassa
ensin aloitettiin solmusta
jonka numero on 8, sitten
4, viimeiseksi solmusta
numero 3. Naapurien läpikäyntijärjestyksellä ei ole
väliä.
6
2
5
1
7
3
8
4
Näin muodostuu virittävä metsä, jonka kukin puu koostuu G:n yhden vahvasti yhtenäisen
komponentin solmuista (muttei G:n kaarista). Tarvittaessa kukin alkuperäisiä kaaria käyttävä puu voidaan muodostaa syvyyssuuntaisella aloittaen kunkin komponentin mistä
tahansa solmusta. Solmujen numeroinnissa ja numerojärjestyksessä käsittelemisessä voidaan hyödyntää esimerkiksi prioriteettijonoa.
2. SUUNNATUT VERKOT
Maksimaalinen virtaus (*)
48
s
d
4
7
10
4
TRAII 4.3.2011 UEF/tkt/SJ
20
12
16
1
3
Huom: Tämä algoritmi käsitellään
suuntaamattoman verkon maksimaalisen sovituksen (kohta 3.5
13
(s. 59)) jälkeen.
9
Ajatellaan verkon solmujen
2 14 4
olevan risteyksiä ja kaarten yhteyksiä. Reaalimaailmassa kyseessä voi olla tietoliikenne, autoliikenne, tavarakapasiteetti,
kaasuputki, sähkönsiirtoverkko, tms. Kaarilla on rajallinen (virtaus) kapasiteetti. Tehtävänä on etsiä maksimaalinen virtaus (arvo, käytettävät kaaret) annetusta lähdesolmusta s
(source) annettuun kohdesolmuun d (sink).
Ongelmaa on suhteellisen helppo lähteä purkamaan käsin lisäämällä virtausta eri
reittejä. Algoritminen ratkaisu lähtee liikkeelle samaan tapaan, mutta optimaalisen ratkaisun löytämiseksi tarvitaan vielä pieni lisäviritys.
Määritellään kaaren jäännöskapasiteetti (residual capacity) — virtauksen lisäyspotentiaali. Jäännösverkko koostuu kaarista joilla on jäännöskapasiteettia. Lisäkapasiteettia
verkkoon voidaan etsiä tämän jäännösverkon puitteissa. Virtausta täydentävä polku (laajentava, augmenting path) on jäännösverkossa solmusta s solmuun d kulkeva polku (vrt.
kaksijakoisen verkon sovitus, s. 60). Täydentävän polun kapasiteetti on minimi polun
kaarien jäännöskapasiteeteista. Aiemmin löydettyä virtaa täydennetään täydentävällä
polulla (kukin kaari polun kapasiteetilla) ja vastaavasti jäännösverkossa polun kaarten
kapasiteettia vähennetään löydetyn polun kapasiteetilla. Itse algoritmi vain toistaa täydentävän polun hakua ja lisäämistä virtaukseen kunnes laajentavaa polkua ei löydy. Täydentävän polun haku voidaan tehdä syvyys- tai leveyssuuntaisella haulla jäännösverkossa
huomioiden painot (edetään vain kaaria joiden kapasiteetti on positiivinen).
Valitettavasti edelläkuvattu yksinkertainen (ahne) algoritmi ei kuitenkaan aina
toimi! Algoritmi saattaa valita (huonon) polun joka estää myöhemmin täydentävän polun
löytymisen. Ongelma korjaantuu lisäämällä mahdollisuus peruttaa jo tehty virtauksen
kasvattaminen kokonaan tai osittain (Ford-Fulkerson).
Jotta täydentävän polun etsimisessä voitaisiin peruuttaa jo valittua kapasiteettia,
jäännösverkkoon merkitään kutakin lisättyä virtausta kohti vastaava määrä jäännöskapasiteettia vastakkaiseen suuntaan. Nyt jokin myöhempi täydentävä polku voi vastakkaiseen suuntaan etenemällä vähentää ko. kaaren kapasiteetin käyttöä ja löytää uuden täydentävän polun, kts. esimerkki jäljempänä ja FordFulkerson.java.
Valitettavasti aikavaativuudeksi tulee (huonolla tuurilla) (kokonaislukuarvoisilla
painoilla) O(E|f *|), missä f * on suurin löydetty virtaus (tarkemmin virtauksen ja pienimmän kaaren kapasiteetin suhde). Aikavaativuus voidaan parantaa aikaan O(VE2) kun käytetään täydentävän polun leveyssuuntaista hakua, eli pyritään löytämään mahdollisimman
lyhyt täydentävä polku (Edmonds-Karp -algoritmi).
Esimerkki 2-14: FordFulkerson toimintaesimerkki:
Haku voi edetä muutenkin, tässä dfs solmujen numerojärjestyksessä. Kussakin kaaressa on merkittynä virta/jäännöskapasiteetti ennen merkityn polun hakua. Vastakaarien kapasiteetti on merkitty vain tarpeen mukaan.
2. SUUNNATUT VERKOT
0/12
1
0/4
0/
13
3
0/20
d
0/
4
0/16
0/7
s
0/10
Polku s-12-4-3-d,
kapasiteetti 7
49
9
0/
2
4
0/14
0/12
0/
7/13
4
d
9
0/
2
4
7/7
0/12
0/
13
d
9
10/0
0/14
0/
13
3
8
1/
2
11/3
4
7/13
d
0
1/11
1
7/0
11/5
4
4/
10/4
s
7/13
0/
2
Polku s-13-d,
kapasiteett
i5
3
1
1
3/
10/6
7/0
s
10/0
Polku s-13-2-4-d,
kapasiteetti 1
0/14
TRAII 4.3.2011 UEF/tkt/SJ
13
3
0/
1
7/0
7/9
7/3
s
0/11
Polku s-12-4-d,
kapasiteetti
3
2. SUUNNATUT VERKOT
3
d
4/
0
7/0
12/8
8
13
1/
2
4
11/3
12/0
1
0/
3
18/2
d
1
0
16/0
7/0
s
0/
0/8
4/
4/
0
7/0
0/8
Huomaa:
6/
kaarta
7
8
1/
2→3
kaarta ei
2
4
alkuperäi11/3
sessä
verkossa ole, mutta nyt lisätään vastavirtaa, eli vähennetään alkuperäistä virtausta.
Lopu
19/1
12/0
16/0
d
s
1
3
llinen maksimaalinen
virtaus on
täydentävien polku7/
9
6
jen kapasi0/
teettien
2
4
summa.
11/3
Uutta laajentavaa polkua ei enää löydy: Polun haku törmää solmuihin 4 (josta ei pääse mihinkään)
ja solmupariin 1&2 joista ei pääse kuin solmuun 4. Toisaalta takaperin ajatellen, ainoa
käyttämätön kapasiteetti (1) solmuun d olisi solmusta 3, mutta solmuun 3 ei johda enää
lisäkapasiteettia. Kuvaan katkoviivalla merkityn leikkauksen kohdalta ei enää voi lisätä
virtausta kohdesolmuun päin. Virtaus on siis maksimaalinen! Virtausverkon oikeellisuuden voi tarkistaa laskemalla kunkin solmun sisääntulevan ja ulosmenevän virtauksen,
sekä vertaamalla kunkin kaaren virtausta verkon alkuperäiseen kapasiteettiin.
Ford-Fulkerson -algoritmia voi myös käyttää maksimaaliseen sovitukseen (vrt.
kaksijakoisen verkon sovitus, s. 59) lisäämällä kaksijakoiseen verkkoon lähde- ja kohdesolmut, sekä kaaret lähtösolmusta ensimmäiseen joukkoon ja toisesta joukosta kohdesolmuun.
4/6
TRAII 4.3.2011 UEF/tkt/SJ
Polku s-23-d,
kapasiteetti
1
6/6
1
10/0
Huomaa:
kaaren
2→1
kapasiteetti
14
(alkuperäinen
4+vastavirta 10).
16/0
4/6
s
0/14
Polku s-21-3-d,
kapasiteetti
6.
50
Luku 3
TRAII 4.3.2011 UEF/tkt/SJ
Suuntaamattomat verkot
Suuntaamaton verkko eroaa suunnatusta verkosta vain siinä, ettei verkon kaarilla ole
suuntaa (tai itseasiassa kaikilla kaarilla on molemmat suunnat). Suunnan puuttumisen
ansiosta suuntaamattomalla verkolla voidaan kuvata solmujen välisiä symmetrisiä suhteita. Tässä luvussa tarkastellaan lähinnä muutamia suuntaamattomien verkkojen algoritmeja. Siirrettäessä algoritmeja suunnatusta verkosta suuntaamattomaan, on meidän erityisesti varmistettava, ettemme vahingossa palaa samaa kaarta takaisin.
3.1 Määritelmiä
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. Suunnatusta verkosta poiketen, suuntaamattoman verkon kaaren ei yleensä sallita johtavan solmusta itseensä ja kahden eri solmun välillä saa olla vain yksi kaari.
Abstraktin tietotyypin suuntamaton verkko operaatiot ovat samat
kuin suunnatun verkon operaatiot. Käytämme perusmuotoista nimeä
Graph nimenomaan suuntaamattoman verkon kanssa. Kun naapuruussuhteet ovat symmetriset, Edge.getStartPoint() ja Edge.getEndPoint()
toimivat "satunnaisesti", eli palauttavat jommankumman pään. Suunnatulle verkolle käyttökelpoinen operaatio onkin Edge.getEndPoint(vertex), joka palauttaa "sen toisen" solmun.
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 sol51
3. SUUNTAAMATTOMAT VERKOT
52
3.2 Pienin virittävä puu
Olkoon G yhtenäinen painotettu verkko. Verkon G
virittävä puu on vapaa puu, jonka solmut ovat samat
kuin G:n solmut ja kaaret ovat G:n kaaria. Virittävän
puun paino on puun kaarten painojen summa. Minimipainoinen virittävä puu on verkon kaikista virittävistä puista pienipainoisin. Verkolla
voi olla useita erilaisia pienimpiä virittäviä puita, mutta niissä kaikissa on yhtä monta
kaarta. Jollei verkko ole yhtenäinen, puita on useita ja kyseessä on virittävä metsä.
Pienin virittävä puu ei useinkaan sisällä verkon kaikkien solmuparien välisiä lyhimpiä polkuja. Sen sijaan pienin virittävä puu ilmaisee, miten koko verkko saadaan yhtenäiseksi mahdollisimman pienin kustannuksin. Tyypillinen esimerkki pienimmän virittävän
puun sovelluksesta on tietoliikenneverkko, jossa tietoliikenneyhteyksien kustannukset
ovat kaarten painoina: etsimällä pienin virittävä puu saadaan selville edullisin tapa kytkeä
verkon solmut yhtenäiseksi kehättömäksi kokonaisuudeksi. Tarkastellaan kahta erilaista
pienimmän virittävän puun muodostavaa algoritmia, ensiksi Primin algoritmia ja sen jälkeen Kruskalin algoritmia.
Primin algoritmi
1 1 5
3
13
2
9
14
3 20
4
4
0 16
11
Algoritmi lähtee liikkeelle mistä
tahansa solmusta ja vie virittävään
puuhun pienipainoisimman aloitussolmuun liittyvistä kaarista. Näin
saadaan mukaan jokin toinen verkon
solmu v2, joka ei vielä sisältynyt
virittävään puuhun. Kun jo mukaan
otetut solmut on merkitty mustiksi ja
18
10
TRAII 4.3.2011 UEF/tkt/SJ
mua. Suuntaamaton verkko — lyhyemmin verkko, jos on selvää, että tarkoitetaan suuntaamatonta verkkoa — on kehäinen, jos siinä on ainakin yksi kehä, muuten kehätön.
Verkon G = (V, E) aliverkoksi sanotaan verkkoa G' = (V', E'), missä V' ⊆ V ja E' sisältää vain sellaisia E:n kaaria, joiden molemmat päätesolmut
kuuluvat joukkoon V'. Mikäli E' sisältää kaikki sellaiset E:n kaaret, joiden molemmat päätesolmut
kuuluvat joukkoon V', sanotaan verkkoa G' solmujoukon V' indusoimaksi aliverkoksi.
Verkko on yhtenäinen, jos sen minkä tahansa kahden solmun välillä on polku. Ellei
verkko ole yhtenäinen, se on epäyhtenäinen. Verkon G yhtenäinen komponentti on jokin
G:n maksimaalinen yhtenäinen indusoitu aliverkko. Vahvasti yhtenäisyyden käsitettä ei
suuntaamattomille verkoille lainkaan määritellä erikseen.
Yhtenäistä kehätöntä verkkoa sanotaan myös vapaaksi puuksi. Vapaasta puusta saadaan (yleinen järjestämätön) puu kiinnittämällä mikä tahansa solmuista juureksi. Jos
vapaassa puussa on n solmua, niin kaaria on n–1 kappaletta, ja jos vapaaseen puuhun lisätään kaari, muodostuu kehäinen verkko.
5
3. SUUNTAAMATTOMAT VERKOT
53
seuraavat kaaret valitaan rivien 13 (pienipainoisin) ja 16 (ei puun sisäinen) ehdot täyttäen,
saadaan listaan mst virittävän puun kaaret yksitellen. Rivin 16 ehdon ansiosta nämä kaaret
eivät näet voi muodostaa kehää, ja kun kaikki verkon solmut on saavutettu, on listassa mst
kaikkiaan n–1 kaarta. Saatu puu voidaan todistaa pienimmäksi kaikista mahdollisista G:n
virittävistä puista.
18
13
TRAII 4.3.2011 UEF/tkt/SJ
2
9
14
3 20
5
4
1 1 5
3
11
0 16
10
Algoritmi 3-1: Primin algoritmi
tuottaa yhtenäisen verkon G
pienimmän virittävän puun
kaarten listan seuraavalla
tavalla:
4
1
LinkedList<Edge> MSTPrim(Graph G) {
LinkedList<Edge> mst = new LinkedList<Edge>();
2
AssignablePriorityQueue<Edge> Q =
3
new AssignablePriorityQueue<Edge>();
4
int n = G.size();
5
varita(G, Graph.WHITE);
// kaikki valkoisiksi
6
Vertex v = G.firstVertex();
// aloitetaan mistä tahansa solmusta
7
v.setColor(Graph.BLACK);
8
for (Edge e : v.edges())
// lähtösolmun kaaret prioriteettijonoon 9
Q.add(e, e.getWeight());
10
int i = 1;
11
while (i < n) {
// kunnes kaikki solmut otettu mukaan
12
Edge e = Q.poll();
13
Vertex v1 = e.getStartPoint();
14
Vertex v2 = e.getEndPoint();
15
if (v1.getColor() != Graph.BLACK ||
// kaaren toinen pää
16
v2.getColor() != Graph.BLACK) { // valkoinen
17
if (v2.getColor() == Graph.BLACK)
18
v2 = v1;
// vaihdetaan v2 siihen valkoiseen 19
mst.add(e);
// kaari listaan
20
v2.setColor(Graph.BLACK); // solmu merk. mukaan otetuksi21
i++;
22
for (Edge e2 : v2.edges())
// kaaret prioriteettijonoon
23
Q.add(e2.getWeight(), e2);
24
} // if
25
} // while
26
return mst;
27
}
28
Primin algoritmissa voidaan aloittaa mistä tahansa solmusta, sillä kaikki solmut ovat välttämättä mukana virittävässä puussa. Voitaisiin myös aloittaa suoraan verkon pienipainoi-
3. SUUNTAAMATTOMAT VERKOT
54
simmasta kaaresta, jolloin virittävään puuhun vietäisiin ensimmäisellä kerralla kaksi solmua ja myöhemmin vain yksi solmu kerrallaan.
Lähtösolmun kaarien vieminen prioriteettijonoon vie (rivi 10) aikaa enintään O(n logn)
(jos lähtösolmusta on kaari kaikkiin muihin solmuihin). Keskitytään riviltä 12 alkavaan
toistoon: kullakin kierroksella viedään virittävään puuhun yksi uusi solmu, joten toistokertoja on kaikkiaan O(n). Kullakin kierroksella listaan lisäys (rivi 20) on mahdollista
tehdä vakioajassa ja pienimmän valinta (rivi 13) tehdään prioriteettijonolla ajassa
O(loge). Prioriteettijono muuttuu (kaaria lisätään ja poistetaan) jatkuvasti, mutta prioriteetit eivät tälläkertaa muutu. Toiston aikana kaikkien solmujen kaaret (eli kaikki kaaret)
viedään prioriteettijonoon (enintään) kerran. Samoin prioriteettijonosta poistetaan (jopa)
kaikki kaaret. Näin ollen aikavaativuus on O(e loge). Pahimmassa tapauksessa (täydellinen verkko) e = O(n2), joten täydellisen verkon tapauksessa aikavaativuus on O(n2 logn)
Jos kaaria on paljon, pääosa ajasta käytetään prioriteettijonon ylläpitämiseen "turhissa" poistoissa – poistetaan jonosta kaari, jonka molemmat päät ovat jo puussa. Prioriteettijonon logaritmisen lisäkertoimen voi välttää käymällä jokaisessa vaiheessa kaikki
solmut suosiolla läpi ilman prioritettijonoa. Käytetään prioriteettijonon sijaan kahta taulukkoa. closest[i] sisältää solmun joka on lähimpänä solmua i vielä kytkemättömistä solmuista. lowcost[i] sisältää ko. kaaren kustannuksen. Kussakin vaiheessa käydään lowcost
läpi pienimmän etsimiseksi ja samalla päivitetään taulukoita. Näin saamme aikavaativuudeksi O(n2).
18
Kruskalin algoritmi
4
11
Primin algoritmin keskeinen idea on
16
5
1 1 5
3 20
kasvattaa virittävä puu oksa oksalta 0
3
lopulliseen muotoonsa liittämällä
puuhun uusia solmuja sitä mukaa kun
13
solmut kyetään jo muodostetusta
9
puusta katsoen edullisimmin saavut2
tamaan. Miltei päinvastainen lähesty14 4
mistapa, jota seuraavaksi esitettävä Kruskalin algoritmi noudattaa, on yhdistellä verkon
solmuja yhtenäisiksi kehättömiksi komponenteiksi, jotka kaikki lopulta liittyvät yhteen
virittäväksi puuksi. Kun yhdistäminen aloitetaan kevyimmistä kaarista, saadaan pienin
virittävä puu.
Kruskalin algoritmi 3-2 etsii verkon G = (V, E) pienimmän virittävän puun lähtemällä liikkeelle verkosta T = (V, ∅), jossa jokainen solmu on oma yhtenäinen komponenttinsa. Kun verkkoon lisätään pienipainoisin niistä E:n kaarista, jotka yhdistävät kaksi
erillistä komponenttia, vähenee erillisten komponenttien lukumäärä yhdellä. Muodostunut uusi komponentti on puu, koska kaaren lisääminen kahden erillisen komponentin
välille ei voi tuottaa kehää. Komponenttien yhdistämistä jatketaan samaan tapaan valitsemalla yhdistäväksi kaareksi aina pienipainoisin niistä E:n kaarista, joiden päätesolmut
kuuluvat eri komponentteihin, kunnes komponentteja on jäljellä enää yksi. Tämä viimeinen komponentti on virittävä puu, koska itse asiassa jokainen muodostetuista komponenteista on G:n jonkin aliverkon virittävä puu — vieläpä pienin virittävä puu.
10
TRAII 4.3.2011 UEF/tkt/SJ
Aikavaativuus
3. SUUNTAAMATTOMAT VERKOT
55
Kaarten tarkasteleminen painojärjestyksessä käy luontevasti prioriteettijonon
avulla. Muodostuvien yhtenäisten komponenttien hallinta ei sen sijaan onnistu tehokkaasti millään tähän mennessä nähdyllä joukkomallilla. Esitellään sen vuoksi vielä yksi
abstrakti joukkomalli, joka soveltuu erillisten komponenttien hallintaan erinomaisen
hyvin.
Keskenään pistevieraiden eli yhteisiä alkioita sisältämättömien joukkojen käsittelyä, jossa joukkoja silloin tällöin yhdistellään, muttei koskaan hajoteta, kutsutaan joskus
MERGE-FIND-ongelmaksi. Tämä nimitys tulee suoraan kahden tärkeimmän käsittelyoperaation nimestä. Käsiteltävät joukot muodostavat aina kokoelman. Itse kokoelman
samoin kuin alkeisjoukkojen muodostamiseksi tarvitaan luonnollisesti omat operaationsa. Määritellään nyt abstraktin tietotyypin pistevieraat joukot (disjoint set, merge-find
set) operaatiot seuraavalla tavalla:
TRAII 4.3.2011 UEF/tkt/SJ
1) M.merge(A, B) yhdistää kokoelman M sisältämät joukot A ja B jommaksikummaksi näistä kahdesta joukosta. Yhdiste säilyy kokoelmassa M, mutta se alkuperäisistä joukoista, johon yhdistettä ei muodosteta, lakkaa olemasta. Jos
A ∉ M, B ∉ M tai A ∩ B ≠ ∅, on operaation tulos määrittelemätön.
2) Set M.find(x) palauttaa kokoelman M sen joukon kuvaajan, joka joukko sisältää
alkion x. Jos x ei sisälly mihinkään kokoelman M joukkoon tai x sisältyy useampaan kuin yhteen kokoelman M joukkoon, on operaation tulos määrittelemätön.
3) M.add(x) muodostaa kokoelmaan M vain alkion x sisältävän uuden joukon.
4) MfSet MfSet() muodostaa tyhjän kokoelman.
Algoritmi 3-2: Kruskalin algoritmi (*):
LinkedList<Edge> MSTKruskal(Graph g) {
LinkedList<Edge> mst = new LinkedList<Edge>();
AssignablePriorityQueue<Edge> Q =
new AssignablePriorityQueue<Edge>();
for (Edge e : g.edges())
// kaikki kaaret prioriteettijonoon
Q.add(e, e.getWeight());
MfSet<Set<Vertex>> M = new MfSet<Set<Vertex>>;
for (Vertex v : g.vertices())
// kaikki solmut joukoiksi
M.add(v);
int i = g.size();
// joukkojen määrä
while (i > 1) {
Edge e = Q.poll();
Set s1 = M.find(e.getStartPoint());
Set s2 = M.find(e.getEndPoint());
if (s1 != s2) {
// eri osapuut
M.merge(s1, s2);
i––;
mst.add(e);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
3. SUUNTAAMATTOMAT VERKOT
return mst;
TRAII 4.3.2011 UEF/tkt/SJ
}
56
21
22
Kruskalin algoritmin aikavaativuuden arvioiminen edellyttää äsken määriteltyjen joukkooperaatioiden aikavaativuuden tuntemista. Koska kaikki kaaret viedään prioriteettijonoon
kerralla, voidaan prioriteettijonona käyttää järjestettyä taulukkoa. Yleisessä tapauksessa
tähän menee O(eloge), vähemmän jos painot esimerkiksi ovat kokonaislukuarvoisia. Solmujoukkokokoelman muodostamiseen riveillä 8-9 O(n) sillä M.add() on O(1). Riviltä 10
alkavan toiston kuluessa käsitellään pahimmillaan kaikki prioriteettijonon kaaret, joten
toistokertoja on O(e). Kasalla toteutetulla prioriteettijonolla M.find()-operaation aikavaativuus on O(logn), jos kokoelmassa on aluksi n alkiota, ja M.merge() -operaatio puolestaan on O(1). Koska Q.poll() on O(loge) ja rivi 18 voidaan toteuttaa vakioaikaisena, on
rivien 10-20 aikavaativuus O(eloge + elogn), mikä on myös koko algoritmin aikavaativuus. Koska tarkasteltava verkko on yhtenäinen, on välttämättä e ≥ n–1, joten kertaluokka
sievenee lopulta muotoon O(eloge). Tehokkaammalla MfSet -toteutuksella (rankkaus ja
poluntiivistys) kaikkien find() ja merge() operaatioiden yhteenlasketuksi aikavaativuudeksi saadaan O(eα(e, v)), missä α on Ackermanin funktion käänteisfunktio, joka kasvaa
erittäin hitaasti. Näin ollen aikavaativuutta rajoittaa lähinnä kaarien lajittelun aikavaativuus.
Mikäli verkossa on suhteellisen vähän kaaria, toisin sanoen e ≈ n, käy pienimmän
virittävän puun etsiminen nopeammin Kruskalin algoritmilla, kun taas runsaasti kaaria
sisältävän verkon tapauksessa Primin algoritmi on tehokkaampi. Ero näiden kahden algoritmin välillä ei tosin ole kovinkaan merkittävä, ellei verkko ole hyvin suuri: Esimerkiksi
1024-solmuisessa verkossa on kaaria enintään 523776 kappaletta, jolloin loge on noin
19, toisin sanoen Kruskalin algoritmin teoreettinen suoritusaika on noin 19-kertainen
Primin algoritmin teoreettiseen suoritusaikaan verrattuna. Käytännössä aikaero voi olla
suurempi, koska Kruskalin algoritmi on Primin algoritmia monimutkaisempi.
Ellei tarkasteltava verkko ole yhtenäinen, voidaan molempia algoritmeja soveltaen
muodostaa verkon pienin virittävä metsä eli kokoelma pienimpiä virittäviä puita, jotka
yhdessä virittävät koko verkon. Tällöin on varottava, ettei vahingossa tarkastella komponenttien väliltä puuttuvia kaaria, joiden painoksi kustannusfunktio antaa äärettömän.
3.3 Suuntaamattoman verkon läpikäynti
Suuntaamattoman verkon läpikäynnissä voidaan soveltaa samoja tekniikoita kuin suunnatun verkon läpikäynnissäkin, kunhan muistetaan naapuruussuhteiden molemminpuolisuus. Suuntaamattoman verkon kaari voidaan tarvittaessa jopa tulkita kahdeksi eri suuntiin suunnatuksi kaareksi.
Verkon solmujen tai kaarten joukon läpikäynti sujuu suuntaamattomassa verkossakin vaivattomimmin foreach -läpikäynnin avulla, jos läpikäyntijärjestys on merkityksetön. Syvyyssuuntaista etsintää taas voidaan soveltaa esimerkiksi ratkaistaessa ongelmaa,
jossa etsitään kahden solmun välistä polkua. Myös virittävä metsä löytyy syvyyssuuntaisen etsinnän avulla — itse asiassa jopa hieman helpommin kuin suunnatun verkon virittävä metsä, sillä suunnatun verkon puu-, paluu-, etenemis- ja ristikkäiskaarten asemesta
suuntaamattomassa verkossa on vain kahdenlaisia kaaria: puukaaria ovat ne kaaret, joita
TRAII 4.3.2011 UEF/tkt/SJ
3. SUUNTAAMATTOMAT VERKOT
57
pitkin edetään verkon vielä käsittelemättömiin solmuihin, ja suunnatun verkon paluu- ja
etenemiskaaret pelkistyvät vastaavassa suuntaamattomassa verkossa paluukaariksi. Ristikkäiskaaria ei voi olla olemassakaan.
Syvyyssuuntaisessa etsinnässä edetään aina solmusta poispäin niin pitkälle kuin
mahdollista ennen kuin palataan tarkastelemaan solmun vielä mahdollisesti käsittelemättömiä naapureita. Toisenlaista strategiaa, jossa ensin tarkastellaan solmun kaikki naapurit,
sen jälkeen kaikki naapureiden vielä käsittelemättömät naapurit, toisin sanoen aloitussolmusta kahden kaaren päässä olevat solmut, ja niin edelleen aina kaikki yhtä monen kaaren
päässä aloitussolmusta olevat vielä käsittelemättömät solmut perätysten, kutsutaan
leveyssuuntaiseksi etsinnäksi. Puuhun sovellettuna leveyssuuntainen etsintä käy solmut
läpi tasoittaisessa järjestyksessä.
Virittävä metsä voidaan muodostaa myös leveyssuuntaisen etsinnän avulla. Muodostuva metsä on tosin muodoltaan yleensä hyvin erilainen syvyyssuuntaisen etsinnän
tuottamaan virittävään metsään verrattuna. Leveyssuuntaisessa etsinnässäkin kohdataan
vain kahdenlaisia kaaria, mutta nyt kaaret ovat joko puu- tai ristikkäiskaaria.
Leveyssuuntaisessa etsinnässäkin solmut tunnistetaan käsitellyiksi värityksen
avulla: kaikki solmut ovat aluksi valkoisia ja käsittelyn alkaessa solmu väritetään harmaaksi. Etsinnässä käytetään apuna jonoa, johon käsittelemättömät solmut viedään ennen
varsinaisen käsittelyn aloittamista. Jottei samaa solmua vietäisi jonoon toistuvasti, on solmut huomattava värittää harmaiksi jo ennen jonoon viemistä. Käsittely voidaan aloittaa
verkon mistä tahansa solmusta, ja ellei verkko ole yhtenäinen, käydään kukin komponentti läpi erikseen vastaavaan tapaan kuin syvyyssuuntaisen etsinnän yhteydessä nähtiin
(algoritmi 2-11 (s. 44)).
Algoritmi 3-3: Leveyssuuntainen läpikäyntialgoritmi:
void bfs(Vertex start) {
// komponentit solmut eivät ole harmaita1
LinkedQueue<Vertex> vQ = new LinkedQueue<Vertex>();
2
start.setColor(Graph.GRAY);
// merkitään jonoon
viedyksi
3
vQ.add(start);
// aloitussolmu jonoon
4
while (! vQ.isEmpty()) {
5
Vertex v = vQ.remove();
6
// use v
7
for (Vertex w : v.neighbours()) {
8
if (w.getColor() != Graph.GRAY) { //käsittelemättömätnaapur9
w.setColor(Graph.GRAY);
// merk. jonoon viedyksi 10
vQ.add(w);
// jonoon
11
}
12
}
} }
13
Algoritmin aikavaativuus on solmujen varsinaista käsittelyä lukuunottamatta O(e), sillä
jokaisen solmun kohdalla joudutaan tutkimaan solmun kaikkien naapureiden väri, toisin
sanoen koko toiston aikana tutkitaan kaikki naapuruussuhteet.
Suuntaamattoman verkon kehättömyys voidaan leveyssuuntaisella etsinnällä selvittää helposti: jos läpikäynnin kuluessa kohdataan paluukaari eli päädytään jo käsiteltyyn
solmuun, on verkossa kehä, muuten ei. Selvitys onnistuu jopa ajassa O(n). (Miten?)
3. SUUNTAAMATTOMAT VERKOT
58
3.4 Leikkaussolmut ja 2-yhtenäiset komponentit (*)
TRAII 4.3.2011 UEF/tkt/SJ
Verkon leikkaussolmu on solmu, jonka poistaminen hajottaa alkujaan yhtenäisen verkon (tai verkon komponentin)
kahdeksi tai useammaksi erilliseksi osaksi. Muun kuin
leikkaussolmun poistaminen ei riko verkon yhtenäisyyttä.
Vastaavasti kaari, jonka poistaminen hajottaa yhtenäisen
komponentin kahdeksi erilliseksi osaksi, on silta.
Yhtenäistä verkkoa, jossa ei ole yhtään leikkaussolmua, sanotaan 2-yhtenäiseksi.
Leikkaussolmujen puuttuminen merkitsee, että verkon minkä tahansa kahden eri solmun
välillä on ainakin kaksi polkua, jotka eivät leikkaa toisiaan. Sama asia voidaan ilmaista
myös toisin: 2-yhtenäisen verkon mikä tahansa solmu tai kaari voidaan poistaa verkon
yhtenäisyyttä rikkomatta. Yleisemmin verkkoa sanotaan k-yhtenäiseksi, jos verkon
minkä tahansa kahden solmun välillä on ainakin k polkua, jotka eivät leikkaa toisiaan.
Tällaisesta verkosta voidaan poistaa k–1 kaarta (tai solmua kaarineen) verkon yhtenäisyyttä rikkomatta. 1-yhtenäisyys tarkoittaa tämän määritelmän mukaan samaa kuin tavanomainen yhtenäisyys.
Algoritmi 3-4: Verkon leikkaussolmut etsitään
3
seuraavalla algoritmilla, joka sopii myös verkon 2- C
yhtenäisyyden
selvittämiseen
[GraphEsimerkki.leikkausSolmut()]:
D 4
E 6
1) Suoritetaan verkossa syvyyssuuntainen etsintä B 2
A 1
F 5
ja numeroidaan solmut käsittelyjärjestyksessä
dfs -numerointi
(esijärjestyksessä). Solmujen numerot talletetaan taulukkoon dfnumber (kuvissa numerointi kunkin solmun ylä-oikealla).
2) Määrätään jokaiselle solmulle low-arvo, joka määritellään seuraavasti:
 dfnumber [ v ]

low [ v ] = min  dfnumber [ z ] ∀z joille on paluukaari ( v, z )

 low [ y ] ∀v:n lapsille y dfs-puussa
Nämä arvot voidaan määrätä jälkijärjestyksessä, jolloin solmun kaikkien lasten
low-arvot tunnetaan solmun omaa low-arvoa määrättäessä. Kuvassa kunkin solmun low-arvot näkyvät ala-oikealla.
3) Leikkaussolmut löytyvät nyt seuraavasti:
A 11
a) dfs-puun juuri on leikkaussolmu vain, jos ja vain jos sillä
on ainakin kaksi lasta.
2
F 55
b) v (≠ dfs-puun juuri) on leikkaussolmu, jos ja vain jos v:llä B 1
on dfs-puussa lapsi w, jolle low[w] ≥ dfnumber[v].
Koko algoritmi voidaan itseasiassa toteuttaa yhdellä ainoalla
syvyyssuuntaisella läpikäynnillä. Rekursiossa edettäessä (=esijärjestys) numeroidaan solmut, rekursiosta palattaessa (= jälkijärjestys) lasketaan low-arvot ja tarkastetaan onko solmu leikkaussolmu vai ei. Katso toteutus kurssin www-sivulla.
C 31
E 66
D 4
1
low -arvot
Tämän algoritmin aikavaativuus on O(n + e). Kun e ≥ n, on aikavaativuus O(e).
3. SUUNTAAMATTOMAT VERKOT
59
3.5 Verkon kaksijakoisuus, sovitus
Verkko on kaksijakoinen, jos sen solmut voidaan
osittaa kahdeksi erilliseksi joukoksi siten, että verkon kukin kaari johtaa joukosta toiseen, toisin
sanoen mikään verkon kaarista ei yhdistä kahta
samaan joukkoon sisältyvää solmua. Kaksijakoisen
verkon kaarten joukon osajoukkoa, jonka mitkään
kaksi kaarta eivät osu samaan solmuun, kutsutaan verkon sovitukseksi. Joskus sovituksesta käytetään myös nimitystä täsmäys, jolla kuvataan sitä, että kahden joukon alkiot saadaan vastaamaan toisiaan jollakin yksiymmärteisellä tavalla.
TRAII 4.3.2011 UEF/tkt/SJ
Esimerkki 3-5: On joukko opettajia ja joukko kursseja (oletetaan aluksi molempia olevan
sama määrä). Kukin opettajista on pätevä opettamaan tiettyjä, muttei kaikkia kursseja. Halutaan kiinnittää kullekin kurssille pätevä opettaja, muttei kahta opettajaa
samalle kurssille eikä kahta kurssia samalle opettajalle.
Ongelma voidaan kuvata verkkona, jonka solmut ositetaan kahdeksi joukoksi:
joukko V1 koostuu opettajista ja joukko V2 kursseista. Verkon kaari (v, w), v ∈ V1 ja
w ∈ V2 , ilmaisee opettajan v olevan pätevä opettamaan kurssia w. Opettajien kiinnittäminen eli sovitusongelma ratkaistaan valitsemalla kaaret verkosta mieluusti
niin, että jokaiseen solmuun liittyy täsmälleen yksi kaari.
Muita tyypillisiä sovelluskohteita ovat erilaiset resurssien allokoinnit. Sovitusongelmaa
voi olla mahdoton ratkaista täydellisesti. Näin on laita esimerkiksi silloin, kun solmujen
joukon osituksen osajoukoissa on keskenään eri määrä alkioita, jolloin alkioiden välille ei
saada vastaavuutta, olipa verkossa millaisia kaaria hyvänsä. Siinä tapauksessa pyritään
mahdollisimman hyvään osittaiseen sovitukseen eli sovitukseen, joka sisältää niin monta
kaarta kuin suinkin mahdollista. Tällaista sovitusta sanotaan maksimaaliseksi sovitukseksi. Jos myös verkon kaikki solmut ovat sovituksessa osallisina, on sovitus täydellinen.
Maksimaalinen sovitus on periaatteessa "helppo" löytää raa’alla voimallakin: muodostetaan kaikki sovitukset ja valitaan niistä paras. Valitettavasti tämä lähestymistapa on
kovin raskas, koska sovitusehdokkaita on yleensä hyvin runsaasti. Jos esimerkiksi solmuja on n+n kappaletta, jolloin täydellisessä sovituksessa on n kaarta, on kelvollisia
ehdokkaita täydelliseksi sovitukseksi pahimmillaan peräti O(n!) erilaista. Kelvottomat
ehdokkaat mukaan lukien ehdokkaita, eli kaarten joukon n-alkioisia osajoukkoja, on paljon enemmän, ja jos täydellisyysvaatimuksesta luovutaan, kasvaa ehdokkaiden määrä
edelleen.
3. SUUNTAAMATTOMAT VERKOT
60
Maksimaalista sovitusta etsittäessä käytetään seuraavaa laajentavien polkujen tekniikkaa: Olkoon M jokin sovitus verkossa G.
Solmu v ∈ V on sovitettu, jos M sisältää solmuun v johtavan kaaren. Sellainen polku, joka yhdistää kaksi sovittamatonta solmua ja
jonka joka toinen kaari sisältyy sovitukseen M, on M:n suhteen laajentava polku. Laajentavan polun pituus on pariton ja sen ensimmäinen ja viimeinen kaari eivät sisälly sovitukseen M. Laajentavan
polun P avulla voidaan sovituksesta M kehittää uusi sovitus M'
seuraavasti: M' = (M ∪ P) \ (M ∩ P) [poissulkeva yhdiste]. Selvästi
M' on laajempi kuin M, sillä M' sisältää ainakin yhden kaaren
enemmän.
Algoritmi verkon maksimaalisen sovituksen etsimiseksi voidaan nyt esittää seuraavanlaisessa muodossa:
TRAII 4.3.2011 UEF/tkt/SJ
1) M := ∅.
2) Etsi M:n suhteen laajentava polku P ja aseta M := (M ∪ P) \ (M ∩ P).
3) Toista vaihetta 2, kunnes ei enää löydy laajentavaa polkua, jolloin M on maksimaalinen sovitus.
[Toiston lopetusehdon riittävyyden todistus sivuutetaan.] Ongelmaksi jää enää se, kuinka
laajentava polku löydetään. Java -toteutus on nähtävillä kurssin www-sivulla (GraphEsimerkki.maksimaalinenSovitus()).
Laajentavan polun etsiminen
Muodostetaan niin sanottu laajentavan polun verkko tasoittain leveyssuuntaisen etsinnän
tavoin. Aluksi tasolla i = 0 lähdetään liikkeelle jostakin vielä sovittamattomasta solmusta.
Parittomalla tasolla i laajentavan polun verkkoon lisätään tason i–1 solmujen ne naapurit,
joihin johtavat kaaret eivät ole mukana sovituksessa. Parillisella tasolla i puolestaan lisätään verkkoon ne tason i–1 solmujen naapurit, joihin johtavat kaaret ovat mukana sovituksessa. Molemmissa tapauksissa laajentavan polun verkkoon lisätään myös ne kaaret,
joiden perusteella naapurisolmut lisättiin. Näin jatketaan, kunnes joko lisätään sovittamaton solmu tai ei voida enää lisätä solmuja. Edellisessä tapauksessa polku viimeksi lisätystä
sovittamattomasta solmusta aloitussolmuun on laajentava polku. Jälkimmäisessä tapauksessa aloitetaan uudelleen jostakin toisesta sovittamattomasta solmusta, ja ellei tällaista
enää ole, ei ole myöskään laajentavaa polkua. Jos laajentava polku on olemassa, se väistämättä ennemmin tai myöhemmin löytyy.
Maksimaalisen sovituksen etsimisen aikavaativuus
Laajentava polku voidaan muodostaa ajassa O(e), sillä kyse on pohjimmiltaan leveyssuuntaisesta etsinnästä, vaikka naapurit joudutaankin käymään läpi kahteen kertaan. Maksimaalisen sovituksen löytämiseksi puolestaan muodostetaan enintään n/2 laajentavaa
polkua, sillä poluista jokainen laajentaa sovitusta ainakin yhdellä kaarella (n = solmujen
lukumäärä alkuperäisessä verkossa, joten täydellisessä sovituksessa on n/2 kaarta). Tämä
merkitsee, että maksimaalinen sovitus löytyy ajassa O(ne), toisin sanoen huomattavasti
nopeammin kuin kaikki mahdolliset vaihtoehdot tutkimalla.
Luku 4
TRAII 4.3.2011 UEF/tkt/SJ
Algoritmistrategioita
Päivittäisessä suunnittelu/ohjelmointityössä algoritmit yleensä otetaan valmiina kirjallisuudesta tai algoritmikirjastoista. Täysin uusia algoritmeja joutuu keksimään suhteellisen
harvoin, mutta joskus kuitenkin. Tässä luvussa kerrataan aiemmin nähtyjä algoritmistrategioita ja näytetään pari uutta. Samalla haetaan erilaisia näkökulmia algoritmien toimintaan esittelemällä vielä yksi lajittelualgoritmi ja muutamia erilaisia merkkijononhakualgoritmeja. Paitsi algoritmien suunnittelussa, näistä toivottavasti on hyötyä myös olemassaolevien algoritmien toiminnan ja tehokkuuden ymmärtämisessä. Tarkemmin algoritmistrategioita käsitellään Algoritmien suunnittelu ja analysointi -kurssilla.
4.1 Ahne eteneminen
Ahneen algoritmin askel on valita aina kussakin vaiheessa parhaimmalta näyttävä vaihtoehto. Vaihtoehtoja voidaan ylläpitää esimerkiksi prioriteettijonossa. Tyypillisiä esimerkkejä ovat esimerkiksi Dijkstran ja Primin algoritmit sekä Huffman-koodaus. Jotta ahne
valinta antaisi oikean lopputuloksen, on varmistuttava ettei valinta sulje pois muita mahdollisuuksia (ellei ole peruuttamisen mahdollisuutta kuten Ford-Fulkerson -algoritmissa).
Joskus toki voidaan käyttää mahdollisesti väärän tuloksen antavaa ahnetta algoritmia tietoisella riskillä jos tulos on riittävän hyvä ja optimaalisen tuloksen hakeminen olisi liian
työlästä (suoritusaika ja/tai ohjelmointityö).
Esimerkki 4-1: Kolikoiden valinta ("vaihtorahaongelma", Java-toteutukset kurssin verkkosivulla):
Käytettävissä tietynkokoisia kolikoita (esimerkiksi: 1c, 2c, 5c, 10c, 20c, 50c). Mikä
on pienin määrä kolikoita jolla annettu summa voidaan rakentaa? Esimerkiksi rahasumma 65 voidaan muodostaa kolikoilla 50 + 10 + 5.
Ahne algoritmi: Otetaan aina suurin kolikko joka voidaan (korkeintaan jäljellä olevan summan kokoinen). Ym. kolikoilla tulos on aina optimaalinen. Algoritmi on
suhteellisen nopea, määritelmän mukaan O(r/s + s), missä r on rahamäärä ja s suurin
kolikko. Sitä on vielä helppo nopeuttaa laskemalla käytettävien suurimpien kolikoiden määrä jakolaskulla.
61
4. ALGORITMISTRATEGIOITA
62
Mutta toimiiko algoritmi aina? Entä jos kolikot olisivatkin 1c, 2c, 5c, 10c, 40c, 50c?
Tällöin esimerkiksi rahasumma 80 olisi ongelmallinen. Ahne algoritmi valitsisi 50
+ 10 + 10 + 10, mutta on helppo nähdä, että 40 + 40 olisi parempi!!!
4.2 Hajoita-ja-hallitse
Nimensä mukaisesti jaetaan syöte osiin, ratkaistaan pienemmät osatehtävät rekursiivisesti, yhdistetään osatulokset. Algoritmin rekursio päättyy triviaaliin tapaukseen. Varsinainen työ tehdään yleensä yhdistämisessä joka on kuitenkin helpompaa kuin varsinainen
tehtävä. Tyypillisiä esimerkkialgoritmeja ovat mm. binäärihaku, pikalajittelu, lomituslajittelu, nopea Fouriermuunnos (FFT). Yleensä syöte jaetaan on kahteen (tai useampaan)
tasakokoiseen osaan. Mutta jako voi olla myös epätasainen, jopa vain yhden alkion vähentävä. Yhdistämistyö voidaan tehdä joko ennen rekursiojakoa (esim. pikalajittelu) tai vasta
sen jälkeen (esim. lomituslajittelu).
TRAII 4.3.2011 UEF/tkt/SJ
Esimerkki 4-2: Hajoita-ja-hallitse kolikkovalinta:
Jaetaan ongelma yhden kolikon ja loppusumman ongelmaksi. Loppusumman
ongelma ratkaistaan rekursiivisesti. Kussakin vaiheessa on yhtä monta mahdollista
ositusta kuin erilaisia kolikoita on. Valitaan näistä paras.
1
int hjhjako(int rahamaara, Iterable<Integer> Kolikot) {
if (rahamaara == 0)
2
return 0;
3
int tulos = rahamaara;
// yläraja
4
for(Integer c : Kolikot)
// kolikkojen arvojen läpikäynti5
if (c <= rahamaara)
6
tulos = min(tulos, 1 + jako(rahamaara – c));
7
return tulos;
8
}
9
Aikavaativuus lasketaan rekursiokaavalla. Tämä algoritmi muistuttaa esimerkissä
1-9 (s. 10) näytettyä Fibonaccin lukujen määritelmään perustuvassa ratkaisua. Tässäkin tehdään valtavasti moninkertaista työtä kun funktiota kutsutaan useasti
samalla parametrilla. Aikavaativuus on eksponentiaalinen, käytännössäkin erittäin
hidas. Ylläolevilla kolikoilla jo 35c laskeminen kestää noin minuutin.
4.3 Dynaaminen ratkaiseminen (ohjelmointi)
Yllä hajoita-ja-hallitse -rahajakoalgoritmissa samoja osaratkaisuja laskettiin useasti. Järkevämpää olisikin tallettaa jo tunnettujen osaratkaisujen arvot esimerkiksi taulukkoon.
Ratkaisu selkeytyy kun aloitetaan (alhaalta ylös) pienemmistä syötteistä, ratkaistaan ne ja
tallennetaan tulokset taulukkoon myöhempää käyttöä varten. Esimerkiksi edellä esitetty
Floydin algoritmi lyhyimpien polkujen hakuun toimii näin.
Esimerkki 4-3: Kolikkovalinta dynaamisesti ratkaisten:
4. ALGORITMISTRATEGIOITA
63
Ratkaisuperiaate hieman kuten edellisessä hajoita-ja-hallitse -algoritmissa, mutta
lasketaan ensin pienet osaongelmat ja tallennetaan niiden ratkaisut taulukkoon.
Suurempaa rahasummaa ratkaistaessa kokeillaan minkä mahdollisen kolikon käyttö
antaisi jäännökselle pienimmän kolikkomäärän. Koska kaikki pienemmät summat
on jo ratkaistu valmiiksi, tämä on helppoa ja nopeaa.
TRAII 4.3.2011 UEF/tkt/SJ
int dynjako(int rahamaara, Iterable<Integer> Kolikot) {
int[] ratkaisut = new int[rahamaara+1];
ratkaisut[0] = 0;
for (int i = 1; i <= rahamaara; i++) {
int min = i;
for (Integer c : Kolikot)
if (c <= i && ratkaisut[i–c]+1 < min)
min = ratkaisut[i–c]+1;
ratkaisut[i] = min;
}
return ratkaisut[rahamaara];
}
1
2
3
4
5
6
7
8
9
10
11
12
Samalla vaivalla saadaan myös kaikki annettua rahamäärää pienempien tehtävien
ratkaisut. Aikavaativuus on selkeästi O(rahamäärä×|Kolikot|). Kullekin kolikkojoukolle voidaan laskea raja jota suuremmat rahasummat voidaan pienentää ratkaista ottamalla rajaa suurempi osuus suurimpina kolikkoina ja loppusumma yo.
algoritmilla.
4.4 Tasoitettu aikavaativuus
Aiemmin (kohdassa 1.2, s. 8) totesimme, ettei pahimman tapauksen valintaa ehtolauseessa voi aina käyttää jos pahin tapaus on harvinainen. Paitsi aikavaativuuden analysoinnissa, tätä voidaan käyttää myös algoritmeja suunniteltaessa. Jos (osa)algoritmia kutsutaan peräkkäin useasti (samassa suorituksessa), sen ei välttämättä tarvitse olla nopea
jokaisella kutsukerralla. Kokonaisuutena on oleellista, että se on keskimäärin nopea.
Yksittäinen hidas suoritus ei haittaa jos muut ovat nopeita. Aikavaativuusluokkina muistetaan esimerkiksi, että (O(n) + n×O(1))/n = O(1), (O(logn) + logn×O(1))/logn = O(1) ja
log n
n
∑ ----i = O(n). Tähän mennessä läpikäymistämme algoritmeista esimerkiksi binääripuun
i = 02
läpikäynti sisäjärjestyksessä käyttäen seuraaja -operaatiota kestää yhteensä O(n) ajan
vaikka yksittäiset seuraaja -operaatiot ovat puun O(puun korkeus). Samoin Merge-Find joukon ja Fibonaccin kasan operaatiot saadaan keskimäärin hyvin nopeiksi. Jos jostain
syystä halutaan jokainen kutsukerta yhtä nopeaksi, voidaan joissain algoritmeissa jakaa
harvoin esiintyvää lisätyötä usealle eri kutsukerralle.
Esimerkki 4-4: Järjestetyn joukon toteuttaminen taulukossa: (*)
4. ALGORITMISTRATEGIOITA
64
Ylläpidetään joukkoa taulukossa siten, että taulukon pääosa järjestetty, taulukon
lopussa korkeintaan O(logn) järjestämätöntä alkiota. Lisäys voidaan tehdä aina taulukon loppuun O(1) ajassa, paitsi, jos lopun järjestämätön osuus kasvaa liian suureksi, jolloin tarvitaan liian pitkäksi kasvaneen järjestämättömän loppuosan lajittelu
(O(lognloglogn)) ja lomitus (O(n)). Lisäys on täten keskimäärin O(n/logn), haku
aina O(logn) (binäärihaku alusta + peräkkäishaku lopusta).
Jos lisäyksen nopeutta halutaan parantaa (haun kustannuksella), annetaan taulukon
lopun järjestämättömän osuuden kasvaa O( n ):een, jolloin järjestämiseen ja lomitukseen ei mene juuri enempää aikaa, mutta sitä tarvitaan harvemmin. Tällöin lisäyksen aikavaativuus on keskimäärin O( n ), samoin haun.
Läpikäynti järjestyksessä edellyttää lopun järjestämistä ennen varsinaista läpikäyntiä, mutta läpikäynnin aikavaativuus on silti O(n), eli O(1) alkiota kohti. Poisto-operaatiossa vain merkitään alkio poistetuksi, ei siirrellä muita. Tasapainotetun binääripuun tai hajautuksen aikavaativuuteen ei silti päästä.
TRAII 4.3.2011 UEF/tkt/SJ
4.5 Haun rajoittaminen
Oletetaan tilanne jossa haetaan parasta vaihtoehtoa (lyhyintä polkua, tms) painotetussa
puussa tai verkossa (vakiopainoisilla kaarilla leveyssuuntainen haku riittää). Syvyyssuuntaista hakua voidaan rajoittaa muistamalla paras siihen mennessä löydetty ratkaisu ja katkaisemalla syvyyteen etenevä rekursio aina kun ko. taso on saavutettu. Keskeneräisiä
hakuja voidaan ylläpitää rekursiossa, jonossa tai prioriteettijonossa, tai muistaa muuten
asema puussa ja peruuttaa (backtracking) edelliseen haarautumiskohtaan. Esimerkkisovellus on esimerkiksi pelien siirtojen optimointi (tilanteen arvottaminen): haussa haaraudutaan eri siirtoihin, sekä positiivisia, että negatiivisia siirtymiä.
4.6 Satunnaistetut algoritmit
Toistaiseksi kaikki tähän mennessä esittelemämme algoritmit ovat olleet deterministisiä,
eli aina samalla syötteellä ne antavat saman tuloksen. Algoritmit voivat kuitenkin käyttää
satunnaislukugeneraattoria hyväkseen. Keinoina voivat olla esimerkiksi satunnaisten ratkaisujen kokeileminen, osatehtäviin jako satunnaisesti, satunnaiset näytteet, säännönmukaisuuden rikkominen satunnaisuudella.
Satunnaisalgoritmit voidaan jakaa kahteen päätyyppiin. Niinsanotut Las Vegas
-algoritmit antavat aina oikean tuloksen, mutta käytetty aika voi vaihdella satunnaisesti.
Tällöin satunnaistuksella esimerkiksi rikotaan säännönmukaisuutta. Tyyppiesimerkki on
pikalajittelu: jakoalkioksi valitaan (kolmen) satunnaisesti valitun alkio(n mediaani). Niinsanotut Monte Carlo -algoritmit sallivat tuloksessa pienen virheen mutta toimivat nopeasti. Esimerkkejä ovat esimerkiksi piin likiarvon laskenta ympyrän sisä/ulkopuolelle osuneiden arvontojen suhteena, likimääräinen integrointi. Monte Carlo -menetelmiksi lasketaan myös algoritmit jotka kokeilevat useita satunnaisia ratkaisuja, mahdollisesti optimoivat niitä paikallisesti ja lopuksi valitsevat parhaan vastaantulleen lopputulokseksi.
4. ALGORITMISTRATEGIOITA
65
Niinsanotut geneettiset algoritmit luovat aluksi useita satunnaisia ratkaisuja, valitsevat niistä parhaat ja yhdistelevät näistä taas uusia ratkaisuja, ja taas valitsevat parhaat
jatkoristeytykseen. Kussakin vaiheessa uusien ratkaisuvaihtoehtojen muodostamiseen
käytetään sopivassa määrin risteyttämistä, mutaatiota ja valintaa. Sopivan mutaatioasteen
valinta vaikuttaa suuresti siihen, miten nopeasti tulokseen päästään. Algoritmin on pystyttävä tehokkaasti arvioimaan kussakin vaiheessa tuotettujen ratkaisujen hyvyys jotta se
voi valita parhaat jatkokäsittelyyn. Samoin ratkaisu pitää voida esittää muodossa jota voidaan helposti risteyttää ja mutatoida, esimerkiksi bittivektorina.
4.7 Kantalukulajittelu
TRAII 4.3.2011 UEF/tkt/SJ
Kaukalolajittelu
Jos lajiteltavien alkioiden avaimet rajoittuvat välille 1…m, voidaan alkiot lajitella vielä
TRAI-kurssilla 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ä.
Monimutkaisempien alkioiden tapauksessa alkiot on todella siirrettävä kaukaloihin, mutta
silloinkin päädytään toteutusrakenne sopivasti valitsemalla hyvin tehokkaaseen ratkaisuun.
Kantalukulajittelu
Kaukalolajittelussa listojen muodostaminen ja yhdistäminen vie suhteellisen paljon aikaa.
Seuraavassa esitettävässä kantalukulajittelussa (radix sort) käytetään toista taulukkoa
aputaulukkona ja alkiot viedään suoraan oikealle paikalleen aputaulukkoon. Kuten kaukalolajittettelussakin, lajittelu tehdään avaimenosa kerrallaan joten kunkin lajitteluvaiheen on oltava vakaa (kuva 4-1). Kun kukin vaihe kantalukulajittelua luo senhetkisestä
syötetaulukosta uuden taulukon, kannattaa vuorotella alkuperäistä taulukkoa ja yhtä aputaulukkoa. Tämä on erityisen kätevää jos vaiheita on parillinen määrä. Tällöin lopullinen
tulos tulee suoraan tallennetuksi syötetaulukkoon.
Kuva 4-2 esittää kunkin taulukon roolin kantalukulajittelun yhdessä vaiheessa.
Seuraava saman osa-avaimen alkio kuuluu seuraavaan indeksiin jne. Tämä toteutetaan
kätevästi kasvattamalla alkusummataulukon arvoja yhdellä aina kun olemme alkio sijoittaneet tulostaulukkoon. Jos käyttämämme taulukot ovat 1-alkuisia, teemme alkusummataulukon arvojen kasvatuksen ennen sijoittamista tulostaulukkoon. Toinen läpikäynti on
siis :
for (int i = 0; i < n; i++) {
T2[r[avain(T1[i], k)]] = T[i];
r[avain(T1[i], k)]++;
}
(4-1)
4. ALGORITMISTRATEGIOITA
123
345
543
233
533
325
343
231
324
66
231
123
543
233
533
343
324
345
325
123
324
325
231
233
533
543
343
345
123
231
233
324
325
343
345
533
543
TRAII 4.3.2011 UEF/tkt/SJ
Kuva 4-1: Vaiheittainen lajittelu.
missä k on kaukalolajittelun kierros (monesko osa-avain on kyseessä) ja avain() ottaa
koko avaimesta k:nnen osa-avaimen.
Kuten kaukalolajittelussakin, varaamme oman kaukalon kullekin mahdolliselle
avaimenosan arvolle. Nyt emme kuitenkaan sijoita arvoja kaukaloon, vaan laskentalajittelun tapaan ainoastaan laskemme kuinka monta ko. avainta lajiteltavassa syötteessä
esiintyy. Laskettuamme kunkin avaimenosan esiintymien määrät, voimme laskea kunkin
avaimen sijainnin (tämän vaiheen) tulostaulukossa. Tämä suoritetaan laskemalla esiintymistaulukosta 0-alkusumma (0, a1 , a1+a2 , a1 +a2 +a3 , …). Alkusummat osoittavat suoraan mihin kunkin osa-avaimen mukainen ensimmäinen alkio kuuluu tulostaulukossa.
Käytännössä siis käymme syötetaulukon uudestaan läpi ja kunkin alkio kohdalla sijoitamme sen tulostaulukkoon alkusummataulukon osoittamaan paikkaan. Kantalukulajit-
T1: 0 1 2 3
1 345
2 543
3 230
4 533
5 325
6 343
7 231
8 324
Esiintymiset
R:
1 1 0 4 1 2
1.
0
1
2
3
4
T2:
1
5
2
3
2.
3.
R:
0-alkusumma
0 1 2 2 6 7
0
1
2
3
0
4
5
4
5
6
7
8
230
231
123
543
533
343
324
345
325
Kuva 4-2: Kantalukulajittelu histogrammilla.
telu on ehkä nopein lajittelualgoritmi reaalimaailman syötteille jos lajiteltavaa on paljon
eivätkä avaimet ole kovin pitkiä. Jollei avain ole jotain erityistä tyyppiä (kuten esimerkissämme numeroita), käytetään osa-avaimena avaimen bittiesityksen bittejä lohko kerrallaan. Esimerkiksi 16 bittiä kerrallaan (jolloin kaukaloita on 65536 kappaletta). Aikavaam
r
tivuus on tällöin O  ---- ( n + 2 ) , missä r on avaimen pituus bitteinä. Kaukalolajittelun
r

nopeus käytännössä johtuu paljolti sen yksinkertaisuudesta, sekä siitä, että syötettä käy-
4. ALGORITMISTRATEGIOITA
67
dään läpi lähinnä peräkkäisjärjestyksessä (joskin alkusummataulukkoa ja tulostaulukkoa
osoitetaan myös satunnaisjärjestyksessä).
4.8 Merkkijonon haku
0000000000111111111122222222223
0123456789012345678901234567890
T merkillinen esimerkkimerkkijono
012345
TRAII 4.3.2011 UEF/tkt/SJ
P merkki
Merkkijonon haku -ongelmassa on tehtävänä selvittää missä kohtaa merkkijono P esiintyy merkkijonossa T. Täsmällisemmin, merkitään n = |T| (tekstin pituus), m = |P| (avaimen
pituus), |Σ| on aakkoston koko. Millä siirtymillä s ∈ [0..n–m] toteutuu P[i] == T[s+i]
∀ i∈[0..m–1]? Kuten järjestäminenkin, kyseessä on yksinkertainen on ongelma johon
on olemassa runsaasti erilaisia algoritmeja. Samoin, kuten järjestämisessä, perustapauksessa (liki) yksinkertaisinkin algoritmi on yleensä riittävän nopea, mutta nopeammat ovat
opettavaisia (vrt. lajittelu). Ongelmasta on myös haastavampia versioita: usea avain, likimääräinen etsintä, säännöllisen lausekkeen etsintä, lähimpänä olevan etsintä, useampi
ulottuvuus, jne. Nämä lähestyvät jo hahmontunnistusongelmia. Esitellään tässä pari tekniikkaa, algoritmien tarkempi toteutus löytyy kirjoista.
Naiivi algoritmi
Kokeillaan jokaisesta tekstin T kohdasta, josko avain P alkaisi siitä. Aikavaativuus on
helppo nähdä olevan O(nm) (pahin tapaus). Käytännössä se ei kuitenkaan ole niin huono,
esimerkiksi java.lang.String.indexOf(String) käyttää tätä algoritmia. Satunnaisella syötteellä (joka toki oikeasti on harvinainen) voidaan todistaa aikavaativuuden olevan O(n).
Helpoiten se nähdään satunnaisella binäärisyötteellä, jolla kunkin askeleen odotusarvo on
2n vertailua. Tehokkuuden parantamisvara syntyy siitä, että jos/kun joudutaan tekemään
useita vertailuja jostakin aloituskohdasta, niiden tulosta voitaisiin ehkä hyödyntää myöhemmin.
Rabin-Karp algoritmi
Rabin-Karp algoritmissa ei vertailla suoraan merkkijonon merkkejä, vaan lasketaan haettavasta avaimesta sormenjälki ja etsitään vastaavaa sormenjälkeä kustakin kohdetekstin
kohdasta. Avaimen (tai sen alkuosan) sormenjälki (kokonaisluku) lasketaan hajautusfunktion tapaan (h(P[0..m–1])). Varsinaisessa haussa lasketaan sormenjälki kustakin tekstinkohdasta samalla tavalla. Samaan sormenjälkeen törmättäessä tarkastetaan josko todella
oli kyse samasta merkkijonosta. Jotta algoritmi olisi tehokas, tekstinkohdan sormenjälki
on pystyttävä laskemaan nopeasti (vakioajassa). Tähän päästään laskemalla sormenjälki
h(T[i+1..i+m]) käyttäen hyväksi edellisen tekstinkohdan sormenjälkeä h(T[i..i+m–1]):tä.
Tekstin sormenjälkeä lasketaan "liukuvalla ikkunalla" lisäämällä aina sormenjälkeen yksi
uusi kirjain ja poistamalla ensimmäisenä lisätty kirjain (FIFO).
4. ALGORITMISTRATEGIOITA
68
Oletetaan merkistön olevan numeroita 0..9. Haettava merkkijono on "175217".
Teksti on "23456789012175217987665". Sormenjälki lasketaan (esimerkissä helppou2


2–i
den vuoksi) kaavalla  ∑ 10 P [ i ] mod 13 , eli avaimesta 175 mod 13 = 6. Tekstistä
i = 0

lasketaan kolmen ensimmäisen merkin avain 234, t = (2×100 + 3×10 + 4) mod 13 = 0 (eli
avainta ei löydy tältä kohtaa). Seuraavaksi t = (t – 2×100)×10 + 5 = 345 mod 13 = 7, eli
vähennetään ensimmäisen merkin vaikutus (luku 2, kerroin nyt 102) ja lisätään uusi (5).
Samalla mukana jo olevien (3 ja 4) kerroin kasvaa 10:llä.
TRAII 4.3.2011 UEF/tkt/SJ
Seuraavaksi t = (t – 3×100)×10 + 6 = 456 mod 13 = 1
Seuraavaksi t = (t – 4×100)×10 + 7 = 567 mod 13 = 8
Seuraavaksi t = (t – 5×100)×10 + 8 = 678 mod 13 = 2
Seuraavaksi t = (t – 6×100)×10 + 9 = 789 mod 13 = 9
Seuraavaksi t = (t – 7×100)×10 + 0 = 890 mod 13 = 6
haettava voisi olla tässä, joten on tarkastettava merkkivertailulla onko
todella kyseessä löytö (ei ole).
jne.
1
2
3
4
5
6
7
8
Oikeasti käytetään niin suurta moduloa kuin koneen laskentatarkkuus antaa myöten (32
bit), 10:llä kertomisen sijaan operaatio kannattaa olla esimerkiksi <<= 1, jolloin useampaa
merkkiä voidaan pitää mukana. Aikavaativuus on O(n + m) (jos vakiot valittu järkevästi,
eikä satu pahita tapausta). Algoritmi on myös helppo laajentaa hakemaan samalla läpikäynnillä useaa avainta. Tällöin kunkin avaimen sormenjälki talletetaan hajautustauluun
(esim. HashMap), jolloin tarkistus on edelleen vakioaikainen syötteen merkkiä kohti.
Äärellisellä automaatilla hakeminen
Avaimesta muodostetaan (jopa ajassa O(m×|Σ|) äärellinen automaatti jolla tekstiä luetaan.
Automaatin kustakin tilasta on siirtymä toiseen tilaan kullakin erilaisella merkillä. Merkkijonon löytymien on erityinen lopetustila. Automaatin siirtymät on merkitty aakkoston
merkeillä. Aikavaativuus O(n + m|Σ|), myös pahin tapaus. Erityisen hyödyllinen mahdollisuus on, että voimme mahduttaa samaan automaattiin usean haettavan merkkijonon
ilman, että varsinaisen haun aikavaativuus kärsii. Esimerkiksi avaimen "ababc" automaatti kuvana:
a
a
0
a
1
a
b
2
a
3
a
b
4
a
c
5
4. ALGORITMISTRATEGIOITA
TRAII 4.3.2011 UEF/tkt/SJ
Tässä 0 on alkutila, tila 5 merkitsee merkkijonon löytymistä. tila
Kuvassa nimetty vain oleelliset siirtymät, kaikki muut siirtymät (taulukossa *) johtavat alkutilaan, kuvassa merkitty harmaalla katkoviivalla. Ohjelmassa automaatti esitetään 2- 0
ulotteisena taulukkona josta kussakin tilassa seuraavana 1
tulevan merkin kohdalta katsotaan mihin tilaan seuraavaksi 2
siirrytään. Tehokkuuden ja yksinkertaisuuden vuoksi myös 3
kaikki siirtymät alkutilaan kannattaa tallettaa.
4
5
Boyer-Moore algoritmi
69
a
1
1
3
1
1
1
merkki
b
c
0
0
2
0
0
0
4
0
0
5
0
0
*
0
0
0
0
0
0
Yllä näimme, että ajassa Θ(n) hakeminen on helppoa. Kahdessa edellisessä algoritmeissä
kukin syötteen merkki käsitellään kerran. Onko mahdollista hakea nopeammin eli hakea
niin, ettei edes kaikkia syötteen merkkejä tarkastella! Seuraava Boyer-Moore algoritmi
pystyy tähän hyppäämällä joidenkin merkkien yli.
Algoritmin perustoiminta on hieman kuten naiivin algoritmin, mutta se pyrkii ohittamaan enemmän merkkejä kerralla. Kunkin siirtymän tarkastus tehdään avaimen lopusta
alkaen. Kun löydetään tekstistä väärä merkki x, voidaan siirtää avainta eteenpäin enemmänkin, riippuen esiintyykö merkki x avaimessa vai ei, ja jos esiintyy, niin missä kohtaa.
Jos tekstin merkkiä x ei löydy koko avaimesta, voidaan siirtyä koko avaimen pituus eteenpäin ja jatkaa siitä. Jos merkki x on avaimessa, siirretään avainta siten, että avaimen merkin x viimeinen esiintymä tulee tekstin merkin x kohdalle ja jatketaan siitä (viimeisen
esiintymän taulukko).
Jos
avai- 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3
men lopusta on jo 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
jokin merkki löy- merkillinen_esimerkikmerkkijono
detty, käytetään
myös toista aputaulukkoa (oikean merkki
loppuosan
taumerkki
lukko) joka kermerkki
too
esiintyykö
merkki
sama
loppuosa
merkki
avaimessa aiemmin. Käytännössä
merkki
otetaan kahden mahdollisen siirtymän maksimi. Siirtymätaulukot rakennetaan ennen
aloittamista ajassa O(m + |Σ|), missä |Σ| on aakkoston koko. Hyvässä (ja keskimääräisessä) tapauksessa itse haku sujuu ajassa O(n/m), pahin tapaus tosin on perusversiolla
O(nm). Käytännössä tämä on varsin hyvä hakualgoritmi (ainakin viritettynä) jos teksti ja
aakkosto ovat kohtuullisen suuria eikä avaimessa ole toistoa.
Likimääräinen hakeminen
Ylläesitetyt algoritmit hakivat annetun avaimen täsmällistä esiintymää. Usein tarvitaan
likimääräistä hakua siten, että sallitaan esimerkiksi muutaman merkin virhe, tai etsitään
lähintä vastinetta. Erityisesti bioinformatiikassa nämä likimääräiset haut ovat tärkeitä.
4. ALGORITMISTRATEGIOITA
70
Määritellään, että merkkijonot A ja B ovat eroavat k merkillä jos seuraavia virheitä
on korkeintaan k kappaletta:
1) Merkki ai on eri kuin bi.
2) Merkki ai puuttuu merkkijonosta B.
3) Merkki bi puuttuu merkkijonosta A.
Esimerkiksi
jono, pino: k = 2.
iso, pino: k = 2.
jono, noki: k = 4.
(4-2)
TRAII 4.3.2011 UEF/tkt/SJ
Kun tekstistä T haetaan avainta P, etsitään ne osat T:stä joissa eroa on enintään k merkkiä.
Virheet ovat muodossa: eri merkki, puuttuva merkki, ylimääräinen merkki. Nyt jos T =
jono, P = noki, niin P löytyy kun k = 2 (alku no löytyy, 2 viimeistä merkkiä ki puuttuvat). Jos merkkijono löytyy jostain kohdasta erolla k–1, se löytyy viereisistä paikoista
erolla k. Esimerkiksi kino, merkkijono. Väärien, puuttuvien ja ylimääräisten yhdistelmä
voi siis olla mikä vain.
Dynaaminen algoritmi likimääräiseen hakemiseen [2]:
Rakennetaan matriisi jossa tekstin merkit ovat sarakkeilla, avaimen merkit riveillä. Indeksoidaan avain 1..m ja teksti 1..n. Matriisin alkio (i, j) kertoo pienimmän virheiden määrän
avaimen osajonolle 1..i kun sitä verrataan merkkijonoon joka päättyy tekstin merkkiin j.
Matriisin rivi 0 alustetaan nolliksi (ei eroja tyhjässä osamerkkijonossa). Matriisin sarake
0 alustetaan indeksillä (kuvaten T:n alusta puuttuvia P:n merkkejä).
merkki
indeksi
P 0
a 1
b 2
a 3
b 4
c 5
T
0
0
1
2
3
4
5
a
1
0
0
1
2
3
4
b
2
0
1
0
1
2
3
c
3
0
1
1
1
2
2
a
4
0
0
1
1
2
3
a
5
0
0
1
1
2
3
a
6
0
0
1
1
2
3
b
7
0
1
0
1
1
2
Matriisin arvot lasketaan riveittäin seuraavasti. Kuhunkin soluun lasketaan
pienin arvo seuraavista:
1) Vasemmanpuoleinen solu
D[i][j–1] +1 (tekstissä ylimääräinen merkki).
2) Ylempi solu D[i–1][j] + 1
(avaimessa
ylimääräinen
merkki).
3) Joko:
c
8
0
1
1
1
2
1
a d c
9 10 11
0 0 0
0 1 1
1 1 2
1 2 2
2 2 3
2 3 2
a
12
0
0
1
2
3
3
b
13
0
1
0
1
2
3
a
14
0
0
1
0
1
2
b
15
0
1
0
1
0
1
c
16
0
1
1
1
1
0
c
17
0
1
2
2
2
1
a
18
0
0
1
2
3
2
merkki
T
a
b
c
a
indeksi
0
1
2
3
4
P
0
0
0
0
0
1
0
0
+1
a
1
1
+1
0
1
+1
b
a
2
3
2
3
1
2
0
+1
+1
+1
+1
1
1
+1
+1
1
1
+1
1
b
19
0
1
0
1
2
3
4. ALGORITMISTRATEGIOITA
71
a) Jos merkit ovat samat: vasemmalla yläpuolella oleva luku D[i–1][j–1] (ei
eroa, merkkijono jatkuu).
b) Jos merkit ovat eri: vasemmalla yläpuolella oleva luku D[i–1][j–1] + 1 (ero,
mutta merkkijono jatkuu).
Matriisin viimeinen rivi näyttää eron ko. tekstin merkkiin (sarakkeelle) loppuvasta osajonosta. Esimerkissä merkkijono abc löytyy merkkijonosta abca erolla 1 loppukohdista 2,
3 ja 4. Alkukohta voidaan selvittää takaperin vasemmalle ylös. On kuitenkin huomattavan, että samaan loppukohtaan voi päättyä useita erilaisia likimääräisiä osumia, näistä
paras osuma etsittäessä ylös/yläviistoon/vasemmalle on pieniarvoisin reitti.
Algoritmi 4-5: Likimääräinen haku dynaamisella ohjelmoinnilla:
LinkedList<Integer> likimaarainenHaku(char[] T, char[] P, int k) {
int n = T.length; int m = P.length;
int[][] D = new int[m+1][n+1];
for (int i = 0; i <= m; i++)
D[i][0] = i;
// alkusiirtymä
TRAII 4.3.2011 UEF/tkt/SJ
for (int i = 0; i <= n; i++)
D[0][i] = 0;
// tyhjä avain
for (int i = 1; i <= m; i++)
// rivi kerrallaan
for (int j = 1; j <= n; j++)
// sarakkeet
if (P[i–1] == T[j–1])
// varsinainen vertailu
D[i][j] = min(D[i–1][j]+1, D[i][j–1]+1, D[i–1][j–1]);
else
D[i][j] = min(D[i–1][j]+1, D[i][j–1]+1, D[i–1][j–1]+1);
LinkedList<Integer> L = new LinkedList<Integer>();
for (int j = 1; j <= n; j++)
if (D[m][j] <= k)
// löytö
L.add(j–1);
// T 0-alkuinen
return L;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Aikavaativuus on selkeästi O(nm), ja vakiokerroin on pienehkö. Yo. algoritmi hakee
kaikki loppukohdat joissa eroavaisuus on ≤k merkkiä, mutta se on helppo muuttaa parhaan osuman etsinnäksi (HT). On olemassa myös monimutkaisempi loppuosapuita käyttävä algoritmi joka suoriutuu tehtävästä O(n(logm + k) ajassa. Käytännössä se on tehokkaampi jos k << m.
Luku 5
TRAII 4.3.2011 UEF/tkt/SJ
Ulkoisen muistin käsittely
Kurssilla tähän mennessä esitetyissä algoritmeissa on oletettu, että kaikki algoritmin
käsittelemät tiedot sopivat kerralla keskusmuistiin. Tämän ansiosta on siirräntään kuluva
aika voitu jättää huomiotta aikavaativuutta arvioitaessa. Rajallinen keskusmuistikapasiteetti aiheuttaa käytännössä sen, että suurta tietomäärää käsiteltäessä huomattava osa tiedoista on algoritmia suoritettaessa saatavilla vain ulkoiselta muistivälineeltä. Ja vaikka
keskusmuistit nykyään ovat kasvaneet käsiteltäviä tietomääriä nopeammin, on syötetieto
joka tapauksessa ladattava massamuistista keskusmuistiin ennen käsittelyä. Edelleen, laitteistojen ja ohjelmistojen epävakavuuden vuoksi päivitetyn tiedon säilyttäminen vain keskusmuistissa on varsin riskialtista. Näin ollen vähänkään tärkeämmissä tietojenkäsittelytehtävissä massamuistia tarvitaan jatkuvasti. Jottei tiedonsiirtoon kuluva aika kasvaisi
kohtuuttomaksi, on suurten tietomäärien käsittelemiseen tarkoitetut algoritmit alun
alkaen suunniteltava sellaisiksi, ettei tietoja tarpeettomasti siirrellä edestakaisin keskusmuistin ja ulkoisen muistin välillä.
Tässä luvussa oletamme massamuistin olevan osittain mekaaninen laite. Liikkuvat
osat (pyörivä levy, liikkuva luku-kirjoituspää, kelattava nauha) aiheuttavat suuria viiveitä
verrattuna (liki) valon nopeudella kulkeviin sähköisiin tai optisiin signaaleihin. Sähköisiin puolijohdemassamuistehin (mm. Flash) ja muihin tulevaisuudessa mahdollisesti edelleen yleistyviin tekniikoihin tässä luvussa esitetyt asiat sopivat vain osittain. Luvun
lopussa onkin lyhyt osio erityisesti Flash-muistin erityispiirteistä.
5.1 Tiedostokäsittely
Keskusmuistin sisältämiin tietoihin voidaan aina viitata yksittäisen tietokentän tai jopa
kentän osan tarkkuudella. Sen sijaan ulkoiselle muistivälineelle talletettua tietoa ei voida
käsitellä edes tietueittain, vaan tietuetta suurempina jaksoina. Jakso on muistivälineeltä tai
muistivälineelle kerrallaan siirrettävä kokonaisuus, jonka sisältöön päästään käsiksi vain,
kun koko jakso on ensin haettu keskusmuistiin. Jaksot talletetaan keskusmuistissa niin
sanottuun puskuriin, joka on tiedonsiirtoon varattu muistialue.
Ohjelmoijan kannalta varsinainen siirräntä massamuistin ja keskusmuistin välillä
on kohtuullisen yksinkertaista. Ohjelmointikielet tarjoavat kutsut ja käyttöjärjestelmä
hoitaa varsinaisen siirrännän.
72
5. ULKOISEN MUISTIN KÄSITTELY
73
Esimerkki 5-1: Tiedoston tietue sisältää kokonaislukukentät A ja B. Jos levyjakson koko
on 512 tavua, mahtuu yhteen jaksoon 128 kokonaislukua á 4 tavua, toisin sanoen
tietueita jaksoon sopii 64 kappaletta. Jos vaikkapa tietueen avain on A-kenttä ja
halutaan muuttaa yhden tietueen B-kentän arvoa, täytyy suorittaa seuraavat toiminnot:
TRAII 4.3.2011 UEF/tkt/SJ
On paikallistettava se levyjakso, joka sisältää halutun tietueen. Peräkkäistiedostossa
tämä edellyttää kaikkien kyseistä jaksoa edeltävien tietueiden hakemista keskusmuistiin ennen kuin haluttu jakso saadaan esiin, mutta suorasaantitiedostosta oikea
jakso saadaan ehkä selville suoraan tietueen avaimen perusteella. Kyseinen jakso
täytyy joka tapauksessa siirtää kokonaan keskusmuistiin. Kun jakso on käytettävissä, täytyy paikallistaa halutun tietueen B-kentän sijaintikohta jaksossa, minkä
jälkeen kentän muuttaminen on mahdollista.
Muutoksen jälkeen koko jakso on vielä vietävä takaisin levylle. Peräkkäistiedoston
tapauksessa joudutaan ehkä jopa muodostamaan kokonaan uusi tiedosto, johon
alkuperäisen tiedoston kaikki muuttumattomat jaksotkin on kopioitava (keskusmuistin kautta). Näistä kolmesta toiminnosta keskimmäinen on nopea, mutta ensimmäinen ja viimeinen ovat hitaita — peräkkäistiedoston tapauksessa jopa erittäin
hitaita.
Massamuistin hitauden huomioiminen
Algoritmeja analysoidessamme olemme tähän asti olettaneet kaikkien operaatioiden vievän vakioajan ja yksinkertaistaneet aikavaativuudet kertaluokkatarkastelulla. Tyypillistä
massamuistia (magneettista kovalevyä) käytettäessä kuitenkin hakuoperaation viemä
vakioaika on niin suuri suhteessa paikalliseen operaatioon, että se on syytä ottaa tarkempaan analyysiin. Pelkkä kertaluokkatarkastelukaan ei ole riittävä, sillä miljoonakertainen
vakio suoritusajassa on useimmiten merkitsevä.
Tiedon lukunopeus kiintolevyltä riippuu levyn pyörimisnopeudesta ja tiedon tallennustiheydestä levyllä. Nopeus paranee jatkuvasti (lähes samaa tahtia prosessoreiden
nopeuden kanssa). Tyypillinen yksittäisen kovalevyn siirtonopeus vuonna 2011 on 30100 Mt/s (kannettavat laitteet), 50-120 Mt/s (perus-PC:t) tai 100-200 Mt/s (teho-PC:t, palvelimet). Vaikka nopeus on pari kertaluokkaa hitaampi kuin keskusmuistin nopeus, kohtuullisen kokoiset syötteet voidaan silti lukea nopeasti levyltä. Suurempi tehokkuusongelma on tiedon paikallistaminen levyltä. Kun käyttöjärjestelmä ja levyohjain ovat paikallistaneet haettavan tiedon sijainnin, siirtää ohjain lukupään oikealle uralle (2-10 ms) ja
odottaa, että levy pyörähtää oikealle kohdalle (0-10 ms). Keskimääräinen hakuaika on
parhaimmillaankin 5-10 ms. Tässä ajassa prosessori voisi suorittaa kymmeniä miljoonia
konekäskyjä, tai levy siirtää satoja kilotavuja tietoa. Kun oikea käsittelykohta on ensin
löydetty, seuraavat peräkkäiset lohkot voidaan lukea levyn pyörimisnopeudella, jopa 4
µs/lohko, 125 Mt/s.
5. ULKOISEN MUISTIN KÄSITTELY
74
Esimerkki 5-2: Satunnaishaku vs. peräkkäishaku (Hitachi US10K300)
Yhteen lohkoon mahtuu 512 tavua,
Tietueen koko on 25 tavua, lohkoon mahtuu 20 tietuetta.
Keskimääräinen hakuaika 5 ms
Lohkon kesto 0,005 ms (100 Mt/s)
• Satunnaisessa järjestyksessä 5 ms / tietue = 200 tietuetta/s
• Peräkkäisessä järjestyksessä 0,005 / 20 = 0,25 µs / tietue = 4 000 000 tietuetta/s
• 100 000 tietuetta (2,5 Mt) : 8 min vs. 0,025 s.
•
•
•
•
Peräkkäisen nopeuden ja oikean levynkohdan paikantamisen nopeusero on niin suuri, että
se on syytä ottaa huomioon algoritmia suunniteltaessa. Erityisesti koska massamuistia
käyttävät algoritmit useimmiten käsittelevät suuria syötteitä (jolloin algoritmin valinta on
aina tärkeämpää). Edelleen, tuhannen..miljoonan kokoiset vakiot saattavat aiheuttaa yllättäviä viiveitä.
TRAII 4.3.2011 UEF/tkt/SJ
Esimerkki 5-3: Aikavaativuus tulkittuna ajaksi.
n
n2 × 5 ms
n2 × 0,25 µs +
n × 5 ms
nlogn × 0,25µs +
logn × 5 ms
10
0,5 s
50 ms
20 ms
100
50 s
502 ms
35,2 ms
1 000
1,4 t
5,25 s
52,5 ms
10 000
5,8 pv
75 s
105 ms
100 000
1,6 v
50 min
510 ms
1 000 000
158 v
71 t
5,1 s
10 000 000
15855 v
290 pv
60,1 s
Vertaillaan kolmea algoritmia, a) O(n2) algoritmi, joka viittaa tietoihin satunnaisesti, b) O(n2) algoritmi, joka tekee kunkin iteraation peräkkäin ja c) O(nlogn) algoritmi, joka tekee iteraation peräkkäin. Käytetään edellisiä oletuksia, eli levyhaku
kestää 5 ms, kunkin jakson lukeminen 0,05 ms ja tietueen lukeminen siten 2,5 µs.
Satunnaisesti viittaava algoritmi on käyttökelvottoman hidas jo pienilläkin syötteillä. Myös O(n2) algoritmin suorituskyky on rajallinen. O(nlogn) algoritmi sensijaan suoriutuu kohtuullisessa ajassa suuristakin syötteistä. Ylläoleva taulukko antaa
aikavaativuuksista konkreettisen esimerkin.
Puskurien käyttö
Jos käsittelemme vain yhtä tiedostoa ja yhdestä kohdasta kerrallaan, voimme useimmiten
helposti järjestää käsittelyn peräkkäiseksi. Jos sensijaan ongelma on luonteeltaan useaa
tiedostoa käsittelevä, tai joudumme käsittelemään samaa tiedostoa useasta kohdasta,
tilanne on hankalampi. Lukukohdan vaihtaminen tiedostosta toiseen vaatii lukupään siirtämisen joka on hidasta. Toistuva lukupään siirtely tiedostosta toiseen on yhtä hidasta
kuin satunnaiskäsittely yhdessä tiedostossa. Emme tietenkään täysin voi välttyä lukupään
siirtelyltä tiedostojen välillä, mutta voimme vähentää sitä huomattavasti. Lukemalla tietoa
5. ULKOISEN MUISTIN KÄSITTELY
75
kerralla enemmän keskusmuistiin (puskureihin) ennen seuraavaan tiedostoon siirtymistä
harvenevat siirtelyt. Vastaavasti voimme tallettaa levylle kirjoitettavaa tietoa keskusmuistin puskuriin jonkin aikaa ja vasta myöhemmin kirjoittaa suuremman määrän tietueita kerralla.
Esimerkki 5-4: Peräkkäiskäsittely, kaksi syötetiedostoa A ja B, yksi tulostiedosto C, käsitellään tiedostoja "samanaikaisesti" (vuorotellen).
• Esimerkiksi C[i] = A[i] + B[i]; tai jotain hyödyllistä O(n) työ.
• Edelliset oletukset (512 t/lohko, 25 t/tietue, 20 tietuetta/lohko, 1000000 tie-
•
•
TRAII 4.3.2011 UEF/tkt/SJ
•
•
•
•
•
tuetta, 25 Mt tiedosto, hakuaika 5 ms, lohkon lukuaika 0,005 ms).
ei puskurointia (1 tietue)
• luetaan 1 kpl A:ta, 1 kpl B:tä, kirjoitetaan 1 kpl C:tä, luetaan 1 kpl A:ta, jne
• 3 × 5 ms / 1 tietue = 67 tietuetta/s = 4t.
yksi puskuri/tiedosto (1,5 kt):
• luetaan 20 kpl A:ta, 20 kpl B:tä, kirjoitetaan 20 kpl C:tä, jne
• 3 × (5 ms + 0,005 ms) / 20 tietuetta = 1332 tietuetta/s = 750 s.
10 puskuria/tiedosto (15 kt):
• luetaan 200 kpl A:ta, 200 kpl B:tä, kirjoitetaan 200 kpl C:tä, jne
• 3×(5ms+10×0,005 ms) / 200 tietuetta = 13200 tietuetta/s = 76 s.
100 puskuria/tiedosto (150 kt):
• luetaan 2000 kpl A:ta, jne
• 3×(5ms+100×0,005ms)/2000 = 121000 tietuetta/s = 8,3s.
1000 puskuria/tiedosto (1,5 Mt):
• 20000 kpl
• 3 × (5 ms + 1 000 × 0,005 ms) / 20000 = 667000 tietuetta/s = 1,5 s.
10000 puskuria/tiedosto (15 Mt):
• 200000 kpl
• 3×(5ms+10000×0,005 ms)/200000 = 1212000 tietuetta/s = 0,82s.
100000 puskuria/tiedosto (150 Mt): (tiedostoa suurempi!)
• 200000 kpl
• 3 × (5 ms + 100000 × 0,005 ms) / 2000000 = 1320000 tietuetta/s = 0,75 s.
Jälleen kerran massamuistin käyttö saattaa aiheuttaa yllätyksen (1000-kertainen nopeus)
vaikka kertaluokkatarkastelussa aikavaativuus olisikin sama. Jonkinlaista puskurointia
siis tarvitaan käsiteltäessä useaa tiedostoa vuorotellen. Järjestelmä yleensä automaattisesti puskuroi muutaman levyjakson tai jopa kokonaisen uran (track) verran, mutta silti
jää jäljelle 10-100 -kertainen parannusmahdollisuus. Kohtuuttoman suuret puskurit vaativat runsaasti keskusmuistia, eivätkä siten yleensä ole tarpeen. Suuren puskurin varaaminen ja kertaalleen käyttäminen saattaa vaatia sivutusta tms. ylimääräistä työtä ja olla jopa
hitaampi. Yo. tapauksessa kohtuullinen kompromissi olisi esimerkiksi 1000 puskuria/tiedosto, ellei muisti ole erityisen tiukalla.
5.2 Onko keskusmuisti ulkoista muistia?
Tietokoneita ei juuri ole toteutettu suoraviivaisella prosessori-keskusmuisti-massamuisti
-periaatteella enää pitkään aikaan. Todellisuudessa muistitasoja on useita. Syynä tähän
5. ULKOISEN MUISTIN KÄSITTELY
76
Muisti
Viive
kellojaks.
Kaista,
Mt/s
Tilavuus,
tavua
Lohko,
sanaa
rekisterit
0
100000
64 - 1024 t
1
1-tason välimuisti
1-2
30000
16 - 128 kt
~4
2-tason välim. (pros)
3-6
20000
0,25-12 Mt
~8
3-tason v. (emolevy)
5 - 20
1000
4 - 32 Mt
~16
keskusmuisti
100-500
8000
1 - 100 Gt
~16
kiintolevy(stö)
2 - 30 M
120
0,1-50 Tt
~128
nauha(roboti)t
200 G
30
0,1 - Tt
~128
ovat kompromissit muistin tilavuuden, nopeuden ja hinnan suhteen. Muistitasoja on
yleensä vähintään viisi. Taulukko 5-1 esittää erään tyypillisen jaottelun. Taulukon mukaisia arvoja esiintyy tietokoneissa vuonna 2008, mutta tarkoituksena ei ole antaa tarkkoja
arvoja, vaan demonstroida muistihierarkian monitasoisuutta ja säännönmukaisuutta.
Huomaamme, että keskusmuistiin (DRAM) viittaaminen on noin kaksi kertaluokkaa hitaampaa kuin välimuistioperaatio. Kuitenkin, kun ensimmäinen tavu on muistista
saatu, seuraavat saadaan varsin nopeasti. Näin ollen tässä luvussa esitellyt massamuistin
peräkkäisyyden huomioonottavat tekniikat hyödyttävät myös keskusmuistia käyttäviä
algoritmeja. Erityisesti taulukoiden läpikäynnin olisi syytä mahdollisuuksien mukaan olla
peräkkäistä. Tarkempi välimuistioptimointi on tärkeää huippusuorituskykyä haettaessa,
mutta se ei ole tämän kurssin asia.
Esimerkki 5-5: Taulukon käsittely peräkkäin/hajasaantina: cs-palvelin (prosessorit
1,28GHz UltraSparc IIIi, 64kt 1-tason välimuisti, 1Mt 2-tason välimuisti)
2048
1024
Mt/s
TRAII 4.3.2011 UEF/tkt/SJ
Taulukko 5-1: Muistihierarkia.
512
256
128
64
32
suora
väli 257
16
16
64
256
1024
4096
16384
65536
6
262144
e+0 43e+06 2e+07
858
77
4
0
.
4.19
1
1.67
Taulukon koko (tavua)
5. ULKOISEN MUISTIN KÄSITTELY
77
TRAII 4.3.2011 UEF/tkt/SJ
Flash-muistin erikoisuudet
Flash-muisti on sähköisesti pyyhittävää ohjelmoitavaa lukumuistia (Electrically-Erasable
Programmable Read-Only Memory, EEPROM). Siis vain kerran kirjoitettavaa muistia
joka voidaan kuitenkin pyyhkiä sähköisesti. Tavallisesta, koko piilastu kerralla tyhjennettävästä, EEPROM:sta poiketen flash-muistin tyhjennys voidaan tehdä lohkoittain. Tyhjä
muisti sisältää vain ykkösiä (bittejä), kirjoittaa voidaan vain nollia. Pyyhkiminen palauttaa kaikki ykkösiksi. Flash -muisteja on kahta päätyyppiä: NOR (vanhempi tavuperustainen) ja NAND (uudempi, tihempi ja edullisempi).
Flash-muistin lukeminen lähes yhtä nopeaa kuin DRAM:sta (varsinkin NOR-tyypin
— NAND -tyypillä on pidempi viive). Lukeminen ja kirjoittaminen voidaan tehdä tyypistä riippuen joko tavu kerrallaan (NOR) tai sivu (0,5-2kt) kerrallaan (NAND). Tyhjentäminen tehdään suuremmissa lohkoissa, tyypillisesti 128kt. Tyhjennys–kirjoitus sykli
voidaan tehdä vain 100.000 – 1.000.000 kertaa. Jotta muisti "kuluisi" tasaisesti, lohkoja
kierrätetään siirtämällä vähän muutettuja tietoja paljon käytetyille alueille ja paljon muutettuja tietoja tallennetaan vähän käytetyille alueille. Tiedon suora muuttaminen ei siis ole
mahdollista, vaan muuttaminen tehdään kopioimalla uusi tieto ja loppuosa vanhasta
sivusta toiseen paikkaan. Näin ollen yhden tavun kirjoittaminen on hitaampaa kuin koko
sivun kirjoittaminen. Tyypistä riippuen, kirjoittaminen vie jopa 0,5ms. Tyhjentämisoperaatiot vievät aikaa useita millisekunteja, jopa sekunnin.
Yksittäinen Flash-siru on kaistanleveydeltää vielä vaatimaton verrattuna nykyaikaiseen kovalevyyn. Suuremman tiedoston lukemis- ja erityisesti kirjoittamisnopeutta voidaan lisätä käyttämällä vuorotellen (oikeastaan rinnakkain) useita eri muistipankkeja
(siruja). Näin suurempien massamuistien lukunopeus voi olla jopa 1Gt/s, mutta hinta
luonnollisesti nousee vastaavasti korkeammaksi.
Käyttöjärjestelmän ja/tai muistikortin ajuri ja tiedostojärjestelmä piilottavat kulutuskierrätyksen ja muut detaljit käyttäjältä. Kuitenkin, optimoitaessa ohjelman toimintaa
mobiililaitteille, voidaan hyödyntää Flash:n vahvuuksia (nopeaa lukemista) ja välttää
liian pieniä kirjoitusoperaatioita puskuroimalla tietoja.
5.3 Ulkoinen lajittelu (*)
Ulkoiselle muistivälineelle talletettuja tietoja käsiteltäessä valtaosa ajasta kuluu jaksojen
siirtelyyn. Usein jakso käsitellään keskusmuistissa (paljon) nopeammin kuin mitä jakson
siirtämiseen kuluu aikaa. Käytännössä siirräntäajan kertaluokka voi olla esimerkiksi satakertainen käsittelyajan kertaluokkaan nähden. Jottei siirräntä veisi turhaan aikaa, kannattaa tiedot yleensä ennen varsinaista käsittelyä lajitella johonkin tarkoituksenmukaiseen
järjestykseen. Koska lajittelukin edellyttää tässä tapauksessa siirräntää, eivät kurssilla
aiemmin esitetyt lajittelualgoritmit ole käyttökelpoisia, sillä niitä suoritettaessa alkioita
haettaisiin jatkuvasti esiin eri kohdista tiedostoa.
Niin sanottu lomituslajittelualgoritmi sopii hyvin ulkoisen lajittelun algoritmiksi.
Lomituslajittelun idea on muodostaa tiedoston sisällöstä yhä pitempiä ja pitempiä lajiteltuja osajonoja, kunnes osajonoja on jäljellä enää yksi: lajiteltu tiedosto. Pitemmät osajonot muodostetaan lyhempiä osajonoja keskenään yhdistelemällä eli lomittamalla.
Lomituslajittelu etenee peräkkäisten vaiheiden kautta. Vaiheen k aluksi oletetaan
lajiteltavien tietueiden olevan kahdessa tiedostossa f2 ja f2 siten, että
5. ULKOISEN MUISTIN KÄSITTELY
78
TRAII 4.3.2011 UEF/tkt/SJ
1) molemmat tiedostot sisältävät k:n tietueen lajiteltuja osajonoja,
2) osajonojen lukumäärä f1:ssä eroaa enintään yhdellä f2:n osajonojen lukumäärästä,
3) enintään yhdessä tiedostoista saa olla viimeisenä vajaa osajono ja
4) mahdollisen vajaan osajonon sisältävässä tiedostossa on osajonoja vähintään
yhtä monta kuin osajonoja on toisessakin tiedostossa.
Näiden oletusten vallitessa lajittelu käy seuraavasti: Luetaan kummastakin tiedostosta k
tietuetta (eli osajono) ja lomitetaan nämä tietueet yhdeksi järjestyksessä olevaksi jonoksi,
joka viedään aputiedostoon. Kun menettelyä toistetaan käyttäen vuorotellen kahta aputiedostoa, pysyvät vaaditut neljä oletusta voimassa koko lajittelun ajan. Lajittelun alkaessa
oletukset saadaan voimaan jakamalla tietueet kahteen aputiedostoon, jolloin k = 1.
Osajonon pituuden kasvaessa osajonoa ei luonnollisesti voida käsitellä kokonaisena, mutta tämä ei lajittelua haittaa: osajonosta haetaan esiin jakson kokoisia palasia
perätysten sitä mukaa kun edellinen palanen on ennätetty käsitellä kokonaan. Algoritmin
kannalta jaksotuksesta ei tarvitse lainkaan välittää, sillä tietueet voidaan käsitellä yksitellen siinä järjestyksessä, missä ne osajonossa kulloinkin ovat. Pohjimmiltaan lomituksessa
on näet kyse kahden lajitellun listan lomittamisesta, mihin riittää tuntea ensimmäinen
vielä lomittamaton alkio kummastakin listasta. Siirrännästähän huolehtii käyttöjärjestelmä!
Lomitus toistetaan O(logn) kertaa ja kullakin toistokerralla käsitellään kaikki n tietuetta. Jos jaksoon sopii b tietuetta, tarvitaan kaikkiaan O((nlogn)/b) jakson siirtoa. Vielä
tätäkin vähemmällä selvitään, kun aluksi muodostetaan keskusmuistissa niin pitkiä osajonoja kuin suinkin on mahdollista, jolloin yhden tietueen osajonojen asemesta päästään
lomittamaan pitempiä osajonoja. Tällöin on tosin tunnettava joitakin toteutusympäristön
ominaisuuksia. — Aiemmin esitettyjä lajittelualgoritmeja käytettäessä jaksojen siirtoja
tarvittaisiin O(n2 /b) eli olennaisesti enemmän kuin lomituslajittelua sovellettaessa.
Lomituslajittelu sopii hyvin myös sisäiseen lajitteluun, jolloin siirräntää ei tarvita
lainkaan. Aikavaativuus on silloin O(nlogn), toisin sanoen teoreettisesti paras mahdollinen. Myös käytännössä lomituslajittelu on varteenotettava vaihtoehto muille aikavaativuudeltaan samaa kertaluokkaa oleville lajittelualgoritmeille.
Jos käytettävissä on 2m ulkoista muistiyksikköä ja näille kullekin oma tiedonsiirtoväylä, voidaan yksinkertainen lomituslajittelu yleistää monilomitukseksi käsittelemällä
kahden tiedoston asemesta m tiedostoa ja tuottamalla vastaavasti m aputiedostoa. Silloin
lomitusvaiheita tarvitaan O(logm n) eikä O(log2 n). Käytännössä m ei tosin voi kasvaa
miten suureksi hyvänsä, sillä keskusmuistitila asettaa rajan kerrallaan käsiteltävissä olevan tiedon määrälle. Jokainen muistiyksikkö vaatii nimittäin oman puskurinsa ja kaikki
puskurit sijaitsevat keskusmuistissa.
Lomitus onnistuu myös m+1 tiedostoa käyttäen, kun lomitetaan osajonoja m tiedostosta yhteen aputiedostoon ja vuorotellaan eri tiedostoja aputiedostona. Jos vaikkapa
m = 2 (eli käytetään kolmea tiedostoa), menetellään seuraavasti: Aluksi tiedostot f1 ja f2
sisältävät yhden tietueen pituisia osajonoja. Näitä lomitetaan tiedostoon f3, kunnes f1 tyhjenee [oletetaan, että |f1| < |f2|]. Seuraavaksi lomitetaan loput yhden tietueen pituiset
osajonot tiedostosta f2 tiedoston f3 kahden tietueen osajonojen kanssa tiedostoon f1, jolloin saadaan kolmen tietueen osajonoja. Tästä jatketaan lomittamalla loput f3:n osajonot
TRAII 4.3.2011 UEF/tkt/SJ
5. ULKOISEN MUISTIN KÄSITTELY
79
f1:n osajonojen kanssa f2:een, jonne muodostuu viiden tietueen osajonoja. Näin lajittelu
etenee, kunnes kaikki tietueet saadaan lomitetuksi yhteen tiedostoon.
Äskeistä lajittelumenetelmää kutsutaan monivaiheiseksi lomitukseksi. Sen kuluessa käsiteltävien osajonojen pituudet ovat 1, 1, 2, 3, 5, 8, 13, 21, … eli kyseessä on niin
sanottu Fibonaccin jono, jonka kaksi ensimmäistä termiä ovat ykkösiä ja muut termit saadaan laskemalla kahden edellisen termin summa. Jotta monivaiheinen lajittelu todella
tuottaa lajitellun tiedoston, täytyy tiedostoissa f1 ja f2 alkujaan olla Fibonaccin jonon kahden peräkkäisen termin ilmaisema määrä tietueita, vieläpä siten, että tiedostossa f1 on
näistä kahdesta termistä pienemmän ja tiedostossa f2 suuremman ilmaisema tietuemäärä.
Ellei näin ole, voidaan tiedostoihin lisätä tarpeellinen määrä valetietueita, jotka lopuksi
erotellaan pois lajitellusta tiedostosta.
Tiedostoja käsiteltäessä oletetaan usein, että jakson siirräntään kuluu aikaa enemmän kuin jakson käsittelyyn. Näin ei tosiasiassa aina ole laita. Esimerkiksi tietueita lajiteltaessa saattaa tietueiden järjestyksen selvittäminen käydä hitaammin kuin jakson siirtäminen. Tällaisessa tilanteessa käsittelyä voidaan tehostaa käyttämällä yhden puskurin
asemesta useampia puskureita: osa puskureista sisältää vielä käsittelemättömiä tietueita,
yhteen puskuriin luetaan jaksoa tiedostosta, yhteen puskuriin muodostetaan uutta jaksoa
ja yhden puskurin sisältöä kirjoitetaan tiedostoon. Useiden puskureiden käyttämisestä on
hyötyä vain siinä tapauksessa, että muistiyksikköjä ja tiedonsiirtoväyliä on käytettävissä
riittävästi ja eri puskureissa olevien tietojen siirtäminen samoin kuin käsitteleminenkin
onnistuu samanaikaisesti. Esimerkiksi lomituslajittelu sujuu mainiosti, jos käytettävissä
on kuusi syötepuskuria. Lomitus onnistuu jopa neljälläkin syötepuskurilla.
5.4 Tiedosto kokoelmana (*)
Tiedostoihin kohdistetaan tyypillisesti operaatiota, jotka ovat samoja kuin kurssin alkuosassa nähdyt kokoelmiin kohdistuvat perusoperaatiot:
1) F.add()
lisää tietueen tiedostoon (loppuun).
2) F.remove()
poistaa tiedostosta tietueen.
3) F.update(i)
muuttaa tiedoston tietueen i sisältöä. Tietueen avainta ei saa muuttaa.
4) F.get(i)
hakee tiedostosta tietueen i.
Poistettava, muutettava tai haettava tietue yksilöidään tietueen avaimen tai jonkin muun
tiedon perusteella.
Etenkin haku, mutta myös poisto ja muuttaminen voivat kohdistua useampaankin
kuin yhteen tietueeseen. Tiedostoon lisäämistä puolestaan rajoitetaan usein niin, ettei
ainakaan tiedostoon jo ennestään sisältyvän tietueen kanssa täysin samanlaista tietuetta
saa toistamiseen tiedostoon viedä. Tavallisesti jopa kahden sama-avaimisen tietueen
esiintyminen tiedostossa kielletään. Näiden rajoitusten seurauksena tiedostoa voidaan itse
asiassa pitää joukkona, jonka alkioiden käsittely vaatii keskusmuistin ja ulkoisen muistin
välistä siirräntää.
5. ULKOISEN MUISTIN KÄSITTELY
80
Tiedostossa lisäys (ja poisto) ovat järkeviä (mahdollisia) vain tiedoston loppuun.
Jos alkioita poistetaan keskeltä, ei niitä fyysisesti poisteta, vaan vain merkitään poistetuksi. Haku tiedostosta tapahtuu yleensä järjestysnumeron perusteella (kuten taulukosta),
tai peräkkäin selaamalla jollei numeroa tiedetä. Jos tiedosto on järjestetty avaimen
mukaan, voidaan toki käyttää binäärihakua, mutta jäljempänä esitetään parempiakin vaihtoehtoja.
java.io.RandomAccessFile
http://java.sun.com/javase/6/docs/api/java/io/RandomAccessFile.html
TRAII 4.3.2011 UEF/tkt/SJ
Ylläesitetystä yleisestä tietuepohjaisesta hajasaantitiedostosta poiketen, Javan hajasaantitiedostoliittymä indeksoidaan tavuilla. Tietueittain käsittely onnistuu kuitenkin helposti
kun tietuenumero kerrotaan tietueen koolla.
1) RandomAccessFile RAF = new RandomAccessFile(tiedosto, tapa)
avaa uuden hajasaantitiedoston käsittelijän, tiedosto on tiedoston nimi (String)
tai aiemmin luotu File-ilmentymä. tapa on "r": vain luku, "rw" : lukukirjoitus,
"rws" / "rwd" : varmista kirjoitus levylle.
2) void RAF.seek(long paikka)
siirrä luku/kirjoituskohta tavun paikka edelle.
3) int RAF.skipBytes(int n)
siirrä luku/kirjoituskohtaa n tavua eteenpäin.
4) long RAF.getFilePointer()
palauttaa tämänhetkisen luku/kirjoituskohdan.
5) byte RAF.read(), int RAF.read(byte[] b), int RAF.read(byte[] b, int off, int len),
XXX RAF.readXXX()
Lukee tavun/b.length/len tavua / yhden XXX (perustyyppejä):n.
6) RAF.write(b), RAF.write(byte[] b), RAF.write(byte[] b, int off, int len),
RAF.writeXXX(XXX)
Kirjoittaa tavun/b.length/len tavua / yhden XXX:n.
7) long RAF.length()
Palauttaa tiedoston pituuden.
8) void RAF.close()
sulkee tiedoston.
Tiedostojen organisointitapoja
Tiedosto-operaatioiden aikavaativuuteen vaikuttaa olennaisimmin se, millä tavoin käsiteltävä tiedosto on organisoitu. Tarkastellaan seuraavaksi joitakin organisointitapoja ja niiden vaikutusta operaatioihin.
Sarjatiedostossa tietueet ovat peräkkäin ilman varsinaista järjestystä. Avainta ei
sarjatiedoston tietueilla tarvita välttämättä lainkaan. Sarjatiedostosta hakeminen on
hidasta, koska tietueen paikallistaminen edellyttää tiedoston järjestelmällistä läpikäyntiä.
Sen sijaan tietueen lisäys käy helposti: tietue voidaan aina lisätä tiedoston loppuun. Tietuetta poistettaessa fyysistä poistamista ei välttämättä tehdä heti, vaan tietue ainoastaan
merkitään poistetuksi; varsinainen poisto tehdään vasta sitten, kun tiedosto kirjoitetaan
uudelleen, jolloin voidaan samalla kertaa poistaa useita tietueita.
TRAII 4.3.2011 UEF/tkt/SJ
5. ULKOISEN MUISTIN KÄSITTELY
81
Peräkkäistiedosto on muuten kuten sarjatiedosto, mutta tietueet lajitellaan avaimen
mukaiseen järjestykseen. Yleensä avain yksilöi tietueen, mutta joka tapauksessa samaavaimiset tietueet sijaitsevat peräkkäistiedostossa toisiinsa nähden välittömästi perätysten. Lisäysoperaatio kohdistuu usein tiedoston alkuun tai keskelle, jolloin tiedostosta on
muodostettava uusi kopio. Tietueita kopioitaessa jätetään poistetuksi merkityt tietueet
luonnollisesti kopioimatta.
Sen paremmin sarja- kuin peräkkäistiedostokaan ei tue tietueen välitöntä saantia,
vaan edellyttää tiedoston läpikäyntiä tietue tietueelta tiedoston alusta lähtien. Välitön
saanti eli niin sanottu suorasaanti on yksinkertaisimmillaan mahdollista suhteellisesta tiedostosta, jossa tietue yksilöidään järjestysnumeron perusteella. Tiedoston ensimmäisen
tietueen järjestysnumero on 1, toisen tietueen järjestysnumero on 2 ja niin edelleen.
Lisäysoperaatio sallitaan vain tiedoston loppuun ja tietueen poistaminen ei aiheuta fyysistä poistamista. Näin kyetään tietueiden järjestysnumerot säilyttämään ennallaan kaikissa muutoksissakin.
Tietueen välitön saanti suhteellisesta tiedostosta edellyttää tietueen järjestysnumeron tuntemista. Useinkaan tietueilla ei ole järjestysnumeroksi sopivaa ominaisuutta valmiina, vaan järjestysnumerot on jollakin tavoin kiinnitettävä tietueisiin. Tässä tarvitaan
apuna hajautusta, jota soveltaen saadaan hajatiedosto. Hajautuksessa tietueen avaimesta
lasketaan hajautusfunktion avulla tietueelle niin sanottu kotiosoite, joka ilmaisee tietueen
talletuspaikan. Kotiosoite voi olla esimerkiksi tietueen talletuspaikan järjestysnumero tiedoston alusta lukien. Silloin on kyse suljetusta hajautuksesta, jossa hajatiedosto toteutetaan suhteellisena tiedostona. Suljetun hajautuksen vaihtoehto on avoin hajautus, jossa
kotiosoite on niin sanotun solutaulun indeksi ja todellinen talletusosoite saadaan solutaulusta. Koska hajautusfunktiot käytännössä tuottavat eri avaimista samoja kotiosoitteita,
täytyy hajautettaessa varautua tällaiseen osoitteiden yhteentörmäykseen jollakin tavoin.
Suljetun hajautuksen tapauksessa tietue talletetaan kotiosoitettaan lähinnä seuraavaan
vapaaseen osoitteeseen, ellei kotiosoite ole vapaa. Avoimessa hajautuksessa puolestaan
menetellään niin, että solutaulun alkio ei sisälläkään yhden alkion osoitetta, vaan listan
osoitteen: kaikki samaan kotiosoitteeseen kuuluvat tietueet viedään kyseiseen listaan.
Esimerkiksi tietuetta haettaessa lasketaan ensin kotiosoite ja tarkistetaan, onko kyseisessä
osoitteessa se tietue, jota ollaan hakemassa. Ellei näin ole, etsitään tietuetta sieltä, minne
kyseiseen osoitteeseen kuuluvat yhteentörmänneet tietueet on talletettu. Jollei tietuetta
sittenkään löydy, ei etsittyä tietuetta ole tiedostossa lainkaan.
Mikäli hajautusfunktio on hyvä eli se hajauttaa tietueet tasaisesti hajautusosoiteavaruuteen, löytyy tietue yleensä viimeistään toisella yrittämällä. Tällöin tietueen etsiminen
vaatii korkeintaan kahden jakson siirräntää. Huono hajautusfunktio voi sen sijaan antaa
saman kotiosoitteen miltei kaikille tietueille, jolloin hajautuksesta ei ole todellista hyötyä.
Hajautuksen onnistumiseen vaikuttaa luonnollisesti myös hajautettavien avainten
jakauma: avaimet sopivasti valitsemalla saadaan runsaasti samoja kotiosoitteita.
Silloin kun tiedostoa halutaan hajakäsittelyn lisäksi käsitellä usein myös avainten
mukaisessa peräkkäisjärjestyksessä, on hajautus huono organisointivaihtoehto. Peräkkäiskäsittely näet edellyttää pahimmillaan kaikkien mahdollisten avainten kotiosoitteiden
laskemista ja kunkin osoitteen sisältävän jakson sekä mahdollisesti muutaman muunkin
jakson siirräntää ennen kuin edes saadaan selville, onko kyseistä avainta vastaavaa tietuetta olemassakaan! Mahdollisten avainten joukko on miltei aina huomattavasti laajempi
TRAII 4.3.2011 UEF/tkt/SJ
5. ULKOISEN MUISTIN KÄSITTELY
82
kuin todella käytössä olevien avainten joukko, joten olemattomien tietueiden etsimisestä
aiheutuu huomattavasti ylimääräistä työtä.
Sekä tehokas peräkkäiskäsittely että nopeahko hajakäsittely mahdollistuvat, kun
tiedosto organisoidaan osoitteelliseksi peräkkäistiedostoksi, johon tietueet talletetaan
avaimen mukaiseen peräkkäisjärjestykseen, mutta kunkin jakson ensimmäisen (viimeisen) tietueen avain sekä jakson tunnistetiedot talletetaan myös hakemistoon. Hakemistoa
tutkimalla saadaan nopeasti selville, mihin jaksoon etsitty tietue sisältyy, joten vain yksi
jakson siirto on tarpeen. Hyvin suuren tiedoston hakemisto rakennetaan monitasoiseksi,
jolloin tarvitaan lisäksi muutama hakemistojakson siirto, muttei niitäkään kovin monta.
Osoitteellisen peräkkäistiedoston jaksoihin jätetään tavallisesti jonkin verran
vapaata tilaa, jottei tietueen lisääminen aina aiheuttaisi raskasta hakemiston uudelleenrakentamista. Vapaa tila on huomattava ohittaa peräkkäiskäsittelyn yhteydessä. Vastaavasti
tietueen poistaminen jättää jaksoon vapaata tilaa, joka voidaan tilaisuuden tullen käyttää
uudelleen. Tietueita voidaan jakson sisällä siirrellä jakson alkua kohti, jolloin vapaa tila
kertyy jakson loppuun, mutta jaksoja ei kovin tiheään kannata yhdistellä, koska jaksojen
yhdistämisen seurauksena on hakemistoonkin tehtävä muutoksia. Jakson ensimmäisen
(viimeisen) tietueen poistaminen vaikuttaa luonnollisesti aina myös hakemistoon. Samoin
käy, kun tiedoston koko kasvaa, jolloin jo olemassaolevia jaksoja jaetaan kahtia.
Jos tietueita haetaan toistuvasti muutenkin kuin avaimen perusteella, kannattaa
ehkä perustaa käänteistiedosto. Käänteistiedostoon kootaan kustakin tietueesta joidenkin
kenttien sisällöt eli toissijainen avain sekä joko tietueen osoite tai ensisijainen avain, joten
toissijaisen avaimen perusteella tietueita etsittäessä haetaan ensin käänteistiedostosta
esiin varsinaisten tietueiden löytämiseksi tarvittavat tiedot. Monasti saman toissijaisen
avaimen omaavia tietueita on useita, jolloin samojen toissijaisten avainten toistuvan käänteistiedostoon tallettamisen estämiseksi on löydettävä jokin ratkaisu. Käänteistiedosto
organisoidaan kulloiseenkin tarkoitukseen sopivimmalla tavalla. Voidaan jopa perustaa
useita käänteistiedostoja, jolloin hakumahdollisuudet entisestään monipuolistuvat. Tosin
käänteistiedostojen kunnossapitokin vie oman aikansa, joten jokaisen käänteistiedoston
tarpeellisuus on tarkoin harkittava.
Mainituista tiedoston organisointitavoista tehokkain on hajautus, mutta sitä ei aina
voida soveltaa. Esimerkiksi peräkkäiskäsittelytarve (alkioiden aakkos- tai muussa järjestyksessä) voi olla este hajautukselle. Myös hyvän hajautusfunktion löytäminen saattaa
tuottaa vaikeuksia. Osoitteellinen peräkkäistiedosto on hyvä kompromissi, jos sekä
peräkkäis- että hajasaantia tarvitaan usein. Joskus peräkkäistiedostokin on riittävä; näin
on esimerkiksi silloin, kun hajasaantitarve on satunnaista. Jos tiedosto on aktiivinen, toisin sanoen tiedoston sisältö muuttuu usein tai monia tietueista jostakin muusta syystä joudutaan tarkastelemaan usein, on peräkkäistiedosto yleensä paras ratkaisu. Tiedoston aktiivisuussuhteen eli kullakin läpikäynnillä mielenkiintoisten tietueiden osuuden kaikista tietueista ei edes tarvitse olla kovin korkea, jotta peräkkäistiedosto puoltaisi paikkansa.
Tässä luvussa esiteltyjä tiedoston organisointitapoja voidaan helposti soveltaa myös
kokonaan keskusmuistiin mahtuvan kokoelman käsittelyssä. Vaikka tieto mahtuisikin
kerralla keskusmuistiin, hyvin suuren taulukon varaaminen ei aina ole mahdollista tai
tehokasta. Yo. tiedostojen organisointitavat soveltuvat nimenomaan hyvin suuren taulukon korvaamiseen. Tietojen käsittelyn kannalta olennaisin ero sisäisen ja ulkoisen muistin
välillä on siirrännän tarve, mihin aiheuttaa lisärasitetta se, että kerrallaan siirrettävä tietomäärä on kussakin ympäristössä kiinteä.
5. ULKOISEN MUISTIN KÄSITTELY
83
5.5 B-puu
Tässä luvussa on todettu massamuistissa tärkeintä olevan hakujen määrän minimointi
sekä pitkään yhtenäiseen tiedostoon keskelle lisäysten välttäminen. Hajautus on hyvä
sanakirjan toteutus, mutta se ei tue lainkaan alkioiden läpikäymistä järjestyksessä. Järjestetty peräkkäistiedosto taas on raskas ylläpitää ja alkioin etsintä vaatii (liki) logaritmisen
määrän hakuja. Tasapainotetut hakupuut (AVL/Punamusta/2-3-4/jne) ovat myös korkeudeltaan logaritmisia ja kierto-operaatiot ovat massamuistissa hankalia.
B-puun suunnittelun perustana on fakta, että yhden haun kustannuksella massamuistista saadaan samalla vaivalla luettua esimerkiksi 100kt tietoa. B-puussa hyödynnetään tätä isona lohkona lukemista ja sillä vähennetään hakujen määrä 1-3:een. Tämä idea
esitettiin yllä osoitteellisessa peräkkäistiedostossa, mutta muodostetaan siitä nyt vielä
yleisempi muoto (puu). Kun kerralla kannattaa lukea paljon tietoa, voidaan kuhunkin
puun solmuun tallettaa enemmän tietoa, eli enemmän avaimia. Tällöin puu madaltuu huomattavasti.
Määritelmä 5-6: B-puun solmulla s on xs avainta ja xs +1 lasta siten, että avaimet jakavat
TRAII 4.3.2011 UEF/tkt/SJ
mv
c f j
ab de gh i k l
p s
no q r
zö
t u xy å ä 12
ko. solmun avainalueen osiin. Alkiot järjestetään sisäjärjestykseen (kuten
sisäjärjestetyssä binääripuussa), kutakin alkiota pienemmät alkiot löytyvät vasemmalta, suuremmat oikealta.
Kunkin solmun vasemmanpuoleisimman lapsen avaimet ovat ensimmäistä avainta
pienemmät. Toisen lapsen avaimet ovat suurempia kuin ensimmäinen avain, mutta
pienempiä kuin toinen avain, jne. Viimeisen lapsen avaimet ovat suurempia kuin
viimeinen avain. B-puun kaikki lehtisolmut ovat samalla syvyydellä. Kullakin haarautumissolmulla on t..2t lasta, eli t–1..2t–1 avainta ja lehtisolmulla on t–1..2t–1
avainta. Juurisolmulla voi olla vähemmän lapsia.
B-puun muodostaminen
Luokitellaan solmut lehtisolmuiksi ja haarautumissolmuiksi. B-puu on aluksi (kun siinä
on vain vähän alkioita) vain juurisolmu joka on alussa myös lehtisolmu. Kun juurisolmuun kertyy liikaa (2t–1) avaimia, muodostetaan keskimmäisestä alkiosta uusi juu-
5. ULKOISEN MUISTIN KÄSITTELY
84
risolmu ja muista alkioista muodostetaan juurisolmulle kaksi lasta. Samoin toimitaan
myöhemminkin kun juurella on jo lapsia ja avaimia kertyy juurisolmuun liikaa.
m
c f j mp t z
c f j
p t z
T1 T2 T3 T4 T5 T6 T7 T8
T1 T2 T3 T4
T5 T6 T7 T8
Tämä on ainoa tilanne jossa puun korkeus kasvaa. Samoin tämä on ainoa tilanne jossa millään solmulla on vähemmän kuin t avainta. Lisäys tehdään aina lehtisolmuun kuten binääripuussakin, mutta uuden solmun perustamisen sijaan uusi avain lisätään lehtisolmuun
oikealle kohdalle (siis vaikkapa keskelle).
b nz
TRAII 4.3.2011 UEF/tkt/SJ
T c f j
b nz
g
T T
c f g j T T
b nz
k
T c f g j k TT
Kun lehtisolmu (tai muu solmu kuin juurisolmu) kasvaa liian suureksi, se jaetaan kahtia,
jolloin sen isälle tulee yksi lapsi lisää, ja samalla yksi avain lisää. Tuoksi isän uudeksi
avaimeksi tulee jaettavan solmun keskimmäinen alkio.
b nz
T c f g j k l m TT
b j n z
T cd f g
k l m
T T
Alkioita poistettaessa solmuja vastaavasti yhdistellään jos ne käyvät liian pieneksi, ja
lopulta juuri ja sen lapset voidaan yhdistää. Jos poistoja on vähän, käytännössä riittää
yksinkertainen poistoalgoritmi, eli solmujen yhdistelyyn tai varsinkaan korkeuden vähentämiseen ei tarvitse mennä.
B-puun toteutus massamuistissa
Jotta massamuistin ominaisuuksia voitaisiin hyödyntää parhaalla mahdollisella tavalla,
solmujen koko t on oltava vähintään tuhansia alkioita, jopa kymmeniä tuhansia. Tarkka
koko riippuu tietueiden odotettavasta määrästä, avaimen/tietueen koosta, keskusmuistin
määrästä ja massamuistin ominaisuuksista. Yleensä juurisolmun kopiota pidetään keskusmuistissa (säästetään yksi levyhaku) ja se päivitetään levylle kun siihen tehdään muutoksia. Solmua käsiteltäessä se luetaan muistiin kokonaisuudessaan, solmua muutettaessa
(lisäavain, jako kahtia) vastaavasti kirjoitetaan kokonaisuudessaan). Päivitys koskee
useimmiten vain yhtä solmua, paitsi kun tehdään jako kahtia, joudutaan kirjoittamaan
kolme solmua. Keskusmuistiin haetun solmun sisällä käytetään binäärihakua. Jos
t = 1000, saadaan kahdella levyhaulla vähintään miljardi (109) avainta. Jos t = 10000, saadaan kahdella levyhaulla vähintään biljoona (1012) avainta, yhdelläkin jo sata miljoonaa
(108).
Luku 6
TRAII 4.3.2011 UEF/tkt/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 lievästä monimutkaisuudestaan huolimatta soveltuu varsin hyvin tietorakenteiden toteuttamisen opetuskieleksi.
6.1 Kotelointi ja parametrointi
Jotta ohjelman monimutkaisuus säilyisi kohtuullisena ja jotta abstraktien tietotyyppien
toteutuksia voitaisiin käyttää useasti, erotetaan selkeästi tietotyypin liittymä ja sen toteutus. Ideaalitapauksessa liittymä määrää toteutuksen (ei päinvastoin). Käytännössä tosin ei
kannata määritellä sellaista liittymää jonka toteuttaminen on mahdotonta tai tehotonta.
Toteutusta suunniteltaessa voimme myös ohjata/tarkentaa liittymässä ilmoitettuja asioita
(mikäli se on tarpeen toteutuksen tehokkuuden kannalta). Liittymän dokumentointi tarkentuu vasta toteutuksesta riippuen (jotkin tyypit, aikavaativuudet). Ainakin teoriassa
toteutustavan voi valita vapaasti, jopa vaihtaa liittymää muuttamatta. Käytännössä kuitenkin toteutustapa vaikuttaa ainakin konkreettisten kokoelmien liittymäänkin, esimerkiksi
listan aseman käytös, tai johtaa tarpeettomiin kompromisseihin. Toisaalta esimerkiksi
pinon ja sanakirjan toteutustapa vaikuttaa korkeintaan aikavaativuuksiin, ei lainkaan liittymään.
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ällainen 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.
85
6. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN (*)
86
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.
TRAII 4.3.2011 UEF/tkt/SJ
(Javan) geneeriset kokoelmat
Javan versioon 1.5 (JSE5) 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 (cast) tai implisiittisiä
tyypinmuunnoksia 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ä ongelma 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ä:
Kokoelma<Alkiotyyppi>
Kokoelma<Alkiotyyppi1, Alkiotyyppi2>
(6-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) {
...
(6-2)
Jos alkiotyyppiä halutaan jotenkin rajoittaa (yleensä tarvittaessa jotain ominaisuuksia),
voidaan
käyttää
esittelyä
<E extends HaluttuYläluokka>
tai
<E super HaluttuAliluokka>, esimerkiksi
public class LajittuvaLista<E extends Comparable> {
(6-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>();
(6-4)
6. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN (*)
87
Esimerkiksi:
LinkedList<String> mjonoLista = new LinkedList<String>();
LinkedList<Set<Integer>> lukuJoukkojenlista =
new LinkedList<Set<Integer>>()
(6-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();
(6-6)
TRAII 4.3.2011 UEF/tkt/SJ
Javan kokoelmajärjestelmä (Collection framework)
Javan vakiokirjaston kokoelmien määrittelyssä on pyritty yhtenäistämään kokoelmien
käyttöä. Näin ollen 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.
6.2 Verkot
Verkko muodostuu solmujen ja kaarten joukoista, joten verkon toteuttaminen palautuu
joukkojen toteuttamiseen. 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.
Tyhjää verkkoa muodostettaessa asetetaan verkon solmujen ja kaarten joukot tyhjiksi. Molempia joukkoja varten määritellään omat lisäys, poisto- ja muut mahdollisesti
tarvittavat operaatiot — mahdollisten läpikäyntien varalta vielä omat läpikäyntioperaatiotkin. Itse joukkoja ei sen sijaan välttämättä tarvitse toteuttaa erillisinä, vaan toteutukset
6. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN (*)
88
voidaan yhdistää rakenteeksi, josta tarpeen mukaan käsitellään jotakin osaa. Molemmat
joukot voidaan toki toteuttaa erillisinäkin, jos niin halutaan.
TRAII 4.3.2011 UEF/tkt/SJ
Vierusmatriisi
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.
Vierusmatriisina esitetyn verkon kaarten lisäys tai poisto on helppoa. Sensijaan solmujen lisääminen ja poistaminen matriisista on kohtuullisen työlästä. Suoraviivaisessa
toteutuksessa rivin ja sarakkeen poistaminen vaatii matriisin uudelleentiivistämisen, joka
vie O(n2) ajan. Linkitetyn version ylläpito taas on hankalampaa. Solmujen lisäämisessä
vastaan voi luonnollisesti tulla varatun matriisin jääminen pieneksi, jolloin joudumme
varaamaan kokonaan uuden matriisin ja kopioimaan vanhat tiedot siihen.
Vieruslista
Ohjelman suorituksen aikana muuttuvan verkon toteutus kannattaa valita vierusmatriisia
dynaamisemmaksi, jollei matriisille ole selkeää tarvetta. Lisäksi jos verkko on usein kovin
harva, toisin sanoen siinä on vähän kaaria (e << n2), on vierusmatriisissa runsaasti tarpeettomia alkioita. Silloin kannattaa vierusmatriisin asemesta rakentaa niin sanottu vieruslistaesitys, joka koostuu solmujen tiedot sisältävästä taulukosta (tai listasta tai joukosta) ja
taulukon kuhunkin alkioon liittyvästä naapureiden listasta. Koska kunkin solmun varsinaiset tiedot riittää tallettaa vai kerran, ei naapurilistassa tarvitse mainita muuta kuin naapurin numero sekä vastaavan kaaren tiedot, kuten esimerkiksi kaaren paino. Edes kaaren
alkusolmua ei ole tarpeen toistaa, sillä alkusolmu on sama kaikille saman naapurilistan
kaarille. Suuntaamattomat kaaret joudutaan tässäkin mallissa toteuttamaan kahdesti,
koska kaaren molemmat päätesolmut ovat toistensa naapureita.
Vieruslistaa voidaan käyttää myös tiheän eli runsaasti kaaria sisältävän verkon
toteuttamiseen. Naapurit kannattaa tosin silloin järjestää listan asemesta etsintäpuuksi,
jottei solmusta toiseen johtavan kaaren olemassaolon selvittäminen olisi kovin hidasta.
Viimemainitussa mallissa naapureiden läpikäynti tosin vaikeutuu siihen verrattuna, miten
kätevästi naapurit saadaan haetuksi esiin listasta. Etsintäpuuta on syytä soveltaa silloinkin, kun kaarten joukko halutaan toteuttaa erillisenä, jottei yksittäisen kaaren etsimiseksi
tarvitse tutkia koko kaarijoukkoa. Suuntaamattomien kaarten kaksinkertainen esittäminen
vältetään järjestämällä kaaret esimerkiksi pieninumeroisemman päätesolmun mukaan.
Kuva 6-1 esittää ylläolevan toteutuksen mukaisen talletusrakenteen pienestä nelisolmuisesta verkosta. Kuvan "...":llä merkittyihin kenttiin talletetaan tarpeen mukaan solmujen/kaarten painoja, värejä, tai muuta tietoa. Kuva 6-2 vastaavasti esittää suuntaamatonta
6. ABSTRAKTIEN TIETOTYYPPIEN TOTEUTTAMINEN (*)
89
verkkoa. Nyt kukin kaari on tallennettu verkkoon kahdesti (molempien solmujen vieruslistaan) ja kuvaajat on kytketty ylimääräisellä viittausparilla ylläpidon tehostamiseksi.
Solmut
Kaaret
...
A
...
...
B
A
B
D
C
...
...
C
...
TRAII 4.3.2011 UEF/tkt/SJ
...
...
D
...
Kuva 6-1: Suunnatun verkon vieruslistatoteutus. Solmujen kuvaajien kokoelma voidaan toteuttaa joko listana tai taulukkona.
Solmut
Kaaret
...
A
A
B
...
C
...
B
...
...
...
C
...
Kuva 6-2: Suuntaamattoman verkon vieruslistatoteutus. Kukin kaari on esitetty molempiin suuntiin, kaaren kuvaajat kytketty toisiinsa.
Kirjallisuutta
[1] Aho A. V., Hopcroft J. E., Ullman J.D.: Data Structures and Algorithms. AddisonWesley, 1983.
TRAII 4.3.2011 UEF/tkt/SJ
[2] Apostolico A., Galil Z. (eds.): Pattern Matching Algorithms. Oxford University
Press, 1997.
[3] Cormen T. H., Leiserson C. E., Rivest R. L.: Introduction to Algorithms. MIT Press
1990.
[4] Hämäläinen A.: Tietorakennekirjasto Javalla. Joensuun Yliopisto, Tietojenkäsittelytieteen laitos, 2006.
[5] Katainen T., Meriläinen M., Juvaste S.: Tietorakennekirjasto Pascalilla ja C:llä.
Joensuun Yliopisto, Tietojenkäsittelytieteen laitos, 2000.
[6] Knuth D. E.: The Art of Computer Programming, Volumes 1-3, (2-3ed). AddisonWesley, 1997-1998.
[7] Weiss M. A.: Data Structures and Algorithm Analysis in C. Addison-Wesley, 1997.
[8] Weiss M. A.: Data Structures and Algorithm Analysis in Java. Addison-Wesley,
1999.
90