Ricorsione (da lucidi di Marco Benedetti)
Funzioni ricorsive Dal punto di vista sintattico, siamo in presenza di una funzione ricorsiva quando all interno della definizione di una funzione compaiono una o più invocazioni alla funzione che si sta definendo; in altre parole, la funzione ricorre a se stessa per svolgere il proprio compito; Questo tipo di ricorsione è chiamata ricorsione diretta; esistono casi più complessi, come quello in cui nel corpo della funzione f 1 si invoca la funzione f 2 e al tempo stesso nel corpo di f 2 si invoca la funzione f 1 (si parla in questo caso di mutua ricorsione); esistono tipi di ricorsione ancora più generali, ma in questa lezione ci limitiamo alla ricorsione semplice; Le funzioni ricorsive non sono -come potrebbe sembrare- condannate a non terminare mai, perchè l invocazione ricorsiva (o, brevemente: ricorsione) non avviene incondizionatamente; ci devono essere, al contrario, dei casi in cui la funzione spezza la catena della ricorsione e riesce a calcolare il risultato che le è richiesto senza ulteriori invocazioni ricorsive; Le funzioni ricorsive risultano estremamente comode nella codifica di tutti gli algoritmi modellati su un procedimento di soluzione induttivo.
Formulazioni induttive Una soluzione induttiva ad un problema è caratterizzata dall individuazione di una dimensione lungo la quale il problema può essere semplificato/ridotto/rimpiccolito; lungo tale dimensione si incontrano quindi istanze più piccole - cioè più semplici - dello stesso problema; Una volta individuata la dimensione lungo la quale muoversi, si specificano i due seguenti passi fondamentali: il caso base, cioè la classe di istanze sufficientemente piccole (quindi sifficientemente semplici) da poter essere risolte in maniera diretta (senza ricorsione); il caso induttivo, cioè la classe delle istanze restanti che - pur non essendo direttamente risolvibili - si prestano ad essere risolte tramite un procedimento di questo tipo: 1. si estrapolano/estraggono/deducono dall istanza in considerazione una o più istanze dello stesso problema che risultano più piccole di quella di partenza; 2. si suppone di saper risolvere direttamente tali istanze (in realtà: si applica ricorsivamente il procedimento che si sta definendo); 3. si compongono in qualche modo le soluzioni a tali istanze e si fanno i calcoli opportuni per ottenere la soluzione dell istanza di partenza.
Implementazioni ricorsive Una volta descritto in maniera induttiva il procedimento di soluzione di un problema, la sua implementazione ricorsiva in C++ ricalca in genere il seguente schema: SOLUZIONE funzione_ricorsiva (PROBLEMA p, altri parametri ) { if (siamo nel caso base) { risolvi direttamente il problema p: sia sol la soluzione; return sol; } else { //siamo nel caso induttivo estrai da p uno o più sottoproblemi p i di dimensione minore di p; per ogni i, sia: s i = funzione_ricorsiva(p i, altri parametri ); } } componi le soluzioni s i ed effettua gli altri calcoli necessari ad ottenere una soluzione sol del problema p; return sol;
Elementi delle funzioni ricorsive Dallo schema descritto si intuisce come le funzioni ricorsive possano garantire la terminazione: ogni volta che una funzione invoca se stessa, sta richiedendo la soluzione di un problema più semplice di quello di partenza; prima o poi si arriverà dunque ad un problema di dimensione sufficientemente piccola da poter essere affrontato nel caso base; a questo punto viene restituita una risposta che, a cascata e all indietro, permette di calcolare le risposte intermedie più complesse rimaste in sospeso ; Nello schema indicato restano da dettagliare caso per caso una serie di elementi: il caso base: può essere esso stesso complesso da risolvere, possono esistere più casi base distinti, ecc. il caso induttivo comprende due fasi fondamentali da progettare di volta in volta: 1. la fase dell estrapolazione del/dei sottoproblema/i; 2. la fase dello sfruttamento della/delle soluzione/i parziale/i; in genere una di queste fasi (quale delle due dipende dal procedimento di soluzione) risulta di complessità predominante rispetto all altra.
Il calcolo del fattoriale Una esempio molto semplice di problema la cui soluzione si presta ad essere descritta in maniera induttiva è il calcolo del fattoriale n! di un intero n: n!= " i =1# 2 #L# n (0!=1) La descrizione induttiva del calcolo è: caso base: per n=0 si ha direttamente n! = 1 caso induttivo:! per n>0 si ha n! = n * (n-1)! n i=1 In questo esempio: il caso base è semplice da riconoscere e calcolare, la ricorsione riguarda un solo sottoproblema la cui estrazione è semplice (si passa dal valore n al valore n-1 ) e lo sfruttamento della soluzione parziale è operata tramite la moltiplicazione; L implementazione è dunque: int fattoriale (int n) { if (n==0) return 1; else return n * fattoriale(n-1); }
I numeri di Fibonacci Nel 1202 Fibonacci si trovò a risolvere il seguente problema (che è un astrazione/semplificazione di un problema reale): Quante coppie di conigli avrò in un recinto dopo un anno se: 1. all inizio dell anno introduco una sola coppia di conigli appena nati; 2. una coppia di conigli diventa sessualmente matura dopo esattamente un mese dalla nascita; 3. esattamente ogni mese una coppia di conigli sessualmente maturi produce una nuova coppia di conigli (un maschio e una femmina); 4. i conigli non muoiono mai. La soluzione generale a questo problema è una sequenza di numeri {fib(i)} nota come sequenza di fibonacci; il valore fib(i) rappresenta il numero di coppie di conigli presenti nel recinto all inizio del mese i-esimo.
I numeri di Fibonacci L inizio della sequenza, come rappresentato in figura: numero di coppie: fib(i) è il seguente: 1, 1, 2, 3, 5, 8, 12,
I numeri di Fibonacci Un pezzo più lungo del prefisso della sequenza di Fibonacci i coppie di conigli fib(i) è il seguente: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55,
Quale è la regola generale? Risposta: I numeri di Fibonacci fib(i) all inizio del mese i ci sono tutti i conigli che c erano all inizio del mese i-1 (i conigli non muoiono) e in più ci sono i nuovi nati; i nuovi nati sono nati dopo una gestazione di un mese che ogni coppia di genitori ha avviato alla maturità sessuale (cioè ad un mese di età); pertanto le nuove coppie all inizio del mese i sono tante quanti erano i conigli sessualmente maturi al mese i-1 e cioè tanti quanti erano i conigli al mese i-2; in definitiva, di conigli al mese i ce ne sono la somma di quanti c erano al mese i-1 con quanti ce n erano al mese i-2.
Quale è la regola generale? ad esempio: I numeri di Fibonacci fib(i) all inizio del mese 2 rimane la coppia introdotta all inizio, ma c è una prima coppia figlia, quindi fib(3)=1+1=2; all inizio del mese 3 restano le 2 coppie già presenti al mese 2, ed in più la coppia che già era presente al mese 1 produce una nuova coppia, per cui fib(3)=2+1=3; all inizio del mese 4 restano le 3 coppie già presenti all inizio del mese 3, ed in più le coppie fertili del mese 3 - che sono 2 perché 2 coppie erano presenti all inizio del mese 2, producono una nuova coppia ciascuna; quindi fib(4)=3+2=5; e così via
I numeri di Fibonacci La formula per calcolare i numeri di Fibonacci è dunque: fib( n) = # 1 se n = 0 oppure n =1 $ % fib(n "1) + fib(n " 2) se n >1 In questo esempio: il caso base è semplice da riconoscere e calcolare, la ricorsione riguarda due sottoproblemi di semplice individuazione (si passa dal valore n ai valori n-1! e n-2 ); lo sfruttamento della soluzione parziale è operato tramite la semplice addizione delle soluzioni dei sottoproblemi individuati; L implementazione è dunque (tre versioni equivalenti): int fib(int n) { } if (n==0 n==1) return 1; else return fib(n-1)+fib(n-2); int fib(int n) { } int fib (int n) { int ris; if (n==0 n==1) ris = 1; else ris = fib(n-1)+fib(n-2); return ris; } return (n==0 n==1)? 1 : (fib(n-1)+fib(n-2));
Si hanno tre pioli (A,B e C) Le torri di Hanoi A B C
Le torri di Hanoi Si hanno tre pioli (A,B e C) e un certo numero di dischi forati - tutti di dimensioni diverse - inizialmente disposti sul piolo A dal più grande (in fondo) al più piccolo (in cima). A B C
Le torri di Hanoi L obiettivo del gioco è mettere tutti i dischi sul piolo C, sempre in ordine dal più grande (in basso) al più piccolo (in cima), seguendo tre regole A B C
Le torri di Hanoi (1) si può spostare un solo disco alla volta; (2) si può spostare solo un disco che non ha altri dischi sopra; (3) non si può mai mettere un disco più grande su uno più piccolo. A B C
Le torri di Hanoi Soluzione del problema con 4 dischi:
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 1
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 2
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 3
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 4
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 5
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 6
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 7
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 8
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 9
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 10
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 11
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 12
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 13
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 14
Le torri di Hanoi Soluzione del problema con 4 dischi: mossa 15
Le torri di Hanoi Soluzione del problema con 4 dischi:
Le torri di Hanoi Per risolvere il problema di Hanoi con soli 4 dischi occorrono dunque 15 mosse; più in generale, la soluzione più breve per risolvere il problema con n dischi è composta da 2 n -1 mosse;
Un algoritmo per le torri di Hanoi Come si descrive induttivamente la soluzione del problema con n dischi? Caso base: se ho un solo disco da spostare da A a C, lo so spostare: A B C
Un algoritmo per le torri di Hanoi Come si descrive induttivamente la soluzione del problema con n dischi? Caso base: se ho un solo disco da spostare da A a C, lo so spostare: A B C
Un algoritmo per le torri di Hanoi Come si descrive induttivamente la soluzione del problema con n dischi? Caso base: se ho un solo disco da spostare da A a C, lo so spostare; Caso induttivo (suppongo di saper risolvere problemi con n-1 dischi): 1. metto gli n-1 dischi più piccoli da A in B A B C
Un algoritmo per le torri di Hanoi Come si descrive induttivamente la soluzione del problema con n dischi? Caso base: se ho un solo disco da spostare da A a C, lo so spostare: Caso induttivo (suppongo di saper risolvere problemi con n-1 dischi): 1. metto gli n-1 dischi più piccoli da A in B 2. metto un disco da A in C A B C
Un algoritmo per le torri di Hanoi Come si descrive induttivamente la soluzione del problema con n dischi? Caso base: se ho un solo disco da spostare da A a C, lo so spostare: Caso induttivo (suppongo di saper risolvere problemi con n-1 dischi): 1. metto gli n-1 dischi più piccoli da A in B 2. metto un disco da A in C 3. metto gli n-1 dischi più piccoli da B in C A B C
Un algoritmo per le torri di Hanoi Come si descrive induttivamente la soluzione del problema con n dischi? Caso base: se ho un solo disco da spostare da A a C, lo so spostare: Caso induttivo (suppongo di saper risolvere problemi con n-1 dischi): 1. metto gli n-1 dischi più piccoli da A in B 2. metto un disco da A in C 3. metto gli n-1 dischi più piccoli da B in C A B C
Un algoritmo per le torri di Hanoi Come si descrive induttivamente la soluzione del problema con n dischi? Caso base: se ho un solo disco da spostare da A a C, lo so spostare: Caso induttivo (suppongo di saper risolvere problemi con n-1 dischi): 1. metto gli n-1 dischi più piccoli da A in B 2. metto un disco da A in C 3. metto gli n-1 dischi più piccoli da B in C A B C
Un algoritmo per le torri di Hanoi Nel problema delle torri di Hanoi: 1. il caso base è semplice da riconoscere e risolvere; 2. la ricorsione riguarda due sottoproblemi la cui estrazione è semplice: si passa da un problema con n dischi a due problemi con n-1 dischi; 3. lo sfruttamento delle soluzioni parziali è operata tramite la giustapposizione delle mosse previste dalla soluzione del primo sottoproblema, seguita da una singola mossa, seguita ancora dalle mosse previste per la soluzione del secondo sottoproblema; L implementazione è dunque: void hanoi(char da_piolo, char a_piolo, char piolo_appoggio, int dischi) { if (dischi==1) { cout << "da " << da_piolo << " a " << a_piolo << endl; } else { hanoi(da_piolo,piolo_appoggio,a_piolo,dischi-1); cout << "da " << dal_piolo << " a " << al_piolo << endl; hanoi(piolo_appoggio,a_piolo,da_piolo,dischi-1); } }