Gli algoritmi di Ricerca e di Ordinamento

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