Implementing Dijkstra Fast AF

Swift Fast AF
7 min readDec 20, 2022

--

Dijkstra’s shortest path algorithm is a popular algorithm used to find the shortest path between two nodes in a graph. It was developed by computer scientist Edsger Dijkstra in 1956 and is widely used in routing and network analysis.

In this tutorial, we will learn how to implement Dijkstra’s algorithm in Swift using three different approaches:

  1. A brute force approach using a priority queue
  2. A more efficient approach using a min-heap
  3. A dynamic programming approach using a memoization table

We will go through each of these approaches in detail, explaining how they work and providing code examples to help you understand the concepts.

Before we dive into the implementation, let’s take a quick look at the basics of Dijkstra’s algorithm.

The Basics of Dijkstra’s Algorithm

Dijkstra’s algorithm is a graph search algorithm that works by starting at a given node and exploring the neighboring nodes. It calculates the shortest path to each of these nodes and continues the search until it has reached the destination node.

The algorithm uses a priority queue to store the nodes that have been visited and their associated distances from the starting node. The priority queue is used to prioritize the search for the shortest path, as the algorithm always explores the node with the smallest distance first.

To implement Dijkstra’s algorithm, we need to define the following variables:

  1. A graph G with vertices V and edges E
  2. A starting vertex s
  3. A destination vertex d

We also need to define the following functions:

  1. A function to calculate the shortest path between two nodes
  2. A function to update the distances of the neighboring nodes
  3. A function to add a node to the priority queue

With these variables and functions in place, we can now implement Dijkstra’s algorithm using the following steps:

  1. Set the distance of the starting vertex to 0 and all other vertices to infinity
  2. Add the starting vertex to the priority queue
  3. While the priority queue is not empty: a. Remove the vertex with the smallest distance from the queue b. For each neighbor of the vertex: i. Calculate the distance to the neighbor ii. If the distance is smaller than the current distance of the neighbor, update the distance and add the neighbor to the queue
  4. Return the distance of the destination vertex

Now that we have a basic understanding of Dijkstra’s algorithm, let’s look at how we can implement it in Swift using three different approaches.

Implementation Approach 1: Brute Force using a Priority Queue

The first approach we will look at is a brute force implementation using a priority queue. This approach involves adding all the nodes to the priority queue and updating their distances as we explore them.

To implement this approach, we need to define a class for our graph, which will store the vertices and edges as dictionaries. We also need to define a class for our priority queue, which will store the nodes and their associated distances.

Here is the code for our graph and priority queue classes:

class Graph {
var vertices: [String: [String: Int]]

init() {
vertices = [String: [String: Int]]()
}

func addVertex(_ vertex: String) {
vertices[vertex] = [String: Int]()
}

func addEdge(_ source: String, _ destination: String, _ weight: Int) {
vertices[source]![destination] = weight
}
}

class PriorityQueue {
var heap: [(Int, String)]

init() {
heap = (Int, String)
}

func addVertex(_ distance: Int, _ vertex: String) {
heap.append((distance, vertex))
heap.sort { $0.0 < $1.0 }
}

func removeVertex() -> (Int, String)? {
if heap.count == 0 {
return nil
}
return heap.removeFirst()
}
}

Now that we have our graph and priority queue classes defined, we can implement the Dijkstra’s algorithm using the following code:

func dijkstra(graph: Graph, source: String, destination: String) -> Int {
var distances = String: Int
var previous = String: String
var queue = PriorityQueue()

for vertex in graph.vertices.keys {
distances[vertex] = Int.max
previous[vertex] = ""
}

distances[source] = 0
queue.addVertex(0, source)

while let vertex = queue.removeVertex() {
let vertexDistance = vertex.0
let vertexName = vertex.1
if vertexName == destination {
break
}
if let neighbors = graph.vertices[vertexName] {
for (neighbor, weight) in neighbors {
let distance = vertexDistance + weight
if distance < distances[neighbor]! {
distances[neighbor] = distance
previous[neighbor] = vertexName
queue.addVertex(distance, neighbor)
}
}
}
}

return distances[destination]!
}

In this code, we first initialize the distances and previous dictionaries, which will store the distance of each vertex from the source and the previous vertex in the shortest path respectively. We also initialize the priority queue and add the source vertex to it.

We then enter a loop where we remove the vertex with the smallest distance from the queue and explore its neighbors. If the distance to a neighbor is smaller than the current distance of the neighbor, we update the distance and add the neighbor to the queue.

Once the destination vertex is reached, we break out of the loop and return the distance of the destination vertex.

Implementation Approach 2: More Efficient Approach using a Min-Heap

The brute force approach we saw above is relatively simple to understand, but it has a time complexity of O(V²). This means that it takes a long time to run on large graphs with many vertices.

To make the algorithm more efficient, we can use a min-heap instead of a priority queue. A min-heap is a binary tree data structure that allows us to efficiently add and remove elements in O(log V) time.

To implement Dijkstra’s algorithm using a min-heap, we need to define a class for our min-heap and modify our graph and priority queue classes to use it. Here is the code for our min-heap class:

class MinHeap {
var heap: [(Int, String)]
init() {
heap = (Int, String)
}

func addVertex(_ distance: Int, _ vertex: String) {
heap.append((distance, vertex))
var currentIndex = heap.count - 1
while currentIndex > 0 && heap[currentIndex].0 < heap[(currentIndex - 1) / 2].0 {
heap.swapAt(currentIndex, (currentIndex - 1) / 2)
currentIndex = (currentIndex - 1) / 2
}
}

func removeVertex() -> (Int, String)? {
if heap.count == 0 {
return nil
}
if heap.count == 1 {
return heap.removeLast()
}
let vertex = heap[0]
heap[0] = heap.removeLast()
var currentIndex = 0
while currentIndex < heap.count {
let leftChildIndex = 2 * currentIndex + 1
let rightChildIndex = 2 * currentIndex + 2
if leftChildIndex >= heap.count {
break
}
var minIndex = currentIndex
if heap[leftChildIndex].0 < heap[minIndex].0 {
minIndex = leftChildIndex
}
if rightChildIndex < heap.count && heap[rightChildIndex].0 < heap[minIndex].0 {
minIndex = rightChildIndex
}
if minIndex == currentIndex {
break
}
heap.swapAt(currentIndex, minIndex)
currentIndex = minIndex
}
return vertex
}
}

We can now implement Dijkstra’s algorithm using the same code as in the brute force approach, but replacing the priority queue with our modified priority queue class.

Implementation Approach 3: Dynamic Programming Approach using a Memoization Table

The second approach we looked at, using a min-heap, is more efficient than the brute force approach, but it still has a time complexity of O(V log V). In large graphs with many vertices, this can still be slow.

To make the algorithm even more efficient, we can use a dynamic programming approach. In this approach, we use a memoization table to store the shortest path distances for each vertex. This allows us to avoid recalculating the distances for the same vertex multiple times, which greatly reduces the time complexity of the algorithm.

To implement this approach, we need to define a class for our memoization table and modify our graph and priority queue classes to use it.

Here is the code for our memoization table class:

class MemoizationTable {
var distances: [String: Int]

init() {
distances = [String: Int]()
}

func updateDistance(_ vertex: String, _ distance: Int) {
distances[vertex] = distance
}

func getDistance(_ vertex: String) -> Int? {
return distances[vertex]
}
}

In this class, we have defined the updateDistance and getDistance functions to update and retrieve the distance of a vertex in the memoization table.

We can now modify our graph and priority queue classes to use the memoization table. Here is the updated code for these classes:

class Graph {
var vertices: [String: [String: Int]]
var memoizationTable: MemoizationTable

init() {
vertices = [String: [String: Int]]()
memoizationTable = MemoizationTable()
}

func addVertex(_ vertex: String) {
vertices[vertex] = [String: Int]()
}

func addEdge(_ source: String, _ destination: String, _ weight: Int) {
vertices[source]![destination] = weight
}
}

class PriorityQueue {
var heap: MinHeap
var memoizationTable: MemoizationTable

init() {
heap = MinHeap()
memoizationTable = MemoizationTable()
}

func addVertex(_ distance: Int, _ vertex: String) {
heap.addVertex(distance, vertex)
}

func removeVertex() -> (Int, String)? {
return heap.removeVertex()
}
}

We can now implement Dijkstra’s algorithm using the following code:

func dijkstra(graph: Graph, source: String, destination: String) -> Int {
var distances = [String: Int]()
var previous = [String: String]()
var queue = PriorityQueue()

for vertex in graph.vertices.keys {
distances[vertex] = Int.max
previous[vertex] = ""
}

distances[source] = 0
queue.addVertex(0, source)

while let vertex = queue.removeVertex() {
let vertexDistance = vertex.0
let vertexName = vertex.1

if vertexName == destination {
break
}
if let neighbors = graph.vertices[vertexName] {
for (neighbor, weight) in neighbors {
let distance = vertexDistance + weight
if let currentDistance = queue.memoizationTable.getDistance(neighbor) {
if distance < currentDistance {
queue.memoizationTable.updateDistance(neighbor, distance)
previous[neighbor] = vertexName
queue.addVertex(distance, neighbor)
}
} else {
queue.memoizationTable.updateDistance(neighbor, distance)
previous[neighbor] = vertexName
queue.addVertex(distance, neighbor)
}
}
}
return distances[destination]!
}

In this code, we have modified the loop that explores the neighbors of the current vertex to check the memoization table before updating the distance of a neighbor. If the distance of the neighbor is already stored in the memoization table, we only update it if the new distance is smaller. If the distance is not stored in the memoization table, we add it to the table and add the neighbor to the priority queue.

Conclusion

In this tutorial, we learned how to implement Dijkstra’s shortest path algorithm in Swift using three different approaches: a brute force approach using a priority queue, a more efficient approach using a min-heap, and a dynamic programming approach using a memoization table.

We saw how each approach works and how to implement it in Swift, and we learned how to optimize the algorithm for larger graphs by using a min-heap or a memoization table.

If you want to learn more about Dijkstra’s algorithm and other graph search algorithms, you can check out the following resources:

- Edsger Dijkstra’s original paper on the algorithm: https://www.cs.utexas.edu/users/EWD/ewd02xx/EWD249.PDF
- A tutorial on Dijkstra’s algorithm by GeeksforGeeks: https://www.geeksforgeeks.org/dijkstras-shortest-path-algorithm-using-priority_queue-stl/
- A tutorial on graph search algorithms by Brilliant: https://brilliant.org/wiki/

--

--