|
| 1 | +""" |
| 2 | +Bidirectional Search Algorithm. |
| 3 | +
|
| 4 | +This algorithm searches from both the source and target nodes simultaneously, |
| 5 | +meeting somewhere in the middle. This approach can significantly reduce the |
| 6 | +search space compared to a traditional one-directional search. |
| 7 | +
|
| 8 | +Time Complexity: O(b^(d/2)) where b is the branching factor and d is the depth |
| 9 | +Space Complexity: O(b^(d/2)) |
| 10 | +
|
| 11 | +https://en.wikipedia.org/wiki/Bidirectional_search |
| 12 | +""" |
| 13 | + |
| 14 | +from collections import deque |
| 15 | + |
| 16 | + |
| 17 | +def expand_search( |
| 18 | + graph: dict[int, list[int]], |
| 19 | + queue: deque[int], |
| 20 | + parents: dict[int, int | None], |
| 21 | + opposite_direction_parents: dict[int, int | None], |
| 22 | +) -> int | None: |
| 23 | + if not queue: |
| 24 | + return None |
| 25 | + |
| 26 | + current = queue.popleft() |
| 27 | + for neighbor in graph[current]: |
| 28 | + if neighbor in parents: |
| 29 | + continue |
| 30 | + |
| 31 | + parents[neighbor] = current |
| 32 | + queue.append(neighbor) |
| 33 | + |
| 34 | + # Check if this creates an intersection |
| 35 | + if neighbor in opposite_direction_parents: |
| 36 | + return neighbor |
| 37 | + |
| 38 | + return None |
| 39 | + |
| 40 | + |
| 41 | +def construct_path(current: int | None, parents: dict[int, int | None]) -> list[int]: |
| 42 | + path: list[int] = [] |
| 43 | + while current is not None: |
| 44 | + path.append(current) |
| 45 | + current = parents[current] |
| 46 | + return path |
| 47 | + |
| 48 | + |
| 49 | +def bidirectional_search( |
| 50 | + graph: dict[int, list[int]], start: int, goal: int |
| 51 | +) -> list[int] | None: |
| 52 | + """ |
| 53 | + Perform bidirectional search on a graph to find the shortest path. |
| 54 | +
|
| 55 | + Args: |
| 56 | + graph: A dictionary where keys are nodes and values are lists of adjacent nodes |
| 57 | + start: The starting node |
| 58 | + goal: The target node |
| 59 | +
|
| 60 | + Returns: |
| 61 | + A list representing the path from start to goal, or None if no path exists |
| 62 | +
|
| 63 | + Examples: |
| 64 | + >>> graph = { |
| 65 | + ... 0: [1, 2], |
| 66 | + ... 1: [0, 3, 4], |
| 67 | + ... 2: [0, 5, 6], |
| 68 | + ... 3: [1, 7], |
| 69 | + ... 4: [1, 8], |
| 70 | + ... 5: [2, 9], |
| 71 | + ... 6: [2, 10], |
| 72 | + ... 7: [3, 11], |
| 73 | + ... 8: [4, 11], |
| 74 | + ... 9: [5, 11], |
| 75 | + ... 10: [6, 11], |
| 76 | + ... 11: [7, 8, 9, 10], |
| 77 | + ... } |
| 78 | + >>> bidirectional_search(graph=graph, start=0, goal=11) |
| 79 | + [0, 1, 3, 7, 11] |
| 80 | + >>> bidirectional_search(graph=graph, start=5, goal=5) |
| 81 | + [5] |
| 82 | + >>> disconnected_graph = { |
| 83 | + ... 0: [1, 2], |
| 84 | + ... 1: [0], |
| 85 | + ... 2: [0], |
| 86 | + ... 3: [4], |
| 87 | + ... 4: [3], |
| 88 | + ... } |
| 89 | + >>> bidirectional_search(graph=disconnected_graph, start=0, goal=3) is None |
| 90 | + True |
| 91 | + """ |
| 92 | + if start == goal: |
| 93 | + return [start] |
| 94 | + |
| 95 | + # Check if start and goal are in the graph |
| 96 | + if start not in graph or goal not in graph: |
| 97 | + return None |
| 98 | + |
| 99 | + # Initialize forward and backward search dictionaries |
| 100 | + # Each maps a node to its parent in the search |
| 101 | + forward_parents: dict[int, int | None] = {start: None} |
| 102 | + backward_parents: dict[int, int | None] = {goal: None} |
| 103 | + |
| 104 | + # Initialize forward and backward search queues |
| 105 | + forward_queue = deque([start]) |
| 106 | + backward_queue = deque([goal]) |
| 107 | + |
| 108 | + # Intersection node (where the two searches meet) |
| 109 | + intersection = None |
| 110 | + |
| 111 | + # Continue until both queues are empty or an intersection is found |
| 112 | + while forward_queue and backward_queue and intersection is None: |
| 113 | + # Expand forward search |
| 114 | + intersection = expand_search( |
| 115 | + graph=graph, |
| 116 | + queue=forward_queue, |
| 117 | + parents=forward_parents, |
| 118 | + opposite_direction_parents=backward_parents, |
| 119 | + ) |
| 120 | + |
| 121 | + # If no intersection found, expand backward search |
| 122 | + if intersection is not None: |
| 123 | + break |
| 124 | + |
| 125 | + intersection = expand_search( |
| 126 | + graph=graph, |
| 127 | + queue=backward_queue, |
| 128 | + parents=backward_parents, |
| 129 | + opposite_direction_parents=forward_parents, |
| 130 | + ) |
| 131 | + |
| 132 | + # If no intersection found, there's no path |
| 133 | + if intersection is None: |
| 134 | + return None |
| 135 | + |
| 136 | + # Construct path from start to intersection |
| 137 | + forward_path: list[int] = construct_path( |
| 138 | + current=intersection, parents=forward_parents |
| 139 | + ) |
| 140 | + forward_path.reverse() |
| 141 | + |
| 142 | + # Construct path from intersection to goal |
| 143 | + backward_path: list[int] = construct_path( |
| 144 | + current=backward_parents[intersection], parents=backward_parents |
| 145 | + ) |
| 146 | + |
| 147 | + # Return the complete path |
| 148 | + return forward_path + backward_path |
| 149 | + |
| 150 | + |
| 151 | +def main() -> None: |
| 152 | + """ |
| 153 | + Run example of bidirectional search algorithm. |
| 154 | +
|
| 155 | + Examples: |
| 156 | + >>> main() # doctest: +NORMALIZE_WHITESPACE |
| 157 | + Path from 0 to 11: [0, 1, 3, 7, 11] |
| 158 | + Path from 5 to 5: [5] |
| 159 | + Path from 0 to 3: None |
| 160 | + """ |
| 161 | + # Example graph represented as an adjacency list |
| 162 | + example_graph = { |
| 163 | + 0: [1, 2], |
| 164 | + 1: [0, 3, 4], |
| 165 | + 2: [0, 5, 6], |
| 166 | + 3: [1, 7], |
| 167 | + 4: [1, 8], |
| 168 | + 5: [2, 9], |
| 169 | + 6: [2, 10], |
| 170 | + 7: [3, 11], |
| 171 | + 8: [4, 11], |
| 172 | + 9: [5, 11], |
| 173 | + 10: [6, 11], |
| 174 | + 11: [7, 8, 9, 10], |
| 175 | + } |
| 176 | + |
| 177 | + # Test case 1: Path exists |
| 178 | + start, goal = 0, 11 |
| 179 | + path = bidirectional_search(graph=example_graph, start=start, goal=goal) |
| 180 | + print(f"Path from {start} to {goal}: {path}") |
| 181 | + |
| 182 | + # Test case 2: Start and goal are the same |
| 183 | + start, goal = 5, 5 |
| 184 | + path = bidirectional_search(graph=example_graph, start=start, goal=goal) |
| 185 | + print(f"Path from {start} to {goal}: {path}") |
| 186 | + |
| 187 | + # Test case 3: No path exists (disconnected graph) |
| 188 | + disconnected_graph = { |
| 189 | + 0: [1, 2], |
| 190 | + 1: [0], |
| 191 | + 2: [0], |
| 192 | + 3: [4], |
| 193 | + 4: [3], |
| 194 | + } |
| 195 | + start, goal = 0, 3 |
| 196 | + path = bidirectional_search(graph=disconnected_graph, start=start, goal=goal) |
| 197 | + print(f"Path from {start} to {goal}: {path}") |
| 198 | + |
| 199 | + |
| 200 | +if __name__ == "__main__": |
| 201 | + main() |
0 commit comments