Problem Solving By Search
Informed Search & Heuristics
A heuristic function, written h(n), estimates the cost from a given state n to the goal — an educated guess, not a guaranteed exact value, that lets a search al
Jr Codex AI Notes
Level: Intermediate Prerequisites: Chapter 2 Time to complete: ~30 minutes
Table of Contents
- What is a Heuristic?
- Greedy Best-First Search
- A* Search
- Admissible & Consistent Heuristics
- Designing a Good Heuristic
- Why A* Is Optimal (Intuition)
- Comparing Every Algorithm So Far
- Summary & Next Steps
1. What is a Heuristic?
A heuristic function, written h(n), estimates the cost from a given state n to the goal — an educated guess, not a guaranteed exact value, that lets a search algorithm prioritize exploring more promising states first.
# For the Romania route-finding problem (Chapter 1), a natural heuristic
# is the STRAIGHT-LINE distance to Bucharest — easy to compute, and a
# reasonable estimate of the remaining travel distance
straight_line_to_bucharest = {
"Arad": 366, "Zerind": 374, "Oradea": 380, "Sibiu": 253,
"Timisoara": 329, "Lugoj": 244, "Mehadia": 241, "Drobeta": 242,
"Craiova": 160, "Rimnicu": 193, "Fagaras": 176, "Pitesti": 100,
"Bucharest": 0,
}Why h(n) Matters
─────────────────────────────────────────
Uninformed search (Ch.2) treats every unexplored state as
EQUALLY worth investigating.
h(n) gives the algorithm a sense of DIRECTION — "Fagaras looks
much closer to Bucharest (176) than Timisoara (329), explore
that way first" — without needing to actually search that far yet.
─────────────────────────────────────────
2. Greedy Best-First Search
Expands the state with the lowest heuristic value — i.e., the state that looks closest to the goal, ignoring the cost already spent getting there.
import heapq
def greedy_best_first_search(problem, heuristic):
frontier = [(heuristic[problem.initial_state], problem.initial_state, [])]
visited = set()
while frontier:
_, state, path = heapq.heappop(frontier) # lowest h(n) FIRST
if problem.goal_test(state):
return path
if state in visited:
continue
visited.add(state)
for action in problem.actions(state):
next_state = problem.result(state, action)
if next_state not in visited:
heapq.heappush(frontier, (heuristic[next_state], next_state, path + [action]))
return None
solution = greedy_best_first_search(from_arad_to_bucharest, straight_line_to_bucharest)
print(solution)Greedy's Weakness — Ignoring Cost Already Spent
─────────────────────────────────────────
Greedy can be FOOLED into a long detour if a nearby-looking
state (low h(n)) is actually reached via an expensive path,
while a slightly farther-looking state would have been reached
via a much cheaper route overall.
Greedy is FAST and often finds a decent path, but is NOT
guaranteed optimal — it only looks at "how far to go,"
never "how much have I already spent."
─────────────────────────────────────────
3. A* Search
A* (pronounced "A-star") fixes Greedy's blind spot by combining both pieces of information: cost already spent (g(n), exactly UCS's ordering from Chapter 2) and estimated cost remaining (h(n), Greedy's ordering).
The A* Evaluation Function
─────────────────────────────────────────
f(n) = g(n) + h(n)
│ │
│ └── estimated cost from n to the GOAL (heuristic)
└── actual cost from the START to n (already spent)
A* expands the state with the LOWEST f(n) — balancing
"cheap so far" AND "looks close to the goal."
─────────────────────────────────────────
import heapq
def a_star_search(problem, heuristic):
# frontier entries: (f(n), g(n), state, path)
frontier = [(heuristic[problem.initial_state], 0, problem.initial_state, [])]
visited = {}
while frontier:
f, g, state, path = heapq.heappop(frontier)
if problem.goal_test(state):
return path, g
if state in visited and visited[state] <= g:
continue
visited[state] = g
for action in problem.actions(state):
next_state = problem.result(state, action)
new_g = g + problem.step_cost(state, action)
new_f = new_g + heuristic[next_state]
if next_state not in visited or visited[next_state] > new_g:
heapq.heappush(frontier, (new_f, new_g, next_state, path + [action]))
return None, float("inf")
solution, cost = a_star_search(from_arad_to_bucharest, straight_line_to_bucharest)
print(f"A* path: {solution}, cost: {cost}") # guaranteed OPTIMAL, given a good heuristic (Section 4)A* is UCS (Chapter 2) plus a heuristic — set every heuristic[state] = 0 and A* becomes exactly UCS, confirming they share the same underlying mechanism, just with different guidance.
4. Admissible & Consistent Heuristics
A*'s optimality guarantee (Section 6) depends entirely on the heuristic satisfying specific properties — an arbitrary or badly-designed heuristic can break the guarantee.
Admissible Heuristic
─────────────────────────────────────────
h(n) NEVER overestimates the true cost to the goal.
h(n) <= actual_cost(n, goal), for EVERY state n.
Straight-line distance is admissible for route-finding:
you can NEVER actually travel in a straighter (shorter)
line than the direct geometric distance.
─────────────────────────────────────────
Consistent (Monotonic) Heuristic
─────────────────────────────────────────
For every state n and successor n' (reached via action costing c):
h(n) <= c + h(n')
(i.e., the heuristic's estimate never "jumps up" by more than
the actual step cost — it decreases smoothly and reliably as
you get closer to the goal)
Every CONSISTENT heuristic is also ADMISSIBLE — but not
every admissible heuristic is consistent (consistency is
the STRONGER, more useful property in practice).
─────────────────────────────────────────
# Checking admissibility conceptually — h(n) must never exceed the REAL cost
def check_admissible_example():
actual_cost_arad_to_bucharest = 418 # the true optimal path cost (via Sibiu-Rimnicu-Pitesti)
heuristic_estimate = straight_line_to_bucharest["Arad"] # 366
print(heuristic_estimate <= actual_cost_arad_to_bucharest) # True — admissible, doesn't overestimate
check_admissible_example()Why this property matters so much: an admissible heuristic guarantees A* never mistakenly rules out the true optimal path by overestimating it — Section 6 explains exactly how this connects to A*'s optimality proof.
5. Designing a Good Heuristic
A heuristic needs to be both admissible/consistent (Section 4) and practically useful — a heuristic of h(n) = 0 everywhere is technically admissible (it never overestimates — trivially true) but gives zero guidance, degenerating A* back into plain UCS.
The Trade-off in Heuristic Design
─────────────────────────────────────────
h(n) = 0 (uninformative) Perfect h(n) (impossible in practice)
───────────────────────── ─────────────────────────
Degenerates to UCS Would go STRAIGHT to the goal,
(correct, but slow — no no wasted exploration at all
guidance at all) (but computing it would usually
require already SOLVING the
problem!)
A GOOD heuristic sits between these extremes: informative
enough to meaningfully speed up search, cheap enough to
compute quickly, and never overestimating (admissible).
─────────────────────────────────────────
# A classic heuristic-design example: the 8-puzzle (Chapter 1's example problem)
def misplaced_tiles_heuristic(state, goal_state):
"""h1: count of tiles in the WRONG position. Admissible, but weak."""
return sum(1 for a, b in zip(state, goal_state) if a != b and a != 0)
def manhattan_distance_heuristic(state, goal_state, size=3):
"""h2: sum of each tile's grid distance from its goal position. Admissible AND stronger than h1."""
total = 0
for i, tile in enumerate(state):
if tile == 0:
continue
goal_index = goal_state.index(tile)
row1, col1 = divmod(i, size)
row2, col2 = divmod(goal_index, size)
total += abs(row1 - row2) + abs(col1 - col2)
return totalDominance — Comparing Two Admissible Heuristics
─────────────────────────────────────────
If h2(n) >= h1(n) for EVERY state n (and both are admissible),
h2 is said to DOMINATE h1 — and A* using h2 will typically
explore FEWER states, since it has more accurate guidance.
Manhattan distance DOMINATES misplaced-tiles for the 8-puzzle:
it's always at least as informative, never overestimates,
and leads to a faster search in practice.
─────────────────────────────────────────
Practical rule of thumb for designing heuristics: relax the problem's constraints to make it trivially solvable, then use that trivial solution's cost as your heuristic — Manhattan distance is exactly this: "if a tile could move directly to its goal position, ignoring other tiles in the way, how far would it travel?"
6. Why A* Is Optimal (Intuition)
A* Optimality — the Core Argument
─────────────────────────────────────────
Suppose A* is about to expand the goal state G, reached via
path cost g(G). f(G) = g(G) + h(G) = g(G) + 0 = g(G).
For A* to have chosen G over some OTHER frontier node n:
f(G) <= f(n) = g(n) + h(n)
Since h is ADMISSIBLE, h(n) never overestimates the true
remaining cost from n. This means no node claiming to lead
to a cheaper solution was being unfairly skipped — A*
would have expanded it FIRST if it truly offered a better path.
This is why an admissible heuristic is REQUIRED for the
optimality guarantee — an overestimating heuristic could
cause A* to wrongly prioritize a worse path that merely
LOOKED cheaper.
─────────────────────────────────────────
This is a simplified version of the formal proof found in AI textbooks — the essential takeaway is that admissibility (Section 4) is precisely the property that prevents A* from ever being misled into missing the true optimal solution.
7. Comparing Every Algorithm So Far
bfs_path = breadth_first_search(from_arad_to_bucharest)
dfs_path = depth_first_search(from_arad_to_bucharest)
ucs_path, ucs_cost = uniform_cost_search(from_arad_to_bucharest)
greedy_path = greedy_best_first_search(from_arad_to_bucharest, straight_line_to_bucharest)
astar_path, astar_cost = a_star_search(from_arad_to_bucharest, straight_line_to_bucharest)
print(f"BFS: {bfs_path}")
print(f"DFS: {dfs_path}")
print(f"UCS: {ucs_path} (cost={ucs_cost})")
print(f"Greedy: {greedy_path}")
print(f"A*: {astar_path} (cost={astar_cost})") # matches UCS's optimal cost, usually faster to find| Algorithm | Uses g(n)? | Uses h(n)? | Optimal? |
|---|---|---|---|
| BFS/DFS | No | No | No (DFS) / steps only (BFS) |
| UCS | Yes | No | Yes |
| Greedy | No | Yes | No |
| A* | Yes | Yes | Yes (with admissible h) |
8. Summary & Next Steps
Key Takeaways
- A heuristic
h(n)estimates remaining cost to the goal, giving search algorithms a sense of direction that uninformed search entirely lacks. - Greedy Best-First Search uses only
h(n)— fast, but not optimal, since it ignores cost already spent. - A* uses
f(n) = g(n) + h(n), combining UCS's cost-so-far with Greedy's goal-direction — guaranteed optimal when the heuristic is admissible (never overestimates). - A good heuristic is informative (speeds up search meaningfully), cheap to compute, and admissible; a common design technique is solving a "relaxed" version of the problem and using its cost as the heuristic.
- Consistency is a stronger property than admissibility and is what most practical heuristics (like Manhattan distance) actually satisfy.
Concept Check
- Why is Greedy Best-First Search not guaranteed to find the optimal path, even though it usually finds a path quickly?
- What does it mean for a heuristic to be "admissible," and why is that property required for A*'s optimality guarantee?
- Why does the Manhattan distance heuristic dominate the misplaced-tiles heuristic for the 8-puzzle?
Next Chapter
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index