Graph
Search from both start and goal simultaneously, expanding the smaller frontier each step. When frontiers meet, the shortest path is found. Reduces O(b^d) to O(b^(d/2)).
Consider LeetCode 127. Word Ladder. You are given a start word, an end word, and a dictionary of valid words. You need to find the shortest transformation sequence from start to end, where each step changes exactly one letter and every intermediate word must be in the dictionary. For example, transforming "hit" to "cog" through the dictionary ["hot", "dot", "dog", "lot", "log", "cog"] gives the chain hit -> hot -> dot -> dog -> cog, which has length 5.
This is a shortest-path problem on an unweighted graph, so BFS is the natural choice. But consider the scale of the search. If each word has length L and the alphabet has 26 letters, each word has up to neighbors to check. In general, if the branching factor is and the shortest path has depth , standard BFS explores nodes before finding the target. For a branching factor of 50 and a path of depth 10, that is nodes. Far too many.
Bidirectional BFS starts two simultaneous searches: one forward from the start node and one backward from the end node. Instead of one frontier expanding through levels, you get two frontiers each expanding through roughly levels. The search terminates as soon as the two frontiers meet.
The node count drops dramatically. Each direction explores nodes, so the total is . With our earlier numbers, that is , a reduction by a factor of . This is the same "meet in the middle" principle used in other algorithms, applied to graph search.
Maintain two sets representing the visited nodes from each direction, and two frontier sets representing the current wavefronts. At each step:
Expanding the smaller frontier ensures that the two search trees stay roughly balanced, minimizing the total number of nodes explored.
Start word: "hit". End word: "cog". Dictionary: {"hot", "dot", "dog", "lot", "log", "cog"}.
Step 0. Forward frontier: {"hit"}. Backward frontier: {"cog"}. Depth: 1.
Step 1. Forward frontier is smaller (tie, pick either). Expand "hit": change each letter to every possibility and check the dictionary. "hot" is valid. New forward frontier: {"hot"}. Forward visited: {"hit", "hot"}. Depth: 2.
Step 2. Both frontiers have size 1 (tie). Expand backward frontier {"cog"}: neighbors in dictionary are "dog" and "log". New backward frontier: {"dog", "log"}. Backward visited: {"cog", "dog", "log"}. Depth: 3.
Step 3. Forward frontier {"hot"} is smaller (size 1 vs 2). Expand "hot": neighbors are "dot" and "lot". New forward frontier: {"dot", "lot"}. Forward visited: {"hit", "hot", "dot", "lot"}. Depth: 4.
Step 4. Both frontiers have size 2. Expand backward frontier {"dog", "log"}. Neighbors of "dog": "dot" and "dog" -> "cog" (already visited). "dot" is in the forward visited set. The frontiers have met.
The total depth is 5 (the current depth counter). We explored roughly 10 nodes total. A unidirectional BFS would have explored the entire reachable graph.
from collections import defaultdict
def ladder_length(begin_word, end_word, word_list):
word_set = set(word_list)
if end_word not in word_set:
return 0
front = {begin_word}
back = {end_word}
visited = {begin_word, end_word}
depth = 1
while front and back:
depth += 1
# Always expand the smaller frontier
if len(front) > len(back):
front, back = back, front
next_front = set()
for word in front:
for i in range(len(word)):
for c in 'abcdefghijklmnopqrstuvwxyz':
candidate = word[:i] + c + word[i+1:]
if candidate in back:
return depth
if candidate in word_set and candidate not in visited:
visited.add(candidate)
next_front.add(candidate)
front = next_front
return 0The key details: front and back are the two frontier sets. Before expanding, we swap them if front is larger so we always expand the smaller one. For each word in the current frontier, we try all single-character mutations. If a mutation appears in the opposite frontier, the two searches have met and we return the depth. Otherwise, if the mutation is a valid unvisited word, we add it to the next frontier.
Using sets for both frontiers and the visited collection makes the membership checks , which is essential for performance.
LeetCode 752. Open the Lock is another natural fit. You have a lock with 4 circular dials, each showing digits 0-9. The lock starts at "0000" and you need to reach a target combination. Each move turns one dial one slot up or down (9 wraps to 0 and vice versa). Some combinations are "deadends" that lock the mechanism permanently. Find the minimum number of moves.
This is BFS on a graph where each node is a 4-digit string and each node has 8 neighbors (4 dials times 2 directions). The branching factor is 8 and the state space has nodes. Bidirectional BFS helps because you know both the start ("0000") and the target.
def open_lock(deadends, target):
dead = set(deadends)
if "0000" in dead:
return -1
if target == "0000":
return 0
front = {"0000"}
back = {target}
visited = {"0000", target}
depth = 0
while front and back:
depth += 1
if len(front) > len(back):
front, back = back, front
next_front = set()
for state in front:
for i in range(4):
d = int(state[i])
for delta in (1, -1):
nd = (d + delta) % 10
candidate = state[:i] + str(nd) + state[i+1:]
if candidate in back:
return depth
if candidate not in visited and candidate not in dead:
visited.add(candidate)
next_front.add(candidate)
front = next_front
return -1The structure is identical to Word Ladder. The only differences are how neighbors are generated (dial turns instead of letter substitutions) and the handling of deadends.
Bidirectional BFS requires three conditions:
You must know the target state. The algorithm searches backward from the goal, so the goal must be a specific, known node. Word Ladder gives you the end word. Open the Lock gives you the target combination. If the problem says "find the nearest node satisfying some property" without specifying which node that is, you cannot search backward from it.
The graph must be unweighted (or uniformly weighted). Bidirectional BFS relies on both frontiers advancing at the same rate. If edges have different weights, the frontiers do not stay synchronized and meeting in the middle does not guarantee the shortest path. For weighted graphs, you would need bidirectional Dijkstra, which is significantly more complex.
The graph must have the possibility of meeting. In a tree (no cycles, single path between any two nodes), the forward and backward searches follow the same path and there is no savings. Bidirectional BFS shines on dense graphs with high branching factors, where the two expanding spheres meet in a large shared middle region.
Unknown target state. Problems like "find the nearest exit in a maze" (where any boundary cell could be the exit) do not have a single known endpoint. You cannot start a backward search from an unknown set of goals. Multi-source BFS from all exits would be the right approach there.
Tree-structured search spaces. If there is only one path from start to goal, both searches explore the same nodes and you gain nothing.
Very small search spaces. If standard BFS already runs quickly, bidirectional BFS adds code complexity without meaningful speedup. The overhead of maintaining two frontiers, checking intersections, and swapping is only worthwhile when the search space is large.
Asymmetric branching. If the backward graph has a much higher branching factor than the forward graph (or vice versa), bidirectional BFS may not save as much as expected. The "expand the smaller frontier" heuristic mitigates this, but in extreme cases one-directional BFS with pruning might be simpler.
Let be the branching factor and be the depth of the shortest path.
Standard BFS: explores nodes. Time and space are both .
Bidirectional BFS: each direction explores roughly levels. The forward search explores nodes and the backward search explores nodes. Total nodes explored: . Time and space are both .
The ratio of improvement is . For (roughly the branching factor for 5-letter words) and , this is , a ten-million-fold reduction.
For the Word Ladder problem specifically, where is the word length and is the dictionary size, generating neighbors takes per word. Standard BFS visits up to words, giving total time. Bidirectional BFS visits fewer words in practice but has the same worst-case bound of since the dictionary size caps the exploration. The practical improvement comes from visiting far fewer words on average, not from a change in worst-case complexity relative to .
For Open the Lock, the state space has states, each with 8 neighbors. Both standard and bidirectional BFS are in the worst case, but bidirectional BFS typically terminates much earlier because the two frontiers collide quickly in the densely connected state graph.
You're probably looking at bidirectional BFS when:
Common templates: