Informationsteknologi Malin Källén, Tom Smedsaas 15 mars 2017 Algoritmanalys Inledning Exempel 1: xn När vi talade om rekursion presenterade vi två olika sätt att beräkna xn , ett iterativt: xn = x · x · x · x · . . . · x och ett rekursivt: ( 1 xn = xxn−1 Båda dessa algoritmer genererar 1000) att generera 1000 n om om (1) n=0 n>0 (2) multiplikationer. Exempelvis så kommer anropet multiplikationer oavsett om power power(x, är implementerad enligt denition 1 eller denition 2. En annan möjlig denition är: 1 n x = (xn/2 )2 x(xn/2 )2 Notera att n/2 om om om n=0 n>0 n>0 och och n n jämn (3) udda betecknar heltalsdivision! Denna denition är rekursiv, precis som denition 2, men i stället för att återföra lösningen av ett problem av storleken av storleken n/2, n på ett problem av storleken n − 1 så använder vi oss av problem d.v.s. ett betydligt mindre problem. Javakod: public static double power(double x, int n) { if (n == 0) return 1.; else { double p = power(x, n/2); if (n % 2 == 0) //n jämn return p*p; else //n udda return x*p*p; } } Anropet power(x,1000) kommer att generera följande sekvens av anrop: power(x,1000) -> power(x,500) -> power(x,250) -> power(x,125) -> power(x,62) -> power(x,31) -> power(x,15) -> power(x,7) -> power(x,3) -> power(x,1) -> power(x,0) 1 Varje uppväckning av power utom den sista innehåller en eller två multiplikationer (kvadre- ring och eventuell multiplikation med x). Sammanlagt krävs endast 15 multiplikationer för att beräkna resultatet! Övning 1: Modiera programmet så att det även hanterar negativa exponenter. Tänk re- kursivt! Hur många anrop kommer ett anrop power(x, -1000) att generera? Asymptotisk notation Om vi beräknar xn enligt denition 1 eller 2, kommer vi att behöva utföra Om vi däremot använder denition 3, kommer vi bara att behöva utföra tioner, förutsatt att n är en jämn tvåpotens. Om n n multiplikationer. log2 (n) multiplika- inte är en jämn tvåpotens, krävs högst en extra multiplikation vid varje halvering av problemet, d.v.s. totalt 2 · log2 (n) multiplikationer. Med de första två denitionerna växer antalet multiplikationer linjärt med säga att algoritmerna är O(n) n. Man brukar n. O(n) uttalas ordo n. Med log2 (n). Man säger att denna algoritm är log(n). (I nästa avsnitt ska vi se varför man inte eller att (tids)komplexiteten är denition 3 växer antalet multiplikationer med O(log(n)), eller att (tids)komplexiteten är behöver ange vilken logaritm som avses.) Mer formellt O anger egentligen en övre gräns: En funktion t(n) är O(f (n)) om det existerar två konstanter c och n0 sådana att: |t(n)| ≤ c · |f (n)| ∀n > n0 (4) Om vi applicerar denna denition på exemplen med antalet multiplikationer ovan, betecknar |t(n)| antalet multiplikationer som behöver utföras. Använder vi någon av de två första de- nitionerna är fall är c=1 och f (n) = n. Med denition 3 är f (n) = log2 (n) och c = 2. I samtliga n0 = 0. I de här exemplen används alltså O för att ange (en övre gräns för) hur många gånger en central operation (i det här fallet multiplikation) används. Notationen kan också användas för att ange en övre gräns för hur lång tid eller hur stort lagringsutrymme en algoritm behöver. Tidsåtgången för en algoritm växer normalt lika fort som antalet operationer, men värdet på c beror då även på sådant som vilket språk algoritmen är implementerad i och vilken dator implementationen körs på. Eftersom O(2n ) O betecknar en övre gräns, är en algoritm som är O(log(n)) också O(n), O(n2 ), och så vidare. Tecknet Ω används för att ange en undre gräns: En funktion två konstanter c och n0 Ω(f (n)) ∀n > n0 O(f (n)) och Ω(f (n)) sägs den vara Θ(f (n)). konstanter c för den övre och den undre gränsen.) t(n) man använda olika är om det existerar sådana att: |t(n)| ≥ c · |f (n)| Om funktionen t(n) är både 2 (5) (Naturligtvis måste Ofta säger man O när man egentligen menar Θ. Notera att det inte spelar någon roll vilken logaritm man använder i eftersom loga (x) = och 1 logb (a) kan bakas in i c. O-, Θ- och Ω-uttrycken, 1 · logb (x) logb (a) Därför behöver man heller inte ange vilken logaritm som avses. Fler exempel I detta avsnitt analyserar vi några algoritmer som ni känner till sedan tidigare. Exempel 2: Indicering i array Antag att du har en array som du ska hämta första elementet (eller något annat givet element) ur. Detta är ett exempel på när tidsåtgången inte växer med problemstorleken. Oavsett hur lång arrayen är kommer det att krävas lika många operationer för att hämta elementet. Indicering i array är alltså Θ(1) eller O(1) vilket är samma sak. Exempel 3: Sökning i osorterad array När det gäller sökning brukar man vid algoritmanalys skilja på lyckad och misslyckad sökning. Om värdet vi letar efter inte nns i arrayen, säger vi att sökningen misslyckas. En misslyckad sökning kräver att vi kontrollerar varje element i arrayen. (Hur ska vi annars kunna vara säkra på att värdet inte nns?) Antalet operationer växer alltså linjärt med problemstorleken (d.v.s. arrayens längd) och vi säger att en misslyckad sökning i en osorterad array är Θ(n). Om det eftersökta värdet nns i arrayen, säger vi att sökningen lyckas. Här är det relevant att skilja på bästa fall, värsta fall och genomsnitt. I värsta fall behöver vi leta igenom hela arrayen innan vi hittar det eftersökta värdet. I genomsnitt behöver vi leta igenom halva arrayen för att hitta det eftersökta värdet. I båda dessa fall växer antalet operationer linjärt med problemstorleken. I bästa fall hittar vi det eftersökta värdet på det första stället vi letar på. Då behövs ett konstant antal operationer -det spelar ingen roll hur lång arrayen är. En lyckad sökning i en osorterad array är alltså: • Θ(1) i bästa fall • Θ(n) i genomsnitt • Θ(n) i värsta fall Generellt är man intresserad av en algoritms komplexitet i värsta fall och/eller i genomsnitt. Exempel 4: Sökning i sorterad array Om arrayen som vi ska söka i är sorterad, minskar mängden arbete avsevärt. I detta exempel kallar vi arrayen för a och det eftersökta värdet för i stigande ordning, d.v.s. så att a[i+1] ≥ a[i] v. 1. Om arrayen är tom har sökningen misslyckats. 3 Vi antar att elementen i för alla index v i. a är sorterade En sökning går då till såhär: kan ju omöjligt nnas i en tom array. 2. Om a[n/2] == v är vi klara. 3. Annars, upprepa proceduren, den här gången med en hälften så lång array: a[n/2] > v, ge a[n/2+1:n] n/2:te elementet) som input. • Om • Om a[n/2] < v, ge a[0:n/2-1] n/2:te elementet) som input. (d.v.s. den delen av (d.v.s. den delen av a a som ligger till höger om det som ligger till vänster om det På det här sättet halverar vi problemstorleken i varje steg. Jämför med hur du själv skulle leta i en mängd sorterade element, exempelvis en telefonkatalog, en klasslista eller registret i en bok! Vid en misslyckad sökning behöver vi fortsätta tills arrayen är tom. Om vi skulle dubblera längd, skulle antalet nödvändiga operationer öka med logaritmiskt; algoritmen är a:s 1. Med andra ord växer mängden arbete Θ(log(n)). Vid en lyckad sökning behöver vi utföra samma procedur, men kommer i genomsnitt att hitta v efter halva tiden. Mängden arbete växer alltså som och kan bakas in i Övning 2: c. Algoritmen är alltså Θ(log(n)) 0, 5 · log(n), men 0, 5 är bara en konstant i genomsnitt. Hur många operationer kräver en lyckad sökning i en sorterad array i bästa respektive värsta fall? Exempel 5: Fibonaccitalen När vi arbetade med rekursion, visade vi en enkel (men inte särskilt eektiv) metod för att beräkna det n:te Fibonaccitalet: public static long fib(int n) { if ( n==0 ) return 0; else if ( n==1 ) return 1; else return fib(n-1) + fib(n-2); } Tiden t(n) det tar att exekvera denna metod växer exponentiellt med n. Mer exakt så är t(n) ≈ c · 1.618n där c är en konstant som beror på dator, programmeringsspråk och så vidare. Bevis* antal För att inse det kan vi räkna hur många additioner anropet t(n) gäller: ( 0 t(n) = 1 + t(n − 1) + t(n − 2) Observera likheten med Fibonaccitalen! 4 om om n ≤ 1, n > 1. fib(n) utför. För detta Detta är en linjär dierensekvation och den homogena ekvationen (som är lika med Fibonaccitalen!) har den karaktäristiska ekvationen r2 − r − 1 = 0, som har lösningen r1,2 √ 1± 5 = , 2 d.v.s. den homogena ekvationen har lösningen F (n) = ar1n + br2n , där a och b bestämmes ur begynnelsevillkoren. Eftersom t(n) = −1 är en partikulärlösning, kan den allmänna lösningen skrivas t(n) = ar1n + br2n − 1. Med hjälp av begynnelsevillkoren kan a och b bestämmas och ger lösningen a =(1 − r2 )/(r1 − r2 ) b = − (1 − r1 )/(r1 − r2 ) Eftersom r1 ≈ 1.618 och r2 ≈ 0.618 < 1 så ser man att, för stora n, gäller t(n) ≈ 1.618n Antalet additioner växer således exponentiellt och därmed växer också tiden t(n) exponentiellt. QED Övning 3: Tag reda på hur lång tid anropen fib(50) respektive fib(100) tar (skulle ta) på din dator. Svara med lämpliga enheter! (Sekunder är inte en lämplig enhet om det tar era timmar. Timmar är inte en lämplig enhet om det tar era dagar eller år.) ♦ Programmet är trädrekursivt, det vill säga varje anrop resulterar i två nya anrop. Detta kan potentiellt ge orimliga exekveringstider. Om du, när vi arbetade med rekursionsmaterialet, testkörde växlingsprogrammet med stora värden på a och n, märkte du förmodligen att exe- kveringstiden växte väldigt snabbt. Även den algoritmen är trädrekursiv och dess tidsåtgång växer exponentiellt. Med s.k. dynamisk programmering skulle växlingsproblemet kunna lösas eektivare, men det ligger utanför den här kursen. Som vi ser i avsnittet om sortering nedan, behöver trädrekursiva algoritmer inte nödvändigtvis ge orimliga exekveringstider. I själva verket är många klassiska eektiva algoritmer (t.ex. sorteringsalgoritmer och snabb Fouriertransform) trädrekursiva. Övning 4: I den här övningen vill vi att ni går tillbaka till SpecialStack, i vilken ni ck ändra och implementera några metoder när vi arbetade med rekursion. Tag genom mätning 5 och teoretiska beräkningar reda på hur lång tid detta program tar på din dator när stackens storlek är 10, 50 respektive 100. Svara med lämpliga enheter! (Sekunder är inte en lämplig enhet om det tar era timmar. Timmar är inte en lämplig enhet om det tar era dagar eller år.) ♦ Sortering Här presenterar och analyserar vi två rekursiva sorteringsalgoritmer. Instickssortering Den vanliga enkla instickssorteringen kan uttryckas rekursivt enligt följande: Induktionsantagande: Vi kan sortera n−1 element. Basfall: Vi kan sortera 1 element. Induktionssteg: Stoppa in det n:te elementet bland de n−1 redan sorterade elementen så att sorteringen bibehålls. Javakod: public static void sort(double [] a, int n) { if ( n > 1 ) { sort( a, n-1 ); // sortera de n-1 första double x = a[n-1]; int i = n-2; // flytta undan while ( i>=0 && a[i]>x ) { a[i+1] = a[i]; i--; } a[i+1] = x; // lägg in sista } } Tiden att sortera n tal med denna algoritm beror på hur talen är permuterade. Det är enkelt att visa att tiden i genomsnitt är Övning 5: sortera n Θ(n2 ). Hur lång tid tar instickssorteringen i bästa respektive värsta fall på sig för att element? Svaret ska ges på formen Θ(f (n)). ♦ Mergesort Nästa algoritm vi går igenom kallas för mergesort (eller samsortering på svenska). Den är, som analysen och övningen nedan visar, väsentligt eektivare än instickssorteringen. 6 Induktionsantagande: Vi kan sortera n/2 element. Induktionssteg: Dela mängden i två delar med vardera n/2 element, Sortera dessa var för sig och sammanfoga sedan de två sorterade delarna. Sortera n element 1. dela i två lika stora delar 2. sortera delarna var för sig 3. sammanfoga delarna Vi ger just nu ingen kod för denna sorteringsmetod. Det beror på att den är lite knölig att implementera och inte heller blir så bra för just arrayer. Vi återkommer till den senare för andra datastrukturer. Arbetet att sammanfoga de två sorterade delarna är proportionellt mot antalet element. (Ut- Θ(f (n)) t(n) = c · f (n).) trycket tiden är d.v.s. Låt t(n) kan, löst uttryckt, tolkas om att tiden är proportionell mot f (n) n element. Då gäller ( c om n = 0, t(n) = 2t(n/2) + dn om n > 0. beteckna tiden att sortera Om n är en jämn 2-potens, n = 2k så gäller t(n) =2t(n/2) + dn = 2(2t(n/4) + dn/2) + dn = =4t(n/4) + dn + dn = . . . =2k t(n/2k ) + dnk = =nt(1) + dn log n d.v.s. tiden är Övning 6: Θ(n log n). Antag att instickssortering och mergesort tar lika lång tid för 1000 tal -säg 1 sekund. Hur lång tid tar det för respektive algoritm att sortera 106 tal respektive 109 tal? Svara med lämpliga enheter! ♦ Praktiska tillämpningar Som vi har sett i ett par olika exempel ovan, kan tidsåtgången för algoritmer snabbt bli orimligt stor om man väljer fel algoritm (eller om det inte nns någon eektiv algoritm tillgänglig). Vi vill ha algoritmer för vilka tidsåtgången växer långsamt. Tiden för att lösa ett litet problem är ofta försumbar; det är när problemstorleken växer som det börjar bli viktigt att ha en eektiv algoritm. Som följande exempel visar, ska man dock inte stirra sig blind på komplexiteten. I praktiken 7 kan till exempel en algoritm som är är Θ(n), lika eektiv som en algoritm som om konstanten (c) är mycket större för den senare. Övning 7: Vi låter Θ(n · log(n)) vara minst n Antag att du kan välja mellan två algoritmer, A och B, för att lösa ett problem. beteckna antalet element i den datastruktur som algoritmerna opererar på. Du vet att algoritm A löser ett problem av storlek n på n sekunder. Tidsåtgången för algoritm B är c · n · log(n) sekunder, där c är en konstant. Du testkör algoritm B på din dator och nner den tar 1 sekund på sig för att lösa ett problem när n = 10. Hur stort måste n vara för att att algoritm A ska ta kortare tid på sig än algoritm B? ♦ Det nns givetvis tillfällen då man faktiskt behöver lösa så här stora problem, men i många verkliga tillämpningar är antalet element förmodligen mindre. Det här var allt vi hade att säga om algoritmanalys för den här gången, men misströsta inte! När vi senare i kursen arbetar med listor och träd kommer ni att få er tillfällen att räkna på olika algoritmers komplexitet. 8