Gli algoritmi di Ricerca e di Ordinamento Sabrina Mantaci A.A.2007-2008 1 Gli algoritmi di ricerca Il problema della ricerca è il seguente: dato un insieme V di oggetti di un certo tipo, e un altro oggetto x dello stesso tipo, stabilire se x ∈ V . Ci proponiamo di scrivere un programma che risolve questo problema. Prima di tutto quando abbiamo a che fare con un problema complesso, ci dobbiamo domandare come rappresentare il nostro insieme. Infatti potremmo rappresentarlo mediante un tipo strutturato set, mediante un array, mediante una lista concatenata, mediante un albero, etc. In realtà quest’osservazione non è banale, visto che questo problema può essere impostato su ciascuna di queste strutture, con performances diverse che vedremo via via. Per il momento vediamo come il problema può essere risolto utilizzando un vettore come struttura dati per rappresentare il nostro insieme. Supponiamo quindi di avere strutturato i nostri dati (per esempio interi) in un vettore V e di voler verificare se un certo intero x è contenuto o meno nel vettore. Scriviamo quindi una funzione a valori booleani che presi in input (ossia come parametri) un vettore di interi e un numero intero, restituisce TRUE se l’elemento è contenuto nel vettore, e FALSE altrimenti. I parametri saranno ovviamente passati per valore, visto che il problema non richiede di effettuare alcuna modifica nè nel vettore, nè nell’elemento da cercare. La funzione sarà la seguente: Function ricercalineare(V:vettore; x:integer):boolean; var i:integer; trovato:boolean; begin i:=1; trovato:=FALSE; while (i<=n) and (trovato=FALSE) do if V[i]=x then trovato:=TRUE else i:=i+1 ricercalineare:=trovato; end; 1 La funzione ricercalineare ha una complessità di tempo che dipende dal numero di volte in cui durante lo svolgimento della funzione vengono effettuate le istruzioni del ciclo. Il caso peggiore può essere individuato come quello in cui l’elemento x non appartiene all’insieme, perchè in questo caso è necessario scandire tutto il vettore. In questo caso si è costretti a compiere n iterazioni, per cui la complessità dell’algoritmo è O(n) nel caso peggiore. Per inciso, è questo il motivo per cui è denominata ricerca lineare. Tuttavia, se gli elementi del vettore sono ordinati dal più piccolo al più grande possiamo fare di meglio. In questo caso infatti si può osservare che è possibile terminare la ricerca lineare con risposta FALSE nel momento in cui procedendo da sinistra verso destra troviamo un elemento più grande di x. Infatti se gli elementi sono ordinati, appena troviamo un elemento del vettore più grande di x, tutti quelli alla sua destra saranno ancora più grandi. Questo ci autorizza a dire che x non è contenuto nell’insieme. Il programma diventa il seguente: Function ricercalineare(V:vettore; x:integer):boolean; var i:integer; trovato:boolean; begin i:=1; trovato:=FALSE; while (i<=n) and (trovato=FALSE) do if V[i]=x then trovato:=TRUE else if V[i]>x then i:=n+1 else i:=i+1 ricercalineare:=trovato; end; Tuttavia anche se in media la situazione migliora nei casi in cui x non appartiene al vettore, la complessità di tempo di questa funzione resta O(n) nel caso peggiore. Infatti l’elemento x potrebbe trovarsi nell’ultima posizione del vettore, oppure essere maggiore di tutti gli elementi del vettore, e questo comporterebbe comunque una scansione di tutto il vettore. Invece, nel caso in cui il vettore è ordinato, possiamo sfruttare quest’ipotesi supplementare per operare in maniera diversa e ottenere un grande vantaggio in termini di tempo di calcolo. Confrontiamo l’elemento x con quello che si trova a metà del vettore. Se in quel punto c’è l’elemento cercato, la funzione termina e restituisce il valore TRUE. In caso contrario, ci sono due possibili casi: o è maggiore, o è minore dell’elemento a metà del vettore. Se x è minore di questo elemento, allora possiamo essere sicuri che se c’è si troverà nella prima metà del vettore, poichè gli elementi nella seconda metà sono tutti maggiori di x. Viceversa, se x è più grande di quest’elemento, cercheremo x nella seconda metà del vettore. Questo comporta che ad ogni iterazione scartiamo metà dei confronti possibili. Questa intuitivamente è la ragione per cui si ottiene un grande miglioramento 2 della complessità. La funzione che implementa quest’algoritmo, che chiameremo ricerca binaria, è la seguente: Function ricercabinaria(V:vettore; x:integer):boolean; var i, j, m: integer; trovato: boolean; begin i:=1; j:=n; trovato:=FALSE; while (i<>j) and (trovato=FALSE) do begin m:=(i+j) div 2; if V[m]=x then trovato:=TRUE else if V[i]>x then j:=m-1 else i:=m+1 end; ricercabinaria:=trovato; end; Con quest’algoritmo, ogni volta che si fa un confronto viene eliminata la metà degli elementi rimanenti. Quindi il numero dei confronti effettuati è uguale al numero di volte che possiamo dividere la dimensione iniziale n per 2 fino ad arrivare ad uno. Questo numero è il logaritmo in base 2 di n. Dunque quest’algoritmo ha una complessità di tempo O(log2 n). Questo algoritmo è chiaramente molto più efficiente del precedente, visto che f (n) = log2 n è una funzione che al crescere di n, cresce molto più lentamente della funzione g(n) = n. Per avere un’idea di questa differenza, si pensi al tempo che occorre a trovare un nome nell’elenco telefonico (ordinato) utilizzando il metodo di ricerca che istintivamente utilizziamo, e che è di tipo binario, e a quanto tempo occorrerebbe per trovare un nome se l’elenco telefonico non fosse ordinato. Ovviamente nell’algoritmo ricercabinaria viene pesantemente utilizzata l’ipotesi che il vettore sia ordinato. Non si può quindi applicare la ricerca binaria in mancanza di quest’ipotesi. Si noti che questo algoritmo ha per definizione stessa una forma ricorsiva: Function ricercabinaria(V:vettore; x,primo,ultimo:integer):integer; var m:integer; begin if primo=ultimo then if V[primo]=x then RICERCABINARIA=TRUE else RICERCABINARIA=FALSE; else begin m:=(primo+ultimo) div 2; if V[m]=x then trovato:=TRUE 3 else if V[m]>x then ricercabinaria:=ricercabinaria(V,x,primo,m-1) else ricercabinaria:=ricercabinaria(V,x,m+1,ultimo) end; end; Si lascia come esercizio di scrivere un programma ricorsivo che realizzi la ricerca lineare. 4 2 Algoritmi di ordinamento Definizione 2.1 Dato un insieme X, una relazione R sull’insieme X è un insieme di coppie di elementi di X. Detto in altre parole, R è un sottoinsieme dei prodotto cartesiano X × X. Definizione 2.2 Diciamo che una relazione R su un insieme X è una relazione d’ordine se i suoi elementi godono delle proprietà antisimmetrica e transitiva. Ossia: • per ogni coppia di elementi x, y ∈ X le condizioni xRy e yRx implicano che x = y (proprietà antisimmetrica); • xRy e yRz allora xRz (proprietà transitiva). Una relazione R su un insieme X si dice relazione d’ordine totale se comunque presi due elementi x, y ∈ X, o xRy oppure yRx. Definizione 2.3 Diciamo che un insieme X è totalmente ordinato se è possibile definire una relazione d’ordine totale R fra i suoi elementi. Esempio: 1. I numeri interi, i numeri relativi, i numeri razionali, i numeri reali, rispetto alla relazione ≤ sono insiemi totalmente ordinati; 2. Le lettere dell’alfabeto, rispetto all’ordine alfabetico. 3. Dato un insieme finito Σ, detto alfabeto, definiamo Σ∗ l’insieme di tutte le sequenze finite di elementi di Σ, che chiamiamo parole o stringhe. Data una parola u ∈ Σ∗ , denotiamo con ui l’i-esimo carattere di u e con |u| la lunghezza di u. Su Σ∗ si può definire l’ordine lessicografico. L’ordine lessicografico è definito nel modo seguente: date due parole u, v ∈ Σ∗ , diciamo che u ≤lex v se: • esiste un k tale che ui = vi per ogni i ≤ k |u| = k e |v| > k (ossia u è prefisso di v); • oppure esiste un k tale che ui = vi per ogni i ≤ k e uk+1 < vk+1 (ossia u e v sono uguali fino al k esimo carattere, ma il (k + 1)-esimo carattere di u è più piccolo del (k + 1)-esimo carattere di v). L’ordine lessicografico è una relazione d’ordine totale su Σ∗ . 4. La relazione di inclusione fra i sottoinsiemi di un insieme è una relazione d’ordine (verificare), ma non è una relazione d’ordine totale. Infatti possiamo avere due sottoinsiemi di X che non sono in relazione di inclusione uno con l’altro. 5 Sia dato un insieme di elementi A = {a1 , a2 , . . . , an }, presi da un insieme totalmente ordinato. Il problema dell’ordinamento (o, in inglese sorting) consiste nel trovare una permutazione σ degli indici {1, 2, . . . , n} tale che aσ(1) ≤ aσ(2) ≤ · · · ≤ aσ(n) Per risolvere il problema del sorting esistono diversi metodi, ciascuno dei quali ha dei pregi e dei difetti. In mancanza di ipotesi supplementari, tutti gli algoritmi di sorting sono basati su confronti degli elementi dell’insieme. Per avere un algoritmo di sorting efficiente, dobbiamo cercare di limitare il numero di confronti. Ma fino a che punto è possibile limitare il numero di questi confronti? Tutti gli algoritmi di sorting basati su confronti rispettano il seguente teorema fondamentale: Teorema 2.1 [Lower bound per gli algoritmi di sorting] Nessun algoritmo di sorting basato su confronti può avere complessità di tempo “worst case” inferiore a O(n log2 n). Ossia un qualunque algoritmo di ordinamento basato su confronti deve svolgere necessariamente almeno O(n log2 n) confronti, se applicato a un generico vettore. 2.1 Algoritmo di ordinamento per selezione (selection sort) Il primo algoritmo che esaminiamo è quello più immediato. Supponiamo che i nostri dati siano organizzati in un array A = [a1 , a2 , . . . , an ]. L’idea del selectionsort è la seguente: 1. si determina l’elemento più piccolo di tutto il vettore; 2. lo si scambia con l’elemento in prima posizione del vettore 3. si cerca il secondo elemento più piccolo lo si scambia con l’elemento in seconda posizione del vettore; 4. si procede cosı̀ fino a quando l’intero vettore è ordinato. Esempio: Supponiamo di avere il seguente array A : 23 14 6 12 34 4 15 20 Al primo passo, l’algoritmo deve cercare l’elemento più piccolo del vettore e piazzarlo nella prima posizione. Con una scansione del vettore verifichiamo che il minimo del vettore è il 4 che si trova nella posizione 6. Scambiamo dunque A[1] e A[6]. 4 14 6 12 34 23 15 20 Alla seconda iterazione verrà cercato il secondo elemento più piccolo del vettore, che è il 6, che si trova nella posizione 3. Alla fine della seconda iterazione A[2] e A[3] vengono scambiati. Cosı̀ via in tutte le altre iterazioni: 6 i=3 4 6 14 12 34 23 15 20 i=4 4 6 12 14 34 23 15 20 4 6 12 14 34 23 15 20 4 6 12 14 15 23 34 20 4 6 12 14 15 20 34 23 20 23 34 i=5 i=6 i=7 Si ottiene quindi: 4 6 12 14 15 La procedura che implementa l’algoritmo di selectionsort sarà la seguente: Procedure selectionsort (var V:vettore); var i, j:integer; procedure scambia(var x,y:integer); var aux:integer; begin aux:=x; x:=y; y:=aux; end; begin for i:=1 to n do begin indmin:=i; for j:=i+1 to n do if V[j]<V[indmin] then indmin:=j scambia (V[i],V[indmin]) end; end; 7 Qual’è la complessità di quest’algoritmo? Dobbiamo contare il numero di confronti effettuati. Nella prima iterazione vengono svolti n − 1 confronti per trovare il minimo, nella seconda n − 2 confronti per trovare il secondo minimo , ... , nella i-esima n − i confronti,..., nella (n − 1)-esima 1 confronto. Dunque in totale avremo: T (n) = n−1 X i = n(n − 1)/2 i=1 Dunque l’algoritmo è O(n2 ). Definizione 2.4 Un algoritmo dice adattivo se la complessità di tempo della sua esecuzione dipende dall’input. Si dice non adattivo nel caso contrario. Si può notare che nel selectionsort il numero di confronti effettuati dall’algoritmo è indipendente dal tipo di input. Per esempio se il vettore è ordinato sin dall’inizio, l’algoritmo non si accorge che non deve fare nessuno scambio prima di avere svolto tutti i confronti. Il selectionsort è quindi un algoritmo non adattivo. Definizione 2.5 Un algoritmo di sorting si dice stabile se gli elementi che hanno lo stesso valore appaiono dopo l’applicazione dell’algoritmo nello stesso ordine relativo in cui si trovavano nel vettore iniziale. L’importanza di questa proprietà sta nel fatto che a volte gli ordinamenti vengono fatti secondo una “chiave” di un record (un particolare insieme di dati relativi ad una stessa entità). Supponiamo per esempio di voler ordinare un elenco di persone secondo il loro cognome, e per quelle persone che hanno lo stesso cognome, secondo l’ordine dei loro nomi. Supponiamo di avere già ordinato i nomi. Se poi ordiniamo secondo i cognomi, vogliamo che venga rispettato, per tutte le persone che hanno uno stesso cognome, l’ordine sui nomi che era stato ottenuto in precedenza. In questo caso occorre che il secondo ordinamento venga effettuato mediante un ordinamento stabile. Detto questo, possiamo osservare che l’algoritmo di selectionsort descritto non è stabile. Consideriamo ad esempio il seguente vettore che contiene due elementi uguali: 4 4 23 2 34 12 15 20 Per chiarezza indichiamo con 41 il 4 che compare in posizione 1 e con 42 quello che compare in posizione 2. 41 42 23 2 34 12 15 20 Alla prima iterazione l’elemento nella prima posizione verrà scambiato col minimo, ossia il valore 2 che si trova alla quarta posizione. 2 42 23 41 34 12 8 15 20 All’iterazione successiva, il 4 che si trova nella posizione 2 non verrà spostato, poichè è il minimo degli elementi rimanenti. Alla terza iterazione il 4 che si trova alla prima posizione verrà scambiato con l’elemento in posizione 3. 2 42 41 23 34 12 15 20 Dopo questa iterazione i 4 alla posizione 2 e 3 sono nella loro posizione definitiva, ma come si evince dagli indici, si trovano in ordine inverso rispetto all’ordine in cui si trovavano all’inizio. Dunque l’algoritmo non è stabile. 2.2 Bubblesort L’algoritmo di sorting che andiamo a descrivere è basato sulla seguente idea. Si scandisce il vettore da sinistra a destra, confrontando ogni elemento con quello adiacente, e scambiandoli se il primo è maggiore del secondo. Ci si rende conto subito che una sola scansione del vettore non basta. Per esempio consideriamo il seguente vettore: 23 14 6 12 34 4 15 20 consideriano la prima scansione del vettore: il primo confronto viene fatto tra il 23 e il 14. Il 23 è maggiore del 14, dunque i due elementi vengono scambiati 14 23 6 12 34 4 15 20 il 23 viene confrontato col 6. Essendo più grande, viene scambiato. 14 6 23 12 34 4 15 20 quindi confrontiamo il 23 col 12 e li scambiamo 23 6 12 23 34 4 15 20 il 23 è confrontato col 34, e questa volta non viene effettuato nessuno scambio. 23 6 12 23 34 4 15 20 Si passa quindi all’elemento successivo, il 34, che viene confrontato col 4, e vengono scambiati. 23 6 12 23 4 34 15 20 6 12 23 4 15 34 20 quindi si scambia il 34 col 15 23 9 Quindi il 34 viene scambiato col 20. Il vettore alla fine della prima scansione sarà il seguente: 23 6 12 23 4 15 20 34 È evidente che il vettore non è ancora ordinato, ma possiamo osservare che, comunque sia fatto il vettore, l’elemento più grande trova la sua posizione definitiva, ossia l’ultima posizione. È per questo che l’algoritmo si chiama Bubblesort, o ordinamento a bolle, poichè l’elemento più grande “affiora” in superficie come le bolle nell’acqua. Ma allora possiamo prevedere che alla prossima iterazione il secondo elemento più grande si troverà alla penultima posizione. In generale, alla i-esima iterazione l’i-esimo elemento più grande sarà piazzato alla posizione n − i + 1. Questo ci dice che dopo al più n − 1 iterazioni, il vettore sarà completamente ordinato. In prima approssimazione la procedura che implementa il bubblesort è la seguente: procedure bubblesort (var V:vettore); var i,j:integer; procedure scambia(var x,y:integer); var aux:integer; begin aux:=x; x:=y; y:=aux; end; begin for i:= 1 to n do for j:= 1 to n-1 do if V[j]>V[j+1] then scambia (V[j],V[j+1]); end; In realtà alcune iterazioni potrebbero essere superflue. Infatti se a una certa iterazione il vettore fosse già ordinato, basterebbe una sola ulteriore iterazione per accorgersi che non è più necessario nessuno scambio, e quindi il vettore è ordinato e l’algoritmo può terminare. Questo può essere controllato trasformando il ciclo esterno in un ciclo repeat-until, utilizzando una variabile booleana che verifica se nella iterazione precedente è stato effettuato uno scambio. procedure bubblesort (var V:vettore); var i,j:integer; scambio:boolean; 10 procedure scambia(var x,y:integer); var aux:integer; begin aux:=x; x:=y; y:=aux; end; begin repeat scambio:=FALSE for j:= 1 to n-1 do if V[j]>V[j+1] then begin scambia (V[j],V[j+1]); scambio:=TRUE; end; until scambio=FALSE end; Nel caso peggiore vengono fatte comunque n iterazioni esterne. Possiamo anche osservare che, visto che all’i-esima iterazione gli ultimi i elementi del vettore sono nella loro posizione definitiva, possiamo interrompere il ciclo interno dopo n − i iterazioni. Questo si può realizzare riducendo di un unità la variabile n alla fine del ciclo interno procedure bubblesort (var V:vettore); var i,j:integer; scambio:boolean procedure scambia(var x,y:integer); var aux:integer; begin aux:=x; x:=y; y:=aux; end; begin repeat scambio:=FALSE for j:= 1 to n-1 do if V[j]>V[j+1] then begin scambia (V[j],V[j+1]); scambio:=TRUE; 11 end; n:=n-1; until scambio=FALSE end; Ma si può fare ancora di meglio. Infatti se in una iterazione interna da un certo punto in poi non vengono effettuati scambi, significa che tali valori si trovano nella loro posizione definitiva. Questo può essere controllato tenendo traccia della posizione in cui è effettuato l’ultimo scambio, e far sı̀ che il successivo ciclo for termini in questa posizione. procedure bubblesort (var V:vettore); var i,j,p:integer; scambio:boolean procedure scambia(var x,y:integer); var aux:integer; begin aux:=x; x:=y; y:=aux; end; begin p:=n; repeat scambio:=FALSE for j:= 1 to n-1 do if V[j]>V[j+1] then begin scambia (V[j],V[j+1]); scambio:=TRUE; p:=j+1 end; n:=p; until scambio=FALSE end; Si può notare che, malgrado tutte le ottimizzazioni effettuate migliorino in media le prestazioni dell’algoritmo, nel caso peggiore (che è per quest’algoritmo quello in cui il vettore da ordinare è ordinato in senso inverso), si devono effettuare nella prima iterazione n−1 confronti e scambi, nella seconda n−2, e cosı̀ via. In totale verranno svolte n∗(n−1)/2 operazioni di confronto e/o scambio. Anche questo algoritmo è quindi un algoritmo di tempo quadratico O(n2 ) nel caso peggiore. Tuttavia, per quanto osservato prima, questo algoritmo si arresta non appena il vettore diventa ordinato. In casi particolarmente fortunati il numero di confronti può essere vicino 12 ad n. Questo algoritmo è quindi un algoritmo adattivo. Inoltre l’algoritmo è stabile in quanto elementi con la stessa chiave non vengono mai invertiti. Infine anche questo algoritmo lavora in loco, ossia non utilizza nessun vettore ausiliario, dunque nessuno spazio di memoria supplementare. 2.3 Mergesort Un altro metodo per il sorting utilizza la tecnica algoritmica del divide et impera (dividi e governa o, in inglese, divide and conquer). Secondo questa tecnica l’input viene diviso in due o più parti più piccole e l’algoritmo viene applicato ricorsivamente a ciascuna delle parti in cui è diviso l’input. In particolare questo metodo di sorting, chiamato mergesort, consiste nel dividere il vettore in due parti e operare ricorsivamente un ordinamento su ciascuna delle due parti. Nella fase di ricostruzione occorre fondere (merge) due vettori ordinati in un unico vettore ordinato. Vediamo cosa succede su un esempio: Esempio: Supponiamo di volere ordinare il seguente array: 23 14 6 12 34 4 15 20 Il vettore viene suddiviso in due vettori di taglia metà: 23 14 6 12 34 4 15 6 12 34 4 12 34 20 A sua volta, ciascuno di essi viene diviso a metà 23 14 15 20 e ancora una volta a metà 23 14 6 4 15 20 A un certo punto si arriva ad avere tanti array di taglia 1. Tali array sono ovviamente ordinati. Il nostro scopo è quello di costruire un vettore ordinato di taglia 2k a partire da due vettori ordinati di taglia k. Al primo passo è molto semplice. Confrontiamo i primi due vettori, nel nostro esempio quello costituito dal solo elemento 23 e quello costituito dal solo elemento 14. Se vogliamo costruire un vettore di taglia 2 con tali elementi ordinati, basta un singolo confronto per stabilire che 14 < 23, e quindi collocare il 14 prima di 23. Lo stesso si fa per le coppie (6, 12), (34, 4), (15, 20). Si ottengono dunque i seguenti vettori: 14 23 6 12 4 13 34 15 20 A questo punto vogliamo unificare in un unico vettore ordinato i primi due vettori (ordinati). Dobbiamo quindi trovare il minimo valore contenuto nei due vettori per trovare quale deve essere inserito come primo elemento nel nuovo vettore. Visto che i due vettori di partenza sono ordinati, l’elemento più piccolo sarà sicuramente o il primo del primo vettore o il primo del secondo vettore. Con un solo confronto riusciamo a stabilire quant’è il minimo. Nella fattispecie è il 6, e lo mettiamo in prima posizione. A questo punto il secondo elemento più piccolo sarà o il primo del primo vettore o il secondo del secondo vettore. In questo caso è il 12, e lo inseriamo in seconda posizione. Essendo che il secondo vettore è esaurito, gli elementi rimanenti del primo vettore vanno ordinatamente inseriti nel nuovo vettore. Lo stesso procedimento può essere adottato per le coppie (4, 34), (15, 20). Otteniamo la seguente configurazione: 6 12 23 14 4 15 20 34 A questo punto restano da fondere gli ultimi due vettori utilizzando la stessa tecnica vista prima. Si ottiene quindi: 6 4 12 15 20 23 34 La procedura mergesort farà quindi uso di una procedura ausiliaria (che chiameremo merge) che, presi in input due vettori ordinati di taglia k, genera un vettore di taglia 2k. La procedura in realtà lavora su una porzione di vettore, e utilizza una variabile locale B di tipo vettore, che ci servirà come vettore ausiliario. Vengono passati come parametri il vettore V, e gli interi l,m,r, che rappresentano le due porzioni di vettore adiacenti che stiamo analizzando ossia l’intervallo [l,m] e l’intervallo [m+1,r]. Si suppone che gli elementi dell’intervallo [l,m] e quelli dell’intervallo [m+1,r] del vettore siano ordinati. Alla fine della procedure vogliamo che gli elementi dell’intervallo [l,r] siano tutti ordinati. La seguente procedura implementa questo algoritmo: ··· ··· l m procedure merge (var V:vettore, l,m,r:integer); var i,j,k:integer; begin i:=l; k:=l; j:=m+1; While (i<=m) and (j<=r) do begin if V[i]<=V[j] then begin B[k]:=V[i]; 14 r i:=i+1; k:=k+1; end else begin B[k]:=V[j]; j:=j+1; k:=k+1; end; end; while i<=m do begin B[k]:=V[i]; i:=i+1; k:=k+1; end; while j<=r do begin B[k]:=V[j]; j:=j+1; k:=k+1; end; for k:=l to r do V[k]:=B[k] end; La procedura mergesort farà uso di questa procedura. L’idea è quella accennata nell’esempio, ossia considerato un vettore, si divide a metà e si applica ricorsivamente la procedura mergesort a ciascuna delle due metà. Una volta che i due sottoarray sono ordinati, si fondono mediante la procedura merge: procedure mergesort (var V:vettore, l,r:integer); var m:integer; begin m:=(l+r) div 2; mergesort(V,l,m); mergesort(V,m+1,r); merge(V,l,m,r) end; Andiamo ora a calcolare la complessità. Prima di tutto consideriamo la complessità della procedura merge. Per ogni elemento che viene inserito nel vettore B si applica tempo costante. Infatti si svolge al più un confronto e l’elemento più piccolo viene copiato nel vettore B. In totale dunque la sua complessità è dell’ordine della taglia della porzione di vettore che si sta ordinando. 15 Andiamo alla procedura mergesort. Ad ogni passo vengono applicate due chiamate ricorsive del mergesort, a due vettori della taglia metà del vettore iniziale, più una chiamata alla procedura merge. Un modo per calcolare la complessità è calcolare la funzione ricorsiva T (n) che calcola la complessità di mergesort su un vettore di taglia n. T (n) = 2T (n/2) + n = 2(2T (n/4) + n/2) + n = = 4T (n/4) + n + n = 4(2T (n/8) + 1/4) + n + n = = 8T (n/8) + n + n + n = · · · = n {z· · · + n} = n log n | +n+ log n volte Dunque la complessità dell’algoritmo è O(n log n). Un altro modo più intuitivo per calcolare questa complessità è il seguente: un vettore può essere diviso a metà un numero di volte uguale al logaritmo della sua taglia. La fase “divide” dell’algoritmo costa tempo costante ad ogni chiamata ricorsiva. La ricostruzione di un vettore di taglia 2k a partire da due vettori di taglia k costa O(k). Possiamo però osservare che ad ogni “livello” di divisione abbiamo un numero di array da fondere tale che la somma di tutte le taglie dei sottovettori è uguale ad n. Dunque la “composizione” costa O(n) ad ogni livello. Essendoci log n livelli, la complessità totale dell’algoritmo è O(n log n). Questo algoritmo utilizza della memoria ausiliaria (il vettore B), dunque non lavora in loco. Inoltre possiamo osservare che questo algoritmo svolge le stesse operazioni per qualunque vettore di input. Questo significa che l’algoritmo è non adattivo. Inoltre questo algoritmo non scambia mai l’ordine di due elementi con chiavi uguali. Segue che il mergesort è un algoritmo stabile. 2.4 Quicksort Un altro algoritmo di sorting è il quicksort o ordinamento rapido. Questo algoritmo si chiama cosı̀ perché è quello che in media ha le performances migliori rispetto ad altri algoritmi, anche se non ha la migliore complessità nel caso pessimo. È quello che si chiama un algoritmo randomizzato, in quanto la sua esecuzione dipende da un valore che viene estratto in maniera più o meno casuale. Illustreremo qui un modo di estrarre questo numero, in dipendenza di alcuni valori del vettore, ma non è questo l’unico modo. Il quicksort funziona come segue. Supponiamo di voler ordinare un insieme di interi. • Si estrae a sorte un numero intero, che possibilmente appartenga al range degli elementi del vettore. Per fare questo potremmo scegliere tale elemento, detto pivot, come la media di due elementi del vettore, per esempio il primo e l’ultimo. • si riarrangiano gli elementi del vettore in maniera tale che nella parte iniziale del vettore ci siano tutti gli elementi più piccoli del pivot e nella parte finale tutti gli elementi più grandi del pivot. Alla fine di questa fase il vettore risulta diviso 16 idealmente in due parti, non necessariamente uguali, quella con tutti gli elementi più piccoli del pivot e quella con tutti gli elementi più grandi del pivot. • Quindi si applica il quicksort ricorsivamente a ciascuna delle due parti del vettore. Le chiamate ricorsive terminano quando la porzione di vettore analizzato è di dimensione 1. Supponiamo di avere il seguente vettore: 23 14 6 16 34 4 12 7 Consideriamo i due estremi del vettore e calcoliamone la media. Nel nostro caso tale media è 15. Chiamiamo tale media pivot. L’idea è di mettere nella parte iniziale del vettore tutti gli elementi più piccoli del pivot e nella parte finale gli elementi più grandi del pivot. Per fare questo procediamo come segue: scandiamo il vettore da sinistra a destra, andando a cercare il primo elemento che risulta più grande del pivot. Questo elemento è probabilmente nella posizione sbagliata. Teniamo in memoria la posizione di questo elemento e cominciamo a scandire il vettore da destra a sinistra, andando a cercare il primo elemento più piccolo del pivot. Tale elemento viene scambiato con quello trovato prima, e si prosegue in questo modo fino a quando ogni elemento non sia stato esaminato ed eventualmente trasportato nella metà di pertinenza. 23 14 6 16 34 4 12 7 In particolare nel nostro vettore 23 risulta subito più grande del pivot e 7 minore del pivot. Quindi i due elementi vengono scambiati. 7 14 6 16 34 4 12 23 si passa ad esaminare il secondo elemento, il 14. Essendo minore di 15 non verrà spostato e si passa all’elemento successivo. Anche 6 < 15 e si passa al successivo. Si trova 16 > 15, quindi ci si ferma e si comincia ad esaminare gli elementi da destra a sinistra. Si trova subito il 12 < 15 che verrà quindi scambiato col 16. Otteniamo quindi: 7 14 6 12 34 4 16 23 Andando avanti da sinistra a destra si trova il 34 > 15. Si riparte quindi da destra e si trova il 4 < 15. I due elementi vengono scambiati. 7 14 6 12 4 34 16 23 Quindi gli indici che scandiscono i due vettori si incontrano. A questo punto tutti gli elementi più piccoli del pivot si trovano prima della posizione 5, mentre tutti quelli più grandi si trovano dalla posizione 6 in poi. L’algoritmo viene quindi richiamato ricorsivamente su ciascuna delle due parti. 17 Si noti che gli elementi che alla fine di una di queste fasi sono collocate in una metà, non dovranno più superare il confine ideale fra le due parti. Quindi un ordinamento indipendente di ciascuna delle due parti porterà ad un ordinamento dell’intero vettore. Il codice in Pascal che implementa questo algoritmo è il seguente. procedure quicksort (var V:vettore, left,right:integer); var i,j, pivot:integer; procedure scambia(var x,y:integer); var aux:integer; begin aux:=x; x:=y; y:=aux; end; begin {quicksort} i:=left; j:=right; pivot:=(V[left]+V[right]) div 2; repeat While V[i]<pivot do i:=i+1; While V[j]>pivot do j:=j-1; scambia(V[i], V[j]) i:=i+1; j:=j-1; until i>j; if right<j then quicksort(V,right,j); if i>left then quicksort(V,i,left) end; Una chiamata della funzione farà un numero di confronti (di ogni elemento col pivot) proporzionale alla taglia della parte di vettore che si sta esaminando. In più ci sono due chiamate ricorsive sulle due parti del vettore. Possiamo notare che se il vettore venisse diviso esattamente a metà la funzione di ricorsione che ne deriverebbe sarebbe la stessa del mergesort. In questo caso particolarmente fortunato avremmo quindi che la complessità dell’algoritmo sarebbe O(n log n). Tuttavia il modo in cui il vettore viene suddiviso dipende molto “dalla sorte” nel senso che dipende dalla scelta del pivot. In casi particolarmente sfortunati, il pivot potrebbe essere tale da creare una partizione sbilanciata del vettore, ossia alla prima chiamata di quicksort il vettore verrebbe diviso in una parte di n − 1 elementi e un parte di un solo elemento. Quindi una sola delle due chiamate ricorsive si attiva, ma su un vettore di taglia n−1. La seconda chiamata ricorsiva l’algoritmo farà quindi n − 1 confronti. Se siamo ancora particolarmente “sfortunati” il nuovo pivot sbilancia ancora la partizione. Otteniamo dunque un’unica chiamata ricorsiva 18 su un vettore di taglia n − 2. In maniera più formale possiamo dire che se ogni estrazione del pivot ci genera una partizione sbilanciata, la funzione di ricorsione sarà: T (n) = n + T (n − 1) = n + (n − 1) + T (n − 2) = · · · = n + (n − 1) + (n − 2) + · · · + 2 + 1 = = n(n + 1)/2 ossia O(n2 ). Dunque abbiamo dimostrato il seguente Teorema Teorema 2.2 Il quicksort ha una complessità nel caso pessimo di O(n2 ). Ci possiamo chiedere comunque come questo algoritmo lavora in media. Si può dimostrare che nel caso medio questo algoritmo si comporta in maniera più probabile come nel caso migliore. Cioé: Teorema 2.3 La complessità del quicksort nel caso medio è O(n log n). In realtà le costanti celate dietro l’ordine di grandezza sono molto piccole. Questo è il motivo per cui il quicksort ha in genere delle performances addirittura migliori del mergesort, che ha una complessità “worst case” migliore. Notiamo infine che il quicksort è un algoritmo non adattivo in quando la sua esecuzione non dipende da come è fatto l’input (può dipendere eventualmente dalla scelta del pivot). Inoltre non è stabile poiché per esempio 7 161 6 12 162 4 18 23 161 18 23 il primo 16 viene scambiato col 4 7 4 6 12 162 e alla fine della prima ricorsione il vettore risulta 7 4 6 12 162 161 18 23 Si può notare che 161 e 162 non si scambieranno più di posto prima della fine dell’algoritmo. 2.5 Countingsort L’algoritmo che ora andiamo a descrivere può essere applicato quando gli elementi che dobbiamo ordinare appartengono ad un insieme finito. Per esempio possiamo supporre che gli elementi da ordinare siano interi compresi in un intervallo finito, oppure le lettere dell’alfabeto. Invece l’algoritmo non può essere applicato ad un insieme di numeri reali contenuti in un intervallo, perchè in un intervallo sono contenuti infiniti numeri reali. L’algoritmo di countingsort funziona come segue. Supponiamo di volere ordinare un vettore V, i cui elementi sono tratti da un insieme finito. 19 • si definisce un vettore count con taglia uguale alla dimensione dell’insieme da cui gli elementi sono tratti. Si inizializzano tutte le caselle del vettore a 0. L’elemento i-esimo di tale vettore servirà a contare quante volte l’elemento di valore i è presente nel vettore. • si scorre il vettore V con un ciclo, e per ogni i si incrementa di 1 la casella V[i] (perchè è stato visto un nuovo elemento con valore V[i]). • si considera il vettore count e a partire dal secondo elemento collezioniamo in ogni posizione k tutte le somme parziali dei primi k elementi di count. In questo modo alla fine count[k] indicherà la posizione in cui dovremo inserire l’ultimo elemento con valore k nel vettore ordinato. • percorrendo il a ritroso vettore V dall’ultima posizione alla prima per ogni indice i inseriamo questo elemento in un vettore ausiliario W nella posizione count[V[i]]. Quindi si decrementa count[V[i]] per determinare dove andrà messo il prossimo elemento di valore V[i]. Vediamo come funziona su un esempio. Supponiamo di voler ordinare un vettore di 16 interi nell’intervallo [1, 5]. V= 3 1 3 5 1 3 2 4 1 2 5 3 4 1 3 4 Si considera un vettore count di dimensione 5 e si inizializza a 0 count= 0 0 0 0 0 Si inizia a scandire il vettore V. Il primo elemento è un 3, dunque si incrementa il contatore count[3] count= 0 0 1 0 0 al secondo passo si incrementerà count[1]. count= 1 0 1 0 0 count= 1 0 2 0 0 quindi di nuovo count[3] e cosı̀ via. Alla fine della iterazione il vettore count sarà il seguente: count= 4 2 5 3 2 Costruiamo il vettore delle somme incrementali count= 4 6 20 11 14 16 A questo punto consideriamo un vettore W della stessa dimensione di V e cominciamo a scandire V da destra a sinistra . V= 3 1 3 5 1 3 2 4 1 2 5 3 4 1 3 4 W= count= 4 6 11 14 16 Al primo passo si legge V[16]=4. Allora questo 4 viene inserito nella posizione count[4] (14) e si decrementa count[4]. V= 3 1 3 5 1 3 2 4 1 2 5 3 4 1 3 4 4 W= count= 4 6 11 13 16 Questo procedimento garantisce che l’ultima occorrenza del 4 sia messo nell’ultima posizione in cui deve figurare un 4 nel vettore risultante. Applicato ad ogni elemento, questo farà si che il counting sort risulti stabile. Al secondo passo si trova un 3, che si inserisce nella posizione count[3] (11), e si decrementa di uno count[3], che diventa 10. V= 3 1 3 5 1 3 2 4 1 2 W= count= 4 6 5 3 4 1 3 4 3 4 10 13 16 Terzo passo V= 3 W= 1 3 5 1 3 2 4 1 2 1 count= 3 6 10 5 3 4 1 3 4 3 4 13 16 Quarto passo V= 3 W= 1 3 5 1 3 2 4 1 2 1 5 3 4 3 count= 3 6 21 10 12 1 3 4 4 4 16 Quinto passo V= 3 1 3 5 1 3 2 4 1 2 1 W= 3 count= 3 6 9 12 5 3 4 3 1 3 4 4 4 16 Sesto passo V= 3 1 3 5 1 3 2 4 1 2 1 W= 5 3 4 3 3 count= 3 1 3 4 4 4 6 9 12 15 4 1 2 5 3 4 5 Settimo passo V= 3 1 3 5 1 3 2 1 W= 2 3 3 count= 3 5 9 12 1 3 4 4 4 5 15 Ottavo passo V= 3 W= 1 3 5 1 3 2 1 1 4 1 2 2 count= 2 5 3 4 3 3 4 1 3 4 4 5 5 9 12 15 4 1 2 5 3 4 1 3 4 3 3 4 4 4 Nono passo V= 3 W= 1 3 5 1 3 2 1 1 2 count= 2 5 9 11 5 15 Decimo passo V= 3 W= 1 3 5 1 3 2 1 2 1 2 4 1 2 5 3 4 3 3 4 CONT= 2 4 9 22 11 15 1 3 4 4 4 5 Undicesimo passo V= 3 1 3 5 1 W= 1 3 2 4 1 2 1 2 2 3 CONT= 2 4 8 5 3 4 3 3 1 3 4 4 4 4 5 11 15 Dodicesimo passo V= 3 1 3 5 W= 1 1 3 2 1 1 2 4 1 2 2 5 3 4 3 3 3 CONT= 1 3 8 1 3 4 4 4 4 5 11 15 Tredicesimo passo V= 3 1 3 5 W= 1 1 1 3 2 4 1 2 1 2 2 3 CONT= 1 3 5 3 4 3 3 4 1 3 4 4 4 5 5 8 11 14 Quattordicesimo passo V= 3 1 3 5 1 3 2 W= 1 1 1 2 2 4 1 2 3 5 3 4 3 3 3 CONT= 1 3 7 1 3 4 4 4 4 5 5 11 14 Quindicesimo passo V= 3 1 3 W= 1 1 1 5 1 3 2 4 1 2 5 3 4 1 3 4 1 2 2 3 3 3 3 4 4 4 5 5 CONT= 0 3 7 11 14 Sedicesimo passo V= 3 1 3 5 1 3 2 W= 1 1 1 1 2 2 3 4 1 2 5 3 4 1 3 4 3 3 3 3 4 4 4 5 5 CONT= 0 3 6 23 11 14 Il codice che implementa questo algoritmo è il seguente: procedure countingsort (var V:vettore); var i,k:integer; count:array [1..k] of integer; W:vettore begin for j:=1 to k do {inizializzazione di count} count[j]:=0; for i:=1 to n do {creazione del vettore contatore} count[V[i]]:=count[V[i]]+1; for j:=2 to k do {creazione del vettore delle somme incrementali} count[i]:=count[i]+count[i-1]; for i:=n downto 1 do begin {Costruzione del vettore ordinato} W[count[V[i]]]:=V[i]; count[V[i]]:=count[V[i]]-1; end; for i:=i to n do V[i]:=W[i]; {copia del vettore W in V} end; Questo algoritmo contiene un ciclo di lunghezza k per l’inizializzazione di count, un ciclo di lunghezza n per la creazione del vettore count, un ciclo di lunghezza k per la creazione del vettore delle somme incrementali, un ciclo di lunghezza n per la creazione del vettore ordinato e un ciclo di lunghezza n per copiare il vettore ordinato in V. Essendo tutti cicli disgiunti, la complessità dell’algoritmo è uguale al massimo tra O(k) e O(n). In genere si suppone che n sia molto maggiore di k, quindi il counting sort ha complessità O(n). Si noti che questo risultato non contraddice il teorema del lower bound per gli algoritmi di sorting. Infatti il counting sort non è un algoritmo di sorting basato su confronti, e questo è possibile perchè sfrutta pesantemente l’ipotesi che l’insieme da cui sono tratti gli elementi è un insieme finito. Infatti se cosı̀ non fosse, non saremmo in grado di dimensionare il vettore count. L’algoritmo sfrutta un vettore ausiliario (il vettore W), quindi non lavora in loco. Inoltre il funzionamento dell’algoritmo non dipende dal vettore iniziale. Quindi è non adattivo. Infine, per come è stato implementato, l’algoritmo è stabile. Nella seguente tabella schematizziamo tutte le caratteristiche fondamentali degli algoritmi di sorting illustrati. 24 Caso pessimo Selectionsort O(n2 ) Bubblesort O(n2 ) Mergesort O(n log n) Quicksort O(n2 ) Countingsort O(n) Caso medio O(n2 ) ? O(n log n) O(n log n) O(n) 25 Caso migliore O(n2 ) O(n) O(n log n) O(n log n) O(n) In Loco SI SI NO SI NO Adattivo Stabile NO NO SI SI NO NO NO NO NO SI