A* Search Programming Tutorial

Master the A* Search Algorithm through interactive heuristic visualization, f(n) = g(n) + h(n) cost analysis, and hands-on Python programming with real geographic data.

A* Search - Best-First Search with Heuristics

Key Idea: A* combines the benefits of UCS (optimal paths) with the efficiency of greedy search (heuristic guidance). It uses f(n) = g(n) + h(n) where:

g(n)
Actual cost from start to n
h(n)
Heuristic estimate from n to goal
f(n)
Total estimated cost
Algorithm Steps:
  1. Initialize: Add start node to priority queue with f(start) = 0 + h(start)
  2. Main Loop: While queue not empty:
    • Remove node with lowest f(n) from priority queue
    • If goal reached, return optimal path
    • Add node to closed list (visited)
    • For each neighbor: calculate g(neighbor), h(neighbor), f(neighbor)
    • If better path found, update costs and add to priority queue
Heuristic Function: For our Saudi cities example, we use straight-line distance (Euclidean distance) to the goal city. This is admissible (never overestimates) and consistent.
A* Properties
Complete: Yes (if h(n) admissible)
Optimal: Yes (if h(n) admissible)
Time: O(b^d) worst case
Space: O(b^d) stores all nodes
Structure: Priority Queue + Heuristic
A* Priority Queue Visualization
Step 0 of 0
A* Priority Queue - Sorted by f(n) = g(n) + h(n)

Click "Start A*" to begin visualization

Current Step:
šŸŽÆ Ready to Begin A* Search Algorithm
Click "Start A*" to see how A* uses heuristics to find optimal paths efficiently.
šŸ“š What you'll see: f(n) = g(n) + h(n) calculations, priority queue operations, and heuristic guidance.
šŸŽ“ Learning Goal: Understand how A* balances optimality with efficiency using admissible heuristics!
Interactive City Map with Heuristics
f(n) Total cost estimate
g(n) Actual cost
h(n) Heuristic estimate
Python Implementation - A* Search
import heapq
import math
from collections import defaultdict

def astar_search(graph, start, goal, heuristic_func):
    """
    A* Search Algorithm Implementation
    
    Args:
        graph: Dictionary where keys are nodes and values are lists of (neighbor, cost) tuples
        start: Starting node
        goal: Goal node
        heuristic_func: Function that takes a node and returns heuristic estimate to goal
    
    Returns:
        (path, total_cost) or (None, float('inf')) if no path exists
    """
    
    print(f"🌟 Starting A* Search: {start} → {goal}")
    print("=" * 50)
    
    # Priority queue: (f_cost, g_cost, node, path)
    # f_cost = g_cost + h_cost for priority ordering
    open_list = [(0, 0, start, [start])]
    
    # Best known g-costs (actual cost from start)
    g_costs = defaultdict(lambda: float('inf'))
    g_costs[start] = 0
    
    # Closed set (visited nodes)
    closed_set = set()
    
    print(f"🧠 Heuristic function: {heuristic_func.__name__}")
    print(f"šŸ“Š Initial state:")
    print(f"   g({start}) = 0")
    print(f"   h({start}) = {heuristic_func(start)}")
    print(f"   f({start}) = {0 + heuristic_func(start)}")
    print()
    
    while open_list:
        # Get node with lowest f-cost (PRIORITY DEQUEUE)
        f_cost, g_cost, current, path = heapq.heappop(open_list)
        h_cost = f_cost - g_cost
        
        print(f"šŸ” Processing: {current}")
        print(f"   šŸ“Š Costs: g({current})={g_cost}, h({current})={h_cost}, f({current})={f_cost}")
        print(f"   šŸ›¤ļø  Current path: {' → '.join(path)}")
        
        # Skip if already processed with better cost
        if current in closed_set:
            print(f"   ā­ļø  {current} already in closed set, skipping")
            continue
        
        # Add to closed set
        closed_set.add(current)
        
        # Check if goal reached
        if current == goal:
            print(f"šŸŽÆ Goal {goal} reached!")
            print(f"šŸ† Optimal path: {' → '.join(path)}")
            print(f"šŸ’° Total cost: {g_cost}")
            print(f"šŸŽÆ Final f-cost: {f_cost}")
            return path, g_cost
        
        # Explore neighbors
        neighbors = graph.get(current, [])
        print(f"   šŸ‘€ Exploring neighbors: {[n[0] for n in neighbors]}")
        
        for neighbor, edge_cost in neighbors:
            if neighbor in closed_set:
                print(f"      ā­ļø  {neighbor} already processed, skipping")
                continue
            
            # Calculate costs
            tentative_g = g_cost + edge_cost
            h_neighbor = heuristic_func(neighbor)
            f_neighbor = tentative_g + h_neighbor
            
            print(f"      šŸ™ļø  Neighbor: {neighbor}")
            print(f"         šŸ“ g({neighbor}) = g({current}) + edge = {g_cost} + {edge_cost} = {tentative_g}")
            print(f"         🧠 h({neighbor}) = {h_neighbor} (heuristic estimate to goal)")
            print(f"         ⭐ f({neighbor}) = g + h = {tentative_g} + {h_neighbor} = {f_neighbor}")
            
            # If this path to neighbor is better
            if tentative_g < g_costs[neighbor]:
                g_costs[neighbor] = tentative_g
                new_path = path + [neighbor]
                
                # Add to open list with priority f_neighbor (PRIORITY ENQUEUE)
                heapq.heappush(open_list, (f_neighbor, tentative_g, neighbor, new_path))
                print(f"         ✨ Updated! Added to open list with f={f_neighbor}")
            else:
                print(f"         ⚫ Not better than existing g={g_costs[neighbor]}")
        
        print(f"   šŸ“‹ Open list size: {len(open_list)}")
        print(f"   šŸ”’ Closed set size: {len(closed_set)}")
        print("-" * 50)
    
    print(f"āŒ No path found from {start} to {goal}")
    return None, float('inf')

def euclidean_heuristic_saudi(node):
    """
    Straight-line distance heuristic for Saudi cities to Makkah
    Based on approximate geographic coordinates
    """
    # Approximate coordinates (for heuristic calculation only)
    coordinates = {
        'Riyadh': (24.7136, 46.6753),
        'Qassim': (26.3927, 43.9697),
        'Medina': (24.5247, 39.5692),
        'Jeddah': (21.4858, 39.1925),
        'Makkah': (21.3891, 39.8579)
    }
    
    goal_coords = coordinates['Makkah']
    node_coords = coordinates.get(node, goal_coords)
    
    # Calculate Euclidean distance (converted to approximate km)
    lat_diff = goal_coords[0] - node_coords[0]
    lon_diff = goal_coords[1] - node_coords[1]
    distance = math.sqrt(lat_diff**2 + lon_diff**2)
    
    # Convert degrees to approximate kilometers (rough conversion)
    km_distance = int(distance * 111)  # 1 degree ā‰ˆ 111 km
    
    return km_distance

# Example: Saudi Cities with real distances (km)
saudi_cities = {
    'Riyadh': [('Qassim', 408), ('Jeddah', 849)],
    'Qassim': [('Riyadh', 408), ('Medina', 320)],
    'Medina': [('Qassim', 320), ('Jeddah', 420)],
    'Jeddah': [('Riyadh', 849), ('Medina', 420), ('Makkah', 80)],
    'Makkah': [('Jeddah', 80)]
}

if __name__ == "__main__":
    print("šŸ•Œ A* SEARCH: RIYADH TO MAKKAH WITH HEURISTICS")
    print("=" * 60)
    
    # Display heuristic values for all cities
    print("🧠 HEURISTIC VALUES (straight-line distance to Makkah):")
    for city in saudi_cities.keys():
        h_val = euclidean_heuristic_saudi(city)
        print(f"   h({city}) = {h_val} km")
    print()
    
    path, cost = astar_search(saudi_cities, 'Riyadh', 'Makkah', euclidean_heuristic_saudi)
    
    print(f"\nšŸ“‹ FINAL SUMMARY:")
    print(f"šŸ Start: Riyadh")
    print(f"šŸŽÆ Goal: Makkah")
    if path:
        print(f"šŸ›¤ļø  Optimal Path: {' → '.join(path)}")
        print(f"šŸ’° Total Cost: {cost} km")
        print(f"šŸ™ļø  Cities in Path: {len(path)}")
        print(f"🧠 Algorithm: A* with Euclidean heuristic")
        print(f"⚔ Efficiency: Explored fewer nodes than blind search!")
    else:
        print(f"āŒ No path found")
    
    print(f"\nšŸŽ“ ALGORITHM INSIGHTS:")
    print(f"āœ… A* guarantees optimal solution when heuristic is admissible")
    print(f"āœ… Heuristic guides search toward goal (more efficient than UCS)")
    print(f"āœ… f(n) = g(n) + h(n) balances actual cost and estimated remaining cost")
    print(f"🧠 Better heuristic → more efficiency (fewer nodes explored)")
Code Execution Output
Python 3.x Terminal astar_search_demo.py
Click "Run Code" to execute A* search algorithm and see step-by-step output.
A* Key Insights
  • Admissible Heuristic: h(n) never overestimates actual cost to goal
  • Consistent Heuristic: h(n) ≤ c(n,n') + h(n') for optimality guarantee
  • f-cost Guidance: Lower f(n) values explored first
  • Optimal & Complete: Finds shortest path if heuristic is admissible
  • Efficiency: Good heuristic reduces nodes explored vs blind search
  • Memory Trade-off: Stores all generated nodes (can be memory-intensive)
A* vs Other Algorithms
  • vs BFS: A* is optimal and often more efficient with good heuristic
  • vs DFS: A* guarantees optimality, DFS does not
  • vs UCS: A* uses heuristic guidance, often explores fewer nodes
  • vs Greedy: A* is optimal, greedy is not (but faster)
  • Best Use: When you need optimal paths and have a good heuristic
  • Common Heuristics: Euclidean, Manhattan distance, etc.
Practice Exercise
Challenge: Master A* Search Algorithm

Try these exercises to deepen your understanding of A* search:

Beginner Level:
  • Calculate f(n) values manually for each step
  • Compare A* performance with UCS on the same problem
  • Implement different heuristic functions
  • Test with inadmissible heuristics and observe results
Advanced Level:
  • Implement A* for grid-based pathfinding
  • Create weighted A* (WA*) variant
  • Implement bidirectional A* search
  • Design problem-specific admissible heuristics
Pro Tip: The quality of your heuristic directly impacts A* efficiency. Better heuristics (closer to actual cost without overestimating) lead to fewer node expansions!