Rappresentazione del Grafo#
Una lista di adiacenza con ID di nodo interi e la scelta giusta per Dijkstra in Go. Le chiavi stringa sono comode per esempi piccoli ma aggiungono overhead di hashing inutile su grafi con migliaia di nodi. Rappresentiamo gli archi pesati come struct e memorizziamo il grafo come una slice di slice (indicizzata per ID nodo).
package graph
const Infinity = int(^uint(0) >> 1)
// Edge rappresenta una connessione diretta e pesata da un nodo a un altro.
type Edge struct {
To int
Weight int
}
// Graph e una lista di adiacenza indicizzata per ID nodo.
type Graph struct {
Edges [][]Edge
Size int
}
func NewGraph(n int) *Graph {
return &Graph{
Edges: make([][]Edge, n),
Size: n,
}
}
// AddEdge aggiunge un arco diretto. Per grafi non diretti, chiama due volte.
func (g *Graph) AddEdge(from, to, weight int) {
g.Edges[from] = append(g.Edges[from], Edge{To: to, Weight: weight})
}Implementazione Naive O(V²)#
L’approccio naive mantiene un insieme di nodi non visitati e lo scansiona linearmente ad ogni iterazione per trovare il nodo con la distanza tentativa minima. Questo e O(V) per iterazione, dando O(V²) in totale. E accettabile per grafi densi (E vicino a V²) ma impraticabile per grafi sparsi con milioni di nodi.
package graph
// DijkstraNaive restituisce la distanza minima da start a ogni nodo raggiungibile.
// Complessita temporale: O(V^2). Usa DijkstraHeap per grafi sparsi di grandi dimensioni.
func DijkstraNaive(g *Graph, start int) (dist []int, prev []int) {
n := g.Size
dist = make([]int, n)
prev = make([]int, n)
visited := make([]bool, n)
for i := range dist {
dist[i] = Infinity
prev[i] = -1
}
dist[start] = 0
for range n {
// Trova il nodo non visitato con la distanza tentativa minima.
u := -1
for v := 0; v < n; v++ {
if !visited[v] && (u == -1 || dist[v] < dist[u]) {
u = v
}
}
if u == -1 || dist[u] == Infinity {
break // Tutti i nodi rimanenti non sono raggiungibili.
}
visited[u] = true
for _, e := range g.Edges[u] {
if alt := dist[u] + e.Weight; alt < dist[e.To] {
dist[e.To] = alt
prev[e.To] = u
}
}
}
return dist, prev
}Ottimizzazione con Priority Queue: O((E+V) log V)#
Sostituire la scansione lineare con un min-heap riduce la complessita da O(V²) a O((E+V) log V). Il pacchetto container/heap di Go richiede l’implementazione di heap.Interface, che consiste in cinque metodi. L’intuizione chiave: non facciamo decrease-key nell’heap (richiederebbe di tracciare gli indici). Invece, inseriamo un nuovo elemento e saltiamo le voci obsolete quando vengono estratte.
package graph
import "container/heap"
type heapItem struct {
node int
dist int
}
type minHeap []heapItem
func (h minHeap) Len() int { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i].dist < h[j].dist }
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any) {
*h = append(*h, x.(heapItem))
}
func (h *minHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
// DijkstraHeap restituisce le distanze minime e la mappa dei predecessori da start.
// Complessita temporale: O((E + V) log V).
func DijkstraHeap(g *Graph, start int) (dist []int, prev []int) {
n := g.Size
dist = make([]int, n)
prev = make([]int, n)
for i := range dist {
dist[i] = Infinity
prev[i] = -1
}
dist[start] = 0
pq := &minHeap{{node: start, dist: 0}}
heap.Init(pq)
for pq.Len() > 0 {
item := heap.Pop(pq).(heapItem)
u := item.node
// Salta le voci obsolete (e gia stato trovato un percorso piu breve).
if item.dist > dist[u] {
continue
}
for _, e := range g.Edges[u] {
if alt := dist[u] + e.Weight; alt < dist[e.To] {
dist[e.To] = alt
prev[e.To] = u
heap.Push(pq, heapItem{node: e.To, dist: alt})
}
}
}
return dist, prev
}Il controllo delle voci obsolete (if item.dist > dist[u]) e fondamentale. Senza di esso, si elabora lo stesso nodo piu volte con distanze non aggiornate, producendo risultati errati. Questo e il bug piu comune nelle implementazioni Dijkstra con heap.
Ricostruzione del Percorso#
L’array prev restituito da entrambe le implementazioni registra il predecessore di ogni nodo sul percorso minimo. Per ricostruire il percorso effettivo da start a end, si risale attraverso prev e si inverte.
package graph
// ReconstructPath restituisce la sequenza di nodi sul percorso minimo da start a end.
// Restituisce nil se end non e raggiungibile da start.
func ReconstructPath(prev []int, start, end int) []int {
if prev[end] == -1 && end != start {
return nil // end non e raggiungibile
}
var path []int
for at := end; at != -1; at = prev[at] {
path = append(path, at)
}
// Inversione in-place.
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return path
}Caso d’Uso Reale: Risoluzione delle Dipendenze tra Servizi#
Considera una piattaforma di microservizi in cui i servizi dichiarano dipendenze da altri servizi. Durante il deployment, vuoi trovare il percorso a latenza minima attraverso il grafo delle dipendenze per determinare l’ordine ottimale di avvio e i colli di bottiglia.
package deps
import "graph"
type ServiceGraph struct {
g *graph.Graph
nameToID map[string]int
idToName []string
}
func NewServiceGraph(services []string) *ServiceGraph {
n := len(services)
sg := &ServiceGraph{
g: graph.NewGraph(n),
nameToID: make(map[string]int, n),
idToName: make([]string, n),
}
for i, name := range services {
sg.nameToID[name] = i
sg.idToName[i] = name
}
return sg
}
func (sg *ServiceGraph) AddDependency(from, to string, latencyMs int) {
sg.g.AddEdge(sg.nameToID[from], sg.nameToID[to], latencyMs)
}
// CriticalPath restituisce il percorso a latenza minima dal servizio from al servizio to.
func (sg *ServiceGraph) CriticalPath(from, to string) ([]string, int) {
start := sg.nameToID[from]
end := sg.nameToID[to]
dist, prev := graph.DijkstraHeap(sg.g, start)
nodeIDs := graph.ReconstructPath(prev, start, end)
names := make([]string, len(nodeIDs))
for i, id := range nodeIDs {
names[i] = sg.idToName[id]
}
return names, dist[end]
}Grafo di Esempio e Percorso Minimo#
graph LR
A["A (0)"] -->|7| B["B (1)"]
A -->|9| C["C (2)"]
A -->|14| F["F (5)"]
B -->|10| C
B -->|15| D["D (3)"]
C -->|11| D
C -->|2| F
D -->|6| E["E (4)"]
E -->|9| F
style A fill:#4CAF50,color:#fff
style C fill:#2196F3,color:#fff
style D fill:#2196F3,color:#fff
style E fill:#FF5722,color:#fff
Il percorso minimo da A a E e A -> C -> D -> E con costo 9+11+6 = 26.
Confronto tra Algoritmi#
Ideale per: grafi con pesi degli archi non negativi, specialmente grafi sparsi.
Complessita temporale: O((E+V) log V) con binary heap.
Complessita spaziale: O(V+E).
Limitazioni: fallisce con pesi degli archi negativi. Nessuna capacita di orientare la ricerca verso l’obiettivo.
Implementazione in Go: container/heap fornisce tutte le primitive necessarie. Nessuna dipendenza esterna richiesta.
Ideale per: grafi con pesi degli archi negativi, rilevamento di cicli negativi.
Complessita temporale: O(V * E). Molto piu lento di Dijkstra su grafi grandi.
Proprieta chiave: puo rilevare cicli a peso negativo. Necessario per il rilevamento di arbitraggi finanziari e certi protocolli di routing di rete.
Quando usarlo in Go: grafi di tassi di cambio valutario dove i log-pesi negativi codificano opportunita di arbitraggio.
Ideale per: pathfinding quando e disponibile una stima euristica della distanza dall’obiettivo (coordinate geografiche, distanza di Manhattan in una griglia).
Complessita temporale: O(E) nel caso migliore con un’euristica perfetta; degenera in Dijkstra con un’euristica zero.
Proprieta chiave: l’euristica deve essere ammissibile (non sovrastima mai la distanza reale).
Quando usarlo in Go: pathfinding spaziale (mappe di gioco, topologia di rete fisica con coordinate geografiche). Non utile per grafi di dipendenze dove non e calcolabile una distanza euristica.
Limitazioni#
Pesi degli archi negativi. Dijkstra assume che, una volta finalizzato un nodo, non esista un percorso piu breve verso di esso. Un arco a peso negativo puo creare un percorso piu breve verso un nodo gia finalizzato, che Dijkstra ignora, producendo risultati errati. Usa Bellman-Ford o l’algoritmo di Johnson per grafi con pesi negativi.
Grafi disconnessi. I nodi non raggiungibili dal punto di partenza mantengono la loro distanza iniziale Infinity. Controlla sempre dist[target] == graph.Infinity prima di usare il risultato.
Integer overflow. Usare int(^uint(0) >> 1) come infinito funziona finche non si sommano due di questi valori (Infinity + weight causa overflow verso un numero negativo). Proteggi le addizioni: if dist[u] != Infinity && dist[u]+e.Weight < dist[v].
Se vuoi approfondire questi temi, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.