Heuristic Search

CSI 4106 Introduction to Artificial Intelligence

Marcel Turcotte

Version: Nov 4, 2026 17:10

Preamble

Message of the Day

Learning Objectives

  • Distinguish the heuristic estimate h, path cost g, and search priority f.
  • Implement A* graph search using a priority queue and best-known path costs.
  • Explain cost optimality under admissibility and the stated duplicate-handling policy.
  • Construct and justify misplaced-tile and Manhattan heuristics for the 8-puzzle.
  • Compare search effort and memory requirements under controlled conditions.

Python Helpers from Lecture 16

Show Python helper functions
import random
from collections import deque

import matplotlib.pyplot as plt
import numpy as np

random.seed(58)

def is_solvable(tiles):
    # Count the inversions in the flattened list of tiles (excluding the blank space).
    # Assumption: board is square and width is odd.
    inversions = 0
    for i in range(len(tiles)):
        for j in range(i + 1, len(tiles)):
            if tiles[i] != 0 and tiles[j] != 0 and tiles[i] > tiles[j]:
                inversions += 1
    return inversions % 2 == 0

def generate_solvable_board():
    # Generate a random board configuration that is guaranteed to be solvable
    tiles = list(range(9))
    random.shuffle(tiles)
    while not is_solvable(tiles):
        random.shuffle(tiles)

    return tuple(tiles)

def plot_board(board, title, num_pos, position):
    ax = plt.subplot(1, num_pos, position)
    ax.set_title(title)
    ax.set_xticks([])
    ax.set_yticks([])

    board = np.array(board).reshape(3, 3).tolist() # Reconfigure the grid to be 3x3

    # Use a color map to display the numbers
    cmap = plt.cm.plasma
    norm = plt.Normalize(vmin=-1, vmax=8)

    for i in range(3):
        for j in range(3):
            tile_value = board[i][j]
            color = cmap(norm(tile_value))
            ax.add_patch(plt.Rectangle((j, 2 - i), 1, 1, facecolor=color, edgecolor='black'))
            if tile_value == 0:
                ax.add_patch(plt.Rectangle((j, 2 - i), 1, 1, facecolor='white', edgecolor='black'))
            else:
                ax.text(j + 0.5, 2 - i + 0.5, str(tile_value),
                        fontsize=16, ha='center', va='center', color='black')

    ax.set_xlim(0, 3)
    ax.set_ylim(0, 3)

def is_empty(frontier):
    """Checks if the frontier is empty."""
    return len(frontier) == 0

def is_goal(state, goal_state):
    """Determines if a given state matches the goal state."""
    return state == goal_state

def expand(state):
    """Generates successor states by moving the blank tile in all possible directions."""
    size = int(len(state) ** 0.5)  # Determine puzzle size (3 for 8-puzzle, 4 for 15-puzzle)
    idx = state.index(0)  # Find the index of the blank tile represented by 0
    x, y = idx % size, idx // size  # Convert index to (x, y) coordinates
    neighbors = []

    # Define possible moves: Left, Right, Up, Down
    moves = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    for dx, dy in moves:
        nx, ny = x + dx, y + dy
        # Check if the new position is within the puzzle boundaries
        if 0 <= nx < size and 0 <= ny < size:
            n_idx = ny * size + nx
            new_state = list(state)
            # Swap the blank tile with the adjacent tile
            new_state[idx], new_state[n_idx] = new_state[n_idx], new_state[idx]
            neighbors.append(tuple(new_state))
    return neighbors

def print_solution(solution):
    """Prints the sequence of steps from the initial to the goal state."""
    size = int(len(solution[0]) ** 0.5)
    for step, state in enumerate(solution):
        print(f"Step {step}:")
        for i in range(size):
            row = state[i*size:(i+1)*size]
            print(' '.join(str(n) if n != 0 else ' ' for n in row))
        print()

def bfs(initial_state, goal_state):

    frontier = deque()  # Initialize the queue for BFS
    frontier.append((initial_state, []))  # A node: (state, preceding states)

    reached = {initial_state}

    expanded = 0  # Count non-goal nodes whose successors are generated

    while not is_empty(frontier):
        current_state, path = frontier.popleft()

        if is_goal(current_state, goal_state):
            print(f"Nodes expanded: {expanded}")
            return path + [current_state]  # Return the successful path

        expanded += 1

        for neighbor in expand(current_state):
            if neighbor not in reached:
                reached.add(neighbor)
                frontier.append((neighbor, path + [current_state]))

    return None

def dfs(initial_state, goal_state):

    frontier = [(initial_state, [])]  # A node: (state, preceding states)

    reached = {initial_state}

    expanded = 0

    while not is_empty(frontier):
        current_state, path = frontier.pop()

        if is_goal(current_state, goal_state):
            print(f"Nodes expanded: {expanded}")
            return path + [current_state]  # Return the successful path

        expanded += 1

        for neighbor in expand(current_state):
            if neighbor not in reached:
                reached.add(neighbor)
                frontier.append((neighbor, path + [current_state]))

    return None

Remarks

  • Breadth-first search (BFS) identifies the optimal solution, 25 moves, after 145,605 non-goal node expansions.

  • Depth-first search (DFS) discovers a solution involving 1,157 moves after 1,187 non-goal node expansions.

Heuristics and Search Priorities

For a search node n representing state s:

  • g(n): known cost of this path from the initial state to s.
  • h(s): estimated least remaining cost from s to a goal.
  • f(n): priority used to select a frontier entry.
Best-first strategy Priority f(n)
Greedy best-first search h(s)
Uniform-cost search g(n)
A* search g(n)+h(s)

A Route-Finding Heuristic

In a road-distance problem, straight-line distance to the destination estimates the remaining distance.

The straight line need not be a usable road. A* adds this estimate to the distance already travelled:

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

Romania: Route-Finding Problem

Romania: Straight-Line Heuristic

Romania: Initial Frontier

Romania: Expand Arad

Romania: Expand Sibiu

Romania: Expand Rimnicu Vilcea

Romania: Expand Fagaras

Romania: Select Bucharest

A Priority Queue

How should the BFS/DFS frontier change when nodes have different priorities?

Use a priority queue ordered by the evaluation function f.

import heapq

A* Implementation

def astar(initial_state, goal_state, heuristic):
    # Entries contain (f, g, state, preceding states).
    frontier = [(heuristic(initial_state, goal_state), 0, initial_state, [])]
    best_g = {initial_state: 0}
    expanded = 0

    while frontier:
        f, g, current_state, path = heapq.heappop(frontier)
        # A cheaper path may have replaced this queue entry.
        if g > best_g[current_state]:
            continue

        if is_goal(current_state, goal_state):
            return path + [current_state], expanded

        expanded += 1
        for neighbor in expand(current_state):
            new_g = g + 1  # Each puzzle move costs 1.
            if neighbor not in best_g or new_g < best_g[neighbor]:
                best_g[neighbor] = new_g
                new_f = new_g + heuristic(neighbor, goal_state)
                heapq.heappush(frontier, (new_f, new_g, neighbor, path + [current_state]))

    return None, expanded

Keeping Better Paths

  • best_g[state] records the cheapest path discovered so far, not a final cost.
  • Enqueue a successor whenever its new path cost is strictly smaller.
  • Skip a popped entry if its stored g is larger than best_g[state].
  • A cheaper path can reopen an already expanded state.
  • Test for a goal only after popping a current minimum-f entry.

Admissibility

A heuristic is admissible if it never overestimates the least remaining solution cost:

0\le h(s)\le h^\star(s).

Here h^\star(s) is the true least cost from s to a goal.

Cost Optimality

An algorithm is cost optimal when it returns a solution with the least path cost whenever it returns a solution.

For the A* graph search above, the guarantee combines:

  • an admissible heuristic;
  • best-cost updates and reopening;
  • a goal test on a current minimum-f entry.

Theorem: Cost Optimality

Consider the finite reachable state-space graph of the implementation above, with unit action costs. Let h^\star(s) be the least remaining cost from state s to a goal.

The heuristic is admissible:

0 \le h(s) \le h^\star(s), \qquad h(G)=0 \text{ for every goal }G.

With the best-cost and reopening policy above, A* returns a least-cost solution when one exists, and reports failure otherwise.

Proof

1. Suppose A* selects a suboptimal goal.

Let the optimal solution cost be C^\star. Suppose the algorithm returns a goal entry G with cost C>C^\star.

Because h(G)=0,

f(G)=g(G)+h(G)=C.

We examine the frontier immediately before that current goal entry is selected.

Proof

2. An optimal-cost continuation remains on the frontier.

Follow an optimal path from the start. Consider its first state that has not yet been expanded at its optimal prefix cost.

A current frontier entry reaches that state with that optimal prefix cost.

Proof

3. That frontier entry has a lower bound no larger than C^\star.

Call the entry n and its state s. Its stored g(n) is the optimal prefix cost. By admissibility,

f(n)=g(n)+h(s) \le g(n)+h^\star(s) =C^\star.

The prefix and the cheapest remaining continuation together form an optimal solution.

Proof

4. The minimum-f rule contradicts selecting the suboptimal goal.

The frontier contains a current entry n with

f(n) \le C^\star < C = f(G).

A* must select an entry with lower f before G. It therefore cannot return a suboptimal goal.

Consistency

A heuristic is consistent if every action from s to s' satisfies

h(s) \le c(s,s')+h(s'), \qquad h(G)=0.

Its estimated remaining cost cannot drop by more than the cost of that action. Consequently, along a path,

f(n')=g(n)+c(s,s')+h(s') \ge g(n)+h(s)=f(n).

Constructing an 8-Puzzle Heuristic

What information about a board could estimate the number of moves remaining?

Can Inversion Count Be a Heuristic?

Consider the raw number of inversions as h(s).

Code
# Define the initial state
initial_board = (1, 2, 3,
                 4, 5, 0,
                 7, 8, 6)

# Define goal state
goal_board = (1, 2, 3,
              4, 5, 6,
              7, 8, 0)

# Plot both boards
plt.figure(figsize=(6, 3))
plot_board(initial_board, "Initial Board", 2, 1)
plot_board(goal_board, "Goal State", 2, 2)
plt.tight_layout()
plt.show()

  • Calculate h(s) and h^\star(s) for this board.
  • What does the comparison establish?

Misplaced Tiles

def misplaced_tiles_distance(state, goal_state):

    # Count the number of misplaced tiles

    misplaced_tiles = sum(1 for s, g in zip(state, goal_state) if s != g and s != 0)
    
    return misplaced_tiles

Misplaced Tiles on Two Boards

Code
GOAL_STATE = (1, 2, 3,
              4, 5, 6,
              7, 8, 0)
SIMPLE_STATE = (1, 2, 3,
                4, 0, 6,
                7, 5, 8)
HARD_STATE = (6, 4, 5,
              8, 2, 7,
              1, 0, 3)

def plot_heuristic_comparison(left, right, goal, heuristics):
    plt.figure(figsize=(8, 2.5))
    for position, state in [(1, left), (3, right)]:
        title = "\n".join(
            f"{name}={heuristic(state, goal)}"
            for name, heuristic in heuristics.items()
        )
        plot_board(state, title, 3, position)
    plot_board(goal, "Goal State", 3, 2)
    plt.tight_layout()
    plt.show()

plot_heuristic_comparison(
    SIMPLE_STATE, HARD_STATE, GOAL_STATE,
    {"misplaced": misplaced_tiles_distance},
)

Calling A* with a Heuristic

solution_states, expanded = astar(
    SIMPLE_STATE, GOAL_STATE, misplaced_tiles_distance
)

Simple Case: Misplaced Tiles

Code
solution_states, expanded = astar(SIMPLE_STATE, GOAL_STATE, misplaced_tiles_distance)
print(f"Solution: {len(solution_states) - 1} moves; nodes expanded: {expanded}")

plt.figure(figsize=(8, 2))
for i, state in enumerate(solution_states):
    plot_board(state, f"Step {i}", len(solution_states), i + 1)
plt.tight_layout()
plt.show()
Solution: 2 moves; nodes expanded: 2

Challenging Case: Misplaced Tiles

misplaced_solution, misplaced_expanded = astar(
    HARD_STATE, GOAL_STATE, misplaced_tiles_distance
)
print(f"Solution: {len(misplaced_solution) - 1} moves")
print(f"Nodes expanded: {misplaced_expanded:,}")
Solution: 25 moves
Nodes expanded: 29,005

Manhattan Distance

def manhattan_distance(state, goal_state):
    distance = 0
    size = int(len(state) ** 0.5)
    for num in range(1, len(state)):
        idx1 = state.index(num)
        idx2 = goal_state.index(num)
        x1, y1 = idx1 % size, idx1 // size
        x2, y2 = idx2 % size, idx2 // size
        distance += abs(x1 - x2) + abs(y1 - y2)
    return distance

h_{\mathrm{Manhattan}}(s)= \sum_{t=1}^{8}\left(|x_t-x_t^\star|+|y_t-y_t^\star|\right).

Manhattan Values on Two Boards

Code
plot_heuristic_comparison(
    SIMPLE_STATE, HARD_STATE, GOAL_STATE,
    {"Manhattan": manhattan_distance},
)

Comparing the Heuristics

For every reachable state, with the blank excluded,

h_{\mathrm{misplaced}}(s) \le h_{\mathrm{Manhattan}}(s) \le h^\star(s).

  • Each misplaced numbered tile contributes at least one to Manhattan distance.
  • Manhattan therefore dominates misplaced tiles: it is a more informative lower bound.
  • Does that guarantee a shorter wall-clock runtime on every instance?

Both Estimates on the Same Boards

Code
plot_heuristic_comparison(
    SIMPLE_STATE, HARD_STATE, GOAL_STATE,
    {"misplaced": misplaced_tiles_distance, "Manhattan": manhattan_distance},
)

Same Misplaced Count, Different Distances

Code
comparison_left = (3, 1, 2,
                   4, 5, 6,
                   7, 8, 0)
comparison_right = (8, 2, 3,
                    4, 5, 6,
                    1, 0, 7)
plot_heuristic_comparison(
    comparison_left, comparison_right, GOAL_STATE,
    {"misplaced": misplaced_tiles_distance, "Manhattan": manhattan_distance},
)

Changing the Heuristic

solution_states, expanded = astar(
    SIMPLE_STATE, GOAL_STATE, manhattan_distance
)

Simple Case: Manhattan

Code
solution_states, expanded = astar(SIMPLE_STATE, GOAL_STATE, manhattan_distance)
print(f"Solution: {len(solution_states) - 1} moves; nodes expanded: {expanded}")

plt.figure(figsize=(8, 2))
for i, state in enumerate(solution_states):
    plot_board(state, f"Step {i}", len(solution_states), i + 1)
plt.tight_layout()
plt.show()
Solution: 2 moves; nodes expanded: 2

Challenging Case: Manhattan

manhattan_solution, manhattan_expanded = astar(
    HARD_STATE, GOAL_STATE, manhattan_distance
)
print(f"Solution: {len(manhattan_solution) - 1} moves")
print(f"Nodes expanded: {manhattan_expanded:,}")
Solution: 25 moves
Nodes expanded: 2,255

Paired Experiments

  • Use the same 1,000 solvable boards with both heuristics.
  • Keep the same A* implementation and tie policy.
  • Retain both solution lengths and compare node expansions.
Code
experiment_rng = random.Random(58)
experiment_states = []
while len(experiment_states) < 1000:
    state = tuple(experiment_rng.sample(range(9), 9))
    if is_solvable(state):
        experiment_states.append(state)

moves_misplaced = []
moves_manhattan = []
expanded_misplaced = []
expanded_manhattan = []

for initial_state in experiment_states:
    path_misplaced, count_misplaced = astar(
        initial_state, GOAL_STATE, misplaced_tiles_distance
    )
    path_manhattan, count_manhattan = astar(
        initial_state, GOAL_STATE, manhattan_distance
    )
    moves_misplaced.append(len(path_misplaced) - 1)
    moves_manhattan.append(len(path_manhattan) - 1)
    expanded_misplaced.append(count_misplaced)
    expanded_manhattan.append(count_manhattan)

assert moves_misplaced == moves_manhattan
print(f"Paired solvable boards: {len(experiment_states)}")
print("Both heuristics return the same solution cost on every board.")
print(f"Mean expansions, misplaced tiles: {np.mean(expanded_misplaced):,.1f}")
print(f"Mean expansions, Manhattan:       {np.mean(expanded_manhattan):,.1f}")
Paired solvable boards: 1000
Both heuristics return the same solution cost on every board.
Mean expansions, misplaced tiles: 16,469.7
Mean expansions, Manhattan:       1,708.2

1,000 Paired Searches

Code
fig, axes = plt.subplots(1, 2, figsize=(10, 4))

axes[0].hist(moves_misplaced, bins=np.arange(-0.5, 32.5), color='green', edgecolor='white')
axes[0].set_title("Solution lengths: both heuristics")
axes[0].set_xlabel("Moves")
axes[0].set_ylabel("Number of boards")

bins = np.histogram_bin_edges(expanded_misplaced + expanded_manhattan, bins=30)
axes[1].hist(expanded_misplaced, bins=bins, alpha=0.6, label="Misplaced tiles")
axes[1].hist(expanded_manhattan, bins=bins, alpha=0.6, label="Manhattan")
axes[1].set_title("Search effort on the same boards")
axes[1].set_xlabel("Non-goal node expansions")
axes[1].set_ylabel("Number of boards")
axes[1].legend()
plt.tight_layout()
plt.show()

Comparing Search Effort

Code
fig, axes = plt.subplots(1, 2, figsize=(10, 4))

axes[0].scatter(moves_manhattan, expanded_manhattan, s=12, alpha=0.4)
axes[0].set_title("Manhattan search")
axes[0].set_xlabel("Solution length (moves)")
axes[0].set_ylabel("Non-goal node expansions")

axes[1].scatter(expanded_misplaced, expanded_manhattan, s=12, alpha=0.4)
limit = max(max(expanded_misplaced), max(expanded_manhattan))
axes[1].plot([0, limit], [0, limit], '--', color='black', label="Equal expansions")
axes[1].set_title("Paired comparison")
axes[1].set_xlabel("Expansions: misplaced tiles")
axes[1].set_ylabel("Expansions: Manhattan")
axes[1].legend()
plt.tight_layout()
plt.show()

Empirical Validation

  • With unit action costs, BFS supplies a minimum-move reference.
  • Compare BFS and A* with Manhattan distance on 25,000 unique solvable boards.
  • Check solution lengths and record non-goal node expansions.
  • Agreement increases confidence in the code; it is not a proof.

Download the Python validation script and run it from the lecture folder:

python3 validate_astar.py 4106 25000

Saved Results: BFS versus A*

Code
import csv
import os
from pathlib import Path
from IPython.display import Markdown, display

number_translation = str.maketrans({",": ",", ".": "."})
def format_number(value, decimals=0):
    return f"{value:,.{decimals}f}".translate(number_translation)

CSV_NAME = "astar_validation_runs-25000_seed-4106.csv"
csv_path = Path(CSV_NAME)
project_dir = os.environ.get("QUARTO_PROJECT_DIR")
if not csv_path.is_file() and project_dir:
    csv_path = Path(project_dir) / "lectures" / "17" / CSV_NAME

if not csv_path.is_file():
    display(Markdown(
        "**Saved results are not available yet.** Run this once beside the notebook, "
        "then rerun the notebook or render the slides:\n\n"
        "```bash\npython3 validate_astar.py 4106 25000\n```"
    ))
else:
    # Read a complete prefix even while the experiment is appending rows.
    csv_text = csv_path.read_text(encoding="utf-8")
    complete_text = csv_text[:csv_text.rfind("\n") + 1]
    records = list(csv.DictReader(complete_text.splitlines()))

    if not records:
        display(Markdown("**The CSV exists, but no completed results are available yet.**"))
    else:
        unique_boards = len({row["board"] for row in records})
        mismatches = sum(
            row["bfs_moves"] == "" or row["astar_moves"] == ""
            or row["bfs_moves"] != row["astar_moves"]
            for row in records
        )
        status = "Partial results. " if len(records) < 25000 else ""
        display(Markdown(
            f"**{status}{format_number(len(records))} completed comparisons** "
            f"({format_number(unique_boards)} unique boards); **{format_number(mismatches)} move-count failures**."
        ))

        methods = [
            ("BFS", [int(row["bfs_expanded"]) for row in records], "steelblue"),
            ("A* (Manhattan)", [int(row["astar_expanded"]) for row in records], "darkorange"),
        ]
        fig, axes = plt.subplots(1, 2, figsize=(10, 2.6))
        table = ["| Method | Mean | Median | Minimum | Maximum |", "|---|---:|---:|---:|---:|"]
        for ax, (name, expanded, color) in zip(axes, methods):
            bins = np.linspace(0, max(1, max(expanded)), 41)
            ax.hist(expanded, bins=bins, color=color, edgecolor="white")
            ax.set_title(name)
            ax.set_xlabel("Non-goal node expansions")
            ax.set_ylabel("Number of boards")
            ax.set_xlim(0, max(1, max(expanded)) * 1.05)
            ax.ticklabel_format(style="plain", axis="x")
            ax.tick_params(labelsize=9)
            table.append(
                f"| {name} | {format_number(np.mean(expanded), 1)} | {format_number(np.median(expanded), 1)} "
                f"| {format_number(min(expanded))} | {format_number(max(expanded))} |"
            )
        plt.tight_layout()
        plt.show()
        display(Markdown("\n".join(table)))

25,000 completed comparisons (25,000 unique boards); 0 move-count failures.

Method Mean Median Minimum Maximum
BFS 93,419.1 93,362.0 16 181,437
A* (Manhattan) 1,643.7 959.0 3 23,647

Exploration: Validate Optimality

BFS returns a minimum-move solution when every action costs one.

  1. Generate a bounded sample of solvable 8-puzzle boards.
  2. Find a BFS solution and an A* solution for each board.
  3. Check that each path is legal, reaches the goal, and has the same number of moves.
  4. Repeat with both admissible heuristics.

Exploration: A Zero Heuristic

With h(s)=0, A* orders its frontier by g(n) alone: it becomes uniform-cost search.

When all action costs are one, both uniform-cost search and BFS expand nodes in nondecreasing depth.

Results on the Lecture 16 Board

Method Solution length Non-goal expansions
BFS 25 moves (optimal) 145,605
DFS 1,157 moves 1,187
A* with Manhattan 25 moves (optimal) 2,255

Measuring Performance

  • Completeness: Does the algorithm find a solution whenever one exists?
  • Cost optimality: Does it return a solution with the least path cost?

Time and Space Complexity

  • State-space graph: use the number of states V and actions E.
  • Search tree: use branching factor b and solution depth d.
  • Actual implementation: also account for heaps, heuristic evaluation, and stored paths.

Videos by Sebastian Lague

A resource dedicated to A^\star

Epilogue

Summary

  • Best-first priorities distinguish greedy search, uniform-cost search, and A*.
  • A* combines known path cost with an estimated remaining cost.
  • Best-cost updates, reopening, and goal selection connect the implementation to the optimality proof.
  • Admissibility, consistency, and dominance justify and compare the puzzle heuristics.

Next Session

References

Hart, Peter E., Nils J. Nilsson, and Bertram Raphael. 1968. “A Formal Basis for the Heuristic Determination of Minimum Cost Paths.” IEEE Transactions on Systems Science and Cybernetics 4 (2): 100–107. https://doi.org/10.1109/tssc.1968.300136.
Russell, Stuart, and Peter Norvig. 2020. Artificial Intelligence: A Modern Approach. 4th ed. Pearson. http://aima.cs.berkeley.edu/.

Marcel Turcotte

[email protected]

School of Electrical Engineering and Computer Science (EECS)

University of Ottawa