Sei sulla pagina 1di 27

RICERCA SEQUENZIALE

Ricerca un elemento k in un insieme di n elementi memorizzati in un‟array A.


Si scandisce l‟array confrontandone ogni elemento con quello ricercato.
DA UTILIZZARE QUANDO L‟ARRAY NON E‟ ORDINATO.

precondizione: a è un array di n elementi, numeri interi senza segno.

1. Ricerca Sequenziale (a, k, n):


2. trovato = false;
3. for (i = 0; i < n, i++) {
4. if (a[i] == k) {
5. trovato = true;
6. }
7. }
8. print trovato;

Attenzione! Il programma scandisce tutto l‟array, anche se l‟elemento è stato trovato non termina perché
non gli viene data l‟istruzione.

Modifichiamo il programma in modo che faccia solo i confronti necessari  usiamo un ciclo
while.

1. Ricerca SequenzialeOpt (a, k, n):


2. i =0;
3. trovato = false;
4. while (i < n) && (!trovato) {
5. if (a[i] == k) {
6. trovato = true;
7. }
8. else {
9. i++;
10. }
11. }
12. print trovato;

Quanti confronti fa questa procedura al caso peggiore?

La ricerca sequenziale ha una complessità lineare: al caso pessimo il numero di confronti è n.

SIMULAZIONE

7 3 8 5 3 4 3

0 1 2 3 4 5 6

Supponiamo che k = 3;
Si esegue il ciclo for, vengono stampati gli indici 1, 4, 6.
Quando i = 7  non è più valida la condizione i < n e il ciclo termina  ATTENZIONE! Non vengono
considerate le righe 8-9-10 perché !trovato non vale true.

Chiamate di funzioni, utilizzo di operatori logici.


1. Ricerca(k, a, n):
2. i = 0;
3. while (i < n) {
4. if (a[i] == k) {
5. return true;
6. }
7. else {
8. i++;
9. }
10. }
11. return false;

Problema: dati gli insiemi B di n1 elementi e C di n2 elementi, vogliamo verificare se k B C.

Chiamiamo la funzione Ricerca in un qualsiasi programma.

1. …
2. …
3. …
4. if (Ricerca(k, B, n1)) && (Ricerca(k, C, n2)) {
5. print “k appartiene a B C”;
6. }
7. else {
8. print “k non appartiene a B C”;
9. }
10. …
11. …
12. …

Riga 4  Se entrambe le guardie valgono true, k è stato trovato in entrambi gli insiemi.

Lavoro da compiere al caso pessimo: O(n).

Qual è il minimo lavoro che posso fare per determinare se k appartiene all’insieme?

(Procediamo come per il problema delle 12 monete).

Quante possibilità ci sono? n + 1  n + la possibilità che k non appartenga all‟insieme.

Assumiamo che l‟array contenga elementi tutti distinti tra loro. Confrontiamo gli elementi a coppie.

x:y

z:w z:w

Dopo 1 confronto posso discriminare tra 2 soluzioni (=possibilità). Dopo 2 confronti posso discriminare
tra 4 (22) soluzioni.. etc. In questo caso 2i ≥ n +1  dopo i confronti, discrimino tra 2i situazioni.

Il numero minimo di confronti per trovare k in un‟array di n elementi è log 2(n + 1).

Al caso ottimo O(log2n).


RICERCA BINARIA

Ricerca un elemento k in un insieme di n elementi memorizzati in un‟array A.


Con una serie di confronti, si riduce sempre più il campo di ricerca.

precondizione: a è un’array ordinato crescente

9. Ricerca Binaria (a, k):


10. sinistra = 0;
11. destra = n – 1;
12. while (sinistra <= destra) {
13. centro = (sinistra + destra)/2;
14. if (a[centro] == k) {
15. return true;
16. }
17. else if (a[centro] > k) {
18. destra = centro – 1;
19. }
20. else {
21. sinistra = centro +1;
22. }
23. }
24. return false;

N.B. riga 5: la divisione è intera.

SIMULAZIONE

4 5 6 7 10 11 15 21 31 33 44

0 1 2 3 4 5 6 7 8 9 10

Supponiamo che k = 21;


Calcoliamo l‟elemento centrale: (0 + 10)/2 = 5  a[5] = 11.
Confrontiamo k con l‟elemento centrale: non sono uguali  riga 12, sinistra prende il valore di centro
+ 1  sinistra = 6, dal momento che l‟elemento sicuramente non si trova nella metà a sinistra del
centro precedente.
si ricomincia il ciclo e si ricalcola il centro: (6 + 10)/2 = 8  a[8] = 31.
Riga 6: a[centro] ≠ k  31 > 21  riga 9.
destra prende il valore del centro diminuito di uno  destra = 7;
si ricomincia il ciclo: centro = (6 + 7)/2 = 6;
a[centro] = k? 15 ≠ 21 & 15 < 21, riga 13.
sinistra = 6 + 1 = 7;
si ricomincia il ciclo (ma attenzione: sinistra = destra, alla prossima iterazione non sarà più valida la
condizione). Ricalcoliamo centro = (7+7)/2 = 7.
a[centro] = k? SI‟  return true.

Quanti confronti fa questa procedura al caso peggiore?

(Verifichiamo: all‟inizio abbiamo un‟array di n elementi; con un solo confronto la dimensione dei dati da
esaminare si dimezza a n/2  poi n/4, n/8 etc.

Supponiamo che n = 2i (una potenza di 2). Avremo:


O meglio:

N.B. Dopo i + 1 volte si ottiene 1 (n = 2i  n/2i = 1).

Nel caso pessimo, i passi da fare sono log2n

Per n elementi, i confronti necessari al caso pessimo sono:

n 2 10 100 1000 10000 1000000


log2n 1 ~4 ~6 ~9 ~13 ~24

Ovvero, con circa 24 confronti si può cercare un elemento tra 1.000.000 di dati!

Formula  con n elementi, servono i confronti  confronti = log2 elementi

Attenzione! Ricerca Binaria si può utilizzare solo se la sequenza di elementi è ordinata. In caso contrario,
si usa Ricerca Sequenziale.

ESERCIZI - Modificare l‟algoritmo affinché restituisca l‟indice della posizione dell‟elemento trovato.

Ricerca Binaria(a, k):


sinistra = 0;
destra = n - 1;
while (sinistra <= destra) {
centro = (sinistra + destra)/2;
if (a[centro] == k) {
return centro;
}
else if (a[centro] > k) {
destra = centro - 1;
}
else {
sinistra = centro + 1;
}
}
return -1;
RICORSIONE

Un algoritmo è ricorsivo quando è definito in termini di se stesso.


Perché è utile la ricorsione?
o Permette di scrivere programmi compatti.
o Può migliorare la velocità di esecuzione.

Esempio  il fattoriale si calcola in maniera ricorsiva:

FATTORIALE

25. Fattoriale (N):


26. k = 1;
27. for (i = 1; i <= N; i++) {
28. k = k * i; // si cambia l‟ordine delle moltiplicazioni
29. }
30. return k;

Abbiamo scritto un algoritmo iterativo; come visto, il fattoriale si calcola ricorsivamente, quindi scriviamo
un algoritmo ricorsivo.

1. RFatt (N):
2. if (N ==1) {
3. return 1; //condizione di terminazione
4. }
5. else {
6. return N* (RFatt(N -1)); //si chiama ricorsivamente RFatt e il risultato si moltiplica per N.
7. }
Simulazione: dal generale si va al particolare e dal particolare si risale al generale (scatole cinesi).

RFatt(5)
RFatt(4)
RFatt(3)
RFatt(2)
RFatt(1)

if (N ==1) return 1  questo viene restituito alla procedura


chiamante.

Questa procedura si chiude.


La procedura chiamata RFatt(1) restituisce 1, che viene moltiplicato per
N = 2.
RFatt(2) restituisce 2 alla procedura chiamante e si chiude.
La procedura chiamata RFatt(2) restituisce 2, che viene moltiplicato per N = 3.

RFatt(3) restituisce 2*3 = 6 alla procedura chiamante e si chiude.

La procedura chiamata RFatt(3) restituisce 6, che viene moltiplicato per N = 4.

RFatt(4) restituisce 6*4 = 24 alla procedura chiamante e si chiude.

La procedura chiamata RFatt(4) restituisce 24, che viene moltiplicato per N = 5.

RFatt(5) restituisce 24*5 = 120.


Cosa fa il calcolatore di fronte ad una procedura ricorsiva?

Il computer comincia l‟esecuzione  in presenza di una chiamata ricorsiva la procedura chiamante:

1. congela i valori delle variabili locali e dei parametri e memorizza il punto di interruzione;
2. l‟esecuzione abbandona la procedura chiamante e parte con la procedura chiamata, iniziando dalla
prima istruzione;
3. quando una procedura termina si ripristina l‟esecuzione della procedura che l‟aveva chiamata 
riprendiamo la computazione dal punto in cui si era interrotta ripristinando i valori delle variabili e dei
parametri.

Supponiamo di avere una procedura P.

P(n) con istruzioni: 1, 2, 3, …, i, i + 1, …, n

Supponiamo che l‟i-esima istruzione sia una chiamata ricorsiva; a questa istruzione segue i + 1.

Terminata la chiamata ricorsiva dell‟istruzione i, si rientra nell‟ambiente della procedura chiamante P e si


ricomincia dall‟istruzione successiva a quella della chiamata ricorsiva; in questo caso si ricomincia da i +
1 e si ripristinano i valori dei parametri e delle variabili congelati in precedenza.

Anche Ricerca Binaria può essere scritto come algoritmo ricorsivo  viene ripetuto lo stesso
procedimento considerando valori nuovi di sinistra e destra.

RICERCA BINARIA RICORSIVO

Ricerca un elemento k in un insieme di n elementi memorizzati in un‟array A.


Con una serie di confronti, si riduce sempre più il campo di ricerca.

precondizione: a è un array ordinato crescente

31. RRICBIN (a, k, sx, dx):


32. if (sx > dx) { //condizione di terminazione  vuol dire che siamo arrivati ad un insieme vuoto
33. return false;
34. }
35. else {
36. cx = (sx + dx)/2;
37. }
38. if (k == a[cx]) {
39. return true;
40. }
41. else if (k < a[cx]) {
42. return RRICBIN(a, k, sx, cx – 1);
43. }
44. else { //cioè se k > a[cx]
45. return RRICBIN(a, k, cx + 1, dx);
46. }

N.B.: nelle righe 18 e 21 viene chiamata ricorsivamente RRICBIN con parametri diversi a seconda del
valore di k.
Qual è l’algoritmo migliore tra Ricerca Binaria e RRICBIN?

Da un lato Ricerca Binaria è migliore perché le operazioni ricorsive hanno un costo maggiore (serve più
memoria, i parametri e le variabili devono essere memorizzati).

Dall‟altro, l’algoritmo ricorsivo in genere è migliore quando la natura stessa del problema è
ricorsiva.

In questo caso specifico, il numero di confronti da effettuare è identico.

Complessità di RRICBIN ricavata con le equazioni di ricorrenza

Poniamo n = 2i
In questo caso:

Nella seconda equazione C(n/2) si spiega ricordando che RRICBIN viene chiamata su uno solo dei due
insiemi. La costante 1 rappresenta il confronto con l’elemento centrale.
Possiamo riscrivere di volta in volta C(n/2x); esempi:

N.B. tanti 1 quanto l’esponente di 2 (tre 1, 23…)


Finiremo con n/2i = 1.
Avremo alla fine allora:

Dove la somma degli 1 è uguale a i. Ma:

Quindi avremo:

Diventa:

i + 1 è il numero di confronti fatto da RRICBIN

Rimane da trovare i:
n = 2i  log2n = log22i  i = log2n

Complessità di RRICBIN:
FIBONACCI

Per il problema della ricerca binaria, il numero di confronti da effettuare rimaneva identico, sia utilizzando
la ricorsione, sia no. In altri casi, l’algoritmo ricorsivo è peggiore a causa della complessità. Un
esempio è l‟algoritmo che ricava i numeri di Fibonacci.

1. RFIB(n):
2. if (n == 0) {
3. return 0;
4. }
5. if (n == 1) {
6. return 1;
7. }
8. else {
9. return (RFIB(n - 1) + RFIB(n - 2));
10. }

Questo è un uso disastroso della ricorsività  si calcola sempre la stessa cosa!

Il tempo di esecuzione è dato da queste equazioni:

Se si risolve la seconda equazione, si ottiene che

Quando la dimensione del problema (n) è esponente del tempo di computazione, la crescita del problema
è esponenziale  anche se la base è molto piccola!

Supponiamo di dover fare 2n operazioni e di svolgere una operazione al secondo.


n 5 10 15 20 25 35 40
tempo 32 secondi 17 minuti 9 ore 12 giorni 1 anno 1089 anni 34865
anni

Per migliorare quest‟algoritmo possiamo operare in due modi:


memorizzare i numeri di Fibonacci in un‟array;
memorizzare come variabili solo gli ultimi due elementi calcolati.

1. Fib(n):
2. if (n == 0) print 0;
3. if (n == 1) print 1;
4. else {
5. a = 0;
6. b = 1;
7. for (i = 2; i < n; i++) { //inizio da 2 perché 0 e 1 sono già calcolati
8. c = a + b;
9. a = b;
10. b = c;
11. }
12. print c;
13. }

Quante operazioni compie questa procedura?


O(n)  il ciclo si compie n -1 volte, le operazioni da svolgere sono 3 quindi circa 3n  siamo passati da
2n ad un numero proporzionale ad n, l‟algoritmo è molto migliore.
Ancora sulla RICORSIONE
Abbiamo visto che nell‟algoritmo di Fibonacci le operazioni da svolgere crescono in modo
esponenziale:

La serie si può risolvere usando due variabili, che contengono gli ultimi due valori calcolati.
Con questo accorgimento, la crescita dell‟algoritmo è lineare.

Algoritmo usato Crescita


Ricorsivo O(2i)
Iterativo O(i)

In questo specifico caso, era possibile sfruttare una soluzione diversa dalla ricorsione.
Altri problemi si risolvono necessariamente con una crescita esponenziale:

TORRI DI HANOI

Supponiamo di avere 64 dischi d‟oro e tre pali.


Si devono spostare tutti i dischi dal piolo n°1 al
piolo n°3, con delle regole:
1. un disco si può spostare solo se è in cima
al piolo;
2. si può spostare un disco più piccolo su uno
più grande, ma non viceversa.

Il piolo n°2 va usato come appoggio.

(Il senso della ricorsione è questo: se so spostare fino a 6 dischi, se devo spostarne 7 con la ricorsione
sposto 6 dischi dal 2° al 3° piolo, dove si trova il settimo disco).

Per risolvere Torri di Hanoi con n dischi  TH(n):


1. spostare n – 1 dischi dal primo piolo al secondo piolo ricorsivamente;
2. spostare disco n al terzo piolo;
3. spostare n - 1 dischi dal secondo piolo al terzo piolo ricorsivamente.

8. TdH(n, primo, secondo, terzo): //rappresentano sempre nell‟ordine origine-appoggio-destinazione


9. if (n ==1) {
10. print “primo”  “terzo”; //  = “va in”
11. }
12. else {
13. TdH(n – 1, primo, terzo, secondo); //il 3° è appoggio, il 2° è destinazione
14. print “primo”  “terzo”;
15. TdH(n -1, secondo, primo, terzo); // il secondo è origine, il primo è appoggio.
16. }

Questo è un problema per il quale l‟unica soluzione conosciuta sfrutta la ricorsione.


Anche supponendo di compiere una mossa al secondo, la crescita esponenziale del problema è
insostenibile:
n 5 10 15 20 25 35 40
tempo 31 17 9 12 1 1089 1.115.689 anni
secondi minuti ore giorni anno anni
Simulazione con 4 dischi

TdH(4, 1, 2, 3);

TdH(3, 1, 3, 2);

TdH(2, 1, 2, 3);

TdH(1, 1, 3, 2) PRINT 1  2

PRINT 1  3

TdH(1, 2, 1, 3) PRINT 2  3
1. TdH(n, primo, secondo, terzo):
2. if (n ==1) {
3. print “primo”  “terzo”;
4. }
PRINT 1  2 5. else {
6. TdH(n – 1, primo, terzo, secondo);
7. print “primo”  “terzo”;
TdH(2, 3, 1, 2) 8. TdH(n -1, secondo, primo, terzo);
9. }
TdH(1, 3, 2, 1) PRINT 3  1

PRINT 3  2

TdH(1, 1, 3, 2) PRINT 1  2

Per n dischi, le mosse minime per


PRINT 1  3 risolvere il problema sono 2n – 1

TdH(3, 2, 1, 3) ↓

O(2n)
TdH(2, 2, 3, 1)

TdH(1, 2, 1, 3) PRINT 2  3 Esempio: 4 dischi

Mosse necessarie: 24 – 1 = 15
PRINT 2  1

TdH(1, 3, 2, 1) PRINT 3  1

PRINT 2  3

TdH(2, 1, 2, 3)

TdH(1, 1, 3, 2) PRINT 1  2

PRINT 1  3
TdH(1, 2, 1, 3) PRINT 2  3
Come visto, Ricerca Binaria [O(logn)] è molto più efficiente di Ricerca Sequenziale [O(n)]. Se l‟array è
ordinato infatti, esso viene suddiviso confrontando k con l‟elemento centrale.

Questa è una tecnica usata molto spesso, chiamata divide et impera:

DIVIDE ET IMPERA – tecnica in 3 passi


1. Dividi il problema da risolvere in sottoproblemi;
2. Risolvi i sottoproblemi:
o ricorsivamente;
o direttamente se sono piccoli (caso base  condizione di terminazione che fa sì che
l‟algoritmo abbia un numero di operazioni finito);
3. Trova la soluzione generale dalla combinazione delle soluzioni dei sottoproblemi.
[Con Ricerca Binaria sono sufficienti i primi due passi]
Problemi correlati: ricerca del massimo in un insieme.

MASSIMO – Ricerca del massimo in un insieme non ordinato


Algoritmo banale
Si scandisce l‟array confrontandone ogni elemento con il primo; se questo risulta maggiore,
diventa il massimo.
Confronti necessari: n – 1 per dimensione dell‟input: n.

1. Massimo(a, n):
2. Max = a[0];
3. for (i = 1; i < n; i++) {
4. if (a[i] > Max) {
5. Max = a[i];
6. }
7. }
8. return Max.

Algoritmo divide et impera


Si divide l‟array in due sottoarray di n/2 elementi ciascuno.
Si trova il massimo M1 del primo sottoarray ricorsivamente (o direttamente se n = 1) e si trova il
massimo M2 del secondo sottoarray.
Si combinano le soluzioni, trovando il massimo tra M1 e M2. Confronti necessari: n – 1.

47. RMassimo (a, sx, dx):


48. if (sx == dx) { //condizione di terminazione
49. Max = a[sx];
50. }
51. cx = (sx + dx)/2;
52. M1 = RMassimo(a, sx, dx);
53. M2 = RMassimo(a, cx + 1, dx);
54. if (M1 < M2) {
55. Max = M2;
56. }
57. else {
58. Max = M1;
59. }
60. return Max;

N.B.: nella prima riga potremmo scrivere RMassimo(a, 0, n -1) ma questa istruzione sarebbe valida solo
all‟inizio; utilizziamo sx e dx perché è necessario che siano variabili.

La procedura continua a dividere l‟array finché arriva ad ottenere sottoinsiemi di singoli elementi.
Se abbiamo l‟array

2 8 3 10 6 5 7 9
0 1 2 3 4 5 6 7

La soluzione di un problema di 8 elementi si rimanda alla soluzione di due sottoproblemi di 4 elementi.

La procedura continua a dividere l‟array finchè non ottiene elementi singoli di cui può restituire il
risultato.

Nella fase TOP-DOWN si divide il problema in sottoproblemi più piccoli.

Nella fase BOTTOM-UP si fanno i confronti.

2 8 3 10 | 6 5 7 9

2 8 | 3 10 6 5 | 7 9

2 | 8 3 | 10 6 | 5 7 | 9
8

2 8 3 10 6 5 7 9

8 10 6 9

10 9

10

N.B. Avevamo 8 elementi, abbiamo fatto 7 (8 - 1) confronti.

Supponiamo di voler ottenere, senza simulazione, il numero di confronti. Un metodo è risolvere


l‟equazione di ricorrenza (scritta in termini di se stessa).

C(n) = numero di confronti fatti da RMassimo su n elementi.

N.B. si aggiunge 1  rappresenta il confronto finale.

Nell‟equazione per n > 1 si ha 2*C(n/2) perché RMassimo è chiamato ricorsivamente su n/2 due volte.

Simulazione per n = 2i

Utilizziamo la seconda equazione:

Possiamo riscrivere C(n/2) come:


E quindi

Ma possiamo scrivere anche C(n/4) come:

E ottenere così:

Continuiamo a dividere per 2, fino ad arrivare ad insiemi di un solo elemento (1 = n/2 i, dove n = 2i):

Dobbiamo svolgere le operazioni:

C(n) = 2i C(1) + 2i – 1 + 2i -2 + … + 20.

20 + 21 + 22 + … + 2i – 1= 2i – 1

Sappiamo che n = 2i
Quindi possiamo scrivere:

20 + 21 + 22 + … + 2i – 1= n – 1

Questo metodo consente di determinare la complessità di un algoritmo.

CONCLUSIONI - Abbiamo visto che sia Massimo che RMassimo necessitano di n – 1 confronti:

n – 1 rappresenta il limite inferiore


SELECTION SORT

algoritmo che esegue l‟ordinamento di un array di n elementi;


si ottiene un ordinamento decrescente  si dice così nell‟incertezza che vi possano essere
elementi ripetuti.
meccanismo: si inizia determinando il minimo degli elementi non ordinati.

precondizione: a è un’array di lunghezza n

61. SelectionSort(a):
62. for (i = 0; i < n; i = i + 1) {
63. minimo = a[i];
64. indiceminimo = i;
65. for (j = i + 1; j < n; j = j + 1) {
66. if (a[j] < minimo) {
67. minimo = a[j];
68. indice minimo = j;}
69. }
70. }
71. a[indiceminimo] = a[i];
72. a[i] = minimo;
73. }

N.B. Abbiamo utilizzato due variabili perché vogliamo ricordare sia il valore (minimo) sia il suo indice
(indice minimo).

SIMULAZIONE

8 3 7 2 5 15 9 4

Ad a[i] viene assegnato il valore del minimo; i parte da 0, il minimo è 8;


si calcola a[j] = 3; a[j] < minimo? SI‟  minimo = a[j]
minimo a[j] = 3; indiceminimo = 1;
j  2; a[j] = 7; a[j] < minimo? NO  j++;
j  3; a[j] = 2; a[j] < minimo? SI‟  minimo = a[j] = 2;
j  4; a[j] = 5; a[j] < minimo ? NO  j++;
j  5; a[j] = 15; a[j] < minimo? NO  j++;
j  6; a[j] = 9; a[j] < minimo? NO  j++;
j  7; a[j] = 4; a[j] < minimo? NO  j++ - j > n  termina la procedura;
ho finito di eseguire il for più interno  e ho trovato che il minimo di questo sottoarray è 2;
esco dal for, sono alla riga 11 del codice  a[indice minimo] = a[i]  devo scambiare gli elementi,
utilizzo una variabile di appoggio;

Scambiati gli elementi, il nuovo array è:

2 3 7 8 5 15 9 4

ricomincia la procedura: adesso i = 1; minimo = 3;


si confronta il minimo corrente con la sottoarray di elementi da ordinare (da indice 2 a indice 7);
Quanti confronti fa questa procedura? (n -1) + (n - 2) + (n - 3) + …. + 1

E‟ il numero di confronti necessario all‟ordinare l‟array, considerando che l‟array si riduce ogni volta di un
elemento. Ma quest‟espressione è uguale anche a:

Al crescere di n, il termine quadratico diventa molto più preponderante  diciamo quindi che questa
procedura è:

O(n2) = numero di confronti da fare

N.B. questo numero rimane invariato, sia al caso pessimo che al caso ottimo si devono fare O(n2)
confronti!
QUICK SORT

usa il Divide et impera;


algoritmo migliore per l‟ordinamento;
usa un passo casuale
meccanismo  si prende l‟ultimo elemento del‟array (posizione fissa ma valore casuale); si
confrontano tutti gli elementi dell‟array con questo elemento; quelli minori si metteranno a
sinistra, quelli maggiori a destra. Si posiziona poi il perno (l‟ultimo elemento dell‟array) nella
giusta posizione.
Quindi, si suddivide l‟array in 2 parti: minori del perno e maggiori del perno. N.B. fatto ciò, gli
elementi non sono ancora ordinati! Si usa ricorsivamente la stessa procedura:
1. dividi secondo il valore del perno;
2. risolvi direttamente o ricorsivamente;

La suddivisione dell‟array in due parti non è perfetta  i sottoarray possono avere dimensioni
diverse, e l‟algoritmo funziona meglio tanto più le partizioni sono bilanciate.

precondizione: per semplicità si suppone che l‟array abbia elementi tutti distinti tra loro

74. QuickSort(a, sx, dx):


75. if (sx < dx) {
76. // scegli come perno l‟elemento a[dx]
77. perno = Distribuzione(a, sx, dx);
78. QuickSort(a, sx, perno -1);
79. QuickSort(a, perno+1, dx);
80. }

Note:

riga 1  all‟inizio l‟algoritmo lavorerà su (a, 0, n - 1);

riga 2  sx == dx  condizione di terminazione.

riga 4  sceglie l‟ultimo elemento dell‟array; ci permette di sapere in che posizione è il perno rispetto agli
altri elementi, dopo aver spostato i minori del perno a sinistra e i maggiori del perno a destra.

righe 5-6  per effettuare le chiamate ricorsive dobbiamo prima separare gli elementi < a[dx] dagli
elementi > a[dx].

SIMULAZIONE

5 30 21 11 9 39 29 14 32 4 35 6 28 24

Si sceglie come perno a[dx] = 24;


Si utilizzano due variabili: i e j;
Lavoriamo con i: finché gli elementi sono < 24 non vanno spostati, gli altri sì.
Iniziamo da i = 0  a[0] = 5  5 < 24? SI‟, non viene spostato.
i = 1  a[1] = 30  30 < 24 ? NO, smetto di lavorare con i e continuo con j.
j lavora in modo opposto, sposta gli elementi minori e lascia stare gli elementi maggiori.
Iniziamo dall‟ultimo elemento prima del perno: 28 > 24? SI‟  non viene spostato.
Continuiamo con 6 > 24? NO  scambiamo il 6 con il 30.

5 6 21 11 9 39 29 14 32 4 35 30 28 24

Lavoriamo con i: 21, 11, 9 < 24? SI‟  non vengono spostati
39 < 24? NO  mi fermo con i, ricomincio con j.
30, 35 > 24? SI‟, li lascio lì.
4 > 24? NO  scambiamo il 4 con il 39.

5 6 21 11 9 4 29 14 32 39 35 30 28 24

Scambiamo 29 con 14;


L‟ultimo scambio sarà tra 24 e 29  adesso l‟array è diviso in 2 sottoarray, ma non è ancora
ordinato.

5 6 21 11 9 4 14 24 32 39 35 30 28 29

L‟algoritmo funziona bene perché le partizioni sono ben bilanciate.


Adesso si applica ricorsivamente il QuickSort sui due sottoarray.

DISTRIBUZIONE

1. Distribuzione(a, sx, dx):


2. i = sx;
3. j = dx – 1;
4. while (i <= j) {
5. while ((i <= j) && (a[i] <= a[dx]))
6. i = i + 1;
7. while ((i <= j) && (a[j] >= a[dx]))
8. j = j - 1;
9. if (i < j)
10. scambia(i, j);
11. }
12. if (i != dx)
13. scambia (i, dx);
14. return i;

Procedura  Si procede a fare confronti finché i due elementi non si incrociano; si fa scorrere l‟indice i da
sx verso dx finché non trova elementi maggiori del perno.

righe 9-13  prima di scambiare, si verifica che i e j non si siano scambiati.

SCAMBIA

1. scambia(i, j):
2. temp = a[j]; a[j] = a[i]; a[i] = temp;

Dopo la prima chiamata di Distribuzione, si applica ricorsivamente il QuickSort nel sottoarray degli
elementi minori del perno.

5 6 21 11 9 4 14 24 32 39 35 30 28 29

Il perno è l‟elemento 14.


5,6 < 14? SI‟, vanno lasciati lì.
21 < 14? NO  ci fermiamo con i, iniziamo con j.
4 > 14? NO  ci fermiamo anche con j  si scambiano 4 e 21;
Continuiamo con i  9 < 14? SI‟, lasciamo stare;
21 < 14? NO, ci fermiamo con i e proseguiamo con j.
i e j si sono incrociati e a[j] > a[dx] (21 > 14);
non scambiamo 9 e 21 ma 14 con 21.
Il nuovo array non è buono  partizioni sbilanciate:

5 6 4 11 9 14 21 24 32 39 35 30 28 29

si prosegue su questo sottoarray:

5 6 4 11 9 14 21 24 32 39 35 30 28 29

Quanti confronti in totale verranno fatti dall’indice i e dall’indice j?

Il costo di distribuzione è O(n), costo simile al numero di confronti necessari  gli indici proseguono
senza mai tornare indietro.

Se le partizioni sono bilanciate, QuickSort funziona bene  al caso pessimo preso il perno potremmo
avere un sottoarray degenere e un sottoarray con tutti gli altri elementi.  caso in cui l‟array era già
stato ordinato.

Se n  0 confronti;

Se n > 1  T(n) = O(n) + T(d) + T(n – d -1). [gli ultimi due rappresentano il tempo sull‟insieme di dx e
quello sull‟insieme di sx, il primo il costo di distribuzione].

Al caso pessimo T(n) = O(n) + T(n -1) (infatti avremo un insieme vuoto e uno di n – 1 elementi 
manca d che rappresenta una partizione degli elementi!)

Quanto vale?

T(n) = O(n) + T(n -1)

T(n -1) = (n – 1) + T(n – 2)

T(n) = n + (n – 1) + T(n -2)

T(n -2) = (n – 2) + T(n -3)

T(n) = n + (n – 1) + (n – 2) + T(n – 3)

…potrei continuare fino a trovare T(1).

T(n) = n + (n – 1) + (n – 2) + …..+ T(1).

Per la legge di Gauss:

e allora ha un costo stimabile in O(n2).


ANALISI DI DISTRIBUZIONE

Distribuzione si realizza in un tempo proporzionale al numero di elementi. L’equazione di ricorrenza di


questa procedura è:

equazione molto difficile da risolvere perché q è variabile

Al caso pessimo l’equazione di ricorrenza è:

Esempio:
3 4 8 11 20

Alla destra del perno (20) non riusciamo a mettere nulla  caso pessimo.

CASO OTTIMO DEL QUICKSORT

Al caso ottimo l’insieme viene diviso in due sottoinsiemi più o meno della stessa dimensione (partizioni
bilanciate), e il perno si trova circa a metà.

L’equazione di ricorrenza sarà:

T(n/2) è il tempo di ogni chiamata ricorsiva.

Calcoliamone il valore per n = 2i:

Riscriviamo T(n/2) come:

E ancora:

Avremo quindi:

O meglio:
In genere bastano tre passi per capire che equazione si sviluppa.

All’ultimo passo avremo:

Ma :

Si può allora scrivere:

Semplificando:

Ricordando che è una costante, possiamo ulteriormente semplificare scrivendo:

(è una costante che abbiamo tralasciato finora, ma sempre presente)

Ricordando che n = 2i  log2n = log22i  i = log2n

Avremo infine:

Conclusioni:

QuickSort al caso pessimo  O(n2)

QuickSort al caso ottimo  O(nlogn)

Il caso ottimo solitamente non interessa  perché un algoritmo potrebbe funzionare benissimo in un solo
caso, ma malissimo in tutti gli altri.

Nello specifico caso del QuickSort, anche al caso medio il tempo medio di
computazione per ordinare un insieme di n elementi è O(nlogn).

Si può risolvere il problema dell’ordinamento in O(x) < O(nlogn)?

No: il problema dell’ordinamento richiede almeno nlogn confronti. Questa quantità rappresenta il limite
inferiore o lower bound.
Limiti inferiori

Supponiamo di avere un array di tre elementi e di volerlo ordinare in ordine a b c


crescente. Quanti confronti sono necessari?

Iniziamo confrontando a e b.

a:b
a<b a>b

b:c b >c
b:c b <c
b <c b > c

a< b< c a:c c< b< a c:a


a < c a >c c < a c > a

a< c< b c< a< b b< c< a b< a< c

Dati tre elementi da ordinare, quali sono le possibili soluzioni?

3! = 6 (3x2x1)

Il fattoriale di n rappresenta tutte le permutazioni di n elementi, cioè tutte le possibilità di scrivere tre
elementi.

Con T confronti si discrimina tra 2T soluzioni.

2T ≥ n!

23 ≥ 3! = 6

Si dimostra che T ≥ O(nlogn)

ovvero, il numero minimo di confronti ≥ a ~nlogn  dall’albero siamo arrivati alla formula generale
ORDINAMENTO LINEARE

Consideriamo un caso di ordinamento particolare: sappiamo che gli elementi da ordinare hanno valori
compresi nell‟intervallo [1, n].

Se gli elementi si ripetono (non sono quindi tutti distinti tra loro) e se appartengono ad un intervallo [1,
n]  possiamo sfruttare questa “proprietà”.

Esempio: sia x l‟array:

1 2 1 1 8 3 3 5
0 1 2 3 4 5 6 7
Utilizziamo un array di appoggio y:

0 0 0 0 0 0 0 0
0 1 2 3 4 5 6 7
Tutte le posizioni di y all‟inizio valgono 0 perché le occorrenze di ogni elemento sono ancora 0.

Iniziato l‟ordinamento, ogni volta che in x si trova un‟occorrenza di un elemento, una casella
corrispondente in y incrementa il suo valore.

x[0] = 1  y[0]++  y[0] = 1


in x[2] trovo ancora 1  y[0]++  y[0] = 2
in x[4] = 8  y[7]++.

Ovvero, in y[i] si aggiornano le occorrenze di i + 1.

Alla fine l‟array y avrà questo aspetto:

3 1 2 0 1 0 0 1
0 1 2 3 4 5 6 7

dove in y[0] troviamo le occorrenze di 1, in y[1] quelle di 2 etc.


A questo punto, si può ordinare x contando le occorrenze di ciascun elemento.

precondizione: gli elementi di x appartengono a [1, n]

81. OrdinaLineare (x, n):


82. “Definisci y di n elementi”
83. for (i = 0; i < n; i++) {
84. y[i] = 0;
85. }
86. for (i = 0; i < n; i++) {
87. y[x[i] – 1]++;
88. }
89. j = 0;
90. for (i = 0; i < n; i++) {
91. while (y[i] > 0) {
92. x[j] = i + 1;
93. j++;
94. y[i]--;
95. }
96. }

N.B. righe 3-4 si azzera y


SIMULAZIONE su x e y

1 6 2 8 3 3 5 6

0 1 2 3 4 5 6 7

x[i] [1, 8]  ogni elemento di x appartiene all’intervallo [1, 8]

0 0 0 0 0 0 0 0
0 1 2 3 4 5 6 7

Il primo for assegna valore 0 a tutte le posizioni di y.


Successivamente, la riga 7 del codice fa sì che ogni volta che in x[i] compare un‟occorrenza di un
numero  y[i – 1], y[i -1] incrementa il proprio valore.
Esempio: per i = 0 y[x[0] – 1] = y[1 -1] = y[0]  y[0]++  si incrementano le occorrenze
dell‟elemento i trovato in x, che sono in indice i – 1 su y.
Il secondo ciclo for provvede ad aggiornare le occorrenze di x su y.
Costruita la matrice y, notiamo che essa è solo un altro modo per descrivere la matrice x.

1 1 3 0 0 2 0 1
0 1 2 3 4 5 6 7

Regola generale: y[i] = k  ci sono k occorrenze dell‟elemento i + 1 in x.

Adesso ricostruiamo x guardando solo y  terzo ciclo for:

i=0
1 6 2 8 3 3 5 6
o y[i] > 0?  y[0] = 1 > 0:
0 1 2 3 4 5 6 7
 x[0] = i + 1 = 1;
 j++  j = 1; i=0
 y[i]--  y[0] = 0  si esce dal while. 0 1 3 0 0 2 0 1
0 1 2 3 4 5 6 7

i=1
o y[i] > 0?  y[1] = 1 > 0:
1 2 2 8 3 3 5 6
 x[1] = i + 1 = 2;
0 1 2 3 4 5 6 7
 j++  j = 2;
 y[i]--  y[1] = 0  si esce dal while. i=1
0 0 3 0 0 2 0 1
i=2 0 1 2 3 4 5 6 7

o y[i] > 0?  y[2] = 3 > 0:


 x[2] = i + 1 = 3;
1 2 3 3 3 3 5 6
 j++  j = 2;
0 1 2 3 4 5 6 7
 y[i]--  y[2] = 2.
o y[i] > 0?  y[2] = 2 > 0: i=2
 x[2] = i + 1 = 3; 0 0 0 0 0 2 0 1
 j++  j = 3; 0 1 2 3 4 5 6 7

 y[i]--  y[2] = 1.
o y[i] > 0?  y[2] = 1 > 0:
 x[2] = i + 1 = 3;
 j++  j = 4;
 y[i]--  y[2] = 0.  si esce dal while.

i=3 1 2 3 3 3 3 5 6
o y[i] > 0?  y[3] = 0  0 > 0? No. 0 1 2 3 4 5 6 7

i = 3, i = 4  non succede nulla


i=4 0 0 0 0 0 2 0 1
o y[i] > 0?  y[4] = 0  0 > 0? No.
0 1 2 3 4 5 6 7
i=5
1 2 3 3 3 6 6 6
o y[i] > 0?  y[5] = 2 > 0:
0 1 2 3 4 5 6 7
 x[5] = i + 1 = 6;
 j++  j = 5; i=5
 y[i]--  y[5] = 1. 0 0 0 0 0 0 0 1
o y[i] > 0?  y[5] = 1 > 0: 0 1 2 3 4 5 6 7

 x[5] = i + 1 = 6;
 j++  j = 6;
 y[i]--  y[5] = 0  si esce dal while.
1 2 3 3 3 6 6 6
i=6 0 1 2 3 4 5 6 7

o y[i] > 0?  y[6] = 0  0 > 0? No. i=6


0 0 0 0 0 0 0 1
i=7 0 1 2 3 4 5 6 7
o y[i] > 0?  y[7] = 1 > 0:
 x[7] = i + 1 = 8;
 j++  j = 7; 1 2 3 3 3 6 6 8

 y[i]--  y[7] = 0  si esce dal while. 0 1 2 3 4 5 6 7

i=7
i < n? No  si esce dal for. La procedura termina. 0 0 0 0 0 0 0 0
0 1 2 3 4 5 6 7

L‟algoritmo ha ricostruito l‟array di partenza tenendo conto del numero di ripetizioni per ciascun
elemento.

Qual è la complessità di OrdinaLineare?

Bisogna stimare il costo di ogni ciclo.

1. Primo for  O(n) si azzera n volte l‟array y.


2. Secondo for  si scandisce x e per ogni valore di x si fa un accesso in y  O(n).
3. Terzo for  dalla simulazione vediamo che in ogni iterazione o va avanti i, o va avanti j. Sia con i
che con j andiamo avanti da 0 a n – 1  in tutto faremo circa n + n passi  O(n + n)  O(n).

ORDINAMENTO LINEARE con modifica

Poniamo che x[i] [1, k]

Di che dimensione è più conveniente costruire y? k o n?

Supponiamo di avere n = 100, ma che tutti gli elementi abbiano valore compreso tra [1, k] dove k = 3.

E‟ più conveniente costruire un array y di soli 3 elementi, poiché esso serve solo per contare le
occorrenze di tre valori.

Quando si ha a disposizione il numero k di valori, si costruisce y di dimensione k.

Modifiche sull‟algoritmo tradizionale:

1. primo ciclo for  da 1 a k


2. secondo ciclo for  rimane uguale ([1, n])
3. terzo ciclo for  da 1 a k
Possiamo avere tre casi a seconda del valore di k:

Fino a quale valore di k conviene usare OrdinaLineare? Fino a k ≤ n.

Quanto costa l’algoritmo così modificato?

1. primo for  O(k)


2. secondo for  O(n)
3. terzo for  O(n + k)

1. OrdinaLineareMod (x, n):


2. for (i = 0; i < k; i++) {
3. y[i] = 0;
4. }
5. for (i = 0; i < n; i++) {
6. y[x[i] – 1]++;
7. }
8. j = 0;
9. for (i = 0; i < k; i++) {
10. while (y[i] > 0) {
11. x[j] = i + 1;
12. j++;
13. y[i]--;
14. }
15. }

N.B. i scorre su n elementi, j scorre su k elementi.

Confronto con QuickSort

Se k ≤ nlogn, conviene utilizzare OrdinamentoLineare.


PROGRAMMAZIONE DINAMICA

La programmazione dinamica si usa quando esiste una definizione ricorsiva del problema. Questa tecnica
ha qualche analogia con la procedura del divide et impera  che trovava la soluzione combinando le
soluzioni dei vari sottoproblemi.

Differenza con divide et impera: nel divide et impera le risoluzioni parziali vengono calcolate
separatamente, nella programmazione dinamica è possibile che lo stesso dato sia “usato” da più
sottoproblemi.

Una caratteristica della programmazione dinamica è l’utilizzo di tabelle.

Analizziamo il problema dell’edit distance. Per un algoritmo di questo tipo si ricorre alla programmazione
dinamica (già vista nel secondo algoritmo per il problema di Fibonacci).

Anche qui la soluzione generale si ricava dalla soluzione dei sottoproblemi.

 Si cerca un algoritmo che minimizza la distanza di allineamento.

EDIT DISTANCE - “Allineamento ottimo”

Date due sequenze di caratteri X e Y trovare un allineamento ottimo delle due, che corrisponde alla
minima distanza tra X e Y.

Supponiamo di lavorare su 2 sequenze di caratteri, x e y, tali che x sia di n caratteri (x1, x2, …, xn) e y di
m caratteri (y1, y2, …, ym).

Vogliamo allineare queste due parole, considerando la distanza carattere per carattere. Si confrontano le
parole e i possibili allineamenti. Si ammettono i seguenti casi:

Se xi = yi  c‟è un match (hanno distanza 0);


Se xi ≠ yi  c‟è un mismatch (d = 1);
Se a xi facciamo corrispondere „ ‟  insertion (d = 1);
Se a „ ‟ facciamo corrispondere yi  deletion (d = 1);

Consideriamo quindi questi errori: carattere diverso, carattere messo in corrispondenza di un carattere
che manca e viceversa. Tutti questi errori “costano” 1.

SIMULAZIONE

x A L B E R O DISTANZA
y L A B B R O 3
CORRISPONDENZA X X = X = =

Se proviamo ad allineare le due parole in modo diverso:


x A L B E (deletion) R O DISTANZA
y (Insertion) L A B B R O 3
CORRISPONDENZA X = X X X = =

Ancora:
x A L (deletion) B E R O DISTANZA
y (Insertion) L A B B R O 3
CORRISPONDENZA X = X = X = =

Anche in questo caso d = 3  le due parole non si possono allineare con d < 3.
d = 3  rappresenta l’allineamento ottimo.
(Il problema dell’allineamento ottimo assume grande importanza nelle sequenze di DNA, e quindi nelle
applicazioni biologiche).

ALL’AUMENTARE DELLE DIMENSIONI DELLE SEQUENZE DI CARATTERI E’ MOLTO IMPORTANTE


L’ALGORITMO USATO  sarebbe disastroso un algoritmo di O(n3).

SIMULAZIONE CON ARRAY BIDIMENSIONALE

Supponiamo di avere due sequenze x e y di lunghezza uguale (per semplicità), cioè:

x = {x1, x2, …, xn) e y = {y1, y2, …, ym) (con n = m)

Supponiamo anche di aver già trovato un allineamento ottimo per un prefisso delle sequenze:

x: x1… xi

y: y1… yj

Costruiamo un array bidimensionale. La matrice M ha dimensioni (n+1)*(m+1).

M[i,j] = distanza (migliore allineamento) tra il prefisso x1 x2 … xi di X e il prefisso y1 y2 … yi di Y.

I caratteri di X corrispondono alle righe di M, i caratteri di Y alle colonne.

L’ultimo valore M[n,m] indica la distanza tra le due sequenze. La riga e la colonna corrispondono a
prefissi vuoti  ciò vuol dire che a questo punto le sequenze non sono ancora state esaminate. La M si
inizializza sulla riga e sulla colonna ponendo:

M[ , j] = j per 0 ≤ j ≤ m (ovvero: il prefisso vuoto di X allineato con il prefisso y1 y2 … yi di Y


corrisponde a una distanza j);
M[ , i] = i per 0 ≤ i ≤ n (ovvero: il prefisso vuoto di Y allineato con il prefisso x1 x2 … xi di X
corrisponde a una distanza i);

Esempio: M[ 3] = 3 corrisponde all’allineamento di LAB con --- (il trattino indica lo spazio vuoto).

Precondizioni:
y L A B B R O
x p(i, j) = se xi = yj  MATCH
1 2 3 4 5 6 p(i, j) = se xi ≠ yj  MISMATCH
A 1 1 1 2 3 4 5 REGOLE:
L 2 1 2 2 3 4 5
Gli elementi M*i, j+ con 1 ≤ i ≤ n e 1 ≤ j ≤ m si
B 3 2 2 2 2 3 4 calcolano progressivamente, riga per riga, con
E 4 3 3 3 3 3 4 la formula:
R 5 4 4 4 4 3 4 M[i, j] = min{M[i, j - 1] +1, M[i – 1, j] +1,
O 6 5 5 5 5 4 3 M[i – 1, j – 1] + p(i, j) } *

COMPLESSITÀ - La complessità di questo algoritmo è O(n2) perché richiede di costruire una matrice
(n+1)x(m+1); inoltre il valore di ciascuna cella è calcolato in tempo costante con la formula ricorsiva *
perché i tre valori che vi appaiono sono già stati calcolati e memorizzati.

Se si richiede di ricostruire un solo allineamento ottimo, l’algoritmo impiega tempo O(n + m) per compiere
il percorso a ritroso dalla casella [n, m] alla casella [ ].