Salta al contenuto principale

L'Algoritmo di Dijkstra in Go: Implementazione, Ottimizzazione e Uso Reale

·9 minuti
Indice dei contenuti
I problemi di percorso minimo sui grafi compaiono costantemente nel platform engineering: routing di rete, risoluzione delle dipendenze, ottimizzazione dei percorsi in un service mesh e scheduling di pipeline CI si riducono tutti a trovare il percorso a costo minimo in un grafo diretto e pesato. L’algoritmo di Dijkstra e il cavallo di battaglia per questi problemi quando i pesi degli archi sono non negativi. Questo articolo copre una corretta implementazione in Go, l’ottimizzazione basata su heap che la rende pratica su grafi di grandi dimensioni, la ricostruzione del percorso e un caso d’uso realistico di risoluzione delle dipendenze.

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).

graph.go
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.

dijkstra_naive.go
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.

dijkstra_heap.go
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
}
Importante

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.

path.go
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.

deps/resolver.go
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].

**Usare una map invece di una slice per le distanze.** Le map hanno un overhead costante maggiore rispetto alle slice. Se i tuoi nodi sono interi in [0, N), usa sempre `[]int`. Le map sono appropriate solo quando gli identificatori dei nodi sono stringhe sparse o interi non contigui. **Mancanza del controllo delle voci obsolete nella versione con heap.** Senza `if item.dist > dist[u] { continue }`, si elaborano i nodi piu volte. Su un grafo con molti archi, questo puo raddoppiare o triplicare il lavoro effettivo e produrre catene di predecessori errate. **Mancata gestione dei grafi disconnessi.** Uscire quando `dist[u] == Infinity` e necessario ma non sufficiente. Un chiamante che dereferenzia `path[0]` su un path nil da `ReconstructPath` causera un panic. Controlla sempre nil prima di usare il path. **Dimenticare di inizializzare `prev` a -1.** Il valore zero di `int` in Go e 0, non -1. Un array `prev` inizializzato a zero suggerira erroneamente che il nodo 0 e predecessore di ogni nodo. **Grafo bidirezionale con una sola chiamata AddEdge.** Un grafo non diretto richiede `AddEdge(from, to, w)` e `AddEdge(to, from, w)`. Omettere la seconda chiamata produce un grafo diretto che silenziosamente genera percorsi minimi errati.

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.

Articoli correlati