Search Algorithms Lab

Frontier Data Structures Mini-Lab

Hands-on exploration of FIFO Queue (BFS), LIFO Stack (DFS), and Min-Heap (UCS)

Learning Goals (10 minutes)

Understand Data Structures

Learn what FIFO, LIFO, and Priority Queue (min-heap) mean and how they work.

See Search Order Impact

Observe how each structure changes the search order (BFS vs DFS vs UCS).

Write Working Code

Create small, working Python snippets to use each structure in graph search.

Mental Model: What We're Building

BFS uses FIFO Queue

Explores layer by layer → shortest number of edges

DFS uses LIFO Stack

Dives deep → low memory, not guaranteed shortest path

UCS uses Min-Heap

Expands lowest path-cost next → finds cheapest path when costs ≥ 0

FIFO Queue

Used by BFS

Like a line at a coffee shop - first person in line gets served first. BFS uses this to explore all neighbors at depth 1, then all at depth 2, etc.

class FIFOQueue:
    def __init__(self): 
        self.q = deque()
    def push(self, x): 
        self.q.append(x)          # enqueue
    def pop(self):    
        return self.q.popleft()   # dequeue (front)
    def empty(self):  
        return not self.q
LIFO Stack

Used by DFS

Like a stack of plates - you take from the top (last one added). DFS uses this to dive deep into one path before exploring alternatives.

class LIFOStack:
    def __init__(self): 
        self.s = []
    def push(self, x): 
        self.s.append(x)          # push top
    def pop(self):    
        return self.s.pop()       # pop top
    def empty(self):  
        return not self.s
Min-Heap PQ

Used by UCS

Like an emergency room - patients with most urgent (lowest) priority numbers get treated first. UCS uses this to always expand the node with the lowest path cost.

class MinHeapPQ:
    def __init__(self): 
        self.h = []
    def push(self, priority, item):
        heapq.heappush(self.h, (priority, item))
    def pop(self):
        priority, item = heapq.heappop(self.h)
        return priority, item
    def empty(self):  
        return not self.h
graph = {
    "S": [("A", 1), ("B", 4)],
    "A": [("C", 2), ("G", 10)],
    "B": [("C", 1), ("G", 5)],
    "C": [("G", 3)],
    "G": []
}
Graph Structure
S (start)
├─ A (cost 1)
│ ├─ C (cost 2)
│ └─ G (cost 10)
└─ B (cost 4)
   ├─ C (cost 1)
   └─ G (cost 5)
C → G (cost 3)
Possible Paths from S to G:
  • S → A → G: cost 1 + 10 = 11
  • S → B → G: cost 4 + 5 = 9
  • S → A → C → G: cost 1 + 2 + 3 = 6 ⭐ (cheapest)
  • S → B → C → G: cost 4 + 1 + 3 = 8
BFS (FIFO queue) — shortest number of edges
def bfs(start, goal, graph):
    frontier = FIFOQueue()
    frontier.push(start)
    parent = {start: None}
    visited = {start}

    while not frontier.empty():
        u = frontier.pop()
        if u == goal:
            # reconstruct path
            path = []
            while u is not None:
                path.append(u); u = parent[u]
            return list(reversed(path))
        # neighbors: ignore costs for BFS
        for v, _ in graph[u]:
            if v not in visited:
                visited.add(v)
                parent[v] = u
                frontier.push(v)
    return None
DFS (LIFO stack) — dives deep
def dfs(start, goal, graph):
    frontier = LIFOStack()
    frontier.push(start)
    parent = {start: None}
    visited = set()

    while not frontier.empty():
        u = frontier.pop()
        if u in visited: 
            continue
        visited.add(u)
        if u == goal:
            path = []
            while u is not None:
                path.append(u); u = parent[u]
            return list(reversed(path))
        for v, _ in graph[u]:
            if v not in visited:
                parent[v] = u
                frontier.push(v)
    return None
UCS (min-heap PQ) — cheapest total cost
def ucs(start, goal, graph):
    frontier = MinHeapPQ()
    frontier.push(0, start)        # (g, node)
    parent = {start: None}
    best_g = {start: 0}

    while not frontier.empty():
        g, u = frontier.pop()
        if g != best_g.get(u, float("inf")):
            continue               # skip outdated queue entries
        if u == goal:
            path = []
            while u is not None:
                path.append(u); u = parent[u]
            return list(reversed(path)), g
        for v, cost in graph[u]:
            ng = g + cost
            if ng < best_g.get(v, float("inf")):
                best_g[v] = ng
                parent[v] = u
                frontier.push(ng, v)
    return None, None
if __name__ == "__main__":
    print("BFS path:", bfs("S", "G", graph))
    print("DFS path:", dfs("S", "G", graph))
    path, cost = ucs("S", "G", graph)
    print("UCS path:", path, "cost:", cost)
Typical Results:
BFS Result

Path: S → B → G

Cost: 9 (not optimal)

Finds fewest edges but ignores costs
DFS Result

Path: Variable

Cost: Depends on path

Depth-first exploration, path varies
UCS Result

Path: S → A → C → G

Cost: 6 (optimal!) ⭐

Guaranteed cheapest path
Question 1

Why does BFS find the fewest edges but not the cheapest path here?

Because BFS ignores edge weights; it assumes each step costs the same. It explores level by level, guaranteeing the minimum number of steps, but this doesn't account for edge costs.
Question 2

Why does UCS guarantee the cheapest path when costs are nonnegative?

UCS always expands the currently cheapest-cost frontier node. If there were a cheaper path to the goal, it would contain a node with lower cost that would have been expanded first.
Question 3

DFS memory vs. reliability?

DFS uses little memory but can wander down long dead ends and is not optimal. BFS uses more memory but is more systematic.
Question 4

What changes if all edges had cost = 1?

UCS and BFS expand nodes in the same order; both return shortest paths, since minimizing edges and minimizing cost would be equivalent.
Exercise 1

Modify the graph by adding a new edge A → G with cost 2. Run all algorithms again. Which results changed and why?

Exercise 2

Create your own graph with at least 5 nodes. Make sure UCS and BFS find different paths. What does this tell you about edge weights?

Exercise 3

Add print statements to see the frontier contents at each step. Observe how FIFO, LIFO, and priority ordering affect exploration.

Exercise 4

Implement a simple graph visualization function that shows the order in which nodes were expanded for each algorithm.

Exercise 5

Time the three algorithms on larger graphs. Which is fastest? Why might the performance differences occur?

Run all three functions (bfs, dfs, ucs) on the given graph
Record the BFS path, DFS path, and UCS path + cost
Write 3-5 sentences explaining how the frontier data structure affects the outcome
Answer at least 2 discussion questions with your own reasoning
(Optional) Complete one hands-on exercise for extra understanding
Reflection Template:

I observed that the choice of frontier data structure significantly affects...
BFS found the path _____ because it uses a _____ which...
DFS found the path _____ because it uses a _____ which...
UCS found the path _____ because it uses a _____ which...
The key insight is that...

Bonus Challenge

Add a new edge from A → G with cost 2 to the graph:

Questions:

  1. What do BFS, DFS, and UCS return now?
  2. Which paths changed, and why?
  3. Is there now a tie for the optimal UCS path?
  4. How do the exploration orders differ?
Extra Challenge: Implement the A* algorithm by adding a heuristic function to UCS!
Common Student Pitfalls:
  • Forgetting a visited (BFS/DFS) or best_g check (UCS) → loops or wrong costs
  • Using a max-heap by mistake (Python's heapq is a min-heap—we're good)
  • Not reconstructing the path via parent
  • Thinking BFS always finds optimal paths (only for unweighted graphs)
Extension Ideas:
  • Add bidirectional search using two frontiers
  • Implement A* by adding heuristic to UCS priority
  • Compare performance on larger randomly generated graphs
  • Visualize search tree expansion using matplotlib or graphviz
  • Add iterative deepening DFS as memory-efficient alternative to BFS
Learning Assessment:

Students should be able to explain:

  • Why different data structures lead to different exploration orders
  • When each algorithm is preferable (optimality vs memory vs speed)
  • How to implement basic graph search with any frontier structure

Ready to Code?

Download the complete Python implementation to run on your machine

Download Python Code Back to Lecture 3