Complessità, algoritmi ricerca e ordinamento

Fondamenti di Informatica
CdL Ingegneria Meccanica
A.A. 2013/14
Docente:
Ing. Ivan Bruno
Complessità, algoritmi ricerca e
ordinamento
1.
2.
3.
4.
Iterazione & ricorsione
Esecuzione e complessità
Algoritmi di ricerca
Algoritmi di ordinamento
1
Iterazione e ricorsione
Ricorsione
In generale si dice ricorsiva una definizione che fa
riferimento a sé stessa, in modo diretto o tramite
ulteriori definizioni intermedie
Ricorsione nei dati
Ricorsione nelle funzioni
Ricorsione nei dati
Es:
struct list
{
float value;
struct list *next;
};
La ricorsione è insita nella definizione del puntatore next che è
ancora del tipo struct list
Non è “corretta” la seguente:
struct list
{
float value;
struct list next;
};
La definizione può contenere il puntatore ad una struttura non
ancora definita (ovvero sarà definita in seguito nel codice)
La rappresentazione del puntatore ha una dimensione e il
formato di un indirizzo generico indipendente dal tipo di dato
puntato
2
Ricorsione nelle funzioni
La definzione di una funzione f() è ricorsiva quando
essa delega parte della sua operazione ad una
ulteriore istanza di f() stessa
Ricorsione diretta: quando il corpo della funzione
f() contiene un riferimento a f() stessa
Ricorsione indiretta: quando il corpo della funzione
f() contiene un riferimento ad una funzione che
direttamente o indirettamente fa riferimento a f()
Ricorsione nelle funzioni
È basata sul principio di induzione matematica
Le funzioni ricorsive sono solitamente eleganti, sintetiche, di
immediata comprensione, ma bisogna stare attenti a non
abusarne.
Infatti, sono meno efficienti delle funzioni iterative, perchè la
stessa funzione viene invocata molte volte, ed ogni invocazione
di una funzione "costa".
Inoltre, bisogna stare attenti a definire correttamente le funzioni
ricorsive, altrimenti il rischio è generare una catena di chiamate
infinita nell'esecuzione del programma!!!!
E’ necessario che ci sia sempre una condizione che produca la
fine della ricorsione, in genere è una condizione iniziale o di
innesco della ricorsione.
3
Differenza tra funzioni iterative e ricorsive
fattoriale di un numero:
Il fattoriale n! di un intero n maggiore o uguale a 2 è definito come:
n! = n * (n-1) * (n-2) * ... * 2 * 1
cioè: n! = n * (n-1)!
Inoltre assumiamo che:
1! = 1
0! = 1
Funzione ITERATIVA:
Funzione RICORSIVA:
int fatt (int n) {
int risultato=1; // variabili locali
int i;
if ( n==0 )
risultato = 1;
else
for(i=1;i<=n; i++) // iterazione
risultato *= i;
return risultato;
}
int fatt (int n) {
if (n==0) return 1;
else
return n*fatt(n-1);
}
Differenza tra funzioni iterative e ricorsive
Ricorsione e stack:
void main(){
int x = 2, y;
y = fatt(x);
}
Funzione RICORSIVA:
int fatt (int n) {
if (n==0) return 1;
else
return n*fatt(n-1);
}
4
Differenza tra funzioni iterative e ricorsive
Caso iterativo e stack
Funzione ITERATIVA:
RECORD
ATTIVAZIONE
FATT(2)
int fatt (int n) {
int risultato=1; // variabili locali
int i;
if ( n==0 )
risultato = 1;
else
for(i=1;i<=n; i++) // iterazione
risultato *= i;
return risultato;
}
void main(){
int x = 2, y;
y = fatt(x);
}
Lista semplice: funzioni ricorsive
Riscrittura di alcune funzioni in chiave ricorsiva
void visit_r(struct list *ptr)
{
if(ptr!=NULL)
{
printf(“%f”,ptr->value);
visit_r(ptr->next);
}
}
//Inserimento ordinato
void ord_insert_r(struct list **ptrptr,
float value)
{
if(*ptrptr!=NULL && (*ptrptr)>value<value)
ord_insert_r(&((*ptrptr)>next),value);
else
pre_insert(ptrptr, value);
}
struct list * search_r(struct list *ptr, float key)
{
struct list * found=NULL;
if(ptr!=NULL)
{
if(ptr->value==key)
{
found=ptr;
}
else
return search_r(ptr->next,key);
}
return found;
}
5
Lista semplice: funzioni ricorsive
La natura ricorsiva della
lista consente agilmente:
//visita all’indietro
void visit_r_backward(struct list *ptr)
{
if(ptr!=NULL)
{
visit_r(ptr->next);
printf(“%f”,ptr->value);
}
}
//Nel caso di {1,2,5} la stampa sarà {5,2,1}
//Implementazione iterativa
void visit_r_backward(struct list *ptr)
{
struct list *tmp_ptr;
init(&tmp_ptr)
while(ptr!=NULL)
{
pre_insert(&tmp_ptr,ptr->value);
ptr=ptr->next;
}
visit(tmp_ptr);
}
La versione iterativa risulta più complessa, in quanto è stato
necessario utilizzare una lista di appoggio dove copiare in ordine
inverso i valori della lista di partenza e successivamente effettuare la
visita in “avanti” (forward)
Differenza tra funzioni iterative e ricorsive
Conclusioni:
Le funzioni ricorsive sono generalmente più vicine
alla definizione matematica di certe funzioni:
serie ricorsive, quali ad esempio i numeri di Fibonacci;
funzioni matematiche ricorsive (fattoriale, etc.).
Le funzioni iterative sono generalmente più
efficienti di una soluzione ricorsiva (sia in termini
di memoria che di tempo di esecuzione).
E’ possibile riscrivere una funzione ricorsiva in
forma iterativa, quest’ulitma tuttavia può risultare
più complessa.
6
Costo di esecuzione e complessità
Il costo di esecuzione di un algoritmo quantifica le
risorse necessarie all’esecuzione
Le risorse in gioco sono principalmente:
Numero di operazioni eseguite (tempo di calcolo)
Quantità di variabili allocate (spazio di memoria)
Generalmente si valuta il tempo di esecuzione:
il tempo necessario dalla partenza al completamento.
dipende dal numero di istruzioni che l’algoritmo esegue e
quindi da come queste sono organizzate.
dipende dai dati che vengono utilizzati dall’algoritmo ma
può anche dipendere da variabili aleatorie. Alcuni di questi
fattori determinano solo variazioni marginali del tempo di
esecuzione, altri sono determinanti.
Complessità: parametri determinanti
I parametri che influenzano in modo determinante il
tempo di esecuzione di un algoritmo vengono detti
parametri determinanti.
Si può intendere la complessità C(X) di un algoritmo
X, come un valore correlato con il tempo di
esecuzione dell’algoritmo X in funzione di k
parametri rilevanti, con k>=1.
C(X)=f(h1,...,hk ) con hi = parametro determinante
Attraverso la complessità è possibile valutare
quanto un algoritmo è efficiente e tra più algoritmi
che producono gli stessi risultati qual è il più
efficiente
7
Complessità
Prima di trattare la complessità per quanto riguarda i linguaggi di alto
livello, facciamo un esempio utilizzando l’Assembly: in questo
linguaggio ciascuna istruzione viene eseguita in un certo numero di
colpi di clock variabile e dipendente dall’architettura della CPU;
Quindi per valutare il tempo di esecuzione e quindi la complessità di un
algoritmo in tale linguaggio basta contare il numero dei colpi di clock .
Questo metodo di valutazione non può essere usato per i linguaggi di
alto livello, nel quale si misura la complessità calcolando il numero di
istruzioni e non quello di colpi di clock.
Per esempio in Assembly le due istruzioni:
A = B*C ;
B = A+9*B
hanno costi diversi nel caso che tali variabili siano in memoria ( 17 colpi di
clock), oppure che siano riferite ai registri ( 9 colpi di clock). In un linguaggio di
alto livello il costo può essere considerato sempre 5 perché non è dato
conoscere dove vanno queste istruzioni.
Complessità: modello di costo
Perché non ci siano indecisioni e per ottenere che la complessità sia indipendente dal
sistema di elaborazione, dai dati in ingresso, dal linguaggio e dal traduttore usato, si
usa il seguente modello di costo:
1.
2.
3.
4.
5.
6.
7.
8.
il costo di ogni operazione di lettura (di una variabile), scrittura (di un dato),
assegnazione, confronto e operazione algebrica è unitario,
il costo di ogni sequenza di istruzioni è dato dalla somma dei costi delle singole
istruzioni,
il costo di un ciclo while oppure do-while è dato dalla somma del costo del test
dell’istruzione e del costo di esecuzione delle istruzioni che costituiscono il corpo
dell’istruzione iterativa,
il costo di un’istruzione if è dato dal costo di valutazione della condizione (che è
unitario), più il costo dell’esecuzione se la condizione e’ vera. Si deve anche
considerare il costo delle istruzioni collegate alla parte in alternativa,
il costo di una struttura iterativa del tipo for è dato dalla somma del costo di
inizializzazione della variabile indice (unitario), del costo di incremento della variabile
indice, del costo di fine ciclo. Il costo delle ultime due operazioni è unitario,
il tempo di esecuzione (costo) di un algoritmo è dato dalla somma dei costi di tutte le
istruzioni che lo compongono trascurando il costo di attivazione.
Il costo di ogni dichiarazione e’ considerato unitario
Il costo di ogni chiamata di funzione o procedura pari al numero di parametri del
sottoprogramma
8
Complessità: modello di costo
Consideriamo come unico parametro rilevante il coinvolgimento della CPU
nell’esecuzione delle istruzioni, cercando di ridurre ad operazioni elementari che
hanno un tempo di esecuzione comparabile. Naturalmente gli errori che si
compiono sono grandi ma nel contesto di un grosso algoritmo e’ possibile farsi
un’idea abbastanza precisa del costo computazionale e quindi anche dei tempi
di esecuzione quando due algoritmi vengono confrontati.
Per esempio di veda la procedura seguente:
void prova(float* a, float *y, int s, float t)
{
int i;
//Dichiarazione
*y=a[1];
//Assegnazione
s=1;
//Assegnazione
for(i=1;i<N;i++)
//Operazioni del for
{
s=s*t;
//Assegnazione nel ciclo
*y=*y+a[i+1]*s
//Assegnazione nel ciclo
}
}
Questo algoritmo ha una complessità pari a C(X)=3+9N. 3 poiché si ha una
dichiarazione, e due assegnazioni. 9N poiché per ogni ciclo del for si hanno 2
assegnazioni, 4 operazioni nel codice interno e 3 altre operazioni nel corpo
dell’istruzione for.
Confronto di algoritmi per complessità
Due algoritmi che risolvono uno stesso tipo di problema sono
confrontabili se è possibile esprimere le loro complessità in
funzione di alcuni degli stessi parametri determinanti.
Su questa base e’ possibile comparare due algoritmi in base alla
loro complessità. Se C(A)<C(B) A è migliore di B, se C(A)>C(B),
B è migliore di A.
Queste disuguaglianze possono valere solo per certi valori del o
dei parametri determinanti. In tale caso e’ possibile identificare
uno o più valori di taglio. Valori per i quali le due complessità si
equivalgono ma che con un incremento di tale valore si ha una
complessità maggiore per uno mentre con un decremento si ha
una complessità maggiore per l’altro.
9
Confronto di algoritmi per complessità
Per esempio, se si ha C(A) = 2n+1 (serie 2) e C(B) = n2 (serie 1)
si ha che per n<3 , B è migliore di A e che per n>3, A è migliore
di B.
30
25
C( )
20
Serie1
15
Serie2
10
5
0
1
2
3
4
5
n
Complessità: casi migliore, medio e peggiore
Per non complicare l’analisi e poter tenere conto della variabilità del
comportamento degli algoritmi in base ai dati si distinguono tre casi:
PEGGIORE, MEDIO, e MIGLIORE
Nella valutazione del caso peggiore (migliore) di esecuzione di un algoritmo si fa
riferimento a quei valori per i dati in ingresso, in corrispondenza dei quali il costo
di esecuzione risulta il più (meno) elevato tra tutti quelli possibili.
Per esempio si consideri quanto costa nei tre i casi fare un inserimento di uno
studente in una fila di banchi.
IL CASO PEGGIORE si ha quando per inserire uno studente e’ necessario spostare
tutti gli altri.
IL CASO MIGLIORE si ha quando lo studente viene inserito in un posto libero e questo
viene raggiunto in un passo.
IL CASO MEDIO si ha quando solo la meta’ degli studenti si deve spostare per fare
posto al loro compagno (inserimento centrale, spostamento degli altri studenti da una
parte o dall’altra)
Si nota bene la differenza tra i tre casi, perché questa operazione dipende dalla
lunghezza della fila di banchi, N e dal numero di studenti già posizionati.
Dal punto di vista teorico il caso medio dovrebbe essere dato dalla somma su
tutti i casi possibili diviso il numero di casi.
10
Complessità asintotica
La presenza di valori di taglio e la difficoltà di trovare la funzione di
complessità in alcuni casi rendono difficile la valutazione ed il confronto
di algoritmi.
Una soluzione consiste nel considerare la complessità dell’algoritmo
come funzione dei suoi parametri determinanti quando il loro valore/ o i
loro valori tendono all’infinito. Si analizza in questo modo un
comportamento asintotico
Questa semplificazione porta nella maggior parte dei casi a risultati
significati anche per il confronto di algoritmi. Quando tale operazione
non produce risultati che permettono di distinguere l’algoritmo più
efficiente dall’altro e’ necessario ritornare a modelli più dettagliati come
quello presentato in precedenza.
Per identificare la complessità asintotica si deriva la funzione di
complessità di dettaglio con i suoi parametri determinanti quindi si
trascurano:
costanti moltiplicative ,
termini additivi di ordine inferiore
Complessità asintotica
La COMPLESSITA’ ASINTOTICA si indica con O(f(N)) e si dice che un
algoritmo ha complessità O(f(N)) se per ogni ingresso di dimensione N esegue
un numero di operazioni che è proporzionale a f(N), a meno di costanti additive.
In pratica la complessità asintotica è il valore a cui tende asintoticamente
l’istruzione dominante/determinante dell’algoritmo.
Nel caso che il numero di operazioni richieste sia costante per ogni ingresso, la
complessità dell’algoritmo è costante e si indica con O(1) o con semplicemente
1.
Nell’analisi della complessità si nota l’esistenza di alcune istruzioni dette
dominanti quando il costo dipende più da certe istruzioni, che da altre e quando
la funzione complessità f(N) è uguale al numero di esecuzioni di tali istruzioni.
Si chiamano istruzioni dominanti quelle che vengono eseguite un elevato
numero di volte durante l’esecuzione dell’algoritmo. Tale numero di esecuzioni
e’ fortemente relazionato con i parametri determinati dell’algoritmo. Per esempio
in un algoritmo che presenta molte istruzioni di selezione ma un solo ciclo for di
N iterazioni, si ha che N e’ un parametro determinante e quindi le istruzioni
dentro il ciclo for sono dominanti poiché vengono eseguite N volte.
11
Notazione asintotica Ο
f(n) = Ο( g(n) ) se ∃ tre costanti c1,c2>0 e n0≥0
tali che c1 g(n) ≤ f(n) ≤ c2 g(n) per ogni n ≥ n0
f(n) = Ο(g(n))
c2 g(n)
f(n)
c1 g(n)
n0
n
Complessità asintotica
Qui di seguito verranno enunciate due regole per
valutare la complessità:
1) se un algoritmo A e’ composto da k parti P1,...,Pk
eseguite sequenzialmente, allora la complessità
C(A) è data dalla parte più costosa.
2) se l’algoritmo A richiede k esecuzioni di un
sottoalgoritmo e se fi(N) è il costo della sua i-esima
esecuzione, allora
(
C ( A) = O ∑i =1 f i ( N )
k
)
12
Complessità asintotica
Proprietà:
Se O(f(n))=O(g(n)) allora C(A)=O(f(n))
Se O(f(n))>O(g(n))
C(A)=O(f(n)+g(n))
∀ c>0 O(f(n))=O(c·f(n))
C(A)=(g(n))
C(A)=O(f(n))
Vale:
O(1) < O(ln2(n)) < O(nα) < O(2βn) ∀ α, β >0
Per esempio:
3N2+5N+2 ha complessità asintotica O(N2) .
Algoritmi di ricerca
Data una sequenza di elementi, occorre verificare
se un elemento fa parte della sequenza oppure
l’elemento non è presente nella sequenza stessa.
In generale una sequenza di elementi si può
realizzare come un array. E la scansione avviene
usando un indice.
Se la sequenza non è ordinata a priori occorre
eseguire una ricerca lineare o sequenziale.
Se la sequenza è ordinata è opportuno eseguire
una ricerca binaria.
13
Ricerca lineare
L’algoritmo di ricerca lineare (o sequenziale) in una
sequenza (array) è basato sulla seguente strategia:
Gli elementi dell’array vengono analizzati in sequenza,
confrontandoli con l’elemento da ricercare (chiave) per
determinare se almeno uno degli elementi è uguale alla
chiave.
Quando si trova un elemento uguale alla chiave la ricerca
termina.
La ricerca è sequenziale, nel senso che gli elementi
dell’array vengono scanditi uno dopo l’altro
sequenzialmente.
L’algoritmo prevede che al più tutti gli elementi dell’array
vengano confrontati con la chiave. Se l’elemento viene
trovato prima di raggiungere la fine della sequenza non
sarà necessario proseguire la ricerca.
Ricerca lineare: complessità
Assegnando un costo costante all’operazione
di comparazione tra il valore cercato e gli
elementi si può definire formalmente il costo
della ricerca formale come segue:
c1 + Γseq ( N − 1) se N > 0
Γseq ( N ) = 
c2
se N = 0

Da cui si ha che Γseq(N)=c·N
C(Rseq)=O(N)
14
Ricerca lineare: esempio C
typedef int Boolean;
#define TRUE 1
#define FALSE 0
#define EPS .000001 //-EPS<x<EPS Utilizzato come soglia per la precisione di macchina
struct data{
float key;
….
};
Boolean sequential_search(struct data *V, int N, struct data T)
{
Boolean found=FALSE;
int count=0;
while(!found && count<N)
{
if(is_equal(V[count],target)==TRUE)
{
found=TRUE;
Boolean is_equal(struct data a, struct data b)
}
{
else
if(a.key-b.key<EPS*a.key && -EPS*a.key<a.keycount++;
b.key)
}
return TRUE;
return found;
else
}
return FALSE;
}
Ricerca binaria o dicotomica
L’algoritmo di ricerca lineare richiede che al più tutti
gli elementi dell’array vengano confrontati con la
chiave. Questo è necessario perché la sequenza
non è ordinata.
Se la sequenza su cui occorre effettuare la ricerca è
ordinata si può usare un algoritmo di ricerca molto più
efficiente che cerca la chiave sfruttando il fatto che gli
elementi della sequenza sono già disposti in un dato
ordine.
Esempi di sequenze ordinate: elenco telefonico, agenda,
etc.
In questi casi si usa un algoritmo di ricerca binaria che è
più efficiente perché riduce lo spazio di ricerca.
15
Ricerca binaria o dicotomica
L’algoritmo di ricerca binaria cerca un elemento in una
sequenza ordinata in maniera crescente (o non decrescente)
eseguendo i passi seguenti finché l’elemento viene trovato o si
è si è completata la ricerca senza trovarlo:
1.
2.
3.
4.
Confronta la chiave con l’elemento centrale della sequenza,
Se la chiave è uguale all’elemento centrale, allora la ricerca
termina positivamente,
Se invece la chiave è maggiore dell’elemento centrale si effettua
la ricerca solo sulla sottosequenza a destra,
Se invece la chiave è minore dell’elemento centrale dello spazio
di ricerca, si effettua la ricerca solo sulla sottosequenza a
sinistra.
Ricerca binaria o dicotomica
Caso vettoriale
Assumiamo dati tipo intero
La natura della strategia di ricerca suggerisce una soluzione ricorsiva:
int b_search(int *V, int N, int target)
{
if(N>0)
{
if(V[N/2]==target)
return 1;
else
{
if(V[N/2]<target)
return b_search(V,N/2,target);
else
return b_search(&V[N/2+1],N-N/2-1,target);
}
}
else
return 0;
}
16
Ricerca binaria: modello costo
Caso vettoriale:
Si può ipotizzare un costo c1 costante per l’accesso
dell’elemento centrale usato per il confronto sommato al costo
per capire se proseguire nella ricerca sulla metà selezionata
c + Γ ( N / 2) se N > 0
Γbin ( N ) =  1 bin
c2
se N = 0

Il procedimento esposto procede a dimezzare il vettore ad ogni
iterazione analizzando solo un sottovettore. La prima volta il
vettore viene ridotto a ½ la seconda ad ¼ , etc. fino a che (nel
caso peggiore di ricerca senza successo) il sottovettore non si
riduce ad un solo elemento. Al passo i-esimo il vettore sarà
composto da un numero di elementi pari a:
N / 2i 
Ricerca binaria: modello costo
L’algoritmo si arresta quando il sottovettore ha lunghezza unitaria:
N 
 2i  = 1
dalla quale si ricava che si arresta per:
i = Log 2 N  + 1
Si può pertanto affermare che la ricerca dicotomica ha una
complessità pari a c1·Log2(N) e quindi complessità asintotica
pari a O(Log2(N)).
17
Ricerca binaria: modello costo
Caso lista in forma collegata:
Bisogna considerare un costo aggiuntivo per la
ricerca dell’elemento mediano.
Il costo aggiuntivo è lineare
c ⋅ N / 2 + Γbin ( N / 2) se N > 0
Γbin ( N ) =  1
c2
se N = 0

Γ(N) = cN/2 + Γ (N/2) = cN/2+cN/4 + Γ (N/4) =… = ( ∑ j=1...i c·N/2j ) + Γ(N/2i) =
= cN ·∑ j=1...i 2-j + T(N/2i)
Γ(N) = cN ·∑ j=1...i 2-j + T(1) = kN
O(N)+O(1)
O(N)
Per i=log2N
Ricerca Binaria, Confronto fra O(N) e O(Log N)
Sulla base della complessità l’algoritmo di ricerca dicotomica
(array) e’ sicuramente più veloce rispetto a quello esaustivo. Nella
tabella sono riportati dei valori che danno un’idea del numero dei
confronti, i, che sono necessari quando si ha a che fare con vettori
di N elementi anche di dimensioni considerevoli. Si noti che con
vettori dell’ordine di elementi sono necessari solo 200 confronti.
Confronti, i
N = 2I − 1
70
60
1
1023
10
1,13E+15
50
1,27E+30
100
1,61E+60
200
50
Confronti
1
40
Serie2
Serie3
30
20
10
58
54
50
46
42
38
34
30
26
22
18
14
6
10
2
0
N
18
Confronto fra (k N) e (H Log N)
Dal grafico precedente sembra evidente che non vi sono valori di N per
i quali LogN risulta peggiore. Per ogni valore di N, algoritmi con O(N)
necessitano di un maggior numero di confronti.
In effetti questa condizione non e’ sempre vera quando si vanno a
considerare le complessità di dettaglio e non quelle asintotiche.
E’ interessante fare un’analisi dell’andamento di due funzionali di costo:
K*N e H Log N, (con Log in base 2). Questi non rappresentano più il
numero di confronti ma il numero delle operazioni elementari Q
effettuate dall’algoritmo.
Dal punto di vista asintotico sicuramente Log N è da preferire ma per
valori piccoli di N con valori diversi di K ed H LogN presenta un
comportamento che può essere peggiore di quello di N.
Confronto fra (k N) e (H Log N)
Per esempio in figura e’ stato confrontato 10 N (in rosa, serie 2) con 50
LogN (in giallo, serie 3). Da questo grafico si evince che in questo caso
è preferibile utilizzare un algoritmo che ha 10 N se N minore di circa
22 e utilizzare l’algoritmo che ha 50LogN solo quando N è grande. Il
confronto su base asintotica, cioè basato sulla complessità asintotica
ha senso solo per N che tende a infinito o quanto meno per N “grande”.
La misura di questo “grande” dipende dai fattori scale del problema e
dalle costanti, per esempio K ed H, rispettivamente 10 e 50, in questo
esempio.
700
600
500
400
Q
Serie2
Serie3
300
200
100
58
54
50
46
42
38
34
30
26
22
18
14
6
10
2
0
N
19
Algoritmi di ordinamento
Problema:
Dato un insieme S di n oggetti presi da un dominio totalmente
ordinato, ordinare S
Esempi: ordinare una lista di nomi alfabeticamente, o
un insieme di numeri, o un insieme di compiti d’esame
in base al cognome dello studente
Input: una sequenza di n numeri <a1,a2,…,an>
Output: una permutazione (riarrangiamento)
<ai1, ai2,…, ain> della sequenza di input tale che:
ai1 ≤ ai2 ≤…≤ ain
Assunzione: I dati sono in memoria primaria
Sequential Sort
Sull’insieme V viene selezionato il massimo e
l’elemento che lo realizza viene swappato con
l’elemento in ultima posizione
Il problema prosegue allo stesso modo sui primi N-1
elementi
Dopo k passaggi nelle ultime k posizioni ci sono i k
elementi maggiori.
Dopo N-1 passaggi il vettore è ordinato
20
Sequential Sort: costo
L’equazione del costo ha la forma:
c ⋅ N + c2 + ΓSeqS ( N − 1) se N > 1
ΓSeqS ( N ) =  1
se N = 1
c3

Dove c1·N è il costo di selezione del massimo e c2 il
costo dello swap.
La soluzione è:
ΓSeqS ( N ) = c ⋅
N ⋅ ( N − 1)
2
E quindi CSeqS(N)=O(N2)
Sequential Sort: implementazione C
Costruisce in Perm il vettore di permutazione (ovvero di indici) tale che se k2>=k1 allora
V[Perm[k2]].key >= V[Perm[k1]].key
void sequential_sort(struct data *V, int N, int *Perm)
{
int count, count_of_max, iter;
for(count=0;count<N;count++)
Perm[count]=count;
for(iter=0;iter<N;iter++)
{
for(count=1, count_max=0;count<N; count++)
{
if(V[Perm[count]].key)>V[Perm[count_of_max]].key)
count_of_max=count;
swap(Perm, count_of_max,N-iter-1);
}
}
}
21
Sequential Sort: ottimizzazione
Si può cercare di sostituire massimo e minimo contemporaneamente
si dimezza il numero di iterazioni, che diventano più costose
computazionalmente
la ricerca contemporanea di massimo e minimo è comunque meno costosa
(il guadagno è qui)
if (x>y)
{
if (x>max)
max=x;
if (y<min)
min=y;
}
else
{
if (y>max)
max=y;
if (x<min)
min=x;
}
NOTA: il costo anche se ridotto di un
fattore ¾ rimane O(N2), stiamo riducendo i
confronti da 4 a 3
Bubble Sort
Richiede il confronto e lo swap di elementi
successivi, iterando sui dati
All’iterazione k si scandiscono le posizioni da 0 a
N-k-1, confrontando gli elementi [posizione] e
[posizione+1], eventualmente permutando le
posizioni non ordinate
Se non si scambia nessun elemento non si itera
più
22
Bubble Sort: proprietà
un elemento che all’inizio dell’iterazione NON
è preceduto da alcun maggiorante avanza
fino ad incontrare il primo; se non ha
maggioranti avanza fino all’ultima posizione
un elemento che all’inizio dell’iterazione è
preceduto da almeno un maggiorante, al
termine dell’iterazione è arretrato di UNA
posizione
Bubble Sort: Esempio
25
35
17
2
10
3
12
Supponiamo di voler ordinare in senso crescente.
Definiamo un indice che mi punta ad un elemento, e si verifica localmente la
condizione di ordinamento. Confrontiamo inizialmente il 25 con il 35, ed
essendo ordinati non compio su di loro nessuna azione, se non quello di
incrementare l’indice.
25
35
17
2
10
3
12
Adesso confronto 35 e 17.
In questo caso non è rispettato l’ordine crescente e devo perciò riorganizzarli;
questo lo posso fare semplicemente scambiandoli.
Questo mi porta alla seguente configurazione:
25
17
35
2
10
3
12
Adesso devo confrontare 35 con 2; sono in ordine
sbagliato, inverto e vado avanti così, fino a che,
nel nostro esempio non si ha 35 sul fondo del
vettore:
23
Bubble Sort: Esempio
25
17
2
10
13
12
35
E’ accaduto che la chiave più pesante è stata ‘ sistemata’; infatti con
l’algoritmo del bubble sort ‘ trascino’ sul fondo le chiavi più pesanti e
questo fa si che alla passata succesiva non mi preoccupi più di loro.
Alla prima passata, ho speso N - 1 confronti ( e un certo numero di
scambi che però non è dominante ) e mi ha permesso di mettere a posto
un elemento. Al giro successivo lavoro però su tutti gli elementi tranne
l’ultimo, che so già sistemato, questo vuol dire che lavoro su un vettore
che mi consente di effettuare N - 2 confronti, successivamente N - 3 ecc.,
secondo una successione che converge, al solito a ,
N ⋅ (N - 1)
2
abbiamo allora una complessità asintotica che è un O( N2 ) .
Se volevamo, al contrario, un ordinamento per chiavi decrescenti,
saranno le chiavi più leggere ad essere spinte verso il basso.
Bubble Sort: Costo
Dopo k iterazioni gli ultimi k elementi sono nelle posizioni corrette
(proprietà 1), dobbiamo iterare solo sulle prime N-k posizioni. Nel
caso peggiore si avranno N-1 confronti e N-1 swap. Allora il
costo assume la forma:
(s + c ) ⋅ (N − 1) + ΓBubS ( N − 1) se N > 1
ΓBubS ( N ) = 
c3
se N = 1

dove c è il costo di confronto, s costo di swap
24
Bubble Sort: Costo
Pertanto:
ΓBubS ( N ) = (s + c ) ⋅
N ⋅ ( N − 1)
2
La complessità è: CBubS(N)=O(N2)
Possiamo terminare senza aver fatto tutte le N-2
iterazioni: nell’algoritmo sequenziale le deve fare
tutte
Posso dover fare (N-1)(N-2) swap: nel sequenziale
ne facciamo sempre (N-1)
il meccanismo di virtualizzazione dell’ordine diventa
importante
Bubble Sort: implementazione C
void BubbleSort(struct data *V, int N, int *perm)
{
int iter;
Boolean noSwap;
Boolean swapFound;
iter = 0;
noSwap = FALSE;
while ( noSwap == FALSE )
{
for (count=0, swapFound=FALSE; count<N-iter-1; count++)
{
if ( isSmaller(V[perm[count]], V[perm[count+1]]) )
{
swap( perm, count, count+1); // virtualizzazione dell’ordine
swapFound = TRUE;
}
}
if ( swapFound == FALSE )
noSwap = TRUE;
else
iter++;
}
}
25
Merge Sort
Il problema viene ricondotto al problema della
fusione di due vettori ordinati separatamente
Dato un vettore di dati, si ordinano separatamente le
due metà e poi vengono fuse ottenendo un vettore
ordinato globalmente
Merge Sort: Fusione
Dato un vettore partizionato in due semivettori
ordinati, il semivettore sinistro viene inizialmente
copiato su un semivettore di appoggio.
Viene poi ripetutamente selezionato e trasferito sul
vettore complessivo il minimo del semivettore di
appoggio e di quello destro.
L’algoritmo termina quando tutti gli elementi del
vettore di appoggio sono stati copiati nel vettore
complessivo
26
Merge Sort: Fusione
Merge Sort: Costo di fusione
L’algoritmo esegue in tempo lineare rispetto
alla lunghezza N del vettore complessivo
Ad ogni selezione e confronto tra due minimi il
vettore complessivo cresce di un elemento
L’algoritmo termina quindi dopo un numero
massimo di N selezioni
Partendo da due semivettori ordinati, selezione e
confronto avviene in tempo costante
Si deve comunque considerare il tempo di copia
del vettore sinistro sul vettore di appoggio che
sempre lineare (N/2)
27
Merge Sort: ordinamento per fusione
Approccio “Divide et Impera”
Ipotesi A:
1.
Ordino i due semivettori con il sequential sort
Per ciascuna metà occorrono (N/2)2 operazioni
(N/2)2 + (N/2)2 = N2/2
2.
3.
in totale
Applico la fusione con costo N
Costo totale N2/2 + N
Soddisfatti? No o almeno fino ad un certo punto,
sicuramente per N molto grande il guadagno
esiste rispetto ad applicare il sequential Sort (fully),
però l’ordine di grandezza è sempre O(N2)
Merge Sort: ordinamento per fusione
Ipotesi B:
Partizionamo i due semivettori in ulteriori due
metà da ordinare e fondere
Il costo diventa:
Γ(N) = 4*ΓSeq(N/4)+2 ΓMerge(N/2)+ ΓMerge(N) =
= 4 N2/16 + 2(N/2)+N
= N2/4 + 2N
28
Merge Sort: Costo totale
Partizionando ricorsivamente fino ad operare su
vettori di lunghezza 2 il costo diventa:
c1 + ΓMergeS ( N / 2) + ΓMergeS ( N / 2) + c2 N
ΓMergeS ( N ) = 
c3

se N > 2
se N = 2
Dove
c1 costo per decide se proseguire nel partizionamento
c2·N costo della fusione dei semivettori
ΓMergeS(N/2) costo di ordinemento dei due semivettori
applicando ricorsivamente il Merge Sort
Merge Sort: complessità
O(N·ln2(N))
29
Merge Sort: implementazione C
Quick Sort: Idea
Per ordinare un array:
scegli un elemento (detto pivot)
metti a sinistra gli elementi <= pivot (minoranti)
metti a destra gli elementi > pivot (maggioranti)
ordina ricorsivamente la parte destra e la parte
sinistra.
L’elemento pivot costituisce una separazione
tra gli elementi effettuando una partizione
Algoritmo di partizione
30
QS: Algoritmo di partizione
Come elemento di pivot si può tranquillamente
scegliere il primo elemento
Si considerano gli N-1 elementi successivi
devo effettuare N confronti
Cpartition=O(N);
Obiettivo è posizionare il pivot in modo da dividere i
minoranti dai maggioranti
QS: implementazione partizione
int partition(struct data *V, int N)
/* assume il primo elemento come pivot;
Restituisce il numero q di valori in V minori o uguali al pivot incluso il pivot
Muove il pivot in posizione q;
Mette nelle prime q posizioni tutti e soli i minoranti del pivot */
{
int l, r;
int pivot = V[0].value;
l=0;
r=N;
while(l<r)
{
do{ r--; }while(V[r].value>pivot && r>l);
if(r!=l)
{
do{ l++; }while(V[l].value<=pivot && l<r);
swap(V,l,r);
}
}
swap(V,l,0);
return l+1;
}
31
QS: esecuzione partizione
QS: ordinamento della partizione
Il partizionamento divide in due subvettori separati dal pivot
Il pivot si trova già nella posizione finale (ad ordinamento
ottenuto)
Si procede allora all’ordinamento delle due partizioni/subvettori di
minoranti (a sx) e di maggioranti (a dx)
void quicksort(struct data *V, int N)
{
int q; // indice del pivot dopo il posizionamento
if(N>0)
{
q=partition(V, N);
quicksort(V,q-1);
quicksort(&V[q],N-q);
}
}
32
QS: costo
L’equazione è:
ΓquickS(N)= Γpartition(N)+ ΓquickS(q-1)+ ΓquickS(N-q)
Con: Γpartition(N)=cN
Caso pessimo: q=1 o q=N ovvero vettore ordinato
o ordinato inversamente
Allora:
ΓquickS(N)=cN+ ΓquickS(0)+ ΓquickS(N-1)=cN+d+ΓquickS(N-1)
Costo simile al selection Sort e quindi complessità pari
a:
CquickS=O(N2);
QS: costo ottimo/medio
Caso in cui si divide sempre per 2 ovvero la
partizione è divisa in due perfettamente e
q=N/2
ΓquickS(N)=cN+ ΓquickS(N/2)+ ΓquickS(N/2)=cN+2ΓquickS(N/2)
Formula analoga al costo del Merge Sort che porta ad avere
una complessità di CquickS =O(Nln2(N))
33
QS: conclusioni
Costo medio per il QS è uguale al costo
ottimo nei casi in cui i dati siano disordinati
La scelta del pivot non impatta su tale costo o
meglio sull’ordine di grandezza che
generalizzato diventa kNlog(N)+hN con k e h
dipendente dall’algoritmo di scelta del pivot
Condizionare la scelta del pivot potrebbe
convenire se esistesse un algoritmo il cui
costo fosse inferiore a O(N) ma questo non
esiste.
34