Informationsteknologi Tom Smedsaas 22 januari 2006 Rekursion och induktion för algoritmkonstruktion Att lösa ett problem rekursivt innebär att man uttrycker lösningen i termer av samma typ av problem som dock måste vara i någon mening enklare. Man delar alltså upp problemet i ett eller flera delproblem (av samma typ), löser dessa (på samma sätt) och sedan kombinerar sedan lösningarna av delproblemen till en lösning av ursprungsproblemet. Exempel: Beräkning av fakultet Iterativ definition: Iterativ beräkning: n! = n(n 1)(n 2)... 2 1 0! = 1 public static int fac(int n) { int p = 1; for (; n>1; n--) p = p*n; return p; Rekursiv definition: n! = { 1 om n = 0 n(n 1)! om n > 0 Rekursiv beräkning: public static int fac(int n) { if (n > 0) return (n*fac(n-1)); else return 1; Ett eller flera rekursionsterminerande fall måste definieras för att förhindra oändlig rekursion I detta exempel blir den rekursiva metoden knappast enklare eller effektivare än den iterativa men rekursiva resonemang är ett ändå kraftfullt sätt att hitta effektiva algoritmer. Att uttrycka sig rekursivt är ofta naturligt i matematiken. Deriveringsreglerna ( derivatan av en summa är summan av derivatorna ) är ett exempel på detta. 1
Inte desto mindre brukar rekursion betraktas som svårt när man börjar med det i programmering. Orsaken till detta är säkert att man i de flesta fall lärt sig programmera med iterationer. Frågor att besvara vid konstruktion av en rekursiv algoritm: 1. Hur kan jag dela upp ursprungsproblemet i mindre problem av samma slag? 2. Hur kombinerar jag lösningarna till delproblemen till en lösning på ursprungsproblemet? 3. Vilka specialfall är lämpliga? Kommer de alltid att nås, oberoende av indata? Detta är mycket besläktat med induktionsbevis: Vi antar att vi kan lösa problemet för ett eller flera mindre problem. Sedan visar man hur man med hjälp av dessa lösningar kan lösa ursprungsproblemet. Precis som i induktionsbevis så måste man ha ett eller flera basfall. Exempel: Skriv ut en sträng med n tecken i omvänd ordning. Hur definiera problemet i termer av sig självt? Induktionsantagande: Antag att vi kan lösa problemet för n 1 tecken. Basfall: Att skriva ut ett tecken i omvänd ordning. Trivialt. Vi kan välja ut de n 1 tecknen på n olika sätt men två av dessa förefaller naturligast: 1. de n 1 första tecknen i strängen eller 2. de n 1 sista tecknen i strängen. Pröva först alternativ 1 d.v.s. vi antar att vi kan skriva ut alla utom det sista tecknet i omvänd ordning. Eftersom det sista tecknet skall vara först måste vi börja med att skriva det. Algoritm skriv strängen i omvänd ordning om längden är 1 eller mindre så skriv strängen annars skriv det sista tecknet skriv alla utom det sista i omvänd ordning Övning 1: Implementera ovanstående algoritm. 2
Alternativ algoritm Pröva i stället alternativ 2, d.v.s. vi antar att vi kan skriva ut alla tecken utom det första i omvänd ordning. Eftersom det första skall ut sist så börjar vi med de n 1 sista. skriv strängen i omvänd ordning om längden är 1 eller mindre så skriv strängen annars skriv alla utom det första i omvänd ordning skriv det första tecknet Även detta fungerar! Första tecknet skrivs ju sist efter alla andra tecken. Övning 2: Skriv en rekursiv metod void printb(int x, int b) som skriver ut x i basen b (Förutsätt, för enkelhetens skull att b< 10) Övning 3: Skriv en metod som läser hela tal från standard input och skriver ut talen i omvänd ordning UTAN att använda listor eller arrayer. Inläsningen avbryts när 0 läses. Det finns ofta möjlighet att välja de mindre problemen på olika sätt. I ovanstående exempel hade vi två naturliga val men vilket vi valde spelar egentligen ingen roll. I andra fall så kan vissa val ge bättre (effektivare) algoritmer än andra. Exempel: Beräkning av x n Vi skall beräkna x n, n heltal 0, med upprepade multiplikationer. Första försök Den rekursiva definitionen x n = kan realiseras med följande metod { 1 om n = 0 xx n 1 om n > 0 public static float power(float x, int n) { if (n > 0) return x*power(x,n-1); else return 1; Anropet pow(x,1000) genererar 1000 multiplikationer. 3
Andra försök Vi kan utgå från följande rekursiva definition 1 om n = 0 x n = (x n/2 ) 2 om n > 0 och n jämn xx n 1 om n > 0 och n udda public static float power(float x, int n) { if (n == 0) return 1; else if (n % 2 == 0) //n jämn return( sqr(power(x,n/2)); else //n udda return( x*power(x,n-1) ); (Anm: sqr måste definieras) Anropet pow(x,1000) kommer att generera följande sekvens av anrop: power(x,1000) power(x,500) power(x,250) power(x,125) power(x,124) power(x,62) power(x,31) power(x,30) power(x,15) power(x,14) power(x,7) power(x,6) power(x,3) power(x,2) power(x,1) power(x,0) Varje uppväckning av pow utom den sista innehåller en multiplikation (kvadrering eller multiplikation med x) vilket innebär att resultatet beräknas med sammanlagt 15 multiplikationer. Övning 4: Implementera ovanstående algoritm utan att använda rekursion. Exempel: Polynomevaluering Problem: Beräkna värdet av polynomet p n (x) = a 0 + a 1 x + a 2 x 2 +... + a n x n 4
Algoritm 1 Induktionsantagande: Antag att vi kan beräkna värdet av p n 1 (x) Basfall: Beräkna p 0 (x) - Trivialt Induktionssteg: Vi beräknar p n (x) som p n (x) = p n 1 (x) + a n x n Induktionssteget kan göras med en addition och n multiplikationer vilket totalt ger n additioner och cirka n 2 multiplikationer. (Vi har ovan sett att det går att beräkna x n med färre multiplikationer men vi skall i stället söka en annan algoritm) Algoritm 2 I första försöket beräknar vi x n från scratch vilket naturligtvis är onödigt eftersom vi i steget innan beräknade x n 1. Genom att lägga till detta i vårt antagande kan vi få en bättre algoritm Induktionsantagande: Antag att vi kan beräkna värdet av p n 1 (x) och x n 1 Basfall: Beräkna p 0 (x) - Trivialt Induktionssteg: Vi beräknar p n (x) som p n (x) = p n 1 (x) + a n xx n 1 Induktionssteget kräver nu en addition och två multiplikationer dvs totalt n additioner och 2n multiplikationer. Algoritm 3 I algoritm 1 och 2 tog vi bort den sista koefficienten när vi skulle göra ett mindre problem. Man kan också ta bort den första koefficienten: Induktionsantagande: Antag att vi kan beräkna värdet av p n 1 (x) där p n 1(x) = a 1 + a 2 x + a 3 x 2 +... + a n x n 1 Basfall: Beräkna p 0 (x) - Trivialt Induktionssteg: Vi beräknar p n (x) som p n (x) = xp n 1(x) + a 0 Induktionssteget kräver nu en addition och endast en multiplikation dvs totalt n additioner och n multiplikationer. Denna algoritm, som brukar kallas Horners schema, är standard för att evaluera polynom. Den implementeras vanligen iterativt dvs 5
public static double p( int n, float x ) { double y = a[n]; for ( ; n>0; n-- ) y = y*x + a[n-1]; return y; Exempel: Hanois torn En mängd med n brickor av olika storlek, alla med hål i mitten, ligger travade på en pinne (A) i storleksordning med den största underst. Problemet går ut på att flytta hela traven till en annan pinne (C) under iakttagande av följande regler: 1. Endast en bricka får flyttas per gång. 2. En större bricka får aldrig läggas på en mindre. Till hjälp har man ytterligare en pinne (B) som får användas för mellanlagring. Induktionsantagande: Vi kan lösa problemet med n 1 brickor. Basfall: Flytta en bricka. Trivialt. Induktionssteg: Vi gör på följande sätt 1. Flytta de n 1 översta brickorna till B. 2. Flytta den kvarvarande från A till C. 3. Flytta de n 1 brickorna på B till C. Problemet löses således rekursivt genom att lösa två problem av storlek n 1. Totala antalet brickförflyttningar b(n) ges av följande differensekvation: { 1 om n = 1, b(n) = b(n 1) + 1 + b(n 1) om n > 1. som har lösningen b(n) = 2 n 1 (Lösningen kan erhållas antingen genom att expandera ekvationen eller genom någon standardteknik för att lösa linjära differensekvationer.) Algoritmen är således mycket tidskrävande om n är stort men det är den bästa möjliga! Det är lätt att inse att lösningen till ett problem av storlek n faktiskt kräver lösning av två problem av storlek n 1 först för att frilägga understa brickan och sedan för att få tillbaka alla brickor på den understa på en ny pinne. 6
Övning 5: Implementera en metod Hanoi(char from, char to, char help, int n) som skriver ut hur flyttningen av n brickor från from till to med hjälp av help skall göras. Exempel: Fibonaccitalen Fibonaccitalen F n definieras enligt 0 om n = 0, F n = 1 om n = 1, F n 1 + F n 2 om n > 1. Definitionen ovan kan användas för att skriva en metod som returnerar det n:te Fibonaccitalet: public static int fib(int n) { if ( n==0 ) return 0; else if ( n==1 ) return 1; else return fib(n-1) + fib(n-2); Detta är en korrekt metod men den är hopplöst ineffektiv för stora värden på n. För att inse det kan vi räkna hur många additioner anropet fib(n) utför. För detta antal T (n) gäller: { 0 om n 1, T (n) = 1 + T (n 1) + T (n 2) om n > 1. Observera likheten med Fibonaccitalen! Detta är en linjär differensekvation och den homogena ekvationen (som är lika med Fibonaccitalen!) har karaktäristiska ekvationen r 2 r 1 = 0, som har lösningen r 1,2 = 1 ± 5, 2 dvs. den homogena ekvationen har lösningen F (n) = ar n 1 + br n 2, där a och b bestämmes ur begynnelsevillkoren. 7
Eftersom T (n) = 1 är en partikulärlösning, kan den allmänna lösningen skrivas T (n) = ar n 1 + br n 2 1. Genom att använda begynnelsevillkoren kan a och b bestämmas och ger lösningen a =(1 r 2 )/(r 1 r 2 ) b = (1 r 1 )/(r 1 r 2 ) Eftersom r 1 1.618 och r 2 0.618 < 1 så ser man att, för stora n, gäller T (n) 1.618 n Antalet additioner växer således exponentiellt. Övning 6: Antag att additionen och det övriga arbetet i ibonacci-metoden tar 1 µs. Hur lång tid tar fib(50) respektive fib(100)? Som framgår av övningen är programmet helt oanvändbart för stora n. Programmet är trädrekursivt det vill säga varje anrop resulterar i två nya anrop. Detta kan potentiellt ge orimliga exekveringstider. Som följande exempel visar så är det dock inte alltid så. I själva verket är många klassiska effektiva algoritmer trädrekursiva (t.ex. sorteringsalgoritmer, snabb Fouriertransform etc.). Exempel: Horisontlinjeproblemet Problem: Konstruera horisontlinjen till n rektangulära byggnader. Varje byggnad representeras av en trippel bestående av x-koordinat för vänster respektive höger vägg samt byggnadens höjd. Induktionsantagande: Antag att vi kan lösa problemet för n 1 byggnader. Induktionssteg: Man stoppar in en ny byggnad i horisontlinjen. Detta kräver O(n) operationer. Sammantaget: Ger en algoritm som kräver storleksordningen cn 2. operationer. Alternativt induktionsantagande: Vi kan konstruera horisontlinjen för n/2 byggnader. Induktionssteg: Om vi har horisontlinjerna för två olika mängder med hus med vardera n/2 byggnader, kan vi bygga den gemensamma horisontlinjen genom en sammanfogning av dessa (som kräver storleksordningen cn operationer) Sammantaget: Om vi delar de n byggnaderna i två lika stora mängder och konstruerar horisontlinjerna för dessa var för sig och sedan sammanfogar dessa så får vi en algoritm som kräver cn log n operationer. 8
Exempel: Sortering Problemet att sortera n element i storleksordning kan även det lösas på flera olika sätt. Algoritm 1 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 Kod: public static void sort(float [] a, int n) { if ( n > 1 ) { sort( a, n-1 ); // sortera de n-1 första float 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 Detta är den vanliga enkla instickssorteringen. Algoritm 2 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 Arbetet att sammanfoga de två sorterade delarna är proportionellt mot antalet element. Låt T (n) beteckna tiden att sortera n element. Då gäller { c om n = 0, T (n) = 2T (n/2) + dn om n > 0. 9
Om n är en jämn 2-potens, n = 2 k så gäller T (n) =2T (n/2) + dn = 2(2T (n/4) + dn/2) + dn = =4T (n/4) + dn + dn =... =2kT (n/2 k ) + dnk = =nt (1) + dn log n dvs tiden är O(n log n). Exempel: Växlingsproblemet På hur många sätt kan man växla a kronor i 100, 50, 10, 5 och 1-mynt (sedlar)? (t.ex. 90 kronor i 50+4*10, 9*10, 8*10 + 10*1 etc.) Formulera en lösning av problemet i termer av sig självt. Viktigt att rekursionsfallet/fallen löser ett i någon mening mindre problem: Ordna myntsorterna i någon ordning. Dela in växlingsförsöken i två grupper: de som inte använder något mynt av första sorten de som använder första sortens mynt Problemets lösning kan nu formuleras: Antalet sätt att växla a kronor vid användande av n olika sorters mynt är 1. antalet sätt att växla a kronor vid användande av alla utom den första sortens mynt (n 1 sorter) plus 2. antalet sätt att växla a d kronor användande alla n sorters mynt (d = 1:a myntsortens valör) Delproblem 1 är mindre än ursprungsproblemet eftersom det använder färre myntslag och delproblem 2 är mindre eftersom det växlar en mindre summa. Antag att myntsorterna representeras i en array change där change[1] = 1; change[2] = 5; change[3] = 10; change[4] = 50; change[5] = 100; Ger grundprogram public static int count( int a, int n ) { return count( a, n-1 ) + count( a-change[n], n ); Vilka specialfall behövs för att undvika oändlig rekursion? 10
a kan bli = 0 vilket innebär ett lyckat försök (räkna det) a kan bli < 0 vilket innebär ett misslyckat försök (räkna ej) n kan bli = 0 vilket innebär ett misslyckat försök (räkna ej) Slutlig version int count( int a, int n) { if ( a == 0 ) return 1; else if (a < 0) (n == 0) return 0; else return count(a,n-1) + count(a-change[n],n); Detta program är användbart om inte a och n är alltför stora men precis som i Fibonacci-exemplet så är tillväxten exponentiell. Programmet kan dock förbättras i detta avseende med hjälp av s.k. dynamisk programmering som dock inte beskrives här. Övning 7: Skriv en metod som skriver ut alla möjliga permutationer av en sträng. Vad är metodens komplexitet (dvs hur beror tiden av stränglängden n)? Syntaxanalys med recursive descent Rekursiva metoder är speciellt lämpliga när de data man skall behandla i sig är rekursivt uppbyggda. Ett exempel på detta är vanliga aritmetiska uttryck. Betrakta t.ex. följande uttryck a + (b + c) d + e (f + g h) Det finns ett antal regler för hur detta skall tolkas av typ multiplikation före addition, parenteser först och från vänster till höger vid lika prioritet. Det är inte alldeles enkelt (men naturligtvis väl genomförbart) att realisera dessa regler i ett program som läser och tolkar ett uttryck. Vi kan emellertid definiera uttryck på följande sätt: Ett uttryck är en sekvens av en eller flera termer med plustecken mellan. En term är en sekvens av en eller flera faktorer med gångertecken mellan. En faktor är antingen ett tal eller ett uttryck omgivet av parenteser. (För enkelhetens skull begränsar vi oss till addition och multiplikation) Observera att ovanstående definition av uttryck är indirekt rekursiv. 11
Om vi skriver en metod som hanterar vart och ett av dessa begrepp så får vi ett enkelt program som korrekt hanterar generella former av denna begränsade variant av aritmetiska uttryck. public static double uttryck() { double sum = term(); while ( nexttoread() == + ) { readnextchar(); sum += term(); return sum; static double term() { double prod = faktor(); while ( nexttoread() == * ) { readnextchar(); prod *= faktor(); return prod; static double faktor() { if ( nexttoread()!= ( ) // skall vara tal return readnextnumber(); else { readnextchar(); // läs förbi ( double result = uttryck(); readnextchar(); // läs förbi ) return result; Programmet bygger på tre primitiver: char nexttoread() som returnerar nästa tecken utan att ta bort det från input-strömmen, char readnextchar() som läser nästa tecken samt double readnextnumber() som läser nästa tal. (I Java kan dessa primitiver t ex uttryckas med hjälp av klassen StreamTokenizer.) 12