Divide et Impera Parte 11 - Risoluzione di problemi per divisione in sottoproblemi “bilanciati” Corso A: Prof. Stefano Berardi http://www.di.unito.it/~stefano Corso B: Prof. Ugo de’ Liguoro http://www.di.unito.it/~deligu Divide et Impera Indice Parte 11 1. La ricorsione è efficiente? Due algoritmi per il minimo e il massimo. 2. Divide et Impera: l’ordinamento per fusione MergeSort. 3. Un altro esempio di Divide et impera: il Quicksort Divide et Impera 1. La ricorsione è efficiente? • Nel caso della ricorsione lineare l’efficienza di una funzione ricorsiva è paragonabile a quella di un’iterazione • Negli esempi visti di ricorsione ad albero (fibonacci, coefficiente binomiale) la ricorsione è molto meno efficiente dell’iterazione. • Questo è vero in generale? Molto dipende da come progettiamo gli algoritmi ricorsivi. Un metodo di progettazione per ottenere algoritmi ricorsivi ad albero efficienti, come vedremo, è il metodo del “Divide et Impera”. Divide et Im pera Minimo e Massimo iterativo Come esempio di ricorsione efficiente vediamo una versione iterativa e una ricorsiva dell’algoritmo MinMax, che calcola il minimo ed il massimo tra i valori di un vettore A[]. void MinMax (int A[], int n, int& min, int& max) // pre: n > 0, ossia A[0..n-1] non e' vuoto // post: min e max sono il minimo ed il massimo // in A[0..n-1] rispettivamente { min = max = A[0]; for (int i = 1; i < n; i++) if (A[i] < min) min = A[i]; else if (max < A[i]) max = A[i];} Sia n=lun(A). L’algoritmo iterativo richiede (n-1) confronti per il minimo, (n-1) per il massimo, dunque 2n 2 confronti. Sembra difficile fare di meglio! Divide et Impera Minimo e Massimo ricorsivo L’algoritmo DI_MinMax calcola il min e max in A[i..j] con un metodo di dimezzamenti analogo alla Ricerca Binaria. 1. Se [i..j] ha al più due punti, calcola il minimo e il massimo direttamente. 2. Altrimenti, divide l’intervallo A[i..j] in due parti il più possibile uguali, A[i..m] e A[m+1..j], dove m è il punto medio di [i..j] 3. Calcola il massimo e il minimo (max1 e min1) di A[i..m], e il massimo e il minimo (max2 e min2) di A[m+1..j]. 4. Il massimo di A[i..j] è il massimo di max1, max2, mentre il minimo di A[i..j] è il minimo di min1, min2. Divide et Impera Minimo e Massimo ricorsivo void DI_MinMax(int A[], int i, int j, int& min, int& max) // pre: i <= j, ossia A[i..j] non e' vuoto // post: min e max sono il min/max in A[i..j] risp. { if (i == j) min = max = A[i]; else if (i == j-1) if (A[i] < A[j]) { min = A[i]; max = A[j];} else { min = A[j]; max = A[i];} else // i..j ha piu' di 2 elementi { int min1, max1, min2, max2; int m = (i + j)/2; // punto medio in i..j DI_MinMax(A, i, m, min1, max1); DI_MinMax(A, m+1, j, min2, max2); min = Min(min1, min2); max = Max(max1, max2); } } Quanti sono i confronti richiesti nel caso ricorsivo? Divide et Impera Minimo e Massimo: la ricorsione è più efficiente Se n = lun(A[i..j]) = j – i + 1 allora i confronti C (n) richiesti dall’algoritmo ricorsivo sono: 0 se n = 1 C (n) = 1 se n = 2 2C (n/2) + 2 se n > 2 Infatti: quando dividiamo il vettore in due vettori di lunghezza n/2, ognuno di essi ci richiede C(n/2) confronti, poi con un altro confronto calcoliamo il massimo tra i due massimi, con un altro ancora il minimo tra i due minimi. A questo punto, ragionando per induzione su n, è possibile dimostrare che i confronti sono C(n) = (3/2)n – 2. L’algoritmo iterativo è più semplice, ma richiede un numero maggiore di confronti: 2n-2. Divide et Impera 2. Divide et Impera “Per meglio dominare occorre dividere gli avversari” Ossia: suddividi il problema in sottoproblemi di dimensione circa eguale (“bilanciati”); risolvi i sottoproblemi con la ricorsione, infine combina i risultati MinMax e la Ricerca binaria sono esempi di algoritmi progettati con “Divide et Impera”. • Il vantaggio del Divide et Impera è che la dimensione massima del sottoproblema che dobbiamo risolvere decresce esponenzialmente, e quindi scende rapidamente a zero. • Per esempio, quando dividiamo in due sottoproblemi di dimensione uguale, la dimensione massima si dimezza ogni volta. 2. Divide et Impera “Per meglio dominare occorre dividere gli avversari” Ossia: suddividi il problema in sottoproblemi di dimensione circa eguale (“bilanciati”); risolvi i sottoproblemi con la ricorsione, infine combina i risultati Ecco come appare un programma “Divide et Impera” DivideEtImpera (P, n) // Pre: n è la dimensione del problema P if n k then risolvi direttamente P else dividi P nei sottoproblemi P1,…, Ph di dimensioni n1,…, nh all’incirca uguali for i = 1 to h do Risi = DivideEtImpera (Pi, ni) return combinazione di Ris1, …, Rish uguale al risultato Divide et Impera “Per meglio dominare occorre dividere gli avversari” Ossia: suddividi il problema in sottoproblemi di dimensione circa eguale (“bilanciati”); risolvi i sottoproblemi con la ricorsione, infine combina i risultati In genere, una stima del tempo necessario si calcola cosí: c se n k h T (n) D(n) C (n) T (n ) se n k i i 1 Tempo per dividere Tempo per combinare Somma dei tempi dei sottoproblemi Divide et Impera Esempio di “Divide et impera”: Ordinamento per fusione 88 52 14 31 25 98 30 23 62 79 Affida una metà da ordinare ad un amico … 25,31,52,88,98 dividi l’insieme da ordinare il più possibile a metà, in modo qualsiasi … e l’altra metà da ordinare ad un altro 14,23,30,62,79 Divide et Impera Ordinamento per fusione 25,31,52,88,98 14,23,30,62,79 25,31,52,88,98 “Fondi” tra loro i due vettori ordinati ottenuti 14,23,25,30,31,52,62,79,88,98 14,23,30,62,79 Fa solo attenzione che la “fusione” sia ordinata! Divide et Impera Ordinamento per fusione o “MergeSort” void MergeSort(int A[], int i, int j) { if (i < j) { int m = (i+j)/2; MergeSort(A, i, m); MergeSort(A, m+1, j); /* per completare l’opera, devo solo “fondere” i segmenti ordinati A[i,m] e A[m+1,j] */ Merge(A, i, m, j); }} Divide et Impera Fusione ordinata (merge) di due segmenti ordinati adiacenti Definiamo un metodo per “fondere” due segmenti ordinati consecutivi A[i..m] e A[m+1..j] in un segmento ordinato A[i...j] che comprende tutti i loro punti. 1. Creiamo un vettore di appoggio B in cui costruire l’unione ordinata di A[i..m] e A[m+1..j]. 2. Prendiamo il primo elemento di A[i..m] e il primo elemento di A[m+1..j] e li confrontiamo. Il più piccolo dei due diventa il minimo di B. 3. Continuiamo così a togliere il più piccolo elemento rimasto da A[i…m] o da A[m+1..j] e a inserirlo in B, finché A[i..m] oppure A[m+1..j] diventano vuoti. 4. A questo punto gli elementi rimasti in A[i..m] oppure A[m+1..j] sono i più grandi, e li ricopiamo in B. 5. Finito di costruire B, lo ricopiamo all’indietro su A. Divide et Impera I primi due passi del Merge 25,31,52,88,98 14 14,23,30,62,79 25,31,52,88,98 14, 14 23 23,30,62,79 Divide et Impera Fusione ordinata (merge) di due segmenti ordinati adiacenti void Merge(int A[], int i, int m, int j) // pre: i <= m <= j A[i..m] e A[m+1..j] ordinati // post: A{i..j] e' ordinato Invariante while: A[p..m], A[q..j] ordinati, {int* B = new int[j-i+1]; B[0..k – 1] ordinato ed i int p = i, q = m+1, k = 0; suoi elem. sono quelli di while (p <= m && q <= j) A[i..p – 1] e A[m+1..q – 1] if (A[p]< A[q]) {B[k]=A[p];k++;p++} else {B[k]=A[q];k++;q++} /* se A[i..m] o A[m+1..j] sono vuoti copio tutti gli elementi rimasti in A[i..m] A[m+1..j] su B. */ Copy(A, p, m, B, k); Copy(A, q, j, B, k+(m-p+1)); /* Riporto il risultato da B in A */ Copy(B, 0, k-1, A, i); delete B;} Divide et Impera La funzione di “copia” tra vettori Nel lucido precedente abbiamo utilizzato funzione di “copia” definita come segue: una void Copy(int A[], int i, int j, int B[], int k) // post: copia A[i..j] su B[] a partire da k { for (int h = i; h <= j; h++, k++) B[k] = A[h]; } Divide et Impera Merge Sort: prima parte esecuzione (divisione) 7 7 7 7 4 4 1 1 2 3 8 4 1 4 8 1 8 2 8 2 6 5 2 3 3 6 5 6 3 6 5 5 Scomponiamo il vettore in segmenti di un punto soltanto (necessariamente ordinati!) Divide et Impera Merge Sort: seconda parte esecuzione (merge) 1 1 4 7 4 2 7 3 5 6 8 7 1 4 4 1 8 2 8 2 7 8 2 3 3 5 6 5 3 6 6 5 Fondiamo tra loro i segmenti ordinati in segmenti più grandi, preservando l’ordine, fino a ricostruire il vettore Divide et Impera Come si stima il tempo di calcolo di una funzione ricorsiva? Ricordiamo quanto abbiamo visto studiando i tempi di calcolo: a ogni algoritmo associamo un ordine di infinito per il tempo di calcolo nel caso peggiore. Per le funzioni ricorsive il tempo di calcolo è a sua volta definito ricorsivamente 1 TFact (n) TFact (n 1) 1 Per esempio, questa è la definizione ricorsiva del tempo di calcolo per la funzione ricorsiva “fattoriale” se n 2 altrimenti Divide et Impera Il tempo di Merge Sort Questa è la definizione ricorsiva del tempo di calcolo per il MergeSort. Richiede di conoscere il tempo richiesto dal Merge: TMergeSort (n) 2T ( n / 2) TMerge (n) E’ facile vedere che TMerge (n) O (n) dunque: c se n 1 T ( n) 2T ( n / 2) n se n 1 Occorrono log2(n) dimezzamenti successivi per trasformare n in 1. Dunque la formula per calcolare T(n) ci chiede di sommare n per log2(n) volte (vedi prossimo lucido) Divide et Impera Il tempo di Merge Sort T (n) 2T (n / 2) n T (n ) Divide et Impera Il tempo di Merge Sort T (n) 2T (n / 2) n n T (n / 2) T (n / 2) Divide et Impera Il tempo di Merge Sort T (n) 2T (n / 2) n n n 2 T (n / 4) T (n / 4) n 2 T (n / 4) T (n / 4) Divide et Impera Il tempo del Merge Sort è O(n log(n)) T (n) 2T (n / 2) n È possibile ordinare in tempo meno di O(n log n) ? n 4 Albero delle chiamate ricorsive, ciascuna con il suo costo n n 2 + n n 2 + n 4 + n 4 + n n 4 n Ogni “livello” dell’albero ha costo totale n, e i livelli sono log 2 (n) Dunque la somma di tutti i costi è: O(n log n) Il MergeSort è ulteriormente migliorabile? Bisogna prima stabilire quale è il tempo minimo richiesto dall’ordinamento Divide et Impera Alberi di decisione e limitazioni inferiori al tempo di calcolo Un albero rappresenta le decisioni prese da un algoritmo quando valgono le condizioni seguenti: i ”nodi interni” dell’albero rappresentano le decisioni prese dall’algoritmo le ”foglie” dell’albero rappresentano i possibili risultati dell’algoritmo i ”rami” dell’albero rappresentano particolari esecuzioni dell’algortimo. La minima lunghezza del ramo più lungo di un albero di decisione di un algoritmo fornisce una limitazione inferiore al numero di decisioni necessarie all’algoritmo nel caso peggiore. Divide et Impera Alberi quasi perfettamente bilanciati Def. Un albero binario è completo se ha 2k vertici per ogni livello k non vuoto. Un albero binario è quasi perfettamente bilanciato (qpb) se, avendo altezza h, è completo sino al livello h 1 (come nella figura qui sopra). Le foglie di un albero quasi perfettamente bilanciato di altezza h sono in numero di: 2h1 s 2h , ovvero h log2 (s) Quindi un problema la cui soluzione può rappresentarsi con un albero q.p.b. con s(n) possibili soluzioni richiede tempo almeno O(log2 s(n)) (la lunghezza del suo ramo più lungo). Un esempio di albero q.p.b. di decisione per l’ordinamento di 3 elementi a:b a<b b<c b:c b<a c<b b<c a:c a:c a, b, c a<c a, c, b c<a c, a, b a<c b, a, c b:c c<b b, c, a c<a b, c, a Ogni nodo corrisponde al confronto tra due elementi del vettore {a,b,c} da ordinare, ogni foglia è uno dei 3! = 6 possibili ordinamenti finali, la massima lunghezza di un “ramo” è 3, mentre la minima è 2 (dato che l’albero è q.p.b.). Divide et Impera Il tempo di calcolo minimo richiesto dall’ordinamento Nel caso dell’ordinamento le foglie dell’albero di decisione sono s(n) = n! (il risultato di un algoritmo di ordinamento è una permutazione, dunque i risultati possibili sono n!). I nodi interni rappresentano i confronti tra due elementi. Nel caso dell’ordinamento il numero dei confronti nel caso peggiore deve essere dunque almeno log2(n!). Usando la formula di Stirling per n! concludiamo che: log 2 (n!) log 2 2n (n / e) n log 2 2n n log 2 (n / e) Ossia il tempo per ordinare è almeno O(nlog(n)). Algoritmi O(nlog(n)), come il MergeSort, impiegano dunque tempo minimo (a meno di costanti moltiplicative, che la Teoria del Tempo di Calcolo non considera). Divide et Impera 3. Divide et impera: il Quick Sort Dividi l’insieme in due scegliendo un elemento (detto “perno”) a caso, e disponendo gli elementi ≤ del perno da una parte e quelli ≥ dall’altra 88 52 14 31 25 98 30 23 62 79 14 31 30 23 25 88 ≤ 52 ≤ 62 98 79 Divide et Impera Quick Sort 14 31 30 23 25 Fà ordinare una metà ad un amico … 14,23,25,30,31 88 ≤ 52 ≤ 62 98 79 …e la seconda metà ad un altro amico 62,79,98,88 Divide et Impera Quick Sort 14,23,25,30,31 52 62,79,98,88 Metti insieme i due risultati con il perno in mezzo: formano un insieme ordinato senza bisogno di fare altro 14,23,25,30,31,52,62,79,88,98 Divide et Impera Quick Sort (supponendo già definita la funzione Perno) void QuickSort(int A[], int i, int j) //pre: A[] e' un vettore, i <= j < dim. di A[] // post: ordina A in senso non decrescente { if (i < j) { int k = Perno(A, i, j); QuickSort(A, i, k); QuickSort(A, k+1, j); } } Divide et Impera Divisione di A[p,u] in due parti con il perno in mezzo int Perno (int A[], int p, int u) // pre: p <= u < dimensione di A[], x = A[p] “perno” // post: se y in A[p..j-1] allora y <= x, A[j-1] == x // se y in A[j..u] allora y >= x { int x = A[p], i = p+1, j = p+1; while (i <= u) // INVARIANTE. Se y in v[p+1..j-1] allora y < x // se z in v[j..i-1] allora z >= x if (A[i] < x) {swap(A[i], A[j]);i++;j++;} else i++; swap(A[p], A[j-1]); // inserisco il perno in mezzo return j-1;} // restituisco la posizione del perno Divide et Impera Quick Sort: il caso pessimo 14,23,25,30,31,52,62,79,88,98 (vuoto) ≤ 14 ≤ 23,25,30,31,52,62,79,88,98 Se Perno sceglie sempre il primo elemento, allora il caso peggiore si verifica se la lista è già ordinata, perché la dimensione del secondo sottoproblema descresce molto lentamente, di un solo elemento alla volta. Divide et Impera Il tempo peggiore per il Quick Sort è O(n2) Sia n la lunghezza del vettore da ordinare e p la posizione del primo perno da inserire T ( n) P ( n) T ( p ) T ( n p ) Tempo della funzione Perno poniamo Tempo = num. dei confronti Caso peggiore: avviene quando p = 1. In tal caso, infatti, abbiamo P(n) = n – 1 e T(1) = 0, quindi deduciamo che: T (n) (n 1) T (n 1) (( n 1) (n 2) .. 1) n(n 1) / 2 O( n 2 ) Divide et Impera Il tempo medio per il Quick Sort è O(nlog(n)) Il tempo impiegato nel caso medio è la media dei tempi impiegati nelle n possibili posizioni del perno: p=0,…,n-1. 1 n 1 Tmedio (n) P ( n) (Tmedio ( p ) Tmedio ( n p )) n p 1 p è la 2 n 1 posizione n 1 Tmedio ( p ) (per n 1) del perno n p 1 Partendo dalla formula per il tempo nel caso medio si n 1 1 dimostra che: Tmedio ( n) 2(n 1) O( n log 2 n) k 3 k Almeno, il QuickSort è ottimo nel caso medio: richiede tempo medio O(nlog(n)) Divide et Impera Fine Fine del Corso di Informatica 2009/2010 Divide et Impera