In [1]:
%load_ext watermark
%watermark -a 'Sebastian Raschka' -u -d -v
Sebastian Raschka 
last updated: 2017-08-09 

CPython 3.6.1
IPython 6.1.0

Dijkstra's Algorithm

Dijkstra's algorithm is an algorithm that finds the shortest path in a directed or undirected graph. In contrast to Breadth-First Search, Dijkstra's algorithm works with weighted graphs -- that is, graphs that have different costs or length assigned to its edges. However, note that Dijkstra's algorithm does not work if the graph contains negative weights (in this case, different algorithms need to be used, for example, the Bellman-Ford algorithm).

A great and concise explanation of Dijkstra's algorithm was written on Wikipedia:

Let the node at which we are starting be called the initial node. Let the distance of node Y be the distance from the initial node to Y. Dijkstra's algorithm will assign some initial distance values and will try to improve them step by step.

  1. Assign to every node a tentative distance value: set it to zero for our initial node and to infinity for all other nodes.
  2. Set the initial node as current. Mark all other nodes unvisited. Create a set of all the unvisited nodes called the unvisited set.
  3. For the current node, consider all of its neighbors and calculate their tentative distances. Compare the newly calculated tentative distance to the current assigned value and assign the smaller one. For example, if the current node A is marked with a distance of 6, and the edge connecting it with a neighbor B has length 2, then the distance to B (through A) will be 6 + 2 = 8. If B was previously marked with a distance greater than 8 then change it to 8. Otherwise, keep the current value.
  4. When we are done considering all of the neighbors of the current node, mark the current node as visited and remove it from the unvisited set. A visited node will never be checked again.
  5. If the destination node has been marked visited (when planning a route between two specific nodes) or if the smallest tentative distance among the nodes in the unvisited set is infinity (when planning a complete traversal; occurs when there is no connection between the initial node and remaining unvisited nodes), then stop. The algorithm has finished.
  6. Otherwise, select the unvisited node that is marked with the smallest tentative distance, set it as the new "current node", and go back to step 3.

[Source: Wikipedia, https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm]

In addition, a great way to study Dijkstra's algorithm is to work through an example by hand, before we implement it in Python:

Before we implement Dijkstra's algorithm, let's convert the graph from the example above into a data structure that we could use. When we are woking with graphs, hash tables are naturally a good choice (aka Python dictionaries). Since we do not only need to sort information about which nodes are connected to each other but also have to keep track of the costs of the connections, let's use a nested dictionary that we call graph:

In [2]:
graph = {'A': {'B': 14, 'C': 9, 'D': 7},
         'B': {'A': 14, 'C': 2, 'F': 9},
         'C': {'A':  9, 'B': 2, 'D': 7, 'E': 11},
         'D': {'A':  7, 'C':10, 'E':15},
         'E': {'C': 11, 'D':15, 'F': 6},
         'F': {'B':  9, 'E': 6}
        }

For example, to get the cost of the edge connecting C and B, we can use the dictionary as follows:

In [3]:
graph['C']['B']
Out[3]:
2
In [4]:
# equivalently:
graph['B']['C']
Out[4]:
2
In [5]:
float('inf') > 99
Out[5]:
True
In [6]:
def dijkstra(graph, start, destination):

    # initialize costs of starting node and its neighbors
    costs = {node: float('inf') for node in graph.keys()}
    costs[start] = 0
    # and use parent_nodes to keep track of the chain of
    # nodes that make up the shortest path
    parent_nodes = {}
    for neighbor in graph[start].keys():
        costs[neighbor] = graph[start][neighbor]
        parent_nodes[neighbor] = start
    
    nodes_checked = set()
    while not len(nodes_checked) == len(graph.keys()):
        
        # get lowest cost node
        min_cost, min_cost_node = float('inf'), None
        for node in costs:
            curr_cost = costs[node]
            if curr_cost < min_cost and node not in nodes_checked:
                min_cost, min_cost_node = curr_cost, node
                
        # check if we can reach any of the lowest cost node's
        # neigbors by going through the lowest cose node
        for neighbor in graph[min_cost_node].keys():
            new_cost = min_cost + graph[min_cost_node][neighbor]
            if new_cost < costs[neighbor]:
                costs[neighbor] = new_cost
                parent_nodes[neighbor] = min_cost_node
                
        # early stopping if we visited the destination
            if neighbor == destination:
                break
        if neighbor == destination:
                break
    
        # add the node to the checked nodes
        nodes_checked.add(min_cost_node)
            
    return costs, parent_nodes


costs, parent_nodes = dijkstra(graph, start='A', destination='F')

print('Costs:', costs)
print('Parent Nodes:', parent_nodes)
Costs: {'A': 0, 'B': 11, 'C': 9, 'D': 7, 'E': 20, 'F': 20}
Parent Nodes: {'B': 'C', 'C': 'A', 'D': 'A', 'E': 'C', 'F': 'B'}

Now, after running the algorithm, the costs dictionary provides us with the minimum cost to reach particular nodes in the graph starting at node A. For instance, the shortest path between A and F is 20.

The parent_nodes dictionary maps each node along the path to its parent. For instance, to see the shortest path from A to F, we can simply backtrack starting from F:

'F': 'B' -> 'B': 'C' -> 'C': 'A'

Thus, the shortest path (with cost 20) is

A -> C -> B -> F.