Analisi della complessità degli algoritmi Algoritmi di ordinamento

Università degli Studi di Palermo
Facoltà di Ingegneria
Note introduttive su:
Analisi della complessità degli algoritmi
Algoritmi di ordinamento
Edoardo Ardizzone & Riccardo Rizzo
Appunti per il corso di
Fondamenti di Informatica
A.A. 2004 - 2005
Corso di Laurea in Ingegneria Informatica
Introduzione all’analisi della complessità. Ricerca lineare.
I metodi di ordinamento, che verranno introdotti nella seconda parte di queste note, rappresentano
una ottima occasione per introdurre un argomento classico dell’informatica, la valutazione della
efficienza del software. Un programma è tanto più efficiente quanto più basso è il suo costo (o
complessità) computazionale, che dipende dalla memoria centrale e dal tempo necessari per la sua
esecuzione. Tranne che in contesti specialistici e per applicazioni avanzate (sistemi embedded,
architetture specializzate, etc), che saranno analizzate in corsi successivi, i bassi costi attuali di
realizzazione fisica delle memorie e le elevate capacità di indirizzamento dei moderni elaboratori
rendono meno significativa la valutazione del primo elemento. Pertanto il costo computazionale di
un programma verrà considerato in questa sede dipendente solo dal tempo di esecuzione.
Il tempo di esecuzione di un programma dipende:
- dalla velocità di esecuzione della macchina, intesa come esecutore astratto del linguaggio di
programmazione, e quindi sia dalle caratteristiche dell’hardware che da quelle del compilatore
(o dell’interprete) adoperato;
- dalle caratteristiche dei dati sui quali il programma opera, e in particolare dal numero degli
elementi realmente significativi nel problema in esame: è intuitivo che ordinare 10 numeri interi
è meno costoso che ordinarne 10000.
Questo tipo di dipendenza rende ardua una valutazione assoluta della efficienza di un modulo, e
praticamente impossibile il confronto tra due moduli che svolgono lo stesso compito utilizzando
algoritmi differenti: non vi è nessuna certezza che se un modulo A è più veloce di un modulo B su
un PC, lo sia anche su un elaboratore di classe superiore. Inoltre A potrebbe richiedere meno tempo
di B se i dati sono disposti in un certo modo, ma B potrebbe essere più veloce per un’altra
disposizione dei dati.
Il problema può essere semplificato sulla base delle seguenti considerazioni. Per quanto riguarda la
dipendenza dalla macchina, nonostante le forti differenze tra le prestazioni degli elaboratori reali, la
loro architettura e, di conseguenza, il loro repertorio istruzioni hanno caratteristiche comuni, per
quanto differenti possano essere le modalità di indirizzamento, il numero e il tipo dei registri, la
durata del ciclo macchina, etc.: sicuramente in tutte le macchine è presente la capacità di reperire e
depositare dati in memoria, e di trasformarli applicando semplici operazioni. Inoltre, a parità di
codice sorgente e di hardware, i codici oggetto prodotti da compilatori più o meno sofisticati,
eventualmente utilizzando in modo più o meno spinta le proprietà dell’hardware, differiranno
normalmente per il fatto che una certa istruzione del codice sorgente sia tradotta, ad esempio, in una
sequenza di 4 istruzioni macchina piuttosto che di 7. Supponendo che il tempo di esecuzione di ogni
istruzione macchina sia lo stesso, si avrà un guadagno di tempo, impiegando il codice prodotto dal
compilatore più sofisticato, di 3/7 del tempo impiegato se si utilizza il codice prodotto dal secondo
compilatore, indipendentemente dal numero di volte in cui deve essere eseguita l’istruzione
sorgente.
In sostanza, la qualità della macchina (hardware più software di base) incide al più per un fattore
costante sul tempo di esecuzione complessivo di un qualsiasi programma: se l’esecuzione di un
programma, avente i dati di ingresso d, su una macchina M richiede un tempo t = f(d), lo stesso
programma su una macchina M’ avrà un tempo di esecuzione t’ = c·f(d). Pertanto, anche se il valore
di c può essere notevole (e questo giustifica le differenze di prezzo tra elaboratori di classe diversa),
la valutazione dell’efficienza di un programma può essere effettuata, a meno di un fattore costante,
prescindendo dalle caratteristiche della macchina.
Per quanto riguarda la dipendenza dai dati, come detto, è naturale aspettarsi che il tempo di
esecuzione di un programma aumenti al crescere della dimensione del problema, che normalmente
(ma non necessariamente) può essere espressa come numero di elementi di un vettore, come
numero di righe di una tabella, come numero di parole di un testo, etc.
Le precedenti considerazioni permettono di definire un approccio alla analisi approssimata della
complessità di un programma, che viene illustrato in dettaglio prendendo in considerazione il
problema della ricerca di un elemento in una sequenza (di interi, in questo caso), realizzata
mediante un array. L’algoritmo utilizzato è quello di ricerca lineare (o esaustiva), che prevede il
confronto dell’elemento cercato con tutti gli elementi dell’array. Il seguente metodo, codificato in
modo volutamente ridondante, costituisce una prima implementazione della ricerca lineare,
restituendo la posizione dell’elemento cercato, se presente nell’array, oppure -1:
public int firstLinearSearch( int a[], int key )
{
int n = a.length; // n.ro elementi dell’array
int pos = -1; // valore da restituire se elemento non trovato
for (int counter = 0; counter < n; counter++)
if ( a[counter] == key ) pos = counter; // elemento trovato
else pos = pos;
// solo per bilanciare il n.ro di istruzioni eseguite
return pos
}
In questo caso, la dimensione del problema è n. L’esecuzione delle prime due istruzioni richiede un
tempo totale c1 indipendente dal valore di n, così come indipendente da n è il tempo c3 necessario
per l’esecuzione dell’istruzione di ritorno successiva al ciclo for. Per quanto riguarda invece il
ciclo for, poiché la inizializzazione della variabile di controllo viene eseguita una volta sola, il suo
tempo di esecuzione può essere incluso in c1. Il tempo c2 richiesto per una singola esecuzione del
corpo del ciclo è quello richiesto per l’esecuzione del test dell’istruzione if e per l’esecuzione o
dell’una o dell’altra assegnazione di un valore a pos (una delle due assegnazioni viene comunque
eseguita). In questo tempo, che non dipende da n, vanno inclusi anche il tempo necessario per il
singolo incremento della variabile di controllo e per una singola esecuzione del test di
continuazione del ciclo. Il numero di ripetizioni del ciclo è invece ovviamente legato al valore di n.
Pertanto si può scrivere per il tempo totale di esecuzione del metodo:
t = c 1 + c2 · n + c 3
Questa equazione non precisa il tempo reale di esecuzione del frammento di programma, ma
fornisce informazioni utili ad una valutazione approssimativa dello stesso. Intanto essa separa la
dipendenza dalla macchina dalla dipendenza dai dati, e mostra come un eventuale cambiamento
delle caratteristiche hardware e software della macchina, che può modificare i valori di c1, c2 e c3,
non altera in ogni caso la dipendenza lineare del tempo di esecuzione dalla dimensione del
problema. Pertanto, se quest’ultima raddoppia, si avrà un tempo di esecuzione “quasi” doppio, non
esattamente doppio per la presenza delle costanti. Per valori abbastanza grandi di n, è intuibile che
la dipendenza lineare dai dati contribuisca al tempo di esecuzione molto più della dipendenza dalla
macchina. Si può pertanto concludere che la complessità computazionale del metodo
firstLinearSearch è, al crescere della dimensione del problema, lineare con essa. Si dice in
questo caso che la complessità asintotica del programma è O(n), cioè è dello stesso ordine di
grandezza di n. In generale, se un programma ha costo f(n), si dirà che la complessità è O(f(n)). Nei
casi più frequenti, f(n) è lineare o logaritmica o quadratica, più raramente cubica o esponenziale.
Si noti tuttavia che in molti casi il costo di un programma può dipendere non solo dalla dimensione
dei dati, ma anche dai particolari valori dei dati stessi, cioé dalla loro distribuzione, come risulta
anche dall’esempio seguente, che costituisce peraltro una implementazione più realistica della
ricerca lineare.
public int otherLinearSearch( int a[], int key )
{
int n = a.length; // n.ro elementi dell’array
for (int counter = 0; counter < n; counter++)
if ( a[counter] == key ) return counter; // elemento trovato
return –1
// elemento non trovato
}
E’ evidente che questa volta il tempo di esecuzione della ricerca dipende non solo dalla dimensione
dei dati, ma anche dalla posizione nella sequenza dell’elemento cercato. Infatti
firstLinearSearch scandisce sempre tutto l’array, mentre otherLinearSearch interrompe la
scansione non appena trova l’elemento cercato, se esiste. L’espressione del tempo di esecuzione,
ricavabile con una analisi analoga a quella condotta in precedenza, è la seguente:
t = k1 + k2 · m + k3
dove le costanti k1, k2 e k3 differiscono, sia pure di poco, dalle costanti c1, c2 e c3, anche se
l’istruzione return counter viene eseguita al più una volta, quando l’elemento viene trovato. La
differenza fondamentale sta però nel fatto che m coincide con n solo se l’elemento cercato è
l’ultimo della sequenza, oppure se esso non si trova nella sequenza. In tutti gli altri casi, m è minore
di n, e in alcuni casi molto minore (per esempio, se la sequenza contiene 10000 elementi e
l’elemento cercato è il primo).
Sarebbe naturalmente troppo complicato determinare espressioni analitiche, per quanto
approssimate, del tempo di esecuzione che tengano conto di tutte le possibili distribuzioni dei dati.
Si preferisce pertanto limitare la trattazione ad alcuni casi di maggiore interesse, come il caso
migliore, il caso peggiore, il caso medio. Il caso migliore si ha in corrispondenza ai valori dei dati
per i quali si ottiene il costo computazionale minore, mentre il caso peggiore corrisponde alla
configurazione dei dati che determina il costo maggiore. La stima del costo nel caso medio si
ottiene valutando la media aritmetica dei costi rispetto a tutti i possibili dati di ingresso. Per il
metodo otherLinearSearch, il caso migliore è quello in cui l’elemento cercato è il primo della
sequenza, e il programma effettua una sola iterazione, e quindi un solo confronto; il caso peggiore è
quello in cui l’elemento cercato è l’ultimo della sequenza, o non è presente (n confronti); il caso
medio (solo se la ricerca ha esito positivo) richiede (n + 1)/2 confronti, se si assume che tutti gli
elementi dell’array possano essere cercati con uguale probabilità. Infatti, dato che in tal caso la
probabilità è pari a 1/n, si ha per il numero medio di confronti da effettuare:
n
n
i
iProb(E(i )) = ∑ =
∑
i =1
i =1 n
n +1
2
essendo Prob(E(i)) la probabilità che l’elemento da cercare sia quello in posizione i. Il caso medio
richiede quindi, nel caso della ricerca lineare, circa la metà dei confronti necessari nel caso
peggiore.
In generale, il modo più utile di valutare la complessità computazionale è quello di considerare il
caso peggiore, che permette di determinare una espressione del costo f(n), funzione solo della
dimensione dei dati, tale che in nessun caso a dimensione n il tempo di esecuzione può risultare
superiore a f(n). L’analisi del caso peggiore consente in altre parole di determinare una
maggiorazione certa sul comportamento di un programma.
Applicando il criterio del caso peggiore alla ricerca lineare, si ottiene che i due metodi
firstLinearSearch e otherLinearSearch sono equivalenti dal punto di vista dell’efficienza di
esecuzione. Però è intuitivo come normalmente otherLinearSearch si comporti meglio di
firstLinearSearch, e l’analisi del caso medio lo conferma. In generale, l’analisi del caso medio
può fornire informazioni aggiuntive di grande utilità e pertanto nel seguito si farà riferimento al
caso peggiore e, quando necessario, al caso medio.
Una conseguenza delle approssimazioni introdotte nella valutazione della complessità è la sua
indipendenza dal linguaggio di programmazione adoperato. In effetti, la complessità asintotica è
normalmente valutata in relazione all’algoritmo impiegato, piuttosto che al codice che lo
implementa.
Inoltre, il modello di costo adottato per la valutazione approssimata della complessità assegna un
costo (convenzionalmente) unitario a ciascuna istruzione semplice, indipendentemente dalla sua
effettiva complessità matematica, e quindi dal numero più o meno elevato di istruzioni macchina
necessarie per la sua effettuazione. Del pari unitario è considerato il costo di un test (di fine ciclo, di
una istruzione if, etc.), mentre si assume nullo il costo della mera attivazione di un modulo di
programma (trascurando quindi il tempo necessario per il passaggio dei parametri, per l’allocazione
di variabili locali, etc.).
Se un programma è costituito da due parti P e Q che vengono eseguite sequenzialmente, e il costo
delle quali è, rispettivamente, O(f(n)) e O(g(n)), allora il costo complessivo del programma è
O(max(f(n), g(n))). Questo risultato si generalizza al caso di un programma costituito da un numero
qualunque di parti eseguite sequenzialmente.
Se un programma richiede per k volte l’esecuzione di un frammento di codice la cui complessità,
relativa alla i-esima esecuzione, è O(fi(n)), il costo complessivo sarà
(
)
O ∑i f i (n )
Nel caso particolare in cui la complessità relativa a ciascuna esecuzione del frammento di
programma è la stessa, si ha ovviamente che il costo complessivo è O(k · f(n)).
Infine, si può notare come alcune operazioni contribuiscano maggiormente di altre al costo di un
algoritmo. Negli esempi finora mostrati, sono rilevanti, in quanto ripetute più volte, le operazioni
effettuate dentro il ciclo for, piuttosto che quelle esterne ad esso. Questa considerazione può essere
generalizzata: date le approssimazioni introdotte, la valutazione della complessità può essere
direttamente effettuata in molti casi con riferimento ad una o più operazioni dominanti
dell’algoritmo. Tali operazioni dominanti, se esistono, possono normalmente essere individuate
analizzando i cicli più interni dell’algoritmo. Se una operazione dominante è eseguita d(n) volte, la
complessità del frammento di programma che la contiene è O(d(n)).
Ricerca binaria
Si consideri adesso il caso di una sequenza ordinata di n valori (per esempio in ordine crescente),
contenuta come prima in un array a. Si ha pertanto:
a[i ] ≤ a[i + 1] per 0 ≤ i < n − 1
La ricerca di un determinato elemento all’interno di una sequenza ordinata può essere effettuata in
modo molto più efficiente rispetto alla ricerca lineare, utilizzando un algoritmo di ricerca binaria o
dicotomica, che deve il suo nome al fatto di essere basato su un ciclo che, ad ogni passo, dimezza la
porzione di sequenza in cui effettuare la ricerca. Per l’applicabilità dell’algoritmo, oltre
all’ordinamento della sequenza è necessario che la struttura dati che la contiene sia accessibile in
modo diretto, come è appunto il caso di un array.
L’algoritmo può essere illustrato informalmente nel modo seguente:
1. Si esegue un primo confronto tra l’elemento cercato e l’elemento mediano della sequenza.
L’elemento mediano di una sequenza ordinata di n elementi è quello di posto n / 2 . Se n è
pari, l’elemento mediano è per convenzione quello di posto n/2.
2. Se l’elemento mediano coincide con l’elemento cercato, la ricerca termina con successo.
Altrimenti si tratta uno dei due casi seguenti:
2.1. L’elemento mediano è maggiore dell’elemento cercato: ciò implica che, se l’elemento
cercato esiste nella sequenza, non può che trovarsi nella sua prima metà.
2.2. L’elemento mediano è minore dell’elemento cercato: ciò implica che, se l’elemento cercato
esiste nella sequenza, non può che trovarsi nella sua seconda metà.
In ogni caso, la ricerca continua restringendo l’analisi alla sola metà della sequenza
potenzialmente in grado di contenere l’elemento cercato, e ripetendo i passi 1 e 2, fino alla
individuazione dell’elemento cercato o finché la porzione di sequenza indagata si riduce ad un
solo elemento che non è quello cercato.
Il metodo seguente implementa l’algoritmo di ricerca binaria in maniera iterativa:
public int bynarySearch( int a[], int key )
{
int n = a.length; // n.ro elementi dell’array
int low = 0; // indice basso della porzione di array analizzata
int high = n - 1; // indice alto della porzione di array analizzata
int middle;
// indice del mediano
while ( low <= high ) { // ciclo di analisi, termina quando low > high
middle = ( low + high ) / 2;
if ( key == a[middle] ) return middle; //ricerca terminata con successo
else if ( key < a[middle] ) high = middle – 1; //vai nella prima metà
else low = middle + 1; // vai nella seconda metà
} // fine ciclo di analisi
return –1
// elemento non trovato
}
L’analisi approssimata della complessità del metodo porta all’espressione:
t = c1 + c2 · m + c3
essendo, come nei casi precedenti, c1 e c3 i tempi di esecuzione delle parti esterne al ciclo, che sono
costanti in quanto indipendenti dai dati, e c2 il tempo necessario per l’esecuzione di una singola
iterazione del ciclo. m è il numero di volte che il ciclo viene ripetuto. In realtà, se la ricerca termina
con successo, il tempo di esecuzione dell’ultima iterazione è sicuramente inferiore a c2, in quanto
viene eseguita una istruzione contro una istruzione e un test delle iterazioni precedenti. Inoltre,
l’istruzione return esterna al ciclo viene eseguita solo se la ricerca non ha successo, quindi c3
dovrebbe essere preso in considerazione solo per questo caso. L’equazione data, tuttavia, può
essere ancora accettata in quanto sicuramente non migliorativa della realtà.
Per determinare il valore di m in funzione dei dati di ingresso, si osservi che nel caso migliore m
vale 1, in quanto viene eseguita una sola iterazione del ciclo. Nel caso peggiore, che è quello di
ricerca infruttuosa, si esce dal ciclo quando high diventa più grande di low, cioè dopo un numero di
dimezzamenti della porzione di sequenza da indagare uguale a log 2 n 
L’espressione del tempo di esecuzione diviene quindi:
t = c1 + c2 log 2 n  + c3
e si può concludere che la complessità asintotica della ricerca binaria è O(log(n)). Rispetto alla
ricerca lineare, questo rappresenta un miglioramento notevolissimo. Se n = 1000, nel caso peggiore
il tempo di esecuzione della ricerca binaria è (circa) 10/1000 di quello della ricerca lineare, mentre
se n = 1000000 il tempo di esecuzione si riduce addirittura a 20/1000000. Si dice che la ricerca
binaria ha nel caso peggiore una complessità inferiore di un ordine di grandezza a quella della
ricerca lineare.
Il concetto di ordine di grandezza può essere formalizzato. Date due funzioni di variabile intera, f(n)
e g(n), strettamente maggiori di 0 e monotone crescenti (ipotesi assai ragionevoli per funzioni che
descrivono il tempo di esecuzione di un algoritmo), si dice che f(n) è O(g(n)), ovvero che le due
funzioni sono dello stesso ordine, se:
lim( f (n ) g (n )) = c
n →∞
essendo c una costante non nulla e finita. Invece l’ordine di f(n) è minore (maggiore) di quello di
g(n) quando:
lim( f (n ) g (n )) = 0 (= ∞ )
n→∞
Le implicazioni pratiche dei concetti appena trattati possono essere notevoli. Si supponga che un
certo problema possa essere risolto da programmi di complessità di ordine crescente, come indicato
nella seguente tabella, che mostra i tempi di esecuzioni per diversi valori della dimensione dei dati.
Per fissare le idee si suppone di utilizzare un elaboratore capace di compiere un milione di
operazioni al secondo.
Complessità
log n
n
n2
n3
2n
n =10
3.32 µsec
10 µsec
100 µsec
1 msec
1.024 msec
n =100
6.64 µsec
100 µsec
10 msec
1 sec
10 milioni di volte
l’età della terra
n =1000
9.96 µsec
1 msec
1 sec
1000 sec
----
Si può notare che, al crescere della complessità e delle dimensioni, la difficoltà del problema può
diventare rapidamente non gestibile. Nella letteratura specializzata, vengono in realtà definiti
problemi trattabili, cioè problemi di cui sia realistico calcolare la soluzione con uno strumento
automatico, i problemi per i quali esiste un algoritmo la cui complessità sia di un ordine di
grandezza polinomiale. Tutti gli altri sono considerati intrattabili. Esistono anche dei problemi, detti
NP-completi, per i quali esiste il sospetto, ma non ancora la certezza matematica, che siano
intrattabili. In pratica, anche un problema trattabile può diventare intrattabile al crescere della
dimensione. Per esempio, dalla tabella precedente, nel caso della complessità cubica, con n = 10000
si otterrebbe un tempo di esecuzione di 1000000 di secondi, cioè di più di 11 giorni. Esistono infine
anche dei problemi algoritmicamente insolubili, per i quali cioé non sono ancora stati individuati
degli algoritmi, e quindi non ha senso parlare di soluzione automatica.
Il problema dell’ordinamento
L'ordinamento dei dati è una delle applicazioni informatiche basilari in pressocché tutti i contesti
applicativi. Il suo scopo è anche quello di facilitare le ricerche dei dati all'interno di insiemi di dati
ordinati. In generale i dati possono essere molto complessi, per esempio si può trattare di oggetti
caratterizzati da molti attributi. Si chiama chiave l’attributo scelto come base per l'ordinamento. In
generale, supponendo che siano dati gli elementi:
a1, a2,
,an
ordinare consiste nel permutare questi elementi secondo un ordine:
ak , ak ,
1
2
,ak
n
tale che, data una funzione di ordinamento f, risulti:
f ak
1
f ak
2
f ak
n
Nel seguito la chiave si considera intera e gli altri attributi di ciascun elemento vengono trascurati.
Un metodo di ordinamento è detto stabile se mantiene inalterato l'ordine relativo dei dati aventi
chiavi uguali; se gli elementi sono già ordinati secondo una chiave secondaria la stabilità del
metodo di ordinamento è spesso desiderabile. Le prestazioni degli algoritmi di ordinamento sono
normalmente confrontate considerando operazioni dominanti quali il numero di confronti fra gli
elementi ed il numero di trasposizioni di elementi. I metodi di ordinamento più semplici sono i
metodi di ordinamento diretto, che richiedono un numero di confronti dell’ordine di N2, essendo N
il numero di elementi da ordinare. In generale, i metodi di ordinamento diretto sono adatti ad
illustrare le caratteristiche principali dei metodi di ordinamento, sono brevi e facili da capire, ma in
genere adatti ad ordinare insiemi di elementi di piccola dimensione. I metodi di ordinamento diretto
trattati nel seguito sono il metodo di ordinamento per selezione, il metodo di ordinamento per
inserzione e il metodo di ordinamento a bolle o bubblesort. Verrà anche trattato un metodo non
diretto ma normalmente più efficiente, il metodo di ordinamento veloce o quicksort. Tutti gli
algoritmi verranno illustrati con riferimento ad array di numeri interi.
Ordinamento per selezione
Nell'ordinamento per selezione si procede per passi successivi: si seleziona il minimo degli elementi
dell’array e lo si scambia con il primo (o ultimo) elemento, quindi si esclude questa posizione dalla
analisi e si cerca nuovamente il minimo, posizionandolo al secondo (o penultimo) posto dell'array, e
così via, fino al completo ordinamento dell’array.
Si supponga per esempio di dover ordinare in senso crescente il seguente array:
21
19
32
9
12
Primo passo
Trovato il minimo, lo si scambia con l'elemento più a sinistra:
21
19
32
9
12
Secondo passo
Il primo elemento dell'array è adesso l’elemento più piccolo, e il minimo va cercato fra gli elementi
rimanenti, segnati in giallo. Trovato il minimo, lo si sposta al secondo posto.
9
19
32
21
12
Gli altri passi di ordinamento sono simili. Il numero complessivo di passi da eseguire è pari alla
lunghezza dell’array meno 1.
Il seguente metodo selectionSort implementa l'ordinamento per selezione di un array. Esso ha a sua
volta bisogno di due metodi: minPos per trovare la posizione del minimo di una parte dell’array (cui
va passata come argomento la posizione da cui cominciare la ricerca del minimo) e swap capace di
scambiare di posto due elementi di un array. Di seguito si riporta il codice relativo ai tre metodi.
public void selectionSort (int[] b)
{
for ( int i=0; i < b.length-1; i++)
{
int mPos = minPos(b, i); //trova il minimo nell'array, da i alla fine
swap(b, mPos, i); // scambia gli elementi
}
}//fine metodo selectionSort
//----------------------------------public int minPos (int[] a, int from)
{
int posMinimo=from;
for (int i=from+1; i< a.length; i++)
if ( a[i] < a[posMinimo] posMinimo=i;
return posMinimo;
}
//-----------------------------------public void swap (int[] v, int i, int j)
{
int temp=v[i];
v[i]=v[j];
v[j]=temp;
}
Complessità dell'algoritmo di ordinamento per selezione
Per determinare la complessità asintotica dell'algoritmo di ordinamento per selezione si può
individuare una eventuale operazione dominante. Ad ogni passo l’algoritmo effettua la ricerca del
minimo di una porzione di array, seguita dallo scambio tra due elementi dell’array. Mentre
quest’ultimo ha un tempo di esecuzione costante, la ricerca del minimo richiede un ciclo for, il cui
corpo è a sua volta basato sul confronto fra due elementi dell'array. Questo confronto può pertanto
essere considerato come l’operazione dominante dell’intero algoritmo. Basta quindi determinare
quante volte viene eseguito il confronto fra due elementi dell'array. Supponendo che l'array abbia N
elementi, l’algoritmo esegue N – 1 passi. Durante il primo passo, la porzione di array da visitare
per la ricerca del minimo è di N – 1 elementi, e questo è anche il numero di confronti da effettuare;
nel secondo passo occorre invece eseguire N – 2 confronti per trovare il minimo tra gli N – 1
elementi rimasti; nel k-simo passo si eseguono N – k confronti per trovare il minimo di N – k + 1
elementi. Il numero di confronti da eseguire può essere calcolato con la seguente formula (di
Gauss):
N −1
N −1
( N − 1)N N ( N − 1)
(
)
(
)
−
=
−
−
N
k
N
N
k = N ( N − 1) −
=
1
∑
∑
2
2
k =1
k =1
Si è sfruttato il noto risultato:
M
∑j=
j =1
M (M + 1)
2
L'ordine di grandezza del numero dei confronti è pertanto N2, per cui l’algoritmo di ordinamento
per selezione ha complessità O(N2). Si osservi che questa conclusione è indipendente dalla
distribuzione dei dati. In altri termini, l’ordinamento per selezione ha complessità O(N2) in ogni
caso, in particolare anche se l’array fosse inizialmente già ordinato.
Ordinamento per inserzione lineare
In questo algoritmo di ordinamento si considera l'array diviso in due parti: una parte già ordinata, ed
una ancora da ordinare. Per ogni passo dell'algoritmo si prende un elemento dalla parte non ordinata
dell’array e lo si inserisce al posto che gli compete nella parte ordinata. L'algoritmo termina quando
si esauriscono gli elementi della parte non ordinata.
Supponendo che gli elementi debbano essere ordinati per valori non decrescenti, l'algoritmo può
essere così illustrato:
Il puntatore ordinati punta alla prima
locazione della porzione non ordinata.
L'elemento viene memorizzato in una variabile
temporanea temp.
A partire dall'ultimo elemento della parte
ordinata, il contenuto della variabile temporanea
è confrontato con ciascun elemento della parte
ordinata.
Se il contenuto di temp risulta minore
dell'elemento dell'array, si passa al successivo
confronto, dopo aver spostato l’elemento verso
destra di una posizione.
Se si trova un elemento dell'array minore di
temp allora il contenuto di temp viene inserito
nel posto immediatamente a destra di questo
elemento.
Si sposta verso destra il riferimento alla
porzione non ordinata, e si ripete il ciclo.
L'algoritmo puo' essere implementato in un unico metodo che si riporta sotto:
public void insertionSort (int[] v)
{
int i;
// scandisce i dati nella parte ordinata
int ordinati;
//puntatore al primo elemento della parte non ordinata =
//numero di elementi ordinati
boolean ins;
//indica se e' possibile inserire l'elemento fra quelli
//ordinati
for ( ordinati=1; ordinati < v.length; ordinati ++)
{
temp=v[ordinati];
ins=false;
i=ordinati; // i punta al primo elemento della parte non ordinata
while (!ins && i>0)
if (temp < v[i-1])
{
v[i]=v[i-1];
i--;
}
else ins=true;
}
}
v[i]=temp;
//si scorre la parte ordinata
//si sposta l'elemento a destra
//altrimenti si inserisce al posto dell'elemento
//corrente
Complessità dell'algoritmo di ordinamento per inserzione lineare
Il ciclo esterno viene eseguito N – 1 volte, se N è il numero degli elementi dell'array, mentre il
numero di iterazioni del ciclo interno dipende da i e da v[i]. Il tempo di esecuzione c2 di una
iterazione del ciclo interno è invece costante rispetto a questi due elementi. Si ha pertanto:
N −1
f ( N ) = c1 ( N − 1) + ∑ c2 mk
k =1
essendo c1 il tempo di esecuzione della parte del ciclo esterno che precede il ciclo interno, mentre
mk è il numero di iterazioni del ciclo interno alla k-esima iterazione del ciclo esterno (che coincide
peraltro con il numero di confronti da effettuare, per cui il confronto è l’operazione dominante). Per
determinare il valore di mk, si supponga di essere nel caso peggiore. Il caso peggiore è quello in cui,
ad ogni iterazione del ciclo esterno, l’elemento preso in esame, cioé temp, è minore di tutti gli
elementi della porzione non ordinata dell’array, cioé se l'array è inizialmente ordinato in maniera
decrescente e lo si vuole ordinare in maniera crescente. In questo caso occorre eseguire un
confronto durante la prima iterazione del ciclo esterno, due confronti durante la seconda e k nella kesima. Pertanto:
N −1
( N − 1)N
k =1
2
f ( N ) = c1 ( N − 1) + ∑ c2 k = c1 ( N − 1) + c2
La complessità dell’ordinamento per inserzione lineare, nel caso peggiore, è pertanto ancora O(N2).
Se invece l’array fosse inizialmente già ordinato, si dovrebbe eseguire un solo confronto ad ogni
iterazione del ciclo esterno, e pertanto la complessità sarebbe O(N).
Ordinamento a bolle (bubblesort)
L'algoritmo sfrutta il fatto che in un array ordinato, per esempio per valori non decrescenti,
l’elemento nella generica posizione i-esima è non maggiore dell’elemento nella posizione i + 1esima, per ogni i compreso tra 0 e N - 2, essendo N gli elementi dell’array. Pertanto l’ordinamento
può essere ottenuto con una serie di iterazioni, durante ognuna delle quali ciascun elemento è
confrontato con il successivo, e scambiato con esso se la relazione di ordinamento non è già
soddisfatta. Durante ogni passata vengono quindi confrontate tutte le coppie di elementi contigui: se
in una coppia il primo valore è minore o uguale al secondo, gli elementi restano inalterati, viceversa
se il primo valore è maggiore del secondo, i due elementi vengono scambiati di posto. Il nome
dell’algoritmo è giustificato dal fatto che i valori più piccoli salgono gradualmente verso la cima
dell’array (considerando la posizione di indice 0 come cima dell’array), proprio come avviene alle
bolle d’aria immerse in un liquido. L’algoritmo richiede N - 1 iterazioni. Il metodo seguente
implementa l’algoritmo:
public void firstBubbleSort (int[] b)
{
for ( int pass = 1; pass < b.length; pass++) // ciclo esterno (n.ro passate)
{
for ( int i = b.length – 2; i >= 0; i--) // ciclo interno (n.ro confronti)
{
if ( b[i] > b[i + 1] ) swap( b, i, i + 1); // eventuale scambio
} // fine ciclo interno
} // fine ciclo esterno
} // fine metodo firstBubbleSort
L’istruzione dominante di questo metodo è chiaramente il confronto tra due elementi contigui
dell’array, seguito dall’eventuale scambio. L’istruzione viene eseguita (N – 1) (N – 1) volte, per cui
la complessità è O(N2). Osserviamo però che al termine della prima passata, che inizia confrontando
l’ultimo elemento con il penultimo, l’elemento più piccolo dell’array sarà correttamente collocato
nella prima posizione dell’array stesso. La seconda iterazione potrà quindi prendere in
considerazione solo gli N - 1 elementi più grandi non ancora ordinati. Al termine della seconda
passata, il secondo elemento più piccolo sarà collocato correttamente in seconda posizione. La terza
iterazione potrà quindi prendere in considerazione solo gli N - 2 più grandi elementi non ancora
ordinati. Al termine della k-esima passata, il k-esimo elemento più piccolo sarà collocato
correttamente in k-esima posizione, e così via, fino al completo ordinamento dell’array. Inoltre, non
è detto che tutte le N – 1 iterazioni del ciclo esterno siano necessarie. Se durante una passata, infatti,
non si verifica alcuno scambio, l’array risulta già ordinato, e non è necessario ricorrere ad ulteriori
iterazioni. Pertanto, l’algoritmo può essere modificato, in modo da adattare la sua complessità alla
configurazione dei dati, come nella seguente versione:
public void bubbleSort (int[] b)
{
boolean noswap; // flag vero per non avvenuto scambio
for ( int pass = 1; pass < b.length; pass++) // ciclo esterno (n.ro passate)
{
noswap = true;
for ( int i = b.length – 2; i >= pass - 1; i--) // ciclo interno (n.ro
confronti)
{
if ( b[i] > b[i + 1] ) // confronto
{ swap( b, i, i + 1); // eventuale scambio
noswap = false;
}
} // fine ciclo interno
if ( noswap ) return;
} // fine ciclo esterno
} // fine metodo bubbleSort
Complessità dell'algoritmo di ordinamento a bolle
In questa formulazione, se l’array è inizialmente già ordinato (caso migliore), l’algoritmo esegue
solo la prima iterazione del ciclo esterno, e poi termina. La complessità è determinata quindi solo
dal ciclo interno, ed è pertanto O(N). Se l’array è inizialmente ordinato a rovescio (caso peggiore),
sono necessarie tutte le N – 1 iterazioni del ciclo esterno, perchè dopo ognuna di esse solo un
elemento si troverà nella posizione corretta. Durante la pass-esima iterazione del ciclo esterno,
peraltro, sono necessarie N – 1 – (pass –1) = N – pass iterazioni del ciclo interno. Pertanto:
f ( N ) = c1 ( N − 1) +
N −1
∑ c2 (N − pass ) = c1 ( N − 1) + c2 N (N − 1) − c2
pass =1
= c1 ( N − 1) + c2 N ( N − 1) − c2
( N − 1)N
2
= c1 ( N − 1) + c2
N −1
pass =
∑
pass =1
( N − 1)N
2
La complessità dell’algoritmo di ordinamento a bolle, nel caso peggiore, è pertanto O(N2).
Ordinamento veloce (quicksort)
L'algoritmo si basa sulla osservazione che è più semplice ordinare più vettori piccoli piuttosto che
uno molto grande. Per ottenere le parti più piccole, si divide l'array in due parti, separate da un
elemento centrale detto sentinella o pivot (cardine): una parte contiene elementi tutti minori del
pivot e una parte contiene tutti elementi maggiori del pivot. Le due parti così ottenute vengono
ulteriormente divise in due, attorno ad altri due elementi pivot, e così via, fino a quando ogni parte è
costituita da soli due elementi, che vengono ordinati facilmente. Si noti come i subarray via via più
piccoli risultino ordinati fra di loro, ma siano in generale disordinati al loro interno.
La classe seguente, che implementa l'ordinamento veloce, è ripresa da [3], ed è disponibile
all'indirizzo http://www.horstmann.com/bigjava.html.
public class QuickSorter
{
private int[] a;
public QuickSorter(int[] anArray)
{
a = anArray;
}
public void sort()
{
sort(0, a.length - 1);
}
public void sort(int from, int to)
{
if (from >= to) return;
int p = partition(from, to);
sort(from, p);
sort(p + 1, to);
}
private int partition(int from, int to)
{
}
}
int pivot = a[from];
int i = from - 1;
int j = to + 1;
while (i < j)
{
i++; while (a[i] < pivot) i++;
j--; while (a[j] > pivot) j--;
if (i < j) swap(i, j);
}
return j;
private void swap(int i, int j)
{
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
Il metodo sort divide il vettore in due parti e viene chiamato ricorsivamente sulle due parti del
vettore stesso. Le due parti del vettore sono ottenute usando il metodo partition che, posto il
valore pivot uguale al primo elemento dell'array da ordinare (pivot = a[from]), organizza le due
parti del vettore in modo che a sinistra stiano solo gli elementi che risultato minori di pivot e a
destra gli elementi maggiori di pivot. Se due elementi non rispettano questa relazione, il metodo
partition utilizza il metodo swap per scambiarne la posizione; come si può vedere, partition
non ordina il vettore, in quanto il semplice scambio non assicura che le due parti del vettore siano
ordinate al loro interno. L'ordinamento del vettore nella sua globalità si ottiene per il fatto che le
parti del vettore vanno diventando sempre più piccole rimanendo ordinate fra di loro. La routine
partition si arresta quando j punta al primo valore minore del pivot ed i punta al primo valore
maggiore del pivot.
Il programma è illustrato dall'esempio in figura:
Il pivot non è inserito fra le due parti in cui il
vettore è diviso, ma è lasciato sempre nella parte
di destra, ed è riordinato nelle passate
successive. Questo è il motivo per cui la
chiamata a sort ogni volta deve considerare gli
elementi da from a p e da p+1 a to. Si noti
anche che ogni elemento è spostato una sola
volta.
Un altro algoritmo, ottenuto modificando di poco il precedente, posiziona invece subito il pivot al
posto giusto. Sotto si riportano i metodi modificati.
public int partition2(int from, int to)
{
int pivot = a[from];
int i = from;
int j = to;
while (i < j)
{
while (a[i] < pivot) i++;
while (a[j] > pivot) j--;
if (i < j) swap(i, j);
}
return j;
}
public void sort2(int from, int to)
{
if (from >= to) return;
int p = partition2(from, to);
sort(from, p-1);
sort(p + 1, to);
}
In questo caso il pivot è
portato subito al posto
giusto e non deve essere
più considerato nelle
successive chiamate di
sort. Inoltre in questo
caso gli elementi
possono essere spostati
piu' volte, si osservi per
esempio il pivot della
prima iterazione.
Complessità dell'algoritmo di ordinamento veloce
Il metodo sort, a parte le due attivazioni ricorsive, esegue un numero di operazioni che dipende da
partition. A sua volta, partition ha una complessità che dipende dal numero di iterazioni del
ciclo while (i < j). Questo numero è al più to + 1 – (from – 1), dato che ad ogni iterazione
viene incrementato i e decrementato j. Pertanto la complessità di partition è lineare con il
numero di elementi sui quali opera. La complessità di sort dipende inoltre chiaramente dal numero
di attivazioni ricorsive di sort stesso, e quindi dalla scelta del pivot. Indicando con f(N) la
funzione, ancora ignota, che esprime il tempo di esecuzione necessario per ordinare un array di N
elementi, si ha:
se N = 1
c

f (N ) = 

cN + f (t ) + f ( N − t ) se N > 1
Si è supposto che la prima attivazione di sort richieda di ordinare un vettore di t elementi e uno di
N – t elementi. Questa equazione di ricorrenza ammette soluzioni diverse al variare di t.
Il caso peggiore dell’algoritmo è quello in cui ogni volta viene scelto come pivot l’elemento più
grande (o più piccolo) del vettore. Se, come nel metodo mostrato in precedenza, il pivot coincide
ogni volta con il primo elemento dell’array, il caso peggiore è dunque quello di un array già
ordinato. Infatti, in questo caso, ogni volta l’array viene partizionato in due parti, una avente un
solo elemento e l’altra i rimanenti. Ricordando che, se si prescinde dal costo delle attivazioni
ricorsive, la complessità è lineare, per la complessità di sort si può così affermare:
costo di
sort(0,N-1)
sort(0,N-2)
sort(0,N-3)
....
sort(0,1)
sort(0,0)
=
=
=
=
=
=
O(N) + costo di sort(0,N-2)
O(N-1) + costo di sort(0,N-3)
O(N-2) + costo di sort(0,N-4)
.....
O(2) + costo di sort(0,0)
O(1)
Sommando si ottiene, per il caso peggiore, la complessità O(N2), dello stesso ordine, cioé, degli
altri algoritmi di ordinamento meno sofisticati finora visti.
L’analisi del caso peggiore non permette però di spiegare il buon comportamento pratico del
quicksort. E’ utile a tal proposito una analisi del caso migliore. Questo si verifica quando il pivot è
scelto in modo tale da suddividere in due parti di uguale lunghezza. L’equazione di ricorrenza
diviene infatti:
f ( N ) = cN + f ( N 2) + f ( N 2) = cN + 2 f ( N 2 )
Si può dimostrare che la soluzione di questa equazione è O(NlogN), e si dimostra altresì che questa
è anche la complessità dell’algoritmo nel caso medio.
Intuitivamente, il comportamento generalmente efficiente dell’algoritmo quicksort si spiega proprio
con il fatto che è improbabile che il pivot divida l’array in due parti, di cui una molto più grande
dell’altra. E’ invece molto più probabile che l’array sia diviso in due parti aventi all’incirca lo
stesso numero di elementi, realizzando quindi una situazione di caso migliore.
Bibliografia
1.
2.
3.
4.
5.
Niklaus Wirth "Algoritmi+Strutture Dati=Programmi", Tecniche Nuove, 1987.
Luca Cabibbo "Fondamenti di Informatica, Oggetti e Java", Mc Graw-Hill, 2004.
Horstmann-"Concetti di informatica e fondamenti di Java 2", Apogeo, 2002.
C. Batini et al. “Fondamenti di programmazione dei calcolatori elettronici”, F. Angeli, 1992.
S. Ceri, D. Mandrioli, L. Sbattella “Informatica arte e mestiere”, McGraw-Hill, 1999.