







Studia grazie alle numerose risorse presenti su Docsity
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Prepara i tuoi esami
Studia grazie alle numerose risorse presenti su Docsity
Prepara i tuoi esami con i documenti condivisi da studenti come te su Docsity
Trova i documenti specifici per gli esami della tua università
Preparati con lezioni e prove svolte basate sui programmi universitari!
Rispondi a reali domande d’esame e scopri la tua preparazione
Riassumi i tuoi documenti, fagli domande, convertili in quiz e mappe concettuali
Studia con prove svolte, tesine e consigli utili
Togliti ogni dubbio leggendo le risposte alle domande fatte da altri studenti come te
Esplora i documenti più scaricati per gli argomenti di studio più popolari
Ottieni i punti per scaricare
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Dispense sulla Complessità Computazionale - parte 1
Tipologia: Appunti
1 / 13
Questa pagina non è visibile nell’anteprima
Non perderti parti importanti!








Premessa
Un aspetto importante che non può essere trascurato nella progettazione di un algoritmo è la caratterizzazione dell’efficienza con la quale l’algoritmo stesso viene eseguito su un elaboratore. L’efficienza è, tra l’altro, da intendersi non solo come tempo di esecuzione, ma anche in funzione dell’utilizzo delle altre risorse del sistema di elaborazione, come, ad esempio, la memoria centrale. In questo capitolo viene introdotto un modello per la misura dell’efficienza di un programma. Il modello permette di caratterizzare l’efficienza di esecuzione ( complessità computazionale o temporale ) e l’efficienza in termini di memoria impiegata ( complessità spaziale ) in maniera indipendente dal sistema di elaborazione sul quale si intende eseguire il programma in oggetto.
La misura dell’efficienza
L’efficienza di un algoritmo (e da ora in poi, se non esplicitamente indicato, parleremo indistintamente di efficienza spaziale o di efficienza computazionale) non può essere misurata semplicemente, in quanto dipendente da un numero elevatissimo di fattori. Dato un programma scritto in linguaggio sorgente l’efficienza dell’esecuzione dipende dal compilatore impiegato che effettua una traduzione in codice macchina che può essere più o meno efficiente, dalla struttura del sistema di elaborazione che può risultare più adeguata ad eseguire rapidamente alcune classi di istruzioni, dalla potenza della CPU del sistema di elaborazione, e via di seguito. E’ quindi importante notare esplicitamente che il tentativo di caratterizzare l’efficienza non può che prescindere dalla architettura di sistema di elaborazione adottato, e concentrarsi sugli aspetti che invece sono strettamente legati alla natura e struttura dell’algoritmo prescelto. Nel seguito di questo paragrafo tenteremo di definire in che ottica avviene la caratterizzazione dell’efficienza e nei paragrafi seguenti daremo degli strumenti operativi per valutare l’efficienza di un generico algoritmo considerato. Supponiamo di considerare un algoritmo A e di voler caratterizzare la sua complessità computazionale. Allora, se indichiamo con T il tempo impiegato dall’algoritmo, ed n la dimensione della struttura dati d su cui opera A , noi siamo interessati a caratterizzare la funzione: T = T ( n ). A titolo di esempio se A è un programma che realizza l’ordinamento di un vettore, è chiaro che n è il riempimento del vettore che influenza , come noto, il tempo di esecuzione di A. Spesso un algoritmo ha un tempo di esecuzione che dipende dalla dimensione di più strutture dati. Si pensi ad un programma che realizza la fusione di due vettori ordinati; in questo caso è evidente che la funzione T dipende sia dalla lunghezza del primo vettore che dalla lunghezza del secondo e quindi è una funzione di più variabili. Nel corso del capitolo corrente il numero di variabili da cui dipende T non influenza i risultati e le considerazioni che tracceremo e, di conseguenza, per motivi di semplicità faremo riferimento ad una funzione T di una sola variabile. Visto che è necessario prescindere dal particolare compilatore o dalla architettura del calcolatore su cui verrà eseguito l’algoritmo, il modello che si assumerà per il calcolo del tempo T ( n ) è il modello computazionale tipicamente indicato con random-access machine ( RAM ). Nel modello RAM si considera un generico mono-processore in cui tutte le istruzioni sono eseguite una dopo l’altra, senza nessun tipo di parallelismo. Inoltre si assumerà che le istruzioni semplici del linguaggio (istruzioni di assegnamento, istruzioni con operatori aritmetici, relazionali o logici) abbiano un costo unitario in termini di tempo impiegato per la loro esecuzione. Su questa base si procederà al calcolo del tempo complessivo T impiegato da tutte le istruzioni dall’algoritmo, tenendo conto ovviamente del fatto che tale tempo dovrà dipendere dalla dimensione n dei dati, visto che siamo interessati alla valutazione di T ( n ). Un’altra considerazione importante che è qui opportuno fare, è che il tempo T ( n ) potrebbe variare sensibilmente in dipendenza dello specifico valore assunto dalla struttura dati in ingresso.
terza fornisce un limite “stretto”. In alcuni contesti è difficile trovare un limite stretto per l’andamento delle funzioni, per cui ci si accontenta di un limite meno preciso. Tali notazioni furono introdotte in un classico articolo di Knuth del ‘76; tuttavia in molti testi viene riportata una sola di queste notazioni, che in genere è la O. Tale scelta è dettata da ragioni di semplicità, sia per non introdurre troppe notazioni (cosa che potrebbe confondere le idee al lettore), sia perché in genere ciò che serve è una limitazione superiore del tempo impiegato da un dato algoritmo e, in quest’ottica, dimostrare che un algoritmo appartiene alla classe O è, come detto, più semplice che dimostrare l’appartenenza alla classe Θ. Vi è inoltre un ultimo punto che vale la pena rimarcare: una notazione asintotica deve, ove possibile essere semplice, tant’è che, come vedremo, l’utilizzo di tali notazioni ci consente di trascurare costanti moltiplicative e termini di ordine inferiore. Orbene, spesso, volendo trovare un limite stretto, è necessario ricorrere a funzioni più complesse di quelle che si potrebbero adottare se ci si limitasse a considerare un limite lasco. Più in generale, se si vuole caratterizzare un algoritmo con un limite stretto può essere necessario dover considerare separatamente il caso migliore e quello peggiore, mentre se ci limita a cercare un limite superiore basta trovarlo per il solo caso peggiore ed evidentemente tale limite sarà valido per l’algoritmo stesso; quest’ultima considerazione può essere un’ulteriore giustifica all’adozione in alcuni testi di una sola notazione, la O. Passiamo ora ad introdurre le definizioni delle tre notazioni asintotiche.
O(g(n))
Date due costanti positive c ed n 0 , una funzione f ( n ) appartiene all’insieme O ( g ( n )), ovvero f ( n ) ∈ O ( g ( n )) se: ∃ c , n 0 > 0 | ∀ n > n 0 , 0 ≤ f ( n ) ≤ c g ( n ) ciò significa che, a partire da una certa dimensione n 0 del dato di ingresso, la funzione g ( n ) maggiora la funzione f ( n ). Possiamo quindi anche dire che la g ( n ) rappresenta un limite superiore per la f ( n ). Tale limite non è però “stretto”. Supponiamo infatti di avere una funzione f ( n ) ∈ O ( n^2 ). Ciò implica che la f ( n ), da un certo punto in poi, è maggiorata da n^2 : se ciò è vero, anche n^3 maggiorerà la f ( n ), e quindi f ( n ) appartiene anche a O ( n^3 ). E’ evidente che quest’ultima appartenenza implica un limite sicuramente meno stretto del precedente, ma rimane comunque formalmente ineccepibile. In generale quando si introduce la notazione O (soprattutto in quei contesti in cui è l’unica notazione presentata) si cerca comunque di individuare un limite superiore il più possibile stretto, fermo restando che tale limite potrebbe essere raffinato ulteriormente. Si noti inoltre che il comportamento per tutti gli n < n 0 non è assolutamente tenuto in conto, per cui potranno esserci dei valori di n < n 0 tali che f ( n ) > g ( n ), come evidenziato anche in Fig. 1b.
Ω(g(n))
Date due costanti positive c ed n 0 , una funzione f ( n ) appartiene all’insieme Ω ( g ( n )), ovvero f ( n ) ∈ Ω ( g ( n )) se: ∃ c , n 0 > 0 | ∀ n > n 0 , f ( n ) ≥ c g ( n ) ≥ 0 ovvero, a partire da una certa dimensione n 0 del dato di ingresso, la funzione g ( n ) è maggiorata dalla funzione f ( n ). Anche in questo caso il limite non è “stretto”, e valgono sostanzialmente tutte le considerazioni fatte per la notazione O ( n ) (cfr. anche Fig. 1c).
Θ(g(n))
Date tre costanti positive c 1 , c 2 ed n 0 , una funzione f ( n ) appartiene all’insieme Θ ( g ( n )) , ovvero f ( n ) ∈ Θ ( g ( n )) se: ∃ c 1 , c 2 , n 0 > 0 | ∀ n > n 0 , 0 ≤ c 1 g ( n ) ≤ f ( n ) ≤ c 2 g ( n ) ovvero a partire da una certa dimensione n 0 del dato di ingresso, la funzione f ( n ) è compresa tra c 1 g ( n ) e c 2 g ( n ). In maniera informale si può dire che, al crescere di n , la f ( n ) e la g ( n ) crescono “allo stesso modo” (vedi Fig. 1a). A differenza delle notazioni precedenti, dunque, se una funzione appartiene ad esempio a Θ ( n^2 ), non potrà appartenere anche a Θ ( n ), né tantomeno a Θ ( n^3 )
Fig. 1 : Esempi di funzioni f ( n ) che appartengono rispettivamente agli insiemi ( a ) Θ ( g ( n )), ( b ) O ( g ( n )) e ( c ) Ω ( g ( n )). Si noti come le relazioni di diseguaglianza che compaiono nelle definizioni sono soddisfatte solo a partire da un certo valore n 0 della dimensione del dato di ingresso.
Implicazioni
Dalle definizioni suvviste discende il seguente teorema (che non dimostreremo): Date due funzioni f ( n ) e g ( n ), una funzione f ( n ) ∈ Θ ( g ( n )) se e solo se f ( n ) ∈ O ( g ( n )) e f ( n ) ∈
Come detto l’utilizzo corretto delle notazioni asintotiche è in espressioni del tipo f ( n ) ∈ O ( g ( n )). E’ tuttavia prassi comune ammettere usi del tipo f ( n ) = O ( n^2 ). Inoltre in alcuni casi è molto utile dal punto di vista notazionale, anche se formalmente scorretto, poter sommare due notazioni asintotiche, cioè ammettere espressioni del tipo T ( n ) = Θ ( n^2 ) + Θ ( n ). Quest’ultima espressione deve evidentemente intendersi come: T ( n ) è uguale alla somma di una qualunque funzione che appartiene all’insieme Θ ( n^2 ) più una qualunque funzione che appartiene all’insieme Θ ( n ).
Come detto all’inizio del capitolo, non è corretto considerare solo il caso migliore per valutare il comportamento asintotico di un algoritmo. Solo in virtù del fatto che, ad es., il BubbleSort nel caso migliore ha una complessità lineare non basta per affermare che l’algoritmo BubbleSort appartenga a Θ ( n ) oppure a O ( n ). Per lo stesso motivo non è corretto neppure affermare che il BubbleSort appartiene a Θ ( n^2 ) solo perché nel caso peggiore la complessità è quadratica. Viceversa è corretto affermare che, nel caso migliore , la complessità del BubbleSort è Θ ( n ), che nel caso medio e nel caso peggiore la complessità è Θ ( n^2 ) e, più in generale che il BubbleSort è O ( n^2 ) ovvero è Ω ( n ). Altre due importanti considerazioni fanno riferimento a casi in cui non sono verificate le condizioni che sono alla base dell’introduzione delle notazioni asintotiche. Infatti, proprio perché le notazioni introdotte sono asintotiche, vengono trascurati i termini di ordine inferiore e le costanti moltiplicative. Tuttavia, nel caso in cui è necessario confrontare algoritmi cui aventi tempi di esecuzione T ( n ) il cui andamento al limite è uguale (es. sono entrambe O ( n^2 )), non è più possibile trascurare tali termini. In tal caso per stabilire quale algoritmo è più conveniente usare bisogna necessariamente tener conto, in primo luogo delle costanti moltiplicative, in primo luogo, e poi dei termini di ordine inferiore. Un esempio classico è dato dalla scelta degli algoritmi di ordinamento: come dimostreremo nel seguito il MergeSort è un algoritmo che appartiene a Θ ( nlogn ), ovvero la sua complessità è sia nel caso migliore che in quello peggiore (e quindi anche in quello medio) Θ ( nlogn ). Il QuickSort ,
complessità costante. Indicando per semplicità con fcond , felse ed f then queste tre complessità e utilizzando la notazione O avremo che la complessità dell’ if è pari a O (max( fcond + fthen , fcond + felse )), dove il simbolo + sta ad indicare che si applica la regola della somma. Volendo invece utilizzare la notazione Θ , siamo costretti, come accennato nell’introduzione, a distinguere un caso migliore ed un caso peggiore. Nel caso migliore avremo che la complessità sarà data da: Θ (min( fcond + fthen , fcond + felse )), mentre nel caso peggiore sarà data da Θ (max( fcond + fthen , fcond + felse )). Ragionamenti analoghi valgono nel caso di un costrutto di tipo case.
Cicli
La complessità di un ciclo while è in generale data dal prodotto della complessità del corpo del while stesso per il numero di volte che esso viene eseguito. In più bisogna considerare il tempo necessario alla verifica della condizione di fine ciclo. Trattandosi di ciclo non predeterminato, sarà necessario distinguere un caso migliore ed un caso peggiore. Dette kmin e kmax il numero minimo e massimo di volte che viene iterato un generico ciclo while , e dette fcond e fcorpo le funzioni che caratterizzano le complessità derivanti rispettivamente dalla valutazione della condizione e dalle istruzioni che compongono il corpo del while , avremo che, nel caso migliore, la complessità sarà data da Θ ( fcond + kmin ⋅ fcorpo )), mentre nel caso peggiore sarà pari a Θ ( fcond + kmax ⋅ fcorpo )),. Si noti che è necessario considerare anche fcond visto che kmin (e anche kmax …) potrebbe essere pari a 0. Utilizzando la notazione O anche in questo caso è possibile avere un’unica espressione per esprimere la complessità del ciclo while, che sarà infatti data da O ( fcond + kmax ⋅ fcorpo )).
La complessità di un ciclo for è data dal prodotto della complessità del corpo del for stesso per il numero di volte che esso viene eseguito. Anche in questo caso bisogna tener conto del fatto che è necessario valutare il tempo necessario all’inizializzazione della variabile di ciclo, al confronto con la condizione di fine ciclo e all’incremento della variabile stessa, tempo che tipicamente è costante. Detta quindi genericamente fcond la funzione che caratterizza tali tempi, detta invece fcorpo la funzione che caratterizza le complessità derivante dalle istruzioni che compongono il corpo del for ed indicate con k il numero di iterazioni, si avrà che la complessità del for è data da Θ ( fcond + k ⋅ fcorpo ). Si noti tuttavia che, dal momento che in C il for di fatto non implementa un ciclo predeterminato, nella pratica valgono le stesse considerazioni fatte nel caso del while.
La complessità di una chiamata di funzione è data dalla somma di due termini: il costo, in termini di complessità computazionale, dell’esecuzione della funzione stessa ed il costo della chiamata. Quest’ultimo è in genere trascurabile, ma non lo è se si effettuano chiamate per valore che hanno l’effetto di copiare sullo stack la struttura dati d la cui dimensione n è proprio quella da cui dipende la nostra funzione T ( n ) di interesse. Tuttavia, se la struttura dati è un vettore ed il linguaggio considerato è il C, poiché alla funzione viene passato di fatto il puntatore al primo elemento del vettore, il tempo di chiamata è indipendente da n.
Nel caso in cui la funzione di cui si vuole calcolare la complessità è una funzione ricorsiva, cioè contiene al suo interno una chiamata a se stessa, la tecnica proposta nei precedenti paragrafi non può essere semplicemente applicata. Infatti, data una generica funzione ricorsiva R , la complessità
della chiamata di un’istanza ricorsiva di R , che compare nel corpo di R stessa, dovrebbe essere data dalla somma del costo di chiamata e del costo dell’esecuzione dell’istanza di R. Ma quest’ultimo costo è proprio quello che stiamo cercando di calcolare, ed è quindi incognito. Per risolvere questo problema è necessario esprimere il tempo incognito T ( n ) dell’esecuzione di R , come somma di due contributi: un tempo Θ ( f ( n )) che deriva dall’insieme di tutte le istruzioni che non contengono chiamate ricorsive, ed un tempo T ( k ) che deriva dalle chiamate ricorsive, invocate su di una dimensione del dato di ingresso più piccola, cioè con k < n. Otterremo quindi un’equazione, detta equazione ricorrente o formula di ricorrenza o più semplicemente ricorrenza , del tipo T ( n )= a T( n / b )+ Θ ( f ( n ) (oppure T ( n )= aT ( n – b )+ Θ ( f ( n )), dove il primo contributo è appunto quello di a chiamate ricorsive su di un dato di dimensione 1/ b (oppure di b unità più piccolo) rispetto a quello di partenza ed il secondo contributo è quello legato a tutte le istruzioni della funzione in cui non compaiono chiamate ricorsive. Per completare la ricorrenza (e permetterne la risoluzione) è necessario anche valutare il tempo impiegato dalla funzione per valori di n sufficientemente piccoli. In questi casi (tipicamente per n =0 o n =1) la funzione termina senza attivare chiamate ricorsive e se ne può quindi calcolare semplicemente il tempo di esecuzione T ( n ), che sarà in generale costante. Ad esempio, nell’ambito del paradigma divide-et-impera , avremo ricorrenze del tipo:
aT n b Cn Dn pern c
pern c T n ( / ) ( ) ( )
, derivanti dall’aver diviso un certo problema padre
in a sottoproblemi figlio di dimensione n / b , avendo indicato con C ( n ) il costo derivante dalla combinazione dei risultati e con D ( n ) il costo relativo alla divisione del problema padre nei problemi figlio ed essendo costante (e quindi pari a Θ (1)) il tempo per la soluzione dei problemi nel caso banale, cioè quando n ≤ c. Per risolvere una formula di ricorrenza, e quindi giungere a determinare la complessità asintotica di T ( n ), è necessario adottare dei particolari metodi di soluzione che saranno presentati e discussi nel prossimo paragrafo.
Formule di ricorrenza: Metodi di soluzione
I metodi proposti per la soluzione delle formule di ricorrenza sono sostanzialmente tre: il metodo iterativo, il metodo di sostituzione ed il metodo principale. Il primo metodo consiste semplicemente nell’iterare la ricorrenza proposta, finché non si riesce a trovare una generalizzazione. A questo punto occorre trovare il valore per il quale si chiude la ricorrenza, sostituirlo nella formula generale e calcolare il risultato applicando le regole per limitare le sommatorie. In generale è quindi necessario fare un po’ di passaggi matematici (che non dovrebbero essere comunque particolarmente complessi). Il metodo di sostituzione consiste nell’ipotizzare una soluzione candidata e dimostrare l’esattezza dell’ipotesi sulla base dell’induzione matematica. Il problema si riconduce dunque alla scelta della funzione candidata. Nel caso in cui la funzione scelta non si dimostri corretta, sarà necessario provare con un’altra funzione. Il metodo principale, viceversa, permette di trovare direttamente la soluzione in un certo numero di casi. In particolare questo metodo si può applicare a funzioni la cui formula di ricorrenza sia del tipo T ( n ) =aT ( n / b ) +f ( n ), cioè il problema padre è divisibile in a problemi figlio tutti di dimensioni 1/ b rispetto al padre, ed f ( n ) è il costo della fase di divisione e combinazione. In tal caso occorre confrontare la funzione f ( n ) con la funzione n log ba : la complessità risultante sarà quella della funzione di ordine maggiore; se le due funzioni sono dello stesso ordine comparirà un termine logaritmico nella soluzione dell’equazione di ricorrenza. Più precisamente, nel caso in cui f ( n ) ∈ Θ ( n log ba ), cioè f ( n ) e n log ba^ sono dello stesso ordine di
grandezza, la soluzione dell’equazione di ricorrenza sarà Θ ( f ( n ) log n ); se viceversa f ( n ) ∈ O ( n log ba-ε ) per qualche ε > 0 (questo equivale a dire che la funzione f ( n ) è maggiorata
polinomialmente da n log ba ) la complessità risultante sarà data ovviamente dalla maggiore delle due
La funzione muovi() ha complessità Θ (1), essendo composta da un’unica istruzione di stampa.
Allora la formula di ricorrenza sarà data da:
Tn pern
pern T n Θ
. Infatti nel caso
banale devo effettuare solo un confronto e la chiamata di muovi(), che hanno entrambe complessità Θ (1). Nel passo induttivo ho invece due chiamate ricorsive, su di un argomento di un’unità più piccolo (che costano quindi 2 T ( n – 1)) ed una chiamata di muovi() che ha complessità Θ (1). Posso ancora risolvere la ricorrenza con il metodo iterativo: T ( n ) = 2 T ( n – 1) + Θ (1) = 2(2 T ( n – 2) + Θ (1)) + Θ (1) = 2(2(2 T ( n – 3) + Θ (1)) + Θ (1))+ Θ (1); effettuando le moltiplicazioni ottengo: T ( n ) = 8 T ( n – 3) + 4 Θ (1) + 2 Θ (1) + Θ (1) da cui generalizzando:
T ( n ) = 2 k^ T ( n – k ) + (^) ∑
−
=
1
0
k
i
iΘ = 2 k (^) T ( n – k ) + (^)
∑
−
=
1
0
k
i
Θ i.
Ancora una volta la ricorrenza si chiude quando k = n –1. In questo caso infatti l’argomento di T (·) sarà pari ad uno ed arriveremo al caso banale T (1)= Θ (1). Quindi sostituendo otteniamo:
T ( n ) = 2 n -1^ T (1) +
∑
−
=
2
0
n
i
Θ i = 2 n -1 Θ (1) +
∑
−
=
2
0
n
i
Θ i =
∑
−
=
1
0
n
i
Θ i.
La sommatoria in parentesi non è altro che la serie geometrica. In genere:
=
∑ x
x x
n^ n
i
i (^). Nel nostro caso allora avremo: 2 1 2 1
1
0
∑ =
−
=
n n n
i
i (^) , da cui sostituendo nella
relazione precedentemente trovata risulta:
T ( n ) = Θ ( 2 n − 1 ) = Θ ( 2 n ).
#define MAX_SIZE 1000 typedef int Vettore[MAX_SIZE];
void Merge(Vettore vet, int i, int j, int k) { Vettore aux; int p, p1 = i, p2 = j + 1;
for (p = i; p <=k; p++) if (p1 > j) aux[p] = vet[p2++]; else if (p2 > k) aux[p] = vet[p1++]; else if (vet[p1] <= vet[p2]) aux[p] = vet[p1++]; else aux[p] = vet[p2++];
for (p = i; p <= k; p++) vet[p] = aux[p]; }
La complessità della funzione Merge() è data dalla somma della complessità delle inizializzazioni e delle complessità dei due cicli for. Questi ultimi hanno entrambi un corpo di complessità Θ (1) che viene eseguito n volte. La complessità della Merge() è dunque Θ (1) + Θ ( n )
void MergeSort(Vettore vet, int i, int j) { if (i < j) { MergeSort(vet, i, (i+j)/2); MergeSort(vet, (i+j)/2 + 1, j); Merge(vet, i, (i+j)/2, j); } }
La formula di ricorrenza in questo caso è data da:
T n n pern
pern T n Θ
Infatti nel caso banale (vettore vuoto o composto da un unico elemento) devo effettuare solo un confronto che ha chiaramente complessità Θ (1). Nel passo induttivo invece devo effettuare un confronto, con costo Θ (1), più due chiamate ricorsive su di un vettore di dimensione metà, che costano 2 T ( n /2), più una chiamata di Merge() sull’intero vettore, chiamata che ha quindi complessità Θ ( n ) (che in questo caso rappresenta il costo di combinazione indicato con C ( n ) nel precedente paragrafo, mentre non ho un costo di divisione D ( n )). Di nuovo, per la regola della somma, Θ (1) + Θ ( n ) = Θ ( n ). Risolviamo dapprima la ricorrenza con il metodo iterativo. T ( n ) = 2 T ( n /2) + Θ ( n ) = 2(2 T ( n /4) + Θ ( n /2)) + Θ ( n ) = 2(2(2 T ( n /8)+ Θ ( n /4)) + Θ ( n /2)) + Θ ( n ); effettuando le moltiplicazioni ottengo: T ( n ) = 8 T ( n /8) + Θ ( n ) + Θ ( n ) + Θ ( n ) da cui generalizzando:
k
i
n 1
Ancora una volta la ricorrenza si chiude quando l’argomento di T (·) è pari ad 1; in questo caso ciò si verifica quando n =2 k. Ciò implica che k sarà uguale a log 2 n. Se sostituiamo nella precedente relazione avremo allora:
n
i
n
log
1
n
i
n
log
1
Θ ( )= Θ ( n ) + Θ ( n log n ) = Θ ( n log n ).
A questo risultato era possibile anche giungere direttamente applicando il metodo principale. Nel nostro caso infatti, la relazione di ricorrenza è del tipo T ( n ) =aT ( n / b ) +f ( n ), con a = 2, b = 2 e f ( n ) = Θ ( n ). Confrontando allora f ( n ) = Θ ( n ) con n log ba^ = n log^22 = n , troviamo che f ( n ) ∈ Θ ( n log ba ), pertanto la soluzione dell’equazione di ricorrenza sarà Θ ( f ( n ) log n ) = Θ ( n log n ). Proviamo ora ad applicare anche il metodo di sostituzione, utilizzando chiaramente la funzione n log n come funzione candidata. Per semplicità dimostriamo solo che il MergeSort() è O ( n log n ). In effetti per dimostrare l’appartenenza a Θ ( n log n ), dovremmo dimostrare anche che il MergeSort() è Ω ( n log n ), dimostrazione che, per brevità, sarà omessa. Conduciamo la dimostrazione per induzione. Partiamo dal passo induttivo: dobbiamo dimostrare che T ( n ) ≤ c n log n , supponendo per induzione completa che T ( n /2) ≤ c ( n /2) log( n /2). Sostituendo l’ipotesi induttiva nella ricorrenza T ( n )=2 T ( n /2) + n , otteniamo: T ( n ) ≤ 2 c ( n /2) log( n /2) + n = cn (log n– log2) + n = cn log n – cn+n ≤ cn log n. L’ultimo passaggio è evidentemente valido per c ≥ 1. Per completare la dimostrazione dobbiamo dimostrare anche la base, cioè che, per un certo k , sia T( k ) ≤ c k log k. Normalmente come valore di k viene scelto 0 oppure 1. In questo caso tuttavia, scegliere k pari ad 1 porta a dover dimostrare la diseguaglianza T (1) ≤ c 1 log 1, che equivale a dover dimostrare T (1) ≤ 0, il che è impossibile. Il principio d’induzione, tuttavia, dice che, dimostrato il passo induttivo, basta dimostrare il caso base per un certo valore di k per garantire la verità dell’asserto per tutti gli n ≥ k. Dimostrare quindi l’asserto T( k ) ≤ c k log k per k = 2 garantisce che tale asserto sia vero per tutti gli n ≥ 2. Ciò è più che sufficiente, dal momento siamo interessati a
diventerà:
Tn n pern
pern T n Θ
. Infatti nel passo induttivo, la complessità sarà data
dalla somma della complessità di Partition()che è Θ ( n ) più la complessità delle due chiamate ricorsive, che avranno in questo caso complessità T (1) e T ( n –1). Poiché T (1) = Θ (1), avremo che nel passo induttivo, la complessità sarà data da Θ ( n ) + Θ (1) + T ( n –1), che, sempre per la regola della somma, è pari a T ( n –1) + Θ ( n ). Risolviamo allora la ricorrenza con il metodo iterativo: T ( n ) = T ( n –1) + Θ ( n ) = ( T ( n –2) + Θ ( n –1)) + Θ ( n ) = (( T ( n –3) + Θ ( n –2)) + Θ ( n –1)) + Θ ( n ); da cui generalizzando:
k
i
n i 1
La ricorrenza si chiude quando k = n –1. In questo caso infatti l’argomento di T (·) sarà pari ad uno ed arriveremo al caso banale T(1)= Θ (1). Quindi sostituendo otteniamo:
T ( n ) = T (1) + (^) ∑
−
=
1
1
n
i
Θ n i =^ Θ (1) +^ ∑
−
=
1
1
n
i
Θ n i =^
n
i
n i 1
Θ ( 1 ) = Θ ( n^2 ).
L’ultimo passaggio deriva dal fatto che tra parentesi ho una serie aritmetica, la cui sommatoria è
pari a ( 1 ) 2
n n +.
Il caso appena considerato corrisponde al caso peggiore per l’algoritmo QuickSort. Si può dimostrare che nel caso medio la complessità del QuickSort è uguale a quella del caso migliore, cioè è Θ ( n log n ).