Tietorakenteet, laskuharjoitus 3, ratkaisuja 1. (a) Toistolauseen runko-osassa tehdään yksi laskuoperaatio, runko on siis vakioaikainen. Jos syöte on n, suoritetaan runko n kertaa, eli aikavaativuus kokonaisuudessaan O(n) Algoritmi käyttää yhtä apumuuttujaa, tilavaatisuus on näinollen vakio O(1) (b) Rekursiivisen algoritmin aikavaativuus on O(n) ja tilavaativuus on O(n), koska funktiota kutsutaan n kertaa ja yksi kutsu vie vakiomäärän aikaa ja muistia. Esimerkiksi jos n = 4, funktiota kutsutaan seuraavasti: Summa(4) Summa(3) Summa(2) Summa(1) Summa(0) Kun funktio saa parametrin 0, se ei enää kutsu itseään vaan kutsuketju alkaa purkautua. Tässä vaiheessa algoritmin muistinkäyttö on suurimmillaan: muistissa ovat samaan aikaan kaikki n funktiokutsua. (c) Summan voi laskea myös ajassa O(1) seuraavan summakaavan avulla: 1+2+3+...+n = n (n+1)/2 Tuloksena oleva algoritmi on selvästi vakioaikainen: Summa(k) return k * (k+1)/2 2. public int paalla(){ return top.key; public int toisiksiylimpana(){ return top.next.key; public int kolmanneksiylimpana(){ return top.next.next.key; public void tulostakolmeylinta(){ System.out.print("kolme ylintä: "); System.out.print( top.key + " " ); System.out.print( top.next.key + " " ); System.out.print( top.next.next.key + " " ); System.out.println(""); public int poistatoisiksiylin(){ PinoSolmu pois = top.next; top.next = pois.next; return pois.key; 1
3. public void lisaatoiseksi(int lisattava) { PinoSolmu vanhatoka = top.next; PinoSolmu uusi = new PinoSolmu(lisattava, vanhatoka ); top.next = uusi; public int koko() { int koko = 0; PinoSolmu p = top; while ( p!=null ) { koko++; p = p.next; return koko; public void tulostakaikki(){ System.out.print("pinossa: "); PinoSolmu p = top; while ( p!=null ) { System.out.print(p.key + " "); p = p.next; System.out.println(""); public void lisaapohjalle(int lisattava){ // HUOM: ei toimi tyhjälle pinolle! PinoSolmu uusi = new PinoSolmu(lisattava, null); PinoSolmu p = top; while ( p.next!=null ) { p = p.next; p.next = uusi; Metodi lisaapohjalle vie nyt aikaa O(n) sillä koko pino käytävä läpi pohjaa etsiessä onnistuu vakioaikaisesti jos pinolle lisätään pohjan muistava attribuutti. Push- ja pop- operaatioissa täytyy tällöin huomioida pohjan muistavan attribuutin päivitys tapauksissa joissa lisätään ensimmäinen tai poistetaan viimeinen. Näin syntyy seuraava tietorakenne: public class PinoJono { private PinoSolmu top; private PinoSolmu bottom; public PinoJono() { top = null; 2
bottom = null; public void push(int k) { PinoSolmu uusi = new PinoSolmu(k, top); top = uusi; // lisättään ensimmäinen if ( bottom==null ) bottom = uusi; public int pop() { PinoSolmu x = top; top = x.next; // poistetaan viimeinen if ( top==null ) bottom = null; return x.key; public boolean empty() { return (top == null); public void lisaapohjalle(int lisattava){ PinoSolmu uusi = new PinoSolmu(lisattava, null); if ( bottom==null ){ top = uusi; else { bottom.next = uusi; bottom = uusi; public void tulostakaikki(){ System.out.print("pinojonossa: "); PinoSolmu p = top; while ( p!=null ) { System.out.print(p.key + " "); p = p.next; System.out.println(""); 4. Määritellään rajapinta jonka pinot toteuttavat. public interface Pino { int pop(); void push(int lisattava); boolean empty(); 3
Koska molemmat pinot toimivat lähes samalla tavalla, tehdään niille abstrakti yläluokka. Ainoa poikkeava kohta eli kasvatuksen yhteydessä tulevan taulukon uuden koon laskenta määritellään abstraktiksi metodiksi. public abstract class KasvavaPino implements Pino { protected int[] taulukko; private int top; public KasvavaPino() { taulukko = new int[100]; top = 0; public int pop() { return taulukko[--top]; public void push(int luku) { if ( top==taulukko.length ) kasvatataulukkoa(); taulukko[ top++ ] = luku; public boolean empty() { return top == 0; private void kasvatataulukkoa() { int[] uusi = new int[laskeuusikoko()]; for ( int i=0 ; i < taulukko.length; i++ ) uusi[i] = taulukko[i]; taulukko = uusi; abstract protected int laskeuusikoko(); Edellisestä on helppo erikoistaa TuplautuvaPino jossa taulukon pituus aina kasinkertaistuu ja VakiollaKasvavaPino jossa taulukon koko kasvaa aina sadalla. public class TuplautuvaPino extends KasvavaPino { protected int laskeuusikoko() { return taulukko.length*2; 4
public class VakiollaKasvavaPino extends KasvavaPino { protected int laskeuusikoko() { return taulukko.length + 100; Mittausta varten luodaan oikeantyyppiset pino-instanssit: Pino p1 = new TuplautuvaPino(); Pino p2 = new VakiollaKasvavaPino(); for ( int operaatioita = 1000; operaatioita<1000000; operaatioita *= 2 ) { mittaaaika(p1, operaatoita); mittaaaika(p2, operaatoita); Pinojen käyttämä aika suhteessa tehtyihin push-operaatioihin nähdään seuraavista kuvista (x-akselissa käytetty aika ja x-akselilla operaatioiden määrä), huomaa kuvien erilaiset skaalat: 5
Pino jossa käytettiin tuplausstrategiaa osoittautui huomattavasti tehokkaammaksi. Pinoa jota kasvatetaan vain 100 alkiota kerrallaan vaivaa se, että joka sadas operaatio on lineaarinen alkioiden lukumäärän suhteen, sillä tällöin alkiot on kopioitava vanhasta taulukosta uuteen isompaan taulukkoon. Kopioinnin takia aikaa vievä push tehdään, niin usein, että keskimääräiseksi push:in aikavaativuudekeksi tulee O(n), ja n kpl push:eja vie aikaa O(n 2 ). Ylempi kuva näyttää tämän. Pino, jossa taulukko tuplataan on paljon nopeampi, koska kallis lineaarisen ajan vievä operaatio suoritetaan verrattain harvoin. Karkeasti ottaen jokaista O(n) aikaa vievää kallista push-operaatiota kohti tehdään n kappaletta halpoja push:eja. Näinollen yksittäisen pushin keskimääräiseksi aikavaativuudeksi tuleekin vain O(1), ja n kpl push:eja vie aikaa O(n). Alempi kuva näyttää tämän. Tässä tehty aikavaativuus "analyysi" ei siis arvioi tavanomaiseen tapaan operaation pahinta tapausta, vaan yksittäisen operaation keskimäärin vievää aikaa suoritettaessa pitempi ketju komentoja. Tälläistä analysointitapaa sanotaan tasoitetuksi analyysiksi (engl. amortized analysis). Tasoitettu analyysi ei varsinaisesti kuulu tietorakenteisiin. Hieman formaalimpi analyysi edellisestä: Jos ajatellaan taulukon yhden alkion kopiontiin kuluvan yhden askelen niin voidaan laskea kuinka monta askelta joudutaan suorittamaan n:ssä lisäyksessä, jotka tehdään tyhjään pinoon. Laskelmissa on oletuksena, että taulukon koon lisäys aloitetaan yhdestä alkiosta. Tämä ei vaikuta mitenkään laskujen suuruusluokkaan, mutta helpottaa arviointia. Lasketaan ensin sadalla kasvavan tapaus 1. 1 Lausekkeessa käytetään lattiafunktiota, joka on pyöristys lähimpään lukua pienempään kokonaislukuun. Esi- 6
n n 100 1+ 100 i = n+100 i=1 i=1 n ( n +1) 100 100 2 n+100 n (n+1) 2 = 50n 2 +51n = O(n 2 ) Kun tehdään n:kpl push-operaatioita, suoritettavien kopiointiaskelten määrä siis on luokaa O(n 2 ). Siis yksittäinen push vie keskimäärin O(n) kopiointiaskelta, eli keskimääräinen aikavaativuus push-operaatioille on lineaarinen.. Tarkastellaan nyt Pino2:n n:ssä peräkkäisessä lisäyksessä käytettävien askelten lukumäärää. Jälkimmäinen summa alla olevassa lausekkeessa seuraa siitä, että taulukko tarvitsee tuplata log 2 n kertaa, ja jokaisella tuplauksella kaikki alkiot pitää kopioida taulukosta toiseen. Oletetaan tässä kuitenkin laskujen yksinkertaistamiseksi, että luku log 2 n on kokonaisluku. Kun taulukko on yhden kokoinen pitää kopioida 1 = 2 0 alkiota, kun taulukko on kahden kokoinen pitää kopioida 2 = 2 1 alkiota jne. n 1+ i=1 log 2 n i=0 2 i = n+2 log 2 n+1 1 n+2 2 log 2 n = n+2 n = 3n Siis keskimäärin n:n lisäyksen jonossa jokainen lisäys vie vakioajan vaikka pahimman tapauksen aikavaativuus onkin lineaarinen. Tälläisessä tapauksessa sanotaan, että operaatio on tasoitetulta vaativuudeltaan vakioaikainen. Taulukkoon voidaan toteuttaa myös taulukon kutistaminen sopivalla tavalla poisto- operaatioiden yhteyteen. Sopiva tapa tarkoittaa tässä sitä, että taulukon koko puolitetaan esimerkiksi silloin, kun sen täyttöaste on 1 4. Tällöin voidaan osoittaa, että peräkkäisistä lisäyksistä ja poistoista saadaan tasoitetulta vaativuudeltaan vakioaikaisia. Ohitamme tässä perustelut, koska tasoitettu vaativuus ei varsinaisesti kuulu kurssin sisältöön. On kuitenkin hyödyllistä tuntea käsite. Lisäksi taulukkoja muuttuvan kokoisia taulukoita tarvitaan melko usein, joten on hyvä tietää miten se voidaan toteuttaa tehokkaasti. (a) Tehtävänä on toteuttaa algoritmi, jolle annetaan n lukua sisältävä taulukko ja joka laskee taulukon lukujen summan. Jos taulukossa olevat alkiot ovat toisistaan riippumattomia, on alkioiden summa selvästi riippuvainen jokaisesta taulukon alkiosta. Eli summan selvittämiseksi ei ole muuta mahdollisuutta kuin "katsoa"jokaisen alkion arvoa. Arvon katsominen onnistuu ajassa O(1) ja koska alkioita on n kappaletta, ei kaikkia voida käydä katsomassa nopeammin kuin ajassa O(n). (b) Algoritmin tilavaativuus ei voi olla aikavaativuutta suurempi, koska jokainen algoritmin askel voi viitata vain kiinteään määrään muistipaikkoja. Tämän vuoksi ei ole mahdollista, että algoritmi olisi vakioaikainen mutta sen muistinkäyttö kasvaisi lineaarisesti. merkiksi 3.14 = 3 ja 3 = 3 7
(c) Algoritmien analyysissa oletetaan yleensä, että luvut vievät vakiomäärän muistia ja esimerkiksi laskutoimitukset tapahtuvat vakioajassa. Tämä ei ole kuitenkaan järkevä oletus, jos luvut ovat huomattavan suuria. Tarkasti ottaen kokonaisluku n vie tilaa O(log n), koska siinä on luokkaa log n numeroa. Tässä logaritmin kantaluku riippuu lukujärjestelmästä, mutta sillä ei ole vaikutusta suuruusluokkaan. Esimerkiksi luvun 12345 bittiesitys 11000000111001 sisältää 14 numeroa ja log 2 12345 = 14. Jos taulukossa on n positiivista kokonaislukua, tulo 2 T[1] 3 T[2] 5 T[3] on varmasti ainakin 2 T[1] 2 T[2] 2 T[3], joka taas on ainakin 2 1 2 1 2 1 eli 2 n. Luvun tallennus vie siis muistia Ω(log2 n ) eli Ω(n). Käytännössä voidaan usein olettaa, että käsiteltävät luvut eivät ole kovin suuria vaan esimerkiksi 32-bittinen muuttuja riittää. Tällöin luvun suuruudella on yläraja ja se vie vain vakiomäärän tilaa. Mutta jos luvut voivat kasvaa rajatta, logaritmi kertoo todellisen muistinkäytön. 8