Universita' di Ferrara Facolta' di Scienze Matematiche, Fisiche e Naturali Laurea Specialistica in Informatica Algoritmi Avanzati Strategie per la progettazione di algoritmi: divide & conquer • problema del min-max • quicksort Copyright © 2007-2008 by Claudio Salati. Lez. 10a 1 DIVIDE-&-CONQUER • Nella progettazione di un algoritmo la strategia e' quasi sempre quella di suddividere il problema in sottoproblemi piu' semplici. • Dato un problema P su un insieme di dati di dimensione n una maniera di suddividerlo in problemi piu' semplici e' quella di considerare problemi analoghi a P ma su insiemi di dati di dimensione minore (cioe' problemi piu' piccoli: piccolo e' facile) • SI RICONDUCE LA SOLUZIONE DI P, dove #P = n, ALLA SOLUZIONE DI m PROBLEMI P1, P2, …, Pm, dove i | 1i m : # Pi = n/k • OVVIAMENTE BISOGNA CHE SIA FACILE: • OPERARE LA SCOMPOSIZIONE • RICOMPORRE LE m SOLUZIONI PARZIALI NELLA SOLUZIONE COMPLESSIVA 2 DIVIDE-&-CONQUER • PERCHE' CONSIDERARE LO STESSO PROBLEMA SOLO PER DIMENSIONI PIU' PICCOLE DOVREBBE ESSERE CONVENIENTE? • PERCHE' SE IL PROBLEMA E' DI COMPLESSITA' n2 DIVIDERLO PER 2 SIGNIFICA OTTENERE UN PROBLEMA 4 VOLTE PIU' FACILE! • Non solo, OPERANDO RICORSIVAMENTE SI ARRIVA AD OTTENERE PROBLEMI ELEMENTARI: e.g., ORDINARE UN VETTORE DI LUNGHEZZA 1 E' MOLTO FACILE, E' GIA' DI PER SE STESSO ORDINATO 3 DIVIDE-&-CONQUER void divideAndConquer(items data [], int fromItem, int toItem) { // risolve il problema per il sottovettore // data[fromItem..toItem]; // la risoluzione si suppone sul posto if (smallEnough(fromItem, toItem)) solve(data, fromItem, toItem); else { int middle = (fromItem + toItem) / 2; // splitProblem(data, fromItem, toItem, // &middle); divideAndConquer(data, fromItem, middle); divideAndConquer(data, middle + 1, toItem); combine(data, fromItem, toItem, middle); } } 4 DIVIDE-&-CONQUER • IL PARTIZIONAMENTO AVVIENE DI NORMA VERSO PROBLEMI BILANCIATI, DI UGUALE DIMENSIONE • SBILANCIARE NON CONVIENE • SUPPONIAMO DI APPLICARE MERGESORT A 2 SOTTOPROBLEMI SBILANCIATI: • • T(n) = T(1) + T(n-1) + T(merge, 1, n-1) • T(1) E' COSTANTE • T(merge, 1, n-1) E' O(n) • E LA COMPLESSITA' TOTALE E' O(n2) INFATTI CON LO SBILANCIAMENTO SI E' TRASFORMATO MERGESORT IN STRAIGHT-INSERTION! 5 DIVIDE-&-CONQUER • SE g(n) E' LA COMPLESSITA' DELLA SOLUZIONE DI UN PROBLEMA smallEnough() • E f(n) E' LA COMPLESSITA' DI combine() 2 SOLUZIONI PARZIALI, CIASCUNA DI DIMENSIONE n/2 • ALLORA LA COMPLESSITA' DI divideAndConquer() E' • • T(n) = g(n) per n smallEnough() • T(n) = 2 * T(n/2) + f(n) per n !smallEnough() La complessita’ della operazione di scomposizione del problema in due sotto-problemi (che nello schema di funzione coincide con il calcolo di middle) e’ supposta assorbibile entro la complessita' di combine() 6 DIVIDE-&-CONQUER • Vogliamo valutare il costo T(n) di risolvere un problema di dimensione n • SUPPONIAMO DI DIVIDERE UN PROBLEMA DI DIMENSIONE n IN a SOTTOPROBLEMI DI DIMENSIONE n/b CIASCUNO • E CHE IL LAVORO PER OPERARE LA RICOMBINAZIONE DELLE SOLUZIONI PARZIALI SIA c * nd (INGLOBANDO IN QUESTO ANCHE L'EVENTUALE COSTO DEL PARTIZIONAMENTO IN SOTTOPROBLEMI) • E SUPPONIAMO CHE RISOLVERE UN PROBLEMA DI DIMENSIONE 1 ABBIA COSTO COSTANTE k1 • ALLORA a * T(n/b) E' IL COSTO DI RISOLVERE TUTTI I SOTTOPROBLEMI 7 DIVIDE-&-CONQUER • Definizione ricorsiva di T(n): { • T(n) = c * nd + a * T(n/b) T(1) = k1 espandiamo ricorsivamente T(n/b) T(n) = c * nd + a * (c * (n/b)d + a * T(n/b2)) = c * nd * (1 + a/bd) + a2 * T(n/b2) • e continuando ad espandere ricorsivamente per p=logbn volte T(n) = c * nd * (1 + a/bd + … + (a/bd)p-1) + ap * T(1) • ma ap = a logbn = (blogba ) logbn = (b logbn )logba = n logba T(n) k1 * n logb a p1 a c * n * d j 0 b d j 8 DIVIDE-&-CONQUER • n n1 n j0 j0 j0 j j j n1 a * x a x * a * x a x * a * x a * x cioe' (se x1) n (1 x ) * a * x j a * (1 xn1) j0 e n1 1 x j a * x a* 1 x j 0 n 9 DIVIDE-&-CONQUER 1 x n1 a k2 • se x<1 allora lim a * n 1 x 1 x per cui k2 maggiora la sommatoria per qualsiasi n finito • se x=1 allora la sommatoria vale (n+1)*a • se x>1 per n sufficientemente grande la sommatoria e' maggiorata da a*xn+1 10 DIVIDE-&-CONQUER • N.B.: la sommatoria e' quella dei primi n termini della serie geometrica • La serie geometrica e la sua sommatoria • convergono per x < 1 • divergono per x > 1 • Per x = 1 la sommatoria della serie geometrica diverge • Per x>1 e n abbastanza grande la sommatoria della serie geometrica coincide approssimativamente, a meno di costanti moltiplicative, con il suo ultimo termine (o meglio, con il termine successivo, per tenere conto dei termini precedenti) • N.B.: nel nostro caso x = a / bd 11 DIVIDE-&-CONQUER a < bd • Caso: x = a / bd < 1 • T(n) k1 * n • T(n) O(n logb a logb a ) O(n ) e quindi, poiche' logba < d : • k2 * c * n d d T(n) O(n ) d in questo caso domina il lavoro non ricorsivo di ricomposizione dei risultati parziali 12 DIVIDE-&-CONQUER a > bd • Caso: x = a / bd > 1 • T(n) k1 * n • ma: logb a p a a d d b b logb n b a c *n * d b p d a logb d b logb n T(n) O(n logb a a log b d logb n b b nlogb ad ) • e quindi: • in questo caso domina il lavoro ricorsivo 13 DIVIDE-&-CONQUER a = bd • Caso: x = a / bd = 1 • T(n) k1 * n • e quindi: • in quanto logba=d • in questo caso il lavoro ricorsivo e quello di ricomposizione si bilanciano, e compare il termine logaritmico logb a c * n * logb n d T(n) O(nd * logb n) 14 DIVIDE-&-CONQUER Riassumendo: • se a bd T(n) O(n • se a = bd T(n) O(n * logb n) max( d,logb a ) ) d 15 DIVIDE-&-CONQUER Nel caso del mergesort: • a=2 • b=2 • d=1 – quindi: a = bd – quindi: T(n) = O(n * log2n) Nel caso dell’algoritmo del torneo: • a=2 • b=2 • d=0 – quindi: a > bd log b a – quindi: T(n) = O(n ) = O(n) 16 DIVIDE-&-CONQUER In generale, se n n n T(n) a1 * T a2 * T ... ak * T c * nd b1 b2 bk allora max( d,logb1 a1,logb 2 a2 ,...,logbk ak ) T(n) O(n ) salvo che le due quantita' maggiori siano uguali. In questo caso e': max( d,logb1 a1,logb 2 a2 ,...,logbk ak ) T(n) O(n * log(n)) 17 DIVIDE-&-CONQUER: esempio • Immaginiamo di dovere individuare elemento minimo e massimo di un vettore. • L'algoritmo seguente fornisce una soluzione banale del problema typedef . . . elemento; struct result { elemento min; elemento max; }; struct result easyMinMax(elemento v[], int n) { assert(n>0); struct result res; res.min = res.max = v[0]; for (int i=1; i<n; i+=1) { if (v[i] < res.min) res.min = v[i]; if (v[i] > res.max) res.max = v[i]; } return (res); } 1 2 3 4 5 6 7 8 18 DIVIDE-&-CONQUER: esempio • Per analizzare la complessita' dell'algoritmo ci si concentra sul numero di confronti tra elementi del vettore che esso esegue. • perche' le altre operazioni sono in numero proporzionale a questi confronti. • perche' se gli elementi sono oggetti complessi (e.g. stringhe) il costo di questi confronti e' predominante rispetto al costo delle altre operazioni. • la complessita' di easyMinMax() e' evidentemente 2*(n-1). • 2*(n-1) rappresenta caso migliore, peggiore, e medio. • e' anche immediato vedere come easyMinMax() puo' essere migliorata: se v[i]<res.min non puo' essere v[i]>res.max quindi il secondo if (riga 6) deve essere eseguito solo se la condizione del primo (riga 5) risulta falsa 19 DIVIDE-&-CONQUER: esempio typedef . . . elemento; struct result { elemento min; elemento max; }; struct result betterMinMax(elemento v[], int n) { assert(n>0); struct result res; res.min = res.max = v[0]; for (int i=1; i<n; i+=1) { if (v[i] < res.min) res.min = v[i]; else if (v[i] > res.max) res.max = v[i]; // end if } return (res); } 1 2 3 4 5 6 7 8 9 20 DIVIDE-&-CONQUER: esempio • la complessita' di betterMinMax() non e' piu' sempre la stessa per i casi migliore, peggiore, e medio. • il caso migliore si ha quando v[] e' ordinato in modo non crescente: in questo caso il numero di confronti e' n-1. • il caso peggiore si ha quando v[] e' ordinato in modo non decrescente: in questo caso il numero di confronti e' 2*(n-1). • nel caso medio sara' v[i]<res.min meta' delle volte e quindi il numero medio di confronti sara' 3*(n-1)/2. • e' possibile fare "di meglio"? • applichiamo la strategia divide & conquer! 21 DIVIDE-&-CONQUER: esempio typedef . . . elemento; typedef struct result { elemento min; elemento max; } result; result dAndCMinMax(elemento v[], int from, int to) { assert(0<=from && from<= to && to<=#v); result res; if (to-from <= 1) { // 1 o 2 elementi nel sottovettore if (v[from] <=v [to]) { res.min = v[from]; res.max = v[to]; } else { res.min = v[to]; res.max = v[from]; } } else { // continua alla pagina seguente 1 2 3 4 5 6 7 8 9 22 DIVIDE-&-CONQUER: esempio // continuazione di dAndCMinMax() // piu' di 2 elementi nel sottovettore int mid = (from + to) / 2; result r1 = dAndCMinMax(v, from, mid); result r2 = dAndCMinMax(v, mid+1, to); res.min = (r1.min <= r2.min) ? r1.min : r2.min; res.max = (r1.max >= r2.max) ? r1.max : r2.max; } return (res); } 10 11 12 13 14 15 16 17 18 19 23 DIVIDE-&-CONQUER: esempio • dAndCMinMAX() correttezza: • per esercizio (elementare per induzione matematica). • dAndCMinMAX() complessita': • e' data dalla seguente relazione ricorsiva: • T(1) = T(2) = 1 • T(n : n>2) = 2 * T(n/2) + 2 • T(n) = 2 * (2 * T(n/4) + 2) + 2 = 4 * (2 * T(n/8) + 2) + (4+2) = 4 * T(n/4) + (4+2) = 8 * T(n/8) + (8+4+2) = ... • e per k tale che 2k=n T(n) 2 k 1 k 1 * T(2) 2 i 2 k 1 (2 k 2) i1 3*n 2 2 24 DIVIDE-&-CONQUER: esempio • N.B.: la complessita' calcolata per dAndCMinMAX() si applica in tutti i casi, peggiore, migliore e medio. • In effetti e' dimostrabile che nessun algoritmo basato su confronti puo' richiedere meno di 3*n/2-2 confronti. • Ma dAndCMinMAX() e' davvero migliore di betterMinMax()? • in termini di complessita' spaziale e' peggiore: richiede l'allocazione di log(n) record di attivazione ricorsiva. • nel confronto si potrebbero contare anche le altre operazioni, e allora il vantaggio di dAndCMinMAX() diventerebbe minore. • c'e' poi da considerare l'overhead temporale delle chiamate ricorsive. • Cio' dimostra l'importanza, in certe circostanze, di considerare i coefficienti moltiplicativi e i termini di grado inferiore nelle funzioni che descrivono la complessita' degli algoritmi. 25 ALGORITMI DI ORDINAMENTO PER CONFRONTO Algoritmo O(n2) Corrispondente algoritmo O(n*log(n)) straight insertion mergesort straight selection heapsort bubblesort ? • Straight insertion si basa sulla creazione di un sottovettore localmente ordinato e sull'inserimento in esso, uno dopo l'altro, degli elementi restanti del vettore • Mergesort si basa sulla creazione di due sottovettori localmente ordinati e sulla loro fusione ordinata • Straight selection, heapsort e bubblesort si basano sulla creazione di un sottovettore globalmente ordinato e con la sua estensione successiva con gli elementi del restante sottovettore disordinato • N.B.: tra sottovettore ordinato e sottovettore disordinato esiste una precisa relazione: tutti gli elementi del primo sono di tutti gli 26 elementi del secondo ALGORITMI DI ORDINAMENTO PER CONFRONTO • Straight insertion si basa – sulla creazione di 2 sottovettori, un sottovettore localmente ordinato e un sottovettore non ordinato (senza alcuna relazione d’ordine tra gli elementi di un sottovettore e quelli dell’altro) – sull'inserimento nel sottovettore ordinato, uno dopo l’altro, degli elementi del sottovettore non ordinato • Mergesort si basa – sulla creazione di 2 sottovettori, entrambi localmente ordinati (senza alcuna relazione d’rdine tra gli elementi di un sottovettore e quelli dell’altro) – sul merge ordinato dei 2 sottovettori – sull’uso della strategia Divide&Conquer 27 ALGORITMI DI ORDINAMENTO PER CONFRONTO • Straight selection si basa – sulla creazione di 2 sottovettori, un sottovettore globalmente ordinato e un sottovettore non ordinato (gli elementi del sottovettore non ordinato sono tutti maggiori o uguali degli elementi del sottovettore globalmente ordinato) – sull'inserimento nel sottovettore ordinato dell’elemento minimo del sottovettore non ordinato • Heapsort si basa – sulla creazione di 2 sottovettori, un sottovettore globalmente ordinato e un sottovettore non ordinato (gli elementi del sottovettore non ordinato sono tutti minori o uguali degli elementi del sottovettore globalmente ordinato) – sul fatto che il sottovettore non ordinato e’ costituito da uno heap basato sulla relazione “maggiore o uguale” – sull'inserimento nel sottovettore ordinato dell’elemento massimo del sottovettore non ordinato 28 – sull’uso della differenziazione formale ALGORITMI DI ORDINAMENTO PER CONFRONTO • Straight exchange e/o bubblesort si basano – sulla creazione di 2 sottovettori, un sottovettore globalmente ordinato e un sottovettore non ordinato (gli elementi del sottovettore non ordinato sono tutti maggiori o uguali degli elementi del sottovettore globalmente ordinato) – sull’utilizzo di una successione di scambi con elementi adiacenti del sottovettore non ordinato per portare un elemento del sottovettore non ordinato al proprio posto nel sottovettore ordinato • Quicksort si basa – sull’applicazione della strategia Divide&Conquer – sull’utilizzo, per spostare un elemento verso la sua posizione corretta, di salti il piu’ lunghi possibile 29 QUICKSORT • • Come saranno i due sottovettori in cui viene suddiviso il vettore da ordinare secondo la strategia Divide&Conquer? • Non possono essere entrambi globalmente ordinati, altrimenti le 2 chiamate ricorsive e la funzione combine() non avrebbero niente da fare, avrebbe fatto gia’ tutto la funzione splitProblem()! • Quindi almeno inizialmente non potranno essere ordinati, saranno le chiamate ricorsive a ordinarli. • Ma la relazione d’ordine tra i due sottovettori? Ma la relazione d’ordine tra i due sottovettori? • La si conserva! • La funzione splitProblem() dovra’ essere tale da suddividere il vettore in 2 sottovettori tali che gli elementi dell’uno sono tutti minori o uguali degli elementi dell’altro. • La funzione combine() sara’ vuota! 30 QUICKSORT • Quicksort si basa come bubblesort sullo scambio di elementi disordinati. • Lo scambio di elementi in quicksort non ha lo scopo di mettere in ordine elementi adiacenti. • Lo scambio in quicksort non avviene tra elementi adiacenti. • Gli elementi fanno degli spostamenti maggiori: e' per questo che ci si aspetta un comportamento migliore di quello di bubblesort • Lo scambio di elementi in quicksort ha lo scopo di creare due sottovettori che abbiano la seguente proprieta': Proprieta' P: Tutti gli elementi del primo sottovettore sono minori o uguali di tutti gli elementi del secondo sottovettore. • Per ottenere un vettore ordinato e’ quindi sufficiente ordinare localmente ciascuno dei due sottovettori ottenuti dal partizionamento del vettore. • L'ordinamento dei due sottovettori e' ovviamente possibile applicando ricorsivamente la procedura. 31 QUICKSORT vettore v v[0] vettore v v[0] v[1] • i = 0..m-1: v[i] v[m] • i = m+1..N-1: v[i] v[m] • e quindi: ... ... v[m] v[N-1] ... v[N-1] i = 0..m-1, j = m+1..N-1 : v[i] v[j] • L'operazione di suddivione del vettore nei due sottovettori separati dall'elemento pivot m e' chiamata partizionamento 32 QUICKSORT: l’elemento pivot • L’elemento pivot dovrebbe essere l’elemento mediano del vettore v • In questo modo i due sottovettori alla sua destra e alla sua sinistra sarebbero bilanciati • Ma come e’ possibile conoscere a priori quale e’ l’elemento mediano del vettore? • Ci vorrebbe un oracolo. • In mancanza dell’oracolo ci si affida al calcolo delle probabilita’: si utilizza come pivot un elemento a caso, il piu’ comodo, il primo elemento del (sotto-)vettore. 33 QUICKSORT: l'algoritmo void quicksort(int v[], int from, int to) { if (to > from) { int m = partition(v, from, to); quicksort(v, from, m-1); quicksort(v, m+1, to); } } • La funzione partition() effettua il partizionamento del vettore v[] in due sottovettori che soddisfano la proprieta' P intorno all'elemento pivot di indice m • Per ordinare il vettore int v[n] e' sufficiente chiamare quicksort(v, 0, n-1); 34 QUICKSORT: funzione partition() .1 int partition(int v[], int from, int to) { assert(to>from); int pivot = v[from]; // seleziono il pivot int fromScan = from+1; int toScan = to; // i=from+1..fromScan-1: v[i]v[from] // i=toScan+1..to: v[i]v[from] for (;;) { while (fromScan <= toScan && v[fromScan] <= pivot) fromScan += 1; while (toScan >= fromScan && v[toScan] >= pivot) toScan -= 1; // continua alla pagina successiva 35 QUICKSORT: funzione partition() .2 // int partition(): continua if (toScan > fromScan) { int temp = v[fromScan]; v[fromScan] = v [toScan]; v[toScan] = temp; fromScan += 1; toScan -= 1; } else { break; } } assert (toscan == fromscan-1); int m = fromScan - 1; v[from] = v[m]; v[m] = pivot; return(m); } 36 QUICKSORT: correttezza .1 Teorema: La funzione partition() suddivide il sottovettore v[from..to] in tre parti (la prima o la terza eventualmente vuote): • il sottovettore v[from..m-1] • l'elemento v[m] • il sottovettore v[m+1..to] tali che: k = from .. m-1: v[k] v[m] k = m+1 .. to: v[m] v[k] Dimostrazione: • Discende immediatamente dall'invariante del ciclo esterno e dal fatto che tale ciclo termina quando toScan=fromScan-1. • L'elemento v[m] al termine della funzione e' uguale all'elemento 37 v[from] all'inizio di essa. QUICKSORT: correttezza .2 Teorema: La funzione quicksort() ordina il sottovettore v[from..to] in modo non decrescente. Dimostrazione: • Per induzione matematica sulla lunghezza del sottovettore v[from..to]. • Ciascuno dei due sottovettori delle chiamate ricorsive e' lungo al piu' to-from, per cui l'ipotesi induttiva e' applicabile. • Dopo le chiamate ricosive i due sottovettori sono ordinati: la correttezza globale discende dalla correttezza della funzione partition(). 38 QUICKSORT: complessita' .1 • L'algoritmo quicksort ha complessita' O(n2). • Nel caso peggiore infatti ogni partizionamento avviene con uno dei due sottovettori vuoto. • La chiamata ricorsiva e' quindi per un sottovettore di lunghezza inferiore di un solo elemento a quella del sottovettore della attivazione chiamante. • Si hanno quindi n attivazioni della funzione e ogni attivazione, relativa ad un sottovettore di lunghezza k, effettua k-1 confronti. • Nel caso migliore pero' il partizionamento e’ con sottovettori bilanciati: in questo caso l'altezza dell'albero delle chiamate ricorsive e' solo log(n) e il comportamento dell'algoritmo diventa di complessita' n*log(n). • La domanda quindi e': • Quale e' il comportamento atteso? • Quale e' il comportamento medio? 39 QUICKSORT: complessita' media .2 • L'ipotesi e' che tutte le permutazioni della sequenza di elementi da ordinare siano equiprobabili. • Sia T(n) la complessita' dell'algoritmo nell'ordinamento di un sottovettore di lunghezza n: • T(0) = T(1) = b (costante) • T(n) = (n - 1) + T(i-1) + T(n-i), per n 2, con i = 1..n essendo i la posizione nel sottovettore partizionato dell'elemento pivot. • Nell'ipotesi fatta i puo' assumere qualunque valore tra 1 e n con uguale probabilita', per cui in media 1 n T(n) (n 1) * (T(i 1) T(n i)) n i1 2 n1 (n 1) * T(i) n i 0 (1) 40 QUICKSORT: complessita' media .3 • Proviamo se la relazione e' soddisfatta da T(n) = c * n * log(n) (che e' la complessita' "desiderata"). • Sostituiamo nella relazione ricorsiva (1): 2 * c n1 c * n * log(n) n 1 * j * log( j) n j1 ? (2) • Quanto vale la sommatoria che compare in (2)? 41 QUICKSORT: complessita' media .4 • Consideriamo una generica funzione monotona crescente f(x) n n 1 n f (k ) f ( x )dx f (k 1) k 1 1 (3) k 1 42 QUICKSORT: complessita' media .5 n f (k 1) f (k ) f(n 1) f(1) k 1 k 1 n Ma per cui, sottraendo (f(n) - f(1)) dall'integrale di (3): n 1 n n 1 f ( x )dx (f (n 1) f (1)) f (k ) f ( x )dx k 1 1 (4) 1 consideriamo allora f(x) = x * ln(x) tenendo conto che x2 x2 x ln( x)dx 2 ln( x) 4 e sostituendo nella disequazione (4) (vedi pagina seguente) 43 QUICKSORT: complessita' media .6 n2 n2 1 * ln( n) 0 (n ln( n) 0) 2 4 4 n1 k ln( k ) k 1 n2 n2 1 * ln( n) 0 2 4 4 cioe' n1 2 2 n n 1 k ln( k ) 2 ln( n) 4 4 k 1 44 QUICKSORT: complessita' media .7 E dividendo per ln(2) n1 n2 n2 1 k log(k ) 2 log(n) 4 * ln( 2) 4 * ln( 2) k 1 che sostituiamo nella relazione ricorsiva (2): 2 c n2 n2 1 n 1 log( n) n 2 4 * ln( 2 ) 4 * ln( 2 ) c c * n 1 c * n * log( n) 1 2 * ln( 2) 2 * ln( 2) * n quindi, a meno di termini di ordine minore: T(n) = O(n*log(n)) 45