Artificial Intelligence: Bidirectional search

Bidirectional search is an AI search algorithm that simultaneously explores the search space from both the initial state (forward) and the goal state (backward), aiming to find a path between them.


Key Concepts

  • Forward Search: Starts from the initial state and expands nodes toward the goal.
  • Backward Search: Starts from the goal state and expands nodes toward the initial state.
  • Intersection: The search continues until the two search frontiers meet, indicating a path has been found.
  • Efficiency: By searching from both directions, it can significantly reduce the number of nodes explored compared to unidirectional search (e.g., BFS or DFS), as it effectively halves the search depth.


How It Works

  1. Maintain two queues: one for forward search (from start) and one for backward search (from goal).
  2. Expand nodes alternately from each queue, using a strategy like breadth-first search (BFS).
  3. Check if a newly expanded node from one direction exists in the other direction’s frontier or visited set.
  4. If an intersection is found, reconstruct the path by tracing back from the intersection node to both the start and goal.


Advantages

  • Faster Convergence: Reduces the exponential growth of the search tree by meeting in the middle.
  • Memory Efficient: When implemented with BFS, it guarantees the shortest path with fewer nodes explored.
  • Applicable to Large Problems: Useful in problems like route planning, puzzle-solving (e.g., 8-puzzle), or graph search.


Disadvantages

  • Memory Usage: Requires storing two frontiers and visited sets, which can be memory-intensive.
  • Goal State Requirement: Needs a well-defined goal state (or set of goal states) to start the backward search.
  • Not Always Optimal: If using heuristic-based searches (e.g., A*), ensuring optimality requires additional checks.


Example

In a maze, bidirectional search starts from the entrance and exit, exploring possible paths until a common point is found. For instance, in a graph, if searching from node A to Z:

  • Forward: Explore neighbors of A, then their neighbors, etc.
  • Backward: Explore predecessors of Z, then their predecessors, etc.
  • When a node (say, M) appears in both searches, a path A → M → Z exists.


Complexity

  • Time Complexity: O(b^(d/2)), where b is the branching factor and d is the depth of the solution. This is much better than O(b^d) for unidirectional BFS.
  • Space Complexity: O(b^(d/2)) for storing nodes, which can still be significant.


Applications

  • Pathfinding: Navigation systems, robotics.
  • Puzzle Solving: Games like Rubik’s Cube or sliding tile puzzles.
  • Network Analysis: Finding shortest paths in social or communication networks.

Example Code:
import matplotlib.pyplot as plt
import numpy as np
from collections import deque

def is_valid_move(x, y, maze):
    return 0 <= x < len(maze) and 0 <= y < len(maze[0]) and maze[x][y] == 0

def bfs(queue, visited, parent):
    (x, y) = queue.popleft()
    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]  # Up, Down, Left, Right moves
    for dx, dy in directions:
        nx, ny = x + dx, y + dy
        if is_valid_move(nx, ny, maze) and (nx, ny) not in visited:
            queue.append((nx, ny))
            visited.add((nx, ny))
            parent[(nx, ny)] = (x, y)

def bidirectional_search(maze, start, goal):
    if maze[start[0]][start[1]] == 1 or maze[goal[0]][goal[1]] == 1:
        return None, None, None
    
    queue_start = deque([start])
    queue_goal = deque([goal])
    visited_start = set([start])
    visited_goal = set([goal])
    parent_start = {start: None}
    parent_goal = {goal: None}
    
    while queue_start and queue_goal:
        bfs(queue_start, visited_start, parent_start)
        bfs(queue_goal, visited_goal, parent_goal)
        
        # Check for intersection
        intersect_node = None
        for node in visited_start:
            if node in visited_goal:
                intersect_node = node
                break
        
        if intersect_node is not None:
            return (intersect_node, parent_start, parent_goal)
    
    return (None, None, None)

def reconstruct_path(intersect_node, parent_start, parent_goal):
    if intersect_node is None:
        return []
    
    path = []
    # from start to intersection
    step = intersect_node
    while step is not None:
        path.append(step)
        step = parent_start[step]
    path.reverse()
    
    # from intersection to goal
    step = parent_goal[intersect_node]
    while step is not None:
        path.append(step)
        step = parent_goal[step]
    
    return path

def visualize(maze, path, start, goal):
    maze_copy = np.array(maze)
    fig, ax = plt.subplots(figsize=(10, 10))
    
    # Coloring the maze
    cmap = plt.cm.Dark2
    colors = {'empty': 0, 'wall': 1, 'path': 2}
    for y in range(len(maze)):
        for x in range(len(maze[0])):
            color = 'white' if maze[y][x] == 0 else 'black'
            ax.fill_between([x, x+1], y, y+1, color=color)

    # Drawing the path
    if path:
        for (y, x) in path:
            ax.fill_between([x, x+1], y, y+1, color='gold', alpha=0.5)
    
    # Mark start and goal
    sy, sx = start
    gy, gx = goal
    ax.plot(sx+0.5, sy+0.5, 'go')  # green dot at start
    ax.plot(gx+0.5, gy+0.5, 'ro')  # red dot at goal

    # Set limits and grid
    ax.set_xlim(0, len(maze[0]))
    ax.set_ylim(0, len(maze))
    ax.set_xticks(range(len(maze[0])))
    ax.set_yticks(range(len(maze)))
    ax.grid(which='both')
    ax.invert_yaxis()  # Invert the y-axis so the first row is at the top
    ax.xaxis.tick_top()  # and the x-axis is on the top

    plt.show()

# Define the maze
maze = [
    [0, 1, 0, 0, 0],
    [0, 1, 0, 1, 0],
    [0, 0, 0, 1, 0],
    [0, 1, 0, 0, 0],
    [0, 0, 0, 1, 0]
]

start = (0, 0)
goal = (4, 4)

intersect_node, parent_start, parent_goal = bidirectional_search(maze, start, goal)
path = reconstruct_path(intersect_node, parent_start, parent_goal)
visualize(maze, path, start, goal)
Output:
bidirection search


Practical Applications of Bidirectional Search in AI


Bidirectional search is not just a theoretical construct but has practical applications in various fields within AI:

* Pathfinding in AI Navigation Systems: Commonly used in scenarios ranging from GPS navigation to movement planning in robotics.

* Puzzle Solving: Effective in games and puzzle-solving where a clear goal state is defined, such as in solving Rubik's Cube.

* Network Analysis: Useful in social network analysis, where connections between entities (like finding the degree of separation between two people) need to be established quickly.