Salta al contenuto
Note per Studenti Algoritmi ricorsivi

Algoritmi ricorsivi

In questa pagina 5

Un algoritmo ricorsivo invoca se stesso su istanze sempre più piccole. La soluzione di un'istanza di taglia nn si ottiene:

  • direttamente per i casi base n=n0,…,n0+kn = n_0, \dots, n_0 + k;
  • altrimenti usando le soluzioni di una o più istanze di taglia <n< n.

È l'induzione trasformata in programma: per questo la correttezza si prova per induzione (vedi 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 →).

Esempi

Algoritmo ReverseArray(A, i, j)           Algoritmo LinearSum(A, n)
Input: array A, indici i, j >= 0          Input: array A, intero n >= 1
Output: A[i..j] ribaltato in loco         Output: somma dei primi n elementi
if i < j then                             if n = 1 then return A[0]
    swap(A[i], A[j])                      else return LinearSum(A, n-1) + A[n-1]
    ReverseArray(A, i+1, j-1)
return

Albero della ricorsione e stack

L'esecuzione su una data istanza è descritta dall'albero della ricorsione: ogni nodo è una chiamata, la radice è la prima, i figli di un nodo sono le chiamate fatte direttamente da esso, le foglie sono i casi base.

A runtime, la memoria si divide in stack (variabili locali e riferimenti: ogni chiamata aggiunge un record di attivazione e lo toglie quando finisce, con politica LIFO, e si accede solo all'ultimo inserito) e heap (oggetti, o strutture allocate con malloc). In LinearSum(A, 5) la chiamata LinearSum(A, 3) crea il suo record sopra quelli di n=5 e n=4; una ricorsione di profondità dd occupa Θ(d)\Theta(d) di stack.

Complessità con l'albero della ricorsione

A ogni nodo si attribuisce il costo delle operazioni della sua chiamata escluse le chiamate ricorsive; il costo totale è la somma sui nodi. Per un limite superiore si stima per ogni istanza di taglia nn, per un limite inferiore si esibisce un'istanza (o una stima valida per tutte).

  • LinearSum: nn nodi (una chiamata per ogni n,n−1,…,1n, n-1, \dots, 1), ciascuno Θ(1)\Theta(1): Θ(n)\Theta(n).
  • ReverseArray, con taglia n=j−i+1n = j - i + 1: ogni chiamata riduce la taglia di 22, quindi ⌊n/2⌋+1\lfloor n/2 \rfloor + 1 chiamate di costo costante: Θ(n)\Theta(n).

Potenze in tempo logaritmico

xn=1x^n = 1 se n=0n = 0; x⋅(x(n−1)/2)2x \cdot (x^{(n-1)/2})^2 se nn dispari; (xn/2)2(x^{n/2})^2 se nn pari e positivo.

Algoritmo Power(x, n)
Input: x reale, n >= 0 intero
Output: x^n
if n = 0 then return 1
if n è dispari then
    y <- Power(x, (n-1)/2)
    return x * y * y
else
    y <- Power(x, n/2)
    return y * y

Alla ii-esima chiamata l'esponente è ≤n/2i\le n/2^i: l'albero è un cammino di O(log⁡n)O(\log n) nodi, ciascuno Θ(1)\Theta(1). tPower(n)∈O(log⁡n)t_{Power}(n) \in O(\log n). Contando le chiamate: n=1000n = 1000 ne richiede 1111 (⌊log⁡2n⌋+2\lfloor \log_2 n \rfloor + 2), contro le 999999 moltiplicazioni dell'approccio ingenuo. Si calcola y una volta sola e si riusa: chiamare Power due volte riporterebbe a Θ(n)\Theta(n).

Con la formula F(n)=15(Φn−Φ^n)F(n) = \frac{1}{\sqrt5}(\Phi^n - \hat\Phi^n) (vedi 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 →), PowerFib(n) =(Power(Φ,n)−Power(Φ^,n))/5= (Power(\Phi,n) - Power(\hat\Phi,n))/\sqrt5 costa O(log⁡n)O(\log n) operazioni aritmetiche su reali (in pratica, con numeri a precisione finita, per nn grandi serve aritmetica esatta).

Fibonacci ricorsivo e memoizzazione

fib(n): se n <= 1 restituisci n; altrimenti restituisci fib(n-1) + fib(n-2)

Il numero di chiamate C(n)C(n) soddisfa 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è C(n)=2F(n+1)−1C(n) = 2F(n+1) - 1: 1,1,3,5,9,15,25,41,…1, 1, 3, 5, 9, 15, 25, 41, \dots (n=0,1,2,3,4,5,6,7n = 0, 1, 2, 3, 4, 5, 6, 7; per fib(5)\mathrm{fib}(5) sono 1515 chiamate, verificato eseguendo il codice). Cresce come Φn\Phi^n: esponenziale. Lo spreco è nei sottoproblemi ripetuti (fib(3)\mathrm{fib}(3) è ricalcolato più volte). Con la memoizzazione (si salva ogni risultato in una tabella e lo si riusa) per fib(5)\mathrm{fib}(5) le chiamate scendono a 99 e in generale a Θ(n)\Theta(n) (ciascun fib(k)\mathrm{fib}(k) è calcolato una volta sola).

Correttezza per induzione

Sia nn la taglia. Si dimostra la correttezza per i casi base n∈[n0,n0+k]n \in [n_0, n_0 + k]; poi, assumendo che l'algoritmo risolva correttamente tutte le istanze di taglia m∈[n0,n]m \in [n_0, n], si dimostra che risolve quelle di taglia n+1n + 1.

LinearSum. Base n=1n = 1: restituisce A[0]A[0] ✓. Passo: per ipotesi LinearSum(A, n) =∑i=0n−1A[i]= \sum_{i=0}^{n-1} A[i], quindi LinearSum(A, n+1) =∑i=0n−1A[i]+A[n]=∑i=0nA[i]= \sum_{i=0}^{n-1} A[i] + A[n] = \sum_{i=0}^{n} A[i] ✓.

Power. Base n=0n = 0: x0=1x^0 = 1 ✓. Passo: se n+1n+1 è pari, per ipotesi y=x(n+1)/2y = x^{(n+1)/2} e y⋅y=xn+1y\cdot y = x^{n+1}; se è dispari, y=xn/2y = x^{n/2} e x⋅y⋅y=x1+nx \cdot y \cdot y = x^{1 + n} ✓.

Errori comuni

  • Dimenticare un caso base, o una chiamata che non riduce la taglia (non termina): in ReverseArray serve i<ji < j.
  • Contare il costo di un nodo includendo le chiamate figlie (le conta due volte).
  • Ricalcolare più volte la stessa sottoistanza (Power con due chiamate, fib senza memoizzazione).
  • Dimenticare che la ricorsione consuma stack: profondità dd ⇒ spazio Θ(d)\Theta(d).

Versione ripasso

  • Ricorsione = induzione: casi base n0..n0+kn_0..n_0+k risolti direttamente; altrimenti chiamate su istanze più piccole. La correttezza si prova per induzione (vedi 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 →).
  • Albero della ricorsione: nodo = chiamata; costo del nodo = operazioni escluse le chiamate figlie; costo totale = somma sui nodi. Stack: un record di attivazione per chiamata (LIFO), profondità dd ⇒ spazio Θ(d)\Theta(d).
  • LinearSum Θ(n)\Theta(n); ReverseArray (taglia j−i+1j-i+1) Θ(n)\Theta(n).
  • Power(xx, nn): xn=1x^n = 1, x (x(n−1)/2)2x\,(x^{(n-1)/2})^2 (dispari), (xn/2)2(x^{n/2})^2 (pari); una sola chiamata ricorsiva, ⌊log⁡2n⌋+2\lfloor\log_2 n\rfloor + 2 chiamate, O(log⁡n)O(\log n); PowerFib O(log⁡n)O(\log n).
  • Fibonacci ricorsivo: C(n)=1+C(n−1)+C(n−2)=2F(n+1)−1C(n) = 1 + C(n-1) + C(n-2) = 2F(n+1) - 1 chiamate (fib(5)\mathrm{fib}(5): 1515), esponenziale ∼Φn\sim \Phi^n per sottoproblemi ripetuti; con memoizzazione Θ(n)\Theta(n) (fib(5)\mathrm{fib}(5): 99 chiamate).
  • Correttezza: base + ipotesi su tutte le taglie ≤n\le n ⇒ taglia n+1n+1.
  • Esempi: ReverseArray(A,i,j): se i<ji < j, swap(A[i], A[j]) e ricorsione su (i+1,j−1)(i+1, j-1); LinearSum(A,n): n=1→A[0]n = 1 \to A[0], altrimenti LinearSum(A,n-1) + A[n-1].
  • Numeri di chiamate: fib(n)\mathrm{fib}(n): 1,1,3,5,9,15,25,411, 1, 3, 5, 9, 15, 25, 41 per n=0..7n = 0..7; Power(1000): 1111 chiamate.
  • Errori: caso base mancante o taglia che non scende; contare le chiamate figlie nel costo del nodo; due chiamate dove ne basta una.

Esercizi su questo argomento

Teoria collegata