grafo pesato

Un grafo è detto pesato se ad ogni arco è associato un valore numerico (detto peso) (funzione associa ad ogni arco un peso).

  • il peso di un cammino è dato dalla somma dei pesi degli archi che lo costituiscono

Per rappresentare un grafo pesato tramite lista di adiacenza, per l’arco di peso nella lista di adiacenza di , invece che il solo nodo di destinazione , ci sarà la coppia .

center

il grafo sopra avrà lista di adiacenza:

G = [
	[(1, 17), (5, 4)],
	[(0, 17), (4, 5), (5, 6)],
	[(3, 12), (4, 10)],
	[(2, 12), (4, 4), (5, 1)],
	[(1, 5), (2, 10), (3, 4)],
	[(0, 4), (1, 6), (3, 1)]
]

algoritmo di Dijkstra

problema:

Dato un grafo pesato, vogliamo trovare i cammini minimi, e quindi anche le distanze da un nodo (sorgente) a tutti gli altri nodi del grafo.

center

(^ cammini minimi dalla sorgente a tutti gli altri nodi)

L’algoritmo di Dijkstra costruisce l’albero dei cammini minimi un arco per volta partendo dal nodo sorgente, e segue questa logica:

  • ad ogni passo, aggiunge all’albero l’arco che produce il nuovo cammino più economico
  • assegna ad ogni nodo come distanza il costo del cammino (che dalla radice porta ad esso)

L’algoritmo rientra nel paradigma della tecnica greedy. Opera infatti secondo una sequenza di decisioni irrevocabili:

  • il cammino dal nodo sorgente ad un nuovo nodo viene deicso ad ogni passo, senza più tornare sulla decisione
  • le decisioni vengono prese in base ad un criterio locale: tra tutti i cammini percorribili, si sceglie quello che costa meno

implementazioni

È impossibile risolvere il problema in meno di . Esistono tre implementazioni principali:

  • una senza strutture dati, in : è ottima nel caso dei grafi densi (in cui ), ma non nel caso di grafi sparsi ()
  • una che utilizza la Heap, in : funziona meglio nel caso di grafi sparsi, ma nel caso di un grafo denso è preferibile la prima
  • una terza, che utilizza la Heap di Fibonacci, in : la migliore in entrambi i casi (ma non trattata)

pseudocodice:

  • P[0...n-1] vettore dei padri inizializzato a -1
  • D[0...n-1] vettore delle distanze inizializzato a +inf (perché si utilizzerà la funzione min())
  • D[s], P[s] = 0, s (la sorgente ha distanza zero da se stessa ed è padre di se stessa)
  • while esistono archi (x,y) con P[x]!=-1 e P[y]==-1:
    • sia (x,y) quello per cui è minimo D[x] + peso(x,y)
    • D[y], P[y] = D[x] + peso(x,y), x (si sceglie il cammino, quindi la distanza del nuovo nodo sarà data dalla distanza del padre + il peso dell’arco, e il padre del nuovo nodo sarà il nodo corrente)

attenzione: l'algoritmo non è corretto nel caso di grafi con pesi anche negativi

Infatti, poiché sceglie il cammino meno costoso ad ogni passo, non considera il caso in cui si percorrerà prima un arco con peso maggiore per poi incontrare archi con pesi negativi (che abbasseranno quindi il costo totale).

prova di correttezza

dimostrazione

Si dimostra per induzione sul numero di iterazioni del while (che assegna una nuova distanza ad un nodo).

Dobbiamo dimostrare che la distanza assegnata è quella minima.

  • caso base: al passo zero viene assegnata distanza zero alla sorgente (e, poiché non consideriamo il caso di pesi negativi, non c’è una distanza minore)
  • ipotesi induttiva: assumiamo che i cammini costruiti fino al passo siano minimi

Sia l’albero dei cammini minimi costruito fino al passo e l’arco aggiunto al passo . Per dimostrare che è la distanza minima, basta mostrare che il costo di un eventuale cammino alternativo è sempre superiore o uguale a .

Sia un qualsiasi cammino alternativo a quello presente nell’albero e il primo arco che incontriamo percorrendo all’indietro tale che è nell’albero e no (ovvero l’ultimo arco percorso dal cammino).

  • per ipotesi induttiva (e poiché non consideriamo archi con pesi negativi)

Ma l’algoritmo ha scelto invece che , quindi, in base alla regola con cui esso sceglie l’arco con cui estendere l’albero, si ha necessariamente .

  • da qui segue

Il cammino alternativo ha quindi un costo superiore a .

implementazione tramite lista

Si può mantenere una lista in cui, per ogni nodo viene memorizzata una tripla , in cui:

  • definitivo flag che assume valore se il costo per raggiungere è stato definitivamente stabilito (ovvero se l’algoritmo ha stabilito che non esiste un percorso migliore per arrivare al nodo )
  • costo costo corrente minimo noto per raggiungere dalla sorgente
    • all’inizio, viene inizializzato a per e a per tutti gli altri nodi
  • origine nodo “padre” lungo il cammino minimo dalla sorgente a
    • inizializzato a

All’inizio, l’unico nodo dell’albero è la sorgente, quindi la lista è inizializzata così:

Seguono una serie di iterazioni dove vengono eseguiti questi passaggi:

  1. selezione del nodo con costo minimo non definitivo: si scorre per individuare il nodo che non è ancora definitivo e che ha il costo corrente minimo è il candidato per il quale il cammino minimo dalla sorgente è attualmente noto
  2. verifica di terminazione: se il costo minimo trovato è , significa che non esistono altri nodi raggiungibili non definitivi. Il ciclo si interrompe.
  3. marcare come definitivo: la flag del nodo selezionato viene aggiornata a (il suo costo definitivo è stato fissato e non sarà più modificato)
  4. aggiornamento dei vicini di : per ogni nodo adiacente a , se non è ancora definitivo e il nuovo costo ottenuto passando per esso (ovvero ) è inferiore o uguale al costo attuale memorizzato per , si aggiorna la terna di
def dijkstra(s, G):
	n = len(G)
	Lista = [(0, float('inf'), -1)]*n
	Lista[s] = (1, 0, s) 
	
	for y, costo in G[s]:
		# aggiorno vicini di s
		Lista[y] = (0, costo, s)
	
	while True:
		minimo, x = float('inf'), -1
		# cerco il nodo non definitivo con costo minimo
		# considerati solo i vicini, gli altri avranno inf
		for i in range(n):
			if Lista[i][0] == 0 and Lista[i][1] < minimo:
				minimo, x = Lista[i][1], i
		
		if minimo == float('inf'):
			# non ci sono più nodi raggiunbili non definitivi
			break
		
		# rendo definitivo il nodo x
		definitivo, costo_x, origine = Lista[x]
		Lista[x] = (1, costo_x, origine)
		
		# aggiornamento vicini
		for y, costo_arco in G[x]:
			# se y non è definitivo e  c'è un cammino migliore passando per x
			if Lista[y][0] == 0 and minimo + costo_arco < Lista[y][1]:
				Lista[y] = (0, minimo + costo_arco, x)
	
	# estrae vettori delle distanze e dei padri
	D,P = [costo for _,costo,_ in Lista], [origine for _,_,origine in Lista]
	return D, P
  • il costo delle istruzioni al di fuori del while è .
  • il while viene eseguito al più volte (ad ogni iterazione un nuovo nodo viene selezionato e reso definitivo). Al suo interno:
    • il primo for viene eseguito esattamente volte
    • il secondo for viene eseguito al più volte (tante quanti sono gli adiacenti)

Il costo del while è quindi , che è anche la complessità dell’implementazione.

[ TODO: inserire passaggi esempio ]

implementazione con Heap

Questa implementazione si basa sull’intuizione per cui, se si evitasse di scorrere ogni volta il vettore per trovare il minimo, si eviterebbe di pagare ad ogni iterazione del while.

  • si può quindi sostituire con una Heap, che ha complessità per l’estrazione del minimo e per l’inserimento

Manteniamo un heap minimo contenente triple , dove è un nodo già inserito nell’albero dei cammini minimi e rappresenta la distanza che si avrebbe qualora il nodo venisse inserito nell’albero dei cammini minimi attraverso .

  • ogni volta che aggiungiamo un nodo all’albero, aggiorniamo anche l’heap inserendo, per ogni vicino di , una nuova tripla
  • dato che non rimuoviamo elementi già presenti nell’heap, possono esistere più entry dello stesso nodo con distanze differenti. Tuttavia, sappiamo che la prima volta che un nodo viene estratto, questa corrisponde alla distanza minnima calcolata fino a quel punto (quindi le successive estrazioni possono essere trascurate).
    • quindi, ad ogni estrazione di un nodo controlliamo prima se esso è già stato aggiunto all’albero (e, in tal caso, ignoriamo l’estrazione)
from heapq import heappush, heappop
 
def dijkstra1(s, G):
	n = len(G)
	D = [float('inf')]*n
	P = [-1]*n
	D[s] = 0
	P[s] = s
	H = [] # min-heap
	
	# inizializzazione heap (con vicini di s)
	for y, costo in G[s]:
		heappush(H, (costo, s, y))
	
	while H:
		# estraggo il nodo con distanza minore
		costo, x, y = heappop(H)
		if P[y] == -1:
			P[y] = x
			D[y] = costo
			
			for v, peso in G[y]:
				if P[v] == -1:
					heappush(H, (D[y]+peso, y, v))
	
	return D, P

Nella heap ci possono essere anche elementi, quindi i costi di inserimento ed estrazione saranno .

  • l’inizializzazione di e costa , e l’inserimento dei vicini di costa .

il costo del while con al suo interno un for è dato invece da:

  • ad ogni iterazione del while si elimina un elemento da e, eventualmente, tramite il for annidato si scorre la lista di adiacenza di un nodo e si aggiungono elementi ad . Ogni lista di adiacenza può essere scorsa al più una volta, quindi ad possono essere aggiunti al massimo elementi. Il numero di iterazioni del while è quindi .
  • escludendo il for annidato, il costo di ciascuna iterazione del while è a causa dell’estrazione da - quindi, senza il for, il while costerebbe .
  • Il for scorre la lista di adiacenza di un nodo . Tuttavia, ogni arco viene esaminato una sola volta in tutto l’algoritmo (quando il nodo viene estratto da ). Per ogni arco esaminato nel for, può essere eseguita un’operazione di inserimento in , che ha costo . Poiché in totale ci sono archi, il numero complessivo di operazioni di inserimento in è al massimo , e quindi il costo totale del for nell’intero algoritmo è

La complessità di questa operazione è quindi