Tietorakenteet, laskuharjoitus 1, 19.-22.1 Huom: laskarit alkavat jo ensimmäisellä luentoviikolla 1. Taustaa http://wiki.helsinki.fi/display/mathstatkurssit/matukurssisivu Halutaan todistaa, että oletuksesta A seuraa väite B eli propositiologiikan merkinnöin A B. A ja B siis ovat propositioita, eli väittämiä jotka ovat joko tosia tai epätosia. A voisi olla esim. väittämä x on rationaaliluku ja B väittämä x on irrationaaliluku. Jos ajatellaan tiettyä lukua x, esim. x = 754, niin väittämä x on irrationaaliluku on luvulle joko tosi tai epätosi. Mitään "kolmatta" vaihtoehtoa ei ole. 1 Loogisten konnektiivien totuus voidaan määritellä totuustaulujen avulla. Seuraavassa implikaation eli A B:n totuustaulu: A B A B T T T F T T T F F F F T Käytännössä tämä sanoo että jos A on tosi, niin B:n on oltava tosi. Muussa tapauksessa asioihin ei oteta kantaa, eli jos A on epätosi, voi B olla joko tosi tai epätosi. Näytetään totuustaulun avulla, että A B:n kanssa yhtäpitävää on B A, eli että A B pitää paikkansa jos ja vain jos B A pitää paikkansa. A B B A B A T T F F T F T F T T T F T F F F F T T T Näemme, että A B:n ja B A:n arvo on sama kaikilla mahdollisilla A:n ja B:n arvoilla, eli ne ovat ekvivalentit: A B B A Tämä voidaan ajatella niin, että olivatpa asiat miten tahansa A:n ja B:n suhteen, ovat lauseet A B ja B A merkitykseltään samat, eli jos toinen on tosi niin toinenkin. Samoin epätotuuden suhteen. Tehdään sitten totuustaulu seuraavalle A B F:lle 1 Eli riippumatta siitä osataanko jotain matemaattista väittämää todistaa, niin väittämä joko on tosi tai ei ole tosi. Voi olla, että ihminen ei tiedä onko väittämä tosi vai epätosi, mutta "matematiikan" mielestä epävarmuutta ei ole. 1
A B B A B A B F T T F F T F T F F T T F T T F F F T F T Nytkin huomaamme, että A B ja B A käyttäytyvät täsmälleen samalla, eli ne ovat ekvivalentit: A B A B F Totuustaulujen avulla on helppo näyttää myös, että A B B A on ekvivalentti A B:n kanssa. 2. Tehtävänä on todistaa induktiolla yhtäsuuruus n i=0 2i = 2 n+1 1. Summakaava on järkevä, kun n on positiivinen kokonaisluku (1, 2, 3,... ). Esimerkiksi jos n = 3, niin n i=0 2i = 3 i=1 2i = 2 0 + 2 1 + 2 2 + 2 3 = 15. Toisaalta 2 n+1 1 = 2 4 1 = 15, joten kaava pitää paikkansa ainakin n:n arvolla 3. Seuraavaksi osoitetaan, että yhtäsuuruus on voimassa kaikilla n:n arvoilla. Induktiotodistus muodostuu kahdesta osasta: perustapauksesta ja induktioaskeleesta. Perustapaus osoittaa, että kaava pitää paikkansa pienimmällä n:n arvolla eli tapauksessa n = 1. Induktioaskel osoittaa, että jos kaava pitää paikkansa tapauksessa n = k, se pitää paikkansa myös tapauksessa n = k + 1. Jos nämä kaksi ominaisuutta pystytään todistamaan, kaava yleistyy kaikille positiivisille kokonaisluvuille, koska mihin tahansa lukuun päästään perustapauksesta hyödyntämällä monta kertaa induktioaskeleen tietoa. Esimerkiksi kaavan on silloin pakko päteä tapauksessa n = 37294, koska ensinnäkin kaava pätee tapauksessa n = 1, sitten kun kaava pätee tapauksessa n = 1, se pätee myös tapauksessa n = 2, edelleen kun kaava pätee tapauksessa n = 2, se pätee myös tapauksessa n = 3, jne., kunnes lopulta kun kaava pätee tapauksessa n = 37293, se pätee myös tapauksessa n = 37294. Perustapaus: Kun n = 0, vasen puoli 0 i=0 2i = 2 0 = 1 ja oikea puoli 2 0+1 1 = 2 1 1 = 1, joten kaava pitää paikkansa. Induktioaskel: Oletetaan, että kaava pätee tapauksessa n = k, ja osoitetaan, että kaava pätee tapauksessa n = k + 1. Toisin sanoen oletetaan, että k i=0 2i = 2 k+1 1, ja osoitetaan tämän tiedon avulla, että k+1 i=0 2i = 2 k+2 1. Otetaan viimeinen summattava summamerkinnän ulkopuolelle: k+1 i=0 2i = k i=0 2i + 2 k+1. Nyt summa k i=0 2i vastaa tapausta n = k, ja induktio-oletuksen perustella se on 2 k+1 1. Eli k+1 i=0 2i = 2 k+1 1 + 2 k+1 = 2 2 k+1 1 = 2 1 2 k+1 1. Muistamme kansakoulusta, että a m a n = a m+n, eli saadaan k+1 i=0 2i = 2 k+2 1. Olemme siis osoittaneen, että kaava pätee myös tapauksessa n = k + 1. 2
3. (i) log 2 4 = 2 sillä 4/2/2 = 1. (ii) log 2 32 = 5 sillä 32/2 = 16, 16/2 = 8, 8/2 = 4, 4/2 = 2 ja 2/2 = 1. Tehdään seuraava huomio: Jakamalla 32 viisi kertaa kahtia, päädytään lukuun yksi: 32/2/2/2/2/1 = 1, joka taas on sama kuin 32/2 5 = 1. Kerrotaan tämä puolittain 2 5 :llä ja saadaan 32 = 2 5. Tämähän on juuri logaritmin määritelmä, eli luvun 32 2-kantainen logaritmi on luku, joka kertoo kuinka monenteen potenssiin 2 on korotettava, jotta saadaan 32! (iii) log 3 3 = 1. (iv) log 3 81 = 4, sillä 81/3 = 27, 27/3 = 9, 9/3 = 3, 3/3 = 1. (v) log 7 1 = 0, sillä 7 0 = 1. (vi) log 7 49 = 2, sillä 49/7/7 = 1. (vii) log 10 1000 = 3. (viii) log 10 10000 = 4. 4. Jatkoa edelliseen tehtävään a. Perustellaan peräkkäisten jakamisten avulla laskusääntö: log a (x y) = log a x + log a y Tässä on mielekästä olettaa tehtävän 3 tapaan, että a:lla jakaminen menee tasan. Tarkastellaan esimerkkiä log 2 32 = log 2 (8 4). Miten monta kertaa tulo 8*4 on jaettava kahdella, jotta se menee ykköseksi? Jos 8 jaetaan 3 kertaa kahdella, tuloksena 1 ja jos 4 jaetaan 2 kertaa, tuloksena 1. Eli jakamalla 3+2 kertaa kahdella, tulosta tulee 1. Eli näyttää siltä, että kokonaisuuden x*y saa ykköseksi, kun siihen kohdistetaan jaot, joilla x:n saa ykköseksi plus ne jaot, joilla y:n saa ykköseksi. Hieman täsmällisemmin ilmaistuna: log a x = j ja log a y = k on kolmosen perusteella sama asia kuin x/2 j = 1 ja y/2 k = 1 joka taas sama kuin x y = x y eli x y saadaan ykköseksi 2 j 2 k 2 j+k jakamalla se kahdella j + k-kertaa, joka siis kolmosen perusteella sama kuin log a x y = j + k. b. Perustellaan edellisen säännön avulla että log a x y = y log a x Oletetaan, että y on kokonaisluku. Nyt log a x y = log a (x x {{... x ) edellisen y kappaletta säännön perusteella tämä on log a x+log a (x x {{... x ) =... = log a x +... + log a x = {{ y-1 kappaletta y kappaletta y log a x Jos y ei ole kokonaisluku, ei kaavaa pystytä perustelemaan suoraan a-kohdan avulla. 5. Fibonaccin lukujonon n:s luku voidaan määritellä seuraavasti F(n) = { 1 jos n = 1 tai n = 2 F(n 1) + F(n 2) jos n > 2 3
Laske kynällä ja paperilla 10 ensimmäistä Fibonaccin lukua. f(1) = 1 f(2) = 1 f(3) = f(2) + f(1) = 1 + 1 = 2 f(4) = f(3) + f(2) = 2 + 1... Kaikki kolme Javalla seuraavassa. Mukana myös pääohjelma testaamista varten class Fibo { public static long fib1(int k){ if ( k== 1 k==2 ) return 1; return fib1(k-1)+fib1(k-2); public static long fib2(int k){ long[] f = new long[k+1]; f[1] = 1; f[2] = 1; for ( int i=3; i<=k; i++ ) f[i] = f[i-1]+f[i-2]; return f[k]; public static long fib3(int k){ if ( k==1 k==2 ) return 1; int fi = 1; // i:s numero int fi_1 = 1; // i-1:s numero int fi_2 = 1; // i-2:s numero for ( int i=3; i<=k; i++ ) { // i:s numero kahden edellisen summa fi = fi_1 + fi_2; // uusi i-2:s on vanha i-1:s fi_2 = fi_1; // uusi i-1:s on vanha i fi_1 = fi; return fi; public static void main(string args[]){ // ensimmäinen komentoriviparametri kertoo mitä algoritmia käytetään if ( args.length<2 ) System.exit(0); int valinta = Integer.parseInt( args[0] ); 4
int n = Integer.parseInt( args[1] ); long vast = 0; if ( valinta == 1 ) vast = fib1(n); else if ( valinta == 2 ) vast = fib2(n); else vast = fib3(n); System.out.println( "fib"+ valinta + "(" + n + ")=" + vast ); Rekursiivinen ratkaisu on esteettisesti tyylikäs, mutta erittäin tehoton. Esim. kun lasketaan f(5), lasketaan ensin rekursiivisesti f(4) ja f(3). Kun lasketaan f(4) tarvitaan myös f(3), mutta algoritmi laskee sen uudelleen. Vastaavasti laskennan aikana eri arvot f(i) lasketaan toistuvati yhä uudelleen ja uudelleen ja aikaa kuluu. Aikavaativuuden suhteen algotimi onkin eksponentiaalinen. Rekursiivisuuden takia myös tilaa kuluu paljon: kun ollaan siinä vaiheessa, että k = 2, on rekursiivisten kutsujen joissa k = 3, 4, 5,..., n kutsu kesken. Koska jokainen kutsuinstanssi vie vakiomäärän tilaa, lienee tilavaativuus O(n). Taulukkoratkaisu hyödyntää esim. lukua f(7) laskiessaan jo valmiina taulukossa olevat arvot f(6) ja f(5). Taulukko käydään kerran läpi, joten aikavaativuus selvästi O(n). Myös tilavaativuus on O(n) sillä aputaulukon koko on n + 1 (indeksiä 0 ei käytetä). Laskettaessa taulukon lokeron f[i] arvoa tarvitaan ainoastaan kahta edellistä lokeroa f[i_1] ja f[i_2], eli taulukon alkuosassa olevat arvot eivät ole enää tarpeen. Kolmas ratkaisu käyttääkin muistia ainoastaan vakiomäärän, eli käytössä on kolme muuttujaa fi, fi_1 ja fi_2, joista ensimmäinen on se fibonacci jolle ollaan juuri laskemassa arvoa, muut kaksi kertovat edellisen ja sitäedellisen. Kun uusi arvo on laskettu, päivitetään edellinen ja sitäedellinen. Aikavaativuus edelleen O(n), mutta tilavaativuus O(1). Äkkisältään tarkastellen fib3 siis vaikuttaa parhaalta. Entä jos tarvitsisimme jossakin sovelluksessa fibonaccin lukuja toistuvasti? Taulukkoon perustuvasta ratkaisusta olisi helpohkosti muokattavissa algoritmi, joka laskiessaan kerran tietyn fibonaccin luvun, esim. F(1000) säilyttäisi laskennan aikana selvitetyt luvut F(3),..., F(1000) tulevaa käyttöä varten. Jos jossain vaiheessa algoritmilta kysyttäisiin esim. fibonaccin lukua F(859) se saataisiin vakioajassa. Jos taas kysyttäisiin esim. lukua F(2345) sen arvo jouduttaisiin laskemaan, mutta voitaisiin aloittaa F(1):n sijasta jo muistissa olevien lukujen ansiosta F(1001):sta. Näin saataisiin taas tulevaisuuden varalle selvitettyä lisää fibonaccin lukuja. 6. Olkoon x desimaaliluku ja n 0 kokonaisluku. Luku x n voidaan laskea seuraavan palautuskaavan mukaisella rekursiivisella algoritmilla: 5
1 jos n = 0 x n = xx n 1 jos n pariton x n/2 x n/2 jos n parillinen Lasketaan seuraavassa 3 11 :lle arvo. Jokaisessa askeleessa on sovellettu jotain palautuskaavan osaa alleviivattuun termiin. 3 11 = 3 3 10 = 3 3 5 3 5 = 3 3 3 4 3 5 = 3 3 3 2 3 2 3 5 = 3 3 3 3 3 5 =... = 3 3 3 3 3 3 3 3 3 3 3 Ratkaisu Javalla, mukana myös pääohjelma testaamista varten. Rekursion toisessa tapauksessa lasketaan x n/2 x n/2. Riittää tietenkin laskea termin x n/2 arvo kertaalleen ja kertoa sitten tulos itsellään. class Pot { public static double potenssi(double x, int n){ if ( n==0 ) return 1; else if ( n % 2!= 0 ) return x*potenssi(x,n-1); else { double apu = potenssi(x, n/2); return apu*apu; public static void main(string args[]){ if ( args.length<2 ) System.exit(0); double x = Double.parseDouble( args[0] ); int n = Integer.parseInt( args[1] ); double vast = potenssi(x,n); System.out.println( x +"^"+ n +" = "+ vast ); Algoritmi laskee potenssin käyttämällä pelkästään kertolaskuoperaatiota. Esim. Java API:sta löytyy pow, joka tekee suunilleen saman asian (Javan pow:issa potenssin tyyppi saa olla double). Täytyy kuitenkin huomioida, että ainakaan yleisesti käytössä olevat prosessoriarkkitehtuurit eivät suoraan tue potenssiin korottamisen tapaisia monimutkaisia operaatiota, vaan ne on joka tapauksessa suoritettava alkeellisimmilla komennoilla esim. kertolaskuoperaatiota käyttäen. Algoritmin syötteen kokona voi käyttää potenssia n. Algoritmi selvästi "puolittaa" potenssin vähintään joka toisella rekursiivisella kutsulla. Jos nimittän n on pariton, muuttuu se seuraavan kutsun jälkeen parilliseksi: 6
2 10001 = 2 2 10000 = 2 2 5000 2 5000, jne. Tekemällä kertominen suoraviivaisesti public static double potenss2(double x, int n){ int vast = 1; for ( int i=1; i<=n; i++ ) vast = vast * x; return vast; päädytään selvästi ajassa O(n) toimivaan algoritmiin. Rekursiivinen algoritmi siis puolittaa syötteen koon vähintään joka toisella kutsulla. Luentokalvoilla todettiin, että ongelma-alueen puolitukseen perustuvat algoritmit ovat O(log n). Voidaan ajatella, että algoritmimme on puolet huonompi kuin logaritminen algoritmi (koska vain joka toinen rekursiivinen kutsu puolittaa syötteen). Koska O-analyysissä vakiot häviävät, ei "puolet huonommalla" ole merkitystä ja täten rekursioon perustuva tapa laskea potenssi on myös O(log n). Eli algoritmi on hyvä, parempaa ei liene olemassa. Huom: tämä päättely ei täytä täysin kaikken jyrkimpiä matemaattisia kriteereitä mutta riittänee TiRa-kurssin tarpeisiin. 7