620 likes | 781 Views
Rekursion og algoritmedesign. Plan. • Rekursion Matematisk induktion Rekursion Simple eksempler Kryptering. • Designmetoder Del-og-hersk Dynamisk programmering Baksporing . Tre centrale begreber. (1) Syntaks Grammatik (udseende)
E N D
Plan • • Rekursion Matematisk induktion Rekursion • Simple eksempler • Kryptering • • Designmetoder • Del-og-hersk • Dynamisk programmering • Baksporing
Tre centrale begreber • (1) Syntaks Grammatik (udseende) • (2) Semantik Betydning (virkning) • (3) Pragmatik Anvendelse (nytte)
Metoder til algoritmedesign • Algoritmedesign er en kreativ proces. Der findes ingen generel mekanisk metode (algoritme) til design af en algoritme for et forelagt problem. • Derimod findes en række metoder, eller rettere “regler for tænkning”, som ofte fører til korrekte og effektive algoritmer. • Nogle af disse metoder er baseret på matematisk bevisførelse. Dermed “indbygges” korrekthed i algoritmerne. • Andre har mere karakter af gode råd.
Matematisk induktion • Lad T være et teorem, der skal bevises, og lad T være udtrykt i termer af heltalsparameteren n. • Teoremet T gælder da for enhver værdi af n ≥ c, hvor c er en konstant, hvis følgende to betingelser er opfyldt: • 1. Basistilfældet: T gælder for n = c, og • 2. Induktionsskridtet: Hvis T gælder for n-1, så gælder T for n. Antagelsen i induktionsskridtet kaldes induktionshypotesen.
Stærk induktion • Teoremet T gælder for enhver værdi af n ≥ c, hvor c er en konstant, hvis følgende to betingelser er opfyldt: • 1. Basistilfældet: T gælder for n = c, og • 2. Induktionsskridtet: Hvis T gælder for ethvert k, c ≤ k < n, så gælder T for n.
(1) Start med en vilkårlig instans af problemet. (2) Prøv at løse dette under antagelse af, at det samme problem - men af mindre størrelse - er blevet løst. Induktion kan benyttes ved design af algoritmer • Induktionsprincippet kan benyttes konstruktivt. Løsning af små problemer benyttes til at løse større problemer.
Eksempel:Sortering af n tal i stigende rækkefølge • Antag at vi kan sortere n-1 tal. • Vi kan da opnå en sortering af n tal ved enten (1) først at sortere n-1 af tallene, og derefter indsætte det n´te tal på den rette plads (sortering ved indsættelse), • eller (2) bestemme det mindste af de n tal og sætte det forrest, sortere de resterende tal, og derefter sætte dem bagefter dette forreste tal (sortering ved udvælgelse).
Eksempel: Binær søgning • For at finde et element i et sorteret array med n elementer: • Opdel arrayet i to (næsten) lige store dele. • Afgør i hvilken af de to dele, elementet skal findes. • Søg videre på samme måde (rekursivt) i denne del. int binarySearch(Comparable[] a, Comparable key, int low, int high) throws ItemNotFound { if (low > high) throw new ItemNotFound(); int mid = (low + high)/2; if (a[mid].compares(key) < 0) return binarySearch(a, key, mid + 1, high); if (a[mid].compares(key) > 0) return binarySearch(a, key, low, mid - 1); return mid; }
Benyt en driver-rutine til at forenkle brug public int binarySearch(Comparable[] a, Comparable key) throws ItemNotFound { return binarySearch(a, key, 0, a.length - 1); } private int binarySearch(Comparable[] a, Comparable key, int low, int high) throws ItemNotFound { ... }
Del-og-hersk • Del-og-hersk er en vigtig teknik til algoritmisering. Teknikken er et eksempel på brugen af stærk induktion. (1) Del problemet op i mindre delproblemer. (2) Hersk ved at løse hvert delproblem. (3) Kombiner resultaterne til en løsning for det oprindelige problem. Hvis delproblemerne er mindre udgaver af det oprindelige problem, kan rekursion ofte benyttes med fordel.
Del-og-herskved rekursion • Pseuodekode: • solve(Problem p) { • if (size(p) <= critical_size) • solve_small_problem(p); • else { • subproblem = divide(p); • solve(subproblem[1]); • solve(subproblem[2]); • .... • combine_solutions(); • } • }
Simpel løsning: power = x; for (i = 1; i < n; i++) power *= x; Antal multiplikationer: n-1. Eksempel:Potensopløftning • Problem. Beregn power(x,n) = xn, hvor x er et reelt tal, • og n er et positivt heltal.
double power(double x, int n) { if (n == 1) return x; if (n % 2 == 0) { double p = power(x, n/2); return p*p; } return x * power(x, n-1); } • Løsning ved del-og-hersk: • Hvis n er lige, opløses problemet power(x,n) i to (ens) delproblemer: power(x,n/2) og power(x,n/2), og løsningen bestemmes som power(x,n/2)*power(x,n/2). Ellers (hvis n er ulige) bestemmes løsningen som x*power(x,n-1). Det kan bevises, at antallet af multiplikationer er cirka log2n. (Hvis n fordobles, øges antallet af multiplikationer kun med 1). Anvendelse: kryptologi, hvor x og n er meget store heltal.
Problem. Givet en sekvens (a1,a2, .., an) af reelle tal. Find en delsekvens (ai,ai+1, .., aj) af konsekutive elementer, sådan at summen af dens elementer er størst mulig. Opdel problemet i to omtrent lige store dele: (1) helt i den venstre del, (2) helt i den højre del, eller (3) indeholder midterelementet Eksempel:Beregning af den maksimale delsekvenssum Enten findes den maksimale delsekvens
3 delproblemer De to første værdier bestemmes ved rekursion. Den sidste værdi bestemmes som summen af • det maksimale suffix for sekvensen til venstre for midterelementet (inklusiv dette), og • det maksimale prefix for sekvensen til højre for midterelementet. Den maskimale delsekvenssum bestemmes som maksimum af disse 3 værdier.
int maxSum(int a[], int left, int right) { if (left == right) return a[left] > 0 ? a[left] : 0; int mid = (left + right)/2; return max3(maxSum(a, left, mid - 1), maxSum(a, mid + 1, right), maxSuffix(a, left, mid) + maxPrefix(a, mid + 1, right)); } int maxSuffix(int a[], int left, int right) { int sum = 0, maxSum = 0; for (int i = right; i >= left; i--) if ((sum += a[i]) > maxSum) maxSum = sum; return maxSum; } Javakode maxPrefiximplementeres analogt
Kompleksitet Tiden, det tager at løse et problem med n tal, T(n), hvor n er en potens af 2, opfylder rekursionsligningerne T(n) = 2*T(n/2) + O(n) T(1) = O(1) Hvis O(n) og O(1) her erstattes med henholdsvis n og 1, bliver løsningen T(n) = n log n + n Der gælder således, at T(n) = O(n log n).
Rekursion • Rekursiv definition af X: X defineres i termer af sig selv. • Rekursion er nyttig, når en generel version af X kan defineres i termer af simplere versioner af X. • Et problem løses rekursivt ved (1) at nedbryde det i mindre delproblemer af samme slags, • (2) fortsætte med dette indtil delproblemerne er så simple, at de umiddelbart kan løses, og • (3) kombinere løsningerne af delproblemerne til en løsning af det oprindelige problem.
Tænk rekursivt • for at opnå • simple og præcise definitioner • elegante løsninger på problemer, der ellers er svære at løse • algoritmer, der er simple at analysere
Præcis (rekursiv) definition: • 1 , hvis n = 1 • n * (n-1)! , hvis n > 1 • n! ={ int faculty(int n) { if (n == 1) return 1; return n * faculty(n-1); } Simpelhed og præcision(eksempel: Fakultetsfunktionen) • Upræcis definition: n! = n * (n-1) * (n-2) * ... * 2 * 1
Implementering af rekursion En rekursiv metode kalder en kopi (inkarnation) af sig selv. Kun én inkarnation er aktiv ad gangen. Resten venter (på, at den kopi, de har kaldt, returnerer). Rekursion kan implementeres ved hjælp af en stak (idet inkarnationerne returnerer i omvendt rækkefølge i forhold til den rækkefølge, hvori de er kaldt).
n = 1 f = 1 n = 2 f = ? n = 2 f = 2 n = 2 f = ? f(1) n = 3 f = ? n = 3 f = ? n = 3 f = ? f(2) f(2) f(2) n = 4 f = ? n = 4 f = ? n = 4 f = ? f(3) f(3) f(3) f(4) f(4) f(4) n = 3 f = ? n = 3 f = 6 n = 4 f = ? n = 4 f = ? n = 4 f = ? n = 4 f = 24 f(3) f(3) f(4) f(4) f(4) f(4) int f(int n) { if (n == 1) return 1; return n * f(n-1); } Illustration af kaldet f(4)
f r i t l a g e r } S t a k t o p b 3 b 2 b 1 a 3 Aktiveringspost a 2 a 1 r e t u r a d r e s s e f o r r i g e a k t i v e r i n g s p o s t B a s i s f ' s returværdi Brug af stak til håndtering af metodekald • int f(int a1, int a2, int a3) { • int b1, b2, b3; • }
from via to Problem. Flyt skiverne fra pinden from til pinden to, idet en større skive aldrig må placeres oven på en mindre skive. Tårnene i Hanoi(fra et munkekloster i Tibet) • At flytte n skiver fra pinden from til pinden to kan foretages ved først at flytte de øverste n-1 skiver fra pinden from til pinden via. • Dernæst flyttes den nederste skive fra pinden from til pinden to. • Endelig flyttes de n-1 skiver fra pinden via til pinden to.
Kaldtræ for move(3,1,3,2) move(3,1,3,2) move(2,1,2,3) move(2,2,3,1) move(1,1,3,2) move(1,3,2,1) move(1,2,1,3) move(1,1,3,2) move(0,3,2,1) move(0,1,2,3) move(0,2,3,1) move(0,1,2,3) move(0,1,2,3) move(0,3,1,2) move(0,2,3,1) move(0,3,2,1) • void move(int n, int from, int to, int via) { • if (n == 0) • return; • move(n-1, from, via, to); • System.out.println("Move " + from + " to " + to); • move(n-1, via, to, from); • }
Effektivitetsanalyse • Tidsforbruget er proportionalt med antallet af flytninger, F(n), hvor n angiver antallet af skiver. • F(n) = F(n-1) + 1 + F(n-1) = 2*F(n-1) + 1, for n > 1 F(1) = 1 • som har løsningen 2n - 1. • Pladsforbruget er det maksimale antal uafsluttede kald afmove, dvs. n. Samlet tidsforbrug for 64 skiver, hvis hver flytning tager 1 sekund: 264 sekunder≈1019 sekunder ≈ 1012år
Fundamentale regler for rekursion • Basistilfælde: Hav altid mindst et tilfælde, der kan løses uden brug af rekursion. • Gør fremskridt: Ethvert rekursivt kald bør nærme sig et basistilfælde. • Tro på det: Antag altid, at et rekursivt kald virker som ønsket. • Undgå dobbeltarbejde: Sørg for at hvert delproblem kun løses én gang. • Benyt aldrig rekursion som en erstatning for en simpel løkke.
void printDecimal(int n) { if (n >= 10) printDecimal(n/10); System.out.print((char) ('0' + n%10)); } Eksempel(tegnvis udskrivning af et positivt heltal) Udskriv n = 7913
Fjernelse af rekursion(et eksempel på algoritmetransformation) • Rekursion har omkostninger i tid og plads. • Omkostningen i tid skyldes mekanismer for metodekald og parameter-overførsel. • Omkostningen i plads er bestemt af det maksimale rekursionsniveau (det maksimale antal metodeaktiveringer, der eksisterer samtidigt). • Enhver rekursiv algoritme kan mekanisk transformeres til en ikke-rekursiv algoritme (ved brug af eksplicit stak)
variabel x = a; while (!B(x)) { S2; x = f(x); } S1; Fjernelse af halerekursion • Iteration er at sætte operationer af samme art efter hinanden. • Rekursion er at stikke operationer af samme art ind i hinanden. Hvis “indstikket” sker helt til slut i enhver operation, må dette være det samme som iteration. RekursionIteration void P(parameter x) { if (B(x)) S1; else { S2; P(f(x)); } } Kald: P(a)
RSA-kryptering(Rivest, Shamir og Adleman, 1978) Problem: Alice ønsker at sende en besked til Bob, men således at ingen andre kan læse hendes besked. Løsning: Bob offentliggør to tal, e og N, som alle, der sender beskeder til ham, skal benytte. Kryptering: Alice sender sin besked M i form af tallet R = Me (mod N). Dekryptering: Den oprindelige besked gendannes af Bob ved hjælp af transformationen Rd (mod N), hvor d er et tal, som kun Bob kender.
RSA-kryptering Bestemmelse af e, d og N: 1) Vælg to store primtal p og q (typisk på mere end 100 cifre). 2) Beregn N = p*q. 3) Beregn N’ = (p-1)*(q-1). 4) Vælg e > 1, således at gcd(N’, e) = 1. 5) Vælg d, således at e*d (mod N’) = 1. [ d.v.s., således at d er multiplikativ invers til e ]. Så vil (Me)d = M (mod N). Bob bør hemmeligholde p, q, N’ og d.
Et eksempel (1) p = 47 og q = 79 (to primtal) (2) N = p*q = 3713 (3) N’ = (p-1)*(q-1) = 3588 (4) e = 37 (gcd(N’, e) = 1) (5) d = 97 (e*d (mod N’) = 1, idet e*d = 3589)
Eksempel fortsat(e = 37, d = 97, N = 3713) Meddelelse: ATTACK AT DAWN Kodning: A = 01, B = 02, C = 03, o.s.v. A T T A C K A T D A W N 0120200103110001200004012314 (opdel i blokke á 2 tegn) Kryptering ved den offentlige nøgle 37: 012037 = 1404 200137 = 2392 (mod 3713) 1404239235360001328422802235 Dekryptering ved den hemmelige nøgle 97: 140497 = 0120239297 = 2001 (mod 3713) 0120200103110001200004012314
Delalgoritmer (1) Potensopløftning af lange heltal (Me og Rd). (2) Afgørelse af om et langt heltal er et primtal. (3) Multiplikation af to lange heltal ((p-1)*(q-1)). (4) Bestemmelse af største fælles divisor for to lange heltal (gcd(N’, e)). (5) Bestemmelse af det multiplikative inverse tal til et langt heltal (e*d (mod N’) = 1).
Test af RSA-kryptering import Supporting.Numerical; public class RSA { public static void main(String[] args) { long x = 10000, y = 50000, message = 123456789; long p, q, n, nPrime, e, d; for (p = x; !Numerical.isPrime(p); p++) ; for (q = y + 2; !Numerical.isPrime(q); q++) ; n = p*q; // p == 10007, q == 50021 nPrime = (p - 1)*(q - 1); for (e = nPrime/10; Numerical.gcd(e, nPrime) != 1; e++) ; d = Numerical.inverse(e, nPrime); long code = Numerical.power(message, e, n); long decode = Numerical.power(code, d, n); if (message != decode) System.out.println("OOPS!!!"); else System.out.println("Success"); } }
Sikkerhed ved RSA-kryptering Hvis d kan bestemmes ud fra kendskab til e og N, brydes brev-hemmeligheden. Hvis N kan faktoriseres, N = x*y, så kan d bestemmes. Imidlertid er faktorisering af et tal en meget vanskelig opgave. Med dagens teknologi vil det tage millioner af år for en computer at faktorisere et tal bestående af 200 cifre.
Dynamisk programmering (bund-til-top, bottom-up): For at løse et stort problem løses alle mindre delproblemer, og deres løsninger gemmes og benyttes til at løse større problemer. Således fortsættes, indtil problemet er løst. Betegnelsen stammer fra operationsanalysen, hvor “programmering” benyttes om formulering af et problem, således at en bestemt metode kan anvendes. Dynamisk programmering • Del-og-hersk (top-til-bund, top-down): • For at løse et stort problem deles problemet op i mindre delproblemer, der løses uafhængigt af hinanden.
Dynamisk programmering • Moderne definition: • Bund-til-top implementering af rekursive programmer med overlappende delproblemer. • Top-til-bund implementering er dog også mulig. Dynamisk programmering er baseret på følgende simple princip: Undgå at gentage en beregning.
0 0 10 55 20 6765 1 1 11 89 21 10946 2 1 12 144 22 17711 3 2 13 233 23 28657 4 3 14 377 24 46368 5 5 15 610 25 75025 6 8 16 987 26 121393 7 13 17 1597 27 196418 8 21 18 2584 28 317811 9 34 19 4181 29 514229 Talrækken vokser eksponentielt: F(n)/F(n-1) går imod 1.618... (det gyldne snit = (1+ )/2) 5 Simpelt eksempelBeregning af Fibonacci-tal(Fibonacci, 1202) • F(n) = F(n-1) + F(n-2) for n >= 2, • F(0) = 0, • F(1) = 1
Rekursiv metode til beregning af F(i) • int F(int n) { • return n <= 1 ? n : F(n-1) + F(n-2); • } Simpel, men meget ineffektiv. Antallet af kald, C(n), tilfredsstiller rekursionligningerne C(n) = C(n-1) + C(n-2) + 1, C(1) = C(0) = 1 som har løsningen C(n) = F(n+2) + F(n-1) - 1. C(n) er altså større end det Fibonacci-tal, der skal beregnes! Ineffektiviteten skyldes, at de samme delproblemer løses mange gange. F.eks. F(9) = F(8) + F(7) = F(7) + F(6) + F(7) = F(6) + F(5) + F(6) + F(6) + F(5)
int F(int n) { if (Fknown[n] != 0) return Fknown[n]; int r = n <= 1 ? n : F(n-1) + F(n-2); Fknown[n] = r; return r; } Undgå genberegninger(benyt “caching”) • Vedligehold en tabel (indiceret ved parameterværdien) indeholdende * 0, hvis den rekursive metode endnu ikke er kaldt med denne parameterværdi * ellers det resultat, der skal returneres • Første kald af metoden for en given parameterværdi: beregn som før, men gem desuden resultatet. • Efterfølgende kald med samme parameterværdi: returner resultatet fra det første kald.
Simpel rekursiv metode: F(9) 34 13 21 8 5 13 8 5 5 3 3 8 5 3 2 Køretid: eksponentiel 1 1 2 5 2 3 2 3 1 1 1 2 3 2 3 2 1 1 1 1 1 2 1 1 1 3 2 2 1 1 1 2 1 1 1 1 0 1 1 1 1 1 1 2 1 1 1 1 1 1 1 0 0 1 1 1 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 1 0 1 0 Husk kendte resultater: Køretid: lineær 34 21 13 13 8 8 5 5 3 3 2 2 1 1 1 Effektivitet
Eksempel: Dynamisk programmering til beregning af Fibonacci-tal: F[0] = 0; F[1] = 1; for (i = 2; i <= n; i++) F[i] = F[i-1] + F[i-2]; Tidsforbruget er lineært. Bund-til-top-tilgang • Dynamisk programmering (traditionel): • * Tabellæg løsningerne til delproblemerne • * Opbyg tabellen i stigende orden af problemstørrelse • * Benyt tidligere tabelindgange til at beregne senere tabelindgange
Afskaffelse af tabel • I Finonacci-algoritmen kan tabellen afskaffes: • F = 1; Fprev = 0; • for (i = 2; i <= n; i++) • { F += Fprev; Fprev = F - Fprev; } Faktisk kan F(n) beregnes i logaritmisk tid:
Optimal møntveksling • Udbetal et beløb i mønter, således at antallet af mønter er minimalt. • Eksempel: Hvis beløbet skal udbetales i amerikanske cents, kan mønterne 1-, 5-, 10- og 25-cent benyttes. Veksling af 63 cents kan da foretages med 6 mønter, nemlig to 25-cent, en 10-cent og tre 1-cent. • For disse mønter vil en grådig algoritme altid give en optimal løsning. Men hvis der f.eks. indføres en 21-cent-mønt, virker denne metode ikke.
Rekursiv top-til-bund-løsning Beregn for hver mønt det minimale antal mønter, der kan benyttes til at veksle det resterende beløb. Tag minimum. • int coins[] = {1, 5, 10, 21, 25}; • int makeChange(int change) { • if (change == 0) return 0; • int min = Integer.MAX_VALUE; • for (int i = 0; i < coins.length; i++) • if (change >= coins[i]) • min = Math.min(min, 1 + makeChange(change - coins[i])); • return min; } Benyt ikke denne algoritme! Eksponentielt tidsforbrug. Undgå genberegninger.
Brug af kendte løsninger • int makeChange(int change) { • if (change <= 0) • return 0; • if (minKnown[change] > 0) • return minKnown[change]; • int min = Integer.MAX_VALUE; • for (int i = 0; i < coins.length; i++) • if (change >= coins[i]) • min = Math.min(min, • 1 + makeChange(change - coins[i])); • minKnown[change] = min; • return min; • }
for ( ; change > 0; change -= lastCoin[change]) System.out.println(lastCoin[change]); Udskrivning af mønterne i en optimal veksling • int makeChange(int change) { • if (change <= 0) • return 0; • if (minKnown[change] > 0) • return minKnown[change]; • int min = Integer.MAX_VALUE, minCoin = 0, m; • for (int i = 0; i < coins.length; i++) • if (change >= coins[i]) { • m = 1 + makeChange(change - coins[i]); • if (m < min) • { min = m; minCoin = coins[i]; } • } • lastCoin[change] = minCoin; • minKnown[change] = min; • return min; • }