Algoritmi e strutture dati 2006/2007 prof. C. Sansone MERGE SORT, QUICK SORT, COUNTING SORT Roberto Bifulco 885/269 Barbara Migliaccio 885/225 Table of contents Introduzione..........................................................................................................................................................................4 Capitolo I Merge sort.............................................................................................................................................................................5 I.1Funzionamento........................................................................................................................................................... 5 I.2Pseudo-codice............................................................................................................................................................. 6 I.3Analisi di correttezza.................................................................................................................................................. 7 I.4Analisi di complessità.................................................................................................................................................8 Capitolo II Quick sort........................................................................................................................................................................... 13 II.1Funzionamento........................................................................................................................................................ 13 II.2Pseudo-codice..........................................................................................................................................................14 II.3Analisi di correttezza...............................................................................................................................................16 II.4Analisi di complessità............................................................................................................................................. 16 Capitolo III Counting sort...................................................................................................................................................................... 18 III.1Funzionamento.......................................................................................................................................................18 III.2Pseudo-codice........................................................................................................................................................ 19 III.3Analisi di complessità............................................................................................................................................ 19 Capitolo IV Modalità di testing.............................................................................................................................................................. 20 IV.1Tool per il testing...................................................................................................................................................20 IV.2Valutazione del tempo di esecuzione.................................................................................................................... 20 IV.3Valutazione del numero di operazioni................................................................................................................... 21 IV.4Verifica di correttezza............................................................................................................................................21 Capitolo V Realizzazione degli algoritmi............................................................................................................................................. 23 V.1Merge sort............................................................................................................................................................... 23 V.2Quick sort................................................................................................................................................................ 24 V.3Counting sort...........................................................................................................................................................25 Capitolo VI Testing delle prestazioni..................................................................................................................................................... 26 VI.1Merge sort.............................................................................................................................................................. 26 VI.1.1Analisi del tempo di esecuzione....................................................................................................................26 VI.1.2Analisi del numero di operazioni.................................................................................................................. 28 VI.1.3Confronto con i risultati teorici..................................................................................................................... 28 VI.2Quick sort...............................................................................................................................................................30 VI.2.1Analisi del tempo di esecuzione....................................................................................................................30 VI.2.2Analisi del numero di operazioni.................................................................................................................. 32 VI.2.3Confronto con i risultati teorici..................................................................................................................... 33 VI.3Counting sort......................................................................................................................................................... 34 VI.3.1Analisi del tempo di esecuzione....................................................................................................................34 VI.3.2Analisi del numero di operazioni.................................................................................................................. 34 VI.3.3Confronto con i risultati teorici..................................................................................................................... 35 VI.4Confronto fra i tre algoritmi.................................................................................................................................. 36 Appendice A: Codice sorgente........................................................................................................................................... 37 TestBench.cpp...............................................................................................................................................................37 mergesort.cpp................................................................................................................................................................42 quicksort.cpp................................................................................................................................................................. 44 countingsort.cpp............................................................................................................................................................45 Appendice B: il tool TestBench..........................................................................................................................................46 Introduzione Introduzione Il problema dell’ordinamento di un insieme è un problema classico dell’informatica che ha una valenza indiscutibile in ambito applicativo. Nelle pagine seguenti esamineremo una serie di algoritmi che risolvono il problema in modo efficiente, quali: il Mergesort, il Quicksort e il Countingsort. Alcuni di essi operano esclusivamente sulla base del confronto dei valori dell’insieme da ordinare, mentre altri risolvono il problema in modo ancora più efficiente utilizzando alcune informazioni aggiuntive sull’insieme da ordinare (ad esempio sulla presenza di elementi duplicati, sul valore minimo e il valore massimo all’interno dell’insieme, o altre informazioni che potrebbero consentire di introdurre delle ottimizzazioni nell’algoritmo, in grado di ridurre in modo significativo la complessità dell’algoritmo stesso). Detta n la dimensione dell'input, l’algoritmo QUICKSORT consente di raggiungere una complessità di O(n log(n)) nel caso medio, mentre nel caso più sfavorevole raggiunge una complessità di Θ(n2). L’algoritmo MERGESORT consente di raggiungere una complessità di Θ (n log(n)) anche nel caso peggiore. E' possibile dimostrare che il limite inferiore alla complessità computazionale del problema dell’ordinamento mediante confronti (senza dunque poter sfruttare altre informazioni sull’insieme da ordinare) è proprio pari a n log(n) L’algoritmo COUNTINGSORT è invece basato su altri criteri e strategie, diverse dal confronto fra i valori degli elementi dell’insieme da ordinare, e sfrutta pertanto altre informazioni sui dati in input; grazie a questo riesce ad ottenere una complessità lineare di Θ (n). Questo testo è diviso in capitoli, presentati brevemente nel seguito: ➢ Capitolo I, Capitolo II, Capitolo III: descrizione degli algoritmi Mergesort, Quicksort e Countingsort da un punto di vista teorico, con analisi di correttezza e complessità e presentazione dello pseudo-codice dell'algoritmo; ➢ Capitolo IV: vengono presentate le problematiche affrontate per la valutazione delle prestazioni degli algoritmi e le soluzioni adottate; ➢ Capitolo V: illustrazione di una possibile implementazione degli algoritmi in C++; ➢ Capitolo VI: presentazione dei risultati sperimentali ottenuti, confronto fra gli algoritmi e verifica delle ipotesi teoriche. Sono poi presenti due appendici utili a comprendere la soluzione adottata per eseguire il testing, vengono quindi presentati il tool di testing ed il relativo codice sorgente. Mergesort, Quicksort, Countingsort 3 Capitolo I Merge sort Capitolo I Merge sort L'algoritmo mergesort è un algoritmo ricorsivo che usa il paradigma del "divide et impera". Questa tecnica consiste in tre passaggi fondamentali: ➢ Divide : il problema viene diviso in un certo numero di sotto-problemi di uguale grandezza; ➢ Impera : i sotto-problemi vengono risolti in modo ricorsivo; ➢ Combina : le soluzioni dei sotto-problemi vengono combinate per generare la soluzione del problema originale. L’intuizione su cui si basa questo potente algoritmo di “ordinamento per fusione” è la seguente: potendo disporre di due sequenze già ordinate, è molto facile fonderle in una sola sequenza completamente ordinata, basta scorrerle entrambe estraendo di volta in volta l’elemento minimo dalle due sequenze originali, per collocarlo in fondo alla sequenza completamente ordinata che si sta costruendo. Il punto di partenza di questo ragionamento è costituito dal fatto che una sequenza composta da un solo elemento è di fatto una sequenza ordinata. Partendo da questi presupposti, quindi, è possibile realizzare un algoritmo che, dopo aver suddiviso l’insieme iniziale A in n sottoinsiemi costituiti da un solo elemento ciascuno, procede a riaggregare i sottoinsiemi fondendoli ordinatamente fino a ricostruire un’unica sequenza ordinata. I.1 Funzionamento Per ordinare un array A[p..r] con il mergesort conformemente al paradigma del "divide et impera", agiremo in questo modo: ➢ Divide : Dividiamo la sequenza degli n elementi da ordinare in due sottosequenze di n/2 elementi ciascuna. Troviamo q, elemento a metà dell'array; ➢ Impera : Ordiniamo le due sottosequenze A[p..q] e A[q + 1..r] in modo ricorsivo utilizzando l’algoritmo merge sort; ➢ Combina A[p..r]. : Si fondono i sottoarray ordinati per ottenere l'array ordinato L’elemento chiave dell’algoritmo è l'operazione di merge (ultimo passaggio) che combina due sotto-sequenze ordinate per produrre una singola sequenza ordinata. Questa ripetutamente compara i primi elementi in testa alle due sotto-sequenze e mette in output il più piccolo valore finché non rimane nessun elemento. Se la scansione di uno dei vettori è arrivata all'ultima componente, si copiano i rimanenti elementi dell'altro nel vettore di output. Il merge ha complessità lineare e non dipende dall’input: infatti, se due sottoarray hanno ciascuno n componenti, abbiamo 2*n confronti (nel caso peggiore) e altrettante copie, quindi: time(n) = 2*n+2*n = 4*n = Θ(n). Mergesort, Quicksort, Countingsort 4 Capitolo I Merge sort Complessivamente potremmo schematizzare l’intero procedimento di ordinamento di una sequenza di 4 elementi [6,2,9,5] come nel diagramma esemplificativo rappresentato nella figura seguente. Le due fasi di divisione spezzano la sequenza di input; le due fasi di merge combinano le sottosequenze ordinate generate nella precedente fase. I.2 Pseudo-codice Merge-Sort(A, p, r) 1 If (p < r) 2 then q = [(p+r)/2] 3 Merge-Sort(A, p,q) 4 Merge-Sort(A, q+1, r) 5 Merge(A, p, q, r) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Merge(A, p, q, r) n1q-p+1 n2 r-q creo i vettori L[1…n1+1] e R[1…n2+1] for i 1 to n1 do L[i] A[p+i-1] for j 1 to n2 do R[j] A[q+j] L[n1+1] infinito R[n2+1] infinito i 1 j 1 for k p to r do If L[i]<= R[j] then A[k] L[i] i i+1 else A[k] R[j] j j+1 Mergesort, Quicksort, Countingsort 5 Capitolo I Merge sort L’algoritmo è suddiviso in due procedure distinte: la procedura ricorsiva MERGESORT e la procedura iterativa MERGE. In sostanza l’idea di fondo di questo algoritmo è quella di suddividere sempre a metà l’array iniziale, in modo tale che i due sotto-array ottenuti abbiano un egual numero di elementi (a meno di uno, nel caso di insiemi di cardinalità non rappresentabile come una potenza di 2 o comunque dispari). Nel MERGESORT durante il processo di suddivisione l’insieme non viene in alcun modo modificato, i suoi elementi non sono spostati né confrontati fra di loro. Semplicemente si opera una suddivisione dicotomica ricorsiva della sequenza iniziale da ordinare, fino ad ottenere n sequenze di dimensione 1. Di questa suddivisione ricorsiva si occupa la procedura ricorsiva MERGESORT, che riceve in input l’intera sequenza A insieme con i due indici p e r che consentono di identificare rispettivamente il primo e l’ultimo elemento della sotto-sequenza su cui deve operare la procedura. La procedura individua l’indice q dell’elemento che si trova a metà della sotto-sequenza delimitata dagli elementi p e r ed innesca due chiamate ricorsive della stessa procedura sulla prima metà della sequenza (dall’elemento di indice p fino all’elemento di indice q) e sulla seconda metà (dall’elemento di indice q +1 fino all’elemento di indice r ). La procedura ricorsiva si ripete fino a quando la sotto-sequenza da suddividere non è composta da un solo elemento: quando p = r la procedura termina senza compiere alcuna operazione. Nel backtracking della ricorsione sta il vero e proprio punto di forza di questo algoritmo. Durante il backtracking infatti vengono fusi a due a due i sottoinsiemi ottenuti con il processo di suddivisione ricorsiva. Durante la fusione gli elementi vengono posizionati in modo da ottenere da due sottosequenze ordinate un’unica sequenza ordinata. Infatti le sequenze composte da un solo elemento da cui inizia il procedimento di fusione sono di per sé ordinate. I.3 Analisi di correttezza Ricordiamo che l’algoritmo Merge(A, p, q, r) assume che i vettori A[p …… q] e A[q+1 …… r] siano ordinati e genera il vettore A[p …… r] ordinato. Per valutare la Correttezza dell’algoritmo utilizziamo il Principio di induzione completa. ➢ Passo base: Se A è un vettore di un solo elemento,dunque p=r e pertanto l’algoritmo non viene eseguito. Il vettore è pertanto banalmente ordinato. ➢ Passo induttivo: Ipotizzo la proprietà vera non solo per n-1, bensì vera da 1 a n-1(induzione completa). Mergesort ordina correttamente n/2 elementi, quindi le due chiamate a Mergesort sono corrette per ipotesi induttiva (ho 2 vettori di n/2 elementi ordinati). ➢ Conclusione: assumendo la correttezza di merge, ho la fusione ordinata dei due vettori. Dunque ho un vettore totalmente ordinato. Valutiamo ora la correttezza della funzione Merge, consideriamo soltanto l'ultimo ciclo, la cui invariante è che il vettore A[p...k-1] è ordinato ed in L[i] ed R[j] ci sono i minimi dei vettori L ed R. ➢ Passo base: Se k=p il vettore A è vuoto e, poiché si ipotizzano L ed R ordinati, essendo i=j=1, L[i] e R[j] rappresentano i minimi dei rispettivi vettori. ➢ Passo induttivo: Ipotizzo la proprietà vera per k=n-1, per k=n vengono valutati i minimi R[j] ed L[i], il minore fra i due viene posto in A[k], poiché A[1..k-1] era ordinato con gli elementi di R ed L, anche A[1...k] sarà ordinato, inoltre l'aggiornamento degli indici i e j Mergesort, Quicksort, Countingsort 6 Capitolo I Merge sort assicura che il minimo di L ed R sia ancora in posizione i e j. ➢ Conclusione: all'uscita dal ciclo, k = n+1, ma, il vettore ha una lunghezza n, quindi A[1...n] è ordinato correttamente, mentre in L[i] ed R[j] rimangono le sole sentinelle. I.4 Analisi di complessità Il mergesort gira nel caso peggiore con complessità rispetto al tempo Θ (n log 2 n). I fattori costanti non sono buoni, così non si usa mergesort per piccoli array. Siccome la funzione merge fa copie dei sottoarray, non opera in-place (cioè l’ordinamento non viene fatto sul posto) . Mergesort si affida pesantemente alla funzione di merge che ha complessità lineare rispetto al tempo. Passiamo ora ai dettagli dell'analisi. Il numero di attivazioni della procedura merge-sort dipende dal numero di componenti del vettore da ordinare. Per un'analisi del mergesort è conveniente disegnare un albero per rappresentare le chiamate ricorsive. Per semplicità, consideriamo un vettore iniziale che abbia n elementi, con n potenza di 2 (n=2k). [ k=log2n ]. Ecco una tabella riassuntiva delle attivazioni: Livello (1) (2) (3) ... (i) ... log2n+1=k+1 Attivazioni di mergesort: Attivazioni 1 attivazione su un vettore di n (=2k) componenti 2 attivazioni su 2 vettori di n/2 (=2k-1) componenti 4 attivazioni su 4 vettori di n/4 (=2k-2) componenti 2i-1 attivazioni su 2i-1 vettori di n/(2i-1) (=2k-i+1) elementi; 2k (=n) attivazioni su 2k vettori di 1 componente. 1 + 2 + 4 + ... + 2k = 2k+1 - 1 = 2^(log2n+1) - 1 = 2 x 2^(log2n) - 1 = 2n - 1 = O(n) Mergesort, Quicksort, Countingsort 7 Capitolo I Merge sort Operazioni di confronto: Ad ogni livello h (h >1) si fa il merge di 2h-1 vettori di lunghezza n/2h-1 [si veda la tabella sopra] poiche', detta L la lunghezza dei due vettori da fondere, il numero di confronti del merge è (nel caso peggiore) 2L, il merge di due vettori al livello h richiede 2*(n/2h-1) confronti ma, poiché al livello h ci sono 2h-1 vettori da fondere, il numero totale di confronti a tale livello e' fisso e vale 2n. Pertanto, il numero globale di confronti nei k livelli vale: time(n) = 2*n*k = Θ(n*log2n) e Costomerge = n + Θ (n*log2n) = Θ (n*log2n) . Dunque il costo dell’algoritmo mergeSort è espresso dalla seguente equazione di ricorrenza: dove con f(n) abbiamo indicato il costo della procedura di merge. Poiché f (n) = O(n), per il teorema Master abbiamo che: T(n) = O(n log(n)) Il problema dell'ordinamento di n elementi ha delimitazione inferiore complessità pari a n*log2n.Ciò implica che l’algoritmo Merge-Sort è un algoritmo di ordinamento ottimale. Possiamo quindi concludere che la complessità computazionale di MERGESORT È O(n log(n)). Non esistono casi più favorevoli di altri: l’algoritmo adotta lo stesso comportamento a prescindere dalla disposizione iniziale degli elementi nella sequenza da ordinare. Mergesort, Quicksort, Countingsort 8 Capitolo II Quick sort Capitolo II Quick sort Uno dei più popolari algoritmi di ordinamento è il quicksort che ha complessità O(nlogn) in media e O(n2) nel caso peggiore. Con le giuste precauzioni il caso peggiore si può evitare. Quicksort ha inoltre il vantaggio di ordinare sul posto e funziona bene anche in ambienti con memoria virtuale. Quicksort, come Mergesort, è basato sul paradigma “Divide et impera”, ossia sull’idea di poter suddividere il problema in sotto-problemi di uguale natura, ma via via sempre più semplici da risolvere; ovviamente tale strategia è vantaggiosa solo se lo sforzo necessario successivamente per ricomporre le soluzioni dei sotto-problemi ed ottenere la soluzione del problema iniziale, è inferiore all’impegno necessario per risolvere il problema nel suo complesso con un algoritmo diretto. Questo tipo di strategia si presta molto bene ad essere implementata mediante un algoritmo ricorsivo, che viene applicato ad istanze sempre più piccole, fino ad arrivare ad istanze elementari del problema originale, che possano essere risolte in via diretta con una sola operazione, senza dover innescare altre chiamate ricorsive. Nello specifico la suddivisione del problema in due sotto-problemi analoghi operata da QUICKSORT è basata sull’idea di separare ogni volta gli elementi “piccoli” da quelli “grandi”, scegliendo in modo arbitrario ogni volta una soglia per definire un metro per distinguere tra elementi grandi ed elementi piccoli. L’elemento che svolge il ruolo di soglia è chiamato pivot, perché è proprio una sorta di “perno” attorno al quale si spostano gli elementi della sequenza da riordinare. II.1 Funzionamento In particolare per ordinare un array A[p..r] con il Quicksort agiremo in questo modo: ➢ Divide : Partizioniamo l’array A[p..r] due sotto-array A[p..q-1] e A[q + 1..r] (eventualmente vuoti) tali che ogni elemento di A[p..q-1] sia minore o uguale ad A[q] che, a sua volta, è minore o uguale a ogni elemento di A[q + 1..r] .Calcoliamo l’indice q come parte di questa procedura di partizionamento; ➢ Impera : Ordiniamo i due sotto-array A[p..q-1] e A[q + 1..r] richiamando ricorsivamente l’algoritmo quicksort; ➢ Combina : Poiché i sotto-array sono ordinati sul posto, non occorre alcun lavoro per combinarli: l’intero array A[p..r] è ordinato. Complessivamente potremmo schematizzare l’intero procedimento di ordinamento come nel diagramma esemplificativo rappresentato nella figura seguente. Mergesort, Quicksort, Countingsort 9 Capitolo II Quick sort II.2 Pseudo-codice QuickSort(A, p, r ) 1 if p < r 2 then q Partition(A, p, r ) 3 QuickSort(A, p, q - 1) 4 QuickSort(A, q + 1, r ) Dunque il quicksort lavora partizionando l'array che deve essere ordinato e ricorsivamente ordina ogni parte (l'algoritmo è ricorsivo nel senso che si richiama su ciascun sotto-vettore fino a quando non si ottengono sotto-vettori di lunghezza 1: a questo punto il vettore di partenza risulta ordinato) . L’elemento chiave dell’algoritmo è la procedura Partition, che riarrangia il sotto-array A[p..r] sul posto. Mergesort, Quicksort, Countingsort 10 Capitolo II Quick sort Partition(A, p, r ) 1 x A[r ] . elemento pivot 2 i p - 1 . dim di A[mid + 1..right] 3 for j p to r - 1 4 do if A[j ] <= x 5 then i i + 1 6 scambia A[i ] con A[j ] 7 scambia A[i + 1] con A[r ] 8 return i + 1 In Partition un elemento dell'array è selezionato come valore pivot. I valori più piccoli del pivot sono sistemati a sinistra del pivot, mentre i più grandi a destra. Il tempo di esecuzione di Partition per un array di dimensione n è Θ (n). In figura a), il pivot selezionato è 3. Gli indici sono a entrambi gli estremi dell'array. Un indice comincia sulla sinistra e seleziona un elemento che è più grande del pivot, mentre un altro indice comincia sulla destra e seleziona un elemento minore del pivot. In questo caso, i numeri 4 e 1 sono selezionati. Questi elementi sono allora scambiati, come mostrato in figura b). Questo processo si ripete finché tutti gli elementi a sinistra del pivot sono minori o uguali al pivot, e tutti gli elementi alla destra del pivot sono >= del pivot. Quicksort ordina ricorsivamente i due sotto-array e alla fine risulterà l'array mostrato in figura c). a) p 4 2 b) p 1 2 c) 1 3 pivot 3 2 r 1 5 r 4 5 3 4 5 Come il processo avanza, può essere necessario muovere il pivot in modo che l'ordine corretto rimanga. In questa maniera quicksort segue nell'ordinare l'array. Se siamo fortunati il pivot selezionato sarà la media di tutti i valori, dividendo in maniera equa l'array. Se è questo il caso l'array è diviso ad ogni passo a metà, e Partition deve esaminare tutti e n gli elementi: la complessità sarà O(nlogn). Per trovare un valore pivot, Partition potrebbe semplicemente selezionare il primo elemento (A[p]). Tutti gli altri valori dovrebbero essere comparati al valore pivot, e messi o a destra o a sinistra del pivot. Comunque, c'è un caso che fallisce miseramente. Si supponga che l'array è all'inizio in ordine. Partition selezionerà sempre il valore più basso come pivot e dividerà l'array con un elemento nella parte sinistra, e r-p elementi nell'altra. Ogni chiamata ricorsiva a quicksort diminuirà la grandezza dell'array. Perciò n chiamate ricorsive saranno richieste per fare l'ordinamento, risultando di complessità O(n2). Una soluzione a questo problema è selezionare casualmente un elemento come pivot. Questo renderebbe estremamente sfortunata l'occorrenza del caso peggiore. Mergesort, Quicksort, Countingsort 11 Capitolo II Quick sort II.3 Analisi di correttezza Per l’analisi di Correttezza dell’algoritmo utilizziamo il Principio di induzione completa. ➢ ➢ ➢ Passo base : Il vettore formato da un solo elemento, p=q=r, è banalmente ordinato. Passo induttivo : Ipotizzo la proprietà vera per q, e che q sia al centro del vettore. Si ottiene: A[1..q-1] e A[q + 1..r] ordinati e A[q] posto opportunamente. Conclusione : Ho un vettore totalmente ordinato. II.4 Analisi di complessità Il tempo di esecuzione del QuickSort dipende da come lavora la Partition ed in particolare dal fatto che il partizionamento sia bilanciato o sbilanciato. Ricordiamo che il partizionamento sbilanciato si verifica quando la Partition produce due sotto-problemi con n - 1 e zero elementi, rispettivamente. La procedura effettua ad ogni passo un numero di confronti proporzionale alla dimensione del vettore. Queste dimensioni però non sono fisse a ogni passo, ma dipendono da come è stato scelto il pivot al passo precedente. Il numero di attivazioni di quicksort dipende quindi dalla scelta del pivot. Caso peggiore: Ad ogni passo si sceglie un pivot tale che un sotto-vettore abbia lunghezza 0 Supponiamo che questo sbilanciamento si verifichi in ogni chiamata ricorsiva; la ricorrenza che definisce il tempo di esecuzione del QuickSort è: Sommando i costi ad ogni livello di ricorsione, otteniamo la serie aritmetica il cui valore è Θ(n2). Basta applicare il metodo della sostituzione per dimostrare che la soluzione della ricorrenza è: T(n) = T(n - 1) + Θ (n) è T(n) = Θ(n2). Il tempo di esecuzione Θ(n2) si ha quando l’array è già completamente ordinato. Caso migliore: Ad ogni passo si sceglie un pivot tale che il vettore si dimezzi (cioe' si emula il merge sort). Nel caso di bilanciamento massimo, la procedura Partition genera due sottoproblemi, ciascuno di dimensione non maggiore di n/2 (uno con dimensione n/2 e l’altro di dimensione n/2 – 1). In questo caso QuickSort viene eseguito molto più velocemente; la ricorrenza per il tempo di esecuzione è: T(n) <= 2T(n/2) + Θ (n) la cui soluzione, per il teorema Master, è: T(n) = O(n logn) Mergesort, Quicksort, Countingsort 12 Capitolo II Quick sort Caso medio: La scelta casuale del pivot rende probabile la divisione in due parti aventi circa lo stesso numero di elementi. Pertanto il tempo del QuickSort nel caso medio è molto più vicino al caso migliore che non al caso peggiore. Per spiegarne il motivo, dobbiamo capire come il partizionamento influisce sul comportamento dell’algoritmo. Supponiamo che Partition produca sempre una ripartizione proporzionale 9-a-1 (in apparenza molto sbilanciata). In questo caso otteniamo una ricorrenza: T(n) <= T(9/10n) + T(1/10n) + cn dove abbiamo incluso la costante c nascosta nel termine Θ (n). Mostriamo un esempio: Sommando i costi di ciascun livello abbiamo che T(n) <=cn(h + 1) dove h = log10/9n è l’altezza dell’albero. Da cui: T(n) = O(n log10/9 n) = O(n logn) In effetti anche una ripartizione 99-a-1 determina un tempo di esecuzione pari a O(n logn). Il motivo è che una qualsiasi ripartizione con proporzionalità costante produce un albero di ricorsione di profondità Θ (logn); il costo di ciascun livello è O(n). Quindi, il tempo di esecuzione è O(n logn). Se eseguiamo QuickSort su un input casuale, è poco probabile che il partizionamento avvenga sempre nello stesso modo ad ogni livello. E’ logico supporre, invece, che qualche ripartizione sarà ben bilanciata e qualche altra sarà molto sbilanciata Nel caso medio Partition produce una combinazione di ripartizioni “buone” e di ripartizioni “cattive” distribuite a caso nell’albero. Si può quindi assumere che le ripartizioni buone e cattive si alternino nei vari livelli dell’albero. Mergesort, Quicksort, Countingsort 13 Capitolo III Counting sort Capitolo III Counting sort Il Counting sort è un algoritmo sorprendentemente semplice e potente in quanto è in grado di ordinare un vettore di n elementi in tempo lineare. Questo è possibile in quanto l’algoritmo non opera esclusivamente sul confronto di elementi, ma fa delle ipotesi aggiuntive. Il Counting sort suppone che ciascuno degli n elementi da ordinare sia un intero compreso tra 0 e k. Quando k = O(n), l’ordinamento viene effettuato in un tempo Θ (n). È bene osservare che negli algoritmi visti nelle pagine precedenti non avevamo mai posto un vincolo così forte sul valore degli elementi dell’insieme. III.1 Funzionamento Il concetto che sta alla base del counting sort è determinare, per ogni elemento x in input, il numero di elementi minori di x. Questa informazione può essere utilizzata per inserire l’elemento x direttamente nella sua posizione nell’array di output. Ad esempio, se ci sono 13 elementi minori di x, allora x deve andare nella posizione 14. Questo schema va leggermente modificato per gestire la presenza di più elementi con lo stesso valore, per evitare che siano inseriti nella stessa posizione. Un’importante proprietà dell’algoritmo Counting Sort è la stabilità: gli elementi con lo stesso valore compaiono nell’array di output nello stesso ordine in cui si trovano nell’array di input. Ovvero, l’uguaglianza di due numeri viene risolta applicando la seguente regola: il numero che si presenta per primo nell’array di input sarà inserito per primo nell’array di output. E’ bene notare che se invece di procedere dall’elemento di indice maggiore a quello minore durante l’assegnazione al vettore di output, si procedesse dal minore al maggiore, si perderebbe la proprietà di stabilità. Complessivamente potremmo schematizzare l’intero procedimento di ordinamento di una sequenza come nel diagramma esemplificativo rappresentato nella figura seguente: Mergesort, Quicksort, Countingsort 14 Capitolo III Counting sort III.2 Pseudo-codice L’algoritmo è composto da una sequenza di cicli privi di nidificazione. Nel codice, supponiamo che l’input sia un array A[1.. n], occorrono altri due arrai: B[1.. n] conterrà l’output ordinato e C[0.. k] è d’appoggio. Counting-Sort (A, B, k) for i 0 to k // Θ(k) do C[i] 0 //azzera gli elementi di C for j 1 to length[A] //Θ(n) do C[A[j]] C[A[j]]+1 //C[i] = numero di elementi = i for i 1 to k //Θ(k) do C[i] = C[i] + C[i-1] //C[i] = numero di elementi ≤ i for j length[A] downto 1 //Θ(n) do B[C[A[j]]] = A[j] //inserisce gli elementi al giusto posto in B C[A[j]] = C[A[j]]–1 III.3 Analisi di complessità Esaminando l’algoritmo si osserva che vi sono due cicli di lunghezza k e due di lunghezza n. I cicli for di riga 1 e 5 costano Θ(k) mentre quelli di riga 3 e 7 costano Θ (n). Pertanto il costo complessivo è Θ(k + n). Nel caso in cui k = O(n) allora Θ(k + n) = Θ (n). Mergesort, Quicksort, Countingsort 15 Capitolo IV Modalità di testing Capitolo IV Modalità di testing Il testing degli algoritmi viene condotto per verificare le analisi teoriche compiute in precedenza. Il testing, inoltre, consente di valutare i termini costanti di tempo, che in una analisi asintotica vengono trascurati. La definizione di un “banco di prova” per un algoritmo deve dunque tener conto delle problematiche di rilevazione delle prestazioni dello stesso, oltre che garantirne il funzionamento corretto. Per consentire la raccolta di tali dati, si è realizzato un tool software che esaminasse i tre algoritmi presentati sia verificando il tempo effettivo di esecuzione, che il numero di operazioni necessarie a compiere l'ordinamento. I dati così raccolti sono poi stati esaminati graficamente per effettuare un'analisi comparativa fra i tre algoritmi. IV.1 Tool per il testing Il tool di testing realizzato in “testbench.cpp” compie le seguenti operazioni: ➢ valutazione dei tempi di esecuzione dell'algoritmo; ➢ controllo del numero di operazioni eseguite dall'algoritmo; ➢ verifica di correttezza dell'ordinamento. IV.2 Valutazione del tempo di esecuzione La valutazione dei tempi è un'operazione molto delicata. In un moderno computer, infatti, i tempi di calcolo sono influenzati da un gran numero di fattori. I tempi ottenuti sono da intendersi come affetti da un'incertezza variabile e difficilmente quantificabile, che, tuttavia, si può considerare sempre uguale per tutti gli algoritmi eseguiti (A patto di mantenere il sistema operativo in uno stato utente sempre uguale). La lettura dei risultati sperimentali potrebbe dunque mostrare qualche leggera incongruenza rispetto ai risultati teorici. Un altro importante fattore da considerare è la scala di grandezza con cui sono campionati i tempi di inizio e fine dell'algoritmo. Gli elaboratori moderni eseguono operazioni in tempi nell'ordine dei nanosecondi. Il campionamento eseguito dal programma di test è fatto invece in millisecondi. Questa differenza di ordini di grandezza comporta che per input di dimensione contenuta il tempo di esecuzioni risulti nullo. Allo stesso modo, input differenti, ma non troppo diversi nella dimensione, potrebbero presentare stesso tempo di esecuzione. Quanto detto finora, in alcuni casi, può dar luogo a risultati talvolta inesatti, ad esempio, due vettori di dimensioni simili e non troppo grandi, potrebbero presentare un tempo di ordinamento nullo per il più grande dei due, e non nullo per il più piccolo. Questo risultato è da intendersi come affetto da una imprecisione dovuta ad una delle variabili non controllabili dell'ambiente di esecuzione dell'algoritmo. Le imprecisioni presentate potrebbero far dubitare dell'effettiva utilità del test condotto, tuttavia, i risultati sono comunque indicativi del comportamento generale dell'algoritmo, anche se non ne identificano precisamente le prestazioni. Inoltre, il tempo di esecuzione, confrontato fra algoritmi differenti, nonostante le imprecisioni, permette di comprendere il peso delle costanti di tempo delle Mergesort, Quicksort, Countingsort 16 Capitolo IV Modalità di testing operazioni adoperate dall'algoritmo, non valutabili in altro modo. I tempi sono valutati facendo un semplice campionamento del tempo di sistema (fornito dalle librerie del sistema operativo), subito prima e subito dopo l'esecuzione dell'ordinamento. Il tempo di ordinamento è ottenuto come differenza fra i due tempi ottenuti. IV.3 Valutazione del numero di operazioni Per una valutazione più precisa delle prestazioni del singolo algoritmo, si è introdotto anche un controllo sul numero di operazioni eseguite. La scelta di questo controllo è dettata dalla necessità di rendere indipendente la stima delle prestazioni dal tempo, che è una variabile troppo influenzata da fattori esterni. Il numero di operazioni è un indice preciso per la stima del comportamento asintotico dell'algoritmo per via sperimentale, ma, a differenza del tempo, non aiuta molto a comprendere le differenze in termini delle costanti di tempo delle singole operazioni. L'introduzione del conteggio delle operazioni all'interno dell'algoritmo comporta chiaramente un incremento del tempo effettivo di esecuzione. Tuttavia, tale incremento, essendo costante e proporzionale proprio al numero di operazioni eseguite, non falsa i risultati ottenuti. Per esporre il modo di conteggio delle operazioni, esaminiamo il seguente frammento di codice: int n1 = q-p+1; int n2 = r-q; int *left = new int[n1]; int *right = new int[n2]; for(int i=0; i<n1 ; i++){ left[i] = a[p+i]; } for(int j=0; j<n2 ; j++){ right[j] = a[q+j+1]; } mopNumber++; mopNumber++; mopNumber++; Ciascun gruppo di operazioni è incluso in un “blocco” che viene conteggiato singolarmente. Quindi, tutte le operazioni prima del primo ciclo for vengono conteggiate come un'unica operazione, formando il primo blocco, i successivi due blocchi sono i due cicli for. Da notare che questa scelta è stata fatta poiché sarebbe superfluo considerare ciascuna operazione singolarmente, in quanto non verrebbe fornita alcuna informazione aggiuntiva. Le istruzioni presenti in un blocco, infatti, sono istruzioni che vengono sempre eseguite tutte, se la prima istruzione di quel blocco è eseguita, quindi, conteggiarle singolarmente aggiungerebbe una costante moltiplicativa sempre uguale in ogni ripetizione dell'algoritmo, non portando, di fatto, informazioni aggiuntive. IV.4 Verifica di correttezza La verifica di correttezza dell'algoritmo non fa altro che controllare che il vettore da ordinare, all'uscita dall'algoritmo sia effettivamente ordinato. Per farlo, viene adoperata la funzione isSorted(): Mergesort, Quicksort, Countingsort 17 Capitolo IV Modalità di testing bool isSorted(int A[], int dimension){ if(dimension<2) return true; for(int i=1; i<dimension ; i++){ if(A[i-1]>A[i]) return false; } return true; } Per verificare la correttezza di tale funzione, distringuiamo due casi fondamentali: ➢ dimension < 2 : in questo caso il vettore ha 1 o zero elementi, quindi è certamente ordinato e la funzione restituisce sempre un risultato positivo. ➢ dimension >=2 : in questo caso viene eseguito il ciclo for. L'invariante di ciclo è che il vettore A[0...i-1] è correttamente ordinato. Passo base: alla prima esecuzione, risulta essere i=1, quindi il vettore ha un unico elemento ed è ordinato. Nel ciclo, si esamina il secondo elemento del vettore, se è minore del primo, allora il vettore non è ordinato, e la funzione restituisce un risultato negativo. In caso contrario il ciclo prosegue. Passo induttivo: supponendo che per i=j-1 l'invariante sia valida, verifichiamo che lo sia anche per i=j. Quando si esamina A[j-1], se è maggiore di A[j], la funzione restituisce un risultato negativo, se invece risulta che A[j-1] è minore di A[j] il ciclo prosegue. Ma poiché A[0...j-1] è un vettore ordinato, e A[j-1] ed A[j] sono fra loro in ordine, il vettore A[0...j] sarà anch'esso ordinato. Passo conclusivo: All'uscita del ciclo i=dimension, ossia, poiché è ancora valida l'invariante, il vettore A[0...dimension-1] è ordinato, ma questo vettore è tutto il vettore restituito dalla funzione di ordinamento, che risulta correttamente ordinato, quindi la funzione restituisce un risultato positivo. Mergesort, Quicksort, Countingsort 18 Capitolo V Realizzazione degli algoritmi Capitolo V Realizzazione degli algoritmi Gli algoritmi sono stati realizzati in linguaggio C++, basandosi sullo pseudo-codice presentato in precedenza. In alcuni punti, sono state apportate piccole modifiche per risolvere problemi realizzativi. V.1 Merge sort unsigned long long mergesort(int a[], int p, int r){ if( p < r ){ int q = (p+r)/2; mopNumber++; mergesort(a,p,q); mergesort(a,q+1,r); merge(a,p,q,r); } return mopNumber; } Riguardo l'implementazione di mergesort, ci si è scostati poco dalla realizzazione in pseudo-codice. Da notare che la funzione restituisce il numero di operazioni compiute. Questa soluzione chiaramente non è necessaria nell'algoritmo, ma risulta utile per valutare le operazioni compiute. void merge(int a[], int p, int q, int r){ int n1 = q-p+1; int n2 = r-q; // Creazione vettori di supporto int *left = new int[n1]; int *right = new int[n2]; // Popolamento vettori di supporto for(int i=0; i<n1 ; i++){ left[i] = a[p+i]; } for(int j=0; j<n2 ; j++){ right[j] = a[q+j+1]; } // Merge ordinato dei vettori int i=0; int j=0; int k=p; while(i<n1 && j<n2){ if(left[i]<right[j]){ a[k] = left[i]; i++; } else { a[k] = right[j]; j++; } k++; } Mergesort, Quicksort, Countingsort mopNumber++; mopNumber++; mopNumber++; mopNumber++; mopNumber++; mopNumber++; mopNumber++; 19 Capitolo V Realizzazione degli algoritmi while(i<n1){ a[k] = left[i]; i++; k++; } while(j<n2){ a[k] = right[j]; j++; k++; } mopNumber++; mopNumber++; // Deallocazione vettori di supporto delete[] left; delete[] right; } La funzione merge presenta una sostanziale differenza rispetto alla versione in pseudo-codice. Si noti infatti l'assenza di sentinelle. Questa scelta è stata compiuta per evitare di dover scegliere un valore massimo per la creazione della sentinella. In alternativa si è adoperato un controllo sugli indici dei vettori L ed R. Tale controllo sostituisce il controllo sul valore di k, in quando la somma delle dimensioni massime degli indici i e j è pari alla dimensione massima dell'indice k. Quando si esce dal primo ciclo while, uno dei due vettori di supporto è stato completamente percorso, quindi, i restanti elementi da inserire appartengono tutti all'altro vettore. Questo è il significato dei due successivi cicli while. L'analisi di complessità fatta sullo pseudo-codice rimane valida in quanto il numero di iterazioni rimane sempre lo stesso, pari a r – p +1 . Infatti, in questa versione dell'algoritmo sono compiute n1 + n2 iterazioni, e sostituendo i valori delle due variabili si ha: n1 + n2 = q – p + 1 + r – q = r – p + 1. V.2 Quick sort unsigned long long quicksort(int A[],int p, int r){ if(p<r){ int q = partition(A,p,r); quicksort(A,p,q-1); quicksort(A,q+1,r); } opNumber++; return opNumber; } Le considerazioni fatte per la funzione mergesort sulle modifiche necessarie per tenere il conteggio dello operazioni, sono valide anche per quicksort. Mergesort, Quicksort, Countingsort 20 Capitolo V Realizzazione degli algoritmi int partition(int A[], int p, int r){ int x = A[r]; int i = p-1; for(int j=p; j<=r-1 ; j++){ if(A[j] <= x){ i = i+1; swap(&A[i],&A[j]); } } swap(&A[i+1],&A[r]); return i+1; } opNumber++; opNumber++; opNumber++; void swap(int *a,int *b){ int temp = *a; *a = *b; *b = temp; } opNumber++; La realizzazione della funzione partition è conforme allo pseudo-codice. V.3 Counting sort unsigned long long countingsort(int A[],int lengthA,int B[], int k){ int *C = new int[k+1]; copNumber++; for(int i=0; i<k+1 ; i++){ C[i] = 0; } for(int j=0; j < lengthA ; j++){ C[A[j]] = C[A[j]] + 1; } for(int i=1 ; i <= k ; i++){ C[i] = C[i] + C[i-1]; } for(int j=lengthA-1 ; j>=0 ; j--){ B[C[A[j]]-1] = A[j]; C[A[j]] = C[A[j]]-1; } copNumber++; copNumber++; copNumber++; copNumber++; delete[] C; return copNumber; } Le considerazioni fatte per le funzioni mergesort e quicksort sulle modifiche necessarie per tenere il conteggio dello operazioni, sono valide anche per countingsort. L'algoritmo è per il rimanente conforme allo pseudo-codice. Mergesort, Quicksort, Countingsort 21 Capitolo VI Testing delle prestazioni Capitolo VI Testing delle prestazioni Utilizzando il tool Testbench si sono raccolti dati sperimentali sui tre algoritmi presentati. Nel seguito è compiuta un'analisi degli algoritmi, verificando prima la coincidenza fra risultati sperimentali e analisi teorica, e, in seguito, effettuando un'analisi comparativa fra i tre algoritmi. I grafici presentati sono stati realizzati tramite la funzione spline. Il sistema su cui sono stati raccolti i dati presenta la seguente configurazione: Sistema operativo Microsoft Windows XP Versione 2002 service pack 2 CPU Intel T2250 @ 1,73 Ghz, 1,73 Ghz (dual core) Memoria RAM 2,0 GB SDRAM VI.1 Merge sort Per analizzare le prestazioni del mergesort, si è eseguito l'algoritmo su tre tipi di vettore: generato casualmente, ordinato, contro ordinato. L'algoritmo è stato eseguito 15 volte su ogni tipo di vettore, ad ogni ripetizione la dimensione dell'input veniva raddoppiata. La prima esecuzione ha avuto in input un vettore di 5.000 unità, la quindicesima, un vettore di 81.920.000 unità. VI.1.1 Analisi del tempo di esecuzione I grafici ottenuti, esaminando il tempo di esecuzione sono presentati nel seguito, sulle ordinate è presente il tempo in millisecondi, mentre sulle ascisse la dimensione del vettore: 65000 60000 55000 50000 45000 40000 35000 Contro-ordinato 30000 Ordinato Random 25000 20000 15000 10000 5000 0 0 25000000 Mergesort, Quicksort, Countingsort 50000000 75000000 100000000 22 Capitolo VI Testing delle prestazioni Dal grafico è evidente come in tutti e tre i casi l'andamento del tempo rispetto alla grandezza dell'input sia rappresentato da una retta di pendenza ridotta. E' tuttavia interessante notare come nel caso di vettore casuale, il tempo impiegato per l'ordinamento sia leggermente maggiore, rispetto agli altri casi, ordinato e contro-ordinato, per i quali il tempo di ordinamento è esattamente lo stesso. VI.1.2 Analisi del numero di operazioni I grafici ottenuti, esaminando il tempo di esecuzione sono presentati nel seguito, sulle ordinate è presente il numero di operazioni eseguite, mentre sulle ascisse la dimensione del vettore: 3000000000 2750000000 2500000000 2250000000 2000000000 1750000000 Contro-ordinato 1500000000 Ordinato Random 1250000000 1000000000 750000000 500000000 250000000 0 0 5000000 10000000 15000000 20000000 25000000 Anche il numero di operazioni cresce con l'input secondo una legge rappresentata da una retta. Come era facile aspettarsi osservando i risultati dei tempi, il vettore random ha bisogno di un maggior numero di operazioni per essere ordinato. E' interessante notare come, anche se in numero molto ridotto, il vettore ordinato richieda più operazioni del vettore contro-ordinato. Questa caratteristica non era risultata nel grafico temporale, dove, evidentemente, visto il numero ridotto di operazioni, il campionamento del tempo al millisecondo non è sufficiente ad evidenziare la differenza. VI.1.3 Confronto con i risultati teorici Quanto evidenziato dai dati sperimentali è in completo accordo con l'analisi teorica compiuta, nel grafico riportato di seguito, è stata riportata anche la funzione nLog(n), dove n è la grandezza dell'input. Mergesort, Quicksort, Countingsort 23 Capitolo VI Testing delle prestazioni 3000000000 2750000000 2500000000 2250000000 2000000000 1750000000 Contro-ordinato 1500000000 Ordinato Random nLog(n) 1250000000 1000000000 750000000 500000000 250000000 0 0 5000000 10000000 15000000 20000000 25000000 E' possibile vedere dal grafico come l'andamento teorico individuato combaci perfettamente con i risultati raccolti sperimentalmente. Il grafico riporta il numero di operazioni sulle ordinate, tuttavia, un risultato analogo si sarebbe ottenuto considerando i tempi. Mergesort, Quicksort, Countingsort 24 Capitolo VI Testing delle prestazioni VI.2 Quick sort L'analisi sperimentale del quicksort è risultata più complessa rispetto a quella compiuta per il mergesort. Sebbene siano entrambi algoritmi ricorsivi, il quicksort effettua un numero sensibilmente maggiore di chiamate rispetto al mergesort se il vettore è già ordinato (o controordinato), quindi, se si creano delle partizioni del vettore sbilanciate. Questa profonda ricorsione comporta un rischio non remoto di stack overflow nel caso di input di dimensione considerevole. Per questo motivo si è dovuto sensibilmente ridurre l'ordine di grandezza dei vettori su cui sono stati effettuati i test. Per analizzare le prestazioni del quicksort, si è eseguito l'algoritmo su tre tipi di vettore: generato casualmente, ordinato, contro ordinato. L'algoritmo è stato eseguito 7 volte su ogni tipo di vettore, ad ogni ripetizione la dimensione dell'input veniva raddoppiata. La prima esecuzione ha avuto in input un vettore di 500 unità, la settima, un vettore di 32.000 unità. Dimensioni maggiori del vettore, sul sistema adoperato per eseguire i test, hanno causato errori di stack overflow. VI.2.1 Analisi del tempo di esecuzione I grafici ottenuti, esaminando il tempo di esecuzione sono presentati nel seguito, sulle ordinate è presente il tempo in millisecondi, mentre sulle ascisse la dimensione del vettore: 16 15 14 13 12 11 10 9 8 Random 7 6 5 4 3 2 1 0 0 5000 10000 15000 20000 25000 30000 35000 Nel caso di un vettore generato casualmente, il tempo di esecuzione rilevato non è indicativo poiché la dimensione ridotta del vettore rende le operazioni troppo veloci per un campionamento del tempo fatto al millisecondo. Tuttavia, quando il vettore è generato casualmente, è improbabile che presenti sbilanciamento nel partizionamento. E' stato dunque eseguito un test con vettori di dimensione pari a quelli usati per il mergesort, ma generati soltanto casualmente, e, come era lecito aspettarsi, non si sono verificati stack overflow. I risultati sono presentati nel seguente grafico: Mergesort, Quicksort, Countingsort 25 Capitolo VI Testing delle prestazioni 275000 250000 225000 200000 175000 150000 Random 125000 100000 75000 50000 25000 0 0 10000000 20000000 30000000 40000000 50000000 60000000 70000000 80000000 90000000 Si può vedere come l'andamento del tempo rispetto alla dimensione dell'input è tendente al lineare, specialmente per grandi dimensioni dell'input. La parte iniziale non presenta questo andamento per via delle interferenze causate dal sistema operativo nella gestione delle chiamate ricorsive, che introducono ritardi non strettamente dipendenti dall'algoritmo. Per grandi dimensioni dell'input, il contributo di queste interferenze è proporzionalmente inferiore, quindi si assume un andamento lineare caratteristico del funzionamento dell'algoritmo. 1400 1300 1200 1100 1000 900 800 700 Contro-ordinato 600 Ordinato 500 400 300 200 100 0 0 5000 10000 15000 20000 25000 30000 35000 Il grafico mostra come nei casi di partizionamento sbilanciato il tempo di esecuzione sia molto più grande rispetto al parizionamento bilanciato. Un vettore casuale di 32.000 elementi è infatti ordinato in 16 millisecondi, mentre un vettore ordinato sempre di 32.000 elementi è ordinato in circa 1400 millisecondi. L'andamento del tempo rispetto alla dimensione dell'input è di tipo quadratico. Mergesort, Quicksort, Countingsort 26 Capitolo VI Testing delle prestazioni VI.2.2 Analisi del numero di operazioni I grafici ottenuti, esaminando il tempo di esecuzione sono presentati nel seguito, sulle ordinate è presente il numero di operazioni eseguite, mentre sulle ascisse la dimensione del vettore: Anche per il numero di operazioni si osserva la grande differenza nel caso di partizionamento bilanciato o sbilanciato: 1100000 1000000 900000 800000 700000 600000 Random 500000 400000 300000 200000 100000 0 0 2000 4000 6000 8000 10000 12000 14000 16000 550000000 500000000 450000000 400000000 350000000 300000000 Contro-ordinato Ordinato 250000000 200000000 150000000 100000000 50000000 0 0 2500 5000 7500 10000 12500 15000 17500 In questo caso è anche ben visibile la differenza di andamento asintotico fra caso migliore (vettore random) e caso peggiore (vettore ordinato/contro-ordinato). Nel primo caso l'andamento è descritto da una retta, nel secondo la dipendenza del numero di operazioni dall'input è di tipo quadratico. Mergesort, Quicksort, Countingsort 27 Capitolo VI Testing delle prestazioni VI.2.3 Confronto con i risultati teorici Quanto evidenziato dai dati sperimentali è in completo accordo con l'analisi teorica compiuta, nel grafico riportato di seguito è stata riportata anche la funzione nLog(n), dove n è la grandezza dell'input. Nel caso migliore infatti, il numero di operazioni assume tale andamento rispetto alla dimensione dell'input: 1200000 1100000 1000000 900000 800000 700000 600000 Random nLog(n) 500000 400000 300000 200000 100000 0 0 1000 2000 3000 4000 5000 6000 7000 8000 9000 10000 11000 12000 13000 14000 15000 16000 550000000 500000000 450000000 400000000 350000000 300000000 Contro-ordinato 250000000 Ordinato n^2 200000000 150000000 100000000 50000000 0 0 2000 4000 6000 8000 10000 12000 14000 16000 Nel caso peggiore, quindi con partizioni sbilanciate, l'andamento del numero di operazioni rispetto alla dimensione dell'input è di tipo quadratico, come è mostrato nel precedente grafico. Mergesort, Quicksort, Countingsort 28 Capitolo VI Testing delle prestazioni VI.3 Counting sort Per analizzare le prestazioni del countingsort, si è eseguito l'algoritmo su tre tipi di vettore: generato casualmente, ordinato, contro ordinato. L'algoritmo è stato eseguito 15 volte su ogni tipo di vettore, ad ogni ripetizione la dimensione dell'input veniva raddoppiata. La prima esecuzione ha avuto in input un vettore di 5.000 unità, la quindicesima, un vettore di 81.920.000 unità. VI.3.1 Analisi del tempo di esecuzione I grafici ottenuti, esaminando il tempo di esecuzione sono presentati nel seguito, sulle ordinate è presente il tempo in millisecondi, mentre sulle ascisse la dimensione del vettore: 4000 3750 3500 3250 3000 2750 2500 2250 Contro-ordinato Ordinato 2000 1750 Random 1500 1250 1000 750 500 250 0 0 20000000 40000000 60000000 80000000 100000000 Il tempo di esecuzione ha un andamento lineare rispetto alla dimensione dell'input sommata al massimo elemento da ordinare. Si può notare come i tempi in caso di vettore random siano quasi il doppio rispetto al caso di vettore ordinato. VI.3.2 Analisi del numero di operazioni I grafici ottenuti, esaminando il tempo di esecuzione sono presentati nel seguito, sulle ordinate è presente il numero di operazioni eseguite, mentre sulle ascisse la dimensione del vettore: Mergesort, Quicksort, Countingsort 29 Capitolo VI Testing delle prestazioni 170000000 160000000 150000000 140000000 130000000 120000000 110000000 100000000 90000000 80000000 70000000 Contro-ordinato Ordinato Random 60000000 50000000 40000000 30000000 20000000 10000000 0 0 5000000 10000000 15000000 20000000 25000000 Dall'analisi del numero di operazioni eseguite emergono due dati molto interessanti. Nei casi di vettori ordinato e contro-ordinato il numero di operazioni eseguite è praticamente lo stesso, mentre nei tempi il vettore contro-ordinato è più veloce di circa il 15%. Quindi le operazioni compiute nel caso di vettore ordinato richiedono più tempo. Secondo dato molto interessante è che il vettore generato casualmente è quello che richiede meno operazioni per essere ordinato, meno della metà rispetto agli altri casi, ma impiega il doppio del tempo. Questo risultato evidenzia il ruolo giocato dalle costanti di tempo, che una semplice analisi asintotica non può mostrare. La spiegazione di questa differenza è data dai diversi ordini di grandezza degli elementi da ordinare. Nel caso di vettore ordinato il massimo fra gli elementi da ordinare era infatti 20'479'999, mentre nel caso di vettore random era 32'700. Questa differenza spiega la differenza di numero di operazioni e rafforza l'osservazione fatta sulle costanti di tempo. VI.3.3 Confronto con i risultati teorici Quanto evidenziato dai dati sperimentali è in completo accordo con l'analisi teorica compiuta, infatti, non esiste un caso migliore o peggiore, ma tutti gli andamenti sono di tipo lineare, come facilmente si può osservare dai grafici presentati. Mergesort, Quicksort, Countingsort 30 Capitolo VI Testing delle prestazioni VI.4 Confronto fra i tre algoritmi Il diagramma riportato nel seguito fa un confronto fra i tre algoritmi nei casi di vettore generato casualmente, per compararne le rispettive costanti di tempo, non evidenziate dall'analisi asintotica: 275000 250000 225000 200000 175000 150000 Countingsort Quicksort Mergesort 125000 100000 75000 50000 25000 0 0 25000000 50000000 75000000 100000000 Dai risultati ottenuti è facile vedere come fra tutti il countingsort sia in assoluto l'algoritmo che impiega minor tempo. Tuttavia, non va dimenticato che non esegue un ordinamento sul posto, e che necessita della conoscenza del massimo valore fra gli elementi da ordinare. Il tempo ridotto del countingsort si spiega facilmente ricordando che è un algoritmo iterativo e che quindi non necessita di continue chiamate a funzione come fanno mergesort e quicksort. Questi ultimi due presentano costanti di tempo differenti, in particolare il quicksort utilizza operazioni più veloci, ma facendo ordinamento sul posto, per input molto grandi risulta sensibilmente più lento del mergesort. Tuttavia, per input di dimensione fino a circa 10'500'000, il quicksort risulta più efficiente del mergesort. Mergesort, Quicksort, Countingsort 31 Appendice A: Codice sorgente Appendice A: Codice sorgente TestBench.cpp void main(){ Selection selection = menuSelection(); fileInitialization(); int dimension = selection.vectorLength; do{ int *a; switch(selection.vectorGenerationType){ case 1: a = getRandomVector(dimension); break; case 2: a = getSortedVector(dimension); break; case 3: a = getRSortedVector(dimension); break; } _SYSTEMTIME *stime = new _SYSTEMTIME; //Counting sort datas int *b = new int[dimension]; for(int i=0; i<dimension ; i++){ b[i] = 0; } int aMax = a[0]; for(int i=0; i<dimension ; i++){ if(aMax < a[i]) aMax = a[i]; } //ORDINAMENTO E VERIFICA DEI TEMPI GetSystemTime(stime); long startTime = stime->wMinute * 60 * 1000 + stime->wSecond * 1000 + stime->wMilliseconds; switch(selection.algorithm){ case 1: operationNumber = mergesort(a,0,dimension-1); break; case 2: operationNumber = quicksort(a,0,dimension-1); break; case 3: operationNumber = countingsort(a,dimension,b,aMax); break; } Mergesort, Quicksort, Countingsort 32 Appendice A: Codice sorgente GetSystemTime(stime); long endTime = stime->wMinute * 60 * 1000 + stime->wSecond * 1000 + stime->wMilliseconds; long totalTime = endTime - startTime; // VERIFICA ORDINAMENTO bool sorted; if(selection.algorithm==3) sorted = isSorted(b,dimension); else sorted = isSorted(a,dimension); if(!sorted) printf("\n !!!Errore di ordinamento!!!\n"); delete[] a; delete[] b; //Stampa su file printOnFile(dimension,totalTime); if(totalTime == 0) printf("\nInput size: %d Time: %d Operations: %u",dimension,totalTime,operationNumber); printf("\nInput size: %d Time: %d Operations: %u",dimension,totalTime,operationNumber); else dimension = dimension * selection.inputIncreaseFactor; selection.repetitionNumber--; }while(selection.repetitionNumber > 0); printf("\n\n"); system("PAUSE"); } Mergesort, Quicksort, Countingsort 33 Appendice A: Codice sorgente bool isSorted(int A[], int dimension){ if(dimension<2) return true; for(int i=1; i<dimension ; i++){ if(A[i-1]>A[i]) return false; } return true; } int *getRandomVector(int dimension){ int *a = new int[dimension]; for(int i=0; i<dimension ; i++) a[i] = rand(); return a; } int *getSortedVector(int dimension){ int *a = new int[dimension]; for(int i=0; i<dimension ; i++) a[i] = i; return a; } int *getRSortedVector(int dimension){ int *a = new int[dimension]; for(int i=0; i<dimension ; i++) a[i] = dimension-i; return a; } Mergesort, Quicksort, Countingsort 34 Appendice A: Codice sorgente Selection menuSelection(){ printf("========= Sort Algorithms Test Bench =========\n\n"); printf("Il programma realizza un testing dei tempi degli algoritmi MergeSort, QuickSort, Counting Sort."); printf(" L'utente specifica i dati relativi alla generazione dell'input. Viene inoltre specificato quante"); printf(" volte va eseguito il test sull'algoritmo e, ad ogni ripetizione, il fattore moltiplicativo di"); printf(" crescita dell'input. I risultati del test vengono poi memorizzati nel file ResultsLog.txt .\n"); Selection selection; do{ printf("\n Algoritmo di ordinamento (1.MergeSort,2.QuickSort,3.CountingSort): "); scanf("%d", &selection.algorithm); }while(selection.algorithm > 3 || selection.algorithm < 1); do{ printf("\n Tipo di generazione vettore (1.Random,2.Ordinato,3.Contro-ordinato): "); scanf("%d", &selection.vectorGenerationType); }while(selection.vectorGenerationType > 3 || selection.vectorGenerationType < 1); do{ printf("\n Inserire la grandezza dell'input: "); scanf("%d", &selection.vectorLength); }while(selection.vectorLength < 1); do{ printf("\n Inserire il fattore moltiplicativo dell'input: "); scanf("%d", &selection.inputIncreaseFactor); }while(selection.inputIncreaseFactor < 1); do{ printf("\n Inserire il numero di ripetizioni del test: "); scanf("%d", &selection.repetitionNumber); }while(selection.repetitionNumber < 1); return selection; } Mergesort, Quicksort, Countingsort 35 Appendice A: Codice sorgente void fileInitialization(){ FILE *f = fopen("ResultsLog.txt", "w"); char text1[] = "La seguente tabella riporta: (Dimensione input, tempo di ordinamento, numero operazioni)\n\n"; fwrite(text1,sizeof(text1)-1,1,f); fclose(f); } void printOnFile(int dimension, long totalTime){ FILE *f = fopen("ResultsLog.txt", "a"); char *buffer = new char[80]; char *string1 = _itoa(dimension,buffer,10); int i=0; char stopChar = string1[i]; while(stopChar!='\0'){ stopChar = string1[i]; i++; } fwrite(string1,i-1,1,f); char text2[] = " "; fwrite(text2,sizeof(text2)-1,1,f); char *string2 = _ltoa(totalTime,buffer,10); i=0; stopChar = string2[i]; while(stopChar!='\0'){ stopChar = string2[i]; i++; } fwrite(string2,i-1,1,f); char text3[] = " "; fwrite(text3,sizeof(text3)-1,1,f); char *string3 = _ultoa(operationNumber,buffer,10); i=0; stopChar = string3[i]; while(stopChar!='\0'){ stopChar = string3[i]; i++; } fwrite(string3,i-1,1,f); fwrite("\n",1,1,f); fclose(f); } Mergesort, Quicksort, Countingsort 36 Appendice A: Codice sorgente mergesort.cpp unsigned long long mergesort(int a[], int p, int r){ if( p < r ){ int q = (p+r)/2; mopNumber++; mergesort(a,p,q); mergesort(a,q+1,r); merge(a,p,q,r); } return mopNumber; } void merge(int a[], int p, int q, int r){ int n1 = q-p+1; int n2 = r-q; // Creazione vettori di supporto int *left = new int[n1]; int *right = new int[n2]; mopNumber++; // Popolamento vettori di supporto for(int i=0; i<n1 ; i++){ left[i] = a[p+i]; mopNumber++; } for(int j=0; j<n2 ; j++){ right[j] = a[q+j+1]; mopNumber++; } // Merge ordinato dei vettori int i=0; int j=0; int k=p; mopNumber++; while(i<n1 && j<n2){ if(left[i]<right[j]){ a[k] = left[i]; i++; mopNumber++; } else { a[k] = right[j]; j++; mopNumber++; } k++; mopNumber++; } Mergesort, Quicksort, Countingsort 37 Appendice A: Codice sorgente while(i<n1){ a[k] = left[i]; i++; k++; mopNumber++; } while(j<n2){ a[k] = right[j]; j++; k++; mopNumber++; } // Deallocazione vettori di supporto delete[] left; delete[] right; } Mergesort, Quicksort, Countingsort 38 Appendice A: Codice sorgente quicksort.cpp unsigned long long opNumber = 0; unsigned long long quicksort(int A[],int p, int r){ if(p<r){ int q = partition(A,p,r); opNumber++; quicksort(A,p,q-1); quicksort(A,q+1,r); } return opNumber; } int partition(int A[], int p, int r){ int x = A[r]; int i = p-1; opNumber++; for(int j=p; j<=r-1 ; j++){ if(A[j] <= x){ i = i+1; swap(&A[i],&A[j]); opNumber++; } opNumber++; } swap(&A[i+1],&A[r]); return i+1; } void swap(int *a,int *b){ int temp = *a; opNumber++; *a = *b; *b = temp; } Mergesort, Quicksort, Countingsort 39 Appendice A: Codice sorgente countingsort.cpp unsigned long long copNumber = 0; unsigned long long countingsort(int A[],int lengthA,int B[], int k){ int *C = new int[k+1]; copNumber++; for(int i=0; i<k+1 ; i++){ C[i] = 0; copNumber++; } for(int j=0; j < lengthA ; j++){ C[A[j]] = C[A[j]] + 1; copNumber++; } for(int i=1 ; i <= k ; i++){ C[i] = C[i] + C[i-1]; copNumber++; } for(int j=lengthA-1 ; j>=0 ; j--){ B[C[A[j]]-1] = A[j]; copNumber++; C[A[j]] = C[A[j]]-1; } delete[] C; return copNumber; } Mergesort, Quicksort, Countingsort 40 Appendice B: il tool TestBench Appendice B: il tool TestBench Il tool TestBench è stato sviluppato appositamente per i tre algoritmi presentati nel testo. Il tool permette di scegliere quale algoritmo testare e come generare l'input. I dati raccolti sono poi mostrati a video e salvati in un file di log. Nel seguito è mostrato un esempio di funzionamento: 1. Si sceglie l'algoritmo da testare: 2. Si seleziona il modo di generazione dell'input 3. Si indica la dimensione iniziale dell'input: 4. Si specifica il fattore moltiplicativo dell'input, ossia, la variabile che viene moltiplicata alla dimensione dell'input iniziale, per eseguire ogni successiva ripetizione dell'algoritmo. Mergesort, Quicksort, Countingsort 41 Appendice B: il tool TestBench 5. Si indica il numero di ripetizioni dell'algoritmo da eseguire (Ad ogni ripetizione l'input cresce secondo il fattore specificato al punto 4) 6. Vengono stampati a video i risultati ed allo stesso tempo viene creato il file di ResultsLog.txt Mergesort, Quicksort, Countingsort 42