Capitolo 5 Algoritmi di ricerca su grafo Gli algoritmi di ricerca su grafo, oggetto dei prossimi paragrafi, rappresentano tecniche fondamentali per determinare nodi che soddisfino particolari proprietà sia su grafi non orientati che orientati. Inoltre, le varianti a tali tecniche sono alla base di numerosi importanti algoritmi, come per esempio: trovare tutti i nodi che a partire da un nodo specifico siano raggiungibili da un path orientato; individuare tutti i path orientati che da tutti i nodi raggiungano un nodo assegnato; individuare tutte le componenti connesse di un grafo; determinare se un grafo è bipartito, ecc. Un’ulteriore applicazione di grande riscontro nella pratica consiste nella ricerca di cicli in grafi orientati e, se il grafo è aciclico, nell’ordinarne i nodi secondo un dato criterio, cosı̀ come vedremo nella Sezione 5.2. In merito ai grafi non orientati vedremo, specificamente nella nella Sezione 5.3, gli algoritmi di ricerca del minimo albero ricoprente di un grafo, all’analisi dei quali faremo precedere la dimostrazione delle condizioni che garantiscono l’ottimalità della soluzione trovata. 5.1 Algoritmi di ricerca su grafo Il problema che ci poniamo di risolvere è il seguente: determinare tutti i nodi in un grafo orientato G(V, E) raggiungibili lungo cammini orientati a partire da un nodo assegnato s, chiamato sorgente. 61 62 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO Partire dalla sorgente ed identificare un numero via via crescente di nodi raggiungi- bili dalla sorgente rappresenta un criterio che possiamo seguire per risolvere il problema. Immaginiamo che ad ogni passo intermedio dell’algoritmo i nodi del grafo possono trovarsi in due stati differenti: marcato, se è possibile raggiungerli a partire dalla sorgente, o non marcato se non è stato ancora possibile. È evidente che, se un nodo i è marcato (e quindi raggiungibile dalla sorgente) ed il nodo j non è ancora marcato ed esiste l’arco (i, j), allora possiamo marcare j perché allora questo è raggiungibile da un cammino dalla sorgente attraverso l’arco (i, j). Chiameremo l’arco (i, j) ammissibile se i è marcato e j non è marcato, non ammissibile viceversa. Successivamente, esaminando archi ammissibili, potremo marcare altri nodi e, ogni volta che ci riusciremo, affermeremo che il nodo i è un predecessore di j. L’algoritmo termina quando il grafo non contiene più archi ammissibili. L’algoritmo visita i nodi marcati in un certo ordine di cui possiamo tenere traccia utilizzando un vettore che chiameremo ordine e nel quale l’elemento ordine(i) contiene il passo nel quale i è stato marcato. In Figura 5.1 viene riportato il listato in pseudocodice dell’algoritmo di ricerca. Nell’algoritmo in questione, il vettore LISTA contiene l’insieme dei nodi marcati che devono ancora essere considerati per individuare altri archi dai quali poi marcare altri nodi, mentre il vettore pred(i) definisce un albero di nodi marcati che prende il nome di albero di ricerca. Quando l’algoritmo sarà terminato, tutti i nodi raggiungibili dalla sorgente attraverso un cammino orientato risulteranno marcati. Notare che in talune istanze di grafo l’algoritmo potrebbe terminare senza aver marcato tutti i nodi del grafo stesso, il che equivale a dire che i nodi non marcati sono tutti quelli non raggiungibili dalla sorgente tramite un cammino orientato. Si può dimostrare facilmente che l’algoritmo ha una complessità computazionale di O(m + n) = O(m). Infatti, ad ogni iterazione del ciclo while o viene trovato un arco ammissibile oppure no. Nel primo caso, l’algoritmo marca un nuovo nodo e lo aggiunge a LISTA, mentre nel secondo caso cancella un nodo marcato da LISTA. Pertanto, dato che ogni nodo viene marcato al più una volta, il ciclo while è eseguito 2n volte. Per quanto riguarda la ricerca di archi ammissibili, per ogni nodo i viene percorsa la lista di adiacenza 5.1. ALGORITMI DI RICERCA SU GRAFO 63 algorithm SEARCH; begin poni tutti nodi come non marcati; marca la sorgente s; pred(s) = 0; next:=1; ordine(s) = 1; LISTA := {s}; while LISTA 6= ∅ do begin seleziona un nodo i in LISTA; if il nodo i è incidente ad un arco ammissibile (i, j) then begin marca il nodo j; pred(j) := i; next:= next + 1; ordine(j) := next; aggiungi il nodo j a LISTA; end else cancella il nodo i da LISTA; end end Figura 5.1: L’algoritmo di ricerca su grafo. A(i) al più una volta; quindi l’algoritmo esamina un totale di P i∈V |A(i)| = m archi e quindi termina al più in O(m) volte. Un aspetto centrale dell’algoritmo è la disciplina con la quale selezioniamo i nodi del grafo contenuti nel vettore LISTA per ricercare archi ammissibili e, quindi, marcare nuovi nodi. Per ottenere ciò possiamo fare uso della lista di adiacenza A(i) introdotta nella Sezione 3.1 alla quale aggiungiamo la seguente regola d’ordinamento: gli archi in A(i) sono disposti in ordine crescente rispetto al loro nodo successore, ovvero se (i, j) e (i, k) sono due nodi consecutivi in A(i), allora j < k. Tra tutte le discipline possibili di selezione di una lista, le due più note sono la coda e la pila che definiscono, rispettivamente, le tecniche di ricerca in ampiezza e di ricerca in profondità. 64 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO 2 5 1 6 3 4 Figura 5.2: Grafo per gli Esempi 5.1.1 e 5.1.2. Ricerca in ampiezza Nel caso della ricerca in ampiezza viene implementata una disciplina di tipo first in, first out (FIFO), ovvero ogni nuovo elemento viene inserito in coda e l’estrazione avviene considerando il primo elemento della lista. Se definiamo la distanza di un nodo i dalla sorgente come la somma degli archi di cui è composto il cammino diretto da s a i, questa disciplina porta a marcare prima i nodi a distanza 1, poi quelli a distanza 2 e cosı̀ via. Proprietà 5.1.1 Nella ricerca in ampiezza, l’albero dei cammini dalla sorgente s ad ogni nodo i è composto dai cammini più corti. Esempio 5.1.1 Considerando il grafo di Figura 5.2, si determini l’albero dei cammini utilizzando la disciplina di ricerca in ampiezza. Per risolvere il nostro problema, analizziamo passo per passo l’evoluzione dell’algoritmo. Chiaramente, si devono inizializzare le variabili secondo i valori che seguono: LISTA = {1}, pred(1) = 0; next = 1; ordine(1) = 1. Step 1 Al primo passo, selezionando il nodo 1 preso da LISTA, possiamo marcare il nodo 2; quindi, pred(2) = 1; next = 2; ordine(2) = 2. Dopo l’ultimo aggiornamento, LISTA = {1, 2} (notare che il nodo 2 è stato aggiunto in coda a LISTA). 5.1. ALGORITMI DI RICERCA SU GRAFO 65 Step 2 Al secondo passo, selezionando il nodo 1 preso da LISTA, possiamo marcare il nodo 3; pred(3) = 1; next = 3; ordine(3) = 3. Dopo l’ultimo aggiornamento, LISTA = {1, 2, 3}. Step 3 Al terzo passo, selezionando il nodo 2 preso da LISTA (notare che il nodo 1 è stato cancellato dalla lista in quanto non presentava più archi ammissibbili ed è quindi stato selezionato il primo nodo che era stato immesso, cioè il nodo 2) possiamo marcare il nodo 4; pred(4) = 2; next = 4; ordine(4) = 4. Dopo l’ultimo aggiornamento, LISTA = {2, 3, 4}. Step 4 Al quarto passo, selezionando il nodo 2 preso da LISTA, possiamo marcare il nodo 5; pred(5) = 2; next = 5; ordine(5) = 5. Dopo l’ultimo aggiornamento, LISTA = {2, 3, 4, 5}. Step 5 Al quinto ed ultimo passo, selezionando il nodo 4 preso da LISTA, possiamo marcare il nodo 6; pred(6) = 4; next = 6; ordine(6) = 6. Dopo l’ultimo aggiornamento, LISTA = {4, 5, 6}. A questo punto l’algoritmo non trova più archi ammissibili e si arresta. In Figura 5.3 è disegnato l’albero dei cammini definito dall’algoritmo ed i valori assunti dai due vettori pred(i) e ordine(i). È da notare che l’applicazione della disciplina FIFO ha originato, come ci dovevamo aspettare, un albero con le minime distanze tra la sorgente s ed ogni nodo di G. s Figura 5.3: Albero dei cammini e valori dei vettori pred(i) e ordine(i) nel caso FIFO. 66 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO Ricerca in profondità Nel caso della ricerca in profondità viene implementata una disciplina del tipo last in, first out (LIFO), ovvero ogni elemento nuovo viene inserito in coda e l’estrazione avviene considerando l’ultimo elemento della lista. Utilizzando questa disciplina si crea un cammino orientato il più lungo possibile e quando non si riescono ad individuare altri archi ammissibili, la lista viene scorsa ritornando verso i nodi che erano stati inseriti nei passi precedenti. Proprietà 5.1.2 Se il nodo j è un successore del nodo i e i 6= j, allora ordine(j) > ordine(i). Inoltre, tutti i successori dei nodi nella sequenza sono ordinati consecutivamente. Esempio 5.1.2 Considerando il grafo di Figura 5.2, si determini l’albero dei cammini utilizzando la disciplina di ricerca in profondità. Anche in questo caso analizziamo passo per passo l’evoluzione dell’algoritmo. Chiaramente, si devono inizializzare le variabili secondo i valori che seguono: LISTA = {1}, pred(1) = 0; next = 1; ordine(1) = 1. Step 1 Al primo passo, selezionando il nodo 1 preso da LISTA, possiamo marcare il nodo 2; pred(2) = 1; ordine(2) = 2. Dopo l’ultimo aggiornamento, LISTA = {1, 2}. Step 2 Al secondo passo, selezionando il nodo 2 preso da LISTA, possiamo marcare il nodo 3 (notare che adesso viene selezionato l’ultimo nodo che era entrato in LISTA, appunto il nodo 2); pred(3) = 2; ordine(3) = 3. Dopo l’ultimo aggiornamento, LISTA = {1, 2, 3}. Step 3 Al terzo passo, selezionando il nodo 3 preso da LISTA, possiamo marcare il nodo 4; pred(4) = 3; ordine(4) = 4. Dopo l’ultimo aggiornamento, LISTA = {1, 2, 3, 4}. Step 4 Al quarto passo, selezionando il nodo 4 preso da LISTA, possiamo marcare il nodo 6; pred(6) = 4; ordine(6) = 5. Dopo l’ultimo aggiornamento, LISTA = {1, 2, 3, 4, 6}. 5.2. ALGORITMO DI ORDINAMENTO TOPOLOGICO 67 Step 5 Al quinto ed ultimo passo, selezionando il nodo 2 preso da LISTA, possiamo marcare il nodo 5; pred(5) = 2; ordine(5) = 6. Dopo l’ultimo aggiornamento, LISTA = {1, 2, 5}. A questo punto l’algoritmo non trova più archi ammissibili e si arresta. In Figura 5.3 è tracciato l’albero dei cammini definito dall’algoritmo ed i valori assunti dai due vettori pred(i) e ordine(i). Osservando l’albero dei cammini individuato, possiamo notare come questa volta il cammino dalla sorgente ai nodi sia il più lungo possibile. s Figura 5.4: Albero dei cammini e valori dei vettori pred(i) e ordine(i) nel caso LIFO. 5.2 Algoritmo di ordinamento topologico Il prossimo problema che affronteremo è quello di cercare, se esistono, cicli diretti in un grafo orientato, altrimenti, se il grafo è aciclico, di etichettare i nodi 1, 2, . . . , n in modo tale che per ogni arco (i, j) ∈ E(G), l’etichetta del nodo i sia minore dell’etichetta di j. Questa numerazione, se esiste, prende il nome di ordinamento topologico. Gli algoritmi di ordinamento topologico sono essenziali in molte applicazioni come per esempio nel project management. Per etichettare i nodi di un grafo G con n numeri distinti possiamo usare un vettore ordine in modo che ordine(i) fornisca l’etichetta del nodo i. 68 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO Definizione 5.2.1 Dato un grafo orientato G, diremo che una etichettatura è un ordinamento topologico se ∀(i, j) ∈ E, si ha che ordine(i) < ordine(j). Come si è detto, non tutti i grafi possono essere ordinati topologicamente; basta infatti che il grafo sia ciclico e la condizione ordine(i) < ordine(j) non può essere dimostrata vera per ogni arco del grafo. Analogamente, un grafo che non contiene cicli può essere ordinato topologicamente. Questo ci porta a dire che un grafo orientato è aciclico se e soltanto se ammette un ordinamento topologico. algorithm ORDINAMENTO TOPOLOGICO; begin for tutti i nodi i ∈ V do indegree(i):=0; for tutti gli archi (i, j) ∈ E do indegree(j):=indegree(j) + 1; LISTA= 0; next:=0; for tutti i nodi i ∈ V do if indegree(i) = 0 then LISTA = LISTA∪{i}; while LISTA 6= ∅ do begin seleziona un nodo i in LISTA e rimuovilo; next:= next + 1; ordine(i) := next; for tutti gli archi (i, j) ∈ E do begin indegree(j):= indegree(j) − 1; if indegree(j) = 0 then LISTA = LISTA∪{j}; end end if next < n then il grafo contiene un ciclo diretto; else il grafo è aciclico ed il vettore ordine contiene il suo ordinamento topologico; end Figura 5.5: L’algoritmo di ordinamento topologico. Vediamo ora una implementazione dell’algoritmo di ordinamento topologico, di cui forniamo lo pseudocodice in Figura 5.5. La variabile indegree(i) considera il numero di archi entranti nel nodo i, che viene chiamato anche grado entrante di un nodo. In analogia, 5.2. ALGORITMO DI ORDINAMENTO TOPOLOGICO 69 2 1 5 7 6 8 3 4 Figura 5.6: Grafo per l’Esempio 5.2.1 si può contare anche il numero di archi uscenti da un nodo che prende il nome di grado uscente. La complessità computazionale di questo algoritmo è O(m). Infatti, per prima cosa, vengono conteggiati i gradi in ingresso di tutti i nodi, formando una lista che comprende tutti i nodi con grado 0. Ad ogni iterazione selezioniamo un nodo i da LISTA, per ogni arco (i, j) ∈ A(i) riduciamo il grado in ingresso di 1 del nodo j e se il grado in ingresso del nodo j diviene 0 lo aggiungiamo a LISTA. Dato che l’algoritmo esamina ogni nodo ed ogni arco del grafo O(1) volte, allora la sua complessità totale e O(m). Esempio 5.2.1 Dato il grafo in Figura 5.6, individuare, se esiste, un ordinamento topologico. L’algoritmo comincia inserendo al primo passo il nodo 1 nella LISTA e quindi ordine(1) = 1. Al successivo passo, in LISTA ci sono i nodi 3 e 4, scelgo il primo e aggiorno il vettore ordine(3) = 2. Continuando, in LISTA ci sono i nodi 2 e 4, scelgo ancora il primo ed ho ordine(2) = 3. Continuando in questo modo, ottengo la sequenza ordine(4) = 4, ordine(6) = 5, ordine(5) = 6, ordine(7) = 7 ed infine ordine(8) = 8. In Figura 5.7 è rappresentato il vettore ordine(i). Si può notare che tale ordinamento trovato non è unico. Infatti, in alcuni passi si possono presentare più di un nodo con grado in ingresso nullo e la scelta di uno e di un altro non pregiudica la soluzione perché la relazione ordine(i) < ordine(j) rimane valida, ∀(i, j) ∈ E. 70 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO Figura 5.7: Contenuto del vettore ordine(i) dopo l’ordinamento topologico. Infine, siccome l’algoritmo termina con una etichettatura ammissibile per ogni nodo, si può affermare che il grafo assegnato è aciclico. È importante ribadire che nel caso dell’ordinamento topologico la selezione dell’elemento dalla lista può essere effettuato arbitrariamente. Questo porta a determinare differenti etichettature, cosa del tutto lecita visto che il nostro obiettivo era quello di rispettare una semplice condizione, ovvero che ∀(i, j) ∈ E, si abbia che ordine(i) < ordine(j). 5.3 Algoritmi di ricerca di alberi ricoprenti minimi Nella Sezione 3.4 abbiamo dato la definizione di albero ricoprente, definendolo come l’albero che comprende tutti i nodi di un dato grafo connesso G. Chiaramente un albero ricoprente non è, in generale, unico, ma dipende dalla scelta degli archi dell’insieme E. In questa sezione vogliamo considerare il Problema del minimo albero ricoprente. Supponiamo sia assegnato un grafo connesso non orientato G = (V, E) e che ad ogni arco (i, j) ∈ E sia assegnato un costo cij , dove cij è un numero intero. Si vuole trovare un albero ricoprente T chiamato minimo albero ricoprente (in inglese: minimum spanning tree, MST) tale che il costo totale, dato dalla somma dei singoli costi degli archi che lo costituiscono, sia minimo. Il problema del minimo albero ricoprente ha una importanza rilevante dato il vasto campo di applicazioni che vanno dal disegno di sistemi fisici, alla cluster analysis fino ai metodi di riduzione della memorizzazione dei dati. 5.3. ALGORITMI DI RICERCA DI ALBERI RICOPRENTI MINIMI 71 Condizioni di ottimalità Per risolvere il problema forniremo due condizioni di ottimalità che ci porteranno a presentare due algoritmi in grado di fornirci la soluzione ottima. Chiaramente le due condizioni sono equivalenti, cosı̀ come i due algoritmi forniscono la stessa soluzione sullo stesso grafo. Prima di definire tali condizioni esponiamo alcune osservazioni preliminari: Definizione 5.3.1 Dato un grafo G = (V ; E), si definisce taglio di G una sua partizione in due insiemi, S ed S = V − S. Ogni taglio definisce un insieme di archi che hanno un estremo in S e l’altro in S. Indicheremo il taglio con la notazione [S, S]. Nell’esposizione successiva faremo spesso riferimento alle due osservazioni seguenti: 1. Per ogni arco (p, q) che non appartiene all’albero ricoprente T , allora T contiene un unico path da p a q. L’arco (p, q) assieme al path definisce un ciclo. 2. Se si cancella un arco (i, j) ∈ T , il grafo risultante partiziona l’insieme V in due sottoinsiemi. Gli archi del grafo G sottostante i cui estremi ricadono uno in un sottoinsieme e uno nell’altro definiscono un taglio. S S T∗ i p j q Figura 5.8: Tagli e path per le condizioni di ottimalità. In Figura 5.8 sono illustrate le due osservazioni che abbiamo esposto, ovvero l’arco (p, q) genera con il path pq un ciclo, mentre la rimozione dell’arco (i, j) genera il taglio [S, S]. Risulterà molto utile al lettore fare riferimento a tale figura per le dimostrazioni che seguono. 72 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO Enunciamo la prima condizione di ottimalità, che prende il nome di condizione di ottimalità sul taglio: Teorema 5.3.1 Un albero ricoprente è un minimo albero ricoprente T ? se e soltanto se è soddisfatta la seguente condizione di ottimalità sul taglio: per ogni arco (i, j) ∈ T ∗ allora cij ≤ cpq , per ogni arco (p, q) contenuto nel taglio formato dalla rimozione dell’arco (i, j) da T ∗ . Dimostrazione: Per dimostrare che ogni albero minimo ricoprente T ? deve soddisfare la condizione di ottimalità del taglio basta notare che, se esistesse un arco (p, q) ∈ [S, S] con cpq < cij , basterebbe sostituire tale arco all’arco (i, j) per ottenere un nuovo albero T ◦ con un costo minore di T ? , contraddicendo la sua ottimalità. Per dimostrare la sufficienza, dobbiamo far vedere che ogni albero T ? che soddisfa la condizione di ottimalità deve essere ottimo. Supponiamo allora che T ◦ 6= T ? sia un minimo albero ricoprente. T ? contiene almeno un arco (i, j), che non appartiene a T ◦ che, se cancellato, crea un taglio [S, S]. Se ora aggiungiamo tale arco a T ◦ creiamo un ciclo W che conterrà un arco (p, q) ∈ [S, S]. Dato che T ? soddisfa la condizione di ottimalità, allora avremo che cij ≤ cpq , ma contemporaneamente T ◦ è un albero minimo ricoprente, quindi cpq ≤ cij ; di conseguenza deve essere che cij = cpq . Chiaramente, sostituire l’arco (p, q) all’arco (i, j) in T ? non comporta alcun aumento del costo di tale albero, ma otteniamo un nuovo T ? che ha un ulteriore arco in comune con T ◦ . Ripetendo tale procedura per tutti i suoi archi, otterremo un nuovo T ? che coincide con il minimo albero ricoprente T ◦ , ovvero anche T ? è un minimo albero ricoprente. La condizione di ottimalità sul taglio implica che ogni arco di un minimo albero ricoprente ha il valore del costo più piccolo tra tutti i costi degli archi che appartengono al taglio generato dalla sua rimozione. Questo implica che noi possiamo includere nell’albero ricoprente minimo qualunque arco a costo minimo appartente a qualunque taglio. Per dimostrare questo ci è utile la seguente proposizione: Proposizione 5.3.2 Sia F un sottoinsieme di archi appartenenti ad un albero ricoprente minimo e sia S un insieme di nodi di certe componenti di F . Si supponga che (i, j) sia un 5.3. ALGORITMI DI RICERCA DI ALBERI RICOPRENTI MINIMI 73 arco a costo minimo nel taglio [S, S]. Allora un minimo albero ricoprente contiene tutti gli archi di F e l’arco (i, j). Dimostrazione: Supponiamo che F ⊆ T ? . Se (i, j) ∈ T ? allora la proposizione è banalmente vera. Se (i, j) ∈ / T ? , aggiungendolo a T ? si crea un ciclo W che contiene almeno un arco (p, q) 6= (i, j), con (p, q) ∈ [S, S]. Per assunzione, cij ≤ cpq , ma T ? soddisfa la condizione di ottimalità e quindi cij ≥ cpq , quindi cij = cpq . Di conseguenza, aggiungere l’arco (i, j) e rimuovere l’arco (p, q) produce un albero minimo ricoprente che contiene tutti gli archi di F e l’arco (i, j). Passiamo ora a dimostrare la seconda condizione di ottimalità che chiameremo condizione di ottimalità sul path: Teorema 5.3.3 Un albero ricoprente è un minimo albero ricoprente T ? se e soltanto se è soddisfatta la seguente condizione di ottimalità sul path: per ogni arco (p, q) ∈ G che non appartiene a T ? si ha che cij ≤ cpq , ∀(i, j) contenuto nel path in T ? che connette i nodi p e q. Dimostrazione: Per dimostrare la necessità, supponiamo che T ? sia un albero minimo ricoprente e che (i, j) sia un arco contenuto nel path tra i nodi p e q. Se cij > cpq , allora la sostituzione di tale arco in T ? al posto dell’arco (i, j) creerebbe un nuovo albero T ◦ con un costo minore di T ? , contraddicendo la sua ottimalità. Per dimostrare che se T ? è ottimo allora deve verificare la condizione di ottimalità sul path, supponiamo che (i, j) sia un arco in T ? e siano S e S i due insiemi di nodi connessi generati dalla cancellazione dell’arco (i, j) da T ? , con i ∈ S e j ∈ S. Consideriamo ora un arco (p, q) ∈ [S, S]. Dato che T ? contiene un unico path tra p e q e dato che (i, j) è l’unico arco tra S ed S, allora (i, j) deve appartenere a tale path. La condizione di ottimalità implica che cij ≤ cpq e dato che tale condizione deve essere vera per ogni arco (p, q) ∈ / T? nel taglio [S, S] generato dalla cancellazione dell’arco (i, j), allora T ? soddisfa la condizione di ottimalità sul taglio e quindi deve essere ottimo. Si può notare come nella dimostrazione di quest’ultimo teorema si sia usata quella della sufficienza del Teorema 5.3.1. Questo evidenzia come le due condizioni siano completamen- 74 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO te equivalenti e che quindi un minimo albero ricoprente T ? che soddisfa la condizione di ottimalità sul taglio deve contemporaneamente soddisfare anche la condizione di ottimalità sul path. Un’altra osservazione è che la condizione di ottimalità sul taglio è una condizione che potremmo indicare come una caratteristica esterna del minimo albero ricoprente, nel senso che coglie una relazione tra un suo arco e gli altri archi fuori dall’albero; viceversa, la condizione di ottimalità sul path coglie una caratterizzazione interna del minimo albero ricoprente che considera la relazione tra un singolo arco, che non appartiene all’albero, ed i diversi archi che appartengono al path, nell’albero, che si forma aggiungendo tale arco. 5.3.1 Algoritmo di Kruskal La condizione di ottimalità sul path suggerisce immediatamente un algoritmo per la ricerca del minimo albero ricoprente su un grafo. Infatti, si può partire con un generico albero ricoprente T e si testa la condizione di ottimalità sul path; se T soddisfa la condizione per ogni arco allora è un albero ottimo, altrimenti ci sarà un qualche arco (p, q) che chiude l’unico path tra p e q in T con cij > cpq . Sara sufficiente sostituire l’arco (p, q) all’arco (i, j) per ottenere un albero di costo più basso; quindi, ripetendo la procedura, in un numero finito di iterazioni avremo l’albero ottimo. La semplicità di questo algoritmo ha però una complessità computazionale che non può essere limitata in un numero polinomiale di iterazioni. Per ottenere un algoritmo più efficiente, noto con il nome di Algoritmo di Kruskal , si può iniziare considerando un ordinamento non decrescente degli archi secondo il loro peso e, quindi, una procedura che a partire da un albero vuoto, rispettando l’ordinamento posto, aggiunga archi uno alla volta. L’algoritmo può essere descritto nel seguente modo: Algoritmo di Kruskal Step 1 - Ordina tutti gli archi del grafo G per valori non decrescenti del costo degli archi; Step 2 - Prendi una foresta ricoprente H = (V (H), F ) di G, con all’inizio F = ∅; 5.3. ALGORITMI DI RICERCA DI ALBERI RICOPRENTI MINIMI 75 Step i - Aggiungi ad F un arco (i, j) ∈ / F a minimo costo, tale che H rimanga una foresta. Stop quando H è un albero ricoprente. L’algoritmo esegue la procedura corretta perché, ad ogni passo, vengono scartati archi che formerebbero un ciclo con quelli che invece appartengono alla foresta corrente. Inoltre, si può notare che gli archi che formerebbero il ciclo hanno un costo che è maggiore o al più uguale al costo degli altri archi, appartenenti alla foresta, nel ciclo; questo perché i costi sono ordinati in ordine non decrescente. Pertanto, l’albero trovato soddisfa la condizione di ottimalità sul path e quindi è ottimo. Esempio 5.3.1 Dato il grafo G = (V, E) riportato in Figura 5.9 ed assegnati i costi cij , ∀(i, j) ∈ E (trascritti accanto ad ogni arco) trovare l’albero ricoprente ottimo mediante l’algoritmo di Kruskal e fornire il suo valore ottimo z ? . 1 c12 = 5 2 18 15 10 12 5 16 8 3 4 Figura 5.9: Grafo per l’Esempio 5.3.1. Il primo passo è quello di ordinare gli archi in ordine non decrescente, attraverso il quale otteniamo la lista {(1, 2), (3, 4), (2, 3), (2, 4), (1, 3), (4, 5), (2, 5)}. Il passo successivo (Step 1 in Figura 5.10) parte dalla foresta F = ∅ e poi seleziona l’arco (1, 2) che è quello a costo minimo e lo aggiunge ad F . Al passo successivo (Step 2 in Figura 5.10) viene selezionato (3, 4) e dopo aver controllato che non crei un ciclo, è aggiunto ad F . L’algoritmo procede selezionando l’arco (2, 3) (Step 3 in Figura 5.10) e, dopo aver controllato che non si crei un ciclo, lo aggiunge ad F. Il test sul ciclo fallisce nei due passi successivi (Step 4 e 5 in Figura 5.10) quando selezionando prima l’arco (2, 4) e poi l’arco (1, 3) ci si accorge che 76 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO generano un ciclo. L’ultimo passo (Step 6 in Figura 5.10) seleziona l’arco (4, 5) e con quest’ultimo inserimento la foresta F diventa l’albero ricoprente ottimo T ? , avente costo z ? = 39. Step 1 Step 2 Step 3 Step 4 Step 5 Step 6 Figura 5.10: Evoluzione dell’algoritmo di Kruskal sul grafo di Figura 5.9. Per determinare la complessità computazionale dell’algoritmo1 occorre osservare che, per individuare un ciclo, si può, tra l’altro, creare ad ogni passo delle liste di nodi in numero pari alle componenti della foresta ricoprente corrente (nell’esempio precedente al passo 3 avremmo le due liste {1, 2, 3, 4} e {5}) e poi, per ogni arco (i, j) che si vuole introdurre, controllare se i nodi i e j appartengono ad una stessa lista. Se il test ha esito positivo, l’arco crea un ciclo (come l’arco (2, 4) al passo 4 dell’esempio) e viene scartato, altrimenti può essere aggiunto alla foresta corrente (come l’arco (4, 5) all’ultimo passo dell’esempio). Per eseguire questi passi occorre effettuare O(n) iterazioni per ogni arco del grafo e, quindi, la complessità computazionale dell’algoritmo di Kruskal è O(nm). 5.3.2 Algoritmo di Prim Il secondo algoritmo per la ricerca del minimo albero ricoprente segue dalla applicazione della condizione di ottimalità sul taglio. L’algoritmo, noto con il nome di Algoritmo di Prim, costruisce un albero ricoprente a partire da un nodo ed aggiunge un arco per 1 Il calcolo è eseguito a meno dell’ordinamento dei costi degli archi che, in un grafo di ordine n, dimensione m e costi di valore arbitrario, è uguale a O(m log m) = O(m log n2 ) = O(m log n). 5.3. ALGORITMI DI RICERCA DI ALBERI RICOPRENTI MINIMI 77 volta. L’arco viene selezionato tra quelli a costo minimo appartenenti al taglio [S, S] generato dall’inserimento in S dei nodi estremi degli archi aggiunti all’albero nel passo precedente. L’algoritmo termina quando S = V . La correttezza dell’algoritmo segue dalla Proposizione 5.3.2 dove è stato dimostrato che ogni arco che aggiungiamo ad un albero è contenuto in un certo albero ricoprente assieme agli archi che sono stati selezionati nei passi successivi. La descrizione dell’algoritmo è la seguente: Algoritmo di Prim Step 1 - Considerare un albero H = (V (H), T ), con inizialmente V (H) = {r} e T = ∅, con r ∈ V (G); Step i - Ad ogni passo, aggiungi all’albero connesso H un arco a minimo costo (i, j) ∈ / H scegliendolo tra quelli che a minimo costo aggiungono un nuovo nodo e mantengono l’albero connesso. Stop se H è il minimo albero ricoprente. In pratica, l’algoritmo evolve attraverso la selezione di archi a minimo costo che abbiano gli estremi uno nell’insieme dei nodi degli archi già selezionati (cioè in S) e l’altro nell’insieme dei nodi degli archi che non sono stati ancora selezionati (cioè in S). Quindi, l’algoritmo mantiene un albero connesso che cresce di un arco ad ogni passo e che, all’ultimo, coincide con l’albero ottimo. Osservando l’algoritmo di Kruskal si nota invece che l’algoritmo aggiunge, ad ogni passo, archi ad una foresta che diventa connessa solo alla fine. Per analizzare la complessità computazionale dell’algoritmo, basta notare che l’algoritmo stesso esegue n − 1 iterazioni per definire gli n − 1 archi dell’albero ed in ogni iterazione viene selezionato da un taglio un arco a costo minimo. Dato che tale selezione può essere eseguita anche sull’intera lista degli archi, ne segue che la complessità dell’algoritmo di Prim è O(nm). Esempio 5.3.2 Considerando il grafo di Figura 5.9, trovare l’albero ricoprente ottimo mediante l’algoritmo di Prim e fornire il suo valore ottimo z ? . 78 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO Supponiamo che l’algoritmo parta da V (H) = {1}. In questo caso, l’algoritmo può scegliere tra gli archi del taglio [S, S] = [{1}, {2, 3, 4, 5}]; l’arco a costo minimo selezionato e che mantiene l’albero connesso è (1, 2) (vedi Step 1 in Figura 5.11, dove la linea tratteggiata indica il taglio); aggiungo l’arco ad H (indicato in figura dagli archi in grassetto) ed il nodo 2 ad S. Al passo successivo (Step 2 in Figura 5.11) l’algoritmo deve scegliere gli archi a costo minimo tra quelli del taglio [{1, 2}, {3, 4, 5}] e, tra tutti, viene scelto l’arco (2, 3). L’algoritmo continua scegliendo (3, 4) in [{1, 2, 3}, {4, 5}] (Step 3 in Figura 5.11) ed infine termina aggiungendo l’arco (4, 5) dal taglio [(1, 2, 3, 4}, {5}] (Step 4 in Figura 5.11), restituendoci l’albero ottimo di costo minimo, pari a z ? = 39. Come si può notare, l’algoritmo termina in n − 1 = 4 passi, avendo ad ogni passo aggiunto un arco ad un albero connesso. Step 1 Step 2 Step 3 Step 4 Figura 5.11: Evoluzione dell’algoritmo di Prim sul grafo di Figura 5.9. Osservazione. Si vuole ribadire che negli esempi che abbiamo esposto, cosı̀ come ci dovevamo aspettare, gli algoritmi forniscono lo stesso valore ottimo e, inoltre, i due alberi ottenuti sono identici. Quest’ultima affermazione non nasconde però una proprietà di unicità sugli archi che compongono l’albero ricoprente ottimo, perché, in generale, si possono ottenere alberi a costo minimo composti da archi differenti. Questo non ci deve sorprendere poiché gli algoritmi si possono trovare nella condizione di poter selezionare archi nella lista (per Kruskal) o nel taglio (per Prim) che abbiano stesso costo. Dato che selezionare un arco piuttosto che un altro è solo funzione delle condizioni di ottimalità, possiamo ottenere alberi minimi ottimi composti da archi differenti. 5.4. ESERCIZI 5.4 79 Esercizi Da inserire. Es. 5.4.1 Data le matrice di adiacenza dell’Esercizio 3.7.2, disegnare il grafo corrispondente e individuare su questo, se esiste, un ordinamento topologico. In caso negativo, motivare perché esso non esiste e quindi rimuovere il minimo numero di archi in modo che sia possibile trovare un ordinamento topologico. 80 CAPITOLO 5. ALGORITMI DI RICERCA SU GRAFO