Dijkstra's Algorithm

Dijkstra's algorithm finds the shortest path from one node to all other nodes in a weighted graph.

Say we had the following graph, which represents the travel cost between different cities in the southeast US:

The diagram represents the travel cost between cities: Atlanta, Memphis, Mobile, Nashville, New Orleans, Savannah.

Traveling from Memphis to Nashville? The cheapest route isn't to go straight from one to the other! (You'll actually want to go via New Orleans, Mobile, and Atlanta for the cheapest path.)

This sort of thing can happen in real life. Lots of airlines like to route through large hubs and might not have much service directly between two smaller cities.

Dijkstra's algorithm lets us find the cheapest route from one city to all other cities. So for Memphis:

The diagram represents the cheapest route from Memphis to all other cities.

What if we only want the cheapest path between one pair of nodes? Just pick one of them as the starting point and run Dijkstra's algorithm. Finding the cheapest path to all nodes includes finding the cheapest path to the other node in the pair.

But isn't Dijkstra's algorithm overkill if we only care about one pair of nodes? Actually no, because we'll still need to consider other nodes in the graph to make sure we've found the lowest-cost weighted path. (Take a look at going from Memphis to Nashville, where the cheapest path includes over half the nodes in the graph.)

The Algorithm

Dijkstra's algorithm is like breadth-first search

Breadth-First Search is an algorithm that systematically explores a graph level by level. Starting from a source point, BFS visits all its immediate neighbors first, then their neighbors, and so on. This methodical approach makes it incredibly useful for finding shortest paths in unweighted graphs, exploring tree-like structures, and solving a variety of problems that come up frequently in coding interviews.

BFS is a cornerstone algorithm. It's often favored in interviews because it strikes a balance: it's conceptually straightforward to understand and implement, yet adaptable to a wide range of challenging questions.

In this article, we’ll cover core concepts of BFS, look at some implementations with time / space complexity analysis, and explore a few different problems where BFS shines. Let's dive in!

Understanding the Core Concepts of BFS

What is Breadth-First Search?

Breadth-First Search (BFS) explores a graph or tree level by level. Imagine dropping a pebble into still water: the ripples spread outwards in circles. BFS mimics this, starting at a source node and systematically visiting all nodes at the current level before moving to the next level. This level-by-level approach ensures you explore the graph in layers of "hops" from the starting point.

Starting from one point in the graph,

A 4-row binary tree represented by circles connected with lines. Our breadth-first search has us start at the root node at the top of the tree.

we look at all the nodes one level away,

The same 4-row binary tree with all nodes at depth 1 (second row) bolded after being visited.

then all the nodes two levels away,

The same 4-row binary tree with all nodes at depth 2 (third row) bolded after being visited.

then all the nodes three levels away,

The same 4-row binary tree with all nodes at depth 3 (fourth and final row) bolded after being visited.

and so on - until we’ve seen every node.

Key Data Structure: The Queue

The key behind BFS's level-by-level exploration is a queue. A queue is a First-In, First-Out (FIFO) data structure: items are added to the back and removed from the front.

In BFS, a queue stores the nodes we need to explore. When we visit a node, we add its unvisited neighbors to the back of the queue. Then, we process the next node at the front of the queue. This FIFO behavior ensures that we explore all nodes at the current "level" before moving to the next level.

The Visited Set

To prevent infinite loops and redundant processing in graphs (especially those with cycles), we need to keep track of visited nodes. This is where a visited set is essential.

Before adding a neighbor to the queue, we check if it's already in the visited set. If it is, we skip it. Otherwise, we add it to the queue and mark it as visited by adding it to the visited set. Using an efficient set data structure, these checks and inserts can be done in O(1)O(1) time on average.

Path Reconstruction: The Predecessor Dictionary

In many BFS problems, especially shortest path problems, you'll need to reconstruct the path from the starting node to the target node once you find it. To do this, we use a predecessor dictionary (or parent dictionary).

As we explore the graph with BFS, whenever we enqueue a neighbor node, we record in the predecessor dictionary that we reached this neighbor from the current node. This dictionary stores each node and its immediate predecessor in the BFS traversal.

Once BFS finds the target node, we can easily reconstruct the path by starting at the target node and using the predecessor dictionary to trace back to its predecessor, then to its predecessor, and so on, until we reach the starting node. This backtracking process gives us the shortest path in reverse order, which we can then reverse to get the correct path.

Path reconstruction takes time proportional to the length of the shortest path. In the worst case, every node in the graph could be in the path, so reconstructing takes O(V)O(V) overall.

The BFS Algorithm: Step-by-Step

Let's break down the BFS algorithm into clear steps:

  1. Initialization:
    • Create an empty queue, queue.
    • Create an empty predecessor dictionary, predecessors
    • Create an empty set, visited
    • Enqueue the starting node into the queue.
    • Add the starting node to visited and predecessors.
  2. Iteration (while the queue is not empty):
    • Dequeue a node from the front of the queue. Let's call this current_node.
    • Process current_node. This might involve:
      • Checking if it's the target node you're searching for.
      • Performing some operation based on the node's value.
      • Simply noting that you've visited it.
    • Explore Neighbors: Get all valid, unvisited neighbors of current_node.
    • For each unvisited neighbor:
      • Enqueue the neighbor into the queue.
      • Add it to visited.
      • In predecessors, record that we got to this neighbor via current_node
  3. Termination: The algorithm terminates when we reach our target node or the queue becomes empty.

    At this point, you often will reconstruct the path from start_node to end_node using the predecessors dictionary.

  from collections import deque


def reconstruct_path(predecessors, start_node, end_node):
    reversed_shortest_path = []

    # Start from the end of the path and work backwards
    current_node = end_node
    while current_node:
        reversed_shortest_path.append(current_node)
        current_node = predecessors[current_node]

    # Reverse our path to get the right order
    reversed_shortest_path.reverse()  # flip it around, in place
    return reversed_shortest_path  # no longer reversed


def bfs_get_path(graph, start_node, end_node):
    if start_node not in graph:
        raise Exception('Start node not in graph')
    if end_node not in graph:
        raise Exception('End node not in graph')

    queue = deque()
    queue.append(start_node)

    visited = set([start_node])

    predecessors = {start_node: None}

    while len(queue) > 0:
        current_node = queue.popleft()

        # Stop when we reach the end node
        if current_node == end_node:
            return reconstruct_path(predecessors, start_node, end_node)

        for neighbor in graph[current_node]:
            if neighbor not in visited:
                queue.append(neighbor)
                visited.add(neighbor)
                predecessors[neighbor] = current_node

    # If we get here, then we never found the end node
    # so there's NO path from start_node to end_node
    return None

Analyzing Time and Space Complexity of BFS

Once you’ve coded up an algorithm, interviewers are going to ask you to analyze its time and space complexity.

Time Complexity: O(V+E)O(V+E)

VV is the number of vertices (nodes) and EE is the number of edges in the graph. We visit each vertex at most once (due to the visited set). For each vertex, we iterate through its neighbors. In total, across all vertices, we examine each edge at most twice (once for the node on each end). Putting it all together, we’ve got O(V+E)O(V+E) time complexity.

Space Complexity: O(V)O(V)

VV is the number of vertices. Our three data structures are the node queue, the visited set, and the backtracking dictionary. Because each node gets added to the queue exactly once, the queue won’t grow beyond O(V)O(V). Our visited set also grows to hold all the nodes, O(V)O(V) size. Finally, our backtracking dictionary has one entry per node, so it’s O(V)O(V), too. Putting these together, we’re still O(V)O(V) space.

Applications of BFS: Where is BFS Useful?

Because of its versatility, BFS shows up in a lot of places. Look for these problem structures that suggest BFS is the appropriate algorithm:

  • Problems where you’re exploring a set of states and have to find the shortest sequence from one state to another.
  • Problems involving grid-based structures (mazes, grids, etc.) where each step has the same cost.
  • Problems where you need to look at connected items (friends in a social network, links within a website, railroads between cities, etc.).

When approaching a problem that might be solved with BFS, consider these steps:

  • Identify BFS Applicability: Does the problem involve finding the shortest path in an unweighted graph, exploring levels or layers, or searching connected components? If so, BFS might be a good fit.
  • Define the Graph/States: What are the "nodes" in your problem? How are they connected (what are the "edges" or transitions)? This could be explicit graph nodes, grid cells, words in a word ladder, etc.
  • Define Start and End: What is the starting node(s)? Is there a specific target node or condition you are searching for?
  • Define Neighbors: How do you find the valid neighbors of a node? Consider constraints (walls in a maze, valid word transformations, etc.).
  • Handle Boundary Conditions: Consider edge cases like empty inputs, disconnected components, and whether BFS is feasible given the input size.

Sample BFS Interview Questions

Here are some common interview problems where a BFS-style algorithm could work:

Binary Tree Level Order Traversal

Problem Description: Given a binary tree, return its node values in level order traversal. That is, nodes at each level are visited from left to right, level by level.

Applying BFS:

  • Our starting node is the root of the binary tree. We don't have a specific ending node; we'll continue until the queue is empty.
  • Each node's neighbors are their left and right children (if they have them).
  • We can check for an empty tree (comparing the root node to None). And, assuming the tree is a reasonable size, BFS should be feasible.

Shortest Path Through a Maze

Problem Description: Given an ASCII maze where each step has the same cost, find the shortest path from the top left to the bottom right.

  .#######             @####### 
..#.#..#             @@#.#..#
#.....##             #@@@@@##
###.#..#      ==>    ###.#@.#
#...#.##             #...#@##
#.###.#.             #.###@#.
#.....##             #....@##
#####...             #####@@@

Applying BFS:

  • Our starting node is the top left corner. Our ending node is the bottom right corner.
  • Each node's neighbors are the points above, below, left, and right - as long as they're not walls. We need to be careful of boundary conditions, like nodes on the edges of the grid.
  • We can check for edge cases like the start or end position being a wall or mazes where there's no path from the start to the finish.
  • If the maze is very large, it's possible BFS will be too slow or require too much memory. Ask your interviewer how large the maze is expected to be!

Number of Islands

Problem Description: Given a 2D grid indicating land and water, count the number of islands.

Applying BFS:

  • Start with a set of visited nodes, initially empty.
  • Iterate through the entire grid. When we reach a point that's an island:
    • If the point is already visited, do nothing.
    • Otherwise, increase our count by 1 and do BFS from that point outwards. Each BFS visits all the points on a single island. A point's neighbors are adjacent points that are also land.
  • Return the total number of islands seen.

Word Ladder

Problem Description: Given a start word and a target word, find the fewest steps required to transform one into the other, changing only one letter at a time.

As an example, to transform head into tail, we could generate this sequence:

  • head
  • heal
  • teal
  • tell
  • tall
  • tail

Applying BFS:

  • Our starting node is the start word. Our ending node is the target word.
  • Each node's neighbors are words that differ by one letter. As an example, some neighboring words for node would be:
    • code
    • mode
    • nods
    • note
    • rode
    • ...
  • We should check for edge cases like:
    • The start or target not being valid words
    • The start and target being different lengths
    • No possible word ladder

Conclusion: BFS - A Powerful Tool in Your Interview Toolkit

Breadth-First Search is an indispensable algorithm in your coding interview toolkit. Its systematic level-by-level exploration makes it a powerful and efficient approach for a wide range of problems. By mastering the core concepts – the queue, visited set, predecessor dictionary – and practicing its implementation, you'll be well-prepared to confidently tackle BFS-related interview questions.

Remember to:

  • Practice recognizing problems where BFS could be used.
  • Clearly articulate your BFS approach during interviews, highlighting how you are defining nodes, neighbors, and handling visited states.
  • Understand and be able to explain the time and space complexity for different BFS variants.

With practice and a solid understanding of BFS, you'll be ready to impress in your next coding interview! Good luck!

(BFS), except we use a priority queue

Quick reference

Binary Heap Priority Queue

This is the most common implementation but not the only one.

Worst Case
space O(n)O(n)
peek O(1)O(1)
dequeue O(lg(n))O(\lg(n))
enqueue O(lg(n))O(\lg(n))

A priority queue is a special queue where:

  1. Every item in the queue has a priority, and
  2. Higher-priority items are dequeued before lower-priority items.

Picture a big list of bugs for an engineering team to tackle. You want to keep the highest-priority bugs at the top of the list.

An element with high priority which is equal 1 is served before elements with lower priority: 2, 28, 39, 84 and 99. So the new element with priority 2 will be served before an element with priority 28 but after the existing element with priority 2.

Strengths:

  • Quickly access the highest-priority item. Priority queues allow you to peek at the top item in O(1)O(1) while keeping other operations relatively cheap (O(lg(n))O(lg(n))).

Weaknesses:

  • Slow enqueues and dequeues. Both operations take O(lg(n))O(\lg(n)) time with priority queues. With normal first-in, first-out queues, these operations are O(1)O(1) time.

Uses

  • Any time you want to handle things with different priority levels: triaging patients in a hospital, locating the closest available taxi, or just a to-do list.
  • Operating system schedulers may use priority queues to select the next process to run, ensuring high-priority tasks run before low-priority ones.
  • Certain foundational algorithms rely on priority queues:
    • Dijkstra's shortest-path
    • A* search (a graph traversal algorithm like BFS)
    • Huffman codes (an encoding for data compression)

Implementation

Binary Heaps

Priority queues are often implemented using binary heaps. Notice how the highest priority is right at the top of the heap, ready to be grabbed in O(1)O(1) time.

A binary heap is a binary tree where the nodes are organized to so that the smallest value is always at the top.
  • To enqueue an item, add it to the heap using the priority as the key. (O(lg(n))O(\lg(n)) time)
  • To peek at the highest priority item, look at the item at the top. (O(1)O(1) time)
  • To dequeue the highest priority item, remove the top item from the heap. (O(lg(n))O(\lg(n)) time)

Other Options

A Sorted List

  • To enqueue, use binary search to figure out where the new item should go. Then scoot items over to make space for the new item. (O(n)O(n) time, since in the worst case you have to scoot everything over to make room)
  • To peek at the highest priority item, look at the item at index zero. (O(1)O(1) time)
  • To dequeue, scoot every item forward one index. (O(n)O(n) time)

A Sorted Linked List

  • To enqueue, walk through the linked list to figure out where the new item should go. Then, reassign pointers to add the new item. (O(n)O(n) time)
  • To peek at the highest priority item, look at the item at the head of the linked list. (O(1)O(1) time)
  • To dequeue, update the linked list's head pointer to point to the second item. (And deallocate the old head node, if you're using a language with manual memory management.) (O(1)O(1) time)

Fancier Heaps

Binary heaps are just one kind of heap. Other kinds of heaps (e.g.: Fibonacci heaps or binomial heaps) can offer faster average performance for some priority queue operations. But, they're much more complex than binary heaps and less commonly used in practice.

In a coding interview, you usually can't go wrong by using a binary-heap based priority queue.

instead of a normal first-in-first-out queue.

Quick reference

Worst Case
space O(n)O(n)
enqueue O(1)O(1)
dequeue O(1)O(1)
peek O(1)O(1)

A queue stores items in a first-in, first-out (FIFO) order.

Picture a queue like the line outside a busy restaurant. First come, first served.

Strengths:

  • Fast operations. All queue operations take O(1)O(1) time.

Uses

  • Breadth-first search uses a queue to keep track of the nodes to visit next.
  • Printers use queues to manage jobs—jobs get printed in the order they're submitted.
  • Web servers use queues to manage requests—page requests get fulfilled in the order they're received.
  • Processes wait in the CPU scheduler's queue for their turn to run.

Implementation

Queues are easy to implement with linked lists:

  • To enqueue, insert at the tail of the linked list.
  • To dequeue, remove at the head of the linked list.

You could implement a queue with an array or dynamic array, but it would get kinda messy. Try drawing it out. You'll notice that you'd need to build out a "scoot over" or "re-center" operation that automatically fires when your queue items hit the bottom edge of the array.

Each item's priority is the cost of reaching it.

Let's work through an example before coding it up. We'll use our graph of cities from before, starting at Memphis.

Since we're already at Memphis, the cost to get there is 0. We don't know how to get anywhere else though, so those costs are \infty. We'll store all these costs in a table and initialize our priority queue.

City Cost
Atlanta \infty
Memphis 0
Mobile \infty
Nashville \infty
New Orleans \infty
Savannah \infty

First up in our queue, we've got Memphis. As with BFS, we'll start by looking at its neighbors.

The diagram represents initialized values of travel cost between cities. The cost to Memphis is equal to zero. Other values are unknown.

Looking at its neighbors, we can get to Atlanta (at a cost of 10), Mobile (cost is 7), Nashville (cost is 15) and New Orleans (cost is 3). So we'll update those travel costs in our table and our priority queue.

Here's what our table and priority queue look like now. To help keep track of things, we'll put a check mark next to nodes we've dequeued. So far, it's just Memphis.

City Cost
Atlanta 10
Memphis 0
Mobile 7
Nashville 15
New Orleans 3
Savannah \infty
The diagram represents the travel graph where Memphis is dequeued.

What next? Repeat!

Next in our priority queue we've got New Orleans, at a cost of 3. Let's remove it from the priority queue and examine its neighbors:

  • We've already dequeued Memphis, so we can skip it.
  • From New Orleans, we can get to Mobile at a cost of 3. Since it takes 3 to get from Memphis to New Orleans, the total cost of this path is 6. That's cheaper than the path we found earlier (where we went straight from Memphis to Mobile at a cost of 7), so we need to update its cost.

We'll also record the fact that we got to Mobile "via" New Orleans. This'll be important for figuring out which cities our cheapest route passes through at the end. (We could skip this step if we just wanted to calculate the lowest price, not the actual path.)

City Cost Via
Atlanta 10 Memphis
Memphis 0
Mobile 6 New Orleans
Nashville 15 Memphis
New Orleans 3 Memphis
Savannah \infty
The diagram represents the travel graph without Memphis and New Orleans.

Now we'll repeat the process again. The next city in our priority queue is Mobile at a cost of 6.

Looking at its neighbors:

  • Memphis and New Orleans have already been dequeued, so we can skip them.
  • We can get to Atlanta (2) via Mobile (6), for a total cost of 8. That's cheaper than our current path to Atlanta (10, via Memphis).
  • We can get to Savannah (6) via Mobile (6), for a total cost of 12. That's cheaper than our current cost (\infty, since we don't have a path yet).

Updating our priority queue and table:

City Cost Via
Atlanta 8 Mobile
Memphis 0
Mobile 6 New Orleans
Nashville 15 Memphis
New Orleans 3 Memphis
Savannah 12 Mobile
The diagram represents the travel graph without Memphis, New Orleans and Mobile.

Next up, Atlanta, at a cost of 8:

  • We can get to Savannah (1) via Atlanta (8), for a total cost of 9. That's cheaper than our current route via Mobile, which cost 12.
  • We can get to Nashville (2) via Atlanta (8), for a total cost of 10. That's cheaper than our current route via Memphis, which cost 15.
  • Memphis and Mobile have already been dequeued, so we can skip them.

Once we dequeue Atlanta, we know that we won't find a cheaper path later on. So, even though there are a few different ways to get to Atlanta, we don't have to consider the cost of all those paths; we just care about the cost of the shortest one.

Here's how things look now:

City Cost Via
Atlanta 8 Mobile
Memphis 0
Mobile 6 New Orleans
Nashville 10 Atlanta
New Orleans 3 Memphis
Savannah 9 Atlanta
The diagram represents the travel graph without Memphis, New Orleans, Mobile and Atlanta.

Almost there. Next city is Savannah, at a cost of 9.

We've already dequeued both of Savannah's neighbors, so we don't need to do anything.

City Cost Via
Atlanta 8 Mobile
Memphis 0
Mobile 6 New Orleans
Nashville 10 Atlanta
New Orleans 3 Memphis
Savannah 9 Atlanta
The diagram represents the travel graph without Memphis, New Orleans, Mobile, Atlanta and Savannah.

Finally, our last city is Nashville, at a cost of 10.

Again, both its neighbors have already been dequeued. We're done!

Here's what our final table looks like:

City Cost Via
Atlanta 8 Mobile
Memphis 0
Mobile 6 New Orleans
Nashville 10 Atlanta
New Orleans 3 Memphis
Savannah 9 Atlanta

Backtracking

Our table above stores the cost of getting to any city from Memphis, but it doesn't store the actual path. To find it, we'd start at that city and use the Via column to backtrack through the graph until we get to Memphis. This is the same way you recover paths after doing a breadth-first search

Breadth-First Search is an algorithm that systematically explores a graph level by level. Starting from a source point, BFS visits all its immediate neighbors first, then their neighbors, and so on. This methodical approach makes it incredibly useful for finding shortest paths in unweighted graphs, exploring tree-like structures, and solving a variety of problems that come up frequently in coding interviews.

BFS is a cornerstone algorithm. It's often favored in interviews because it strikes a balance: it's conceptually straightforward to understand and implement, yet adaptable to a wide range of challenging questions.

In this article, we’ll cover core concepts of BFS, look at some implementations with time / space complexity analysis, and explore a few different problems where BFS shines. Let's dive in!

Understanding the Core Concepts of BFS

What is Breadth-First Search?

Breadth-First Search (BFS) explores a graph or tree level by level. Imagine dropping a pebble into still water: the ripples spread outwards in circles. BFS mimics this, starting at a source node and systematically visiting all nodes at the current level before moving to the next level. This level-by-level approach ensures you explore the graph in layers of "hops" from the starting point.

Starting from one point in the graph,

A 4-row binary tree represented by circles connected with lines. Our breadth-first search has us start at the root node at the top of the tree.

we look at all the nodes one level away,

The same 4-row binary tree with all nodes at depth 1 (second row) bolded after being visited.

then all the nodes two levels away,

The same 4-row binary tree with all nodes at depth 2 (third row) bolded after being visited.

then all the nodes three levels away,

The same 4-row binary tree with all nodes at depth 3 (fourth and final row) bolded after being visited.

and so on - until we’ve seen every node.

Key Data Structure: The Queue

The key behind BFS's level-by-level exploration is a queue. A queue is a First-In, First-Out (FIFO) data structure: items are added to the back and removed from the front.

In BFS, a queue stores the nodes we need to explore. When we visit a node, we add its unvisited neighbors to the back of the queue. Then, we process the next node at the front of the queue. This FIFO behavior ensures that we explore all nodes at the current "level" before moving to the next level.

The Visited Set

To prevent infinite loops and redundant processing in graphs (especially those with cycles), we need to keep track of visited nodes. This is where a visited set is essential.

Before adding a neighbor to the queue, we check if it's already in the visited set. If it is, we skip it. Otherwise, we add it to the queue and mark it as visited by adding it to the visited set. Using an efficient set data structure, these checks and inserts can be done in O(1)O(1) time on average.

Path Reconstruction: The Predecessor Dictionary

In many BFS problems, especially shortest path problems, you'll need to reconstruct the path from the starting node to the target node once you find it. To do this, we use a predecessor dictionary (or parent dictionary).

As we explore the graph with BFS, whenever we enqueue a neighbor node, we record in the predecessor dictionary that we reached this neighbor from the current node. This dictionary stores each node and its immediate predecessor in the BFS traversal.

Once BFS finds the target node, we can easily reconstruct the path by starting at the target node and using the predecessor dictionary to trace back to its predecessor, then to its predecessor, and so on, until we reach the starting node. This backtracking process gives us the shortest path in reverse order, which we can then reverse to get the correct path.

Path reconstruction takes time proportional to the length of the shortest path. In the worst case, every node in the graph could be in the path, so reconstructing takes O(V)O(V) overall.

The BFS Algorithm: Step-by-Step

Let's break down the BFS algorithm into clear steps:

  1. Initialization:
    • Create an empty queue, queue.
    • Create an empty predecessor dictionary, predecessors
    • Create an empty set, visited
    • Enqueue the starting node into the queue.
    • Add the starting node to visited and predecessors.
  2. Iteration (while the queue is not empty):
    • Dequeue a node from the front of the queue. Let's call this current_node.
    • Process current_node. This might involve:
      • Checking if it's the target node you're searching for.
      • Performing some operation based on the node's value.
      • Simply noting that you've visited it.
    • Explore Neighbors: Get all valid, unvisited neighbors of current_node.
    • For each unvisited neighbor:
      • Enqueue the neighbor into the queue.
      • Add it to visited.
      • In predecessors, record that we got to this neighbor via current_node
  3. Termination: The algorithm terminates when we reach our target node or the queue becomes empty.

    At this point, you often will reconstruct the path from start_node to end_node using the predecessors dictionary.

  from collections import deque


def reconstruct_path(predecessors, start_node, end_node):
    reversed_shortest_path = []

    # Start from the end of the path and work backwards
    current_node = end_node
    while current_node:
        reversed_shortest_path.append(current_node)
        current_node = predecessors[current_node]

    # Reverse our path to get the right order
    reversed_shortest_path.reverse()  # flip it around, in place
    return reversed_shortest_path  # no longer reversed


def bfs_get_path(graph, start_node, end_node):
    if start_node not in graph:
        raise Exception('Start node not in graph')
    if end_node not in graph:
        raise Exception('End node not in graph')

    queue = deque()
    queue.append(start_node)

    visited = set([start_node])

    predecessors = {start_node: None}

    while len(queue) > 0:
        current_node = queue.popleft()

        # Stop when we reach the end node
        if current_node == end_node:
            return reconstruct_path(predecessors, start_node, end_node)

        for neighbor in graph[current_node]:
            if neighbor not in visited:
                queue.append(neighbor)
                visited.add(neighbor)
                predecessors[neighbor] = current_node

    # If we get here, then we never found the end node
    # so there's NO path from start_node to end_node
    return None

Analyzing Time and Space Complexity of BFS

Once you’ve coded up an algorithm, interviewers are going to ask you to analyze its time and space complexity.

Time Complexity: O(V+E)O(V+E)

VV is the number of vertices (nodes) and EE is the number of edges in the graph. We visit each vertex at most once (due to the visited set). For each vertex, we iterate through its neighbors. In total, across all vertices, we examine each edge at most twice (once for the node on each end). Putting it all together, we’ve got O(V+E)O(V+E) time complexity.

Space Complexity: O(V)O(V)

VV is the number of vertices. Our three data structures are the node queue, the visited set, and the backtracking dictionary. Because each node gets added to the queue exactly once, the queue won’t grow beyond O(V)O(V). Our visited set also grows to hold all the nodes, O(V)O(V) size. Finally, our backtracking dictionary has one entry per node, so it’s O(V)O(V), too. Putting these together, we’re still O(V)O(V) space.

Applications of BFS: Where is BFS Useful?

Because of its versatility, BFS shows up in a lot of places. Look for these problem structures that suggest BFS is the appropriate algorithm:

  • Problems where you’re exploring a set of states and have to find the shortest sequence from one state to another.
  • Problems involving grid-based structures (mazes, grids, etc.) where each step has the same cost.
  • Problems where you need to look at connected items (friends in a social network, links within a website, railroads between cities, etc.).

When approaching a problem that might be solved with BFS, consider these steps:

  • Identify BFS Applicability: Does the problem involve finding the shortest path in an unweighted graph, exploring levels or layers, or searching connected components? If so, BFS might be a good fit.
  • Define the Graph/States: What are the "nodes" in your problem? How are they connected (what are the "edges" or transitions)? This could be explicit graph nodes, grid cells, words in a word ladder, etc.
  • Define Start and End: What is the starting node(s)? Is there a specific target node or condition you are searching for?
  • Define Neighbors: How do you find the valid neighbors of a node? Consider constraints (walls in a maze, valid word transformations, etc.).
  • Handle Boundary Conditions: Consider edge cases like empty inputs, disconnected components, and whether BFS is feasible given the input size.

Sample BFS Interview Questions

Here are some common interview problems where a BFS-style algorithm could work:

Binary Tree Level Order Traversal

Problem Description: Given a binary tree, return its node values in level order traversal. That is, nodes at each level are visited from left to right, level by level.

Applying BFS:

  • Our starting node is the root of the binary tree. We don't have a specific ending node; we'll continue until the queue is empty.
  • Each node's neighbors are their left and right children (if they have them).
  • We can check for an empty tree (comparing the root node to None). And, assuming the tree is a reasonable size, BFS should be feasible.

Shortest Path Through a Maze

Problem Description: Given an ASCII maze where each step has the same cost, find the shortest path from the top left to the bottom right.

  .#######             @####### 
..#.#..#             @@#.#..#
#.....##             #@@@@@##
###.#..#      ==>    ###.#@.#
#...#.##             #...#@##
#.###.#.             #.###@#.
#.....##             #....@##
#####...             #####@@@

Applying BFS:

  • Our starting node is the top left corner. Our ending node is the bottom right corner.
  • Each node's neighbors are the points above, below, left, and right - as long as they're not walls. We need to be careful of boundary conditions, like nodes on the edges of the grid.
  • We can check for edge cases like the start or end position being a wall or mazes where there's no path from the start to the finish.
  • If the maze is very large, it's possible BFS will be too slow or require too much memory. Ask your interviewer how large the maze is expected to be!

Number of Islands

Problem Description: Given a 2D grid indicating land and water, count the number of islands.

Applying BFS:

  • Start with a set of visited nodes, initially empty.
  • Iterate through the entire grid. When we reach a point that's an island:
    • If the point is already visited, do nothing.
    • Otherwise, increase our count by 1 and do BFS from that point outwards. Each BFS visits all the points on a single island. A point's neighbors are adjacent points that are also land.
  • Return the total number of islands seen.

Word Ladder

Problem Description: Given a start word and a target word, find the fewest steps required to transform one into the other, changing only one letter at a time.

As an example, to transform head into tail, we could generate this sequence:

  • head
  • heal
  • teal
  • tell
  • tall
  • tail

Applying BFS:

  • Our starting node is the start word. Our ending node is the target word.
  • Each node's neighbors are words that differ by one letter. As an example, some neighboring words for node would be:
    • code
    • mode
    • nods
    • note
    • rode
    • ...
  • We should check for edge cases like:
    • The start or target not being valid words
    • The start and target being different lengths
    • No possible word ladder

Conclusion: BFS - A Powerful Tool in Your Interview Toolkit

Breadth-First Search is an indispensable algorithm in your coding interview toolkit. Its systematic level-by-level exploration makes it a powerful and efficient approach for a wide range of problems. By mastering the core concepts – the queue, visited set, predecessor dictionary – and practicing its implementation, you'll be well-prepared to confidently tackle BFS-related interview questions.

Remember to:

  • Practice recognizing problems where BFS could be used.
  • Clearly articulate your BFS approach during interviews, highlighting how you are defining nodes, neighbors, and handling visited states.
  • Understand and be able to explain the time and space complexity for different BFS variants.

With practice and a solid understanding of BFS, you'll be ready to impress in your next coding interview! Good luck!

.

Implementation

Here's an implementation in code. We'll split our table of data across a few different dictionaries and a set.

  import heapq

def dijkstra(graph_adjacency_list, start_node):

    # Initially, it costs an unknown amount to get anywhere
    # other than the start_node (which we can get to for free)
    cost_to_get_to = { node : float('inf') for node in graph_adjacency_list }
    cost_to_get_to[start_node] = 0

    # Track which nodes we've already dequeued -- we know
    # we've already found the shortest path to each of them
    # (This is the "checkmark" from our table)
    dequeued_nodes = set()

    # Priority queue ordering cities by the cost to get to them
    priority_queue = []
    for node in graph_adjacency_list:
        heapq.heappush(priority_queue, (cost_to_get_to[node], node))

    while len(priority_queue) > 0:
    
        # Dequeue the next node from our priority queue
        cheapest_cost, cheapest_node = heapq.heappop(priority_queue)
        dequeued_nodes.add(cheapest_node)

        # Update cost_to_get_to for neighboring nodes
        for neighbor, edge_cost in graph_adjacency_list[cheapest_node]:

            # Nodes we dequeued earlier can be skipped
            if neighbor in dequeued_nodes:
                continue

            # Can we get there more cheaply via this neighbor node?
            if cost_to_get_to[cheapest_node] + edge_cost < cost_to_get_to[neighbor]:

                # Update the cost to reach the node
                cost_to_get_to[neighbor] = cost_to_get_to[cheapest_node] + edge_cost

                # Update the cost to reach this node in the priority queue
                heapq.heapupdate(priority_queue, neighbor, cost_to_get_to[neighbor])

    return cost_to_get_to

Our implementation doesn't keep track of how we reached each node, and we don't recover the cheapest paths. Take a stab at adding that feature to the implementation!

Time and Space Complexity

What's the time complexity?

  • Our initialization involves a constant amount of work per node, plus inserting O(N)O(N) nodes into priority_queue will take O(Nlg(N))O(N*lg(N)) time. (Priority queues are built on heaps, which have O(lgn)O(\lg{n}) insertions

    Quick reference

    Worst Case
    get min O(1)O(1)
    remove min O(lg(n))O(lg(n))
    insert O(lg(n))O(lg(n))
    heapify O(n)O(n)
    space O(n)O(n)

    A binary heap is a binary tree where the smallest value is always at the top.

    A binary heap is a binary tree where the nodes are organized to so that the smallest value is always at the top.

    A min-heap has the smallest value at the top. A max-heap has the largest value at the top. We'll describe min-heaps here, but the implementation for max-heaps is nearly identical.

    Strengths:

    • Quickly access the smallest item. Binary heaps allow you to grab the smallest item (the root) in O(1)O(1) time, while keeping other operations relatively cheap (O(lg(n))O(lg(n)) time).

    • Space efficient. Binary heaps are usually implemented with lists, saving the overhead cost of storing pointers to child nodes.

    Weaknesses

    • Limited interface. Binary heaps only provide easy access to the smallest item. Finding other items in the heap takes O(n)O(n) time, since we have to iterate through all the nodes.

    Uses

    Implementation

    Heaps are implemented as complete binary trees.

    In a complete binary tree:

    • each level is filled up before another level is added, and
    • the bottom tier is filled in from left to right.

    As we'll see, this allows us to efficiently store our heap as a list.

    In a heap, every node is smaller than its children.

    The diagram depicts comparing the first level of the diagram with the lower level.
    The diagram depicts comparing the node 5 with the lower level.
    The diagram depicts comparing the node 2 with the lower level.
    The diagram depicts comparing the node 16 with the lowest level.

    Inserting a new item

    Inserting a new item to the original tree.
    1. Add the item to the bottom of the tree.

      To insert a new item into the heap, add it at the bottom of the tree.
    2. Compare the item with its parent. If the new item is smaller, swap the two.

      To insert a new item into the heap, add it at the bottom of the tree.
    3. Continue comparing and swapping, allowing the new item to "bubble up" until the it's larger than its parent.

      To insert a new item into the heap, add it at the bottom of the tree.

    Because our heap is built on a complete binary tree, we know it's also balanced. Which means the height of the tree is lgn\lg{n}. So we'll do at most lgn\lg{n} of these swaps, giving us a total time cost of O(lgn)O(\lg{n}).

    Removing the smallest item

    Easy—it's right there at the top:

    Removing the smallest item from the original tree.

    Now, we have to shuffle some things around to make this a valid heap again.

    1. Take the bottom level's right-most item and move it to the top, to fill in the hole.

      Take the bottom level's right-most item and move it to the top (filling in the hole).
    2. Compare the item with its children.

      Take the bottom level's right-most item and move it to the top (filling in the hole).

      If it's larger than either child, swap the item with the smaller of the two children.

      Take the bottom level's right-most item and move it to the top (filling in the hole).
    3. Continue comparing and swapping, allowing the item to "bubble down" until it's smaller than its children.

      Take the bottom level's right-most item and move it to the top (filling in the hole).

    As with inserting (above), we'll do at most lgn\lg{n} of these swaps, giving us a total time cost of O(lgn)O(\lg{n}).

    Heaps are built on lists

    Complete trees and heaps are often stored as lists, one node after the other, like this:

    Complete trees are often stored as dynamic arrays, one node after the other.

    Using a list to store a complete binary tree is very efficient. Since there are no gaps in complete trees, there are no unused slots in the list. And, inserting a new item in the bottom right part of the tree just means appending to the list.

    But how do we traverse the tree when it's a list? How do we go from a node to its left and right children? With a bit of clever indexing! See if you can come up with the formulas for a node's left child, right child, and parent. Then, check your answer.

    Looking at a few nodes, it's easy enough to derive the general formulas.

    • Left Child: Index 0's left child is at index 1. Index 1's left child is at index 3. And index 2's left child is a 5. In general, a node at index ii's left child will be at index 2i+12*i + 1.

    • Right Child: This node always comes right after the left child. In general, a node at index ii's right child will be at index 2i+22*i + 2.

    • Parent: The nodes at indices 1 and 2 have their parent at index 0. The nodes at indices 3 and 4 have their parent at index 1. And the nodes at indices 5 and 6 have their parent at index 2. In general, a node at index ii has its parent at index (i1)/2(i-1)/2.

    Don't bother memorizing these ... just work through a few examples and you'll be able to come up with them on the fly. Just remember the main idea is that you're multiplying or dividing by 2. This makes sense, because the number of nodes on each level of a complete binary tree doubles as you move down level by level.

    Heapify: Transform a List Into a Heap

    Say we want to make a heap out of the items in a list.

    We could create a new empty heap and add in the items from the list one at a time. If the list has nn elements, then this takes O(nlg(n))O(n*\lg(n)).

    It turns out that there's a more efficient way to transform a list into a heap.

    We'll take our input list and treat it like the nodes in a complete binary tree, just like we did above:

    The list [8, 3, 2, 7, 9, 1, 4] maps to a complete binary tree (8; 3, 2; 7, 9, 1, 4). Nodes within a level are separated by a comma; levels are separated with semicolons.

    To transform the tree into a valid heap, we'll compare each node to its children and move nodes around so that parent nodes are always smaller than their children.

    This causes larger nodes to move lower in the tree, "bubbling down" to allow smaller values to reach the top.

    Look familiar? This is the same bubbling down we were doing to remove items from the heap!

    We'll work from the leaf-nodes at the bottom upwards. To start off, let's look at the leaves.

    The leaf nodes don't have any children, so they don't need to move down at all. Great.

    In the binary tree (8; 3, 2; 7, 9, 1, 4), the bottom 4 nodes (7, 9, 1, 4) correspond to the last four elements in the list [8, 3, 2, 7, 9, 1, 4].

    Let's look at the nodes in the next level:

    In the binary tree (8; 3, 2,; 7, 9, 1, 4), the middle level has two nodes (3, 2), which correspond to the second and third elements in the list [8, 3, 2, 7, 9, 1, 4].

    We'll start with the left node (3) and its children:

    The (3) node has two children: 7 and 9. In the list [8, 3, 2, 7, 9, 1, 4], node (3) is at index 1 and its children are at indices 3 and 4.

    Since 3 is smaller than both 7 and 9, it's already in the right spot.

    But, looking at the right node (2) and its children, since 1 is smaller than 2, we'll swap them.

    The binary tree with highlighted nodes 1, 2 and 4. Nodes 1 and 2 are swapped.

    Notice how we've got two small valid min-heaps. We're getting close!

    The binary tree with highlighted nodes 3, 7, 9, 1, 2, 4.

    Moving up, we've got an 8 at the root.

    The binary tree with highlighted node 8.

    Since 8 is larger than 1, 8 bubbles down, swapping places with the smaller child: 1.

    The binary tree with highlighted nodes 8 and 1 which are swapped.

    Then, we need to compare 8 to its two children—2 and 4. Since 8 is bigger than both of them, we swap with the smaller child, which is 2.

    The binary tree with highlighted nodes 8, 2 and 4. Nodes 8 and 2 are swapped.

    At this point, we've transformed the input tree into a valid min heap. Nice!

    Heapify complexity

    What's the time complexity of heapify'ing a list?

    It's tempting to say it's O(nlg(n))O(n*\lg(n)). After all, we have to examine all nn nodes, and a node might bubble down O(lg(n))O(\lg(n)) levels.

    That's an overestimate of the amount of work though. All of the leaf nodes at the bottom of the tree won't have to move down at all. And the parents of those nodes will only move down once. In fact, there's only one node that might move down O(lg(n))O(\lg(n)) times: the root node.

    Since binary heaps are based on complete binary trees, there will be n/2n/2 nodes in the bottom level, n/4n/4 nodes in the second-to-last level, etc. Each time we go up a level we cut the number of nodes in half.

    A binary tree with 5 rows of nodes. The root node is on top, and every node has 2 children in the row below. Each row is labelled with the number of nodes in the row, which doubles from the top down: 1, 2, 4, 8, 16.

    So, we'll move n/2n/2 nodes on the bottom level 0 times. The n/4n/4 nodes one level up move at most 1 time. Then, n/8n/8 nodes move at most 2 times. And so on, until we get to the root node, which moves lg(n)\lg(n) times.

    Adding this all up, we've got:

    0n2+1n4+2n8+3n16+ 0 * \frac{n}{2} + 1 * \frac{n}{4} + 2 * \frac{n}{8} + 3 * \frac{n}{16} + \ldots

    Alternatively, this can be expressed as a summation:

    ni2i+1 n * \sum \frac{i}{2^{i + 1}}

    The sum is a geometric series that converges to 1/2. (Take our word for it—the arithmetic to prove this gets a bit hairy.) Then, multiplying by nn, we have n/2n/2. That's O(n)O(n).

    ) That's O(Nlg(N))O(N*lg(N)) time overall for all the initialization work.
  • We'll go through the loop O(N)O(N) times, and each time we call remove_min(), taking O(lg(N))O(lg(N)) time (assuming a heap-based priority queue). Over the entire algorithm, that's O(Nlg(N))O(N*lg(N)) time.
  • We'll update cost_to_get_to once per edge, or O(M)O(M) times. Each priority queue update costs O(lg(N))O(lg(N)) time. That's O(Mlg(N))O(M*lg(N)) time overall.

Putting all the steps together, the time complexity for Dijkstra's algorithm is O(Nlg(N)+Mlg(N))O(N*lg(N) + M*lg(N)). Sometimes, this complexity is written O((N+M)lg(n))O((N + M)lg(n)).

What about space complexity? All our data structures hold a constant amount of data for each node in the graph. That's O(N)O(N) space in total.

What's next?

If you're ready to start applying these concepts to some problems, check out our mock coding interview questions.

They mimic a real interview by offering hints when you're stuck or you're missing an optimization.

Try some questions now

. . .