DEV Community

Cover image for A* Search Algorithm and Iterative Deepening A* in JavaScript
Piyush Chauhan
Piyush Chauhan

Posted on

A* Search Algorithm and Iterative Deepening A* in JavaScript

1. Introduction

Search algorithms play a crucial role in solving problems across computer science, robotics, game development, and beyond. Among them, heuristic-based search algorithms stand out for their ability to efficiently find solutions by leveraging additional knowledge about the problem domain. This article delves into two such algorithms: the widely used A* search algorithm and its memory-efficient counterpart, Iterative Deepening A* (IDA*).

By the end of this article, you will:

  • Understand the core principles of A* and IDA*.
  • Learn how these algorithms work through code implementations in JavaScript.
  • Explore their mathematical underpinnings and use cases.

2. Understanding A* Search Algorithm

What is A*?

A* is a best-first search algorithm that aims to find the shortest path from a start node to a goal node. It uses an evaluation function:

f(n)=g(n)+h(n) f(n) = g(n) + h(n)

Where:

  • g(n)g(n) : The cost of the path from the start node to the current node nn .
  • h(n)h(n) : A heuristic estimate of the cost to reach the goal node from nn .

The algorithm prioritizes nodes with the smallest f(n)f(n) value, making it both optimal and complete, provided the heuristic h(n)h(n) is admissible (never overestimates the true cost).

Properties of A*

  • Completeness: Guaranteed to find a solution if one exists.
  • Optimality: Finds the shortest path with an admissible heuristic.
  • Admissibility and Consistency: Ensures the heuristic is reliable for optimal solutions.

Wikipedia Pseudocode for A*

function reconstruct_path(cameFrom, current)
    total_path := {current}
    while current in cameFrom.Keys:
        current := cameFrom[current]
        total_path.prepend(current)
    return total_path

// A* finds a path from start to goal.
// h is the heuristic function. h(n) estimates the cost to reach goal from node n.
function A_Star(start, goal, h)
    // The set of discovered nodes that may need to be (re-)expanded.
    // Initially, only the start node is known.
    // This is usually implemented as a min-heap or priority queue rather than a hash-set.
    openSet := {start}

    // For node n, cameFrom[n] is the node immediately preceding it on the cheapest path from the start
    // to n currently known.
    cameFrom := an empty map

    // For node n, gScore[n] is the currently known cost of the cheapest path from start to n.
    gScore := map with default value of Infinity
    gScore[start] := 0

    // For node n, fScore[n] := gScore[n] + h(n). fScore[n] represents our current best guess as to
    // how cheap a path could be from start to finish if it goes through n.
    fScore := map with default value of Infinity
    fScore[start] := h(start)

    while openSet is not empty
        // This operation can occur in O(Log(N)) time if openSet is a min-heap or a priority queue
        current := the node in openSet having the lowest fScore[] value
        if current = goal
            return reconstruct_path(cameFrom, current)

        openSet.Remove(current)
        for each neighbor of current
            // d(current,neighbor) is the weight of the edge from current to neighbor
            // tentative_gScore is the distance from start to the neighbor through current
            tentative_gScore := gScore[current] + d(current, neighbor)
            if tentative_gScore < gScore[neighbor]
                // This path to neighbor is better than any previous one. Record it!
                cameFrom[neighbor] := current
                gScore[neighbor] := tentative_gScore
                fScore[neighbor] := tentative_gScore + h(neighbor)
                if neighbor not in openSet
                    openSet.add(neighbor)

    // Open set is empty but goal was never reached
    return failure
Enter fullscreen mode Exit fullscreen mode

3. Implementing A* in JavaScript

Below is a JavaScript implementation of A* for a grid-based shortest path problem:

const assert = require("assert");

class PriorityQueue {
  constructor() {
    this.elements = [];
  }

  enqueue(item, priority) {
    this.elements.push({ item, priority });
    this.elements.sort((a, b) => a.priority - b.priority);
  }

  dequeue() {
    return this.elements.shift().item;
  }

  isEmpty() {
    return this.elements.length === 0;
  }
}

function aStar(start, goal, grid, heuristic) {
  const rows = grid.length;
  const cols = grid[0].length;

  const openSet = new PriorityQueue();
  openSet.enqueue(start, 0);

  const cameFrom = {};
  const gScore = Array.from({ length: rows }, () => Array(cols).fill(Infinity));
  gScore[start[0]][start[1]] = 0;

  const fScore = Array.from({ length: rows }, () => Array(cols).fill(Infinity));
  fScore[start[0]][start[1]] = heuristic(start, goal);

  while (!openSet.isEmpty()) {
    const current = openSet.dequeue();
    const [x, y] = current;

    if (current[0] === goal[0] && current[1] === goal[1]) {
      return reconstructPath(cameFrom, current);
    }

    for (const [dx, dy] of [
      [0, 1],
      [1, 0],
      [0, -1],
      [-1, 0],
    ]) {
      const neighbor = [x + dx, y + dy];
      const [nx, ny] = neighbor;

      if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && grid[nx][ny] === 0) {
        const tentative_gScore = gScore[x][y] + 1;

        if (tentative_gScore < gScore[nx][ny]) {
          cameFrom[neighbor] = current;
          gScore[nx][ny] = tentative_gScore;
          fScore[nx][ny] = tentative_gScore + heuristic(neighbor, goal);

          openSet.enqueue(neighbor, fScore[nx][ny]);
        }
      }
    }
  }
  return "No path found";
}

function heuristic([x1, y1], [x2, y2]) {
  return Math.abs(x1 - x2) + Math.abs(y1 - y2);
}

function reconstructPath(cameFrom, current) {
  const path = [current];
  while (cameFrom[current]) {
    current = cameFrom[current];
    path.unshift(current);
  }
  return path;
}

// Test A* Algorithm
const grid = [
  [0, 0, 0, 0],
  [1, 1, 1, 0],
  [0, 0, 0, 0],
  [0, 1, 1, 1],
  [0, 0, 0, 0],
];

const start = [0, 0];
const goal = [4, 3];
const path = aStar(start, goal, grid, heuristic);
console.log("Found Path:", path);
assert.notStrictEqual(path, "No path found");
assert.strictEqual(Array.isArray(path), true);

// Test for no path
const blockedGrid = [
  [0, 1, 0],
  [1, 1, 0],
  [0, 1, 0],
];
const noPathResult = aStar([0, 0], [2, 2], blockedGrid, heuristic);
assert.strictEqual(noPathResult, "No path found");
Enter fullscreen mode Exit fullscreen mode

4. Mathematical Proofs and Analysis

Proof of Optimality

A* guarantees the shortest path if the heuristic h(n)h(n) is admissible and consistent.

  1. Admissibility: h(n)h(n) never overestimates the true cost to the goal. This ensures that A* will not bypass the optimal path.
  2. Consistency: For every pair of nodes nn and nn' , the heuristic satisfies: h(n)c(n,n)+h(n)h(n) \leq c(n, n') + h(n') where c(n,n)c(n, n') is the cost of transitioning from nn to nn' . This property ensures that the f(n)f(n) values along a path are non-decreasing.

Time and Space Complexity

  • Time Complexity: In the worst case, A* explores all nodes with f(n)f(n) less than the cost of the optimal solution. This can grow exponentially with the branching factor bb and the depth dd of the solution: O(bd)O(b^d) .
  • Space Complexity: A* stores all explored nodes in memory, leading to O(bd)O(b^d) space usage.

5. Limitations of A*

While A* is highly efficient for many problems, it has significant limitations:

  • Memory Requirements: Storing all explored nodes can become infeasible for large graphs.
  • Performance: On graphs with high branching factors or inaccurate heuristics, A* may perform poorly.

6. Iterative Deepening A* (IDA*)

Why IDA*?

IDA* addresses A*'s memory limitations by combining the benefits of A*'s heuristic-guided search with the memory efficiency of depth-first search. Instead of maintaining a priority queue, IDA* performs iterative depth-first searches with increasing ff -cost thresholds.

How It Works

  1. Initialize the threshold to h(start)h(start) .
  2. Perform a depth-first search, pruning paths with f(n)>f(n) > threshold.
  3. If no solution is found, increase the threshold to the smallest f(n)f(n) value that exceeded the current threshold.
  4. Repeat until the goal is reached.

Wikipedia Pseudocode for IDA*

path              current search path (acts like a stack)
node              current node (last node in current path)
g                 the cost to reach current node
f                 estimated cost of the cheapest path (root..node..goal)
h(node)           estimated cost of the cheapest path (node..goal)
cost(node, succ)  step cost function
is_goal(node)     goal test
successors(node)  node expanding function, expand nodes ordered by g + h(node)
ida_star(root)    return either NOT_FOUND or a pair with the best path and its cost

procedure ida_star(root)
    bound := h(root)
    path := [root]
    loop
        t := search(path, 0, bound)
        if t = FOUND then return (path, bound)
        if t = ∞ then return NOT_FOUND
        bound := t
    end loop
end procedure

function search(path, g, bound)
    node := path.last
    f := g + h(node)
    if f > bound then return f
    if is_goal(node) then return FOUND
    min := ∞
    for succ in successors(node) do
        if succ not in path then
            path.push(succ)
            t := search(path, g + cost(node, succ), bound)
            if t = FOUND then return FOUND
            if t < min then min := t
            path.pop()
        end if
    end for
    return min
end function
Enter fullscreen mode Exit fullscreen mode

7. Implementing IDA* in JavaScript

function idaStar(start, goal, grid, heuristic) {
  let threshold = heuristic(start, goal);

  while (true) {
    const cameFrom = {};
    const result = dfs(
      start,
      0,
      threshold,
      goal,
      grid,
      heuristic,
      cameFrom,
      new Set()
    );
    if (result.found) {
      return reconstructPath(cameFrom, goal);
    }
    if (result.threshold === Infinity) {
      return "No path found";
    }
    threshold = result.threshold;
  }
}

function dfs(node, g, threshold, goal, grid, heuristic, cameFrom, visited) {
  const f = g + heuristic(node, goal);
  if (f > threshold) return { threshold: f, found: false };
  if (node[0] === goal[0] && node[1] === goal[1])
    return { found: true, cameFrom };

  visited.add(node.toString());
  let minThreshold = Infinity;
  for (const [dx, dy] of [
    [0, 1],
    [1, 0],
    [0, -1],
    [-1, 0],
  ]) {
    const neighbor = [node[0] + dx, node[1] + dy];
    const [nx, ny] = neighbor;

    if (
      nx >= 0 &&
      nx < grid.length &&
      ny >= 0 &&
      ny < grid[0].length &&
      grid[nx][ny] === 0 &&
      !visited.has(neighbor.toString())
    ) {
      cameFrom[neighbor] = node;
      const result = dfs(
        neighbor,
        g + 1,
        threshold,
        goal,
        grid,
        heuristic,
        cameFrom,
        visited
      );
      if (result.found) return result;
      if (result.threshold < minThreshold) minThreshold = result.threshold;
    }
  }
  visited.delete(node.toString());
  return { threshold: minThreshold, found: false };
}

const grid = [
  [0, 0, 0, 0],
  [1, 1, 1, 0],
  [0, 0, 0, 0],
  [0, 1, 1, 1],
  [0, 0, 0, 0],
];

const start = [0, 0];
const goal = [4, 3];

const pathIdaStar = idaStar(start, goal, grid, heuristic);
console.log("IDA* Found Path:", pathIdaStar);
assert.notStrictEqual(pathIdaStar, "No path found");
assert.strictEqual(Array.isArray(pathIdaStar), true);

const blockedGrid = [
  [0, 1, 0],
  [1, 1, 0],
  [0, 1, 0],
];

const noPathResultIdaStar = idaStar([0, 0], [2, 2], blockedGrid, heuristic);
assert.strictEqual(noPathResultIdaStar, "No path found");
Enter fullscreen mode Exit fullscreen mode

8. Comparing A* and IDA*

Feature A* IDA*
Memory Usage High (stores all nodes) Low (depth-first search)
Speed Faster for smaller graphs Slower due to iterative nature
Implementation Complexity Moderate Higher
Suitability for Large Graphs Limited More suitable

9. Use Cases

  1. Game Development: Pathfinding for characters and AI in games (e.g., NPC movement).
  2. Robotics: Navigation for autonomous robots in real-world environments.
  3. Network Routing: Finding the shortest paths in network graphs.
  4. Puzzle Solving: Solving puzzles like the 8-puzzle or 15-puzzle efficiently.

10. Common Pitfalls and Best Practices

When implementing and applying A* or IDA*, it’s essential to avoid common mistakes and adopt best practices to ensure optimal performance.

1. Choosing Heuristics Wisely

  • Admissibility: Ensure that the heuristic never overestimates the cost to reach the goal. This guarantees that A* will find the optimal solution.
  • Consistency (Monotonicity): Verify that the heuristic satisfies the triangle inequality: h(n)c(n,n)+h(n)h(n) \leq c(n, n') + h(n') . This ensures that the algorithm’s f(n)f(n) values are non-decreasing.
  • Common heuristics include:
    • Manhattan Distance: Suitable for grid-based problems where movement is restricted to horizontal or vertical directions.
    • Euclidean Distance: Best for problems allowing diagonal or free movement in a continuous space.
    • Domain-Specific Heuristics: Tailor heuristics based on specific problem knowledge to improve performance.

2. Balancing g(n)g(n) and h(n)h(n)

  • Assign appropriate weights to g(n)g(n) (path cost so far) and h(n)h(n) (estimated cost to goal).
  • Overemphasizing h(n)h(n) might lead to suboptimal solutions or longer paths.
  • Relying heavily on g(n)g(n) might degrade performance, as the algorithm explores more nodes unnecessarily.

3. Debugging and Visualization Techniques

  • Visualization: Tools like heatmaps or animations of the search process can help debug and optimize the algorithm.
  • Logging: Track f(n)f(n) , g(n)g(n) , and h(n)h(n) values for each node to identify anomalies.
  • Unit Testing: Validate heuristic functions and graph structures with simple test cases to ensure correctness.
  • Benchmarking: Test the implementation on different datasets to measure performance and identify bottlenecks.

11. Advanced Topics

1. Variants of A*

A* has inspired several variants tailored to specific scenarios or performance needs:

  • Weighted A*:

    • Modifies the evaluation function to f(n)=g(n)+wh(n)f(n) = g(n) + w \cdot h(n) , where ww (weight) adjusts the heuristic's influence.
    • Used to trade optimality for speed by biasing the search toward the goal.
  • Memory-Bounded A*:

  • Hierarchical A*:

    • Splits large graphs into smaller subgraphs, solves each independently, and combines results.
    • Ideal for complex environments like city navigation.

2. Integration with Machine Learning

Machine learning can enhance A* by dynamically adjusting heuristics or guiding the search process:

  • Dynamic Heuristics: Use neural networks or reinforcement learning to predict better heuristic values for each state.
  • Adaptive Pathfinding: Train models to optimize paths based on historical data or changing environments.
  • Hybrid Models: Combine A* with ML-driven approaches for problems like real-time strategy games or robotics.

3. Parallelizing A* for Performance Optimization

Parallelization can significantly speed up A* for large graphs:

  • Parallel Open Set: Divide the open set among multiple threads or processes, each exploring a subset of nodes.
  • Distributed Search: Use distributed systems to partition the graph and execute search concurrently.
  • GPU Acceleration: Leverage GPUs for massive parallelism, especially for heuristic computations and large-scale graph traversal.

12. Conclusion

The A* search algorithm and Iterative Deepening A* are powerful tools for solving pathfinding and graph traversal problems. While A* excels in speed and guarantees optimality with sufficient memory, IDA* offers a memory-efficient alternative for large graphs. By understanding their principles, properties, and implementations, you can leverage these algorithms to tackle a wide range of computational problems effectively.

Top comments (0)