Tietorakenteet, laskuharjoitus 4, 7. 11.2 1. Tehtävässä piti toteuttaa jono käyttäen Javan valmiita LinkedList- sekä ArrayListtietorakenteita sekä tutkia niiden tehokkuutta. Kuvassa 1 näkyvät suoritettujen mittausten tulokset. 3000 LinkedList ArrayList 2500 2000 ms 1500 1000 500 0 10000 20000 30000 40000 50000 60000 70000 80000 90000 100000 alkioita Kuva 1: Jonon nopeus eri toteutuksilla lisättäessä ensin n alkiota ja tämän jälkeen poistettaessa n alkiota. Mittauksista näkee, että LinkedList on nopeudessaan aivan ylivoimainen. Tutkimalla Javan API-kuvausta selviää, että ArrayList on toteutettu taulukkoon ja Linked- List on toteutettu linkitettynä rakenteena. Poistettaessa ArrayListin alkupäästä tai keskeltä alkio joudutaan siirtämään kaikki sitä seuraavat alkiot askeleella eteenpäin. Linkitetyssä toteutuksessa taas korjataan vain viiteiden paikkaa ja jätetään poistettu listasolmu roskien kerääjän huoleksi. Siis ArrayListista poistettaessa joudutaan tekemään n i askelta, jossa n on alkioiden lukumäärä ja i poistettavan alkion indeksi. Kun alkio poistetaan aina listan edestä, niin n:ssä poisto-operaatiossa joudutaan tekemään (n 1) + (n 2) +... + 1 + 0 = n 1 i=0 i = O(n2 ) askelta. Tällöin yhtä operaatiota kohti kuluu keskimäärin lineaarinen aika. LinkedListia käytettäessä jokainen poisto listan kärjestä sujuu vakioajassa, joten n:n poisto-operatioon kuluvaksi ajaksi saadaan n O(1) = O(n). 1
Tämän perusteella LinkedListin käyttö tuntuu ArrayListin käyttö viisaammalta. Kuitenkin yleisessä tapauksessa riippuu sovelluksesta kumpaa kannattaa käyttää. Esimerkiksi ArrayList toteuttaa Javan RandomAccess rajapinnan, joka tarkoittaa, että jokaiseen listan alkioon päästään käsiksi vakioajassa. LinkedListin tapauksessa listaa joudutaan aina kelaamaan i askelta, kun halutaan päästä sen i:nnenteen alkioon käsiksi. Ohessa on Java-koodit, joita mittauksissa käytettiin. ArrayListJono.java import java.util.arraylist; class ArrayListJono { private ArrayList<Integer> lista; public ArrayListJono() { this.lista = new ArrayList<Integer>(); public boolean enqueue(integer alkio) { /* Tavallinen ArrayList-metodi palauttaa true, jos operaatio * onnistui ja false jos ei onnistunut. Voimme siis palauttaa * arvon suoraan kutsujalle. */ return this.lista.add(alkio); public Integer dequeue() { if (this.isempty()) return null; return this.lista.remove(0); public boolean isempty() { return this.lista.isempty(); LinkedListJono.java import java.util.linkedlist; class LinkedListJono { private LinkedList<Integer> lista; 2
public LinkedListJono() { this.lista = new LinkedList<Integer>(); public boolean enqueue(integer alkio) { return this.lista.add(alkio); public Integer dequeue() { if (this.isempty()) return null; return this.lista.remove(0); public boolean isempty() { return this.lista.isempty(); PerformanceTest.java import java.io.filewriter; import java.io.printwriter; import java.io.ioexception; class PerformanceTest { public static void main(string[] args) { if ( args.length < 3) { System.out.println("Anna testattava väli muodossa \"alku " + "loppu askel\""); System.out.println("Olkoon N = alku + i*askel. Jokaista N " + "<= loppu, molempiin jonoihin" + "tehdään N lisäystä ja poistoa"); System.exit(0); int alku = Integer.parseInt(args[0]); int loppu = Integer.parseInt(args[1]); int askel = Integer.parseInt(args[2]); PrintWriter ulos = null; try { ulos = new PrintWriter(new FileWriter("ArrayListJono.dat")); 3
for (int i = alku; i <= loppu; i += askel) { ArrayListJono jono1 = new ArrayListJono(); long start = System.currentTimeMillis(); for (int j = 0; j < i; j++) jono1.enqueue(j); for (int j = 0; j < i; j++) jono1.dequeue(); long end = System.currentTimeMillis(); ulos.println(i+"\t"+(end - start)); /* Toivotaan, että Javan roskienkeruu tapahtuu * ajanottojen ulkopuolella. */ System.gc(); catch (IOException e) { e.printstacktrace(); finally { if (ulos!= null) ulos.close(); ulos = null; try { ulos = new PrintWriter(new FileWriter("LinkedListJono.dat")); for (int i = alku; i <= loppu; i += askel) { LinkedListJono jono2 = new LinkedListJono(); long start = System.currentTimeMillis(); for (int j = 0; j < i; j++) jono2.enqueue(j); for (int j = 0; j < i; j++) jono2.dequeue(); long end = System.currentTimeMillis(); ulos.println(i+"\t"+(end - start)); System.gc(); catch (IOException e) { e.printstacktrace(); finally { if (ulos!= null) ulos.close(); 2. Ohessa olevan listan metodeilla on seuraavat aikavaativuudet. delete(k) vie lineaarisen ajan, koska samalla täytyy suorittaa avaimen haku. Pahimmassa tapauksessa (esimerkiksi jos poistettavaa avainta ei löydy) jou- 4
dutaan selaamaan listan kaikki alkiot läpi. Linkkien ylläpito-operaatiot, joita tehdään jos alkio poistetaan, sujuvat vakioajassa. search(k) vie samoista syistä lineaarisen ajan kuin delete-operaatiokin. insert(k) sujuu vakioajassa laittamalla jokainen lisättävä alkio jonon etupäähän. Tarvittavat linkkien ylläpito-operaatiot sujuvat vakio-ajassa. list() vie lineaarisen ajan, koska lista on pyyhkäistävä läpi kertaalleen. reverse() vie myös lineaarisen ajan, koska listan jok ikisen viitteen suunta on käännettävä. Tämä kyetään tekemään kulkemalla kerran listan läpi. Ohessa on vielä toteutuksen Java-koodi. class MyLinkedList { private class ListNode { /* Sisäluokan attribuutteihin halutaan päästä helposti käsiksi, * joten jätetään ne julkisiksi. */ public int key; public ListNode next; public ListNode(int arvo) { this.key = arvo; this.next = null; private ListNode head; public MyLinkedList() { this.head = null; public boolean search(int avain) { /* Iteroidaan listaa kunnes löydetään avain tai loppu tulee vastaan.*/ while ( current!= null && avain!= current.key ) { return (current!= null)? true : false; public void delete(int avain) { ListNode prev = null; 5
while ( current!= null && avain!= current.key ) { prev = current; if (current!= null) { // jos löytyi hoidetaan linkit kuntoon if ( prev!= null ) // poistettu alkio ei ollut ensimmäinen prev.next = current.next; else { // poistettu alkio oli ensimmäinen this.head = current.next; public void insert(int avain) { ListNode node = new ListNode(avain); node.next = this.head; this.head = node; public void list() { while( current!= null ) { System.out.print(current.key + " -> "); System.out.println("null"); public void reverse() { if ( this.head == null this.head.next == null ) return; ListNode prev = this.head; ListNode current = prev.next; prev.next = null; while ( current!= null ) { ListNode temp = current.next; current.next = prev; prev = current; current = temp; this.head = prev; public static void main(string[] args) { MyLinkedList lista = new MyLinkedList(); 6
for (int i = 0; i < 5; i++ ) lista.insert( (int) (100*Math.random()) ); lista.insert(101); lista.insert(101); for (int i = 0; i < 5; i++ ) lista.insert( (int) (100*Math.random()) ); lista.list(); lista.delete(101); lista.list(); lista.reverse(); lista.list(); if ( lista.search(101) ) System.out.println("101 löytyi listasta"); else System.out.println("101 ei löytynyt listasta"); 3. Kun lista pidetään järjestyksessä jokainen operaatio paitsi insert(k) vievät saman ajan kuin edelläkin. Koska järjestettyyn listaan ei voi lisätä alkioita mielivaltaiseen paikkaan, täytyy uusille alkioille aina etsiä oikea paikka listasta. Pahimmassa tapauksessa kuljetaan lista kerran läpi, jonka vuoksi tämä vie ajan O(n). Ohessa on toteutus Javalla. Huomaa, että Javamaisempi tapa tässä olisi ollut tehdä tästä edellisen kohdan aliluokka. class MyOrderedList { private class ListNode { /* Sisäluokan attribuutteihin halutaan päästä helposti käsiksi, * joten jätetään ne julkisiksi. */ public int key; public ListNode next; public ListNode(int arvo) { this.key = arvo; this.next = null; private ListNode head; public MyOrderedList() { this.head = null; 7
public boolean search(int avain) { /* Iteroidaan listaa kunnes löydetään avain tai loppu tulee vastaan.*/ while ( current!= null && avain > current.key ) { return (current!= null && current.key == avain)? true : false; public void delete(int avain) { ListNode prev = null; while ( current!= null && avain > current.key ) { prev = current; if (current!= null) { // jos löytyi hoidetaan linkit kuntoon if ( prev!= null ) // poistettu alkio ei ollut ensimmäinen prev.next = current.next; else { // poistettu alkio oli ensimmäinen this.head = current.next; public void insert(int avain) { ListNode node = new ListNode(avain); ListNode prev = null; while ( current!= null && avain > current.key ) { prev = current; node.next = current; if (prev == null) this.head = node; else prev.next = node; public void list() { while( current!= null ) { System.out.print(current.key + " -> "); 8
System.out.println("null"); public static void main(string[] args) { MyOrderedList lista = new MyOrderedList(); for (int i = 0; i < 5; i++ ) lista.add( (int) (100*Math.random()) ); lista.insert(101); lista.insert(101); for (int i = 0; i < 5; i++ ) lista.insert( (int) (100*Math.random()) ); lista.list(); lista.delete(101); lista.list(); lista.delete(101); lista.list(); if ( lista.search(101) ) System.out.println("101 löytyi listasta"); else System.out.println("101 ei löytynyt listasta"); 4. Esitetään kaksisuuntaisesti linkitetty lista toteutettuna taulukkoon, jonka koko on n. Taulukon kukin alkio sisältää kentät key, prev sekä next. next- ja prev-kentät sisältävät taulukon indeksit asiaan kuuluville solmuille. Tämän lisäksi talletetaan taulukon yhteyteen kokonaisluku head, joka on ensimmäisen solmun indeksi taulukossa. Käytännössä tämä voitaisiin toteuttaa käyttäen kaksiulotteista n 3-taulukkoa tai tallettamalla yksiulotteisen taulukon kenttiin viite tietueeseen, johon on talletettu edellämainitut kentät. Pseudokoodiesityksessä emme ota kantaa varsinaiseen toteutukseen vaan viittaamme esimerkiksi taulukon 3:nnessa paikassa olevan alkion avaimeen merkinnällä A[3 ]. key. Tallettamamme tiedon lisäksi meidän tarvitsee myös ylläpitää informaatiota taulukon vapaista paikoista. Käytetään tähän pinoa P, jossa on talletettuna vapaiden indeksien paikat. Lisättäessä taulukkoon alkioita talletetaan uusi alkio pinon päältä löytyvälle taulukon riville. Poistettaessa alkioita pinoon lisätään poistetun alkion indeksi. Pinon koko on suurimmillaan n, joten listan esitys taulukossa vaatii tilaa jokaisella ajanhetkellä Θ(n). Merkitään numerolla 0 Nil-viitettä. Alussa kun lista on tyhjä pinossa on kokonaisluvut 1, 2,..., n ja headin arvo on 0. Tarvitsemme siis listan L esittämiseen taulukon A, pinon P sekä kokonaisluvun head. Nyt olemme valmiita esittämään pseudokoodit Insert- sekä Delete-operaatioille. Insert ottaa parametrinaan avaimen ja lisää sen listaan sekä palauttaa lisätyn avaimen indeksin. Delete ottaa parametrikseen poistettavan alkion indeksin taulukossa 9
ja poistaa tämän indeksin paikalta löytyneen alkion. Tässä on hyvä huomata, että jos Delete- operaatiolle annetaan indeksi joka ei kuulu listaan, niin koko tietorakenne korruptoituu. Delete(L, x) 1 if head == x // Poistetaan listan ensimmäinen alkio 2 head = A[x].next 3 if A[x].next 0 // Poistettava alkio ei ollut listan viimeinen 4 A[A[x].next].prev = 0 5 else // Poistettavalla alkiolla on ainakin yksi edeltäjä 6 A[A[x].prev].next = A[x].next 7 if A[x].next 0 // Poistettava alkio ei ollut listan viimeinen 8 A[A[x].next].prev = A[x].prev // Lopuksi lisätään poistetun alkion paikka vapaiden alkioiden joukkoon 9 Push(P, x) Insert(L, k) 1 if Empty(P) 2 error lista on täynnä 3 i = Pop(P) 4 A[head].prev = i 5 A[i].key = k 6 A[i].next = head 7 A[i].prev = 0 8 head = i Nähdään molempien operaatioiden sujuvan vakioajassa, sillä osaamme toteuttaa pinon operaatiot tehokkaasti eikä kummassakaan operaatiossa suoriteta korkeintaan kuin vakiomäärän verran askelia. 5. Esitetään ensin vakiotilassa ja neliöllisessä ajassa toimiva algoritmi silmukan havaitsemiseen. Ajatuksena on, että edetään listassa aina yksi solmu kerrallaan ja pidetään yllä invarianttia: Ennen tätä solmua listassa ei ole esiintynyt viitteitä taaksepäin. Jos tämän hetkinen solmu on järjestyksessään n:s, niin käydään läpi solmut 1,..., n 1 ja tarkistetaan onko joku niistä myös n:s solmu. Siis n:n solmun tarkistamisessa tarvitsee suorittaa n 1 askelta. Summaamalla kaikkien solmujen tarkistukseen kuluvan ajan tarvitaan silmukallisessa listassa n 1 i=0 i+o(n) = O(n2 )+O(n) = O(n 2 ) askelta. Kaavan O(n) tulee silmukan havaitsemiseen kuuluvasta ajasta viimeisellä kierroksella. On helppo nähdä että myös silmukattoman listan tarkistamiseen kuluu saman verran aikaa. Esitetään nyt algoritmi pseudokoodina. 10
OnkoSilmukkaa1(L) 1 current = L.head.next 2 n = 1 3 while current Nil 4 check = L. head 5 i = 0 6 repeat 7 if check == current // sykli löytyi 8 return True 9 check = check. next 10 i = i + 1 11 until i n 12 current = current. next 13 n = n + 1 14 return True Tarkastelemalla pseudokoodia havaitaan, että sitä ei tarvitsisi muuttaa kovinkaan paljon, mikäli haluttaisiin myö palauttaa solmu, jonka linkki oli virheellinen. Tämä vaatisi prev- mukana pitämistä, jonka arvo olisi riviltä 6 alkavassa silmukassa aina sellainen, että prev.next == check. Toinen tapa havaita onko listassa silmukka olisi koittaa kääntää listan linkit muokkaamalla tehtävässä 4 suunniteltua Reverse-operaatiota. Tällöin, jos listassa L olisi silmukka, niin L. head pysyisi samana Reverse-operaationkin jälkeen. Jos taas listassa ei olisi silmukkaa niin listan ensimmäinen alkio olisi joku toinen alkio, kuin ennen Reverse-operaatiota. Lista voitaisiin palauttaa alkuperäiseksi kääntämällä se uudelleen. Tämä veisi tilaa ainoastaan vakiomäärän, eikä aikavaativuuskaan olisi kuin Θ(n). Kuitenkin tässä lähestymistavassa on 2 ongelmaa: listaa muokattaisiin laskennan aikana emmekä löytäisi kohtaa, jossa silmukka esiintyy. Ongelma voidaan muotoilla hiukan yleisemmällä tavalla, jolloin puhutaan syklin tunnistamisalgoritmeista (cycle detection). Ongelmaan on esitetty useita ajassa O(n) ja tilassa O(1) toimivia algoritmeja, joista tässä esitellään Floydin kilpikonna ja jänis - algoritmi sovellettuna yksisuuntaisesti linkitettyyn listaan. Yleistettäessä algoritmi sykleihin yleisellä tasolla ei se muutu juuri lainkaan 1. Ajatuksena on lähettää kilpikonna ja jänis kulkemaan listaa eteenpäin. Kilpikonna on jänistä hitaampi ja se ehtii edetä vain yhden solmun verran siinä ajassa kun jänis etenee kahden solmun verran. Jokaisen askeleen jälkeen tarkistetaan ovatko jänis ja kilpikonna samassa kohdassa. Mikäli ne jossain vaiheessa ovat on listassa silmukka. Algoritmi voidaan muokata palauttamaan sen solmun järjestysnumeron, johon listan taaksepäin suuntautuva linkki osoittaa. Tässä esitetään kuitenkin ainoastaan algoritmin alkuosa, joka tutkii onko listassa silmukka. Merkitään kilpikonnaa ja jänistä viitemuuttujilla k ja j. 1 Tällöin tarkastellaan mielivaltaista funktiota f ja lukujonoa x 0, x 1 = f(x 0), x 2 = f(x 1),... Tapauksessamme funktion f korvaa linkitetyn listan next-viite 11
OnkoSilmukkaa2(L) 1 k = j = L.head 2 if j == Nil tai j.next == Nil 3 return False 4 repeat 5 k = k.next 6 j = j.next.next 7 until j == k tai j == Nil tai j.next == Nil 8 if j == k 9 return True 10 else return False Tarkastellaan tapausta, jossa listassa on silmukka. Kun kilpikonna pääsee listan silmukan alkukohtaan on jänis jo kiertämässä silmukkaa. Jänis etenee kilpikonnaan nähden kaksinkertaisella vauhdilla, joten kun kilpikonna on kiertänyt silmukan on jänis kiertänyt sen vähintään kerran. Tämän vuoksi aika, jonka kilpikonna viettää silmukassa on luokkaa O(n). Lisäksi kilpikonnan matka silmukaan kestää O(n), joten koko algoritmille riittää lineaarinen aika. Algoritmissa käytetään vain kahta muuttujaa, joten se toimii vakiotilassa. 12