Algoritmanalys Analys av algoritmer används för att uppskatta effektivitet. Om vi t. ex. har n stycken tal lagrat i en array och vi vill linjärsöka i denna. Det betyder att vi måste leta i arrayen tills vi hittar vad vi vill eller tills arrayen tar slut. Genomsnittligen behövs n/2 jämförelser vilket är proportionellt mot n, vi säger att vi har en O(n) algoritm. Om vi å andra sidan har en sorterad array och binärsöker så får vi en O(log n) algoritm. Man brukar mäta detta i en proportionsalitetsfaktor som anger beroendet av antalet element man opererar på. 1 2 Vi kan sortera med en urvalssortering vilket betyder att vi går igenom alla talen n gånger, eftersom vi har n tal blir detta en O(n 2 ) algoritm. Om vi tänker en stund så ser vi att vi får väldigt olika faktorer beroende på algoritmen och tillväxten med antal element är väldigt olika: 10 100 1000 10000 1000000 log n 3 6 10 14 20 n 10 100 1000 10000 1000000 n log n 30 600 10000 140000 20000000 n 2 100 10000 1000000 10 8 10 12 Vi inser att gör stor skillnad hur algoritmen fungerar. Det kan vara oerhört viktigt att hitta effektiva algoritmer. Som exempel kan vi beräkna x^10 som x*x*x*x*x*x*x*x*x*x vilket uppenbarligen blir O(n) Men med lite tankearbete (eller litteraturstudier) ser vi att: x^10 är x^5 * x^5 x^5 är x * x^2 * x^2 x^2 är x * x Blir 1 + 2 + 1 pluttifikationer vilket summerar till 4. Vi inser att detta blir en O(log n) algoritm Kommer i princip att gå minst dubbelt så fort redan vid potensen 10. Nu har vi en del overhead och annat som kanske inte ger exakt det förhållandet men ökningen blir enligt denna formel. 3 4
Rekursiva algoritmer En rekursiv algoritm uttrycks i termer av sig själv. Exempel: n! kan beräknas med hjälp av följande sekventiella algoritm: p = 1; för i från 1 till n utför p = p * i förutsättning: p >= 0 Om vi formulerar samma sak rekursivt så blir det så här: n! = n * (n-1)! under samma förutsättning. t. ex. 5! = 5 * 4! eller utvecklat 5! = 5 * 4! = 5 * 4 * 3! = 5 * 4 * 3 * 2! = 5 * 4 * 3 * 2 * 1! = 5 * 4 * 3 * 2 * 1 * 0! = 5 * 4 * 3 * 2 * 1 * 0 * -1! =... Hmm något är fel här, algoritmen har inget slut! Det förefaller ju rätt självklart (jämför induktionsbevis) att man inte bara kan uttrycka en fakultet som en annan fakultet, man måste ju också ha ett slutvillkor. 5 6 Således n! = 1 om n = 0 eller 1 annars n! = n * (n-1)! En rekursiv algoritm måste ha: Ett icke rekursivt slutvillkor, ett basfall. En rekursiv formulering av det generella fallet. Räcker detta? f(n) = 0 om n = 0 annars f(n) = f(2*n) + n - 1 Alltså sammanfattningsvis: En korrekt rekursiv algoritm består av: Ett trivialt fall, kallat basfall, icke rekursivt. Ett generellt rekursivt fall som konvergerar mot basfallet, eller annorlunda uttryckt den generella formeln skall innebära en förenkling av uttrycket så att det så småningom blir lösbart. Tankegången med rekursion är att problemet är lite för svårt att lösa så vi uttrycker ett enklare problem ända till vi kan lösa det. Sedan går vi tillbaka och löser delproblemen. Blir detta speciellt bra? Nej eftersom det generella fallet aldrig konvergerar mot basfallet. 7 8
Ett enkelt exempel på en algoritm: Hur många personer är vi f. n. i detta rum? En icke rekursiv algoritm går då igenom raderna en i taget och ökar en räknare med ett för varje person som finns. En rekursiv algoritm formuleras så här: Om det bara är jag här så är det en person i rummet. Annars är det jag och alla andra vilket mer matematiskt uttryckt är 1 + antalet övriga personer. Illusteras praktiskt ganska enkelt. Ett annat exempel på detta är sortering. Vi kan utrycka sortering av en array på följande sätt: Om det vi har fler än ett element så leta upp det minsta talet i arrayen, swappa det mot det första elementet i arrayen. sortera sedan resten av vektorn (med första elementet borträknat) annars är vi klara. sort(v:array, start : heltal, antal :heltal) om antal fler än ett leta upp minsta värdet swappa det med v[start] sort(v, start + 1, antal - 1) annars klara eller i Java (utdrag ur nån klass) 9 10 public void sort(int [] arr, int start, int stop) // // urvalssortering, start = 1:a elementet att sortera // stop = sista elementet att sortera // if (start == stop) return; // bara ett element int min = start; for (int i = start; i <=stop; i++) if (arr[i] < arr[min]) min = i; int tmp = arr[min]; arr[min] = arr[start]; arr[start] = tmp; sort(arr,start+1,stop); // sortera resten public static void main(string [] args) int [] tal = new int[100]; Std.out.println( Ge 100 tal ); for (int i=0; i<100; i++) tal[i] = Std.in.readInt(); sort(tal, 0, 99); Std.out.println( Sorterad ); for (i=0; i<100; i++) Std.out.print(al[i] + ); if ((i + 1) % 10 == 0) Std.out.println(); Std.out.println(); Att notera: I) Rekursiva program blir ofta mer lättlästa och klara än sekventiella eftersom det försvinner en massa explicit repeterande. II) Det är ofta svårare att förstå ett rekursivt program. Vi har oftast inte en rekursiv hjärna. (det finns dom som har). III) Det blir inte alltid bättre eller effektivare med rekursion, ibland blir det katastrofalt ineffektivt ibland blir det mycket bra. En del problem är svåra att lösa sekventiellt men trivialt rekursivt och vice versa. 11 12
Som ett exempel på en ineffektiv rekursiv algoritm kan anföras Fibonci-serien, vilken som bekant beskriver förökningstakten hos kaniner givet att man har två av lämplig sort vid mätningens början. f(1) = f(2) = 1 f(n) = f(n-1) + f(n-2) om n>2 Vi ser ju att om vi vill ha det 7 Fibonci-talet så kommer vi att räkna ut det sjätte talet en gång medan t. ex. f(4) räknas ut tre gånger, f(3) fem gånger, f(2) åtta gånger o. s. v. Huru implementeras då rekursion i ett programmeringsspråk? Funktioner kan anropa sig själva! Det verkar ju lite inåtvänt men det måste ju alltid finnas någon utanför som gör ett initialt anrop. Hur blir det med parametrarna då? Sekventiellt blir det inga problem det blir en enkel repetition med n-2 varv, där varje tal beräknas en gång. 13 14 Kom ihåg följande: En lokal variabel är en variabel som deklareras i en funktion. De skapas när funktionen anropas. Vad händer då när den anropar sig själv? Tja hur ska funktionen veta vem som anropat den? Alltså skapas nya lokala variabler varje gång funktionen anropas oavsett vem som anropat. Värdeparametrar kopieras ju vid anrop. När en funktion anropar sig själv kopieras parametrarna innan de skickas till funktionen själv. Verkar lite vimsigt det här men det enklaste sättet att övertyga sin klentrogna hjärna är att vi för varje anrop får en ny funktion med egna lokala variabler och egna parametrar. Att alla funktionerna heter fak eller fib behöver vi inte bekymra oss om. Statiska variabler fungerar som vanligt, d. v. s. de delas mellan alla inblandade. Ex beräkna 5! med metoden public int fak(int n) if(n <= 1) return 1; else return n * fak(n - 1); public static void main(string [] args) Std.out.println(fak(5)); main anropar fak med parametern 5 fak anropar sig själv med parametern 4 fak anropar sig själv med parametern 3 fak anropar sig själv med parametern 2 fak anropas sig själv med parametern 1 fak returnar 1 till sig själv fak räknar ut 2*1 som den returnerar till sig själv fak räknar ut 2*3 som den returnerar till sig själv fak räknar ut 6*4 som den returnerar till sig själv fak räknar ur 24*5 som den returnerar till main 15 16
Några exempel på rekursiva enkla algoritmer: Läs ett antal tal och skriv ut dem i omvänd ordning sluta med en nolla. public void las() int tal; Std.out.print( Ge ett tal: ); tal = Std.in.readInt(); if (tal == 0) return; las(); Std.out.print(tal + ); om vi nu ger följande tal 4 5-7 6 12 3 4 5 7 0 dvs Ge ett tal: 4 Ge ett tal: 5 osv så skrivs 7 5 4 3 12 6-7 5 4 Hur kan det bli så? Vart tar talen vägen under tiden? 17 18 Proceduren har en lokal variabel med namnet tal där ett tal kan sparas. Algoritmen i övrigt är ju läs tal om tal skilt från 0 gör något skriv sedan ut tal om vi har 4 5-7 6 12 3 0 vurtar de på de häringa vise: main anropar las las läser in 4 till tal, anropar las las läser in 5 till tal, anropar las las läser in -7 till tal, anropar las las läser in 6 till tal, anropar las las läser in 12 till tal, anropar las las läser in 3 till tal, anropar las las läser in 0 till tal, återvänder sedan till las las skriver ut 3 återvänder till las las skriver ut 12 återvänder till las las skriver ut 6 återvänder till las las skriver ut -7 återvänder till las las skriver ut 5 återvänder till las las skriver ut 4 återvänder till main En funktion som skriver ut alla element i en array void skriv_vektor(int [] v[], int start, int antal) if(antal > 0) Std.out.print(v[start] + ); skriv_vektor(v, start+1, antal-1) Analys: Har vi ett trivialfall som inte är rekursivt? Svar ja, om antal = 0 Har vi ett rekursivt generellt fall som beskriver vad vi vill? Svar ja, Kommer det generella fallet att degenereras till trivialfallet? Svar ja, eftersom antal minskar för varje anrop Alltså en korrekt rekursion 19 20
Ett litet experiment: Antag att vi har följande lilla testprogram import java.util.*; public class PowTest public static double pow1(double x, int n) double p = 1; for (int i = 1; i <= n; i++) p = p*x; return p; public static double pow2(double x, int n) if(n == 1) return x; else return x * pow2(x, n-1); public static double pow3(double x, int n) if (n == 0) return 1; if (n == 1) return x; if (n % 2 == 0) return pow3(x*x, n/2); else return x*pow3(x*x, n/2); // ta tid på dessa public static void main(string [] args) Calendar dt1, dt2, dt3, dt4, dt5; double d1=0,d2=0,d3=0,d4=0; dt1 = Calendar.getInstance(); for (int i = 0; i < 10000; i++) d1 = Math.pow(5.0,195); dt2 = Calendar.getInstance(); for (int i = 0; i < 10000; i++) d2 = pow1(5.0,195); dt3 = Calendar.getInstance(); for (int i = 0; i < 10000; i++) d3 = pow2(5.0,195); dt4 = Calendar.getInstance(); for (int i = 0; i < 10000; i++) d4 = pow3(5.0,195); dt5 = Calendar.getInstance(); long t1,t2,t3,t4; t1 = dt2.gettime().gettime() - dt1.gettime().gettime(); t2 = dt3.gettime().gettime() - dt2.gettime().gettime(); t3 = dt4.gettime().gettime() - dt3.gettime().gettime(); t4 = dt5.gettime().gettime() - dt4.gettime().gettime(); System.out.println(t1 + " " + d1); System.out.println(t2 + " " + d2); System.out.println(t3 + " " + d3); System.out.println(t4 + " " + d4); > java PowTest 30 1.9913648889155653E136 104 1.991364888915566E136 928 1.991364888915566E136 37 1.991364888915566E136 Ger oss information om hur många millisekunder det tar att utföra 10000 anrop av var och en av de fyra funktionerna. Man kan notera att den inbyggda och den sista av våra är ungefär lika effektiva. Vår andra variant är långsammare och den rekursiva mycket långsammare. Kan man då dra slutsatsen att rekursiva algoritmer är sämre än icke rekursiva? Nej inte generellt. Ett metodanrop tar alltid en viss tid och om det mesta som sker i metoden är anrop så kommer en rekursiv att vara sämre. Om det som görs inuti metoden tar en större andel av tiden blir detta mindre viktigt. 21 22 Man kan göra följande lilla experiment: > java -Djava.compiler=none PowTest 52 1.9913648889155653E136 1777 1.991364888915566E136 5334 1.991364888915566E136 248 1.991364888915566E136 Vi ser att det blev ganska annorlunda. Nu har vi stängt av den inbyggda JIT en!!! Det gör att vi inte tjänar något på att utföra samma satser om och om igen. Det blir en ren tolkning av koden. Antag att vi har ett antal tecken i en följd. Skriv ut alla permutationer av denna teckenföljd Algoritm: om antalet tecken är 0, inga permutationer annars för i=1..n, lägg det i:e tecknet sist, skriv ut alla permutationer att av de n-1 tecknen. 23 24
public void swap(char [] s, int a, int b) char tmp = s[a]; s[a] = s[b]; s[b] = tmp; public void perm(char [] s, int n) // permutera n tecken //s[0], s[1],... s[n-1] perm(s,3); Ger oss utskriften > java PermTest bca a cab a b if (n==0) for (int i = 0; i < s.length; i++) Std.out.print(s[i]); Std.out.println(); else for (int i= 0; i<=n-1; i++) swap(s, i, n-1); perm(s, n-1); swap(s, i, n-1); public static void main(string [] args) char[] s = a, b, c ; 25 26 Fungerar så här utgå från: flytta a sist: flytta c sist: n == 0 ger: lägg tillbaka c: flytta b sist: n==0 ger lägg tillbaka b: lägg tillbaka a: lägg b sist: lägg a sist: n==0 ger oss lägg tillbaka a: flytta c sist: betrakta: n == 0 ger oss: lägg tillbaka c: lägg tillbaka b: flytta c sist: flytta a sist: n == 0 ger oss: lägg tillbaka a: lägg b sist: n==0 ger oss: klart. a bc b c a ca c a ab ba b ab ab a bca a cab a b 27