




















Studia grazie alle numerose risorse presenti su Docsity
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Prepara i tuoi esami
Studia grazie alle numerose risorse presenti su Docsity
Prepara i tuoi esami con i documenti condivisi da studenti come te su Docsity
Trova i documenti specifici per gli esami della tua università
Preparati con lezioni e prove svolte basate sui programmi universitari!
Rispondi a reali domande d’esame e scopri la tua preparazione
Riassumi i tuoi documenti, fagli domande, convertili in quiz e mappe concettuali
Studia con prove svolte, tesine e consigli utili
Togliti ogni dubbio leggendo le risposte alle domande fatte da altri studenti come te
Esplora i documenti più scaricati per gli argomenti di studio più popolari
Ottieni i punti per scaricare
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Una panoramica degli algoritmi di ordinamento in informatica, con particolare attenzione a insertion sort, selection sort, merge sort e quick sort. Vengono illustrati i principi di funzionamento di ciascun algoritmo, il loro pseudocodice e la loro complessità computazionale. Utile per studenti di informatica che desiderano approfondire le tecniche di ordinamento dei dati.
Tipologia: Dispense
1 / 28
Questa pagina non è visibile nell’anteprima
Non perderti parti importanti!





















Il problema dell'ordinamento degli elementi di un insieme è un problema molto ricorrente in informatica poiché ha un’importanza fondamentale per le applicazioni: lo si ritrova molto frequentemente come sottoproblema nell’ambito dei problemi reali. In effetti si stima che una parte rilevante del tempo di calcolo complessivo consumato nel mondo sia relativa all’esecuzione di algoritmi di ordinamento.
Un algoritmo di ordinamento è un algoritmo capace di ordinare gli elementi di un insieme sulla base di una certa relazione d'ordine, definita sull’insieme stesso (maggiore/minore, precede/segue, ecc.).
Tutti gli algoritmi che vedremo hanno alcuni aspetti in comune. Si basano su due tipi fondamentali di operazioni: il confronto fra due elementi e lo scambio di due elementi. Questa ipotesi di lavoro è necessaria per non costruire soluzioni irrealistiche (ad esempio, definendo una operazione che in un sol passo ordina un numero di elementi non limitato da una costante). Inoltre, per semplicità di trattazione, si suppone che gli n elementi da ordinare siano numeri interi e siano contenuti in un vettore i cui indici vanno da 1 a n.
Tuttavia, nei problemi reali, i dati da ordinare sono ben più complessi: in generale essi sono strutturati in record , cioè in gruppi di informazioni non sempre omogenee relative allo stesso soggetto (ad esempio, l'archivio studenti ha moltissimi record, ciascuno contenente molte informazioni relative ad un singolo studente), e si vuole ordinarli rispetto ad una di tali informazioni (ad esempio il cognome, oppure il numero di matricola, ecc.).
Di algoritmi di ordinamento ne esitono diversi e di diversi tipi. In un’ottica di progetto di algoritmi efficienti, illustreremo dapprima algoritmi di ordinamento concettualmente semplici ma non molto efficienti, per renderci conto poi che volendo migliorare l’efficienza sarà necessario mettere in campo idee meno banali.
L’algoritmo insertion sort ( ordinamento per inserzione ) si può intuitivamente assimilare al modo in cui una persona ordina un mazzo di carte. Partendo da un mazzo vuoto inseriamo una carta alla volta, cercando il punto giusto dove inserirla. Alla fine il mazzo di carte risulta ordinato.
Nel caso dell’algoritmo insertion sort gli elementi da ordinare sono contenuti nel vettore quindi non si parte da un insieme vuoto ma si deve trovare la posizione giusta di ogni elemento (è come se si volessero ordinare le carte tenendole in mano, anziché appoggiandole e
prendendole poi una per una). Ciò si effettua “estraendo” l’elemento così da liberare la sua posizione corrente, spostando poi verso destra tutti gli elementi alla sua sinistra (già ordinati) che sono maggiori di esso ed, infine, inserendo l’elemento nella posizione che si è liberata. Alla fine del procedimento il vettore risulterà ordinato.
La formulazione dell’algoritmo è la seguente:
Pseudocodice Costo
Numero di esecuzioni
1 for j = 2 to n do (^) c 1 = Ө(1) n
2 x A[j] c 2 = Ө(1) n - 1
3 i j - 1 c 3 = Ө(1) n - 1
4 while ((i > 0)and(A[i]) > x) c 4 = Ө(1)
5 A[i+1] A[i] c 5 = Ө(1)
6 i i - 1 c 6 = Ө(1)
7 A[i+1] x c 7 = Ө(1) n – 1
Nella terza colonna, tj denota il numero di volte che il test del while viene eseguito per quel valore di j.
Valutiamo la complessità dell’algoritmo insertion sort.
Caso migliore
Esso si verifica quando il numero di spostamenti da effettuare è minimo e cioè quando il vettore è già ordinato. In tal caso la condizione del while non è mai verificata e quindi tj = 1 per tutti i valori di j , quindi la complessità è:
T(n) = c 1 n + c 2 (n – 1) + c 3 (n – 1) + c 4 (n – 1) + c 7 (n – 1) = Ө (n)
Caso peggiore
Esso si verifica quando il numero di spostamenti da effettuare è massimo e cioè quando il vettore in partenza è ordinato all’incontrario. In tal caso tj = j per tutti i valori di j , quindi la complessità è:
T(n) = c 1 n + c 2 (n – 1) + c 3 (n – 1) + c 4 + c 5 + c 6 + c 7 (n – 1) =
La complessità di questo algoritmo non dipende in alcun modo dalla distribuzione iniziale dei dati nel vettore, cioè resta la stessa sia che il vettore sia inizialmente ordinato o no. Essa è:
T(n) = c 1 n + (c 2 + c 6 )(n – 1) + c 3 + (c 4 + c 5 ) = = c 1 n + (c 2 + c 6 )(n – 1) + c 3 (n – 1) + (c 3 + c 4 + c 5 ) = = c 1 n + (c 2 +c 3 + c 6 )(n – 1) + (c 3 + c 4 + c 5 )
Ora, ponendo k = n – i – 1 abbiamo = = = Ө (n^2 ) per cui
T(n) = c 1 n + (c 2 + c 3 + c 6 )(n – 1) + (c 3 + c 4 + c 5 ) Ө (n^2 ) = Ө (n) + Ө (n^2 ) = Ө (n^2 ).
L’algoritmo bubble sort ( ordinamento a bolle ) ha un funzionamento molto semplice: l’algoritmo ispeziona una dopo l’altra ogni coppia di elementi adiacenti e, se l’ordine dei due elementi non è quello giusto, essi vengono scambiati. L’algoritmo prosegue fino a che non vi sono più coppie di elementi adiacenti nell’ordine sbagliato.
La formulazione dell’algoritmo è la seguente:
Pseudocodice Costo
Numero di esecuzioni
1 for i = 1 to n do c 1 = Ө(1) n + 1
2 for j = n downto i + 1 do c 2 = Ө(1)
4 if (A[j] < A[j - 1]) (^) c 3 = Ө(1)
6 Scambia A[j] e A[j - 1] (^) c 4 = O(1)
In questo caso abbiamo:
T(n) = c 1 (n + 1) + c 2 + (c 3 + c 4 ) = = c 1 (n + 1) + c 2 n + (c 2 + c 3 + c 4 ) = = c 1 (n + 1) + c 2 n + (c 2 + c 3 + c 4 ) =
= c 1 (n + 1) + c 2 n + (c 2 + c 3 + c 4 ) = Ө (n) + Ө (n^2 ) = Ө (n^2 ).
Abbiamo visto che i tre algoritmi di ordinamento precedenti hanno tutti una complessità asintotica che cresce come il quadrato del numero di elementi da ordinare.
Una domanda sorge spontanea: si può fare di meglio? E se si, quanto meglio si può fare?
Una risposta alla prima domanda viene data non appena si riesce a progettare un algoritmo di ordinamento che esibisca una complessità asintotica inferiore a quella quadratica, ma rispondere alla seconda domanda è più difficile: chi ci assicura che non si possa fare meglio anche di questo ipotetico nuovo algoritmo?
In altre parole, come si fa a stabilire un limite di complessità al di sotto del quale nessun algoritmo di ordinamento basato su confronti fra coppie di elementi possa andare?
Esiste uno strumento adatto allo scopo, l’ albero di decisione. Esso permette di rappresentare tutte le strade che la computazione di uno specifico algoritmo può intraprendere, sulla base dei possibili esiti dei test previsti dall’algoritmo stesso.
Nel caso degli algoritmi di ordinamento basati su confronti, ogni test effettuato ha due soli possibili esiti (ad es.: minore/uguale oppure no). Quindi l’albero di decisione relativo a un qualunque algoritmo di ordinamento basato su confronti ha queste proprietà:
e quindi:
log n! = Ө (log ) = Ө (log ) = Ө (( )log ) = Ө (n log )
Dunque h ≥ Ө(n log n) , per cui si deduce il seguente
Teorema :
La complessità di qualunque algoritmo di ordinamento basato su confronti è Ω(n log n).
Studieremo nel seguito tre algoritmi di ordinamento molto più efficienti dei precedenti: essi infatti raggiungono il limite inferiore teorico. Per due degli algoritmi (mergesort ed heapsort) ciò avviene anche nel caso peggiore, per il terzo (quicksort) solo nel caso medio.
L’algoritmo mergesort ( ordinamento per fusione ) è un algoritmo ricorsivo che adotta una tecnica algoritmica detta divide et impera. Essa può essere descritta come segue:
Intuitivamente il mergesort funziona in questo modo:
Lo pseudocodice dell’algoritmo mergesort è il seguente:
Funzione Merge_sort (A: vettore; indice_primo, indice_ultimo: intero)
if (indice_primo < indice_ultimo) indice_medio Merge_sort (A, indice_primo, indice_medio) Merge_sort (A, indice_medio + 1, indice_ultimo) Fondi (A, indice_primo, indice_medio, indice_ultimo) return
Vediamone il funzionamento su un problema costituito di 8 elementi, dando per scontato (solo per il momento) il funzionamento della funzione Fondi:
Si osservi che le caselle gialle del disegno corripondono alle operazioni di suddivisione del vettore in sottovettori, cosa che avviene solo tramite le varie chiamate ricorsive. Le caselle azzurre del disegno invece indicano i casi base e quelle verdi corrispondono alle chiamate della funzione Fondi, le quali restituiscono porzioni di vettore ordinate sempre più grandi.
[5, 2, 4, 6 , 1, 3, 6 , 2 ]
[5, 2, 4, 6 ] (^) [1, 3, 6, 2]
[5, 2] (^) [ 4 , 6 ] [ 1 , 3 ] (^) [ 6 , 2 ]
[5] [ 2 ] [ 4 ] [ 6 ] [ 1 ] [ 3 ] [ 6 ] [ 2 ] [5] [ 2 ] [ 4 ] [ 6 ] [ 1 ] [ 3 ] [ 6 ] [ 2 ]
[ 2 , 5 ] [ 4 , 6 ] [ 1 , 3 ] [ 2 , 6 ]
[ 2 , 4 , 5 , 6 ] [1, 2, 3, 6]
[ 1 , 2, 2 , 3 , 4 , 5 , 6 , 6]
20 ricopia B[1..k-1] su A[indice_primo..indice_ultimo]
21 return
Analizziamo la complessità dell’operazione di fusione e poi quella dell’algoritmo mergesort.
Nella fusione si effettuano:
Dunque la complessità della fusione è Ө(n).
L’equazione di ricorrenza del mergesort è di conseguenza:
Essa ricade nel caso 2 del teorema principale, quindi la soluzione è T(n) = Ө(n log n).
Un’ultima osservazione a proposito del mergesort è che l’operazione di fusione non si può fare “in loco”, cioè aggiornando direttamente il vettore A, senza incorrere in un aggravio della complessità. Infatti, in A bisognerebbe fare spazio via via al prossimo minimo, ma questo costringerebbe a spostare di una posizione tutta la sottosequenza rimanente per ogni nuovo minimo, il che costerebbe Ө(n) operazioni elementari per ciascun elemento da inserire facendo lievitare quindi la complessità della fusione da Ө(n) a Ө(n^2 ).
L’algoritmo quicksort ( ordinamento veloce ) è un algoritmo che ha una caratteristica molto interessante: nonostante abbia una complessità che nel caso peggiore è O(n^2 ) , nella pratica si rivela spesso la soluzione migliore per grandi valori di n perché:
Esso quindi riunisce i vantaggi del selection sort (ordinamento in loco) e del merge sort (ridotto tempo di esecuzione). Ha però lo svantaggio della elevata complessità nel caso peggiore.
Anche il quicksort sfrutta la tecnica algoritmica del divide et impera e può convenientemente essere espresso in forma ricorsiva:
Lo pseudocodice dell’algoritmo quicksort è il seguente:
Funzione Quick_sort (A: vettore; indice_primo, indice_ultimo: intero)
if (indice_primo < indice_ultimo) then indice_medio Partiziona (A, indice_primo, indice_ultimo) Quick _sort (A, indice_primo, indice_medio) Quick _sort (A, indice_medio + 1, indice_ultimo) return
strettamente maggiore di tutti gli altri, Partiziona restituirebbe il valore j = n e la ricorsione non terminerebbe.
Un esempio di funzionamento del quicksort su una sequenza di 8 elementi è il seguente.
Nella figura:
5 3 2 6 4 1 3 7
j
5 3 2 6 4 1 3 7
3 3 2 6 4 1 5 7
i
j
i indice_primo indice_ultimo
3 3 2 1 4 6 5 7
Chiamate ricorsive sulle due sottosequenze
3 3 2 6 4 1 5 7
Valore_pivot = 5
3 3 2 1 4 6 5 7
i
j
Analizziamo la complessità del quicksort. Nel partizionamento si effettuano:
3 3 2 1 4 6 j
i
3 3 2 1 4
1 3 2 3 4
i
j
Valore_pivot = 3
1 3 2 3 4
1 2 3 3 4
i
j
1 2 3 3 4
Chiamate ricorsive sulle due sottosequenze
5 7
Valore_pivot = 6
i
j
6 5 7
5 6 7
i
j
Caso base 5 6 7
6 7
i
Valore_pivot = 6
j
6 7
Casi base
Caso migliore
Il caso migliore è quello in cui, ad ogni passo, la dimensione dei due sottoproblemi è identica.
In tale situazione l’equazione di ricorrenza è:
T(n) = 2T( ) + Ө (n)
che, come già visto per il mergesort, ha soluzione T(n) = Ө(n log n).
Caso medio
Valutiamo la complessità del caso medio, nell’ipotesi che il valore del pivot suddivida con uguale probabilità 1/n la sequenza da ordinare in due sottosequenze di dimensioni k ed (n – k) , per tutti i valori di k compresi fra 1 ed (n – 1). La piccola differenza di probabilità fra 1/(n-1) , che sarebbe il valore corretto, ed 1/n , che si adotta per semplicare i calcoli, è assorbita dal termine Ө(n) dell’equazione di ricorrenza e dunque non influenza i risultati.
Sotto tale ipotesi la complessità diviene:
Ora, per ogni valore di i e k = 1, 2, …, n – 1 il termine T(k) compare due volte nella sommatoria, la prima quando k = i e la seconda quando k = n – i.
Valutiamo dunque la complessità di:
Ө
Utilizziamo il metodo della sostituzione, ipotizzando la soluzione:
Sostituiamo la soluzione innanzi tutto nel caso base, ottenendo:
T(1) a 1 log 1 + b = b
che è vera per b opportunamente grande.
Sostituendo, invece, nell'equazione generica, possiamo scrivere:
Ө Ө
Ora, la sommatoria può essere riscritta come:
Osserviamo che:
Dunque possiamo scrivere:
Ritorniamo alla relazione (*) per inserirvi l’espressione trovata:
scegliendo a sufficientemente grande si ha n n e quindi per tale a si avrà:
ossia:
T(n) = O(n log n) nel caso medio.
Si noti che adottando un approccio più “naïf” non riusciremmo a dimostrare la complessità. Riconsiderando la sommatoria , potremmo basarci sul fatto che log q ≤ log n per
derivare quest’altra disuguaglianza, più semplice della precedente:
In questo caso la struttura dati utilizzata si chiama heap (o heap binario ). Esso è un albero binario completo o quasi completo , ossia un albero binario in cui tutti i livelli sono pieni, tranne l’ultimo, i cui nodi sono addensati a sinistra, con la proprietà che la chiave su ogni nodo è maggiore o uguale alla chiave dei suoi figli (proprietà di ordinamento verticale).
Il modo più naturale per memorizzare questa struttura dati è utilizzare un vettore A , con indici che vanno da 1 fino al numero di nodi dell’heap, heap_size , i cui elementi possono essere messi in corrispondenza con i nodi dell’heap:
Con questa implementazione, la proprietà di ordinamento verticale implica che per tutti gli elementi tranne A[1] (poiché esso corrisponde alla radice dell’albero e quindi non ha genitore) vale:
A[i] ≤ A[parent(i)].
Poiché lo heap ha tutti i livelli completamente pieni tranne al più l’ultimo, la sua altezza è Ө(log n) : questa proprietà, come vedremo, è fondamentale ai fini della complessità dell’heapsort.
La figura seguente illustra uno heap, visto sia come albero binario che come vettore:
Si noti che l’elemento massimo risiede nella radice, quindi può essere trovato in tempo O(1).
L’algoritmo heapsort si basa su tre componenti distinte:
Heapify
Questa funzione ha lo scopo di mantenere la proprietà di heap, sotto l’ipotesi che nell’albero su cui viene fatta lavorare sia garantita la proprietà di heap per entrambi i sottoalberi (sinistro e destro) della radice. Di conseguenza l’unico nodo che può violare la proprietà di heap è la radice dell’albero, che può essere minore di uno o di entrambi i figli.
In tal caso, la funzione scambia la radice col maggiore dei suoi due figli. Questo scambio può rendere non più soddisfatta la proprietà di heap nel sottoalbero coinvolto nello scambio (ma non nell’altro), per cui è necessario riapplicare ricorsivamente la funzione a tale sottoalbero. In sostanza, la funzione muove il valore della radice lungo un opportuno cammino verso il basso finché tutti i sottoalberi le cui radici si trovano lungo quel cammino risultano avere la proprietà di heap. Lo pseudocodice di Heapify, che lavora su vettore, è il seguente.
16
14
7 8
10
9 3
2 4 1
16 14 10 7 8 9 3 2 4 1
Inutilizzati
1 2 3 4 5 6 7 8 9 10 11 12 13
heap_size = 10