Add bidirectional search algorithm implementation (#12649)

* Add bidirectional search algorithm implementation

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix style and linting issues in bidirectional search

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add doctest for main function

* Add doctest for main function

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fixed deprications

* fixed deprications

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* removed unused import

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update bidirectional_search.py

* Update bidirectional_search.py

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update bidirectional_search.py

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Maxim Smolskiy <mithridatus@mail.ru>
This commit is contained in:
Prajwal Choudhari 2025-05-22 20:38:37 +05:30 committed by GitHub
parent 26ad6891d8
commit ca445f5296
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,201 @@
"""
Bidirectional Search Algorithm.
This algorithm searches from both the source and target nodes simultaneously,
meeting somewhere in the middle. This approach can significantly reduce the
search space compared to a traditional one-directional search.
Time Complexity: O(b^(d/2)) where b is the branching factor and d is the depth
Space Complexity: O(b^(d/2))
https://en.wikipedia.org/wiki/Bidirectional_search
"""
from collections import deque
def expand_search(
graph: dict[int, list[int]],
queue: deque[int],
parents: dict[int, int | None],
opposite_direction_parents: dict[int, int | None],
) -> int | None:
if not queue:
return None
current = queue.popleft()
for neighbor in graph[current]:
if neighbor in parents:
continue
parents[neighbor] = current
queue.append(neighbor)
# Check if this creates an intersection
if neighbor in opposite_direction_parents:
return neighbor
return None
def construct_path(current: int | None, parents: dict[int, int | None]) -> list[int]:
path: list[int] = []
while current is not None:
path.append(current)
current = parents[current]
return path
def bidirectional_search(
graph: dict[int, list[int]], start: int, goal: int
) -> list[int] | None:
"""
Perform bidirectional search on a graph to find the shortest path.
Args:
graph: A dictionary where keys are nodes and values are lists of adjacent nodes
start: The starting node
goal: The target node
Returns:
A list representing the path from start to goal, or None if no path exists
Examples:
>>> graph = {
... 0: [1, 2],
... 1: [0, 3, 4],
... 2: [0, 5, 6],
... 3: [1, 7],
... 4: [1, 8],
... 5: [2, 9],
... 6: [2, 10],
... 7: [3, 11],
... 8: [4, 11],
... 9: [5, 11],
... 10: [6, 11],
... 11: [7, 8, 9, 10],
... }
>>> bidirectional_search(graph=graph, start=0, goal=11)
[0, 1, 3, 7, 11]
>>> bidirectional_search(graph=graph, start=5, goal=5)
[5]
>>> disconnected_graph = {
... 0: [1, 2],
... 1: [0],
... 2: [0],
... 3: [4],
... 4: [3],
... }
>>> bidirectional_search(graph=disconnected_graph, start=0, goal=3) is None
True
"""
if start == goal:
return [start]
# Check if start and goal are in the graph
if start not in graph or goal not in graph:
return None
# Initialize forward and backward search dictionaries
# Each maps a node to its parent in the search
forward_parents: dict[int, int | None] = {start: None}
backward_parents: dict[int, int | None] = {goal: None}
# Initialize forward and backward search queues
forward_queue = deque([start])
backward_queue = deque([goal])
# Intersection node (where the two searches meet)
intersection = None
# Continue until both queues are empty or an intersection is found
while forward_queue and backward_queue and intersection is None:
# Expand forward search
intersection = expand_search(
graph=graph,
queue=forward_queue,
parents=forward_parents,
opposite_direction_parents=backward_parents,
)
# If no intersection found, expand backward search
if intersection is not None:
break
intersection = expand_search(
graph=graph,
queue=backward_queue,
parents=backward_parents,
opposite_direction_parents=forward_parents,
)
# If no intersection found, there's no path
if intersection is None:
return None
# Construct path from start to intersection
forward_path: list[int] = construct_path(
current=intersection, parents=forward_parents
)
forward_path.reverse()
# Construct path from intersection to goal
backward_path: list[int] = construct_path(
current=backward_parents[intersection], parents=backward_parents
)
# Return the complete path
return forward_path + backward_path
def main() -> None:
"""
Run example of bidirectional search algorithm.
Examples:
>>> main() # doctest: +NORMALIZE_WHITESPACE
Path from 0 to 11: [0, 1, 3, 7, 11]
Path from 5 to 5: [5]
Path from 0 to 3: None
"""
# Example graph represented as an adjacency list
example_graph = {
0: [1, 2],
1: [0, 3, 4],
2: [0, 5, 6],
3: [1, 7],
4: [1, 8],
5: [2, 9],
6: [2, 10],
7: [3, 11],
8: [4, 11],
9: [5, 11],
10: [6, 11],
11: [7, 8, 9, 10],
}
# Test case 1: Path exists
start, goal = 0, 11
path = bidirectional_search(graph=example_graph, start=start, goal=goal)
print(f"Path from {start} to {goal}: {path}")
# Test case 2: Start and goal are the same
start, goal = 5, 5
path = bidirectional_search(graph=example_graph, start=start, goal=goal)
print(f"Path from {start} to {goal}: {path}")
# Test case 3: No path exists (disconnected graph)
disconnected_graph = {
0: [1, 2],
1: [0],
2: [0],
3: [4],
4: [3],
}
start, goal = 0, 3
path = bidirectional_search(graph=disconnected_graph, start=start, goal=goal)
print(f"Path from {start} to {goal}: {path}")
if __name__ == "__main__":
main()