UCS/Dijkstra Programming Tutorial

Master Uniform-Cost Search and Dijkstra's Algorithm through interactive priority queue visualization, cost tracking, and hands-on Python programming.

Uniform-Cost Search (UCS) / Dijkstra's Algorithm

Key Idea: UCS expands nodes based on their accumulated path cost, g(n), always prioritizing the node with the lowest cost. It employs a priority queue (min-heap) to ensure it finds the optimal path when all edge costs are positive.

How It Works:
  1. Start at the source node
  2. Insert the starting node into a priority queue with a path cost of 0
  3. Repeatedly remove the node with the smallest path cost from the priority queue
  4. Expand its neighbors, calculating their path costs. Update costs if a better path is found
  5. The algorithm stops when the goal node is dequeued (UCS) or all nodes are processed (Dijkstra)
Teaching Note: Breadth-First Search (BFS) can be considered a special case of UCS where all edge costs are uniform (e.g., cost = 1). Dijkstra's Algorithm is essentially Uniform-Cost Search applied to finding shortest paths from a single source to all other nodes in a graph.
Algorithm Properties
Complete: Yes (if costs > 0)
Optimal: Yes
Time: O((V + E) log V)
Space: O(V)
Structure: Priority Queue
Priority Queue Visualization
Step 0 of 0
Priority Queue (Min-Heap) - Lower cost = Higher priority

Click "Start UCS" to begin visualization

Current Step:
šŸŽÆ Ready to Begin UCS Algorithm
Click "Start UCS" to begin the Uniform-Cost Search demonstration.
šŸ“š What you'll see: Step-by-step priority queue operations, g(n) cost calculations, and optimal path discovery.
šŸŽ“ Learning Goal: Understand how UCS finds the shortest path by always expanding the lowest-cost node first.
Interactive City Map
Geographic layout shows Saudi Arabia cities
Start Cities Goal
Python Implementation
import heapq
from collections import defaultdict

def ucs_dijkstra(graph, start, goal=None):
    """
    Uniform-Cost Search / Dijkstra's Algorithm implementation
    
    Args:
        graph: Dictionary where keys are nodes and values are lists of (neighbor, cost) tuples
        start: Starting node
        goal: Goal node (None for full Dijkstra to all nodes)
    
    Returns:
        If goal specified: (path, total_cost) or (None, float('inf')) if no path
        If goal is None: (distances, previous) for all reachable nodes
    """
    
    # Priority queue: (cost, node, path)
    priority_queue = [(0, start, [start])]
    
    # Best known distances to each node
    distances = defaultdict(lambda: float('inf'))
    distances[start] = 0
    
    # For path reconstruction
    previous = {}
    visited = set()
    
    print(f"šŸš€ Starting UCS/Dijkstra from {start}")
    print(f"šŸ“Š Initial state: distances = {dict(distances)}")
    print(f"šŸ”„ Priority queue: {priority_queue}")
    print()
    
    while priority_queue:
        # Get node with minimum cost (PRIORITY DEQUEUE)
        current_cost, current_node, current_path = heapq.heappop(priority_queue)
        
        print(f"šŸ” Processing: {current_node} (cost: {current_cost})")
        print(f"šŸ“Š Current path: {' → '.join(current_path)}")
        
        # Skip if we've already processed this node with a better cost
        if current_node in visited:
            print(f"ā­ļø  {current_node} already visited with better cost, skipping")
            continue
        
        # Mark as visited
        visited.add(current_node)
        
        # If this is our goal, we're done (for UCS)
        if goal and current_node == goal:
            print(f"šŸŽÆ Goal {goal} reached!")
            print(f"šŸ† Optimal path: {' → '.join(current_path)}")
            print(f"šŸ’° Total cost: {current_cost}")
            return current_path, current_cost
        
        # Explore neighbors
        neighbors = graph.get(current_node, [])
        print(f"šŸ‘€ Exploring neighbors of {current_node}: {[n[0] for n in neighbors]}")
        
        for neighbor, edge_cost in neighbors:
            new_cost = current_cost + edge_cost
            
            # If we found a better path to this neighbor
            if new_cost < distances[neighbor]:
                distances[neighbor] = new_cost
                previous[neighbor] = current_node
                new_path = current_path + [neighbor]
                
                # Add to priority queue (PRIORITY ENQUEUE)
                heapq.heappush(priority_queue, (new_cost, neighbor, new_path))
                print(f"  āž• Updated {neighbor}: cost {new_cost} via {current_node}")
            else:
                print(f"  ⚫ {neighbor}: cost {new_cost} not better than {distances[neighbor]}")
        
        print(f"šŸ“Š Current distances: {dict(distances)}")
        print(f"šŸ”„ Priority queue size: {len(priority_queue)}")
        print("-" * 50)
    
    if goal:
        print(f"āŒ No path found to {goal}")
        return None, float('inf')
    else:
        print(f"āœ… Dijkstra complete! Final distances: {dict(distances)}")
        return distances, previous

def reconstruct_path(previous, start, goal):
    """Reconstruct path from previous pointers"""
    path = []
    current = goal
    while current is not None:
        path.append(current)
        current = previous.get(current)
    path.reverse()
    return path if path[0] == start else []

# 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("=" * 60)
    print("šŸ•Œ UNIFORM-COST SEARCH: RIYADH TO MAKKAH")
    print("=" * 60)
    
    path, cost = ucs_dijkstra(saudi_cities, 'Riyadh', 'Makkah')
    
    print(f"\nšŸ“‹ 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 Visited: {len(path)}")
    else:
        print(f"āŒ No path found")
Code Execution Output
Python 3.x Terminal ucs_dijkstra_demo.py
Click "Run Code" to execute UCS/Dijkstra algorithm and see step-by-step output.
Key Observations
  • Priority-Based: Always processes the node with lowest accumulated cost
  • Cost Tracking: Maintains g(n) values for all discovered nodes
  • Path Updates: Updates costs when better paths are discovered
  • Optimal Solution: Guaranteed to find shortest path for positive edge weights
  • Early Termination: UCS stops when goal is dequeued; Dijkstra processes all nodes
Programming Insights
  • Min-Heap: Use heapq for efficient priority queue operations
  • Cost Comparison: Always check if new path offers better cost
  • Visited Set: Prevents reprocessing nodes with worse costs
  • Path Reconstruction: Store parent pointers for path recovery
  • Infinity Handling: Use float('inf') for unreachable nodes
Heap Structure in Search Algorithms
šŸ”¹ What is a Heap?

A binary heap is a complete binary tree stored as an array. It satisfies the heap property:

  • Min-Heap: Parent ≤ Children (used in priority queues)
  • Max-Heap: Parent ≄ Children

šŸ‘‰ For search algorithms, we usually use a min-heap (so the smallest cost node is always at the top).

šŸ”¹ Operations
  • Insert (push): Add element → bubble up to maintain heap property
  • Extract-Min (pop): Remove root (smallest) → move last element to root → bubble down
  • Peek: Look at root without removing it
šŸ”¹ Example: Min-Heap for UCS/Dijkstra

Suppose we have costs to reach nodes:

Start → A = 5
Start → B = 2
Start → C = 8

The min-heap (priority queue) will store them as:

        (2:B)
       /     \
   (5:A)     (8:C)

First pop() → node B (2) (lowest cost).

If exploring B leads to a cheaper cost to C = 4, update heap:

        (4:C)
       /     \
   (5:A)     (old C removed)

Now C (cost 4) is the next to expand, then A (5).

šŸ”¹ Where Heap Fits in Each Algorithm
DFS: Frontier = Stack
āŒ No heap needed
BFS: Frontier = Queue
āŒ No heap needed
UCS/Dijkstra: Frontier = Priority Queue
āœ… Min-Heap implementation
A* Search: Same as Dijkstra but priority = cost + heuristic
āœ… Min-Heap implementation
⚔ Performance Benefits
  • Insert: O(log n)
  • Extract-Min: O(log n)
  • Peek: O(1)
Much better than O(n) linear search!
Key Takeaway for Students:
A heap is just a fast way to implement a priority queue, making operations like "get the lowest-cost node" efficient. The heap structure ensures that we can always quickly find and remove the most promising node to explore next!
Implementation Note:
In Python, we use heapq module which provides a min-heap. In our JavaScript visualization above, we use a simpler sorted array approach for educational clarity, but real implementations use binary heaps for efficiency.
šŸ”¹ Heap vs Dictionary Approach
šŸ† Heap-Based Priority Queue
  • āœ… Efficient: O(log n) operations
  • āœ… Scalable: Great for large graphs
  • āœ… Industry Standard: Used in real implementations
  • āš ļø Complex: Harder to visualize and debug
šŸ“š Dictionary Approach View Tutorial
  • āœ… Simple: Easy to understand and debug
  • āœ… Educational: Perfect for learning
  • āœ… Transparent: See all costs at once
  • āš ļø Slower: O(n) to find minimum
Practice Exercise
Challenge: Modify the UCS Algorithm

Try these modifications to deepen your understanding:

Beginner Level:
  • Add a counter to track how many nodes were explored
  • Print the priority queue contents at each step
  • Implement a function to find the second shortest path
Advanced Level:
  • Implement bidirectional UCS
  • Add heuristic function to convert UCS to A*
  • Handle negative edge weights (Bellman-Ford style)
Tip: Compare the performance of UCS vs BFS vs DFS on the same graph. Notice how UCS finds the optimal solution by considering edge weights!