[IA Series 2/n] Search Algorithms and Intelligent Agents
Introduction
Search Algorithms are a key part of how Intelligent Agents learn and navigate their environment. In these examples, I’m using Search to find a route through a maze - the agent will determine a sequence of actions to achieve its goal of reaching the end point.
The purpose of this document is to dive into the details of four search algorithms and show their tradeoffs in a simple environment. I’ve implemented two categories: Uninformed and Informed Search. For the Informed Algorithms, I’ve included heuristics and cost calculations to guide the search process.
These are purely Symbolic agents - but what does “Symbolic” actually mean?
In this context, Symbolic means that the agent uses explicit rules and symbols to represent knowledge and perform reasoning. Reasoning in this context is the ability to take rational actions, to act as a rational agent.
The agents here use clear, human-readable representations (like coordinates in a grid) and follow well-defined logical steps to find the end of the maze. The algorithms manipulate these symbols according to formal rules - there’s no statistical learning or pattern recognition involved. Everything is explicit, transparent, and based on logical operations.
The key characteristics of my symbolic implementations:
- They use explicit representations of the maze as a graph
- They follow clear, deterministic rules for exploration
- The decision-making process can be traced step-by-step
- The solutions are guaranteed (when they exist) rather than probabilistic
In contrast, neural approaches might learn to navigate mazes through experience, without explicit programming of the navigation rules.
See here for a Basic Q-Learning Statistical/Reinforcement Learning Agent.
If you would like to see a visualisation of the agents navigating the mazes or look at the code head over to here.
Table of contents
- Uninformed Search Algorithms
- Informed Search Algorithms
- Other Search Algorithms (not (yet 🤞) implemented in the current codebase)
Uninformed Search Algorithms
Uninformed search algorithms (also called blind search algorithms) operate without using domain-specific knowledge about the problem beyond what is provided in the problem definition. They systematically explore the search space without any information about how close they are to the goal.
Available Uninformed Search Algorithms
Algorithm Comparison
Feature | Breadth-First Search | Depth-First Search |
---|---|---|
Data Structure | FIFO Queue | LIFO Stack |
Strategy | Explores all nodes at present depth before moving to next level | Explores as far as possible along each branch before backtracking |
Completeness | Yes (finds solution if one exists in finite graphs) | Yes (for finite graphs) |
Time Complexity | O(V + E) | O(V + E) |
Space Complexity | O(V) - must store all vertices at a level | O(h) - h is maximum depth of search tree |
Optimality | Optimal for unweighted graphs (finds shortest path) | Not optimal (may not find shortest path) |
Memory Usage | Higher (stores all nodes at current level) | Lower (stores only nodes on current path) |
Best Use Cases | Finding shortest paths, exploring graphs with shallow solutions | Memory-constrained environments, graphs with deep solutions |
Implementation Notes
There is an abstract base class for uninformed searches.
This is to reduce code duplication. As the BFS and DFS algorithms are identical except for the data storage and next node retrieval, the code is the same.
The code for the base class is here:
maze_solver/algorithms/uninformed/base.py
Each algorithm implements the following key methods to accommodate the differences in algorithms:
__init__
: Sets the name of the frontier (queue/stack)_initialize_frontier
: Sets up the data structure (queue/stack)_get_next_node
: Retrieves the next node based on strategy (FIFO/LIFO)
Informed Search Algorithms
Informed search algorithms use domain-specific knowledge (heuristics) to guide exploration toward the goal more efficiently. They leverage additional information about how close a state might be to the goal.
One way to frame the difference is that an uninformed search is like being blindfolded in a maze and feeling your way around, whereas an informed search is one where you can jump up and see the maze’s exit sign, you (sort of) know how far away you are.
Clearly this is a metaphor, in practice agents work on numbers and need measurements rather than visual cues, so we need to measure the distiances. For Greedy Best First Search we need the estimated distance to the goal and for A* we also need the cost of the distance we have travelled.
Available Informed Search Algorithms
Maze Solver Heuristics
So this information needs to be available to the agent and the best place for it is in the Maze Environment. This is not complex for the simple two dimensional mazes generated by the Maze Environment class.
The maze_solver/core/environment.py
file has been edited to include the following distance metrics as heuristics:
Manhattan Distance
The Manhattan Distance (also known as Taxicab Geometry) measures the sum of the absolute differences between two points' coordinates. In a grid-based maze, this represents the minimum number of horizontal and vertical moves needed to reach the goal, assuming no walls.
def calculate_manhattan_distance(self, state: Tuple[int, int], goal: Tuple[int, int]) -> int:
"""Calculate Manhattan distance heuristic from state to goal."""
return abs(state[0] - goal[0]) + abs(state[1] - goal[1])
Euclidean Distance
The Euclidean distance measures the straight-line or “as the crow flies” distance between two points.
def calculate_euclidean_distance(self, state: Tuple[int, int], goal: Tuple[int, int]) -> float:
"""Calculate Euclidean distance heuristic from state to goal."""
return ((state[0] - goal[0]) ** 2 + (state[1] - goal[1]) ** 2) ** 0.5
Why Manhattan is Often Preferred
While both heuristics can work, Manhattan distance is generally preferred for grid-based pathfinding because:
- It matches the movement constraints in a grid where diagonal movement is not allowed.
- It maintains consistency with step costs (each move costs 1 unit).
- It’s an admissible heuristic for grid movement. It never overestimates the true cost to the goal.
- It’s computationally simpler (no square roots).
For A* search in particular, using Manhattan distance as the heuristic ensures that the algorithm remains consistent when it adds the path cost (also measured in grid steps) to the estimated distance to the goal.
Step Cost Calculation
The cost of moving between adjacent cells is defined as follows:
def get_step_cost(self, state1: Tuple[int, int], state2: Tuple[int, int]) -> int:
"""Calculate cost of moving from state1 to state2."""
# For uniform cost in grid-based maze, return 1
return 1
This unified cost model means each step has the same weight, which aligns perfectly with Manhattan distance calculations in a grid-based environment.
Making the Maze fun and costly
This implmentation uses a uniform cost of 1, which doesn’t really add value whilst adding more code. To highlight why I think it’s worthwhile keeping in mind, imagine the maze as an obstacle course, especially valuable if there are more than one route to the end goal. If there is an obstacle at a particular point it would cost more to traverse.
An example would be
# Example: Higher cost for muddy terrain
def get_step_cost(self, state1: Tuple[int, int], state2: Tuple[int, int]) -> int:
if state2 in self.muddy_terrain:
return 5 # 5x cost to traverse mud
return 1 # Normal terrain
Note: this would only impact the A* Search, the Greedy Best First Search is unaware of costs.
Algorithm Comparison
Algorithmic Complexity Variables
First the notation used:
Variable | Definition | Significance |
---|---|---|
b | Branching factor | Average number of successors (children) per node; represents available choices at each decision point |
d | Shallowest solution depth | Length of the shortest path from start to goal |
m | Maximum depth | Maximum depth of the search tree/graph (potentially infinite in unbounded graphs) |
C* | Optimal solution cost | Total path cost from start to goal along the optimal path |
ε | Minimum edge cost | Smallest step cost in the graph |
The ratio C/ε* represents the maximum number of steps in any optimal solution, which is particularly relevant for understanding the performance bounds of algorithms like Uniform-Cost Search.
These variables help quantify the tradeoffs between different search algorithms in terms of time efficiency, space requirements, completeness, and optimality guarantees.
Table of comparison
Feature | Greedy Best-First Search | A* Search |
---|---|---|
Decision Function | $f(n) = h(n)$ | $f(n) = g(n) + h(n)$ |
Strategy | Expand node closest to goal according to heuristic | Balance path cost so far and estimated cost to goal |
Completeness | Not guaranteed | Yes (with finite, non-negative edge costs) |
Time Complexity | O(b^m) worst case | O(b^d) worst case, but better in practice |
Space Complexity | O(b^m) | O(b^d) |
Optimality | Not optimal | Optimal if heuristic is admissible |
Memory Usage | High | High |
Best Use Cases | Quick approximate solutions | Finding optimal paths when good heuristic available |
Key Advantage | Often finds decent solutions quickly | Guaranteed to find shortest path with admissible heuristic |
Key Weakness | Can make poor choices with misleading heuristics | Requires more memory than Greedy |
Other Search Algorithms (not (yet 🤞) implemented in the current codebase)
This is the “if I had more time chapter” :)
We’ve covered four fundamental search algorithms (BFS, DFS, Greedy Best-First, and A*), there are several other important search algorithms worth exploring:
-
Uniform-Cost Search: An uninformed algorithm that expands nodes in order of their path cost, guaranteeing optimality even with varying edge costs. Think of it as A* without a heuristic function.
-
Iterative Deepening Search: Combines the space efficiency of DFS with the completeness of BFS by running a series of depth-limited searches with increasing depth limits.
-
Depth-Limited Search: A variation of DFS that stops exploring when it reaches a predefined depth limit, preventing infinite loops in infinite graphs.
-
Bidirectional Search: Searches simultaneously from both the start and goal, dramatically reducing the search space by meeting in the middle.
-
Beam Search: A modification of BFS that only keeps a fixed number of the most promising nodes at each depth, trading optimality for efficiency.
-
Jump Point Search: An optimization of A* for uniform-cost grid maps that skips over “obvious” paths, dramatically improving performance in many scenarios.
Full table of comparison
The table below summarizes the key properties of several search algorithms (see above for the notation):
Algorithm | Complete? | Optimal? | Time Complexity | Space Complexity |
---|---|---|---|---|
Breadth-First | Yes¹ | Yes² | O(b^d) | O(b^d) |
Uniform-Cost | Yes¹ | Yes | O(b^[1+C*/ε]) | O(b^[1+C*/ε]) |
Depth-First | No | No | O(b^m) | O(bm) |
Depth-Limited | No | No | O(b^l) | O(bl) |
Iterative Deepening | Yes¹ | Yes² | O(b^d) | O(bd) |
Bidirectional | Yes¹ | Yes² | O(b^(d/2)) | O(b^(d/2)) |
Greedy Best-First | No | No | O(b^m) | O(b^m) |
A* | Yes¹ | Yes³ | O(b^d) | O(b^d) |
¹Complete if the branching factor b is finite ²Optimal for unweighted graphs ³Optimal if the heuristic is admissible
These algorithms represent different tradeoffs between completeness, optimality, time efficiency, and space efficiency. The best choice depends on your specific problem characteristics and constraints.