Esercizi · Algoritmi ricorsivi e iterativi a confronto¶
Esercizi · Capitolo 18 · ogni soluzione si apre con un clic · Capitolo 18: Algoritmi ricorsivi e iterativi a confronto · Eserciziario, volume 5 (PDF)
Esercizio 18.1: Algoritmi ricorsivi contro iterativi (E1)
Consideriamo i seguenti due algoritmi ricorsivi.
Domande
-
GCDReceRecBSsono ricorsivi in coda? Giustificare la risposta. -
Trasformare
GCDRecin un algoritmo iterativo equivalenteGCDIt, applicando passo per passo l'eliminazione meccanica della ricorsione in coda. Poi eseguire entrambi gli algoritmi con \((a,b)=(1071,462)\), elencando i valori dei parametri in ogni chiamata e in ogni iterazione. -
Trasformare
RecBSin un algoritmo iterativo equivalenteItBS. Poi eseguire entrambi gli algoritmi sull'array \(a=[2,5,8,12,16,23,38,56,72,91]\) (con \(l=1\), \(r=10\)) per \(m=23\) e per \(m=4\). -
Confrontare le versioni ricorsiva e iterativa dei due algoritmi in termini di tempo e spazio. In particolare, determinare la profondità della ricorsione di
GCDRec\((89,55)\) e la massima profondità della ricorsione diRecBSsu un array di \(n=10^6\) elementi.
Soluzione
-
Entrambi gli algoritmi sono ricorsivi in coda.
-
In
GCDRecl'unica chiamata ricorsiva è alla riga 5: il suo risultato viene restituito così com'è, senza alcuna ulteriore operazione. -
In
RecBSle chiamate ricorsive sono alle righe 10 e 12: in entrambi i casi il risultato della chiamata viene restituito così com'è.
Quindi, dopo ogni chiamata ricorsiva, nella funzione chiamante non resta nulla da fare: il suo frame non serve più, e la chiamata può essere sostituita da un aggiornamento dei parametri.
-
-
Applichiamo le tre regole dell'eliminazione meccanica della ricorsione in coda.
-
La condizione del caso base è \(b=0\) (riga 2): la condizione del ciclo è \(b\neq 0\).
-
La chiamata in coda \(\texttt{GCDRec}(b,\, a\bmod b)\) diventa
l'assegnazione simultanea \((a,b)\leftarrow(b, a\bmod b)\). Poiché il nuovo valore di \(b\) deve essere calcolato con il vecchio valore di \(a\), usiamo una variabile temporanea \(r\). -
Il return del caso base (return \(a\), riga 3) viene spostato dopo il ciclo.
Algoritmo: GCDIt
1function GCDIt(a, b)2while \(b \neq 0\) do3\(r := a \bmod b\)4\(a \leftarrow b\)5\(b \leftarrow r\)6end7return \(a\)8endEsecuzione con \((a,b)=(1071,462)\):
GCDRecGCDItchiamata \(a\) \(b\) \(a \bmod b\) iterazione \(a\) \(b\) \(r\) 1 1071 462 147 1 1071 462 147 2 462 147 21 2 462 147 21 3 147 21 0 3 147 21 0 4 21 0 – fine 21 0 – Entrambi gli algoritmi passano per le stesse coppie \((1071,462)\), \((462,147)\), \((147,21)\), \((21,0)\) e restituiscono \(21\).
GCDRecesegue \(4\) chiamate (tutte attive contemporaneamente quando si raggiunge il caso base);GCDItesegue \(3\) iterazioni. -
-
La condizione del caso base \(l>r\) diventa la condizione del ciclo \(l\le r\); il secondo caso base (\(a[mid]=m\)) resta dentro il ciclo come un return; la chiamata in coda alla riga 10 diventa \(l\leftarrow mid+1\) e la chiamata in coda alla riga 12 diventa \(r\leftarrow mid-1\) (i parametri \(a\) e \(m\) non cambiano); il return di \(-1\) viene spostato dopo il ciclo.
Algoritmo: ItBinarySearch
1function ItBS(a, l, r, m)2while \(l \le r\) do3\(mid := l + \lfloor (r-l)/2 \rfloor\)4if \(a[mid] = m\) then5return \(mid\)6end7if \(a[mid] < m\) then8\(l \leftarrow mid+1\)9else10\(r \leftarrow mid-1\)11end12end13return \(-1\)14endI due algoritmi passano per gli stessi valori di \((l,r)\); per
RecBSogni riga è una chiamata, perItBSogni riga è un'iterazione (l'ultima riga con \(l>r\) è il test finale del ciclo).-
\(m=23\):
chiamata / iterazione \(l\) \(r\) \(mid\) \(a[mid]\) 1 1 10 5 \(16<23\) \(\Rightarrow\) \(l=6\) 2 6 10 8 \(56>23\) \(\Rightarrow\) \(r=7\) 3 6 7 6 \(23=m\) \(\Rightarrow\) restituisce \(6\) -
\(m=4\):
chiamata / iterazione \(l\) \(r\) \(mid\) \(a[mid]\) 1 1 10 5 \(16>4\) \(\Rightarrow\) \(r=4\) 2 1 4 2 \(5>4\) \(\Rightarrow\) \(r=1\) 3 1 1 1 \(2<4\) \(\Rightarrow\) \(l=2\) 4 2 1 – \(l>r\) \(\Rightarrow\) restituisce \(-1\)
Per \(m=23\)
RecBSesegue \(3\) chiamate eItBS\(3\) iterazioni; per \(m=4\)RecBSesegue \(4\) chiamate eItBS\(3\) iterazioni più il test finale. -
-
Tempo. Le versioni iterative passano per esattamente gli stessi valori dei parametri di quelle ricorsive, con un numero costante di passi per ogni chiamata / iterazione. Quindi il numero di passi ha lo stesso ordine di crescita: \(\Theta(\log\min(a,b))\) nel caso peggiore per l'MCD e \(\Theta(\log_2 n)\) nel caso peggiore per la ricerca binaria. Le versioni ricorsive pagano soltanto il sovraccarico (costante) delle chiamate.
Spazio. Le versioni iterative usano \(\Theta(1)\) di memoria aggiuntiva (i parametri e le variabili \(r\) o \(mid\)). Nelle versioni ricorsive tutte le chiamate restano attive finché non si raggiunge il caso base, quindi la pila contiene tanti frame quanti sono la profondità della ricorsione.
-
GCDRec\((89,55)\) passa per le coppie\[ (89,55),(55,34),(34,21),(21,13),(13,8),(8,5),(5,3),(3,2),(2,1),(1,0), \]quindi la profondità della ricorsione è \(10\). Numeri di Fibonacci consecutivi sono il caso peggiore dell'algoritmo di Euclide: la profondità è \(\Theta(\log\min(a,b))\).
-
Per
RecBSsu \(n\) elementi la profondità massima è \(\lfloor\log_2 n\rfloor+2\) (le dimensioni dei sotto-array sono al più \(n,\lfloor n/2\rfloor,\dots,1\), più l'ultima chiamata sul sotto-array vuoto). Per \(n=10^6\): \(\lfloor\log_2 10^6\rfloor+2=19+2=21\).
Per entrambi gli algoritmi, la ricorsione non dà alcun guadagno in tempo e una (piccola, logaritmica) perdita in spazio. Poiché sono ricorsivi in coda, la ricorsione può essere eliminata meccanicamente, ottenendo cicli con \(\Theta(1)\) di memoria aggiuntiva.
-
Esercizio 18.2: Algoritmi ricorsivi contro iterativi (E2)
Consideriamo i seguenti algoritmi ricorsivi e dimensioni dell'input.
-
(a) \(\texttt{Fact}(n)\): restituisce \(1\) se \(n=0\), altrimenti \(n\cdot\texttt{Fact}(n-1)\); con \(n=1000\).
-
(b) \(\texttt{SumDecrease}(a,n)\): restituisce \(0\) se \(n=0\), altrimenti \(\texttt{SumDecrease}(a,n-1)+a[n]\); con \(n=10^6\).
-
(c) \(\texttt{SumHalving}(a,l,r)\): restituisce \(a[l]\) se \(l=r\), altrimenti \(\texttt{SumHalving}(a,l,mid)+\texttt{SumHalving}(a,mid+1,r)\) con \(mid=\lfloor(l+r)/2\rfloor\); su un array di \(n=10^6\) elementi.
-
(d) Merge Sort su un array di \(n=1000\) elementi (caso base: sotto-array di un elemento).
-
(e) Torre di Hanoi con \(n=20\) dischi (caso base: \(0\) dischi).
-
(f) \(\texttt{FibRec}(n)\): restituisce \(n\) se \(n\le 1\), altrimenti \(\texttt{FibRec}(n-1)+\texttt{FibRec}(n-2)\); con \(n=30\).
-
(g) Potenza veloce ricorsiva \(x^n\) (caso base: \(n=0\); chiamata ricorsiva su \(\lfloor n/2\rfloor\)); con \(n=10^9\).
Supponiamo che ogni frame della pila occupi \(48\) byte e che la pila delle chiamate abbia una dimensione di \(8\) MB \(=8\cdot 2^{20}=8\,388\,608\) byte.
Domande
-
Determinare la profondità della ricorsione di ciascun algoritmo per la dimensione dell'input data.
-
Determinare la memoria occupata dalla pila delle chiamate al suo massimo, e dire quali esecuzioni causano uno stack overflow.
-
Determinare il massimo \(n\) per cui \(\texttt{SumDecrease}(a,n)\) non causa uno stack overflow, e la profondità di \(\texttt{SumHalving}\) per lo stesso \(n\).
-
Per \(\texttt{FibRec}(30)\), confrontare la profondità della ricorsione con il numero totale di chiamate. Il problema di questo algoritmo è lo spazio o il tempo?
Soluzione
-
La profondità della ricorsione è il massimo numero di chiamate attive contemporaneamente, cioè il numero di chiamate sul cammino più lungo dalla radice a una foglia dell'albero di ricorsione.
-
(a) Le chiamate attive sono \(\texttt{Fact}(1000),\texttt{Fact}(999),\dots,\texttt{Fact}(0)\): profondità \(n+1=1001\).
-
(b) Le chiamate attive sono \(\texttt{SumDecrease}(a,10^6),\dots,\texttt{SumDecrease}(a,0)\): profondità \(n+1=1\,000\,001\).
-
(c) Il sotto-array più grande al livello \(t\) dell'albero ha \(\lceil n/2^t\rceil\) elementi, che diventa \(1\) per \(t=\lceil\log_2 n\rceil\). Profondità \(\lceil\log_2 n\rceil+1\); poiché \(2^{19}=524\,288<10^6\le 2^{20}=1\,048\,576\), si ha \(\lceil\log_2 10^6\rceil=20\) e la profondità è \(21\).
-
(d) Come in (c): \(\lceil\log_2 1000\rceil+1=10+1=11\) (poiché \(2^9=512<1000\le 2^{10}=1024\)).
-
(e) Le chiamate attive sono \(\texttt{Hanoi}(20),\texttt{Hanoi}(19),\dots,\texttt{Hanoi}(0)\): profondità \(n+1=21\).
-
(f) Il cammino più lungo è \(\texttt{FibRec}(30),\texttt{FibRec}(29),\dots,\texttt{FibRec}(1)\): profondità \(n=30\).
-
(g) L'esponente assume i valori \(n,\lfloor n/2\rfloor,\dots,1,0\), cioè la profondità è \(\lfloor\log_2 n\rfloor+2\). Poiché \(2^{29}=536\,870\,912\le 10^9<2^{30}\), si ha \(\lfloor\log_2 10^9\rfloor=29\) e la profondità è \(31\).
-
-
La memoria della pila al suo massimo è \(48\) byte per la profondità:
algoritmo profondità pila (byte) overflow? (a) Fact\((1000)\)\(1\,001\) \(48\,048\) no (b) SumDecrease, \(n=10^6\)\(1\,000\,001\) \(48\,000\,048\) sì (c) SumHalving, \(n=10^6\)\(21\) \(1\,008\) no (d) Merge Sort, \(n=1000\) \(11\) \(528\) no (e) Hanoi, \(n=20\) \(21\) \(1\,008\) no (f) FibRec\((30)\)\(30\) \(1\,440\) no (g) potenza veloce, \(n=10^9\) \(31\) \(1\,488\) no Solo (b) causa uno stack overflow: \(48\,000\,048>8\,388\,608\) byte (circa \(46\) MB contro \(8\) MB).
-
La pila contiene al più \(\lfloor 8\,388\,608/48\rfloor=174\,762\) frame. \(\texttt{SumDecrease}(a,n)\) richiede \(n+1\) frame, quindi non va in overflow se e solo se
\[ n+1\le 174\,762 \quad\Longleftrightarrow\quad n\le 174\,761. \]Per \(n=174\,761\), poiché \(2^{17}=131\,072<n\le 2^{18}=262\,144\), \(\texttt{SumHalving}\) ha profondità \(\lceil\log_2 n\rceil+1=18+1=19\), cioè \(912\) byte di pila. Entrambi gli algoritmi eseguono \(\Theta(n)\) passi: la versione per dimezzamento usa una pila esponenzialmente più piccola.
-
\(\texttt{FibRec}(30)\) ha profondità \(30\) (solo \(1\,440\) byte di pila), ma esegue
\[ 2F(31)-1=2\cdot 1\,346\,269-1=2\,692\,537 \]chiamate. Il problema di
FibRecè il tempo, \(\Theta(\varphi^n)\), non lo spazio, \(\Theta(n)\): la profondità della ricorsione e il numero di chiamate sono due quantità molto diverse.Una profondità della ricorsione \(\Theta(\log_2 n)\) non è mai un problema in pratica; una profondità \(\Theta(n)\) (decremento di uno) causa uno stack overflow già per \(n\) dell'ordine di \(10^5\)–\(10^6\), mentre il ciclo corrispondente usa \(\Theta(1)\) di memoria.
Esercizio 18.3: Algoritmi ricorsivi contro iterativi (E3)
Consideriamo i seguenti quattro algoritmi ricorsivi.
\(\texttt{MaxHalf}(a,mid+1,r))\)
\(+\,\texttt{Trib}(n-3)\)
MaxDec e MaxHalf calcolano il massimo di un array \(a[1..n]\) (MaxHalf si chiama come \(\texttt{MaxHalf}(a,1,n)\)); PowDec calcola \(x^n\); Trib calcola i numeri di Tribonacci \(T(0)=0\), \(T(1)=0\), \(T(2)=1\), \(T(n)=T(n-1)+T(n-2)+T(n-3)\).
Domande
Per ciascuno dei quattro algoritmi:
-
scrivere l'equazione di ricorrenza del numero di passi \(S(n)\) e determinarne l'ordine di crescita;
-
determinare la profondità della ricorsione e la memoria aggiuntiva usata;
-
indicare il miglior algoritmo iterativo visto nel corso per lo stesso problema, con il suo tempo e il suo spazio;
-
decidere se la ricorsione guadagna, è equivalente o perde, giustificando la risposta.
Soluzione
A: MaxDecrease¶
-
Ogni chiamata esegue un numero costante \(c\) di passi oltre alla chiamata ricorsiva su \(n-1\) elementi:
\[ S(n)=S(n-1)+c,\qquad S(1)=d. \]Srotolando, \(S(n)=d+c\,(n-1)=\Theta(n)\).
-
Le chiamate \(\texttt{MaxDec}(a,n),\dots,\texttt{MaxDec}(a,1)\) sono tutte attive contemporaneamente: profondità \(n\), memoria aggiuntiva \(\Theta(n)\). L'algoritmo non è ricorsivo in coda (il massimo viene calcolato dopo che la chiamata termina).
-
FindMaximum(capitolo «Tempo di esecuzione»): un ciclo con \(S(n)=4n-1=\Theta(n)\) passi e \(\Theta(1)\) di memoria aggiuntiva. -
UGUALE tempo, PERDITA in spazio: la ricorsione per decremento di uno è un ciclo sotto mentite spoglie, con un sovraccarico delle chiamate e un rischio di stack overflow per \(n\) grande.
B: MaxHalving¶
-
Due chiamate ricorsive sulle metà, più un numero costante di passi:
\[ S(n)=2S(n/2)+\Theta(1). \]Master theorem: \(a=2\), \(b=2\), \(f(n)=\Theta(1)\); \(n^{\log_b a}=n^{\log_2 2}=n\). Poiché \(f(n)=\Theta(1)=O(n^{1-\varepsilon})\) con \(\varepsilon=1\), il caso 1 dà \(S(n)=\Theta(n)\).
-
Profondità \(\lceil\log_2 n\rceil+1\), memoria aggiuntiva \(\Theta(\log_2 n)\).
-
Di nuovo
FindMaximum: tempo \(\Theta(n)\), spazio \(\Theta(1)\). -
UGUALE tempo, piccola PERDITA in spazio (\(\Theta(\log_2 n)\)). Il divide et impera qui non aiuta: l'albero di ricorsione ha \(2n-1\) nodi, ciascuno con lavoro costante, quindi il lavoro totale è ancora \(\Theta(n)\). Nessun algoritmo può fare di meglio, perché ogni elemento deve essere esaminato almeno una volta. Il divide et impera dà un guadagno solo quando riduce il lavoro totale, come nel Merge Sort, in cui la combinazione delle metà costa \(\Theta(n)\) invece dei \(\Theta(n^2)\) degli algoritmi iterativi.
C: PowerDecrease¶
-
\(S(n)=S(n-1)+c\), \(S(0)=d\) \(\Rightarrow\) \(S(n)=c\,n+d=\Theta(n)\).
-
Profondità \(n+1\) (chiamate con esponente \(n,n-1,\dots,0\)), memoria aggiuntiva \(\Theta(n)\).
-
Il ciclo con \(n\) moltiplicazioni ha tempo \(\Theta(n)\) e spazio \(\Theta(1)\); l'algoritmo iterativo square-and-multiply
PowerItha tempo \(\Theta(\log_2 n)\) e spazio \(\Theta(1)\). -
PERDITA: stesso tempo del semplice ciclo e \(\Theta(n)\) di memoria; rispetto al ciclo square-and-multiply è addirittura esponenzialmente più lento in termini di \(\log_2 n\). La ricorsione conviene per la potenza solo quando dimezza l'esponente (potenza veloce): \(S(n)=S(n/2)+\Theta(1)=\Theta(\log_2 n)\).
D: Tribonacci¶
-
Sia \(C(n)\) il numero di chiamate; allora \(C(0)=C(1)=C(2)=1\) e
\[ C(n)=1+C(n-1)+C(n-2)+C(n-3),\qquad n\ge 3, \]e \(S(n)=\Theta(C(n))\), poiché ogni chiamata esegue un numero costante di passi. La funzione \(C\) è non decrescente, quindi per \(n\ge 3\)
\[ C(n)\ge 3\,C(n-3)\ge 3^2C(n-6)\ge\cdots\ge 3^{\lfloor n/3\rfloor}. \]Quindi \(S(n)=\Omega(3^{n/3})=\Omega(1.442^n)\): l'algoritmo è esponenziale. (Per esempio \(C(10)=289\), \(C(20)=128\,287\), \(C(25)=2\,700\,421\).) Allo stesso modo, \(C(n)\le 3C(n-1)+1\) dà \(S(n)=O(3^n)\).
-
Il cammino più lungo nell'albero di ricorsione è \(\texttt{Trib}(n),\texttt{Trib}(n-1),\dots,\texttt{Trib}(2)\): profondità \(n-1\), memoria aggiuntiva \(\Theta(n)\).
-
Un ciclo che calcola \(T(3),T(4),\dots,T(n)\) tenendo solo gli ultimi tre valori in tre variabili: tempo \(\Theta(n)\) e spazio \(\Theta(1)\).
-
Enorme PERDITA: i sottoproblemi si sovrappongono (per esempio \(\texttt{Trib}(n-1)\) e \(\texttt{Trib}(n-2)\) chiamano entrambe \(\texttt{Trib}(n-3)\)), quindi gli stessi valori vengono ricalcolati un numero esponenziale di volte. La memoizzazione o il ciclo bottom-up (programmazione dinamica) riportano il tempo a \(\Theta(n)\).
| ricorsivo | miglior iterativo | esito | ||
|---|---|---|---|---|
| tempo | spazio | tempo, spazio | ||
| A | \(\Theta(n)\) | \(\Theta(n)\) | \(\Theta(n)\), \(\Theta(1)\) | stesso tempo, perdita in spazio |
| B | \(\Theta(n)\) | \(\Theta(\log_2 n)\) | \(\Theta(n)\), \(\Theta(1)\) | stesso tempo, piccola perdita |
| C | \(\Theta(n)\) | \(\Theta(n)\) | \(\Theta(\log_2 n)\), \(\Theta(1)\) | perdita |
| D | esponenziale | \(\Theta(n)\) | \(\Theta(n)\), \(\Theta(1)\) | enorme perdita |
Esercizio 18.4: Algoritmi ricorsivi contro iterativi (E4)
I coefficienti binomiali soddisfano, per \(0<k<n\), l'equazione di ricorrenza (regola di Pascal)
Consideriamo il seguente algoritmo ricorsivo.
Domande
-
Disegnare l'albero di ricorsione di \(\texttt{BinomRec}(4,2)\), contare le chiamate e indicare i sottoproblemi che vengono risolti più di una volta.
-
Dimostrare che il numero di chiamate eseguite da \(\texttt{BinomRec}(n,k)\) è \(N(n,k)=2\binom{n}{k}-1\).
-
Calcolare \(N(10,5)\), \(N(30,15)\) e \(N(40,20)\), e stimare il tempo richiesto da \(\texttt{BinomRec}(40,20)\) su un calcolatore che esegue \(10^9\) chiamate al secondo.
-
Scrivere una versione con memoizzazione
BinomMemoe dimostrare che, per \(0<k<n\), esegue esattamente \(2k(n-k)+1\) chiamate. Calcolare questo numero per i tre casi della domanda 3. -
Scrivere un algoritmo iterativo che calcola \(\binom{n}{k}\) riga per riga (triangolo di Pascal), e darne il tempo e lo spazio.
Soluzione
-
Ogni nodo è una chiamata, etichettata con \((n,k)\); le foglie sono casi base (\(k=0\) oppure \(k=n\)) e restituiscono \(1\).
L'albero ha \(11\) nodi, cioè \(11\) chiamate, e \(6=\binom{4}{2}\) foglie. Il sottoproblema \((2,1)\) viene risolto due volte (e così le sue sotto-chiamate \((1,0)\) e \((1,1)\)).
-
Ogni chiamata è o una foglia (caso base, restituisce \(1\)) o un nodo interno con esattamente due figli. Il valore restituito da una chiamata è la somma dei valori restituiti dalle foglie del suo sotto-albero, cioè il numero delle sue foglie. Quindi l'albero di \(\texttt{BinomRec}(n,k)\) ha \(\binom{n}{k}\) foglie.
In un albero in cui ogni nodo interno ha esattamente due figli, il numero di nodi interni è il numero di foglie meno uno (per induzione: una singola foglia ha \(0\) nodi interni; unendo due alberi di questo tipo con \(L_1\) e \(L_2\) foglie sotto una nuova radice si ottengono \((L_1-1)+(L_2-1)+1=(L_1+L_2)-1\) nodi interni). Quindi
\[ N(n,k)=\binom{n}{k}+\left(\binom{n}{k}-1\right)=2\binom{n}{k}-1. \]Per esempio \(N(4,2)=2\cdot 6-1=11\), come nella domanda 1.
3.
$$
N(10,5)=2\cdot 252-1=503,\qquad N(30,15)=2\cdot 155\,117\,520-1=310\,235\,039,
$$
$$
N(40,20)=2\cdot 137\,846\,528\,820-1=275\,693\,057\,639.
$$
A $10^9$ chiamate al secondo, $\texttt{BinomRec}(40,20)$ richiede circa $275.7$ secondi, cioè più di $4.5$ minuti, per calcolare un numero che si può ottenere con qualche centinaio di addizioni (si vedano le domande 4 e 5). Poiché $\binom{2m}{m}\ge \frac{4^m}{2m+1}$, il numero di chiamate cresce in modo esponenziale.
-
Memorizziamo i valori già calcolati in una tabella \(memo\), indicizzata dalla coppia \((i,j)\):
Algoritmo: BinomMemo
Input: Numeri naturali \(0\le k\le n\) e una tabella \(memo\) le cui voci sono tutte non definiteOutput: Il coefficiente binomiale \(\binom{n}{k}\)1function BinomMemo(n, k, memo)2if \(k = 0\) or \(k = n\) then3return \(1\)4end5if \(memo[n][k]\) è non definita then6\(memo[n][k] \leftarrow \texttt{BinomMemo}(n-1,k-1,memo)+\texttt{BinomMemo}(n-1,k,memo)\)7end8return \(memo[n][k]\)9endNumero di chiamate. A partire da \((n,k)\), una chiamata raggiunge le coppie \((n-s,\,k-t)\), dove \(s\) è il numero di passi verso il basso e \(t\le s\) è il numero di questi passi in cui \(k\) diminuisce. Sia \(u=s-t\) il numero di passi in cui \(k\) non diminuisce. Una coppia è interna (non è un caso base) se \(k-t\ge 1\) e \((n-s)-(k-t)\ge 1\), cioè
\[ 0\le t\le k-1 \qquad\text{e}\qquad 0\le u\le n-k-1, \]quindi ci sono esattamente \(k(n-k)\) sottoproblemi interni, e tutti vengono raggiunti. Il corpo di ciascun sottoproblema interno (le due chiamate ricorsive) viene eseguito solo la prima volta che viene chiamato; le volte successive il valore viene letto da \(memo\). Ogni chiamata, tranne la prima, è effettuata dal corpo di un sottoproblema interno, che effettua esattamente \(2\) chiamate. Quindi il numero di chiamate è
\[ 1+2k(n-k). \]Per i tre casi:
\[ (10,5):~1+2\cdot 25=51,\qquad (30,15):~1+2\cdot 225=451,\qquad (40,20):~1+2\cdot 400=801, \]contro le \(503\), \(310\,235\,039\) e \(275\,693\,057\,639\) chiamate di
BinomRec. Il tempo è \(\Theta(k(n-k))\) e la memoria è \(\Theta(k(n-k))\) per la tabella più \(\Theta(n)\) per la pila (profondità \(\le n\)). -
La riga \(i\) del triangolo di Pascal si calcola dalla riga \(i-1\). Tenendo un solo array \(c[0..k]\) e aggiornandolo da destra a sinistra (in modo che \(c[j-1]\) contenga ancora il valore della riga precedente quando si aggiorna \(c[j]\)):
Algoritmo: BinomIt
Input: Due numeri naturali \(n\) e \(k\) con \(0\le k\le n\)Output: Il coefficiente binomiale \(\binom{n}{k}\)1function BinomIt(n, k)2\(c[0] := 1\); \(c[1..k] := 0\)3\(i := 1\)4while \(i \le n\) do5\(j := \min(i,k)\)6while \(j \ge 1\) do7\(c[j] \leftarrow c[j] + c[j-1]\)8\(j \leftarrow j-1\)9end10\(i \leftarrow i+1\)11end12return \(c[k]\)13endDopo la \(i\)-esima iterazione del ciclo esterno, \(c[j]=\binom{i}{j}\) per \(0\le j\le \min(i,k)\). Il ciclo interno viene eseguito al più \(k\) volte per ciascuna delle \(n\) righe: tempo \(O(n\,k)\) (e \(\Theta(nk)\) quando \(k\le n/2\)), memoria aggiuntiva \(\Theta(k)\), nessuna ricorsione. Per \((40,20)\) l'algoritmo esegue \(\sum_{i=1}^{40}\min(i,20)=210+400=610\) addizioni.
La ricorsione ingenua è esponenziale perché i sottoproblemi si sovrappongono; la memoizzazione (top-down) e il triangolo di Pascal iterativo (bottom-up) risolvono ogni sottoproblema una sola volta, con un numero polinomiale di passi. Questa è l'idea della programmazione dinamica.
Esercizio 18.5: Algoritmi ricorsivi contro iterativi (E5)
Consideriamo i due seguenti algoritmi per i numeri di Fibonacci (\(F(0)=0\), \(F(1)=1\), \(F(n)=F(n-1)+F(n-2)\)).
\(+\,\texttt{FibRec}(n-2)\)
Supponiamo che ogni riga eseguita conti come un passo (la riga 5 conta come un passo, oltre ai passi delle due sotto-chiamate) e che il calcolatore esegua \(10^9\) passi al secondo.
Domande
-
Determinare il numero esatto di passi \(S_{it}(n)\) eseguiti da
FibItper \(n\ge 2\). -
Determinare il numero esatto di passi \(S_{rec}(n)\) eseguiti da
FibRec, usando il fatto cheFibRec\((n)\) esegue \(2F(n+1)-1\) chiamate. -
Stimare i tempi di esecuzione dei due algoritmi per \(n=50\), sapendo che
\[ F(51)=20\,365\,011\,074. \] -
Stimare il tempo di esecuzione di
FibRecper \(n=60\) e \(n=100\), sapendo che\[ F(61)=2\,504\,730\,781\,961 \qquad\text{e}\qquad F(101)\approx 5.73\cdot 10^{20}. \] -
Determinare il massimo \(n\) per cui
FibRectermina entro un'ora, sapendo che\[ F(58)=591\,286\,729\,879\qquad\text{e}\qquad F(59)=956\,722\,026\,041. \]
Soluzione
-
Per \(n\ge 2\) il numero di volte che ciascuna riga di
FibItviene eseguita è:-
La riga 2 viene eseguita \(1\) volta (il return nella riga successiva non viene eseguito)
-
La riga 8 viene eseguita \(n\) volte (\(i=2,3,\dots,n+1\))
-
Le righe 9, 10, 11, 12 vengono eseguite \(n-1\) volte ciascuna
-
La riga 14 viene eseguita \(1\) volta
Quindi
\[ S_{it}(n)=1+3+n+4(n-1)+1=5n+1=\Theta(n). \] -
-
Ogni chiamata di
FibRecesegue esattamente \(2\) passi propri: la riga 2 e poi o la riga 3 (caso base) o la riga 5. Poiché \(\texttt{FibRec}(n)\) esegue \(2F(n+1)-1\) chiamate,\[ S_{rec}(n)=2\big(2F(n+1)-1\big)=4F(n+1)-2=\Theta(\varphi^n). \] -
Per \(n=50\):
\[ S_{it}(50)=5\cdot 50+1=251 \text{ passi}\quad\Longrightarrow\quad \frac{251}{10^9}\text{ s}=251\text{ ns}, \]\[ S_{rec}(50)=4\cdot 20\,365\,011\,074-2=81\,460\,044\,294 \text{ passi}\quad\Longrightarrow\quad \approx 81.5 \text{ s}. \]L'algoritmo ricorsivo è circa \(3\cdot 10^8\) volte più lento.
-
Per \(n=60\):
\[ S_{rec}(60)=4\cdot 2\,504\,730\,781\,961-2=10\,018\,923\,127\,842\text{ passi}\quad\Longrightarrow\quad\approx 10\,019\text{ s}\approx 2.8\text{ ore}. \]Per \(n=100\):
\[ S_{rec}(100)\approx 4\cdot 5.73\cdot 10^{20}\approx 2.29\cdot 10^{21}\text{ passi}\quad\Longrightarrow\quad\approx 2.29\cdot 10^{12}\text{ s}. \]Poiché un anno ha circa \(3.156\cdot 10^7\) secondi, questo corrisponde a circa \(7.3\cdot 10^4\) anni. Nel frattempo,
FibIt\((100)\) esegue \(501\) passi, cioè circa mezzo microsecondo. -
Un'ora corrisponde a \(3600\cdot 10^9=3.6\cdot 10^{12}\) passi. Servono
\[ 4F(n+1)-2\le 3.6\cdot 10^{12}\quad\Longleftrightarrow\quad F(n+1)\le 9\cdot 10^{11}+0.5. \]Poiché \(F(58)=591\,286\,729\,879\le 9\cdot 10^{11}<F(59)=956\,722\,026\,041\), la condizione vale per \(n+1\le 58\). Il massimo \(n\) è \(n=57\), con
\[ S_{rec}(57)=4\cdot 591\,286\,729\,879-2=2\,365\,146\,919\,514\text{ passi}\approx 39\text{ minuti}, \]mentre \(n=58\) richiede già \(4F(59)-2\approx 3.83\cdot 10^{12}\) passi, cioè circa \(64\) minuti. Ogni volta che \(n\) aumenta di \(1\), il tempo di
FibRecviene moltiplicato per circa \(\varphi\approx 1.618\): un calcolatore \(10\) volte più veloce permetterebbe di aumentare \(n\) solo di \(\log_\varphi 10\approx 4.8\).Con un algoritmo esponenziale, calcolatori più veloci non aiutano: la dimensione dei problemi risolvibili cresce solo di una costante additiva. L'algoritmo iterativo, con \(5n+1\) passi, calcola \(F(57)\) in meno di \(0.3\) microsecondi.