Professional Documents
Culture Documents
Indice
1
1 1 1 3 3 4 4 5 6 6 7 8 9 10 10 11 11 12 14 14 15 18 19 19 20 21 21
Soluzione Ricorsiva . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Realizzazione in C++ 1.5.1 1.5.2 1.5.3 1.5.4 1.5.1.1 1.5.2.1 1.5.3.1 1.5.4.1 1.5.4.2 1.5.4.3
Listato v1.0 . . . . . . . . . . . . . . . . . . . . . . . . . . Simulazione . . . . . . . . . . . . . . . . . . . . . Simulazione . . . . . . . . . . . . . . . . . . . . . Simulazione . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Prestazioni versione iterativa a doppio ciclo . . . Prestazioni versione iterativa a singolo ciclo . . . Prestazioni versione ricorsiva . . . . . . . . . . . Listato v2.0 . . . . . . . . . . . . . . . . . . . . . . . . . . Listato v3.0 - Ricorsione . . . . . . . . . . . . . . . . . . . Valutazione delle prestazioni
1.6
Conclusioni
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Topological sort
2.1 2.2 Introduzione . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Depth-First-Search 2.2.1 2.2.2 . . . . . . . . . . . . . . . . . . . . . . . . . Pseudocodice . . . . . . . . . . . . . . . . . . . . . . . . . Correttezza dell'algoritmo . . . . . . . . . . . . . . . . . .
22
22 23 24 24
INDICE
Complessit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Pseudocodice . . . . . . . . . . . . . . . . . . . . . . . . . Funzionamento graco . . . . . . . . . . . . . . . . . . . . Correttezza . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.3.1 2.3.3.2 2.3.3.3 Teorema del cammino bianco . . . . . . . . . . . Teorema sui gra aciclici . . . . . . . . . . . . . Correttezza del topological sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
25 26 26 26 27 28 28 28 29 29 31
Topological Sort
2.4
II
Capitolo 1
Il problema del massimo sotto-array
1.1 Introduzione
Si vuole analizzare, risolvere e implementare il seguente problema :
Sia dato un vettore A di n elementi, determinare il sottoarray contiguo e non vuoto di A tale che la somma dei suoi elementi sia massima
Chiameremo tale sottoarray, massimo sottoarray. Gli n elementi presenti nel vettore A, sono interi positivi e negativi, in quanto il problema con soli numeri positivi banalmente risolto (il massimo sottoarray coinciderebbe con l'intero array A).
1.2.1 Pseudocodice
1
CAPITOLO 1.
1 2 3 4 5 6 7 8 9 10 11
FIND-MAXIMUM-SUB-ARRAY(A, low, high) maxsum = -infinito for i = low to high partialsum = 0 for j = i to high partialsum = partialsum + A[j] if partialsum > maxsum maxsum = partialsum lowmax = i highmax = j return(maxsum, lowmax, highmax)
La funzione nd-maximum-sub-array prende come parametri di ingresso:
1. Il vettore A nel quale ricercare il sottovettore 2. L'indice del primo valore dal quale iniziare la ricerca. 3. L'indice dell'ultimo valore entro il quale la ricerca termina. I valori di ritorno della funzione sono anch'essi tre: 1. Il valore della somma degli elementi del massimo sottoarray. 2. L'indice estremo inferiore del sottoarray massimo. 3. L'indice estremo superiore del sottoarray massimo.
Dunque come si evince in gura 1.1, l'algoritmo esplora tutti i possibili sottovettori del vettore di partenza a partire dall'indice low no all'indice high. I valori del vettore in celle di colore bianco rappresentano valori non ancora processati nell' i-j-esima iterazione, mentre quelli di colore grigio rappresentano
CAPITOLO 1.
possibili sottoarray dei quali valutarne la somma dei valori. Una variabile par-
tialsum tiene conto della somma parziale man mano che il sottoarray espande
i propri estremi; se essa risulta essere maggiore della somma dei valori di un precedente massimo sottoarray, allora il nuovo valore del massimo sottoarray proprio la somma parziale e gli estremi del massimo sottoarray si aggiornano a quelli correnti (ossia la i-j-esima iterazione). Questo ci spinge a pensare ad una possibile invariante di ciclo per dimostrare la correttezza dell'algoritmo.
Passo Inizializzazione:
i = 0
j = i = 0.
del vettore, che alla prima iterazione risulta essere banalmente il massimo sottoarray (in quanto l'unico esaminato). maxsum quindi inizializzato al primo valore di partial sum, vericando cos l'invariante di ciclo.
Passo Conservazione: Dopo ogni i-j-esima iterazione partialsum viene ricalcolato aggiungendo il valore corrente j-esimo del vettore A. A questo punto partialsum potrebbe essere superiore al valore di maxsum, ma la condizione testata dall' if ci permette di riassegnare a maxsum il valore di
partialsum = A[j ];
condizione testata ci permette di ristabilire l'invariante di ciclo, cosicch il valore di ritorno della funzione sia quello corretto.
1.2.3 Complessit
Tralasciando la complessita spaziale, analizziamo la complessit temporale del nostro algorimo in versione iterativa, salvo poi migliorarla con una versione ricorsiva. Supponiamo di avere un vettore con n elementi.
CAPITOLO 1.
L'algoritmo consta di varie assegnazioni, tutte con complessit costante e di due cicli for innestati. Il ciclo pi esterno esegue una complessit pari a un ciclo ulteriore di Dunque:
(1)
iterazioni, dunque ha
T (n) =
i=0
n (n i)
T (n)
an2 + bn + c
Dato che la notazione asintotica nasconde il valore delle costanti diremo che
T (n) n2
Si pu migliorare la complessit dell'algoritmo facendo una semplice osservazione, pur non cambiando la natura iterativa. Tratteremo questo argomento nel prossimo paragrafo.
1.3.1 Pseudocodice
Come gi accennato in precedenza, dunque, la dierenza tra i due algoritmi sta nel valutare la condizione
partialsum < 0.
1 2 3 4
CAPITOLO 1.
Figure 1.2: Funzionamento graco : ricerca del massimo sottoarray (versione semplicata)
5 6 7 8 9 10 11 12 13 14
for i = low to high partialsum = partialsum + A[j] if partialsum > maxsum maxsum = partialsum lowmax = j highmax = i if partialsum < 0 partialsum = 0 j = i + 1 return(maxsum, lowmax, highmax)
In gura 1.2 mostrato il funzionamento dell'algoritmo in versione semplicata. Nella gura 1.2 gli elementi in bianco rappresentano quelli non tenuti in considerazione nella i-esima iterazione, mentre quelli in grigio rappresentano il sottoarray del quale si sta valutando se la somma degli elementi sia massima. Il sottoarray dell'iterazione evidenziata in rosso, rappresenta il massimo sottoarray, da quel momento in poi gli indici lowmax e highmax non cambieranno pi in quanto non sar pi presente un sottoarray avente somma parziale superiore.
CAPITOLO 1.
prima versione iterativa) citiamo quella che potrebbe essere una invariante di ciclo per la versione semplicata. Ad ogni i-esima iterazione lowmax e highmax sono gli indici estremi del sottoarray avente somma di elementi pari a maxsum. Inoltre maxsum per ogni iterazione risulta essere maggiore o al pi uguale di partialsum. E' possibile dimostrare l'invarianza di ciclo con un discorso simile a quello trattato nel precedente paragrafo. In pi bisogna giusticare la riga di codice
j = i + 1:
1.3.3 Complessit
La dierenza sostanziale per quanto riguarda le due versioni sin ora trattate che la prima utilizza due cicli innestati, mentre la seconda sfrutta un'interessante osservazione sul massimo sottoarray per utilizzare una sola iterazione. ed al pi delle somme, sono eseguite in tempo costante ( quindi Tutte le operazioni interne al ciclo sono eseguite volte (con n pari al numero di elementi nell'array). Giungiamo quindi a conclusione che la complessit della nuova versione dell'algoritmo lineare ed in particolare : Considerando che tutte le operazioni svolte all'interno dell ciclo sono assegnazioni
ossia n
T (n) (n)
2. La ricerca del massimo sottoarray all'interno del sottoarray di destra, ossia in
3. La ricerca del massimo sottoarray nel vettore che passa per mid, ossia tra tutti i sottoarray con indici estremi i e j tali che
CAPITOLO 1.
Ciascuno dei tre sottoproblemi ha una dimensione decisamente inferiore a quella del problema di partenza (in quanto il vettore originario viene partizionato in due parti), dunque possibile applicare il metodo divide et impera per la soluzione ricorsiva del problema. Qualsiasi sottoarray che passa per la mediana formato da due sottoarray nella forma sottoarray nella forma
A [low...mid]e A [mid + 1...high], quindi basta trovare i massimi A [i...mid]e A [mid + 1...j ] e poi combinarli. Utilizziamo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
Find-Max-Crossing-Subarray(A, low, high, mid) leftsum = -infinito righttsum = -infinito sum = 0 for i = mid to low sum = sum + A[i] if(sum > leftsum) leftsum = sum leftindex = i sum = 0 for j = mid+1 to high sum = sum + A[j] if(sum > rightsum) rightsum = sum rightindex = j return (leftindex, rightindex, leftsum + rightsum)
L'algoritmo appena illustrato procede nel cercare il valore massimo della somma degli elementi a sinistra di mid, memorizzandone l'indice estremo inferiore, poi trova la somma massima a destra e restituisce inne gli indici del massimo sottoarray ed il valore della somma degli elementi dello stesso. Questa procedura serve solo da supporto per quella che stiamo per discutere. Prima di discutere la procedura che si servir di Find-Max-Crossing-
CAPITOLO 1.
Il primo eettua
mid low + 1
iterazioni. iterazioni.
Il secondo eettua
high mid 1
Per ciascuno dei due cicli vengono eettuate operazioni a complessit costante
(1),
dunque:
(mid low + 1) (1) + (high mid 1) (1) + (1) (mid low + 1) + (high mid 1) + (1) (mid low + 1 + high mid 1) + (1) (high low) + (1) T (n) (n)
1 2 3 4 5 6 7 8 9 10
Find-Maximum-Subarray(A, low, high) if low = high return (low, high, A[low]) else mid = floor(low + high / 2) (maxleft, lowleft, highleft) = Find-Maximum-Subarray(A, low, mid) (maxright, lowright, highright) = Find-Maximum-Subarray(A, mid + 1, high) (maxcross, lowcross, highcross) =
Elaborato in ASD: Algoritmi e Strutture Dati 8
CAPITOLO 1.
11 12 13 14 15 16 17
Find-Max-Crossing-Subarray(A, low, high, mid) if(maxleft > maxright AND maxleft > maxcross) return (maxleft, lowleft, highleft) else if(maxright > maxleft AND maxright > maxcross) return (maxright, lowright, highright) else return (maxcross, lowcross, highcross)
Analizziamo la procedura di ricerca del massimo sottoarray: La condizione di uscita dalla ricorsione che alla funzione stessa sia passato un vettore di un solo elemento (caso somma, il valore stesso dell'elemento. Se gli indici estremi non coincidono, viene calcolato l'indice mediana e si chiama ricorsivamente la funzione sulla parte sinistra, destra e sui sottovettori contenenti l'indice stesso. A questo punto ci troviamo con tre valori interi, corrispondenti alle somme dei massimi sottoarray a sinistra, destra ed a cavallo tra i due. Tra questi tre basta trovare quello pi grande e restituire gli indici associati al vettore che ha generato quella somma.
low = high);
sottoarray quello con indici coincidenti low e high, e con valore massimo di
Dimostriamo quindi tramite il principio di induzione completa, la correttezza dell'algoritmo, supponendo per semplicit corretta la chiamata alla funzione
Find-Max-Crossing-Subarray.
Per
A [low]che
n 1 elementi, la ricorsione opera su n elementi, ossia su un insieme contenuto in n 1 per cui le chiamate 2 ricorsive sono corrette e ritornano il valore corretto della somma degli
Supponendo corrette le chiamate su elementi del sottoarray sinistro, destro e a cavallo tra i due. A questo punto necessario trovare il valore massimo tra i tre, ma l'algoritmo tramite le tre comparazioni opera proprio trovando il valore maggiore. sottoarray. Dunque viene restituito il valore maggiore e relativamente gli indici corretti del
Dunque deduciamo che la funzione operi correttamente. Elaborato in ASD: Algoritmi e Strutture Dati 9
CAPITOLO 1.
1.4.4 Complessit
Per analizzare la complessit di un algoritmo ricorsivo, andiamo ad analizzare la complessit di ciasciuna operazione: una di queste fa uso proprio della funzione della quale si sta calcolando la complessit. Ci si imbatte quindi in una equazione alle ricorrenze. L'algoritmo prevede sostanzialmente dei confronti (con complessit costante), due chiamate ricorsive ed una chiamata alla funzione Find-MaxCrossing-Subarray ( complessit mensioni ciascuna pari ad
(n)
).
n>1
quindi:
T (n) = 2 T
Esso risulta
n + (n) 2
essere in forma del teorema dell'esperto n b + f (n) con a 1 e b > 1 logb a Dunque bisogna confrontare (n) con n ed essendo (n) dello stesso log2 2 ordine di grandezza di n = n la soluzione risulta essere nella forma
T (n) = a T
Per trovare la stessa soluzione (o vericare quella trovata) possibile costruire l'albero di ricorrenza. Esso va ad esplorare la complessit di ogni singola chiamata ricorsiva alla funzione stessa. Il nodo della radice esplode nella complessit
dono alle due chiamate ricorsive sulla met degli elementi. A loro volta ciascuno di questi nodi esplodono in altre due chiamate di funzione sulla met degli elementi di partenza. quello in gura 1.3 Sommando la complessit per ogni livello, otteniamo proprio L'albero che ne viene fuori
n. low =
high,
in input (ossia
n).
dell'albero. Per un albero binario l'altezza risulta
hn
essere
con
CAPITOLO 1.
una serie di codici per apprezzare con una maggiore precisione il tempo di esecuzione della funzione stessa, ma rimandiamo il lettore ai paragra successivi per la completa spiegazione.
1 2 3 4 5 6
#include "functions.h" int find_maximum_sub_array (const int A[], const int low, const int high, int &low_max, int &high_max){ int partial_sum = 0;
Elaborato in ASD: Algoritmi e Strutture Dati 11
CAPITOLO 1.
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
int max_sum = -2147483648; int i, j; for(i = low; i <= high; i++){ partial_sum = 0; for(j = i; j <= high; j++){ partial_sum += A[j]; if(partial_sum > max_sum){ max_sum = partial_sum; low_max = i; high_max = j; } } } return max_sum; }
Le variabili locali utili alla funzione sono quattro: 1. partial_sum : variabile di tipo intero che conterr iterazione per iterazione il valore della somma parziale di ogni sottoarray. 2. max_sum : variabile di tipo intero inizializzata al minimo valore esprimibile su 32 bit (con segno), conterr il valore della somma degli elementi del massimo sottoarray. 3. i : indice del ciclo pi esterno. 4. j : indice del ciclo pi interno. Ogni iterazione esterna ha lo scopo di azzerare il valore della somma parziale (da ricalcolare per ogni sottovettore) e di stabilire un indice minimo di partenza per un sottoarray. Con l'iterazione interna invece si esplorano tutti i sottovettori aventi come indice minimo quello dell'i-esima iterazione. Per ogni j-esima iterazione si aggiorna il contenuto della somma parziale e si provvede ad aggiornare quello della somma massima se e solo se quest'ultimo inferiore alla somma parziale.
1.5.1.1
Simulazione
Per testare il funzionamento corretto della nostra subroutine mostriamo il risultato di due simulazioni : 1. Vettore di interi casuale : per mostrare la correttezza per una generica distribuzione di dati in input. 2. Vettore di interi scelti : per testare il corretto funzionamento di tutte le nostre versioni dell'algoritmo con la stessa distribuzione di dati in input.
12
CAPITOLO 1.
La prima simulazione mostra il seguente risultato : A[0]: -2 A[1]: 1 A[2]: -4 A[3]: -5 A[4]: -3 A[5]: -2 A[6]: 1 A[7]: 2 A[8]: 4 A[9]: -5 Execution Time = 0.001 millisec Maximum Sub-Array Have Sum : 7 Index Values Of Maximum Sub-Array Are 6 And 8 Max Sub-Array Is 1 ; 2 ; 4 ; Utilizziamo nella seconda simulazione un esempio riportato su libro di testo , come esempio di confronto tra tutti gli algoritmi discussi. A[0]: 13 A[1]: -3 A[2]: -25 A[3]: 20 A[4]: -3 A[5]: -16 A[6]: -23 A[7]: 18 A[8]: 20 A[9]: -7 A[10]: 12 A[11]: -5 A[12]: -22 A[13]: 15 A[14]: -4 A[15]: 7 Execution Time = 0.001 millisec Maximum Sub-Array Have Sum : 43 Index Values Of Maximum Sub-Array Are 7 And 10 Max Sub-Array Is 18 ; 20 ; -7 ; 12 ;
1 Fig
13
CAPITOLO 1.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
#include "functions.h" int find_maximum_sub_array (const int A[], const int low, const int high, int &low_max, int &high_max){ int partial_sum = 0; int max_sum = -2147483648; int i, j; j = low; for(i = low; i <= high; i++){ partial_sum += A[i]; if(partial_sum > max_sum){ max_sum = partial_sum; low_max = j; high_max = i; } if(partial_sum <= 0){ partial_sum = 0; j = i + 1; } } return max_sum; }
Il trattamento di partial_sum e max_sum rimane il medesimo; in aggiunta viene valutata la condizione di valore negativo della somma parziale: in tal caso l'indice estremo basso j viene aggiornato al prossimo valore di i.
1.5.2.1
Simulazione
Testiamo il funzionamento della seconda versione dell'algoritmo, utilizzando una distribuzione di dati in input casuale. A[0]: -3 A[1]: 3 A[2]: -5 A[3]: -2 A[4]: -3 A[5]: 1 A[6]: 0 A[7]: -2 A[8]: -4 A[9]: -4 Execution Time = 0 millisec Maximum Sub-Array Have Sum : 3 Elaborato in ASD: Algoritmi e Strutture Dati 14
CAPITOLO 1.
Index Values Of Maximum Sub-Array Are 1 And 1 Max Sub-Array Is 3 ; Quindi, il funzionamento con il nostro esempio base. A[0]: 13 A[1]: -3 A[2]: -25 A[3]: 20 A[4]: -3 A[5]: -16 A[6]: -23 A[7]: 18 A[8]: 20 A[9]: -7 A[10]: 12 A[11]: -5 A[12]: -22 A[13]: 15 A[14]: -4 A[15]: 7 Execution Time = 0 millisec Maximum Sub-Array Have Sum : 43 Index Values Of Maximum Sub-Array Are 7 And 10 Max Sub-Array Is 18 ; 20 ; -7 ; 12 ; Da notare il tempo di esecuzione dei due algoritmi. La stima misurata di 0 millisecondi in quanto l'algoritmo risulta cos veloce che non se ne riesce ad apprezzare una stima di misura al millesimo secondo.
1 2 3 4 5 6 7 8 9
#include "functions.h" #include <math.h> int find_max_crossing_subarray (const int A[], const int low, const int mid, const int high, int &low_max, int &high_max){ int i, j; int partial_sum; int right_sum;
Elaborato in ASD: Algoritmi e Strutture Dati 15
CAPITOLO 1.
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
int left_sum; //LEFT-SIDE-FIND-MAX-SUB-ARRAY partial_sum = 0; left_sum = -65535; for(i = mid; i >= low; i--){ partial_sum += A[i]; if(partial_sum > left_sum){ left_sum = partial_sum; low_max = i; } } //RIGHT-SIDE-FIND-MAX-SUB-ARRAY partial_sum = 0; right_sum = -65535; for(j = mid+1; j <= high; j++){ partial_sum += A[j]; if(partial_sum > right_sum){ right_sum = partial_sum; high_max = j; } } return left_sum + right_sum; }
All'interno della funzione troviamo variabili utili allo scope della funzione, tra cui:
right_sum : memorizza la somma di elementi dei sottoarray di destra. left_sum : memorizza la somma di elementi dei sottoarray di sinistra partial_sum : memorizza il valore parziale della somma per valutare se quest'ultima massima.
La funzione si suddivide in due fasi, una per la ricerca del massimo sottovettore di sinistra (rispetto all'elemento mid ) e l'altra per la ricerca a destra. Si noti infatti che gli indici del ciclo di sinistra sono decrescenti, in quanto a noi interessa trovare un sottoarray che contenga mid e che sia massimo: logica quindi la scelta di partire ad esaminare il vettore da mid per poi decrescere. Dualmente nel secondo ciclo si parte dall'elemento subito dopo mid (per non includere nella somma due volte il valore di mid ), per poi crescere verso la ne del vettore. Anche qui c' da notare che tra i parametri di ingresso risultano due variabili passate per riferimento : esse rappresentano i valori degli indici del massimo sottoarray, che precedentemente avevamo segnalato come valori di ritorno. Entriamo dunque nel vivo della funzione di calcolo del massimo sottoarray, il cui codice segue.
16
CAPITOLO 1.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
#include "functions.h" #include <math.h> int find_maximum_subarray (const int A[], const int low, const int high, int &low_max, int &high_max){ int mid; int left_low, left_high; int right_low, right_high; int cross_low, cross_high; signed int left_max_sum, right_max_sum, cross_max_sum; if(low == high){ low_max = low; high_max = high; return A[low]; } else{ mid = floor((double)((low + high)/2)); //LEFT SIDE left_max_sum = find_maximum_subarray(A, low, mid, left_low, left_high); //RIGHT SIDE right_max_sum = find_maximum_subarray(A, mid+1, high, right_low, right_high ); //CROSS cross_max_sum = find_max_crossing_subarray (A, low, mid, high, cross_low, cross_high); //FIND MAX VALUE if(left_max_sum >= right_max_sum && left_max_sum >= cross_max_sum){ low_max = left_low; high_max = left_high; return left_max_sum; }else if(right_max_sum >= left_max_sum && right_max_sum >= cross_max_sum){ low_max = right_low; high_max = right_high; return right_max_sum; }else{ low_max = cross_low; high_max = cross_high; return cross_max_sum; } } }
17
CAPITOLO 1.
L'algoritmo
il
medesimo
di
quello di
visto
in
Nell'implementazione
abbiamo
bisogno
dichiarare
variabili
ranno i valori massimi di somma, e gli indici dei sottoarray di destra, sinistra ed a cavallo tra i due. Gli indici estremi inferiori saranno memorizzati in left_low,
1.5.3.1
Simulazione
Anche in questo caso testiamo il funzionamento della nostra funzione in C/C++ La prima simulazione sar eettuata su una distribuzione di dati in input casuale e dar come risultato: A[0]: -1 A[1]: -1 A[2]: 0 A[3]: 0 A[4]: 2 A[5]: -1 A[6]: -4 A[7]: 4 A[8]: 3 A[9]: -2 Execution Time = 0 millisec Maximum Sub-Array Have Sum : 7 Index Values Of Maximum Sub-Array Are 7 And 8 Max Sub-Array Is 4 ; 3 ; Mentre la seconda sulla nota distribuzione di dati in input, gi utilizzata in precedenza. A[0]: 13 A[1]: -3 A[2]: -25 A[3]: 20 A[4]: -3 A[5]: -16 A[6]: -23 A[7]: 18 A[8]: 20 A[9]: -7 A[10]: 12 A[11]: -5 A[12]: -22
18
CAPITOLO 1.
A[13]: 15 A[14]: -4 A[15]: 7 Execution Time = 0 millisec Maximum Sub-Array Have Sum : 43 Index Values Of Maximum Sub-Array Are 7 And 10 Max Sub-Array Is 18 ; 20 ; -7 ; 12 ; Il quale il medesimo risultato ottenuto con gli altri due codici precedenti. Con questo si chiude la trattazione sul funzionamento del nostro algoritmo; adesso andremo a valutarne le prestazioni.
(g (n))
f (n) (g (n)) {f (n) : c1 , c2 , n0 > 0|c1 g (n) < f (n) < c2 g (n) , n n0 }
Ossia, dire che una funzione inferiore alla funzione che
(g (n)) signica dire che possibile trovare g (n) fanno da limite sia superiore che (n), a partire da un certo n0 .
1.5.4.1
In gura 1.4 mostriamo il graco dell'andamento temporale della prima versione del nostro algoritmo. Ricordiamo che la complessit trovata era In questo caso le costanti trovate sono: Elaborato in ASD: Algoritmi e Strutture Dati 19
T (n) n2
CAPITOLO 1.
c1 =
1 250 1 1000
c2 =
1.5.4.2
In gura 1.5 il graco dell'andamento temporale della seconda versione del nostro algoritmo. Ricordiamo che la complessit trovata era Le costanti trovate sono:
T (n) (n)
c1 =
1 200 1 90
20
c2 =
CAPITOLO 1.
1.5.4.3
In gura 1.6 il graco dell'andamento temporale della terza ed ultima versione del nostro algoritmo. Ricordiamo che la complessit trovata era
T (n)
(n lg n)
Le costanti trovate sono:
c1 =
1 300 1 40
c2 =
1.6 Conclusioni
Con l'analisi delle prestazioni degli algoritmi si chiude la trattazione del problema di ricerca del massimo sottoarray. Tra le soluzioni implementate, la prima risulta ovviamente la pi lenta, in quanto al crescere della cardinalit del vettore in input, i tempi crescono con il quadrato. Inoltre stesso in fase di simulazione si riscontrato un tempo di esecuzione totale del programma (da 100 a 3000 valori) improponibile per un semplice algoritmo; diverso il caso degli altri due che hanno necessitato decisamente di meno tempo per l'esecuzione.
21
Capitolo 2
Topological sort
2.1 Introduzione
Prima di formalizzare il problema dell'ordinamento topologico, bene dare alcune denizioni utili a comprenderlo a fondo. Le denizioni provengono dalla
22
CAPITOLO 2.
TOPOLOGICAL SORT
2.2 Depth-First-Search
La visita in profondit Depth-First-Search (DFS) di un grafo consiste nella esplorazione sistematica di tutti i vertici andando in ogni istante il pi possibile in profondit. Gli archi vengono esplorati a partire dall'ultimo vertice scoperto
v che abbia ancora archi non esplorati uscenti e quando questi sono niti si
torna indietro per esplorare gli altri archi uscenti dal vertice dal quale v era stato scoperto. Il procedimento continua no a quando non vengono scoperti tutti i vertici raggiungibili dal vertice sorgente originario. Se al termine rimane qualche vertice non scoperto, uno di questi diventa una nuova sorgente e si ripete la ricerca a partire da esso. E' possibile visualizzare uno dei possibili ordini di scoperta dei nodi (con nodo sorgente '1') in gura 2.1 Per creare un sistema di tracciamento dello stato di visita, ad ogni nodo viene associato un colore:
Ogni vertice inizialmente bianco. E' grigio quando viene scoperto. Viene reso nero quando la visita nita, cio quando la sua lista di adiacenza stata completamente esaminata.
Oltre al colore, si associano ad ogni vertice due informazioni temporali: 1. Tempo di inizio visita: quando un nodo reso grigio per la prima volta. 2. Tempo di ne visita: quando reso nero. Elaborato in ASD: Algoritmi e Strutture Dati 23
CAPITOLO 2.
TOPOLOGICAL SORT
Il tempo un intero compreso fra 1 e due volte il numero di vertici, poich ogni vertice pu essere scoperto una sola volta e la sua visita pu nire una sola volta.
2.2.1 Pseudocodice
Di seguito specichiamo in pseudocodice il funzionamento della DFS
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
DFS(G) Per ogni u in V[G] u.color = WHITE u.parent = NIL time = 0 Per ogni v in V[G] if v.color = WHITE DFS-Visit(v) DFS-Visit(u) u.color = GREY u.starttime = time = time + 1 Per ogni v in Adj(u) if v.color = white v.parent = u DFS-Visit(v) u.color = black u.endtime = time = time + 1
L'algoritmo DFS comincia con l'inizializzazione di tutti i nodi. Nel particolare tutti i nodi sono inizialmente bianchi e con predecessore nullo. Si noti l'utilizzo di un contatore del tempo, inizializzato a zero. Come da denizione di DFS, si comincia la visita da un qualsiasi nodo che non sia ancora stato esplorato (quindi di colore bianco) per poi andare pi in profondit possibile richiamando ricorsivamente la DFS-Visit. Non appena inizia la visita in profondit di un nodo si pone il colore a grigio e si marca il tempo di inizio visita, per poi andare a valutare i nodi adiacenti al nodo in questione: se uno di questi colorato di bianco, si imposta la dipendenza di predecessore e si inizia una nuova visita sul nodo adiacente bianco. Terminati i nodi adiacenti bianchi, il nodo pu essere marcato di nero e se ne pu registrare il tempo di ne visita.
24
CAPITOLO 2.
TOPOLOGICAL SORT
DFS-Visit un algoritmo ricorsivo e per tanto ne dimostriamo la correttezza tramite principio di induzione completa; la visita pu dirsi terminata quando tutti i nodi connessi all'origine sono colorati di nero.
n = 1,
il nodo viene scoperto e colorato di grigio, menDunque non si entra nel ciclo e
non si richiama ricorsivamente la funzione. Successivamente il nodo viene colorato di nero, ma essendo l'unico nodo, ci fa terminare la visita di profondit.
mente, essa viene eettuata su tutti i nodi contenuti nella lista di adiMa al pi un nodo pu essere connesso a tutti gli
n1
nodi
n.
tri nodi connessi all'origine tramite un cammino semplice, essi sarebbero presenti nella lista di adiacenza di un nodo intermedio, che chiamerebbe correttamente la funzione; se non fossero connessi tramite un cammino semplice, non rientrerebbero nella denizione di visita in profondit. Per ipotesi induttiva l'algoritmo opera correttamente. Dimostrare la correttezza della DFS quindi banale, in quanto l'algoritmo non fa altro che richiamare la visita in profondit su ogni nodo del grafo.
Se avessimo di
Se i nodi sono connessi, la chiamata alla DFS-Visit eettuata solo per i nodi bianchi, e dato che una chiamata corretta (colora di nero tutti i nodi connessi all'origine), scopriremo tutti i nodi del grafo.
2.2.3 Complessit
Per un analisi pi precisa della complessit della visita in profondit (che poi vedremo coincidere con la complessit dell'ordinamento topologico), ricorriamo all'analisi ammortizzata. Se volessimo ricorrere alle equazioni con ricorrenze ci troveremmo di fronte a una soluzione poco accurata, invece con un analisi attenta del codice ci rendiamo conto di conoscere gi esattamente quante iterazioni l'algoritmo eettuer, ossia siamo in grado di fare un analisi ammortizzata. Per quanto riguarda la DFS-Visit, siamo in grado di dire che avvengono (nel ciclo for) 'E' confronti, dove E il numero di archi presenti nel grafo G. Utilizzando per l'analisi ammortizzata ci accorgiamo che questo un limite superiore (ossia la complessit un bianco. Dato che la prima cosa che la DFS-Visit fa marcare il nodo di grigio, essa viene richiamata in un numero di volte che al pi E. Diremo quindi che la complessit
O (E )):
(E ).
25
CAPITOLO 2.
TOPOLOGICAL SORT
La DFS invece un semplice algoritmo iterativo, che richiama la DFS-Visit per ogni suo vertice. In realt abbiamo detto anche precedentemente, che la chiamata alla DFS-Visit viene fatta per ogni nodo solo se della DFS chiama
E = {}
(caso peggiore
per la DFS). Dunque anche in questo caso, anzich aermare che l'iterazione diremo che la chiama
O (V ) volte (V ).
T (n) (V + E )
Un vertice v il cui tempo di ne visita e' successivo ad un vertice u, dovra' precederlo nell'ordinamento nale.
2.3.1 Pseudocodice
Sfruttando dunque l'algoritmo DFS, il topological sort risulta banale: baster inserire in una coda i nodi, man mano che diventano neri, per poi leggerli dalla testa (per cui il primo che diventa nero, il primo ad esser letto).
1 2 3 4 5 6
Topological-Sort(G) Chiama DFS(G) per calcolare tutti i tempi di fine visita Data Q coda di vertici Per ogni v in V(G) Q.Enqueue(v) ogni volta che termino la visita su un nodo ( quando diventa nero) return Q
Sostanzialmente il funzionamento del topological-sort sta tutto nella visita in profondit di un grafo. Infatti per quanto riguarda la complessit di questo algoritmo, dato che l'accodamento ha una complessit l'algoritmo ha una complessit pari a quella della DFS, ossia
(1), diremo (V + E ).
che
CAPITOLO 2.
TOPOLOGICAL SORT
L'ordinamento topologico invece da luogo al graco in gura 2.3. La gura 2.3 non presenta archi all'indietro, ed dunque un ordinamento topologico corretto. Nella gura 2.2 sono stati evidenziati anche i tempi di inizio e ne visita della DFS. Ordinare topologicamente equivale semplicemente ad ordinare i nodi a seconda del loro tempo di ne visita (dal pi grande al pi piccolo).
2.3.3 Correttezza
Per quanto riguarda la verica della correttezza di questo tipo di algoritmi, dobbiamo prendere in prestito i teoremi sulla teoria del gra.
(u, v ) V ,
se esiste un
(u, v )
27
CAPITOLO 2.
TOPOLOGICAL SORT
Prima di dimostrare questo, deniamo i tipi di archi in base al colore dei nodi e deniamo
se v bianco. L'arco un arco d'albero se v grigio. L'arco un arco di ritorno se v nero. L'arco un arco in avanti o un arco di attraversamento se inoltre se
2.3.3.1
In una foresta DFS, un nodo v discendente di u, se e solo se al tempo contenente esclusivamente nodi bianchi.
d[u]
2.3.3.2
Un grafo orientato aciclico se e solo se l'algoritmo DFS su G non trova alcun arco di ritorno. Supponiamo che G contenga un ciclo c. Sia v il primo vertice scoperto in c, e
(u, v )
Allora, al tempo
d[v ],
c' un percorso
bianco da v a u. Per il teorema del cammino bianco, sappiamo che u diventa un discendente di v nella foresta DF. Perci
(u, v )
(u, v ).
Allora il vertice v
un antenato di u nella foresta DF. Quindi esiste certamente un percorso che va da v a u nel grafo G. Tale percorso, concatenato con l'arco di ritorno forma un ciclo, quindi il grafo G non aciclico. non trova archi di ritorno, il grafo aciclico.
(u, v ),
2.3.3.3
Dunque, constatata l'assenza di archi di ritorno in un grafo aciclico possiamo dire che per ogni coppia
(u, v ) V ,
l'arco
(u, v )
pu essere:
f [v ] < f [u]
poich v gi diventato nero prima di aver concluso la
f [v ] < f [u]
visita di u.
28
CAPITOLO 2.
TOPOLOGICAL SORT
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
#include "dfs.h" #include <iostream> using std::cout; using std::endl; extern int time_visit; extern int index; void DFS(char G[], node_set V, const int riemp, const AdjMatrix Adj, node_set inorder){ int i; for(i = 0; i < riemp; i++){ V[i].id = i; V[i].label = G[i]; V[i].color = w; V[i].parent = 0; V[i].time_start = 0; V[i].time_end = 0; } time_visit = 0; index = 0; for(i = 0; i < riemp; i++){ if(V[i].color == w){ DFS_Visit(V[i], V, Adj, riemp, inorder ); } } index--; } void DFS_Visit(node &u, node_set V, const AdjMatrix Adj, const int riemp, node_set inorder){ int i; u.color = g; u.time_start = ++time_visit; for(i = 0; i < riemp; i++){ if(Adj[u.id][i] == 1){
29
CAPITOLO 2.
TOPOLOGICAL SORT
37 38 39 40 41 42 43 44 45 46 47 48
if(V[i].color == w){ V[i].parent = u; DFS_Visit(V[i], V, Adj, riemp, inorder); } } } u.color = b; u.time_end = ++time_visit; inorder[index].label = u.label; inorder[index].time_end = u.time_end; index++; }
E' necessario anche riportare parte dell'header le, in quanto necessario visualizzare la struttura di un nodo di un grafo. Ogni nodo presenta un numero identicativo (utile per individuarlo nella matrice di adiacenza), un'etichetta per il riconoscimento da parte dell'utente, un colore, un predecessore, un tempo di inizio visita e ne visita.
1 2 3 4 5 6 7 8 9 10
struct node{ int id; char label; char color; //w = WHITE, g = GRAY, b = BLACK char parent; int time_start; int time_end; }; typedef node node_set[DIM];
L'algoritmo della DFS stato realizzato tramite matrici di adiacenza, dove la i-j esima locazione contiene un 1 se il nodo individuato dalla riga i ha un arco verso il nodo individuato dalla colonna j. L'algoritmo quello gi visualizzato in pseudocodice. Commentiamo per alcune piccole aggiunte dovute all'implementazione. Sono presenti due variabili extern, ossia due variabili dichiarate globali in altri le a cui possibile accedere (in lettura e scrittura) in questo le. Sebbene l'utilizzo di variabili globali (ed ancor pi di tipo extern) sconsigliato nella programmazione, le utilizzeremo in quanto il progetto di piccole dimensioni e trattasi di una simulazione a scopo solo didattico. Una prima variabile conteggia gli istanti di tempo, la seconda conta quanti elementi sono inseriti di volta in volta nell vettore nale da cui leggere l'ordine dei nodi. A questo punto per ogni nodo bianco viene richiamata la DFS-Visit. Essa colora il nodo che sta visitando ed esplora la matrice di adiacenza partendo dalla riga relativa al nodo da visitare, per valutare eventuali nodi
30
CAPITOLO 2.
TOPOLOGICAL SORT
adiacenti. Se uno di questi nodi adiacenti bianco, la procedura opera ricorsivamente. In seguito viene aumentato il tempo e segnato nel nodo che ha terminato la visita; l'etichetta del nodo viene poi aggiunta alla lista ordinata topologicamente. Baster stampare in ordine inverso questa lista per ottenere un corretto ordinamento topologico. L'output di questo algoritmo scritto in C++, a seguito di un input in gura 2.2 mostrato in gura 2.4. Nell'output del programma troviamo anche segnati i tempi di inizio e ne visita, perfettamente coincidenti con l'analisi graca eettuata in precedenza.
(V + E ).
esecuzione.
La simulazione consta nell'ordinamento topologico di 256 gra diversi (rispettivamente aventi da 1 a 255 nodi). La matrice di adiacenza viene generata automaticamente con una procedura randomizzata. In seguito l'ordinamento eseguito mille volte, salvo poi dividere il tempo di esecuzione per mille. Gracando i risultati otteniamo il risultato in gura 2.5. A sinistra possibile visualizzare l'andamento temporale al crescere di vertci e transizioni. A destra possibile vedere l'esplicazione della notazione tetha. Si noti che
c1 =
1 40 1 50
c2 =
31
CAPITOLO 2.
TOPOLOGICAL SORT
32
CAPITOLO 2.
TOPOLOGICAL SORT
33