6. Algoritmien suunnitteluparadigmoja Tässä osassa käsitellään erilaisia algoritmien suunnitteluperiaatteita esimerkkien valossa. Esimerkkialgoritmeja pyritään myös analysoimaan vaihtelevalla tarkkuudella. Hajota ja hallitsemenetelmää koskevat asiat perustuvat pääasiassa kirjan [Cor] kappaleeseen 2.3 ja lukuun 9. Dynaamista taulukointia käsitellään saman teoksen luvussa 15 ja ahneita algoritmeja puolestaan luvussa 16. Peruuttavia algoritmeja käsitellään mm. Steven Skienan kirjan [Ski] luvussa 7. 6.1. Hajota ja hallitse-menetelmä Monesti rekursiivinen algoritmi syntyy havaitsemalla, että käsiteltävä tapaus voidaan pilkkoa pienempiin samankaltaisiin tapauksiin, jolloin lopulta päädytään triviaaliin tai muuten helposti ratkaistavaan tapaukseen. Tällaista suunnitteluperiaatetta sanotaan hajota ja hallitsemenetelmäksi (divide-and-conquer approach). Yleisesti hajota ja hallitse-algoritmi koostuu kolmesta osasta: 1. Hajota algoritmi osaongelmiksi. (Divide) 2. Hallitse: Ratkaise osaongelma rekursiivisesti ja kun ongelma on riittävän pieni, ratkaise se suoraan. (Conquer) 3. Yhdistä osaongelmien ratkaisut alkuperäisen ongelman ratkaisuksi. (Combine) Tyypillinen hajota ja hallitse-algoritmi on aiemmin algoritmien analyysi -osassa esitetty lomituslajittelu, jonka algoritmi on seuraava: Syöte: Taulukko A[1,..,n], n >= 1, luvut 1 <= p <= q <= n Tulostus: Taulukon alkiot A[p..q] suuruusjärjestyksessä LOMITUSLAJITTELU(A,p,q) 1. if p < q then 2. r = ( p q) / 2 3. LOMITUSLAJITTELU(A,p,r) 4. LOMITUSLAJITTELU(A,r+1,q) 5. LOMITA(A,p,r,q) 6. return Muistettakoon, että LOMITA on algoritmi, joka lomittaa kaksi järjestyksessä olevaa listaa yhdeksi järjestyksessä olevaksi listaksi. Näin ollen yllä ensimmäinen askel eli hajottaminen on taulukon jakaminen keskeltä kahtia. Mikäli listassa on vain yksi alkio, on päädytty triviaaliin tapaukseen, jolloin lajittelua ei enää tarvita. Toisena askeleena lajitellaan pienemmät listat samalla algoritmilla. Kolmannessa vaiheessa yhdistetään lajitellut taulukot järjestetyksi taulukoksi lomittamalla ne. Aina algoritmissa ei tarvita kolmatta vaihetta. Esimerkiksi binäärisessä haussa järjestetystä taulukosta jaetaan taulukko puoliksi ja tutkitaan, kummassa puolessa haettava luku on. Tätä toistetaan kunnes luku löydetään. Yhdistämisvaihetta ei siis suoriteta lainkaan.
Esitetään vielä yksi esimerkki hajota ja hallitse-algoritmista. Oletetaan, että on annettuna n erisuurta lukua järjestämättömänä taulukkona ja näistä on löydettävä suuruusjärjestyksessä k:s alkio. Kuinka monta operaatiota tähän tarvitaan? Jos k=1, eli haetaan minimiä tai maksimia, se löydetään tietysti lineaarisessa ajassa: riittää käydä taulukko kerran läpi ja pitää yllä tietoa siitä, mikä on pienin (tai suurin) luettu. Sama pätee, jos haetaan toiseksi pienintä tai suurinta alkiota; silloin tarvitaan kaksi apumuuttujaa, kuten lukija epäilemättä heti havaitsee. Mutta entä jos haetaan suuruusjärjestyksessä keskimmäistä eli mediaania (tällöin etsitään järjestyksessä ( n 1) / 2 :ttä alkiota)? Tämä on hyvin tavallinen operaatio tilastotieteessä, kun aineistosta halutaan laskea keskilukuja. Luvut voidaan luonnollisesti järjestää algoritmilla, jonka kompleksisuus on luokkaa ( nlg n), kuten aiemmin on havaittu, ja järjestetystä taulukosta voidaan lukea vastaus suoraan. Ongelma voidaan kuitenkin ratkaista nopeamminkin käyttäen pikalajittelusta tuttua ositusalgoritmia. Annetaan tässä versio, joka valitsee sarana-alkion satunnaisesti, jolloin algoritmi ei kärsi jo lajitellun taulukon ongelmasta. Syöte: Taulukko A[1,..,n], n >= 1, ja luvut p ja r, jotka toteuttavat ehdon 1 <= p <= r <= n. Tulostus: Suorituksen jälkeen taulukon alkiot toteuttavat ehdon: On olemassa luku x taulukon osassa A[p,..,r] ja on olemassa p <= k <= r niin, että A[k] = x ja taulukon alkiot A[p,..,k-1] ovat korkeintaan yhtä suuria kuin x ja taulukon alkiot A[k+1,..,r] vähintään yhtä suuria kuin x. Palauttaa luvun k. OSITA(A,p,r) 1. valitse x = A[m] satunnaisesti taulukon osasta A[p,..,r] 2. vaihda A[m] ja A[r] 3. i = p-1 4. for j = p to r-1 5. if A[j] == x 6. vaihda A[j] ja A[r] 7. if A[j] <= x 8. i = i+1 8. vaihda A[i] ja A[j] 10. vaihda A[i+1] ja A[r] 11. return i+1 Koska yksi vaihto-operaatio on vakioaikainen (vaatii kolme sijoitusta) ja taulukon alue käydään vain kertaalleen läpi, on selvää, että algoritmi OSITA on lineaariaikainen (sen kompleksisuus on luokkaa ( r p) ).
Nyt voidaan esittää algoritmi, joka etsii taulukosta k:nneksi pienimmän luvun (ja esimerkiksi mediaanin kun asetetaan k ( n 1) / 2 ). Algoritmia kutsutaan siten aluksi seuraavasti: ETSI(A,1,n,k). Syöte: Taulukko A[1,..,n], n >= 1, luvut 1 <= p <= q <= n, ja k, jolle p <= k <= q Tulostus: Palauttaa taulukon osasta A[p,..,q] taulukon k:nneksi pienimmän alkion. ETSI(A,p,q,k) 1. x = OSITA(A,p,q) 2. if x == k 3. return A[x] // Alkio löytyi 4. else if x > k 5. return ETSI(A,p,x-1,k) // Haetaan alemmalta puolelta 6. else 7. return ETSI(A,x+1,q,k) // Haetaan ylemmälta puolelta Algoritmi OSITA jakaa taulukon sarana-alkion suhteen niin, että sen vasemmalla puolella on sarana-alkiota pienempiä ja sen oikealla puolella sarana-alkiota suurempia alkioita, joten k:nneksi pienin alkio sijaitsee valillä A[p..q]. Siten algoritmi on korrekti. Tarkastellaan nyt algoritmin kompleksisuutta. Huonolla onnella ositusalgoritmi löytää jokaisella kerralla pienimmän tai suurimman alkion taulukon osasta, jolloin algoritmin kompleksisuusluokaksi tulee ( n 2 ). Parhaimmassa tapauksessa taulukko jakautuu jokaisessa osituksessa kahteen yhtä suureen osaan, jolloin askelia tulee luokkaa n + n/2 + n/4 + = noin 2*n. Voidaan myös osoittaa, että keskimäärinkin algoritmin suoritusaika on luokkaa (n). Tarkastelu on samantapainen kuin pikalajittelualgoritmin keskimääräisen suoritusajan arviointi eikä siihen paneuduta tässä tarkemmin. 6.2. Dynaaminen taulukointi Kuten yllä tuli ilmi, hajota ja hallitse-algoritmeissa ongelma jaetaan osaongelmiin, jotka sitten ratkaistaan rekursiivisesti ja ratkaisut yhdistetään. Tällainen algoritmi on tehokas, mikäli osaongelmat ovat erilliset; muussa tapauksessa joudutaan rekursiossa ratkaisemaan samoja ongelmia yhä uudelleen. Päällekkäisyyden vuoksi esimerkiksi Fibonaccin lukujen rekursiivinen laskeminen on erittäin tehotonta. Kun rekursio ei pure, voidaan usein soveltaa dynaamista taulukointia (dynamic programming), joka toimii bottom-up -periaatteella: osaongelmien tulokset tallennetaan (yleensä taulukkoon), jolloin kukin osaongelma on ratkaistava ainoastaan kerran. Dynaamista taulukointia käytetään erityisesti optimointiongelmien ratkaisemiseen. Tällöin algoritmin löytämiseksi noudatetaan yleensä seuraavia neljää askelta: 1. Hahmotetaan optimaalisen ratkaisun rakenne. 2. Määritetään rekursio, jolla optimiratkaisu löydetään. 3. Lasketaan optimiarvo ratkaisulle lähtien alhaalta. 4. Konstruoidaan optimiratkaisu edellä saadun tiedon perusteella.
Tyypillinen dynaamisen taulukoinnin avulla ratkeava ongelma on kokoonpanolinjan aikatauluongelma. Oletetaan, että autotehtaassa on kaksi kokoonpanolinjaa (linjat 1 ja 2), joilla kummallakin on n kappaletta työpisteitä (merkitään Pi,j, missä i=1,2 ja j = 1,...n). Samalla kohdalla olevilla työpisteillä (ts. pisteillä P1,j ja P2,j) tehdään sama työ ja auton valmistumiseksi tarvitaan kaikki n vaihetta. Pisteellä Pi,j operaation tekemiseen oletetaan kuluvan aika ai,j. Pisteen Pi,j jätettyään auto voi siirtyä välittömästi saman linjan seuraavalla pisteelle tai toisen linjan seuraavalle pisteelle, mihin kuluu aika ti,j. Oletetaan vielä, että linjan i ensimmäiselle pisteelle siirtymiseen kuluu aika ei ja linjalta i poistumiseen kuluu aika xi. Ongelmana on löytää nopein reitti linjaston läpi. Piste P1,1 Piste P1,2 Piste P1,n-1 Piste P1,n Kokoonpanolinja 1 a1,1 a1,2 a1,n-1 a1,n Kori saapuu e1 e2 t1,1 t2,1 t1,2 t2,2 t1,n-1 t2,n-1 x1 x2 Valmis auto Kokoonpanolinja 2 a2,1 a2,2 a2,n-1 a2,n Piste P2,1 Piste P2,2 Piste P2,n-1 Piste P2,n Kuva 6.1. Kaavio kokoonpanolinjoista siirtymäaikoineen. Ongelma voidaan luonnollisesti ratkaista triviaalisti kokeilemalla kaikki reittivaihtoehdot ja valitsemalla niistä lyhin. Koska jokaisessa n:ssä askelessa voidaan valita kahdella tavalla, joudutaan tutkimaan 2 n eri vaihtoehtoa. Vaihtoehtojen lukumäärä kasvaa n:n mukana erittäin voimakkaasti, joten tätä algoritmia ei voi käyttää suurilla n:n arvoilla. Sovelletaan edellä mainittua neljän askeleen menetelmää tehokkaamman algoritmin löytämiseksi. Aluksi on hahmotettava optimiratkaisun rakenne. Tarkastellaan auton nopeinta reittiä linjaston alusta pisteelle Pi,j. Jos kyseessä on ensimmäinen työpiste (j=1), vaihtoehtoja ei ole ja kuluva aika on ei. Jos taas j>1, niin auto on voinut tulla pisteelle Pi,j kahta kautta: joko pisteeltä P1,j-1 tai pisteeltä P2,j-1. Olipa edellinen piste kumpi hyvänsä, on selvää, että auton on täytynyt tulla edelliselle pisteelle mahdollisimman nopeaa reittiä (muutenhan reittiä voitaisiin nopeuttaa). Siksi optimiratkaisu ongelmaan nopein reitti linjaston alusta pisteelle Pi,j sisältää optimiratkaisun osaongelmaan nopein reitti linjaston alusta pisteelle P1,j-1 tai pisteelle P2,j-1. Tämä mahdollistaa dynaamisen taulukoinnin ja optimiratkaisun konstruoinnin bottom-up-periaatteella. Nopein reitti linjaston alusta pisteelle Pi,j on siis toinen seuraavista: nopein reitti linjaston alusta saman
linjan edelliselle pisteelle tai nopein reitti linjaston alusta toisen linjan edelliselle pisteelle ja linjan vaihdolla. Toiseksi haetaan rekursiivinen ratkaisu: edellä olevan tarkastelun perusteella luonteviksi osaongelmiksi tarjoutuvat nopeimman reitin määrittämiset kuhunkin työpisteeseen asti. Käytetään kahta taulukkoa: Taulukossa f1 ovat ajat linjan 1 työpisteille ja taulukossa f2 linjan 2 työpisteille. Siis f1[j] on nopein aika alusta pisteeseen P1,j ja f2[j] on nopein aika alusta pisteeseen P2,j. Merkitään vielä fs:llä auton nopeinta aikaa tehtaan läpi, jolloin aiemman päättelyn perusteella fs = min(f1[n]+ x1,f2[n]+x2). Lyhin aika ensimmäiselle pisteelle on helppo laskea, sillä aikaa kuluu ainoastaan linjalle siirtymiseen ja pisteen töihin, joten f1[1] = e1 + a1,1 ja f2[1] = e2 + a2,1. (1) Edelleen, kuten yllä on todettu, nopein reitti pisteelle P1,j voi tulla joko pisteeltä P1,j-1 tai pisteeltä P2,j-1. Mikäli tullaan toiselta linjalta, on otettava huomioon linjan vaihtoon kuluva aika. Näin ollen pisteen työ mukaan lukien nopein aika on joko f1[j-1] + a1,j (ei vaihdeta linjaa) tai f2[j-1] + t2,j-1 + a1,j (vaihdetaan linjaa). Oikea aika on pienempi näistä, eli f1[j] = min(f1[j-1] + a1,j, f2[j-1] + t2,j-1 + a1,j). (2) Vastaavasti saadaan toiselle linjalle f2[j] = min(f2[j-1] + a2,j, f1[j-1] + t1,j-1 + a2,j). (3) Kolmanneksi mietitään, miten optimiarvo kannattaa laskea. Yllä johdettujen ominaisuuksien perusteella voitaisiin kirjoittaa rekursiivinen algoritmi kaavojen (2) ja (3) avulla ja päättää rekursio kaavasta (1) saataviin arvoihin. Käytettäisiin siis kahta rekursiivista funktiota f1 ja f2, jotka kutsuvat toisiaan aina pienenevillä arvoilla, kunnes rekursio päättyy kaavaan (1). Tämä on kuitenkin erittäin tehoton tapa, sillä rekursio haarautuu kahteen osaan joka askelessa ja nähdään että tarvitaan 2 n askelta laskemaan f1[n] ja f2[n]. Näin ollen algoritmin kompleksisuus on ( 2 n ), mikä tekee siitä käyttökelvottoman vähänkään suuremmilla luvun n arvoilla. Sen sijaan aloittamalla taulukkojen arvojen laskenta alkupäästä, saadaan tehokas lineaariaikainen algoritmi.
Syöte: Taulukot a1[1,..,n], a2[1,..,n], t1[1,..,n], t2[1,..,n], n >= 1, luvut e1, e2, x1, x2. Taulukoissa a1 ja a2 ovat linjojen 1 ja 2 työpisteiden ajat. Taulukoissa t1 ja t2 ovat linjoilta 1 ja 2 kultakin työpisteeltä linjan vaihtoon kuluva aika. Luvut e1 ja e2 ovat linjojen aloitusajat ja x1 ja x2 lopetusajat. Tulostus: Taulukkoihin f1 ja f2 lasketaan linjojen 1 ja 2 työpisteille nopein mahdollinen alusta kuluva aika. Taulukkoihin l1 ja l2 lasketaan linja, jota pitkin nopeimmassa reittivalinnassa tullaan työpisteelle. Esimerkiksi l1[3] on 1, jos pisteelle P1,3 tullaan nopeimmassa reitissä pisteeltä P1,2 ja 2, jos pisteeltä P2,2. Luku fs on koko linjaston nopein aika ja ls linja, jolta nopeimmassa reitissä tullaan ulos. NOPEINREITTI(a1,a2,t1,t2,e1,e2,x1,x2) 1. f1[1] = e1 + a1[1] 2. f2[1] = e2 + a2[1] 3. for j = 2 to n // Täytetään arvo taulukkoon f1 4. if (f1[j-1]+a1[j]) <= (f2[j-1]+t2[j-1]+a1[j]) 5. f1[j] = f1[j-1]+a1[j] 6. l1[j] = 1 7. else 8. f1[j] = f2[j-1]+t2[j-1]+a1[j] 9. l1[j] = 2 // Täytetään arvo taulukkoon f2 10. if (f2[j-1] + a2[j]) <= (f1[j-1]+t1[j-1]+a2[j]) 11. f2[j] = f2[j-1] + a2[j] 12. l2[j]=2 13. else 14. f2[j] = f1[j-1]+t1[j-1]+a2[j] 15. l2[j]=1 // Lopuksi katsotaan, kummasta linjasta tullaan ulos 16. if (f1[n]+x1) <= (f2[n]+x2) 17. fs = f1[n]+x1 18. ls = 1 19. else 20. fs = f2[n]+x2 21. ls = 2 22. return Algoritmissa suoritetaan yksi silmukka, jonka sisällä on ainoastaan vakioaikaisia operaatioita. Siten on selvää, että algoritmin kompleksisuus on luokkaa (n), minkä vuoksi algoritmia voidaan suorittaa myös hyvin suurilla luvun n arvoilla. Nyt on jäljellä enää neljäs askel eli varsinaisen optimiratkaisun konstruointi, mikä tässä tarkoittaa nopeimman reitin etsimistä. Kaikki olennainen informaatio on hankittu suorittamalla edellä oleva algoritmi. Nopeimman reitin aika saadaan suoraan muuttujan fs arvosta. Yllä lasketaan myös taulukkoihin l1 ja l2 sen linjan numero, jolta linjan 1 tai 2 tietylle pisteelle tullaan alusta alkaen nopeinta reittiä. Huomaa, että taulukoihin ei lasketa ensimmäiselle alkiolle arvoa, koska ensimmäistä pistettä ei edellä mikään piste. Lisäksi ls on se linja, jolta optimiratkaisussa poistutaan linjastolta. Taulukot l1 ja l2 sekä muuttuja ls sisältävät kaiken informaation, josta optimireitti voidaan lukea. Muuttujan ls arvo kertoo linjan, jolla viimeinen tehtävä suoritetaan;
jos tämä arvo on 1, saadaan sitä edeltävän aseman linja taulukosta l1, muuten se saadaan taulukosta l2 jne. Seuraava algoritmi tulostaa reitin lopusta alkuun: Syöte: Taulukot l1[1,..,n], l2[1,..,n], luku ls Tulostus: Nopein reitti linjastolla lopusta alkuun. TULOSTAREITTI(l1,l2,ls) 1. edellinen = ls 2. for j = n downto 1 do 3. if edellinen == 1 then 4. print linja 1 piste j 5. edellinen = l1[j] 6. else 7. print linja 2 piste j 8. edellinen = l2[j] 9. return Sovelletaan kehiteltyjä algoritmeja seuraavaan konkreettiseen tapaukseen: Piste P1,1 Piste P1,2 Piste P1,3 Piste P1,4 Piste P1,5 Kokoonpanolinja 1 7 9 3 4 8 2 2 3 1 3 3 Kori saapuu 4 2 1 2 2 2 Valmis auto Kokoonpanolinja 2 8 5 6 4 5 Nyt siis e1=2, e2=4, x1=3 ja x2=2, a1 = {7,9,3,4,8}, a2 = {8,5,6,4,5}, t1 = {2,3,1,3} sekä t2 = {2,1,2,2}. Tällöin f1[1] = e1 + a1[1] = 9 f2[1] = e2 + a2[1] = 12 f1[2] = min(f1[1]+a1[2],f2[1]+t2[1]+a1[2]) = min(9+9,12+2+9) = 18 l1[2] = 1 f2[2] = min(f2[1]+a2[2],f1[1]+t1[1]+a2[2]) = min(12+5,9+2+5) = 16 l2[2] = 1 Näin jatkamalla saadaan Piste P2,1 Piste P2,2 Piste P2,3 Piste P2,4 Piste P2,5
f1 = 9 18 20 24 32 f2 = 12 16 22 25 30 l1 = 1 2 1 1 l2 = 1 2 1 2 sekä nopeimman reitin aika fs = min(f1[5]+x1,f2[5]+x2) = min(32+3,30+2) = 32 ja ls = 2. Algoritmilla TULOSTAREITTI saadaan nopein reitti lopusta alkuun. Aluksi muuttuja edellinen = ls = 2, joten aluksi tulostetaan Linja 2, piste 5 Sitten edellinen = l2[4] = 2, joten seuraavalla kierroksella tulostetaan Linja 2, piste 4 minkä jälkeen edellinen = l2[3] = 1, tulostetaan Linja 1, piste 3 ja näin jatkamalla saadaan nopein reitti tulostettua: Linja 2, piste 2 Linja 1, piste 1. Alusta loppuun reitti on siis: Linja 1; piste 1, Linja 2; piste 2, Linja 1; piste 3, Linja 2, piste 4 ja Linja 2, piste 5. 6.3. Ahneet algoritmit Ahneita algoritmeja (greedy algorithms) sovelletaan enimmäkseen optimointiongelmiin, kuten dynaamista taulukointiakin. Ahne strategia on erittäin yksinkertainen: kussakin tilanteessa tehdään parhaalta näyttävä ratkaisu. Hämmästyttävää kyllä, tämä menetelmä sopii yllättävän moniin laskennallisiin ongelmiin. Algoritmin luonteesta johtuen sellainen on yleensä helppo suunnitella; sen sijaan voi olla melkoisen hankalaa osoittaa, että algoritmi tuottaa optimaalisen ratkaisun. Monessa tapauksessa ahne strategia ei annakaan oikeaa ratkaisua, mutta silti riittävän hyvän, jotta se kelpaa arvioimaan oikeaa ratkaisua, kun oikeaan ratkaisuun johtava algoritmi on kompleksisuutensa vuoksi käyttökelvoton. Tarkastellaan aluksi yksinkertaista esimerkkiä. Oletetaan, että käytössä on rajoittamaton määrä kaikkia Suomessa käytettäviä eurosenttikolikoita (siis 50, 20, 10 ja 5 sentin) ja ongelmana on kerätä näistä annettu rahasumma käyttäen mahdollisimman pieni määrä kolikoita. Ahne algoritmi ratkaisuksi on valita mahdollisimman arvokas kolikko joka kerran, kunnes rahamäärä on saatu täyteen. Perustellaan seuraavaksi, että tämä algoritmi tuottaa optimaalisen tuloksen. Yleisesti optimaalisessa valinnassa voi olla korkeintaan yksi viisisenttinen, koska kaksi viisisenttistä voidaan korvata yhdellä kymmensenttisellä. Vastaavasti kymmensenttisiä voi olla korkeintaan yksi. 20-senttisiä ei voi olla kahta enempää, koska kolme 20-senttistä voidaan korvata 50- ja 10- senttisillä. Oletetaan nyt, että on olemassa rahasummia, jotka voidaan kerätä vähemmällä
määrällä kolikkoja käyttäen jotakin muuta kuin ahnetta menetelmää. Olkoon X senttiä pienin näistä summista. Tällöin X:n parhaassa valinnassa ei esiinny 50-senttistä, koska myös ahneessa esityksessä on tällöin varmasti 50-senttinen. Mutta silloin X-50 olisi pienempi rahasumma, jolle ahne tapa ei tuota parasta esitystä. Näin ollen ainoat periaatteelliset mahdollisuudet X:n valinnalle ovat 5,10,15, 20, 25, 30, 35, 40, 45, 50 ja 55 senttiä. Näistä jokainen summa voidaan kuitenkin kerätä ahneesti mahdollisimman pienellä kolikkomäärällä. Siten ahne algoritmi tuottaa aina optimiratkaisun. Yleisesti näin ei kuitenkaan tämänkaltaisissa ongelmissa käy. Jos kolikkojemme arvot olisivat esimerkiksi 40, 30, 10 ja 5 senttiä, 60 sentin summa kerättäisiin ahneella strategialla 40 + 10 + 10, kun optimaalinen tapa muodostaa summa olisi 30 + 30. Tutkitaan seuraavaksi toiminnon valitsemisongelmaa. Oletetaan, että joukossa S on n toimintoa, S = {a1,a2,a3,...,an}. Jokaisella toiminnolla on alkamis- ja loppumisaika si ja fi. Ongelmana on löytää mahdollisimman suuri toimintojen joukko, jossa kahta toimintoa ei suoriteta yhtä aikaa. Tällaisia toimintoja sanotaan yhteensopiviksi. Olkoon joukossa S esimerkiksi 11 toimintoa, joiden alkamis- ja loppumisajat ovat seuraavat: i 1 2 3 4 5 6 7 8 9 11 si 1 3 0 5 3 5 6 8 8 2 12 fi 4 5 6 7 8 9 11 12 13 14 Nyt esimerkiksi toiminnot a1 ja a2 menevät päällekkäin, joten niitä ei voi valita samaan joukkoon. Joukko {a3,a7,a11} on mahdollinen, mutta ei mahdollisimman suuri. Sen sijaan joukko {a1,a4,a9,a11} on mahdollisimman suuri, ei kuitenkaan ainoa sellainen. Etsitään nyt ahne ratkaisu ongelmaan. Oletetaan, että toiminto ak esiintyy jossakin optimiratkaisussa. Tämä toiminto jakaa muut toiminnot kolmeen joukkoon: 1. Ennen ak:n alkamista päättyvät toiminnot. 2. Toiminnon ak päättymisen jälkeen alkavat toiminnot. 3. Toiminnon ak kanssa päällekkäiset toiminnot. Optimiratkaisun ne toiminnot, jotka kuuluvat joukkoon 1 muodostavat optimiratkaisun joukossa 1. Muutenhan optimiratkaisun ennen ak:n alkamista päättyvät toiminnot voitaisiin korvata joukolla, jossa on enemmän toimintoja ja saataisiin suurempi joukko yhteensopivia toimintoja kuin optimiratkaisussa, mikä ei ole mahdollista. Ahne strategia perustuu seuraavaan havaintoon: Olkoon A jokin optimiratkaisu ja olkoon ak sen ajallisesti ensimmäinen toiminto ja olkoon a1 kaikista toiminnoista ensimmäiseksi päättyvä. (Tämä ei siis välttämättä sisälly ratkaisuun.) Koska a1 päättyy viimeistään yhtä aikaa toiminnon ak kanssa, ratkaisussa A toiminto ak voidaan korvata toiminnolla a1. Näinkin saadaan optimiratkaisu ja voidaan päätellä, että ainakin yhdessä optimiratkaisussa ensin päättyvä toiminto on mukana. Siten saadaan algoritmi ongelman ratkaisuksi: 1. Lajitellaan toiminnot päättymisajan mukaan nousevaan järjestykseen. 2. Valitaan ensin päättyvä toiminto 3. Poistetaan valitun toiminnon kanssa päällekkäiset 4. Jos toimintoja jäljellä, mene askeleeseen 2.
Sovelletaan tätä algoritmia esimerkkitapaukseen: Annetussa taulukossa toiminnot on jo lajiteltu loppumisajan mukaan. Näin ollen valitaan ensin toiminto a1. Ensimmäinen tämän kanssa yhteensopiva on a4, joka siis valitaan seuraavaksi. Tämän jälkeen seuraava toiminnon a4 loppumisen jälkeen alkava toiminto on a8. Tämän toiminnon valinnan jälkeen ainoa yhteensopiva toiminto on a11, joten se valitaan ja saadaan optimiratkaisu {a1,a4,a8,a11}. Mikä on algoritmin kompleksisuus? Kun toiminnot on järjestetty, askeleet 2, 3 ja 4 voidaan suorittaa käymällä toiminnot kertaalleen läpi, joten tämän osan kompleksisuus on (n). Lajittelua ei voida (vertailemalla) suorittaa nopeammin kuin ajassa ( nlg n), joten askel 1 dominoi algoritmia ja kokonaiskompleksisuus on ( nlg n). Annettu algoritmi on ahne siinä mielessä, että aina valitaan se toiminto, joka kuluttaa jäljellä olevaa aikaa vähiten. Kannattaa huomata, että dynaaminen taulukointi on ahnetta strategiaa yleisempi. Koeta esimerkiksi soveltaa ahnetta strategiaa kokoonpanolinjan tapaukseen. Saatko tällöin optimiratkaisua? Ahneen algoritmin luomisessa noudatetaan yleensä seuraavia kolmea vaihetta: 1. Esitetään optimointiongelma niin, että kun valinta tehdään, jää jäljelle ainoastaan yksi ratkaistava osaongelma. 2. Osoitetaan, että ahne valinta sisältyy aina johonkin alkuperäisongelman optimiratkaisuun. Tämä varmistaa, että ahne valinta on turvallinen. 3. Varmistutaan siitä, että kun ahne valinta yhdistetään osaongelman optimiratkaisuun, saadaan alkuperäisongelman optimiratkaisu. Tarkastellaan vielä lopuksi ns. Huffmanin koodeja, joiden avulla voidaan pakata merkkijonoja varsin tehokkaasti. Huffmanin koodeja käytetään yleisesti ja niiden avulla saavutetaan monissa tapauksissa kymmenien prosenttien tilansäästö. Tarkastellaan aluksi esimerkkiä: Oletetaan, että tiedosto koostuu merkeistä a, b, c, d, e ja f, joita on yhteensä 10000 kappaletta. Oletetaan vielä, että kirjainten esiintymismäärät ovat seuraavat: a b c d e f 400000 2500 50200 800 Tehtävänä on esittää tiedosto mahdollisimman pienellä bittimäärällä. Jos kaikkien merkkien koodaamiseen käytetään sama määrä bittejä, tarvitaan kolme bittiä kirjainten esittämiseen ja tiedosto saadaan esitettyä 30000 bitillä. Entä, jos käytetään tiheimmin esiintyville merkeille pienempää bittimäärää esimerkiksi seuraavasti: a b c d e f 11 10101 101 1100 Tällöin tiedoston sisältö voidaan esittää 4000 + 3*1000 + 3*2500 + 3*1200 + 4*500 + 4*800 = 23300 bitillä. Tällä koodauksella on toinenkin etu: mikään koodisana ei ole alkuosana toisessa sanassa, joten merkkien dekoodaus on helppoa. Esimerkiksi jono 001001101111111 esitetään muodossa 0 0101 111 111 ja se dekoodautuu seuraavasti: aacdbb. Tällaista koodia sanotaan etuliitekoodiksi (prefix code).
Binäärinen etuliitekoodi voidaan aina esittää puuna, jossa puun lehdillä ovat koodattavat merkit ja puun vasemman haaran valitseminen tarkoittaa nollabittiä ja oikean haaran valitseminen ykkösbittiä. Polku juuresta lehteen antaa tällöin lehdellä sijaitsevan merkin koodin. Edellä oleva koodi esitettäisiin puuna a c e f d b Koodin laatiminen on siis yhtäpitävää puun muodostamisen kanssa. Huffman keksi ahneen strategian, jolla voidaan löytää optimaalinen etuliitekoodi. Oletetaan, että tiedosto koostuu joukon C merkeistä ja että jokaisen merkin c suhteellista osuutta merkitään f[c]:lla. Esimerkkimme tapauksessa C = {a,b,c,d,e,f} ja suhteelliset osuudet käyvät ilmi taulukosta Merkki a b c d e f Lukumäärä 400000 2500 50200 800 f[] 0.4 0.1 0.25 0.05 0.12 0.08
Huffmanin algoritmi on seuraava Syöte: Taulukko C[1,..,n], jossa käytettävät merkit ja taulukko f[1,..,n], jossa merkkien suhteelliset osuudet. Tulostus: Muodostaa puun, joka esittäää optimaalista etuliitekoodia. HUFFMAN(C,f) 1. Tee merkeistä n kappaletta pelkästä juuresta koostuvia binääripuita. Solmun datana merkki ja suhteellinen osuus, joka on avain. 2. Järjestä puut avainkentän mukaan nousevaan järjestykseen 3. for j = 1 to n-1 do 4. Yhdistä kaksi juuriarvoltaan pienintä puuta lisäämällä ne vasemmaksi ja oikeaksi haaraksi puuhun, jonka juuren avainkenttä on lisättävien puiden juuriarvojen summa. 5. Siirrä muodostettu puu järjestyksessä oikeaan kohtaan 6. return Algoritmin lopputuloksena on koodia esittävä binääripuu. Algoritmi on ahne, sillä se valitsee aina kaksi juuriarvoltaan pienintä puuta yhdistettäväksi. Ensimmäisen vaiheen kompleksisuusluokka on (n), koska yhden puun muodostaminen on vakioaikainen operaatio ja se on tehtävä n kertaa. Järjestäminen (vaihe 2) voidaan tunnetusti suorittaa kompleksisuusluokkaa ( nlg n) olevalla algoritmilla. Askelia 4 ja 5 suoritetaan n-1 kertaa. Askel 4 on vakioaikainen, koska siinä valitaan järjestetystä listasta kaksi pienintä eli ensimmäiset ja muodostetaan uusi juuri puuhun. Askelessa 5 haetaan paikka alkiolle järjestetystä listasta; tämän operaation kompleksisuusluokka on (lg n), koska se voidaan tehdä binäärihaulla. Näin ollen silmukan kompleksisuusluokka on ( nlg n) ja siten koko algoritmin kompleksisuusluokka on ( nlg n). Vielä ei ole kuitenkaan takeita siitä, että algoritmi tuottaisi hyvän koodin. Seuraavaksi perustellaan, miksi algoritmi toimii. Tarkastellaan aluksi algoritmin toimintaa annetussa esimerkkitapauksessa.
VAIHE I d:5 f:8 b:10 e:12 c:25 a:40 VAIHE II b:10 e:12 13 c:25 a:40 d:5 f:8 VAIHE III 13 22 c:25 a:40 d:5 f:8 b:10 e:12 VAIHE IV c:25 35 a:40 13 d:5 f:8 22 b:10 e:12 VAIHE V a:40 60 c:25 35 13 d:5 f:8 22 b:10 e:12
VAIHE VI: Valmis koodi 100 a:40 60 c:25 35 13 d:5 f:8 22 b:10 e:12 Näin saatiin kirjaimille seuraavat esitykset: a c d F b e 10101 111111 Esimerkkitapauksemme tiedoston sisällön esittämiseen kuluisi näin 4000 + 4*1000 + 2*2500 + 4*1200 + 4*500 + 4*800 = 23000 bittiä. Algoritmin implementoinnissa kannattaa käyttää minimikekoa puiden hallintaan. Perustellaan nyt algoritmin oikeellisuutta yleisessä tapauksessa. Tehdään aluksi Havainto 1. Jos x ja z ovat kaksi harvimmin esiintyvää merkkiä, on olemassa optimaalinen koodi, jossa merkkejä x ja z vastaavat koodisanat ovat yhtä pitkät ja eroavat toisistaan vain yhdellä bitillä.
Perustelu: Olkoon T jokin optimaalinen koodi. Jos merkit ovat koodin puuesityksessä sisarussolmut, niin väite pitää paikkansa. Olkoon nyt koodin T puuesitys seuraava T x z a b Merkit a ja b olkoot puussa syvimmällä esiintyvät sisarussolmut (jolloin niiden koodiesitykset ovat vähintään yhtä pitkät kuin minkään muun merkin ja eroavat toisistaan vain yhdellä bitillä). Tällaiset ovat olemassa, sillä kaikilla alimman tason merkeillä on puussa myös sisarhaara; muutenhan merkki voitaisiin nostaa tasoa ylemmäs ja siten lyhentää sen esitystä muuttamatta koodia muuten. Tämä ei ole mahdollista, jos koodi on optimaalinen. Jos nyt määritellään koodi T puulla, jossa x vaihtaa paikkaa a:n ja z b:n kanssa, ei koodauksessa käytettävien bittien määrä voi kasvaa, koska x ja y esiintyivät harvimmin ja koodissa T merkkien x ja y koodisanat ovat korkeintaan yhtä pitkät kuin merkkien a ja b. Havainnosta 1 seuraa, että algoritmissa voidaan yhdistää aluksi kaksi pienimmän suhteellisen osuuden merkkiä, jotka joutuvat lopuksi syvimmälle puuhun. Havainto 2. Olkoon C merkkien joukko ja f[c] kunkin merkin c suhteellinen osuus. Olkoot x ja y harvimmin esiintyvät merkit (f[x]ja f[y] pienimmät). Olkoon C joukko, joka saadaan poistamalla joukosta C merkit x ja y sekä lisäämällä uusi merkki z, jolle f(z) = f[x]+f[y]. Olkoon T puu, joka esittää optimaalista etuliitekoodia joukolle C. Muodostetaan puu T seuraavasti: Korvataan puussa T merkin z lehti sisäisellä solmulla, jonka lapsina ovat x ja y. Tällöin T on optimaalinen etuliitekoodi joukolle C. Perustelu: Oletetaan, että merkkejä on N kappaletta ja merkitään merkin c bittilukumäärää koodissa T symbolilla (c). Tällöin koodin vaatima bittimäärä on N c C f d T c d ( c) N B( T) T ja koska N on vakio, koodi on optimaalinen kun koodin kustannus B(T) on mahdollisimman pieni. Huomataan ensin, että koodeissa T ja T pätee d T ( x) d ( y) d '( z) 1 T T ja koska koodit T ja T eroavat ainoastaan merkkien x, y ja z osalta, saadaan
B( T ) B( T' ) B( T' ) B( T ) f f x dt ( x) f y dt ( y) ( f x f ( y ) dt '( x) f x f y x f y. Oletetaan nyt, että väite ei pitäisikään paikkaansa. Silloin olisi olemassa koodi T, joka koodaisi merkit vähemmillä biteillä kuin T, ts. B( T'') B( T). Havainnon 1 perusteella voidaan olettaa, että T :ssa merkit x ja y ovat sisarussolmuja. Muodostetaan nyt koodi T korvaamalla solmujen x ja y yhteinen vanhempi merkillä z, jolle f(z) = f(x) + f(y). Silloin x f y B( T) f x f y B( '). B( T''') B( T'') f T Näin ollen T ei olisikaan optimaalinen koodi vastoin oletusta. Siis havainto 2 pitää paikkansa. Havainnosta 2 puolestaan seuraa heti, että Huffmanin algoritmi tuottaa optimaalisen etuliitekoodin. Ahneille algoritmeille on kehitetty myös yleinen teoreettinen viitekehys, joka perustuu ns. matroidin käsitteeseen. Asiasta kiinnostunut lukija voi perehtyä aiheeseen kirjan [Cor] luvusta 16.4. 6.4. Peruuttavat algoritmit Monet laskennalliset ongelmat vaativat jonkin mahdollisten ratkaisujen joukon läpikäymistä. Peruuttaminen (backtracking) on systemaattinen tapa luetella joukon alkiot. Joukkoon voivat kuulua esimerkiksi jonkin alkiojoukon kaikki mahdolliset järjestykset (permutaatiot), verkon kaikki virittävät puut jne. Yhteistä tällaisille ongelmille on, että jokainen mahdollinen ratkaisuehdotelma muodostetaan täsmälleen kerran. Muuten saattaa oikea ratkaisu jäädä löytymättä tai joudutaan tekemään turhaa työtä. Peruuttavat algoritmit soveltuvat erityisesti kombinatorisiin haku- ja konstruointitehtäviin. Esitetään seuraavaksi yksi geneerinen peruuttavan algoritmin toteutus. Oletetaan, että tutkittavan ongelman ratkaisu voidaan esittää vektorina (r1,r2,,rn) missä ri kuuluu järjestettyyn joukkoon Si. Tällöin ratkaisua voidaan yrittää konstruoida osittain siten, että kun on muodostettu osittaisratkaisu (r1,r2,,rk) missä k<n, yritetään vektorin loppuun lisätä uusi alkio niin, että saadaan uusi osittaisratkaisu. Ellei alkiota voida lisätä, poistetaan viimeinen alkio tarkasteltavasta osittaisratkaisusta ja siirrytään kokeilemaan seuraavaa osittaisratkaisua. Lopulta joko saadaan muodostettua täydellinen ratkaisu tai havaitaan että ratkaisua ei ole. Yleistasolla algoritmi voidaan esittää seuraavasti:
Syöte: Osittainen ratkaisu r = (r 1,r 2,...,r k) ja indeksi k Output: Kun ratkaisu löydetään se käsitellään Peruuta(r,k) 1. if r ratkaisu 2. Käsittele r 3. else 4. k = k + 1 5. Muodosta joukko S k 6. while S k!= ø 7. r k = alkio joukosta S k 8. Poista r k joukosta S k 9. Peruuta(r,k) Huomaa, että algoritmi muistuttaa verkkojen syvyyshakua. Algoritmia voidaankin pitää eräänlaisena syvyyshakuna osittaisratkaisujen muodostamassa verkossa. Sovelletaan tätä menetelmää ns. kahdeksan kuningattaren ongelmaan: Kuinka monella tavalla kahdeksan shakkipelin kuningatarta voidaan asetella shakkilaudalle niin, että ne eivät uhkaa toisiaan? Shakissa kuningatar liikkuu sijainnistaan pysty- ja vaakasuunnassa sekä kulmittain niin pitkälle kuin lautaa riittää. Alla on yksi mahdollinen asetelma: Q Q Q Q Muunnetaan ratkaisut sopivaan muotoon. Heti havaitaan, että ratkaisussa on aina yksi kuningatar kullakin pystyrivillä. Näin ollen ratkaisu voidaan esittää muodossa (r1,r2,,r8) missä ri on kuningattaren paikka i:nnellä pystyrivillä. Kun vektori (r1,r2,,rk) on annettu, on suoraviivaista tarkistaa onko kyseessä osittaisratkaisu, ts. uhkaavatko mitkään kaksi k:sta ensimmäisestä kuningattaresta toisiaan. Näin voidaan soveltaa edellä mainittua ratkaisumenetelmää ja saadaan algoritmiksi Q Q Q Q
Syöte: Taulukko t[1..8], jossa paikat ja sarake col Output: Kun ratkaisu löydetään lisätään lukumäärää Queens(t,col) 1. if col == 8 2. ratkaisuja = ratkaisuja+1 3. else 4. col = col + 1 5. for i = 1 to 8 6. t[col]=i 7. if osittaisratkaisu(t,col) 8. Queens(t,col) Edellä on oletettu, että apualgoritmi osittaisratkaisu kertoo, onko taulukon alkuosassa laillinen asetelma. Algoritmin aluksi asetetaan t=[1,,1] ja kutsutaan Queens(t,0). Harjoitustehtävä: Kirjoita valitsemallasi ohjelmointikielellä ohjelma, joka laskee ratkaisujen lukumäärän. Lähteet: [Cor] Cormen, T.H., Leiserson, C.E., Rivest, R.L., Stein, C. Introduction to Algorithms, 2 nd edition, The MIT Press 2001. [Ski] Skiena, S.S. The Algorithm Design Manual, 2 nd edition, Springer 2008.