IL PROBLEMA DELLO SHORTEST SPANNING TREE n. 1 - Formulazione del problema Consideriamo il seguente problema: Abbiamo un certo numero di città a cui deve essere fornito un servizio, quale può essere l energia elettrica, il gas, l acqua, la fognatura, ecc... ; potenzialmente queste città possono essere collegate tra di loro mediante un elettrodotto, o un gasdotto, acquedotto, ecc.; alcuni di questi collegamenti diretti sono resi impossibili da ostacoli fisici insormontabili o difficilmente sormontabili, quali colline, laghi, ecc.; i collegamenti possibili consentono comunque di collegare tra loro a due a due le diverse cittá, passando per le altre. Si deve decidere quali collegamenti realizzare effettivamente, in maniera da servire tutte le cittá con il minimo costo. Possiamo formalizzare il problema pensando le cittá come nodi di un grafo, e i collegamenti potenziali tra due cittá come archi del grafo. Il grafo é pesato, poiché ad ogni arco puó essere associato il costo di costruzione del collegamento, qualora il collegamento venisse realmente realizzato; inoltre il grafo é non orientato, poiché il costo di costruzione di un qualunque arco (a, b) coincide con il costo dell arco (b, a). Dal momento che i collegamenti devono essere realizzati al minimo costo, non ha senso prevedere di costruire archi che collegano una cittá con se stessa e quindi possiamo supporre che il grafo sia senza lacci. Inoltre il grafo é connesso, poiché si é detto che due cittá sono sempre collegabili mediante un cammino che passa per le altre. I collegamenti che si vanno realmente a realizzare possono essere interpretati come gli archi di un sottografo G = (N, A ) di G; tale sottografo deve essere tale che: (1) due cittá sono sempre raggiungibili una dall altra, e quindi G deve essere connesso, (2) il grafo non deve contenere cicli, altrimenti ci sarebbero degli archi superflui, che comporterebbero un costo aggiuntivo evitabile, (3) tutte le cittá devono essere servite, e quindi deve essere N = N. Pertanto G deve essere un albero tale che N = N; un tale sottografo dicesi spanning tree, cioé albero invadente o ricoprente il grafo G. Naturalmente tra tutti gli alberi invadenti occorre scegliere quello di costo minimo. Pertanto il problema puó essere cosí formalizzato: Dato un grafo non orientato connesso e senza lacci G = (N, A), costruire un sottografo G = (N, A ) che sia un albero invadente e che abbia lunghezza minima tra tutti gli alberi invadenti. n. 2 - Algoritmo di PRIM Il problema testé formulato puó essere risolto in maniera iterativa: ad ogni iterazione si dispone di un sottografo G = (N, A ) che sia un albero, e, (finché N N), si aggiunge un arco ad A, (e i relativi nodi ad N ), in modo che il nuovo sottografo sia ancora un albero; ovviamente la scelta dell arco da aggiungere deve essere funzionale all obiettivo di costruire un albero di lunghezza minima. Ebbene, per individuare l arco (a, b) da aggiungere ad A, osserviamo che: (1) se ad A aggiungiamo un arco (a, b), tale che entrambi gli estremi a e b appartengono ad N, allora il nuovo sottografo contiene dei cicli; (infatti, il cammino unione dell arco (a, b) con il cammino congiungente b con a in G é un ciclo); (2) se ad A aggiungiamo un arco (a, b), tale che entrambi gli estremi a e b non appartengono ad N, allora il nuovo sottografo non é connesso, poiché a e b non sono congiungibili con i nodi di N. 1
2 Pertanto, se vogliamo che il nuovo sottografo sia ancora un albero, dobbiamo aggiungere ad A un arco che congiunge un nodo a N con un nodo b N N. In effetti, si vede facilmente che, se a N e b / N, allora il sottografo G = (N, A ), con N = N {b}, A = A {(a, b)}, é ancora connesso e senza cicli, e quindi é ancora un albero. Infatti, G é connesso poiché: - due nodi distinti di N sono tra loro congiungibili in G e quindi in G, - il nodo b é congiungibile con a mediante l arco (a, b), ed é congiungibile con qualunque a N, a a mediante il cammino unione del cammino congiungente a ad a con l arco (a, b). Inoltre, G non ha cicli; infatti se esistesse un ciclo in G, tale ciclo dovrebbe passare per il nodo b, (altrimenti sarebbe un ciclo in G, contro l ipotesi che G non ha cicli). Ne seguirebbe che il ciclo dovrebbe contenere l arco (a, b) ed un ulteriore arco (b, a ) A, con a a: ma allora ne seguirebbe che b N, mentre b era stato scelto in N N. D altra parte, dovendo cercare l albero invadente di lunghezza minima, sembra ragionevole scegliere, (tra gli archi candidati ad essere aggiunti ad A ), quello di lunghezza minima. Siamo dunque in grado di formulare il seguente Algoritmo di PRIM (0) - (Inizializzazione) - Si sceglie arbitrariamente un nodo a N e si pone N = {a}, A =. (1) - (Fase iterativa) - Finché N N, si cercano ā N, b N N tali che (ā, b) A e l(ā, b) = min {l(a, b) : a N, b N N, (a, b) A}. Si aggiunge b ad N ed (ā, b) ad A. Dopo n 1 iterazioni l algoritmo si interrompe e fornisce un sottografo G che é un albero invadente di lunghezza minima. Infatti é evidente che il sottografo iniziale G = (N, A ), con A =, é un albero; conseguentemente, il sottografo trovato dopo la prima iterazione é un albero. Ma allora anche quello trovato dopo la seconda iterazione é un albero, e in generale il sottografo trovato dopo ogni iterazione é un albero. Pertanto, il sottografo G = (N, A ) trovato dopo n 1 iterazioni é un albero; d altra parte tale albero é invadente, perché, dopo n 1 iterazioni, l insieme N avrá n elementi e quindi coincide con N. E inoltre intuitivo che, avendo ogni volta selezionato l arco piú breve tra quelli candidati, tale albero invadente sia quello di lunghezza minima. I dettagli della dimostrazione della ottimalitá dell albero prodotto dall algoritmo di Prim sono contenuti nella Appendice. Osservazione 2.1 - L algoritmo di Prim puó essere formalizzato in maniera piú dettagliata, utilizzando un contatore k dei nodi giá serviti, una variabile reale MIN che serve per calcolare la minima lunghezza degli archi congiungenti un nodo a N con un nodo b / N, una variab ile NEXT che serve per individuare il prossimo nodo da servire, e due vettori n-dimensionali, LABEL e P RED, per memorizzare i nodi e gli archi giá selezionati, nel senso che per ogni j = 1, 2,..., n risulta: { 1 se aj N, LABEL(j) = 0 altrimenti, j se a j é il nodo iniziale P RED(j) = i > 0 se a j N ed (a i, a j ) A,. 0 se a j / N L algoritmo di PRIM assume allora la forma:
3 Algoritmo di PRIM (0) - (Inizializzazione) - Si sceglie arbitrariamente un nodo, ad esempio a 1 N, e si pone k = 1, LABEL(1) = P RED(1) = 1, LABEL(j) = P RED(j) = 0 per ogni j = 2, 3,..., n. (1) - (Fase iterativa) - Si pone M IN = M, (dove M é un numero molto grande rispetto alle lunghezze degli archi del grafo), e si esegue il ciclo: Per i = 1, 2,..., n e per j = 1, 2,..., n esegui: se LABEL(i) = 1 e LABEL(j) = 0 ed l(a i, a j ) < M, allora poni: MIN = l(a i, a j ), NEXT = j, PRED (NEXT)=i. Alla fine del ciclo si pone k = k + 1, LABEL(NEXT)=1. (2) -(Test di arresto) - Se k = n STOP, altrimenti si torna al Passo 1. Osservazione 2.2 - Alla fine della esecuzione dell algoritmo di Prim, dal vettore P RED si ricostruisce l albero invadente di lunghezza minima: esso é formato dagli n 1 archi (P RED(2), 2), (P RED(3), 3),..., (P RED(n), n). La lunghezza di tale albero é data ovviamente dalla somma delle lunghezze di tali archi. n. 3 - L algoritmo di PRIM-DIJKSTRA L algoritmo di Prim descritto sopra effettua ad ogni iterazione n 2 confronti, prima di individuare il prossimo nodo da servire e il relativo arco da aggiungere. L algoritmo giunge quindi alla soluzione ottima con (n 1) n 2 operazioni, e non é molto efficiente perché non cerca di tenere conto delle informazioni via via acquisite. Un approccio piú efficiente si otterrebbe se ad ogni iterazione noi ricercassimo per ogni nodo a j non ancora scelto l arco piú breve che congiunge a j con i nodi giá scelti, e memorizzassimo, per un successivo utilizzo, la lunghezza LMIN(j) di tale arco e l arco stesso (P RED(j), j). E chiaro, infatti, che la scelta del prossimo arco da aggiungere si restringe a tali archi, e per trovare il prossimo nodo da servire sará sufficiente cercare il nodo non servito per cui si ha il min {LMIN(j) : a j N }. D altra parte, se abbiamo trovato che il nodo da aggiungere é a h ed LMIN(j) rappresenta la lunghezza dell arco piú breve che congiunge a j con i nodi serviti prima dell aggiunta di a h, per trovare l arco piú breve che congiunge a j ai nodi scelti dopo l aggiunta di a h basterá confrontare LMIN(j) con la lunghezza l(h, j) dell arco (a h, a j ). E chiaro, infatti, che se LMIN(j) l(h, h), allora l arco piú breve trovato prima é rimasto competitivo anche dopo l aggiunta di a h ; se peró risulta l(h, j) < LMIN(j), allora l arco piú breve dopo l aggiunta di a h é diventato l arco (a h, a j ), ed occorre quindi sostituire LMIN(j) e P RED(j) trovati prima, rispettivamente, con l(h, j) e h. Siamo cosí in grado di formulare la seguente variante dell algoritmo di Prim che va sotto il nome di Algoritmo di Prim - Dijkstra. Consideriamo dunque i vettori LABEL e P RED per memorizzare i nodi e gli archi serviti, il vettore LMIN, la cui generica componente j-esima rappresenta, per ogni nodo a j non servito, la lunghezza dell arco piú breve che congiunge a j ai nodi giá serviti. Consideriamo inoltre il contatore k dei nodi serviti, le variabili NEXT e LAST che rappresentano il prossimo nodo da servire e l ultimo nodo servito, e la variabile M IN necessaria per calcolare la lunghezza dell arco da aggiungere ad ogni iterazione. Si ha allora il seguente
4 Algoritmo di PRIM - DIJKSTRA (0) - (Inizializzazione) - Si sceglie arbitrariamente un nodo, ad esempio a 1 N, e si inizializzano i vettori LABEL, P RED, e LM IN, ponendo LABEL(1) = 1, P RED(1) = 1, LMIN(1) = 0, LABEL(j) = 0, P RED(j) = 1, LMIN(j) = l(1, j) per ogni j = 2, 3,..., n. Si inizializza il contatore k ponendo k = 1 e si pone LAST = 1. (1) - (Fase iterativa) - Si pone M IN = M, (dove M é un numero molto grande rispetto alle lunghezze degli archi del grafo), e si esegue il ciclo: Per j = 1, 2,..., n esegui: se LABEL(j) = 0 ed LMIN(j) < M, allora si pone MIN = LMIN(j), NEXT = j. Alla fine del ciclo si pone LABEL(NEXT ) = 1, k = k + 1. (2) - (Test di arresto) - Se k = n STOP, altrimenti si va al Passo 3. (3) - (Aggiornamento) - Si pone LAST = NEXT e si esegue il ciclo: per ogni j = 2, 3,..., n esegui: se LABEL(j) = 0 e risulta LMIN(j) > l(last, j), allora poni: LMIN(j) = l(last, j) e P RED(j) = LAST. Con i vettori LMIN e P RED cosí modificati si torna al Passo 1. Osservazione 3.1 - L algoritmo di Prim-Dijkstra effettua, in ciascuna delle n 1 iterazioni, al massimo n confronti per calcolare MIN e quindi NEXT, ed al massimo n confronti per aggiornare i vettori LM IN e P RED, e dunque complessivamente al massimo 2n(n 1) operazioni, e quindi ha una complessitá computazionale di ordine n 2, mentre l algoritmo di Prim aveva una complessitá computazionale di ordine n 3. Alla fine dell esecuzione dell algoritmo si ricostruisce l albero invadente dal vettore P RED, mentre dal vettore LM IN si ottiene direttamente la lunghezza dello shortest spanning tree. Infatti, se in una generica iterazione si seleziona il nodo a j, allora LMIN(j) rappresenterá la lunghezza dell arco scelto in quella iterazione; di conseguenza la lunghezza dell intero albero invadente sará dato da j LMIN(j). n. 4 - Algoritmo di Kruskal L algoritmo di Prim costruisce l albero invadente di lunghezza minima in maniera iterativa, aggiungendo ad ogni iterazione al sottografo G = (N, A ) l arco piú breve che aggiunto ai precedenti conserva al sottografo G la caratteristica di essere un albero, cioé un sottografo connesso e senza cicli. E possibile seguire peró un altro approccio: ad ogni iterazione si possiede un sottografo G che puó essere sconnesso, ma deve essere senza cicli, e si aggiunge l arco piú breve che non forma cicli con quelli giá scelti. Di conseguenza gli estremi dell arco (a, b) da aggiungere devono appartenere a due diverse componenti connesse del grafo G ; aggiungendo l arco (a, b) ad A, le due componenti connesse di G a cui appartenevano a e b verranno a formare un unica componente connessa. Pertanto ad ogni iterazione si costruisce un sottografo in cui il numero delle componenti connesse diminuisce di una unitá. Ebbene, si parte con il sottografo (N, ), in cui non c é nessun arco, e quindi nessun nodo puó essere raggiungibile da un altro nodo, ed in cui quindi ci sono n componenti connesse, quanti sono i nodi, e ad ogni iterazione si aggiunge un arco che produce una riduzione di una unitá del numero delle componenti connesse. Dopo n 1 iterazioni avremo costruito un sottografo G = (N, A ) senza cicli, in cui sono rimaste n (n 1) componenti connesse, cioé in cui é rimasta un unica componente connessa. Pertanto il sottografo trovato dopo n 1 iterazioni é connesso, senza cicli e serve tutti i nodi, cioé é un albero invadente.
Infine avendo avuto cura di aggiungere sempre l arco piú breve tra quelli disponibili, il sottografo finale é un albero invadente di lunghezza minima. Il procedimento descritto é la sostanza del procedimento di ricerca dello shortest spanning tree, noto come algoritmo di KRUSKAL Per illustrarne i dettagli supponiamo che il grafo sia stato memorizzato mediante i vettori F ROM, T O e LEN GT H, ordinati in ordine crescente di lunghezza. Consideriamo a) un vettore m-dimensionale LABEL per memorizzare gli archi accettati: LABEL(j) = 1 se l arco j-esimo é stato giá scelto, altrimenti LABEL(j) = 0, b) un vettore n dimensionale COMP, caratterizzato dal fatto che COMP (j) = i se a j appartiene alla stessa componente connessa del nodo a i, c) il contatore k degli archi giá accettati, d) una variabile intera ARCO che rappresenta l indice dell arco sotto esame, da accettare o da scartare. Si ha allora il seguente: 5 ALGORITMO DI KRUSKAL (0) - (inizializzazione) Si pone k = 0, ARCO = 1 e si inizializzano i vettori LABEL e COMP ponendo LABEL(j) = 0 per ogni j = 1, 2,..., m e COMP (i) = i per ogni i = 1, 2,... n. (1) - (Fase iterativa) - Si esegue il ciclo: finché COMP(FROM(ARCO)) = COMP(TO(ARCO)), si pone ARCO = ARCO + 1. Alla fine del ciclo si pone LABEL(ARCO) = 1, k = k + 1. (2) - (Test di arresto) Se k = n 1 STOP, altrimenti si va al Passo 3 (3) - (Aggiornamento) Si esegue il ciclo: per i = 1, 2,..., n esegui: se COMP (i) = COMP (T O(ARCO)), allora poni COMP (i) = COMP (F ROM(ARCO)). Con il vettore COMP cosí modificato si torna al Passo 1. n. 5 - Appendice Dimostrazione della ottimalitá dell albero generato dall algoritmo di Prim Per dimostrare che l albero generato dall algoritmo di Prim é quello di lunghezza minima, é opportuno premettere la seguente Definizione. Si dice sottoalbero ottimo di G un sottografo G = (N, A ) di G per cui esiste un albero invadente di lunghezza minima G = (N, A ) tale che A A. Sussiste allora il seguente Teorema di Prim. Sia G = (N, A ) un sottoalbero ottimo di G tale che N N e siano ā N, b N N tali che (ā, b) A e (*) l(ā, b) = min {l(a, b) : a N, b N N, (a, b) A}. Allora il sottografo G = (N { b}, A {(ā, b)} é ancora un sottoalbero ottimo. Dim. Sia G = (N, A ) un albero invadente di lunghezza minima tale che A A e dimostriamo che esiste un albero invadente di lunghezza minima G = (N, A ) tale che A A (ā, b).
6 Ebbene, se (ā, b) A, l albero G soddisfa la tesi. In caso contrario, esiste un cammino in G che congiunge ā con b; tale cammino conterrá un arco (a, b) tale che a N e b / N. Consideriamo allora il sottografo G di G ottenuto sostituendo in A l arco (a, b) con l arco (ā, b), (cioé G = (N, A ), con A = A {(ā, b)} {(a, b)}), e dimostriamo che : (1) G é connesso, (2) G non ha cicli, (3) la lunghezza di G é minore o uguale della lunghezza di G ; ne seguirá che G é (al pari di G ), un albero invadente di lunghezza minima, e quindi soddisfa la tesi, dal momento che risulta chiaramente G A {(ā, b)}. Dim. di (1). Siano a, b N, a b e dimostriamo che esiste un cammino in G un acmmino congiungente a con b. A tal fine, sia Γ il cammino in G congiungente a con b ; se Γ non contiene l arco (a, b), allora Γ soddisfa la tesi. In caso contrario, il cammino cercato si ottiene sostituendo Gamma con il cammino Γ unione dei seguenti cammini: il cammino congiungente a con a, il cammino congiungente a con ā, l arco (ā, b), il cammino congiungente b con b, il cammino congiungente b con b. Dim. di (2). Supponiamo per assurdo che G contiene un ciclo Γ e dimostriamo che allora esiste un ciclo in G, il che é impossibile, poiché G é un albero. Infatti, se Γ non contiene l arco (ā, b), allora Γ stesso é un ciclo in G. Se Γ contiene l arco (ā, b), allora un ciclo in G si ottiene sostituendo, in Γ, l arco (ā, b) con il cammino Γ unione dei seguenti cammini: il cammino congiungente ā con a, l arco (a, b), il cammino congiungente b con b. Dim di (3). I sottografi G e G differiscono solo per gli archi (ā, b) e (a, b) e risulta l(ā, b) l(a, b), in virtú di (*); ne segue che la lunghezza di G é minore o uguale della lunghezza di G. Dal teorema di Prim si deduce che il sottografo trovato alla fine dell esecuzione dell algoritmo di Prim é un albero invadente di lunghezza minima. Infatti é evidente che il sottografo iniziale G = (N, A ), con A =, é un sottoalbero ottimo; conseguentemente, il sottografo trovato dopo la prima iterazione é un sottoalbero ottimo. Ma allora anche quello trovato dopo la seconda iterazione é un sottoalbero ottimo, e in generale il sottografo trovato dopo ogni iterazione é un sottoalbero ottimo. Pertanto, il sottoalbero G = (N, A ) trovato dopo n 1 iterazioni é un sottoalbero ottimo, e quindi esiste un albero invadente di lunghezza minima G = (N, A ), tale che A A. Ebbene si vede facilmente che N = N, A = A, e quindi che il sottoalbero G trovato dall algoritmo di Prim é proprio l albero invadente di lunghezza minima G = (N, A ). Infatti, dopo n 1 iterazioni, l insieme N avrá n elementi e quindi coincide con N; d altra parte, deve essere A = A, perché se ci fosse un arco (a, b) A A, il cammino unione dell arco (a, b) con il cammino in G congiungente b con a sarebbe un ciclo in G, e questo é impossibile, poiché G é un albero.