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