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 f1 si invoca la funzione f2 e al tempo stesso nel corpo di f2 si invoca la funzione f1 (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 pi di dimensione minore di p; per ogni i, sia: si = funzione_ricorsiva(pi, altri parametri…); componi le soluzioni si 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
n!= " i = 1# 2 #L# n (0!= 1) i=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)! !
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):
1. 2. 3.
4.
Quante coppie di conigli avrò in un recinto dopo un anno se: all’inizio dell’anno introduco una sola coppia di conigli appena nati; una coppia di conigli diventa sessualmente matura dopo esattamente un mese dalla nascita; esattamente ogni mese una coppia di conigli sessualmente maturi produce una nuova coppia di conigli (un maschio e una femmina); 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
è il seguente: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, …
fib(i)
I numeri di Fibonacci Quale è la regola generale? Risposta: • 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.
fib(i)
I numeri di Fibonacci Quale è la regola generale? ad esempio: • 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…
fib(i)
I numeri di Fibonacci
La formula per calcolare i numeri di Fibonacci è dunque: #1 se n = 0 oppure n = 1 fib( n ) = $ % 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 ris; if (n==0 || n==1) ris = 1; else ris = fib(n-1)+fib(n-2); return ris; }
int fib(int n) { return (n==0||n==1)? 1 : (fib(n-1)+fib(n-2)); }
Le torri di Hanoi Si hanno tre pioli (A,B e C) …
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 2n-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); } }