Salta al contenuto
Note per Studenti Esercizio 5 · Fibonacci ricorsivo, chiamate e complessità

Esercizio 5Fibonacci ricorsivo, chiamate e complessità

In questa pagina 5

Testo (scritto del 09/07/2024, seconda parte, esercizio 1, 4 punti). Si consideri il seguente algoritmo ricorsivo.

Algoritmo fibonacci(n)
Input: numero intero n
Output: valore n-esimo della sequenza di Fibonacci
if n <= 1 then return n
else return fibonacci(n-1) + fibonacci(n-2)

(a) Descrivere passo dopo passo l'esecuzione per fibonacci(5), mostrando i valori di tutte le variabili in ogni passo (usare l'albero delle chiamate oppure lo stack delle chiamate). (b) Calcolare il numero totale di chiamate ricorsive effettuate per fibonacci(5). (c) Analizzare la complessità temporale al caso pessimo dell'algoritmo. (d) Descrivere un'idea per modificare l'algoritmo allo scopo di migliorarne l'efficienza nel tempo.


(a) Albero delle chiamate

Ogni nodo è una chiamata, il valore a destra del segno uguale è ciò che restituisce (vedi Algoritmi ricorsiviAlgoritmi ricorsivi come induzione eseguita; albero della ricorsione e record di attivazione nello stack; esempi ReverseArray, LinearSum, Power in tempo logaritmico, Fibonacci ricorsivo (esponenziale) e con memoizzazione; analisi della complessità con l'albero della ricorsione e correttezza per induzione.Algoritmi ricorsivi → e RicorsioneFunzioni che chiamano se stesse: caso base e passo ricorsivo, stack delle chiamate, esempi su numeri, stringhe e liste, ricorsione multipla e suo costo, divide et impera, confronto con l'iterazione.Ricorsione →):

f(5) = 5
├─ f(4) = 3
│   ├─ f(3) = 2
│   │   ├─ f(2) = 1
│   │   │   ├─ f(1) = 1
│   │   │   └─ f(0) = 0
│   │   └─ f(1) = 1
│   └─ f(2) = 1
│       ├─ f(1) = 1
│       └─ f(0) = 0
└─ f(3) = 2
    ├─ f(2) = 1
    │   ├─ f(1) = 1
    │   └─ f(0) = 0
    └─ f(1) = 1

Ordine di esecuzione (la chiamata sinistra si risolve per intero prima della destra): f(5)→f(4)→f(3)→f(2)→f(1)=1f(5) \to f(4) \to f(3) \to f(2) \to f(1) = 1, poi f(0)=0f(0) = 0 e f(2)=1+0=1f(2) = 1 + 0 = 1; poi f(1)=1f(1) = 1 e f(3)=1+1=2f(3) = 1 + 1 = 2; poi f(2)f(2) destra di f(4)f(4): f(1)=1f(1) = 1, f(0)=0f(0) = 0, f(2)=1f(2) = 1, quindi f(4)=2+1=3f(4) = 2 + 1 = 3; poi f(3)f(3) destra di f(5)f(5) (che ripete tutto il lavoro svolto per f(3)f(3) a sinistra): f(3)=2f(3) = 2 e infine f(5)=3+2=5f(5) = 3 + 2 = 5. Nello stack, al momento più profondo, ci sono 55 record: f(5),f(4),f(3),f(2),f(1)f(5), f(4), f(3), f(2), f(1), cioè profondità nn (lo spazio è quindi Θ(n)\Theta(n) anche se il tempo è esponenziale: i record vengono tolti e riusati; il funzionamento dello stack è in Stack e chiamata di funzioni in ARMChiamata con BL e ritorno con BX LR; convenzione di chiamata AAPCS (argomenti in r0-r3, risultato in r0, registri da preservare r4-r11); stack discendente pieno con PUSH e POP; record di attivazione; funzioni foglia e non foglia; esempio ricorsivo del fattoriale con evoluzione dello stack.Stack e chiamata di funzioni in ARM →).

Stack delle chiamate nei primi passi (cima a destra; ogni record ha il suo parametro n e, dopo la prima chiamata figlia, il valore parziale ricevuto):

passo stack (dal fondo alla cima) evento
1 f(5)f(5) prima chiamata
2 f(5), f(4)f(5),\,f(4) f(5)f(5) chiama f(4)f(4)
3 f(5), f(4), f(3)f(5),\,f(4),\,f(3)
4 f(5), f(4), f(3), f(2)f(5),\,f(4),\,f(3),\,f(2)
5 f(5), f(4), f(3), f(2), f(1)f(5),\,f(4),\,f(3),\,f(2),\,f(1) caso base: restituisce 11 e il record viene tolto
6 f(5), f(4), f(3), f(2), f(0)f(5),\,f(4),\,f(3),\,f(2),\,f(0) f(2)f(2) chiama la seconda figlia: caso base, restituisce 00
7 f(5), f(4), f(3)f(5),\,f(4),\,f(3) f(2)f(2) ha ricevuto 11 e 00: restituisce 1+0=11+0=1 e viene tolto
8 f(5), f(4), f(3), f(1)f(5),\,f(4),\,f(3),\,f(1) f(3)f(3) chiama f(1)f(1) e riceve 11: f(3)=1+1=2f(3)=1+1=2
9 f(5), f(4)f(5),\,f(4) f(4)f(4) ha ricevuto 22 e chiama f(2)f(2), e così via

(b) Numero di chiamate

Contando i nodi dell'albero: f(5)f(5): 11; f(4)f(4): 11; f(3)f(3): 22; f(2)f(2): 33; f(1)f(1): 55; f(0)f(0): 33. Totale 1+1+2+3+5+3=151 + 1 + 2 + 3 + 5 + 3 = \mathbf{15} chiamate (compresa la prima), cioè 1414 chiamate ricorsive oltre alla chiamata iniziale. (Dipende dalla lettura del testo: "chiamate effettuate" va dichiarato; qui 1515 nodi dell'albero, verificato eseguendo il codice.)

Contare le chiamate in generale: C(0)=C(1)=1C(0) = C(1) = 1 e C(n)=1+C(n−1)+C(n−2)C(n) = 1 + C(n-1) + C(n-2), cioè 1,1,3,5,9,15,25,41,67,…1, 1, 3, 5, 9, 15, 25, 41, 67, \dots. Si dimostra per induzione che C(n)=2F(n+1)−1C(n) = 2F(n+1) - 1: base C(0)=1=2F(1)−1C(0)=1=2F(1)-1, C(1)=1=2F(2)−1C(1)=1=2F(2)-1; passo C(n+1)=1+C(n)+C(n−1)=1+(2F(n+1)−1)+(2F(n)−1)=2(F(n+1)+F(n))−1=2F(n+2)−1C(n+1)=1+C(n)+C(n-1)=1+(2F(n+1)-1)+(2F(n)-1)=2(F(n+1)+F(n))-1=2F(n+2)-1 (Principio di induzionePer dimostrare una proprietà per ogni n basta il passo base e il passo induttivo da n a n+1.Principio di induzione →, Dimostrazioni, induzione e invariantiTecniche di dimostrazione (esempio, controesempio, assurdo), induzione con casi base multipli, invarianti di ciclo (inizializzazione, conservazione, uso alla fine), schema generale per provare la correttezza; esempi svolti su arrayMax, sequenza di bit e numeri di Fibonacci.Dimostrazioni, induzione e invarianti →).

Grafico interattivo: Numero di chiamate di fibonacci(n), C(n) = 2F(n+1) − 1 ≈ 2·Φ^(n+1)/√5 − 1 (15 per n = 5, 1973 per n = 15), a confronto con le 2n − 1 della versione con memoizzazione (9 per n = 5): la crescita è esponenziale contro lineare

(c) Complessità al caso pessimo

Il costo di ogni nodo (escluse le chiamate figlie) è Θ(1)\Theta(1), quindi il tempo è proporzionale al numero di nodi: T(n)∈Θ(C(n))=Θ(F(n+1))T(n) \in \Theta(C(n)) = \Theta(F(n+1)).

Quindi T(n)∈Θ(Φn)T(n) \in \Theta(\Phi^n), in particolare 2n/2≲T(n)≲2n2^{n/2} \lesssim T(n) \lesssim 2^n: esponenziale, inefficiente anche per n≈50n \approx 50.

(d) Come migliorarlo

Lo spreco sono i sottoproblemi ripetuti: f(3)f(3) è calcolato 2 volte, f(2)f(2) 3 volte, f(1)f(1) 5 volte. Rimedi:

  1. Memoizzazione: una tabella M[0..n]M[0..n]; prima di calcolare f(k)f(k) si controlla M[k]M[k], altrimenti si calcola e si salva. Ogni f(k)f(k) è calcolato una volta: Θ(n)\Theta(n) tempo e spazio. Per f(5)f(5) le chiamate scendono da 1515 a 99: la discesa f(5),f(4),f(3),f(2),f(1),f(0)f(5),f(4),f(3),f(2),f(1),f(0) fa 66 chiamate e riempie M[2]=1M[2]=1, M[3]=2M[3]=2, M[4]=3M[4]=3, M[5]=5M[5]=5; le altre 33 (la seconda figlia di f(3)f(3), di f(4)f(4) e di f(5)f(5)) trovano subito il valore in MM o sono un caso base.
  2. Iterativo (dal basso): a <- 0; b <- 1; for i <- 2 to n: (a, b) <- (b, a+b); return b (con n≥1n \ge 1): Θ(n)\Theta(n) tempo, Θ(1)\Theta(1) spazio.
  3. Potenza veloce: con la formula F(n)=15(Φn−Φ^n)F(n) = \frac{1}{\sqrt5}(\Phi^n - \hat\Phi^n) e l'algoritmo Power (o con potenze di matrici 2×22 \times 2 a interi, che evitano gli errori di arrotondamento): O(log⁡n)O(\log n) operazioni (vedi Algoritmi ricorsiviAlgoritmi ricorsivi come induzione eseguita; albero della ricorsione e record di attivazione nello stack; esempi ReverseArray, LinearSum, Power in tempo logaritmico, Fibonacci ricorsivo (esponenziale) e con memoizzazione; analisi della complessità con l'albero della ricorsione e correttezza per induzione.Algoritmi ricorsivi →).

Errori comuni

  • Dire che l'albero ha nn nodi (è il numero di livelli): ha Θ(Φn)\Theta(\Phi^n) nodi.
  • Contare male il numero di chiamate: per f(5)f(5) sono 1515 nodi (non 55, non 88).
  • Concludere Θ(n)\Theta(n) perché "ci sono nn livelli": a ogni livello il lavoro raddoppia circa.
  • Proporre di "usare un ciclo" senza spiegare che il guadagno viene dal non ripetere sottoproblemi.

Versione ripasso

Testo. Algoritmo fibonacci(n): se n≤1n \le 1 restituisce nn, altrimenti fibonacci(n−1)+fibonacci(n−2)\text{fibonacci}(n-1) + \text{fibonacci}(n-2). Per n=5n = 5: (a) esecuzione passo per passo, con albero o stack delle chiamate; (b) numero di chiamate; (c) complessità al caso pessimo; (d) come migliorarlo.

Teoria collegata