Finders

In this particular project, we are required to implement three search algorithms:

  • BFS
  • DFS
  • A*

BFS

BFS or breadth-first search traverses the graph by exploring all the neighbors of the root of the graph first, and then proceeds to the next depth of the graph until the end is found.

Implementing this is simple enough - let us keep a queue, add neighbors to it, and iterate on the queue until we find our target. Notice how our implementation accepts arbitrary data structures for the grid/graph by allowing input of a get_neighbor function.

In [ ]:
def bfs(grid, get_adjacent, start, end):
    queue, visited = [start], set()
    while queue:
        curr = queue.pop(0)
        if curr in visited:
            continue
        visited.add(curr)
        for neighbor in get_adjacent(grid, curr):
            if neighbor == end:
                # found!
            else:
                queue.append(neighbor)

Notice that in our breadth first search, we want to know the path traversed by our algorithm to get to the end. There's a couple of methods to achieve this, but to keep the data structures as simple and pure as possible, I opted to implement a backtrace.

In [ ]:
def backtrack(grid, parents, start, end):
    path = [end]
    while path[-1] != start:
        path.append(parents[path[-1]])
    path.reverse()
    return path

def bfs(grid, get_adjacent, start, end):
    queue, visited, parents = [start], set(), {}
    while queue:
        curr = queue.pop(0)
        if curr in visited:
            continue
        visited.add(curr)
        for neighbor in get_adjacent(grid, curr):
            if neighbor not in visited:
                parents[neighbor] = curr
            if neighbor == end:
                path = backtrack(grid, parents, start, end)

                if with_expansion: return path, expansion
                else: return path,
            else:
                queue.append(neighbor)

We can also make our algorithm return the list of expanded nodes. This will help us later if we want to see what 'path' the algorithm took to generate the solution.

In [3]:
def bfs(grid, get_adjacent, start, end, with_expansion=False):
    queue, visited, parents = [start], set(), {}
    if with_expansion:
        expansion = []
    while queue:
        curr = queue.pop(0)
        if curr in visited:
            continue
        visited.add(curr)
        if with_expansion:
            expansion.append(curr)
        for neighbor in get_adjacent(grid, curr):
            if neighbor not in visited:
                parents[neighbor] = curr
            if neighbor == end:
                path = backtrack(grid, parents, start, end)

                if with_expansion: return path, expansion
                else: return path,
            else:
                queue.append(neighbor)

DFS

DFS or depth-first search traverses the graph by exploring to the end of each branch first.

Implementing this takes even less lines of code - we simply keep a stack, and add neighbors of the node there:

In [4]:
def dfs(grid, get_adjacent, start, end, with_expansion=False):
    stack, visited = [(start, [start])], []
    while stack:
        curr, path = stack.pop()
        if curr == end:
            if with_expansion: return path, visited
            else: return path,
        if curr not in visited:
            visited.append(curr)
        for neighbor in get_adjacent(grid, curr):
            if neighbor not in visited:
                stack.append((neighbor, path + [neighbor]))

Note that I took the liberty of implementing it with the path and expansion path taken into account.

A*

A is perhaps the most interesting search-algorithm here, being the only informed search algorithm. In A, we make use of a heuristic function that will guide the search.

It is a bit longer than the two previous algorithms, but not that much longer.

The first thing that is different is that we need two new arguments, cost and heuristic. cost will return the cost to traverse between two nodes, while heuristic will serve as the heuristic function for our algorithm. This makes our algorithm more general by supporting different problem spaces.

We also add the data structures that we need - in particular, a queue (a priority queue), a set for visited nodes, a map for parents (for backtracking) and a map for G and F.

In [15]:
def astar(grid, get_adjacent, start, end, cost, heuristic, with_expansion=False):
    queue, visited, parents = set([start]), set(), {}
    g_cost, f_cost = {}, {}

    g_cost[start] = 0
    f_cost[start] = heuristic(start, end)

We then take the item in the queue with the lowest cost for F

In [18]:
def astar_cut(): # not really a function, we just define it as such so jupyter won't run it
    while queue:
        curr = None
        curr_score = None
        # TODO: Turn this into a priority queue?
        for coord in queue:
            if curr == None or f_cost[coord] < curr_score:
                curr_score = f_cost[coord]
                curr = coord

After that, we check if it is the goal. If not, we remove it from the queue and iterate over its neighbors, updating the parent map, f_cost, and g_cost in the process:

In [ ]:
def astar_cut():
    for neighbor in get_adjacent(grid, curr):
        if neighbor in visited:
            continue

        g = g_cost[curr] + manhattan_distance(curr, neighbor)

        if neighbor not in queue:
            queue.add(neighbor)
        elif g >= g_cost[neighbor]:
            continue

        parents[neighbor] = curr
        g_cost[neighbor] = g
        f_cost[neighbor] = g + heuristic(neighbor, end)

This is the whole algorithm defined:

In [16]:
def astar(grid, get_adjacent, start, end, cost, heuristic, with_expansion=False):
    queue, visited, parents = set([start]), set(), {}
    g_cost, f_cost = {}, {}

    g_cost[start] = 0
    f_cost[start] = heuristic(start, end)

    if with_expansion:
        expansion = []
    while queue:
        curr = None
        curr_score = None
        # TODO: Turn this into a priority queue?
        for coord in queue:
            if curr == None or f_cost[coord] < curr_score:
                curr_score = f_cost[coord]
                curr = coord

        if curr == end:
            path = backtrack(grid, parents, start, end)

            if with_expansion: return path, expansion
            else: return path,

        queue.remove(curr)

        if curr in visited:
            continue

        visited.add(curr)

        if with_expansion:
            expansion.append(curr)

        for neighbor in get_adjacent(grid, curr):
            if neighbor in visited:
                continue

            g = g_cost[curr] + manhattan_distance(curr, neighbor)

            if neighbor not in queue:
                queue.add(neighbor)
            elif g >= g_cost[neighbor]:
                continue

            parents[neighbor] = curr
            g_cost[neighbor] = g
            f_cost[neighbor] = g + heuristic(neighbor, end)

With our generic algorithms defined, we can move on to see how we can use it to search in our grid.


This is a post in the CS 180 Pathfinding series.
Other posts in this series:

Arian Valdez @Secretmapper

React.JS and Node.JS Software engineering consultant. Developer/Designer Hybrid. Author of Alt Tracker, Combustion, Riyu, etc.