Universita' di Ferrara Facolta' di Scienze Matematiche, Fisiche e Naturali Laurea Specialistica in Informatica Algoritmi Avanzati Complessita' degli algoritmi e dei problemi: sorting e searching • • • • straight insertion ricerca binaria mergesort, heapsort radix sort (detto anche distribution sort) Copyright © 2006-2009 by Claudio Salati. Lez. 5 1 COMPLESSITA' DEGLI ALGORITMI Perche' perdere tempo ad analizzare la complessita' di un algoritmo? • Puo' essere divertente. • Per mettere alla prova la propria capacita' di preveggenza. • Perche' si e' studiosi di efficienza. • Per scrivere algoritmi piu' efficienti che ci permettano di risparmiare tempo e spazio. (perche' volere risparmiare tempo e spazio? Vedi ad es. S. Penge, Zen e arte della programmazione) 2 COMPLESSITA' SPAZIALE E TEMPORALE • Si puo' distinguere tra • complessita' temporale: occupazione di tempo di CPU per l'esecuzione di un programma • complessita' spaziale: occupazione di spazio di memoria per ospitare le strutture dati utilizzate dal programma. • Noi ci occuperemo principalmente di complessita' temporale • La complessita' viene comunque misurata in funzione delle dimensioni del problema • Teorema: se la complessita' spaziale e' O(g(n)) allora la complessita' temporale e' almeno O(g(n)). (non vale il viceversa) Dimostrazione: se ho creato delle strutture dati per risolvere il problema, devo averle scritte e/o lette almeno 1 volta: se le dimensioni delle strutture dati sono O(g(n)), per fare questo non 3 posso avere impiegato meno di O(g(n)) tempo. CALCOLO DELLA COMPLESSITA' (temporale) • Ipotesi di lavoro: • si esegue una istruzione alla volta • il costo di un algoritmo dipende dal numero di istruzioni che richiede di eseguire (non dalla lunghezza del suo testo) • ogni accesso in memoria ha lo stesso costo il metodo piu' accurato per misurare le prestazioni di un algoritmo e' contare le azioni che sono eseguite durante la sua esecuzione • per le operazioni elementari si puo' individuare (si assume) un costo (tempo) fisso/costante • le operazioni complesse possono essere scomposte in operazioni elementari • una analisi a priori del tempo di calcolo ignora tutti i fattori dipendenti dalla macchina o dal linguaggio di programmazione, e si concentra sulla determinazione dell'ordine di grandezza 4 della complessita' dell'algoritmo Complessita' e suo UPPER BOUND • Definizione: per complessita' di un algoritmo intendiamo la somma delle frequenze di esecuzione di tutti i suoi statement (in funzione della dimensione del problema che si sta affrontando) • Definizione: • f(n) = O(g(n)) (si legge: f di n uguale O grande di g di n) se esistono due costanti positive c ed n0 tali che | f(n) | c * | g(n) | per tutti gli n n0 • una analisi a priori del tempo di calcolo f(n) di un algoritmo puo' essere usata per determinare una funzione g(n) tale che f(n) = O(g(n)) • Con l'algoritmo ha un tempo di calcolo O(g(n)) intendiamo che se l'algoritmo e' eseguito su un qualche computer, sullo stesso tipo di dati, per valori crescenti della dimensione n dei dati di ingresso, i tempi risultanti sono sempre minori di c * | g(n) |, con c costante positiva. 5 UPPER BOUND • Teorema: se A(n) = am * nm + … + a1 * n + a0 e' un polinomio di grado m allora A(n) = O(nm) • Dimostrazione: • | A(n) | | a m | * nm + … + | a 1 | * n + | a 0 | = = ( | am | + | am-1 | / n + … + | a0 | / nm ) * nm (per n1) ( | am | + | am-1 | + … + | a0 | ) * nm • scegliendo n0 = 1 e c = ( | am | + | am | + … + | a0 | ) si ha | A(n) | c * nm, cioe' A(n) = O(nm) QED • Conclusioni: • se descriviamo la frequenza di esecuzione di uno statement con un polinomio di grado m come A(n) allora il tempo di calcolo dello statement e' O(nm) • tramite la notazione O() esprimiamo una funzione che e' un upper bound della complessita' dell'algoritmo 6 COMPLESSITA' DEGLI ALGORITMI Quale e' l'importanza della costante associata all'ordine di complessita'? • Consideriamo due algoritmi che risolvono lo stesso problema, il primo di complessita' O(n) ed il secondo di complessita' O(n2). Quale e' il migliore? • Se le complessita' effettive sono 10*n e 1*n2 rispettivamente, il primo algoritmo e' migliore del secondo per tutti gli n>10 • Se le complessita' effettive sono 104*n e 1*n2 rispettivamente, il primo algoritmo e' migliore del secondo solo per tutti gli n>104 La costante associata all'ordine di grandezza mi dice fino a quali dimensioni un problema puo' essere considerato piccolo e risolubile (magari convenientemente) con algoritmi banali. 7 COMPLESSITA' ASINTOTICA Perche' e' importante la complessita' asintotica (cioe' l'ordine di grandezza della complessita')? Perche' e' lei che determina la dimensione massima dei problemi trattabili con un algoritmo • Se le dimensioni dei problemi sono piccole, tutti gli algoritmi richiedono comunque "poco" tempo e sono "equivalenti"! • Consideriamo il valore di alcune funzioni al crescere di n: • • f(n) = n : 1 2 3 4 • f(n) = n2 : 1 4 9 16 25 36 49 64 81 100 • f(n) = n3 : 1 8 27 64 125 … • f(n) = 2n : 2 4 8 5 6 7 8 9 10 1000 16 32 64 128 256 512 1024 Consideriamo in particolare, al crescere di n, il valore della funzione f(n) = n*log(n): • f(2)=2 f(3)=4.8 f(4)=8 • f(8)=24 f(9)=28.5 f(5)=11.6 f(10)=33.2 f(6)=15.5 f(100)=664.4 f(7)=19.7 8 COMPLESSITA' ASINTOTICA • Dati due algoritmi di complessita' asintotica • O(n*log(n)) • O(nx) e con x>1, esiste un valore n0 tale che per tutti i valori n>n0 (problemi di grandi dimensioni) e' migliore il primo, mentre per valori bassi di n diventano significativi, al fine del confronto, il valore di x, il coefficiente del termine di grado massimo ed i termini di grado minore per cui puo' risultare piu' conveniente il secondo algoritmo Un algoritmo di complessita' asintotica O(n*log(n)) e' "quasi lineare" Quindi e' molto buono, perche' e' difficile essere meglio che lineare (se leggo tutti i dati in ingresso almeno una volta ho gia' una complessita' almeno lineare!) 9 COMPLESSITA' DEGLI ALGORITMI • La dimensione del problema per la quale un algoritmo richiede meno operazioni di un altro dipende • dal coefficiente del termine di grado massimo • dai termini di ordine minore e dai loro coefficienti • Questi elementi dipendono: • dall'algoritmo e dalla sua codifica (e.g. iterazione vs. ricorsione) • dal calcolatore che si usa e dal suo set di istruzioni • dal linguaggio e dal sistema di programmazione utilizzati • In una analisi a priori del tempo di calcolo di un algoritmo non vengono considerati fattori dipendenti dalla codifica, dalla macchina e dal linguaggio utilizzati; Si determinano l'ordine di grandezza (l'upper bound), ed eventualmente quei termini che influenzano significativamente il confronto, basandosi sulla sola struttura dell'algoritmo 10 LOWER BOUND • Definizione: • f(n) = (g(n)) (si legge: f di n uguale omega di g di n) se esistono due costanti positive c ed n0 tali che | f(n) | c * | g(n) | per tutti gli n n0 • Con l'algoritmo ha un tempo di calcolo (g(n)) intendiamo che se l'algoritmo e' eseguito su un qualche computer, sullo stesso tipo di dati, per valori crescenti di dimensione n dei dati di ingresso, i tempi risultanti sono sempre superiori a c * | g(n) |, con c costante positiva. • Tramite la notazione esprimiamo un lower bound della complessita' dell'algoritmo • La nozione di lower bound si puo' pero' applicare non solo alla complessita' di un algoritmo, ma anche a quella di un problema 11 Complessita' dei Problemi e degli Algoritmi • Bubblesort e' sia O(n2) che (n2), ma puo' essere (n) se lo si scrive nella forma: while (nella iterazione precedente ci sono stati scambi) { ... } • Straight selection e' sia O(n2) che (n2) • Mergesort e' sia O(n*log(n)) che (n*log(n)) • A noi in genere interessano i lower bound dei problemi, non degli algoritmi, infatti il lower bound di un problema rappresenta l'upper bound minimo di tutti gli algoritmi (noti e non noti) che lo risolvono • Se il lower bound di un problema e' (f(n)) cio' vuol dire che non esiste (non puo' esistere) nessun algoritmo con upper bound minore 12 di O(f(n)) Analisi del caso peggiore e Upper bound • Il calcolo della complessita' dell'algoritmo viene effettuato contando il numero delle istruzioni eseguite, ponendosi in una situazione che 1. Maggiormente penalizza il tempo di calcolo analisi del caso peggiore 2. Rende medio il tempo di calcolo • analisi del caso medio Considerare una condizione media comporta in generale complesse valutazioni probabilistiche, per cui di solito ci si limita ad analizzare il caso peggiore: si cerca cioe' l'upper bound della complessita' di un algoritmo 13 Complessita' dell'algoritmo di sort per straight selection • Il numero di confronti (all'interno della funzione findMinEl()) e' indipendente dalla situazione iniziale (dall'ordine iniziale degli elementi nel vettore) • Ad ogni iterazione del ciclo esterno della funzione straightSelection() devo effettuare 3 assegnamenti • Il caso peggiore si ha quando ad ogni iterazione del ciclo della funzione findMinEl() devo effettuare 1 assegnamento (cio' avviene quando il vettore e' inizialmente ordinato in modo non crescente) • il ciclo esterno e' ripetuto n-1 volte • nel caso peggiore l'i-esima iterazione prevede (trascurando le operazioni di controllo del ciclo) • 3 + (n-i-1) assegnamenti • (n-i-1) confronti per un totale di operazioni di … (lasciato per esercizio) • e quindi una complessita' O(n ) 2 14 Sort di un vettore: Straight insertion • Problema: Dato un vettore di N elementi interi, N1, ordinarlo in modo non decrescente • Fino ad ora abbiamo visto 2 algoritmi, basati su due diverse strategie, ma in ogni caso di complessita' quadratica: • straight selection • straight exchange o bubblesort • Vediamo una terza strategia: Straight insertion • ALL’i-ESIMA ITERAZIONE IL SOTTOVETTORE v[0..i-1] E’ GIA’ ORDINATO (ma solo al proprio interno, non rispetto al resto di v) • SI PRENDE L’ELEMENTO DI INDICE i E LO SI INSERISCE NELLA POSIZIONE APPROPRIATA ALL’INTERNO DEL SOTTOVETTORE GIA’ ORDINATO, TRASLANDO IN AVANTI DI UN POSTO NEL VETTORE GLI ELEMENTI CHE SONO MAGGIORI DI LUI • C'E' SPAZIO PER FARLO PERCHE' DAL VETTORE E' STATO 15 ESTRATTO L'ELEMENTO DI INDICE i Straight Insertion: la strategia • Situazione all'i-esima iterazione del ciclo: vettore v v[0] .. v[i-1] ordinato v[i] v[i+1] ... v[N-1] i v[0]..v[i-1] tratto del vettore gia' internamente ordinato in modo non decrescente. Questo sottovettore deriva dal processamento sequenziale degli i precedenti elementi del vettore originale. Non c'e' relazione tra gli elementi del sottovettore v[0]..v[i-1] e quelli del sottovettore v[i]..v[N-1] i indice dell'elemento del vettore che si vuole inserire al posto giusto nel sottovettore ordinato v[0]..v[i-1] 16 Straight Insertion: l'algoritmo void straightInsertion(int n, int v[]) { 1 int i = 1; // { P; in; (j: 0<ji-1) v[j-1]v[j] } while (i <= n-1) { int toInsert = whereToInsert(0, i-1, v, v[i]); // { in-1; (j: 0jtoInsert-1) v[j]v[i]; // (j: toInsertji-1) v[j]>v[i]; } int temp = v[i]; shiftOneRight(toInsert, i-1, v); v[toInsert] = temp; i += 1; // { in; (j: 0<ji-1) v[j-1]v[j] } } // { i=n; (j: 0<jn-1) v[j-1]v[j] } 2 3 4 5 6 7 8 9 } 10 17 Straight Insertion: l'algoritmo int whereToInsert(int low, int high, int v[], int el) { 1 // 0lowhigh<size | int v[size] // (j: low<jhigh) v[j-1]v[j] int scan = low; // (j: low<jhigh) v[j-1]v[j] // (j: lowjscan-1) v[j]el // scan high+1 while (scan <= high && v[scan] <= el) scan +=1; // (j: low<jhigh) v[j-1]v[j] // (j: lowjscan-1) v[j]el // (j: scanjhigh) v[j]>el // scan high+1 return scan; 2 3 4 } 5 18 Straight Insertion: l'algoritmo void shiftOneRight(int low, int high, int v[]) { 1 // 0<=low, low-1 <= high, // 0<=high<size-1 | int v[size] int scan = high; while (scan >= low) { v[scan+1] = v[scan]; scan -= 1; } } 2 3 4 5 6 7 19 Straight Insertion: correttezza, terminazione, complessita' • Correttezza: lasciata per esercizio • Terminazione: lasciata per esercizio • Complessita': • ad ogni iterazione del ciclo esterno vengono effettuati • toInsert confronti per localizzare la posizione in v (nel sottovettore v[0]..v[i-1]) in cui inserire v[i] • ed i-toInsert assegnamenti per spostare di una posizione a destra gli elementi del sottovettore v[toInsert]..v[i] • per cui anche questo algoritmo e' quadratico 20 RICERCA BINARIA • Ma e' veramente necessario impiegare O(i) confronti per localizzare la posizione in v[] (in particolare nel sottovettore v[0]..v[i-1]) in cui inserire v[i] • Il sottovettore v[0]..v[i-1]e' internamente ordinato! • Confrontiamo v[i] con v[i/2] • se v[i]=v[i/2] possiamo inserire v[i] in posizione i/2 (o i/2+1: va bene lo stesso) • se v[i]<v[i/2] sicuramente non dovremo inserire v[i] a destra di v[i/2] • per localizzare la posizione in cui inserire v[i] a sinistra di v[i/2] possiamo applicare ricorsivamente la stessa strategia, confrontando v[i] con v[(i/2)/2] • possiamo ovviamente procedere in modo analogo e speculare nel caso v[i]>v[i/2] 21 RICERCA BINARIA: l'algoritmo (del dizionario) int whereToInsert(int low, int high, int v[], int el) { // // // // 0lowhigh<size | int v[size] (j: 0<jsize-1) v[j-1]v[j] (j: 0jlow-1) v[j]el (j: high+1jsize) v[j]>el . . . // low return high+1 // (j: 0<jsize-1) v[j-1]v[j] // (j: lowjreturn-1) v[j]el // (j: returnjhigh) v[j]>el } 22 RICERCA BINARIA: la strategia • Situazione all'i-esima chiamata ricorsiva: vettore v ... v[low] low v[low..high] ... v[high] high ... v[size] size tratto del vettore sotto esame. Gli elementi di questo sottovettore sono tra loro ordinati. v[0..low-1] tratti del vettore non significativi. Si assume che v[high+1..size] gli elementi contenuti in questi sotto-vettori, veri o fittizi, siano comunque ordinati e che • tutti gli elementi del primo sottovettore siano el • tutti gli elementi del secondo sottovettore siano >el 23 RICERCA BINARIA: la strategia • N.B.: si assume per comodita' che tutti gli elementi del vettore, anche quelli esterni al range di interesse, siano ordinati e di valore opportuno (vedi pagina precedente). • Si assume anche che il vettore si prolunghi con un elemento fittizio di indice size (essendo size la dimensione del vettore). Ovviamente v[size] e' assunto >el. • Ovviamente non ha senso accedere gli elementi esterni al range di interesse low..high, sia che siano veri, sia che siano fittizi. (e' comunque impossibile sapere a priori se sono veri o fittizi, eccetto v[size] che e' fittizio) 24 RICERCA BINARIA: l'algoritmo int whereToInsert(int low, int high, int v[], int el) { int scan = (low + high) / 2; if (v[scan] <= el) { int newLow = scan + 1; if (newLow <= high) return whereToInsert(newLow, high, v, el); else return newLow; // end if } else { // v[scan]>el int newHigh = scan; if (newHigh > low) return whereToInsert(low, newHigh-1, v, el); else return newHigh; // end if } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 2516 RICERCA BINARIA: analisi dell'algoritmo 1 int whereToInsert(int low, int high, int v[], int el) { // (j: 0jlow-1) v[j]el // (j: high+1jsize) v[j]>el int scan = (low + high) / 2; // lowscanhigh if (v[scan] <= el) { // (j: 0jscan) v[j]el // (j: high+1jsize) v[j]>el int newLow = scan + 1; // newLow high+1 // (j: 0jnewLow-1) v[j]el // (j: high+1jsize) v[j]>el if (newLow <= high) return whereToInsert(newLow, high, v, el); else // newLow = high+1, era low=high // (j: 0jnewLow-1) v[j]el // (j: newLowjsize) v[j]>el return newLow; // end if 26 } else {... RICERCA BINARIA: analisi dell'algoritmo 2 int whereToInsert(int low, int high, int v[], int el) { // (j: 0jlow-1) v[j]el // (j: high+1jsize) v[j]>el int scan = (low + high) / 2; // lowscanhigh if (v[scan] <= el) { ... } else { // v[scan]>el int newHigh = scan; // (j: 0jlow-1) v[j]el // (j: newHighjsize) v[j]>el if (newHigh > low) return whereToInsert(low, newHigh-1, v, el); else // newHigh=low // (j: 0jnewHigh-1) v[j]el // (j: newHighjsize) v[j]>el return newHigh; // end if } 27 } Ricerca binaria: correttezza, terminazione, complessita' • Correttezza: la dimostrazione e' per induzione sulla lunghezza del sottovettore da esaminare, cioe' sulla differenza high-low base dell'induzione: se low=high allora anche scan=high=low. • Se v[scan]el allora newLow>high e la funzione ritorna correttamente high+1 • Se v[scan]>el allora newHigh=low e la funzione ritorna correttamente low (devo inserire el in posizione low, traslando in avanti v[low]) passo induttivo: vedi lucido successivo. 28 Ricerca binaria: correttezza, terminazione, complessita' • Correttezza (continua): passo induttivo: supponiamo che l'algoritmo funzioni kn, e dimostriamo che funziona per n+1: (quindi possiamo assumere high>low) In questo caso lowscan<high. • Se v[scan]el allora low<newLowhigh e high-newLow<n, per cui la funzione si comporta correttamente per ipotesi induttiva (ipotesi induttiva applicabile, precondizioni della funzione soddisfatte) • Se v[scan]>el allora • se newHigh=low la funzione ritorna correttamente low • se newHigh>low allora newHigh-1-low<n, per cui la funzione si comporta correttamente per ipotesi induttiva 29 Straight Insertion: verso Mergesort • Ordine di complessita' della funzione di ricerca binaria: O(log(n)) infatti ad ogni confronto si dimezza la dimensione del vettore da esaminare • La complessita' asintotica di Straight Insertion migliora avendo ridotto da lineare a logaritmica la complessita' di whereToInsert()? • No, perche' rimane comunque lineare la complessita' di shiftOneRight() • Supponiamo pero' di utilizzare una tecnica simile a quella della ricerca binaria a Straight Insertion nel suo complesso: • nella ricerca binaria ad ogni ricorsione dimezzo la dimensione del sottovettore che devo esaminare, e riconduco la soluzione del problema dimensione n a quella del problema di dimensione n/2. • applichiamo la stessa tecnica a Straight Insertion. 30 Straight Insertion: verso Mergesort • Divido il problema di ordinare un vettore di dimensione n in quello di ordinare due sottovettori di dimensione n/2 • Risolto questo, inserisco in modo ordinato gli elementi del secondo sottovettore nel primo sottovettore. • Perche' dovrebbe essere piu' efficiente che nel caso di Straight Insertion? • Dimezzando la dimensione di un problema quadratico, la sua soluzione e' 4 volte piu' facile! • L'inserimento puo' tenere conto del fatto che gli elementi del secondo sottovettore sono anch'essi ordinati (mentre nel caso di Straight Insertion l'inserimento avviene a partire da un secondo sottovettore non ordinato): una volta inserito il primo elemento, il secondo dovra' essere inserito nel sottovettore ordinato alla sua destra! • Ovviamente dovro' evitare operazioni di traslazione! 31 MERGESORT: la strategia • Problema: Dato un vettore di N elementi interi, N1, ordinarlo in modo non decrescente • Premessa: P = { N1, int v[N] } • Conseguenza: C = { N1, int v[N], j=0..N-2 : v[j] v[j+1] } • Strategia: 1. si opera in modo ricorsivo; 2. si divide il vettore in due sottovettori: il sottovettore v[0]..v[(N-1)/2] e il sottovettore v[(N-1)/2+1]..v[N-1]; 3. si ordina in modo indipendente ciascuno dei due sottovettori; 4. si fa il merge (fusione) ordinato dei due sottovettori ordinati in un unico vettore globalmente ordinato. 32 Mergesort: perche' questa strategia? • Perche' questa strategia? • Perche' gli algoritmi di sort considerati fino ad ora sono tutti di complessita' O(n2). • Quindi, se divido per 2 la dimensione del problema la sua soluzione diventa 4 volte piu' rapida! • E risolvere entrambi i sottoproblemi mi costa (al piu') solo la meta' che risolvere il problema originale. (utilizzando uno degli algoritmi quadratici gia' noti) • Mi resta ancora meta' del tempo per operare la fusione delle due soluzioni parziali: se la fusione e' facile, globalmente ci guadagno. • In realta', ovviamente, anche i due sottoproblemi possono essere risolti utilizzando ricorsivamente la stessa strategia. 33 Mergesort: l'algoritmo void mergeSort(int v[], int low, int high) { assert(low<=high); if (low<high) { int middle = (low + high) / 2; // low<=middle<high mergeSort(v, low, middle); // (j: low<jmiddle) v[j-1]v[j] mergeSort(v, middle+1, high); // (j: low<jmiddle) v[j-1]v[j] // (j: middle+1<jhigh) v[j-1]v[j] merge(v, low, middle, high); // (j: low<jhigh) v[j-1]v[j] } } 1 2 3 4 5 7 8 9 34 Mergesort: l'algoritmo di merge - 1 void merge(int v[], int low, int middle, int high) { int *pBufferV = malloc((high-low+1) * sizeof(int)); int bufferIndex = 0; int lowScan = low; int highScan = middle + 1; while (lowScan<=middle && highScan<=high) { if (v[lowScan]<=v[highScan]) { pBufferV[bufferIndex] = v[lowScan]; lowScan += 1; } else { pBufferV[bufferIndex] = v[highScan]; highScan += 1; } bufferIndex += 1; } // continua alla pagina seguente 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 35 Mergesort: l'algoritmo di merge - 2 if (lowScan>middle) // && highScan<=high while (highScan<=high) { pBufferV[bufferIndex] = v[highScan]; highScan += 1; bufferIndex += 1; } else // lowScan<=middle && highScan>high while (lowScan<=middle) { pBufferV[bufferIndex] = v[lowScan]; lowScan += 1; bufferIndex += 1; } // end if // continua alla pagina seguente 17 18 19 20 21 22 23 24 25 26 27 28 36 Mergesort: l'algoritmo di merge - 3 for (bufferIndex = 0, lowScan = low; lowScan<=high; bufferIndex += 1, lowScan +=1) v[lowScan] = pBufferV[bufferIndex]; // end for free(pBufferV); // punto 1: // // // // // 29 30 31 32 33 avete notato che quando si alloca memoria bisogna indicare di quanta se ne ha bisogno, ma quando si libera memoria non occorre indicare quanta se ne libera! Strano! o no? // punto 2: // // // // // } perche' abbiamo utilizzato memoria dinamica e non memoria automatica, anche se la disciplina di allocazione che abbiamo realizzato e' identica a quella che e' usata per la memoria automatica? 34 37 Mergesort: esercizi 1. Aggiungere a merge() tutte le asserzioni ritenute significative per ottenere una buona documentazione della funzione. Considerare in particolare • precondizioni • postcondizioni (prima della copiatura finale all’indietro, cioe' prima della riga 29) • invariante del primo ciclo 2. Perche' nella realizzazione di merge() non ci si e' accontentati di allocare come variabile automatica un vettore di dimensione costante (fissata a tempo di compilazione)? Considerare • problemi di efficienza • problemi di funzionalita' 38 Mergesort: risposte parziali Invariante del primo ciclo (riga 7) di merge() • lowScan middle highScan high (N.B. cio', insieme alla negazione della condizione di controllo del ciclo, garantisce che esattamente uno dei due rami dell'if successivo, riga 17, sia eseguito) • bufferIndex-0 = (lowScan-low + highScan-(middle+1)) • j=0..bufferIndex-2 : pBufferV[j] pBufferV[j+1] • j=low..middle-1 : v[j] v[j+1] • j=middle+1..high-1 : v[j] v[j+1] • (j=lowScan..middle, k= 0..bufferIndex) : v[j] pBufferV[k] • (j=highScan..high, k= 0..bufferIndex) : v[j] pBufferV[k] • j=low..lowScan-1 : v[j] pBufferV[0.. bufferIndex-1] • j=middle+1..highScan-1 : v[j] pBufferV[0.. bufferIndex-1] 39 Mergesort: correttezza • MERGESORT E' CORRETTA: • ASSUMIAMO LA CORRETTEZZA DELL'ALGORITMO DI MERGE. • LA CORRETTEZZA DI MERGESORT SI STABILISCE PER INDUZIONEMATEMATICA SUL NUMERO DEGLI ELEMENTI DEL VETTORE. • BASE: SE LUNGO 1 (low=high) E' GIA' ORDINATO E NON SI FA NIENTE • INDUZIONE: SE PIU' LUNGO, CIASCUNA DELLE DUE META' E' ORDINATA PER HP INDUTTIVA, MERGE LAVORA BENE, PER CUI TUTTO LAVORA BENE • MERGE E' CORRETTA: • TUTTI GLI ELEMENTI DI CIASCUNO DEI SOTTOVETTORI SONO INSERITI NEL VETTORE FINALE: QUELLI NON INSERITI NEL PRIMO LOOP LO SONO NEL SECONDO. • GLI ELEMENTI SONO INSERITI IN ORDINE NON DECRESCENTE • PERCHE' LO ERANO GIA', E QUINDI PER INDUZIONE SULLA LUNGHEZZA DEL VETTORE RISULTANTE, NEL PRIMO CICLO • PERCHE' LO ERANO GIA' NEL SECONDO 40 Mergesort: terminazione • TERMINAZIONE: • MERGESORT TERMINA PERCHE' LE RICORSIONI TERMINANO APPLICANDOSI SEMPRE SU VETTORI DI LUNGHEZZA DIMEZZATA, PER CUI ALLA FINE SI OTTIENE SEMPRE UN VETTORE DI LUNGHEZZA 1 (SE high==low+1 ALLORA (low+high)/2==low E middle+1==high) • MERGE TERMINA PERCHE': • A OGNI ITERAZIONE DEL PRIMO LOOP VIENE INCREMENTATO DI 1 L'INDICE DI SCANSIONE O DELL'UNO O DELL'ALTRO SOTTOVETTORE • IL SECONDO LOOP SI APPLICA SOLO SUL VETTORE (CHE C'E') NON COMPLETAMENTE SCANDITO DAL PRIMO LOOP: IL LOOP TERMINA PERCHE' L'INDICE DI SCANSIONE DEL VETTORE E' INCREMENTATO DI 1 AD OGNI ITERAZIONE • ANCHE L'ULTIMO LOOP TERMINA PER LO STESSO MOTIVO 41 Mergesort: complessita' • Complessita' dell'algoritmo di merge: • Ogni iterazione del primo statement while comporta 3 confronti e 3 assegnamenti • una iterazione del ciclo while contenuto in ciascuno dei rami dello statement if successivo comporta 1 confronto e 3 assegnamenti • il totale complessivo delle iterazioni dei due primi cicli while e' high-low+1 • l'ultimo ciclo (di ricopiatura) e' eseguito' high-low+1 volte, e comporta 3 assegnamenti e 1 confronto ad ogni iterazione • quindi l'esecuzione dell funzione merge() comporta l'esecuzione di meno di 2*(3+3)*(high-low+1) operazioni • pertanto la funzione merge() ha complessita' O(n), essendo evidentemente n= high-low+1 la dimensione del problema 42 Mergesort: complessita' • Complessita' dell'algoritmo di mergesort: • la figura seguente mostra la sequenza delle chiamate ricorsive fatte dalla funzione mergeSort() quando e' applicata ad un vettore di 10 elementi (di indice da 0 a 9) • la coppia di valori in ogni nodo rappresenta i valori dei parametri low e high per quell'attivazione di mergeSort() • viene anche indicato l'ordine delle operazioni di call e return • l'operazione di divide continua fino a che ogni nodo contiene un solo elemento 43 Mergesort: complessita' • Complessita' dell'algoritmo di mergesort: Call, return 0, 9 1, 9 10, 18 0, 4 2, 5 5, 9 7, 8 0, 2 11, 14 3, 4 5, 7 3, 3 6, 4 8, 6 9, 7 12, 12 0, 1 2, 2 3, 3 4, 4 5, 6 4, 1 0, 0 5, 2 1, 1 16, 17 13, 10 5, 5 8, 9 15, 13 17, 15 7, 7 8, 8 18, 16 9, 9 14, 11 6, 6 44 Mergesort: complessita' • Complessita' dell'algoritmo di mergesort: • la complessita' della funzione mergeSort() e' descritta dalla seguente relazione ricorsiva: T(n) = { a con a costante, se n=1 2 * T(n/2) + c * n + c' con c e c' costanti, se n>1 • espandendo in modo ricorsivo T(n/2) e trascurando la costante c': T(n) = 2 * T(n/2) + c * n = 2 * (2 * T(n/4) + c * n/2) + c * n = = 4 * T(n/4) + 2 * c * n = = 4 * (2 * T(n/8) + c * n/4) + 2 * c * n = = 8 * T(n/8) + 3 * c * n = p in generale p = 2 * T(n/2 ) + p * c * n 45 Parentesi - Esercizio • verificare, utilizzando il principio di induzione matematica, la uguaglianza delle due seguenti relazione ricorsive: 1. T(n) = 2 * T(n/2) + c * n p p 2. T(n) = 2 * T(n/2 ) + p * c * n • suggerimento: basta assumere l'uguaglianza per p=n e verificare che applicando la definizione 1 si ottiene la relazione 2 per p=n+1 46 Mergesort: complessita' • Complessita' dell'algoritmo di mergesort: • T(n) = 2p * T(n/2p) + p * c * n • dove p p • 2 * T(n/2 ) e' il costo dell'attivazione ricorsiva di mergeSort() • p * c * n e' il costo delle attivazioni della funzione merge() • ponendo p = log2n p (e quindi 2 =n) T(n) = n * T(1) + log2n * c * n = a * n + c * n * log2n e quindi T(n) = O(n * log2n) = O(n * log(n)) 47 Mergesort: complessita' • Perche’ la strategia adottata per mergesort funzioni occorre che la suddivisione del vettore avvenga in due sotto-vettori bilanciati, cioe’ di uguale numero di elementi. • Supponiamo infatti di suddividere il vettore in 2 sotto-vettori, il primo di n-1 elementi e il secondo di 1 elemento: • La soluzione del primo problema avrebbe costo O((n-1)2) = O(n2) • Quindi no si sarebbe guadagnato niente! • In effetti, se modifichiamo mergesort suddividendo il vettore in 2 sotto-vettori, il primo di n-1 elementi e il secondo di 1 elemento, quello che otteniamo e’ la versione ricorsiva di straight-insertion! 48 Complessita' di mergesort e straight selection • Si e' visto che, considerando il termine di ordine maggiore, le complessita' di mergesort e straight selection sono rispettivamente • Tmergesort(n) = c1 * n * log2n • Tstraightselection (n) = c2 * n2 • da cio' deriva che straight selection e' migliore di mergesort quando c2 * n2 < c1 * n * log2n cioe' quando c1 > c2 * n / log2n • quando il vettore da ordinare e' abbastanza corto puo' essere piu' conveniente utilizzare straight selection (l'algoritmo asintoticamente peggiore!) • N.B.: in realta' quando si calcola la complessita' di un algoritmo di ordinamento per confronti, normalmente si contano solo i confronti tra elementi del vettore 49 ORDINAMENTO PER CONFRONTI COMPLESSITA' DEL PROBLEMA • QUANTI SONO I POSSIBILI ORDINAMENTI DI UN INSIEME DI n ELEMENTI? n! = n * (n-1) * (n-2) * ... * 1 • ORDINARE UN INSIEME DI n ELEMENTI SIGNIFICA SELEZIONARE TRA LE n! PERMUTAZIONI DEGLI ELEMENTI DELL'INSIEME QUELLA CHE SODDISFA LA RELAZIONE DI ORDINAMENTO • LA SELEZIONE AVVIENE PER CONFRONTI DI 2 ELEMENTI, IL CHE MI PORTA A PRENDERE UNA DECISIONE • POSSIAMO QUINDI IMMAGINARE LE n! PERMUTAZIONI COME LE FOGLIE DI UN ALBERO BINARIO DI DECISIONI CHE CI PERMETTE DI SELEZIONARE LA PERMUTAZIONE GIUSTA • UN ALBERO BINARIO COMPLETO DI ALTEZZA h HA 2h FOGLIE (ELEMENTARE PER INDUZIONE, E LO VEDREMO DURANTE IL CORSO) PERCIO' SE LE FOGLIE SONO n!, L'ALTEZZA MINIMA DELL'ALBERO BINARIO DI DECISIONE E' log(n!) 50 ORDINAMENTO PER CONFRONTI COMPLESSITA' DEL PROBLEMA • UNA APPROSSIMAZIONE DI n! E' (n/e)n E QUINDI log(n!) = n * (log(n) - log(e)) = n*log(n) - 1.44*n = (n*log(n)) • SEGUENDO L'ALBERO DI DECISIONE SI DEBBONO OPERARE TANTI CONFRONTI QUANTA E' L'ALTEZZA DELL'ALBERO • PERTANTO LA COMPLESSITA' DEL PROBLEMA DI ORDINARE UN VETTORE PER CONFRONTO DI ELEMENTI E' DI ORDINE n*log(n) • NON PUO' ESISTERE NESSUN ALGORITMO CHE OPERA PER CONFRONTO CHE SIA MEGLIO DI O(n*log(n)) • PERTANTO MERGESORT (IN QUESTO SENSO) E' OTTIMO 51 ORDINAMENTO PER CONFRONTI COMPLESSITA' DEL PROBLEMA • SI PUO' FARE DI MEGLIO, OPERANDO PER CONFRONTI? • NOTA CHE L'OPERAZIONE DI merge RICHIEDE L'UTILIZZO DI UN VETTORE DI APPOGGIO IN CUI OPERARE IL MERGE, E QUINDI UNA COPIATURA ALL'INDIETRO: LAVORO SPAZIO: MERGESORT NON OPERA 'SUL POSTO' COME FANNO GLI ALGORITMI O(n2) VISTI (E.G. BUBBLESORT) MA RICHIEDE UNO SPAZIO DI MEMORIA AGGIUNTIVO DI DIMENSIONE n • LA COMPLESSITA' SPAZIALE DI MERGESORT E' MAGGIORE DI QUELLA DI BUBBLESORT: 2*n ANZICHE' n • IN PIU' C'E' L'OVERHEAD (anche spaziale) DELLE CHIAMATE DI PROCEDURA E QUELLO DELLA DOPPIA COPIATURA PER IL MERGING • Esistono algoritmi di ordinamento per confronto di complessita’ O(n*log(n)) che operano sul posto? Si', heapsort 52 HEAPSORT (TREESORT) • Modificando straight -insertion, che e' di complessita' O(n2), abbiamo ottenuto un nuovo algoritmo, mergesort, di complessita' O(n*log(n)) • Un primo tentativo per ottenere questo risultato e' stato quello di rendere logaritmico, anziche' lineare, il costo di localizzare la posizione dove inserire il nuovo elemento nel sottovettore gia' ordinato. • Ri-consideriamo straight -selection: cosa e' che lo rende quadratico? • Il fatto che ad ogni iterazione il costo per identificare il nuovo elemento minimo e' lineare: sarebbe possibile renderlo logaritmico? 53 HEAPSORT (TREESORT) • NEL PRIMO CICLO DI STRAIGHT SELECTION TRAMITE n-1 CONFRONTI VIENE IDENTIFICATO L'ELEMENTO MINIMO DEL VETTORE • NON SI RACCOGLIE NESSUNA ALTRA INFORMAZIONE RIGUARDO AGLI ALTRI n-1 ELEMENTI NON MINIMI! • COSA POTREMMO FARE IN ALTERNATIVA CON n-1 CONFRONTI A NOSTRA DISPOSIZIONE? 1. DIVIDIAMO GLI n ELEMENTI IN COPPIE 2. CON n/2 CONFRONTI SI PUO' STABILIRE L'ELEMENTO MINIMO DI OGNI COPPIA, OTTENENDO n/2 ELEMENTI 3. I PASSI (1) E (2) POSSONO ESSERE ITERATI FINO AD OTTENERE L'ELEMENTO MINIMO DEL VETTORE, PIU' UN ALBERO DI RELAZIONI TRA GLI ELEMENTI DEL VETTORE VIA VIA SELEZIONATI IL TUTTO CON • n/2 + n/4 + n/8 + ... = n-1 CONFRONTI L'ALBERO COSI' COSTRUITO HA OVVIAMENTE ALTEZZA log(n) 54 HEAPSORT: esempio • vettore = [ 44, 55, 12, 42, 94, 18, 6, 67 ] 6 12 6 44 44 12 55 12 18 42 94 6 18 6 67 55 HEAPSORT • PROCEDIAMO COME IN STRAIGHT SELECTION: 1. ESTRAIAMO DALL'ALBERO L'ELEMENTO MINIMO. PER FARLO OCCORRE CANCELLARLO A TUTTI I LIVELLI DELL'ALBERO SOSTITUENDOLO CON UNA CHIAVE "INDEFINITO". CIO' IMPLICA log(n) OPERAZIONI. 2. SEGUENDO IL PATH DELL'EX ELEMENTO MINIMO RIEMPIAMO I BUCHI LASCIATI DALLA SUA CANCELLAZIONE FACENDO EMERGERE DAL BASSO IL RELATIVO SOSTITUTO. QUESTA OPERAZIONE COSTA ANCH'ESSA log(n), ED IN PARTICOLARE IMPLICA log(n) CONFRONTI. 3. SI E' COSI' SELEZIONATO IL SECONDO ELEMENTO MINIMO MA QUESTA VOLTA IN TEMPO log(n). 4. RIPETENDO I PASSI (1)...(3) FINO A CHE SONO ESTRATTI TUTTI GLI ELEMENTI DEL VETTORE ORIGINARIO ABBIAMO ORDINATO IL VETTORE CON UN NUMERO DI CONFRONTI PARI A: (n-1) + (n-1)*log(n) ED IN GENERALE CON UN COSTO • O(n*log(n)) LA COSA NON VIENE GRATIS: • BISOGNA MEMORIZZARE E MANTENERE LA STRUTTURA AD ALBERO. • OCCORRE TRATTARE LA CHIAVE "INDEFINITO", CHE OLTRE TUTTO CAUSA CONFRONTI INUTILI. 56 HEAPSORT: esempio • vettore = [ 44, 55, 12, 42, 94, 18, 6, 67 ] 6 12 6 44 44 • 12 55 12 18 42 94 6 18 6 67 vettore ordinato = [ ] 57 HEAPSORT: esempio • vettore = [ 44, 55, 12, 42, 94, 18, 6, 67 ] 12 44 44 • 12 55 12 18 42 94 18 67 vettore ordinato = [ 6 ] 58 HEAPSORT: esempio • vettore = [ 44, 55, 12, 42, 94, 18, 6, 67 ] 12 12 18 44 44 • 12 55 12 18 42 94 67 18 67 vettore ordinato = [ 6 ] 59 HEAPSORT: esempio • vettore = [ 44, 55, 12, 42, 94, 18, 6, 67 ] 12 12 18 44 44 • 12 55 12 18 42 94 67 18 67 vettore ordinato = [ 6 ] 60 HEAPSORT: esempio • vettore = [ 44, 55, 12, 42, 94, 18, 6, 67 ] 18 44 44 • 18 55 42 94 67 18 67 vettore ordinato = [ 6, 12 ] 61 HEAPSORT: esempio • vettore = [ 44, 55, 12, 42, 94, 18, 6, 67 ] 18 42 44 44 • 18 42 55 18 42 94 67 18 67 vettore ordinato = [ 6, 12 ] 62 HEAP • Ma per mantenere questa organizzazione degli elementi del vettore che ne riflette degli ordinamenti parziali e' necessario mantenere, oltre al vettore originario, anche una esplicita struttura dati ad albero? • E come fare a costruire questa struttura ad albero? • L'algoritmo del torneo per identificare l'elemento minimo di un vettore in linea di principio raccoglie esattamente questa informazione, ma non la traduce in una struttura dati esplicita. • In sostanza, sarebbe facile costruire e mantenere la struttura dati che mi rende espliciti tutti i risultati di tutti gli scontri del torneo? • Esistono alternative piu' semplici? • Si' esiste una struttura dati che mi rappresenta sostanzialmente la stessa informazione dei risultati degli incontri del torneo: questa struttura dati e' chiamata heap. 63 HEAP • il vettore stesso puo' essere strutturato internamente in modo da mantenere questa informazione: Il vettore stesso puo' essere utilizzato per rappresentare un albero binario. In questo albero binario ogni nodo e' minore o uguale dei suoi figli (se ne ha). Un vettore cosi' strutturato e' chiamato heap • infatti: UN ALBERO BINARIO COMPLETO di n elementi PUO' ESSERE RAPPRESENTATO IN UN ARRAY (di indici da 0 a n-1) DOVE I FIGLI SINISTRO E DESTRO DEL NODO i SI TROVANO RISPETTIVAMENTE AGLI INDICI 2*i+1 E 2*i+2 perche' i nostri vettori sono indiciati a partire da 0 se i nostri vettori fossero indiciati a partire da 1 i figli si troverebbero agli indici i*2 e i*2+1 64 HEAP • N.B.: un array rappresenta sempre un albero binario completo o quasi completo cioe' un albero che ha tutti i nodi a tutte le profondita' salvo eventualmente a quella massima, in cui possono essere assenti le foglie piu’ a destra • Perche’ un array che rappresenta un albero binario sia uno heap deve anche valere la regola • h[i] h[2*i+1] • h[i] h[2*i+2] (se questo elemento esiste) per ogni i 0 .. (n/2)-1, cioe' per tutti gli elementi che hanno almeno un figlio (n e' la dimensione del vettore, i cui indici vanno da 0 a n-1) • N.B. (n/2)-1 = ((n-1)-1)/2 Poiche' i figli del nodo i si trovano agli indici 2*i+1 e 2*i+2, il padre del nodo di indice k si trova all'indice (k-1)/2 65 • Nota che, se l’array h e’ uno heap, h[0] e’ l’elemento minimo dell’array HEAP: esempio • vettore = [ 4, 12, 6, 14, 22, 18, 16, 44, 55, 23, 42, 94, 19, 26, 67 ] v[0]=4 v[1]=12 v[3]=14 v[7] =44 v[2]=6 v[4]=22 v[8] =55 v[9] =23 v[10] =42 v[5]=18 v[11] =94 • i | 0in/2-1 : v[i]v[2*i+1] v[i]v[2*i+2] • i | 0in-1 : v[0]v[i] v[12] =19 v[6]=16 v[13] =26 v[14] =67 66 HEAPSORT: percolazione (sift) • CONSIDERIAMO UNO HEAP IN CUI MANCHI L'ELEMENTO DI INDICE 0 E PENSIAMO DI INSERIRE UN NUOVO ELEMENTO. METTENDOLO IN POSIZIONE 0, QUELLO CHE SI OTTIENE E' UNO HEAP? CIOE' E' h[0]h[1] E h[0]h[2] ? • SE NO, BISOGNA FARLO PERCOLARE AL POSTO GIUSTO: • Se h[2] < h[0] h[1] ALLORA BISOGNA SCAMBIARE h[0] E h[2] E POI HEAPIFICARE RICORSIVAMENTE IL SOTTOALBERO DI RADICE h[2]. • Se h[1]< h[0] h [2] ALLORA BISOGNA SCAMBIARE h[0] E h[1] E POI HEAPIFICARE RICORSIVAMENTE IL SOTTOALBERO DI RADICE h[1]. • Se h[0] > h[1] h[0] > h[2] ALLORA BISOGNA SCAMBIARE h[0] E min(h[1], h[2]) E POI HEAPIFICARE RICORSIVAMENTE IL SOTTOALBERO DI RADICE h[1] O h[2] MODIFICATO. • PERCHE' SIA MANTENUTA LA PROPRIETA' DELLO HEAP SI DEVE HEAPIFICARE VERSO IL FIGLIO MINORE! • OVVIAMENTE TUTTI I DISCORSI SI POSSONO FARE IN MODO DUALE 67 PER HEAP IN CUI VALE LA RELAZIONE ANZICHE' . Percolazione : esempio v[0]= v[1]=42 v[3]=55 v[2]=6 v[4]=94 v[5]=18 v[6]=12 • facciamo percolare in v l'elemento 44 68 Percolazione : esempio v[0]=44 v[1]=42 v[3]=55 v[2]=6 v[4]=94 v[5]=18 v[6]=12 • facciamo percolare in v l'elemento 44 69 Percolazione : esempio v[0]=6 v[1]=42 v[3]=55 v[2]=44 v[4]=94 v[5]=18 v[6]=12 • facciamo percolare in v l'elemento 44 70 Percolazione : esempio v[0]=6 v[1]=42 v[3]=55 v[2]=12 v[4]=94 v[5]=18 v[6]=44 • facciamo percolare in v l'elemento 44 71 HEAP: procedura di percolazione o sift() • • fa percolare in un vettore heapificato dall'elemento di indice i+1 fino all'elemento di indice r un elemento inserito nella posizione i la percolazione termina quando un nodo non ha figli o e' minore o uguale di tutti (1 o 2) i suoi figli void sift (int n, int v [], int i, int r) { // int v[n]; 0<=i<=n-1; 0<=r<=n-1; i<=r // N.B.: n in effetti e' irrilevante, conta solo r int j = 2*i+1; // indice di eventuale figlio sx if (j<=r) { // esiste almeno il figlio sx? if (j<r && v[j]>v[j+1]) j += 1; // e il dx? // j e' l'indice del figlio con valore minore // se v[j]<v[i] li scambia e percola ricors. if (v[i]>v[j]) { // scambia v[i] e v[j] } int tmp = v[i]; v[i] = v[j]; v[j] = tmp; sift(n, v, j, r); // percola ricorsivamente } // end if } // end if 72 HEAPSORT : procedura di sift() • procedura di sift() • CORRETTEZZA: E' DIMOSTRATA FACILEMENTE PER INDUZIONE MATEMATICA sull'altezza del nodo da cui si fa percolare, • • TENENDO CONTO CHE SE i*2+1>r IL SOTTOALBERO DI RADICE i, CHE CONTIENE SOLO L'ELEMENTO i, E' OVVIAMENTE UNO HEAP, E • ASSUMENDO CHE TUTTI GLI ELEMENTI DI INDICE >i SIANO RADICI DI SOTTOALBERI HEAP COMPLESSITA': E' LINEARE CON L'ALTEZZA NELL'ALBERO DEL NODO DI INDICE i, IN QUANTO E' T(altezza) = T(altezza-1) + cost 73 HEAPSORT: heapificazione iniziale • Per applicare la strategia delineata bisogna inizialmente heapificare l'intero vettore in ingresso (che, in generale, non e' uno heap). • In realta', qualsiasi vettore di dimensione n e' gia' intrinsecamente uno heap per tutti gli elementi di indice i>n/2-1, per i quali non esiste nessun elemento 2*i+1 e 2*i+2 con cui confrontarsi! • Per heapificare il resto del vettore: void buildHeap(int v[], int n) { // int v[n] int k = n/2-1; // v[] e' gia' heapificato nel suffisso k+1..n-1: // - j=k+1..n-1 : 2*j+1n-1 v[j]v[2*j+1] // - j=k+1..n-1 : 2*j+2n-1 v[j]v[2*j+2] while (k>=0) { sift(n, v, k, n-1); k -= 1; } } 74 HEAPSORT: heapificazione iniziale • buildHeap(), correttezza: – La dimostrazione di correttezza (per induzione matematica) e' banale tenendo conto della correttezza di sift() e notando che quando sift-iamo l'elemento k tutti i successivi sono gia' heapificati. • buildHeap(), complessita': – Poiche' lo heap ha altezza log(n), essendo un albero binario completo o quasi completo, e poiche' la procedura sift() e' invocata n/2 volte, buildHeap() e' di complessita' al piu' O(n*log(n)). – Vedi esercizi. 75 HEAPSORT: l'algoritmo • Dopo l'applicazione di buidHeap() v[0] e' l'elemento minimo che vogliamo estrarre: Dove lo mettiamo? Al posto dell'elemento in posizione v[n-1]. • Poi mettiamo il valore che era precedentemente in v[n-1] in v[0] e lo facciamo percolare al posto giusto utilizzando la funzione sift(), e tenendo conto che lo heap si e' accorciato di un elemento. • Ripetiamo questa operazione n-1 volte e il vettore e' ordinato. Ma e’ ordinato in modo non crescente! • Per ordinare il vettore in modo non descrescente avremmo dovuto costruire lo heap utilizzando la relazione anziche la relazione . 76 Heapsort: la strategia • Situazione all'i-esima iterazione del ciclo: vettore v v[0] v[1] ... v[i] v[i+1] .. v[N-1] ordinato i v[i+i]..v[N-1] tratto del vettore gia' globalmente ordinato in modo non crescente. Tutti gli elementi del sottovettore v[i+1]..v[N-1] sono minori o uguali di tutti gli elementi del sottovettore v[0]..v[i] v[0]..v[i] sottovettore strutturato ad heap secondo la relazione i posizione nel vettore nella quale andare ad inserire l'elemento minimo selezionato nel sottovettore heap 77 HEAPSORT: l'algoritmo void heapSort(int n, int v[]) { int k; buildHeap(v, n); // v[0]..v[k] e' uno heap con relazione // j=k+2..n-1 : v[j-1]v[j] // j=0..k, i=k+1..n-1 : v[j]v[i] for(k=n-1; k>=1; k-=1) { int temp = v[0]; v[0] = v[k]; v[k] = temp; sift(n, v, 0, k-1); } } 78 HEAPSORT: correttezza e complessita' • Correttezza: IL VETTORE E' ORDINATO IN MODO NON CRESCENTE Dimostrazione: e' per induzione sulle iterazioni. L'IPOTESI INDUTTIVA E' CHE ALL'ITERAZIONE DI INDICE i IL SOTTOVETTORE v[i+1..n-1] E' ORDINATO IN QUESTO MODO, E CHE I SUOI ELEMENTI SONO TUTTI DI QUELLI DEL SOTTOVETTORE v[0..i] • Complessita': • buildHeap() E' O(n*log(n)), COSI' E' OVVIAMENTE ANCHE IL RESTO PER CUI TUTTO L'ALGORITMO E' O(n*log(n)) ED E' IN-PLACE • OVVIAMENTE SE VOGLIAMO UN VETTORE ORDINATO IN MODO NON DECRESCENTE NON SCAMBIAMO GLI ELEMENTI DOPO HEAPSORT, MA CAMBIAMO DA A LA RELAZIONE CARATTERIZZANTE DELLO HEAP 79 Ordinare senza confrontare • Si e' visto che operando per confronto non e' possibile ordinare un vettore in tempo migliore che O(n*log(n)). • Ma come e' possibile ordinare un vettore senza eseguire confronti? • Come si fa a ordinare un mazzo di carte? • Di solito per prima cosa si separano cuori, fiori, quadri e picche, e per fare questo non si opera alcun confronto. • I confronti entrano in gioco per ordinare tra loro carte dello stesso seme. Che sono normalmente ordinate utilizzando straight insertion. • Questo algoritmo ci dice che e' possibile ordinare non solo per confronti, ma anche "per distribuzione". • E' possibile operare solo per distribuzione? 80 Ordinare per distribuzione L’algoritmo: • Prima distribuisco le carte secondo il loro valore (numero), indipendentemente dal colore, in 13 pacchetti diversi e ordinati tra loro. Il pacchetto dei 2 segue quello degli assi e precede quello dei 3. • Poi raccolgo i pacchetti mantenedo il loro ordine. • Poi distribuisco le carte cosi' raccolte in base al seme, in 4 pacchetti diversi e ordinati tra loro. • Poi raccolgo i pacchetti nel loro ordine: il mazzo risulta ordinato! Note: • La sequenza con cui si applicanoi criteri di distribuzione e’ essenziale: • Se avessi distribuito al primo giro in base al colore e al secondo in base al numero non avrei ottenuto un mazzo ordinato. • Avrei ottenuto un mazzo con tutti gli assi, seguiti da tutti i 2, seguiti da tutti i 3, … ogni sottomazzetto di egual numero ordinato secondo il colore! • Per ottenere il mazzo ordinato devo distribuire prima in base alla “cifra” piu’ leggera (il numero), poi in base a quella piu’ pesante (il colore).81 RADIX SORT (Distribution Sort) void radixSort(int n, int numLen, char* v[]) { // // // // // // // Un intero e' rappresentato di caratteri in base 10. Tutte le stringhe hanno la numLen (>= 1). Per unificare la lunghezza dove necessario sono stati leading 0 come una stringa stessa lunghezza, delle stringhe, aggiunti dei struct distributionSlot { int elsInSlot; char* slot[?]; // cosa e'? See next page. } (struct distributionSlot) radixBins[10]; // un bidone per radice, cioe' per cifra 82 char* • Cosa significa slot[?] ? char* slot[?]; ? • Con questa notazione indichiamo un array di dimensioni indefinite, che possono crescere (e calare?) secondo necessita' (e.g. quando scriviamo un valore in un elemento non ancora esistente). • Una struttura dati di questo tipo non esiste come primitiva del C! • A un certo punto ci dovremo quindi chiedere: come realizzarla? • Nota che la modalita' di realizzazione dovra' garantire che il costo delle operazioni sulla struttura dati sia quello che ipotizzeremo per valutare la complessita' di radixSort() • Nello scrivere radixSort() dovremo quindi porre attenzione a quali operazioni sono necessarie per manipolare un oggetto di tipo: char* slot[?] 83 RADIX SORT (Distribution Sort) // reset distribution bins for (int dScan = 0; dScan <= 9; dScan += 1) radixBins[dScan].elsInSlot = 0; // end for // distribute/collect for each digit, from // least significant for (dScan = numLen-1; dScan>=0; dScan -= 1) { // gli elementi in v sono ordinati rispetto // al loro suffisso dScan+1..numLen-1 // distribute for (int sScan = 0; sScan < n; sScan += 1) { int iSlot = v[sScan][dScan] - '0'; int last = radixBins[iSlot].elsInSlot; radixBins[iSlot].slot[last] = v[sScan]; radixBins[iSlot].elsInSlot += 1; } 84 RADIX SORT (Distribution Sort) // collect // gli elementi in ogni slot sono ordinati // rispetto al loro suffisso dScan+1..numLen-1 int sScan = 0; for (int cScan = 0; cScan <= 9; cScan += 1) { for(int iScan = 0; iScan < radixBins[cScan].elsInSlot; iScan += 1) { v[sScan]= radixBins[cScan].slots[iScan]; sScan += 1; } radixBins[cScan].elsInSlot = 0; } // gli elementi in v sono ordinati // rispetto al loro suffisso dScan..numLen-1 } } 85 RADIXSORT: correttezza - 1 La procedura ordina le stringhe numeriche in ingresso in modo non decrescente. Dimostrazione: e' per induzione sulle iterazioni del ciclo di distribuzione/raccolta, cioe' in pratica sulla lunghezza delle stringhe. • Per stringhe di lunghezza 1 (numLen=1) il ciclo viene eseguito una sola volta: • tutte le stringhe "0" sono sistemate nel bidone 0, tutte le stringhe "1" sono sistemate nel bidone 1, e cosi' via. • poiche' la fase di raccolta scandisce i bidoni in ordine da 0 a 9, nell'array finale tutte le stringhe "0" precedono tutte le stringhe "1" che precedono tutte le stringhe "2" e cosi' via. Continua alla pagina seguente. 86 RADIXSORT: correttezza - 2 • Supponiamo ora che la procedura operi correttamente per stringhe di lunghezza n. • Per ipotesi induttiva, dopo n iterazioni di distribuzione/raccolta le stringhe sono ordinate nel vettore rispetto ai loro n ultimi digit. • L'n+1-esima iterazione processa le stringhe nell'ordine in cui compaiono nel vettore, distribuendole in base alla n+1-esima cifra dal fondo. • Pertanto tutte le stringhe che hanno l'n+1-esimo digit che vale '0' si trovano nel bidone 0, e all'interno del bidone queste stringhe sono ordinate in base ai loro ultimi n digit. • Lo stesso ragionamento si puo' fare per le stringhe il cui n+1esimo digit da destra vale '1', '2', … • E poiche' la procedura di raccolta scandisce i bidoni e il loro contenuto in ordine, anche il vettore di raccolta risultera' ordinato, rispetto alle ultime n+1 cifre delle stringhe. QED 87 RADIXSORT: complessita‘ - prologo Operazioni su char* slot[?] • In che modo (con quali operazioni) sono accedute le strutture dati di tipo char* slot[?]? • Scritture (inserimenti) e letture/estrazioni in ordine FIFO • E’ possibile tramite liste implementare una coda FIFO in cui le operazioni di inserzione/rimozione hanno costo O(1) • Quindi e’ legittimo assumere che anche le operazioni che accedono a strutture dati char* slot[?] abbiano costo unitario. • In realta’ durante la fase di collect sarebbe piu’ conveniente potere raccogliere tutti gli elementi di un bidone tramite una unica operazione di complessita’ costante (anziche’ trasferirli uno per uno). Perche’ questo sia possibile e’ sufficiente che tra le operazioni sulla lista FIFO ci sia anche quella di concatenazione di liste e che questa operazione abbia complessita’ costante! 88 RADIXSORT: complessita' Complessita': • il ciclo di distribuzione/raccolta viene ripetuto numLen volte, cioe' tante volte quanto e' la lunghezza delle stringhe da ordinare • il corpo del ciclo ha complessita' O(n) dove n e' il numero di stringhe da ordinare N.B.: trascurando le operazioni necessarie per scandire tutti i bidoni. Questa ipotesi e’ ragionevole se i bidoni sono normalmente pieni, il che e’ probabilisticamente vero se il numero delle stringhe (casuali) e’ molto maggiore di quello dei bidoni. • per cui l'algoritmo ha complessita' O(numLen * n) • ed e' conveniente rispetto agli algoritmi che operano per confronto quando numLen<log(n) • in realta' bisognerebbe tenere conto dei fattori moltiplicativi: e.g. ad ogni iterazione ogni elemento e' copiato due volte! 89 RADIXSORT: complessita' • Nota che piu’ bidoni ho a disposizione, piu’ rapido e’ l’algoritmo, perche’ a parita’ di lunghezza delle stringhe da ordinare il numero di fasi di distribuzione-raccolta (pari al numero di cifre dei numeri) e’ minore. Dovendo ordinare un vettore di interi senza segno da 64 bit posso utilizzare ad esempio radici da 4, 8, 16 bit (e corrispondentemente 16, 256, 65536 scatole): nel primo caso l’algoritmo effettuera’ 16 fasi, nel secondo 8, nel terzo 4. • In realta’ pero’, detto #bins il numero dei bidoni utilizzati nella distribuzione, la fase di raccolta ha complessita’ O(#bins+n) perche’ devono essere scanditi tutti i bidoni, anche se sono vuoti. • Normalmente si assume che sia n >> #bins, cosi’ che O(#bins+n) O(n), ma questo non e’ vero se #bins diventa molto grande. • Da questo discende che non e’ sempre conveniente moltiplicare il numero dei bidoni, e quindi diminuire numLen . • In particolare non e’ detto che sia conveniente utilizzare tanti bidoni quanti sono i caratteri dell’alfabeto in caso di stringhe Unicode (216 caratteri), salvo che il numero delle stringhe da ordinare non sia 90 comunque molto maggiore di 216. RADIXSORT: generalizzazione • L'algoritmo di radixsort puo' ovviamente essere generalizzato anche a stringhe non numeriche e di lunghezza diversa: • se consideriamo stringhe sull'alfabeto ASCII esteso (256 caratteri) quante radici dobbiamo considerare? • Come si gestiscono stringhe di lunghezza diversa? • nel caso di stringhe numeriche basta considerarle logicamente estese con degli 0 a sinistra • nel caso di stringhe alfabetiche basta considerarle logicamente estese con degli spazi (o dei '\0') a destra • Notare che la fase di raccolta puo' essere eliminata utilizzando 2 sequenze di bidoni di distribuzione e palleggiando le stringhe in distribuzione dall'una all'altra • Esercizio: riscrivere la procedura di distribution sort perche' possa trattare stringhe alfanumeriche di lunghezza variabile ed evitando la fase di raccolta 91 RankSort e sorting parallelo (da: G. Stefanescu—National University of Singapore - Parallel & Concurrent Programming, Spring, 2004) • Strategia dell’algoritmo di Rank Sort: • count the number of numbers that are smaller than a number a in the array • this gives the position of a in the sorted array • this procedure has to be repeated for all elements of the array • Hence the time complexity of Rank Sort is n*(n-1), thus O(n2). • Rank Sort e’ il peggiore algoritmo di ordinamento sequenziale visto nel corso! 92 RankSort e sorting parallelo • Versione sequenziale dell’algoritmo di Rank Sort: void rankSort(int a[],int sorted[], unsigned n) { /* works well only if there are no repetitions of the numbers * in the array */ for (unsigned i=0; i<n; i+=1) { int rank = 0; for (unsigned j=0; j<n; j+=1) if (a[i] > a[j]) rank += 1; sorted[rank] = a[i]; } } 93 RankSort e sorting parallelo • Versione parallela dell’algoritmo di Rank Sort, utilizzando n processori: void rankSort(int a[],int sorted[], unsigned n) { /* works well only if there are no repetitions of the * numbers in the array */ forall (unsigned i=0; i<n; i+=1) { /* l’istruzione parallela forall distribuisce * l’esecuzione del ciclo su n processori paralleli */ int rank = 0; for (unsigned j=0; j<n; j+=1) if (a[i] > a[j]) rank += 1; sorted[rank] = a[i]; } } • n processors work in parallel to find the ranks of all numbers of the array. • Parallel time complexity is O(n), better than any sequential sorting 94 algorithm.