🌳 Dijkstra's Algorithm for Grids in Python

Dec. 2022 June 2024

There are many resources for implementing Dijkstra's algorithm for graphs on the internet, but there are few resources for implementing the algorithm for the special case where the graph is a 2d grid with obstacles. Moreover, the various library implementations all assume some sort of graph data structure which makes sense; graphs are the natural structure for the algorithm, and you could convert an obstacle-grid to a graph. However, sometimes all you want and need is a 2d grid which is exactly what I have implemented for your convenience. If you just want the code, you can find it at the end of the article, but first let us discuss the algorithm.

Dijkstra's Algorithm

Dijkstra's algorithm is an algorithm for finding the shortest path between two points potentially avoiding obstacles on the way. It works by exploring the distances to all nodes in the grid starting at the source node, always considering the shortest path yet, until the desired node is found in a bread-first search manner.

The idea is to choose a tile that is the current shortest path, and then compute the distance to neighbouring tiles (up down left right) repeat until the destination node is found.

  1. Initialize the distances as "infinite" and mark all tiles in the matrix as unvisited. Furthermore, initialize an obstacle matrix that contains ones for all tiles except the obstacles which contain a very high number - "infinite", so we discourage the algorithm from going to those tiles.
  2. Set the distance of the entry and destination tile to 0, indicating that it can be reached directly from the source.
  3. Repeat the following steps until all vertices have been visited:
    - Select the unvisited tile with the smallest distance from the source.
    - Mark this tile as visited.
    - For each unvisited neighbor (up down left right) of this tile, calculate the distance to that neighbor as the sum of the distance to the current tile and the cost of moving on that tile. If this distance is smaller than the current estimated distance to the neighbor, update the neighbor's distance with the new value
  4. Once we have reached the destination tile, the algorithm is finished, and we have found the shortest path.

In code, we get:

Initialize the maps:

# initialize cost heuristic map
obstacles = obstacles.copy()
# obstacles should have very high cost, so we avoid them.
obstacles *= 1000
# normal tiles should have 1 cost (1 so we can backtrack)
obstacles += np.ones(obstacles.shape)
# source and destination are free
obstacles[initial_node[0],initial_node[1]] = 0
obstacles[desired_node[0],desired_node[1]] = 0


# initialize maps for distances and visited nodes
size_of_floor = obstacles.shape[0]

# we only want to visit nodes once
visited = np.zeros([size_of_floor,size_of_floor],bool)

# initiate matrix to keep track of distance to source node
# initial distance to nodes is infinity so we always get a lower actual distance
distances = np.ones([size_of_floor,size_of_floor]) * np.inf
# initial node has a distance of 0 to itself
distances[initial_node[0],initial_node[1]] = 0

Then we repeat the following:

For each potential direction we can go (up down left right), we check that the potential tile is valid (within the grid) and not visited.

directions = [up, down, left, right]
    for direction in directions:
        potential_node = direction(current_node)
        if valid_node(potential_node, size_of_floor): # boundary checking
           if not visited[potential_node[0],potential_node[1]]:

If so, we want to update the distance to that node if it's the shortest path to that node we have discovered so far.

# update distance if it is the shortest discovered
if distance < distances[potential_node[0],potential_node[1]]:
    distances[potential_node[0],potential_node[1]] = distance

Finally, after exhausting all the potential nodes, we mark the current node as visited, and choose the next node to be the node with the current shortest path.

# mark current node as visited
visited[current_node[0]  ,current_node[1]] = True

# select next node
# by choosing the the shortest path so far
t=distances.copy()
# we don't want to visit nodes that have already been visited
t[np.where(visited)]=np.inf
# choose the shortest path
node_index = np.argmin(t)

# convert index to row,col.
node_row = node_index//size_of_floor
node_col = node_index%size_of_floor
# update current node.
current_node = (node_row, node_col)

If the next node we select is the destination node, we stop the algorithm, and backtrack to get the actual path.

Backtracking is very simple, we just start with the destination node, and choose the next node to be the one with the shortest distance in a greedy fashion until we reach the source node.

The code

(for copy-pasting)

import numpy as np

def valid_node(node, size_of_grid):
    """Checks if node is within the grid boundaries."""
    if node[0] < 0 or node[0] >= size_of_grid:
        return False
    if node[1] < 0 or node[1] >= size_of_grid:
        return False
    return True

def up(node):
    return (node[0]-1,node[1])

def down(node):
    return (node[0]+1,node[1])

def left(node):
    return (node[0],node[1]-1)

def right(node):
    return (node[0],node[1]+1)

def backtrack(initial_node, desired_node, distances):
    # idea start at the last node then choose the least number of steps to go back
    # last node
    path = [desired_node]

    size_of_grid = distances.shape[0]

    while True:
        # check up down left right - choose the direction that has the least distance
        potential_distances = []
        potential_nodes = []

        directions = [up,down,left,right]

        for direction in directions:
            node = direction(path[-1])
            if valid_node(node, size_of_grid):
                potential_nodes.append(node)
                potential_distances.append(distances[node[0],node[1]])

        least_distance_index = np.argmin(potential_distances)
        path.append(potential_nodes[least_distance_index])

        if path[-1][0] == initial_node[0] and path[-1][1] == initial_node[1]:
            break

    return list(reversed(path))

def dijkstra(initial_node, desired_node, obstacles):
    """Dijkstras algorithm for finding the shortest path between two nodes in a graph.

    Args:
        initial_node (list): [row,col] coordinates of the initial node
        desired_node (list): [row,col] coordinates of the desired node
        obstacles (array 2d): 2d numpy array that contains any obstacles as 1s and free space as 0s

    Returns:
        list[list]: list of list of nodes that form the shortest path
    """
    # initialize cost heuristic map
    obstacles = obstacles.copy()
    # obstacles should have very high cost, so we avoid them.
    obstacles *= 1000
    # normal tiles should have 1 cost (1 so we can backtrack)
    obstacles += np.ones(obstacles.shape)
    # source and destination are free
    obstacles[initial_node[0],initial_node[1]] = 0
    obstacles[desired_node[0],desired_node[1]] = 0


    # initialize maps for distances and visited nodes
    size_of_floor = obstacles.shape[0]

    # we only want to visit nodes once
    visited = np.zeros([size_of_floor,size_of_floor],bool)

    # initiate matrix to keep track of distance to source node
    # initial distance to nodes is infinity so we always get a lower actual distance
    distances = np.ones([size_of_floor,size_of_floor]) * np.inf
    # initial node has a distance of 0 to itself
    distances[initial_node[0],initial_node[1]] = 0

    # start algorithm
    current_node = [initial_node[0], initial_node[1]]
    while True:
        directions = [up, down, left, right]
        for direction in directions:
            potential_node = direction(current_node)
            if valid_node(potential_node, size_of_floor): # boundary checking
                if not visited[potential_node[0],potential_node[1]]: # check if we have visited this node before
                    # update distance to node
                    distance = distances[current_node[0], current_node[1]] + obstacles[potential_node[0],potential_node[1]]

                    # update distance if it is the shortest discovered
                    if distance < distances[potential_node[0],potential_node[1]]:
                        distances[potential_node[0],potential_node[1]] = distance


        # mark current node as visited
        visited[current_node[0]  ,current_node[1]] = True

        # select next node
        # by choosing the the shortest path so far
        t=distances.copy()
        # we don't want to visit nodes that have already been visited
        t[np.where(visited)]=np.inf
        # choose the shortest path
        node_index = np.argmin(t)

        # convert index to row,col.
        node_row = node_index//size_of_floor
        node_col = node_index%size_of_floor
        # update current node.
        current_node = (node_row, node_col)

        # stop if we have reached the desired node
        if current_node[0] == desired_node[0] and current_node[1]==desired_node[1]:
            break

    # backtrack to construct path
    return backtrack(initial_node,desired_node,distances)

For running the code:

import matplotlib.pyplot as plt
obstacles = np.array([[0,0,0,0,0,0,0,0,0,0],
                      [0,0,0,0,0,0,0,0,0,0],
                      [0,0,0,0,1,0,0,0,0,0],
                      [0,0,0,1,0,1,0,0,0,0],
                      [1,1,1,1,0,1,1,0,0,0],
                      [0,0,0,1,0,1,0,0,0,0],
                      [0,0,0,0,0,1,1,0,0,0],
                      [0,0,0,0,0,1,0,0,0,0],
                      [0,0,0,0,1,0,0,0,0,0],
                      [0,0,0,0,0,0,0,0,0,0]], dtype=float)
path = dijkstra([0,0],[3,4],obstacles)
print(path)

    
p = np.zeros(shape=obstacles.shape)
for i in range(len(path)):
    p[path[i][0],path[i][1]] = np.nan

plt.imshow(p+obstacles, cmap='jet')
plt.show()
Figure_1

Continue reading

Loading...