Algoritmi esponenziali • Supponiamo che f(n) sia la funzione che rappresenta il numero di operazioni eseguite da un algoritmo e supponiamo che il tempo necessario per compiere una operazione sia un microsecondo: 1µs = 10-6sec. • Vogliamo vedere che per valori di n non elevati gli algoritmi impiegano un tempo troppo elevato per poter essere utilizzati: gli algoritmi esponenziali sono impraticabili. Algoritmi esponenziali Algoritmi esponenziali • Vediamo questa tabella dove riportiamo, al variare di n, il tempo impiegato da alcune funzioni di n (cap. 5) n log2(n) 100*n 10*n2 n3 10 2.3 µs 1ms 1ms 1ms 20 2.99µs 2ms 4ms 8ms 60 4.09µs 6ms 36ms 0.21sec 2n 1024µs ~ 1ms 1.048sec 260µs ~ 366 secoli Algoritmi esponenziali 260µs ~ 366 secoli, vediamo come mai 260µs ~ 10 60×0.3µs ~ 1018µs~ 1012 sec (1.1529 ×1012) (1µs = 10-6sec ) • Quanti secondi in un anno? 1 minuto = 60 secondi 1 ora = 3600 secondi 1 giorno = 86400 secondi 1 secolo = 3.15×109 secondi secondi 1.1529 ×1012 secoli = = = 366 secondi in un secolo 3.15×109 1 Algoritmi esponenziali • Esistono degli algoritmi che sono intrinsecamente esponenziali: • calcolare le permutazioni di n elementi: n! ~ nn • Ci sono problemi che sono risolti da un algoritmo deterministico esponenziale, ma il cui algoritmo non deterministico è polinomiale. L’algoritmo non deterministico è un algoritmo ideale: può essere simulato pensando di poter eseguire scelte contemporanee ed è rappresentabile su un albero: la profondità dell’albero può essere proporzionale alla dimensione dei dati. Tali problemi si chiamano NP (Non deterministico Polinomiale). Algoritmi esponenziali • Esempio. • Consideriamo la formula logica F(x1, x2, x3) = (x1 e x2 ) o (non x3) e ci chiediamo se esiste una scelta di valori per le variabili x1, x2, x3 che renda vera F. • Un algoritmo deterministico prova tutte le possibilità e poiché il valore di xi può essere vero o falso, le possibilità sono 23. Algoritmi esponenziali Algoritmi esponenziali • In generale considerando F(x1, x2, .., xn) il problema può essere risolto in un tempo O(2n). • Se potessimo esplorare l’albero in modo da poter percorrere contemporaneamente i due rami V e F, in n passi arriveremmo alle foglie. Quindi l’algoritmo non deterministico è O(n). • L’algoritmo non deterministico è in grado di scegliere il valore di xi che porta alla soluzione. Per simulare tale comportamento si può costruire un albero: ogni nodo rappresenta una variabile della formula; da ogni nodo partono due rami che rappresentano il vero e il falso. V x1 x2 x3 …… x1 F x2 x3 x2 x3 x3 x3 .............. 2 Complessità asintotica • Le considerazioni fatte sulla complessità valgono solo se n →∞ e F(n) →∞ • Se invece n si mantiene limitato anche un algoritmo esponenziale può essere utilizzato; per lo stesso motivo anche la costante moltiplicativa, che solitamente trascuriamo nella notazione O-grande, può invece essere fondamentale nella scelta. Complessità asintotica • Esempio. f1(n) = 1000 * n f1 è O(n) f2(n) = 10 * n2 f2 è O(n2) • Quindi: • se n →∞ è preferibile f1 • se n ≤10 è preferibile f2 , infatti: 1000 * n ≤ 1000*10 = 104 10 * n2 ≤ 10*100 = 103 Ordinamento per inserimento Ordinamento per inserimento • L’idea è quella di inserire una componente in ordine rispetto ad una sequenza di componenti già ordinate. • Esempio. Vogliamo inserire 6 nella sequenza: 2 5 7 9 10 • La cosa più efficiente è partire dall’ultima posizione e slittare verso destra le componenti che sono maggiori si 6: in tale modo quelle più piccole restano “ferme”: 7 7 9 10 2 5 ora c’è posto per inserire 6. 3 Ordinamento per inserimento • Per effettuare l’ordinamento si parte dalla seconda componente che si inserisce in ordine rispetto alla prima, poi si considera la terza, che si inserisce in ordine rispetto alle prime due, in generale: si vuole inserire la k-esima componente in ordine rispetto alle k-1 già ordinate. • Poiché il numero di componenti non aumenta, per poter slittare in avanti le componenti che precedono nella sequenza, è necessaria una variabile di appoggio x per salvare il valore: 2 5 9 11 3 1 20 2 3 5 9 x 11 1 20 Ordinamento per inserimento • Bisogna non uscire dall’array se si deve inserire un valore prima della prima componente. Ci sono varie strategie per realizzare ciò, una di queste è sfruttare la “valutazione pigra dei predicati”: for(k=1; k<=n-1; k++) {//v[0] è già //in ordine x=v[k]; i=k; while((i!=0) && (x < v[i-1])){ v[i]=v[i-1]; i--; } //fine while v[i]=x; }//fine for Ordinamento per inserimento • Complessità. • Caso favorevole: array già ordinato 1 3 8 10 20 il predicato del ciclo interno è sempre falso, ( x < v[i-1] è falso), quindi il numero di operazioni è proporzionale a n : Ω (n). • Caso peggiore: array in ordine inverso 20 10 9 7 4 1 il ciclo interno viene sempre eseguito per valori crescenti di i=k: 1 + 2 + 3 + …. + (n-1) = n (n-1)/2 : O(n2/2) Scelte multiple: switch 4 Scelte multiple: switch • La struttura switch si usa al posto di una situazione in cui ci siano scelte del tipo “else if“ annidate (par. 13.7.2). • Esempio. int x, y; cin>>x; if (x == y else if y else if y 1) = 1; (x == 2) = 4; (x == 4) = 16; Scelte multiple: switch • Sintassi. • Viene valutata una espressione (di tipo intero o carattere: switch(espressione){ case valore1: istruzione1; case valore2: istruzione2; case valore3: istruzione3; } dove valore1, valore2, valore3 sono valori costanti e diversi tra loro. Scelte multiple: switch Scelte multiple: switch • Semantica. • Il valore dell’espressione viene cercata tra i valori presenti nella case e se viene trovata si esegue l’istruzione indicata e tutte le successive. • Se si vuole che le istruzioni siano in alternativa, come avviene con l’annidamento delle if, si deve utilizzare la parola chiave break. switch(espressione){ case costante1: istruzione; break; case costante2: istruzione; break; case costante3: istruzione; } • Se il valore dell’espressione non è presente nelle case, non vi è alcuna segnalazione di errore. • Per gestire meglio il caso “valore non presente”, si può introdurre un “default”. 5 Scelte multiple: switch Scelte multiple: switch int k; cin>>k; switch(k){ case val1: istruzione; break; case val2: istruzione; break; case val3: istruzione; break; default: cout<<"il valore di k" << "non e’ presente \n"; } • Se non si usa break, l’ordine delle case fa variare le operazioni da eseguire. • Esempio. cin>>x; switch (x){ case 1: y = 1; case 2: y = 4; case 4: y = 16; } //se x = 1 y = 16 vengono eseguite tutte le assegnazioni su y. Scelte multiple: switch • Il valore 1 è dell’ultima case: switch (x){ case 4: y = 16; case 2: y = 4; case 1: y = 1; } //se x = 1 y = 1 Ciclo do • Questa struttura è utile quando si devono scegliere metodi diversi da eseguire in alternativa, come un menu che propone delle scelte: la lettura del menu è sicuramente più chiara che non l’elenco delle varie “else if”. 6 Ciclo do Ciclo do • Capita a volte di dover eseguire il corpo di un ciclo almeno una volta, per poi ripeterne l’esecuzione se è verificata una particolare condizione. • Sintassi. • Esempio: leggiamo un valore in ingresso e vogliamo che esso sia positivo: se il valore non è positivo vogliamo poter ripetere la lettura. • Semantica. L’iterazione viene eseguita la prima volta; viene valutata la condizione: se è vera si esegue nuovamente l’iterazione, se è falsa il ciclo termina. Ciclo do Ciclo do • Equivale a scrivere: • oppure boolean iterazione; continua =true; while(condizione){ while(continua){ iterazione; iterazione; } if(!condizione) continua=false; } do { //iterazione } while(condizione); • Esercizio. Calcolare la somma 1 +1./2 + 1./3 + 1./4 + … + 1./n + … fino a quando 1./n > t con t valore stabilito (ad esempio t = 10-6). • Provare a scrivere l’algoritmo usando sia il ciclo while che il ciclo do. 7 Ricorsione Ricorsione • Una scomposizione di un problema P in sottoproblemi P1, P2, …, Pn si dice ricorsiva se almeno uno dei sottoproblemi è formalmente simile a P e di dimensione inferiore. • Esempio. Fattoriale di un numero naturale. Per definizione 0! = 1 (caso base) n! = n * (n-1)! • Vediamo che n! viene definito tramite (n-1)! che è un numero più “piccolo” di n. Ricorsione Ricorsione • Per capire una definizione ricorsiva, bisogna “espanderla”: si “ricopia” la formula sostituendo al posto di n il numero n-1 e si prosegue fino al “caso base”: n! = n*(n-1)! = n* [(n-1) * (n-2)!] = . . . = = n*(n-1)* . . . * (n-n)! • Ci chiediamo se è una buona definizione. • È una buona definizione perché esiste una dimensione del problema, una condizione, che non necessita di ulteriori scomposizioni; il problema viene risolto direttamente: 0! = 1 • il problema per n=0 è risolto senza utilizzo della ricorsione. • Poiché (n-n)! = 0! = 1 per definizione, si ottiene: n! = n*(n-1)* . . . * 1 8 Ricorsione Ricorsione • Possiamo scrivere algoritmi ricorsivi, algoritmi che “richiamano” se stessi. Il main non può essere ricorsivo: il main è richiamato dal Sistema Operativo. • Un generico algoritmo ricorsivo avrà una struttura del tipo: se condizione allora risolvi direttamente altrimenti ricorsione oppure se condizione allora ricorsione • In questo caso può esserci o meno una alternativa: se la condizione è falsa non si esegue nulla. • Se la ricorsione non termina, si hanno infinite chiamate per l’algoritmo e si può occupare tutta la memoria: questo è un errore grave, come quello di costruire un ciclo infinito. Ricorsione Ricorsione • La scrittura di un algoritmo ricorsivo è semplice se si sta realizzando una formula matematica come quella del fattoriale: 0! =1, n! = n*(n-1)! intestazione della funzione fattoriale(n intero) definizione variabili f intero se n==0 allora f ←1 altrimenti f ← n * fattoriale(n-1) //finese restituire f • Cosa accade veramente? Facciamo uno schema della scomposizione: n! n* (n-1)! (n-1)* (n-2)! .. . . . . . . 1* 0! =1 9 Ricorsione • I vari prodotti n* , (n-1)* , … restano “sospesi” perché il controllo passa al sottoprogramma chiamato. • Dopo l’ultima chiamata che restituisce 1, si può ritornare indietro ed eseguire i prodotti “sospesi”. • Come può una funzione rimanere sospesa e poi, quando si riattiva, eseguire i prodotti giusti? Ricorsione • La funzione ricorsiva avrà una scrittura del tipo: int fattoriale(int n){ int f; if(n == 0) f = 1; else f = n * fattoriale(n-1); return f; }//fine fattoriale ricorsivo • Nella funzione appare: n nell’intestazione n-1 nella chiamata. Ricorsione Ricorsione • Una parte della memoria, RunTimeStack, mantiene le descrizioni delle attivazioni dei sottoprogrammi (funzioni): • Con la ricorsione la funzione è sempre la stessa, ma la gestione nel RunTimeStack è analoga: nelle varie “copie” della funzione sono memorizzati i parametri e le istruzioni di quella chiamata: funzione M main ……. chiama M ……. quando si esegue l’istruzione di chiamata di una funzione, il “controllo” passa alla funzione e quando la funzione è terminata, il controllo ritorna al chiamante f f f ritorno chiama f …. chiama f …. main … chiama f ….. il PC (contatore di programma) contiene l’indirizzo della prossima istruzione da eseguire 10 Ricorsione Ricorsione • Che cosa deve essere memorizzato per poter eseguire le operazioni? • Si deve memorizzare: • quali sono le operazioni da eseguire prima e dopo la chiamata • quale è il valore delle variabili a quel livello di chiamata. • Vediamo un esempio di questa memorizzazione calcolando ricorsivamente 5! • Quando un sottoprogramma termina, l’area allocata ritorna libera. ritorno 1 1 * 0! 2 * 1! 2*1 = 2 3 * 2! 3*2 = 6 4 * 3! 4*6 = 24 5 * 4! 5*24 =120 Complessità di un algoritmo ricorsivo Complessità di un algoritmo ricorsivo • Il tempo di un algoritmo ricorsivo si ottiene sommando vari tempi: • Il tempo per effettuare una chiamata è O(1): infatti si effettua un passaggio di parametri, in chiamata, e il ritorno di un valore, quando il metodo è terminato; questo equivale ad un numero finito di assegnazioni e pertanto è costante. • Il tempo dell’algoritmo di dimensione inferiore è T(dimensioneinferiore) • il tempo delle operazioni eseguite nell’algoritmo (esclusa la ricorsione) • il tempo della chiamata della funzione • il tempo dell’algoritmo di dimensione inferiore • Il tempo delle operazioni della parte non ricorsiva si calcola contando confronti, assegnazioni, cicli: T(n). 11 Complessità di un algoritmo ricorsivo Complessità di un algoritmo ricorsivo • Esempio. Complessità dell’algoritmo ricorsivo per il calcolo di n! • Sia T(n) il tempo per calcolare n!: possiamo contare il numero di moltiplicazioni, dal momento che questa è l’operazione fondamentale: costante se n=0 T(n) = costante + T(n-1) se n>0 Se contiamo solo le moltiplicazioni la prima costante • Se vogliamo con precisione contare tutte le operazioni avremo: • per la prima costante tc + ta + tritorno • per la seconda costante: tc + tprodotto + tchiamata + ta + tritorno • Otteniamo così la formula: T(n) = c + T(n-1) è 0 e la seconda costante è 1. Complessità di un algoritmo ricorsivo • Analogamente a quanto fatto definizione, espandiamo la formula: con Ricorsione e iterazione la T(n) = c + T(n-1) = c + (c + T(n-2)) = 2c + T(n-2) = = 2c + (c + T(n-3)) = 3c + T(n-3) = = 3c + (c + T(n-4)) = 4c + T(n-4) = …. = n · c + T(n-n) = n · c + T(0) = = (n+1) · c ⇒ O(n) • Se avessimo contato le moltiplicazioni, avremmo avuto: T(n) = 1 + T(n-1) = …. = n + T(0) = n • Avremmo anche potuto calcolare il fattoriale in maniera iterativa; la scomposizione iterativa del fattoriale è diversa: n! f← ← f*1 f← ← f*2 ....... f← ← f*n 12 Ricorsione e iterazione Ricorsione e iterazione • Anche la scrittura dell’algoritmo cambia; possiamo scrivere delle istruzioni del tipo: • La complessità non cambia: abbiamo infatti una struttura iterativa che viene eseguita n volte: ta + (n+1) · tp + n · ta + tritorno intestazione della funzione fattiterativo(n intero) definizione variabili f, i intero f ←1 per i da 1 a n eseguire f←f*i //fineper restituire f Il meccanismo della ricorsione • Schematizziamo un algoritmo ricorsivo nel modo seguente: algRicorsivo(parametri) α se P allora β altrimenti γ chiama algRicorsivo(nuoviparametri) δ //finese ritorno //fine algRicorsivo quindi sempre c··n operazioni. Il meccanismo della ricorsione dove α, β , γ , δ sono gruppi di istruzioni, P è il predicato che governa la ricorsione, ritorno indica ritorno al chiamante (ci può essere uno scalare oppure void). • Vediamo il funzionamento: • α (P vero) β ritorno • α (P falso) γ chiama [α (P vero) β ritorno] δ ritorno • Supponiamo che P sia falso 3 volte e indichiamo solo le istruzioni α, β , γ , δ 13 Il meccanismo della ricorsione falso falso falso vero α γ α γ αγ α β ritorno ritorno δ δ Il meccanismo della ricorsione • Se l'istruzione δ non c'è, la ricorsione si scioglie facilmente: ritorno α (P falso) γ δ • Le istruzioni δ devono essere eseguite con i valori dei parametri al momento in cui le operazioni α γ hanno effettuato la chiamata: vengono salvati nel RunTimeStack. • Dallo schema si vede che una scomposizione ricorsiva si può sempre trasformare in iterativa. α (P falso) γ α (P falso) γ α (P vero) β e si trasforma nella seguente struttura iterativa dove (non P) è il valore di predicato che effettua la chiamata: α mentre non P eseguire γ costruire nuoviparametri α //finementre β Il meccanismo della ricorsione Il meccanismo della ricorsione • Esempio. Stampare i primi numeri naturali in ordine decrescente: n, n-1, n-2, …, 1. algoritmo stamparic( intero n) se n>0 allora stampare n chiamare stamparic(n-1) //finese //fine algoritmo • Trasformiamo l’algoritmo: la chiamata viene eseguita quando n>0, pertanto questo sarà il predicato del ciclo: • manca δ; l’algoritmo è semplice, manca l’istruzione manca β pertanto la chiamata è per n>0. α, e mentre n>0 eseguire stampare n n← n-1 //costruire nuovi parametri //finementre • Esercizio. Stampare i numeri in ordine crescente. Costruire “manualmente” lo Stack per memorizzare δ. 14 Numeri di Fibonacci Numeri di Fibonacci • Leonardo da Pisa (detto Fibonacci, 1175-1240) fu un illustre matematico che si interessò di vari problemi, alcuni dei quali oggi potremmo chiamarli “dinamica delle popolazioni”, ossia lo studio di come si evolvono le popolazioni. • Indichiamo con Fn il numero di conigli dopo n anni e proviamo a calcolarli a partire dal primo anno: • F1 = 1 coppia iniziale • F2 = 1 la stessa coppia • F3 = 1 + 1 = 2 F1 + la coppia nata da F1 (≥2 anni) • F4 = 2 + 1 = 3 F3 + la coppia nata da F2 (≥2 anni) • F5 = 3 + 1 + 1 = 5 F4 + le due coppie nate da F3 • Problema astratto. Consideriamo: • un’isola deserta: sistema isolato • una coppia di conigli genera un’altra coppia ogni anno • i conigli si riproducono solo dopo due anni dalla loro nascita • i conigli sono immortali (n → +∞ ) • Quante coppie ci sono dopo n anni? (≥2 anni) ….. Numeri di Fibonacci Numeri di Fibonacci • In generale si avrà Fn = Fn-1 + Fn-2 dove Fn-1 rappresenta le coppie presenti l’anno precedente ed Fn-2 rappresenta una nuova coppia per ogni coppia di almeno 2 anni. • I numeri si calcolano facilmente sommando i valori dei due posti precedenti: • L’algoritmo più immediato da scrivere è quello che ricopia la definizione e pertanto è un algoritmo ricorsivo, che avrà una scrittura del tipo: n 1 2 3 F(n) 1 1 2 4 5 6 7 3 5 8 13 8 9 10 21 34 55 11 12 89 144 intestazione funzione fibonacci (n intero) definizione variabili fib intero se n ==1 oppure n ==2 allora fib ← 1 altrimenti fib ← fibonacci (n-1) + fibonacci (n-2) //finese restituire fib 15 Numeri di Fibonacci Numeri di Fibonacci • Possiamo rappresentare le chiamate ricorsive con una struttura di “albero”, come abbiamo fatto con il fattoriale. • Un albero è un insieme di punti, detti nodi, a cui è associata una struttura d’ordine che gode delle seguenti proprietà: • esiste uno ed un solo nodo che precede tutti gli altri, detto radice • ogni nodo, esclusa la radice, ha un unico predecessore immediato. Numeri di Fibonacci F2 F3 F4 F1 F5 F2 F2 F3 • • • • Ogni nodo con successore si chiama padre. Ogni nodo con predecessore si chiama figlio. I nodi senza successore si chiamano foglie. L’arco che collega un nodo padre a un nodo figlio si chiama ramo. • Possiamo rappresentare l’albero per n = 5. Numeri di Fibonacci F1 F5 è la radice, F1 e F2 sono foglie; le foglie non hanno ulteriori chiamate ricorsive e restituiscono il valore 1 e nel ritorno si eseguono le somme. • Si può dimostrare che il numero delle foglie dell’albero della ricorsione per la costruzione di Fn coincide con il valore di Fn. • Se vogliamo contare le chiamate della funzione, dobbiamo anche aggiungere il numero dei nodi interni, che corrisponde al numero delle chiamate ricorsive. Si può dimostrare che tale numero è uguale al numero delle foglie meno uno. • Possiamo concludere che: la complessità dell’algoritmo ricorsivo “cresce” come F(n). 16 Numeri di Fibonacci Numeri di Fibonacci • Osservando l’albero della ricorsione si nota che molti valori Fn sono calcolati più volte: nel caso di F5, F2 viene calcolato 3 volte. • Si possono pertanto memorizzare tali valori in un array e calcolarli una volta sola. • Si dovrà però dare una dimensione all’array, stabilendo un numero massimo di elementi da calcolare. intestazione funzione fibonacci2 (n intero) definizione variabili fib[nummax], i intero fib[1] ← 1 fib[2] ← 1 per i da 3 a n eseguire fib[i] ← fib[i-1] + fib[i-2] //fineper restituire fib[n] Numeri di Fibonacci Numeri di Fibonacci • Quale complessità ha l’algoritmo che utilizza l’array? • Osserviamo nuovamente il calcolo dei valori Fn , ed osserviamo che ad ogni passo si utilizzano solo i valori precedenti, che possono essere salvati in due variabili scalari: • F1 = 1 • F2 = 1 • F3 = F2 + F1 • F4 = F3 + F2 • F5 = F4 + F3 queste somme sono del tipo: f ← f + valoreprecedente tempo O(n): ciclo che viene eseguito n volte spazio O(n): si utilizza un array di nummax componenti per calcolare Fn con n<=nummax • Si può scrivere un algoritmo ancora più efficiente. 17 Numeri di Fibonacci • Si ottiene così il seguente algoritmo: intestazione funzione fibonacci3 (n intero) definizione variabili fib, i, prec, prec1 intero prec ← 1 //F1 fib ← 1 //F2 per i da 3 a n eseguire prec1 ← fib fib ← fib + prec prec ← prec1 //fineper restituire fib //salviamo F2, prima di // F3 = F2 + F1 // perché servirà nel //calcolo di F4 Numeri di Fibonacci • Quale complessità ha l’algoritmo che utilizza le sole variabili scalari? tempo O(n): ciclo che viene eseguito n volte spazio O(1): si utilizza un numero costante di locazioni di memoria Numeri di Fibonacci Numeri di Fibonacci • Per calcolare la complessità dell’algoritmo ricorsivo dobbiamo capire “come” il valore di F(n) cresce, andando all’infinito. • Possiamo stimare il valore utilizzando un algoritmo numerico. • Si cerca una funzione che soddisfi la relazione di ricorrenza Fn = Fn-1 + Fn-2 e si prova con an , a ≠ 0; l’equazione diventa: an = an-1 + an-2 da cui raccogliendo an-2 si ottiene: an-2 ( a2 – a – 1) = 0 • Poiché a ≠ 0 cerchiamo le soluzioni dell’equazione ( a2 – a – 1) = 0 e troviamo le due radici reali: φ = (1 + √5 ) / 2 ~ 1.618 φ = (1- √ 5 ) / 2 ~ - 0.618 φ è la sezione aurea. • Si può dimostrare che Fn = (φn - φn ) / √ 5 18 Numeri di Fibonacci Numeri di Fibonacci • Esiste quindi un algoritmo numerico con il quale calcolare il numero Fn. • Però tale algoritmo non può essere preciso, dal momento che Fn è un numero naturale e la radice di 5 è un numero irrazionale: quindi una qualunque applicazione di tale algoritmo fornisce solo una approssimazione. • La complessità di tempo e di spazio è O(1). • Utilizziamo la formula Fn = (φn - φn ) / √ 5 per stimare come F(n) → + ∞ • L’algoritmo ricorsivo ha complessità O(Fn); dal momento che: |φ| <1 si ha che | φn | → 0 1< φ < 2 si ha che φn < 2n e pertanto l’algoritmo cresce in maniera esponenziale con limitazione superiore 2n: tempo O(2n). Numeri di Fibonacci La torre di Hanoi F5 F4 F3 F2 F3 F2 F2 F1 F1 • La complessità di spazio è O(n); infatti le chiamate ricorsive si espandono in profondità, non sono contemporanee: F5, F4, F3, F2, ritorno, F1, ritorno F2, calcola F3, ritorno, … • La leggenda narra che dei sacerdoti di un tempio di Brahma lavorino per spostare una pila di 64 dischi d’oro da un piolo ad un altro, utilizzandone uno di appoggio e seguendo delle regole; alla fine del lavoro ci sarà la fine del mondo (par. 8.2). A B C 19 La torre di Hanoi • La configurazione finale dovrà essere: La torre di Hanoi • La regola è la seguente: • si può spostare un solo disco alla volta • non si può mettere un disco grande su uno piccolo. • La soluzione più intuitiva è quella ricorsiva: • se spostiamo la pila di n-1 dischi da A a B, possiamo muovere il primo disco da A a C e poi spostare la pila di n-1 dischi da B a C. A B C • Indichiamo con H(n, A, B,C) il problema di Hanoi di dimensione n. La torre di Hanoi • La scomposizione ricorsiva sarà perciò: H(n-1, A, C, B) H(1, A, B, C) //muove un disco H(n-1, B, A, C) • Possiamo scrivere le chiamate ricorsive nel caso n=3. • Applichiamo l’espansione della formula ricorsiva e vediamo come si muovono i dischi. La torre di Hanoi H(2,A,C,B) H(3,A,B,C) H(1,A,B,C) H(1,A,C,B) H(1,C,A,B) H(1,A,B,C) H(2,B,A,C) H(1,B,C,A) H(1,B,A,C) H(1,A,B,C) 20 La torre di Hanoi • Complessità. Quanti sono gli spostamenti ei dischi? • Per spostare un disco da un piolo ad un altro, ed ottenere la stessa configurazione, si deve spostare 2 volte la pila di dischi che gli sta sopra; quindi ogni disco si muove un numero di volte che è doppio rispetto al disco che gli sta immediatamente sotto. • Contiamo gli spostamenti a partire dal primo: La torre di Hanoi disco 1 1 spostamento 2 2 3 2*2 = 4 = 22 4 2*4 = 8 = 23 5 2*8 = 16 = 24 ……… n 2n-1 sommiamo gli spostamenti 2 1 + 2 + 2 + 23 + 24 + …+ 2n-1 = 2n -1 quindi O(2n) l’algoritmo è esponenziale La torre di Hanoi • Si può anche scrivere un algoritmo iterativo, che rimane esponenziale, osservando il movimento dei dischi: disco1 disco2 disco3 A–C A–B–C A–C–B–A–C • I dischi pari percorrono ciclicamente in ordine alfabetico i pioli, i dischi dispari li percorrono in ordine inverso. 21