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