diff --git a/graphs/bidirectional_search.py b/graphs/bidirectional_search.py new file mode 100644 index 000000000000..0c609e5509f7 --- /dev/null +++ b/graphs/bidirectional_search.py @@ -0,0 +1,180 @@ +""" +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 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, 0, 11) + [0, 1, 3, 7, 11] + >>> bidirectional_search(graph, 5, 5) + [5] + >>> disconnected_graph = { + ... 0: [1, 2], + ... 1: [0], + ... 2: [0], + ... 3: [4], + ... 4: [3], + ... } + >>> bidirectional_search(disconnected_graph, 0, 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 + if forward_queue: + current = forward_queue.popleft() + for neighbor in graph[current]: + if neighbor not in forward_parents: + forward_parents[neighbor] = current + forward_queue.append(neighbor) + + # Check if this creates an intersection + if neighbor in backward_parents: + intersection = neighbor + break + + # If no intersection found, expand backward search + if intersection is None and backward_queue: + current = backward_queue.popleft() + for neighbor in graph[current]: + if neighbor not in backward_parents: + backward_parents[neighbor] = current + backward_queue.append(neighbor) + + # Check if this creates an intersection + if neighbor in forward_parents: + intersection = neighbor + break + + # If no intersection found, there's no path + if intersection is None: + return None + + # Construct path from start to intersection + forward_path: list[int] = [] + current_forward: int | None = intersection + while current_forward is not None: + forward_path.append(current_forward) + current_forward = forward_parents[current_forward] + forward_path.reverse() + + # Construct path from intersection to goal + backward_path: list[int] = [] + current_backward: int | None = backward_parents[intersection] + while current_backward is not None: + backward_path.append(current_backward) + current_backward = backward_parents[current_backward] + + # 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(example_graph, start, 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(example_graph, start, 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(disconnected_graph, start, goal) + print(f"Path from {start} to {goal}: {path}") + + +if __name__ == "__main__": + main()