Linguaggio C Problemi di Ricerca e Ordinamento: Algoritmi e Complessità. 1 Complessità degli Algoritmi • Si definisce Complessità di un Algoritmo C(A) la funzione dei parametri rilevanti per A che determina il numero di operazioni necessarie per l’esecuzione di A. Quindi: C ( A) = f (n1 , n2 ,..., nk ) • Questo modello non dipende dal tipo di Hardware o implementazione dell’Algoritmo (Software), ma si concentra esclusivamente sulla natura del Problema che viene affrontato. 2 Complessità degli Algoritmi • • • In generale, l’unico parametro rilevante (o comunque il più significativo) è la dimensione del data set su cui lavora l’Algoritmo (ad esempio: il numero di elementi memorizzati in un vettore); E’ possibile indicare degli ordini (o classi) di complessità principali ai quali in genere gli Algoritmi appartengono; Tra questi ordini principali, vale la seguente relazione di dominanza: α Θ(1) < Θ(ln 2 (n)) < Θ(n ) < Θ(2 β *n ) ∀α, β > 0 3 Problema della Ricerca • • Dato un insieme di informazioni (semplici o strutturate) memorizzate nella Struttura Dati D (Vettore, Matrice, Lista, Albero, …) l’obiettivo è quello di determinare se un dato elemento “target” k appartiene o meno all’insieme. Due possibili approcci sono: – La Ricerca Sequenziale, – La Ricerca Binaria. 4 Ricerca Sequenziale • • • • Quando non esistono ipotesi di alcun tipo sui valori memorizzati in D, l’unica possibilità è quella di scandire D elemento per elemento. Appena il valore target k viene trovato la ricerca termina con successo. Se l’insieme è stato esaminato tutto senza aver trovato k la ricerca termina con fallimento. La Ricerca Sequenziale è un approccio possibile per tutte le Strutture Dati fin qui esaminate, e la complessità esprime il numero massimo di confronti che devono essere effettuati, che in questo caso è direttamente proporzionale alla dimensione n del data set, quindi: Θ(n) 5 Ricerca Sequenziale typedef int boolean; #define TRUE 1 #define FALSE 0 #define N … //Dimensione del Vettore … int V[N]; … boolean Ric_Seq(int *V, int k) { boolean Trovato = FALSE; int i = 0; while (!Trovato && i<N) { if (V[i]==k) Trovato = TRUE; else i++; } return Trovato; } 6 Ricerca Binaria • • Se i valori in D sono ordinati, allora l’esito di un solo test può escludere più di un elemento di D dallo spazio della ricerca (si pensi all’elenco telefonico…); Inoltre, se la Struttura Dati D è un Vettore, diviene conveniente applicare la cosiddetta Ricerca binaria (o dicotomica): – Il target k viene confrontato con l’elemento di posizione mediana (ossia D[n/2]), – Se è “uguale” la ricerca termina con successo, altrimenti • Se è “maggiore” la ricerca prosegue nella metà destra di D, • Altrimenti la ricerca prosegue nella metà sinistra di D. 7 Ricerca Binaria • • • • Ogni volta che il valore target k viene confrontato con il valore mediano di D, l’insieme sul quale proseguire la ricerca si dimezza. Il numero massimo di confronti coincide con l’altezza di un Albero binario bilanciato contenente lo stesso numero n di elementi di D, L’altezza di un Albero binario bilanciato è legata alla profondità massima dell’albero stesso, che cresce in maniera logaritmica su n, Segue quindi che la complessità dell’Algoritmo di Ricerca Binaria è direttamente proporzionale al logaritmo sul numero di elementi n contenuti nella struttura D, ossia: Θ(log 2 (n)) 8 Ricerca Binaria • • • • L’Algoritmo di Ricerca Binaria presenta una complessità logaritmica solo in determinate condizioni, ad esempio: – Utilizzo in un Vettore ordinato, – Utilizzo in un Albero binario di ricerca bilanciato. In una Lista, ad esempio, non essendo possibile accedere ai dati in tempo costante (Θ(1)), la complessità degenera a quella lineare (Θ(n)) indipendentemente dall’ordinamento. In un Albero binario di ricerca non bilanciato, la proprietà relativa alla profondità massima non è in generale rispettata, e questo può portare la complessità, nei casi limite, a degenerare a quella lineare. Esistono comunque delle tecniche che permettono di preservare il bilanciamento di un Albero binario di ricerca durante l’inserimento dei dati. 9 Ricerca Binaria in un Vettore typedef int boolean; #define TRUE 1 #define FALSE 0 #define N … //Dimensione del Vettore … int V[N]; boolean Trovato; … Trovato = Ric_Bin(V,N,k); … boolean Ric_Bin(int *V, int n, int k) { if (n>0) { if (k == V[n/2]) return TRUE; else if (k < V[n/2]) return Ric_Bin(V,n/2,k); else return Ric_Bin(&V[n/2+1],n-n/2-1,k); } else return FALSE; } 10 Ordinamento • Si possono individuare due possibili approcci: – Mantenimento dell’ordine di un insieme, – Creazione di un insieme ordinato a partire da uno non ordinato. • Nel primo caso, si opera in maniera tale da preservare l’ordine man mano che vengono aggiunti nuovi elementi nell’insieme già ordinato (ordinamento per inserzione). La complessità dell’ordinamento per inserzione dipende strettamente dal tipo di struttura dati utilizzata: • 11 Ordinamento per inserzione • Nel caso di un Albero binario bilanciato, l’ordinamento è preservato dalla struttura stessa dell’albero, e dato che inserire un nuovo elemento ha complessità logaritmica, inserire n elementi costerà al più: Θ(n log 2 n) • Per restituire in uscita gli elementi ordinati è sufficiente visitare l’albero con visita simmetrica, con costo lineare, ossia pari a Θ(n). 12 Ordinamento per inserzione • • • Nel caso di una Lista concatenata ordinata, trovare la posizione del nuovo elemento implica dover scandire la struttura, con un costo lineare. L’inserimento nella posizione corretta è reso efficiente dall’utilizzo dei puntatori, rendendo, dal punto di vista della complessità, irrilevante (Θ(1)) tale operazione. In conclusione, supponendo di dover inserire n elementi, nel suo complesso la procedura costerà al più: 2 Θ( n ) 13 Ordinamento per inserzione • • • • Se la struttura utilizzata è un Vettore, trovare la posizione dove inserire il nuovo elemento ha costo Θ(log2(n)), se si utilizza l’algoritmo di ricerca binaria. Nel caso del Vettore però è necessario spostare fisicamente gli elementi maggiori del nuovo elemento, in modo da fargli posto. L’operazione di spostare gli elementi del Vettore, ha un costo lineare con la dimensione del Vettore, quindi l’inserimento di ogni nuovo elemento costa in media log2(n) + n, dove n è la dimensione corrente del vettore. Dovendo inserire un totale di n elementi, la complessità sarà pari a n*(log2(n) + n), ossia nlog2(n) + n2 e questo significa che la complessità dell’intera procedura sarà al più: 2 Θ( n ) 14 Considerazioni sulla complessità dell’Ordinamento • • • Quando una procedura di ordinamento effettua solo confronti e spostamenti, il numero minino di operazioni necessarie è proporzionale a Θ(nlog2(n)), dove n è la dimensione della struttura. L’Albero binario di ricerca si rivela una struttura molto efficiente oltre che per la ricerca, anche per l’ordinamento, essendo questo garantito per costruzione. L’Algoritmo di ordinamento per inserzione in una Lista o in un Vettore appartengono alla medesima classe di complessità (hanno lo stesso comportamento asintotico), ma si può affermare che in una Lista la procedura è più efficiente. 15 Ordinamento per affioramento (Bubble Sort) • • • • • In talune situazioni può rendersi necessario ordinare un insieme di elementi disordinato, ad esempio un vettore. Consideriamo le procedure che effettuano confronti e spostamenti per l’ordinamento di un Vettore. Uno degli Algoritmi più semplici, ma non il più efficiente, è il cosiddetto Bubble Sort. L’idea è quella di confrontare tra loro gli elementi di tutte le coppie consecutive e scambiare gli elementi della coppia quando questi non sono ordinati. Il procedimento è iterativo, e alla fine di ciascuna iterazione un elemento del Vettore si troverà nella sua posizione finale. 16 Bubble Sort: un esempio • • • • • • Consideriamo il vettore V = {6,10,4,1,8} e supponiamo di volerlo ordinare in modo crescente. La prima coppia esaminata è 6,10 e siccome 10 > 6 allora non effettuo lo scambio, La seconda coppia da prendere in esame è 10,4 e questa volta effettuo lo scambio … Alla fine della prima iterazione ho esaminato tutte le coppie consecutive di V, e V = {6,4,1,8,10}. Adesso l’Algoritmo procederà sulle coppie del sottovettore V1 = {6,4,1,8} … Al termine di ciascuna iterazione, l’elemento massimo del vettore affiora fino ad occupare l’ultima posizione, e in generale gli altri elementi tendono ad assumere la loro posizione finale. 17 Bubble Sort: analisi della complessità • • Quanti confronti e spostamenti si effettuano al più? In generale, se il Vettore V è di dimensione n, allora il numero massimo di confronti e spostamenti equivale al numero di coppie consecutive coinvolte nelle varie iterazioni, ossia: n2 − n (n − 1) + (n − 2) + ... + 1 = ∑ (n − 1) = = Θ( n 2 ) 2 • Il Bubble Sort ha quindi complessità polinomiale di ordine 2. 18 Bubble Sort void BubleSort(int *V, int size) { int i,j; for (i=size; i>=1; i--) for (j=0; j<i; j++) if (V[j] > V[j+1]) scambia(&V[j], &V[j+1]); } NOTA: l’Algoritmo potrebbe essere modificato in modo da terminare nel caso il vettore V sia già ordinato alla generica iterazione i. 19 Quicksort (ordinamento veloce) • Alla base del Quicksort vi è un procedimento che porta un elemento del Vettore (pivot o perno) scelto secondo un qualche criterio (generalmente il primo elemento del Vettore) ad occupare la sua posizione finale. • Contemporaneamente effettua degli spostamenti in maniera tale che tutti gli elementi minori di pivot siano a sinistra, e gli elementi maggiori a destra. • La procedura descritta viene chiamata di partizionamento, e se si applica ricorsivamente ai sottovettori (partizioni) così determinati, si otterrà l’ordinamento sperato. • Alla base dell’Algoritmo Quicksort vi è dunque la procedura di partizionamento del Vettore. 20 Quicksort (ordinamento veloce) • La procedura Partiziona utilizza due indici, l e r, per scandire simultaneamente V dal primo elemento in poi, e dall’ultimo all’indietro, ed effettuando lo scambio dei due elementi in esame se l’ordine non è rispettato, finché l < r. • All’inizio tengo fisso l e faccio scorrere r all’indietro; dopo aver effettuato uno scambio, tengo fisso r e faccio scorrere l. • Esempio, sia V = {34,27,64,25,18,29,76,81} e 34 il pivot, – {34,27,64,25,18,29,76,81} quando V[l] = 34 e V[r] = 29, viene effettuato lo scambio, – {29,27,64,25,18,34,76,81} adesso faccio scorrere l … – {29,27,64,25,18,34,76,81} effettuo lo scambio … – {29,27,34,25,18,64,76,81} adesso faccio scorrere r … – {29,27,34,25,18,64,76,81} effettuo lo scambio … – {29,27,18,25,34,64,76,81} adesso faccio scorrere l … – {29,27,18,25,34,64,76,81} l == r, la partizione è fatta! 21 Quicksort: analisi della complessità • Supponiamo che la dimensione del Vettore e la distribuzione degli elementi contenuti siano tali da generare dei partizionamenti sempre bilanciati (caso “migliore”). • Quanti partizionamenti devo effettuare? • La prima suddivisione produce 2 sottovettori di n/2 elementi ciascuno, la seconda 4 sottovettori di n/4 elementi ciascuno … • L’ultima suddivisione, diciamo la p-esima, genera 2p sottovettori di n/2p = 1 elementi ciascuno, quindi p = log2(n). • La scansione dei singoli sottovettori è lineare; la suddivisione del Vettore V viene fatta in n passi, la seconda suddivisione prevede la scansione di 2 sottovettori di dimensione n/2, che in totale da n passi, poi 4 sottovettori di dimensione n/4 che in totale da ancora n passi … • In generale, se p è il numero di partizionamenti del Vettore, il numero totale di passi sarà n*p, quindi in questo caso la complessità del Quicksort sarà: Θ(np) = Θ(n log 2 (n)) 22 Quicksort: analisi della complessità • L’algoritmo del Quicksort è considerato ottimo tra quelli che operano confronti e spostamenti, ma il comportamento dell’algoritmo dipende strettamente da come sono distribuiti i dati e anche dalla scelta del pivot. • Se per esempio gli elementi sono già ordinati, il partizionamento sarà fortemente sbilanciato (caso “pessimo”) e la complessità degenera a quella quadratica, come nel Bubble Sort. • Esistono metodi stabili la cui complessità è sempre proporzionale a Θ(nlog2(n)) (es: Mergesort). 23 Quicksort int Partiziona(int *V, int l, int r) { while (l < r) { while (V[l] <= V[r] && l < r) r--; if (l < r) { scambia(&V[l],&V[r]); while (V[l] <= V[r] && l < r) l++; if (l < r) scambia(&V[l],&V[r]); } } return l; //l == r sarà l’indice relativo al pivot } 24 Quicksort int main() { … Quicksort(V,0,size-1); … } void Quicksort(int *V, int l, int r) { int perno; if (l < r) { perno = Partiziona(V,l,r); Quicksort(V,l,perno-1); Quicksort(V,perno+1,r); } } 25