A Beginner’s Guide and Optimization Techniques

Graphs are fundamental data structures in computer science, representing relationships between entities. One of the most common problems involving graphs is finding the shortest path between nodes. Dijkstra’s algorithm is a classic solution to this problem for graphs with non-negative edge weights. In this guide, we’ll implement Dijkstra’s algorithm in Go and explore ways to optimize it using advanced data structures.\

Basic Implementation of Dijkstra’s Algorithm

Let’s begin by understanding the core concept. Dijkstra’s algorithm maintains a set of nodes whose shortest distance from the source is known and repeatedly selects the node with the minimum distance to explore its neighbors.

First, we’ll represent our graph. In Go, a convenient way to represent a graph is with adjacency lists using maps.

package main

import (
    "fmt"
)

type Edge struct {
    node   string
    weight int
}

type Graph struct {
    nodes map[string][]Edge
}

func NewGraph() *Graph {
    return &Graph{nodes: make(map[string][]Edge)}
}

func (g *Graph) AddEdge(from, to string, weight int) {
    g.nodes[from] = append(g.nodes[from], Edge{node: to, weight: weight})
}

Implementing the Algorithm

Now, let’s implement the basic version of Dijkstra’s algorithm:

func Dijkstra(graph *Graph, start string) (distances map[string]int, previous map[string]string) {
    distances = make(map[string]int)
    previous = make(map[string]string)
    unvisited := make(map[string]bool)

    // Initialize distances and unvisited nodes
    for node := range graph.nodes {
        distances[node] = int(^uint(0) >> 1) // Set to infinity
        unvisited[node] = true
    }
    distances[start] = 0

    for len(unvisited) > 0 {
        // Find the unvisited node with the smallest distance
        var currentNode string
        smallestDistance := int(^uint(0) >> 1)
        for node := range unvisited {
            if distances[node] <= smallestDistance {
                smallestDistance = distances[node]
                currentNode = node
            }
        }

        // Remove the node from unvisited set
        delete(unvisited, currentNode)

        // Update distances to neighbors
        for _, edge := range graph.nodes[currentNode] {
            alt := distances[currentNode] + edge.weight
            if alt < distances[edge.node] {
                distances[edge.node] = alt
                previous[edge.node] = currentNode
            }
        }
    }

    return distances, previous
}

Explanation

  • Distances keeps track of the shortest distance from the start node to every other node.
  • Previous helps reconstruct the shortest path.
  • We iterate over all unvisited nodes, selecting the one with the smallest known distance.
  • For each neighbor of the current node, we check if a shorter path exists via the current node.

Example Usage

func main() {
    graph := NewGraph()

    edges := []struct {
        from   string
        to     string
        weight int
    }{
        {"A", "B", 7},
        {"A", "C", 9},
        {"A", "F", 14},
        {"B", "C", 10},
        {"B", "D", 15},
        {"C", "D", 11},
        {"C", "F", 2},
        {"D", "E", 6},
        {"E", "F", 9},
    }

    for _, edge := range edges {
        graph.AddEdge(edge.from, edge.to, edge.weight)
        graph.AddEdge(edge.to, edge.from, edge.weight) // Assuming undirected graph
    }

    distances, previous := Dijkstra(graph, "A")

    fmt.Println("Distances:", distances)
    fmt.Println("Previous nodes:", previous)
}

This code constructs a graph and finds the shortest paths from node “A” to all other nodes.

Optimizing Dijkstra’s Algorithm with a Priority Queue

The basic implementation above has a time complexity of O(N²) due to the selection of the minimum node using a linear search. We can optimize this by using a priority queue (min-heap) to select the node with the smallest distance in logarithmic time.

Implementing a Priority Queue

Go’s standard library provides container/heap which can be used to implement a priority queue.

func main() {
    graph := NewGraph()

    edges := []struct {
        from   string
        to     string
        weight int
    }{
        {"A", "B", 7},
        {"A", "C", 9},
        {"A", "F", 14},
        {"B", "C", 10},
        {"B", "D", 15},
        {"C", "D", 11},
        {"C", "F", 2},
        {"D", "E", 6},
        {"E", "F", 9},
    }

    for _, edge := range edges {
        graph.AddEdge(edge.from, edge.to, edge.weight)
        graph.AddEdge(edge.to, edge.from, edge.weight) // Assuming undirected graph
    }

    distances, previous := Dijkstra(graph, "A")

    fmt.Println("Distances:", distances)
    fmt.Println("Previous nodes:", previous)
}

This code constructs a graph and finds the shortest paths from node “A” to all other nodes.

Optimizing Dijkstra’s Algorithm with a Priority Queue

The basic implementation above has a time complexity of O(N²) due to the selection of the minimum node using a linear search. We can optimize this by using a priority queue (min-heap) to select the node with the smallest distance in logarithmic time.

Implementing a Priority Queue

Go’s standard library provides container/heap which can be used to implement a priority queue.

import (
    "container/heap"
)

type Item struct {
    node     string
    priority int
    index    int
}

type PriorityQueue []*Item

func (pq PriorityQueue) Len() int { return len(pq) }

func (pq PriorityQueue) Less(i, j int) bool {
    return pq[i].priority < pq[j].priority
}

func (pq PriorityQueue) Swap(i, j int) {
    pq[i], pq[j] = pq[j], pq[i]
    pq[i].index = i
    pq[j].index = j
}

func (pq *PriorityQueue) Push(x interface{}) {
    n := len(*pq)
    item := x.(*Item)
    item.index = n
    *pq = append(*pq, item)
}

func (pq *PriorityQueue) Pop() interface{} {
    old := *pq
    n := len(old)
    item := old[n-1]
    old[n-1] = nil  // Avoid memory leak
    item.index = -1 // For safety
    *pq = old[0 : n-1]
    return item
}

func (pq *PriorityQueue) update(item *Item, priority int) {
    item.priority = priority
    heap.Fix(pq, item.index)
}

Optimized Dijkstra’s Algorithm

func DijkstraWithHeap(graph *Graph, start string) (distances map[string]int, previous map[string]string) {
    distances = make(map[string]int)
    previous = make(map[string]string)

    pq := make(PriorityQueue, 0)
    heap.Init(&pq)

    heap.Push(&pq, &Item{node: start, priority: 0})

    for pq.Len() > 0 {
        item := heap.Pop(&pq).(*Item)
        currentNode := item.node
        currentDistance := item.priority

        if _, ok := distances[currentNode]; ok {
            continue // Node already visited
        }

        distances[currentNode] = currentDistance

        for _, edge := range graph.nodes[currentNode] {
            if _, ok := distances[edge.node]; !ok {
                alt := currentDistance + edge.weight
                heap.Push(&pq, &Item{node: edge.node, priority: alt})
                previous[edge.node] = currentNode
            }
        }
    }

    return distances, previous
}

Explanation

  • We use a priority queue pq to always select the node with the smallest distance.
  • When we pop a node from the heap, we check if it’s already visited to avoid redundant processing.
  • The overall time complexity reduces to O((E + V) log V), where E is the number of edges and V is the number of vertices.

Testing the Optimized Algorithm

Let’s test our optimized function with the same graph:

func main() {
    graph := NewGraph()

    edges := []struct {
        from   string
        to     string
        weight int
    }{
        {"A", "B", 7},
        {"A", "C", 9},
        {"A", "F", 14},
        {"B", "C", 10},
        {"B", "D", 15},
        {"C", "D", 11},
        {"C", "F", 2},
        {"D", "E", 6},
        {"E", "F", 9},
    }

    for _, edge := range edges {
        graph.AddEdge(edge.from, edge.to, edge.weight)
        graph.AddEdge(edge.to, edge.from, edge.weight) // Assuming undirected graph
    }

    distances, previous := DijkstraWithHeap(graph, "A")

    fmt.Println("Distances with heap:", distances)
    fmt.Println("Previous nodes with heap:", previous)
}

You should observe the same results as before, but with improved efficiency, especially noticeable in larger graphs.

Conclusion

Implementing Dijkstra’s algorithm provides valuable insights into graph traversal and optimization techniques. Starting with a basic implementation helps understand the core logic, while optimizing with a priority queue significantly improves performance. While further optimizations are possible, the heap-based approach offers a good balance between complexity and efficiency for most practical applications.

References