Lecture 36 - Graph representation & traversals

Logistics

  • HW7 due Friday Dec 5 (no corrections)
  • HW6 still being graded
  • Last three lectures: Graph algorithms

Learning objectives

By the end of today, you should be able to:

  • Represent graphs using adjacency lists and adjacency matrices
  • Explain BFS and DFS algorithms
  • Analyze the time and space complexity of graph algorithms
  • Recognize applications of BFS and DFS

What is a graph?

Graph: A collection of nodes (vertices) connected by edges

Unlike trees:

  • No root
  • Can have cycles
  • Can have multiple paths between nodes
  • Edges can be directed OR undirected

Graph terminology

Basic terms:

    A --- B
    |     |
    C --- D --- E
  • Vertex/Node: A, B, C, D, E (the circles)
  • Edge: Connection between vertices (the lines)
  • Neighbors/Adjacent: B is adjacent to A and D
  • Degree: Number of edges connected to vertex
    • Degree of A = 2 (connected to B and C)
    • Degree of D = 3 (connected to B, C, E)
  • Path: Sequence of vertices connected by edges
    • A → B → D → E is a path from A to E
  • Cycle: Path that starts and ends at same vertex
    • A → B → D → C → A is a cycle

Directed vs Undirected graphs

Undirected graph: Edges have no direction (two-way streets)

    A --- B      A can reach B, B can reach A
    |     |
    C --- D

Directed graph (digraph): Edges have direction (one-way streets)

    A --> B      A can reach B, but B cannot reach A
    ^     |
    |     v
    C <-- D     C and D have two edges, one going each way
      -->

Example: Twitter follows are directed (you can follow someone who doesn't follow back)

(If you're in 122, you've been seeing a lot of these with Markov Chains!)

When there are no cycles (paths back to a node once you leave) we call this a Directed Acyclic Graph or DAG

Weighted vs Unweighted graphs

Unweighted: All edges are equal

    A --- B

Weighted: Edges have costs/weights

    A --5-- B     Distance, cost, time, etc.

Example: Road network with distances, flight routes with costs, Markov Chains with probabilities

Think-pair-share: Graph examples

Which of these are naturally modeled as

  • Weighted/unweighted
  • Directed/undirected
  • Cyclic/acyclic
  1. Social network (Facebook, LinkedIn)
  2. Road map
  3. Course prerequisites
  4. Web pages with links
  5. Family tree
  6. Chess game states

Challenge: How to store a graph?

Need to answer:

  • What vertices exist?
  • Which vertices are connected?
  • (For weighted graphs) What are the edge weights?

Two main approaches:

  1. Adjacency List
  2. Adjacency Matrix

Adjacency list

Idea: For each vertex, store a list of its neighbors

Example graph:

    0 --- 1
    |     |
    2 --- 3

Adjacency list representation:

0: [1, 2]  
1: [0, 3]     
2: [0, 3]
3: [1, 2]

In Rust (using Vec of Vecs):

fn main() {
    // Graph with 4 vertices (0, 1, 2, 3)
    let graph: Vec<Vec<usize>> = vec![
        vec![1, 2],  
        vec![0, 3],  
        vec![0, 3],  
        vec![1, 2],  
    ];
    // Check if edge exists: 0 -- 1?
    if graph[0].contains(&1) {
        println!("Edge 0-1 exists");
    }
    // Iterate over neighbors of vertex 2
    for &neighbor in &graph[2] {
        println!("2 is connected to {}", neighbor);
    }
}

With HashMap (when vertices aren't 0..n):

use std::collections::HashMap;

fn main() {
    let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
    graph.insert("Alice", vec!["Bob", "Charlie"]);
    graph.insert("Bob", vec!["Alice", "David"]);
    graph.insert("Charlie", vec!["Alice", "David"]);
    graph.insert("David", vec!["Bob", "Charlie"]);
    // Neighbors of Alice
    if let Some(neighbors) = graph.get("Alice") {
        println!("Alice's friends: {:?}", neighbors);
    }
}

Adjacency matrix

Idea: 2D array where matrix[i][j] = 1 if edge from i to j exists

(Yep, we're in "row-stochastic world" here... sorry)

Example graph (same as before):

    0 --- 1
    |     |
    2 --- 3

Adjacency matrix:

     0  1  2  3
  0 [0, 1, 1, 0]   Row 0: edges from vertex 0
  1 [1, 0, 0, 1]   Row 1: edges from vertex 1
  2 [1, 0, 0, 1]   Row 2: edges from vertex 2
  3 [0, 1, 1, 0]   Row 3: edges from vertex 3

In Rust:

fn main() {
    // Graph with 4 vertices
    let graph: Vec<Vec<usize>> = vec![
        vec![0, 1, 1, 0],
        vec![1, 0, 0, 1],
        vec![1, 0, 0, 1],
        vec![0, 1, 1, 0],
    ];
    // Check if edge exists: 0 -- 1?
    if graph[0][1] == 1 {
        println!("Edge 0-1 exists");
    }
    // Find all neighbors of vertex 2
    for j in 0..graph[2].len() {
        if graph[2][j] == 1 {
            println!("2 is connected to {}", j);
        }
    }
}

For weighted graphs: Store weight instead of 1, use 0 or ∞ for no edge

Adjacency list vs adjacency matrix

Graph with V vertices, E edges:

OperationAdjacency ListAdjacency Matrix
SpaceO(V + E)O(V^2)
Check if edge (u,v) existsO(degree of u)O(1)
Find all neighbors of uO(degree of u)O(V)
Add edgeO(1)O(1)
Remove edgeO(degree of u)O(1)

Rule of thumb: Use adjacency list unless you have a good reason not to!

They are especially useful for sparse graphs - and most real-world graphs are sparse!

The challenge of exploring a graph

Given a graph and starting vertex, we want to visit all reachable vertices

Two main strategies:

  1. BFS: Explore level by level (breadth-first)
  2. DFS: Explore as far as possible, then backtrack (depth-first)

BFS vs DFS in ten seconds: https://www.youtube.com/shorts/L1vGm2_cPU0

BFS: The idea

Breadth-First Search: Explore vertices in order of their distance from start

  • Like ripples in a pond
  • Or the weird image that works for me, like pinching the graph at a point and "picking it up" and letting the rest fall down by gravity...

Example graph (before "picking up"):

    E---C
     \   \
      \   A---B
       \ /   /
        D---/
         \
          F

"Picked up" by A:

        A
       / \
      B   C
       \ / \
        D   E
         \
          F

BFS traversal starting at A:

Level 0: A
Level 1: B, C (neighbors of A)
Level 2: D, E (neighbors of B and C)
Level 3: F (neighbor of E)

BFS Algorithm

High-level:

  1. Start with source vertex in a queue
  2. Mark source as visited
  3. While queue not empty:
    • Pop a vertex
    • For each unvisited neighbor:
      • Mark as visited
      • Push onto the queue

Notice that: Queue ensures we explore level by level

BFS Example

Graph:

    0 --- 1 --- 4
    |     |
    2 --- 3

BFS from vertex 0:

Step 1: Queue = [0], Visited = {0}
        Pop 0, Push neighbors 1, 2
        Queue = [1, 2], Visited = {0, 1, 2}
Step 2: Pop 1, Push unvisited neighbors 3, 4
        Queue = [2, 3, 4], Visited = {0, 1, 2, 3, 4}
Step 3: Pop 2, no new neighbors (0 and 3 already visited)
        Queue = [3, 4], Visited = {0, 1, 2, 3, 4}
Step 4: Pop 3, no new neighbors
        Queue = [4], Visited = {0, 1, 2, 3, 4}
Step 5: Pop 4, no new neighbors
        Queue = [], Done!
Order visited: 0, 1, 2, 3, 4

BFS Implementation in Rust

#![allow(unused)]
fn main() {
use std::collections::{VecDeque, HashSet};

fn bfs(graph: &Vec<Vec<usize>>, start: usize) {
    let mut queue = VecDeque::new();
    let mut visited = HashSet::new();

    queue.push_back(start);
    visited.insert(start);

    while let Some(vertex) = queue.pop_front() {
        println!("Visiting: {}", vertex);

        for &neighbor in &graph[vertex] {
            if !visited.contains(&neighbor) {
                visited.insert(neighbor);
                queue.push_back(neighbor);
            }
        }
    }
}
}

Output: Visiting: 0, 1, 2, 3, 4

BFS Applications

1. Shortest path in unweighted graph

  • BFS finds shortest path from source to all vertices!
  • Distance = level in BFS tree

2. Connected components

  • Run BFS from each unvisited vertex
  • Each BFS finds one connected component

3. Bipartite testing

  • Can graph be 2-colored? (vertices colored so no edge connects same color)
  • Use BFS to assign colors

4. Social networks

  • Find degrees of separation (Kevin Bacon number, Erdős number, Erdős-Bacon number...)

BFS Complexity

Time complexity:

  • Visit each vertex once: O(V)
  • Check each edge at most twice (once from each endpoint): O(E)
  • Total: O(V + E)

Space complexity:

  • Queue: O(V) in worst case
  • Visited set: O(V)
  • Total: O(V)

Overall linear in graph size

Depth-first search (DFS)

Depth-First Search: Explore as far as possible along each branch before backtracking

Like maze exploration - keep going until you hit a dead end, then backtrack

Example graph:

    A
   / \
  B   C
  |   |
  D   F
  |
  E

Use a stack for this one! Explore deeply before exploring breadth

DFS vs BFS

BFS (Queue - FIFO):

    0
   / \
  1   2
 / \
3   4

Order: 0, 1, 2, 3, 4 (level by level)

DFS (Stack/Recursion - LIFO):

    0
   / \
  1   2
 / \
3   4

Order: 0, 1, 3, 4, 2 (go deep first)

DFS Algorithm

High-level

  1. Start with source vertex in a stack
  2. While stack not empty:
    • Pop a vertex
    • Mark as visited
    • For each unvisited neighbor:
      • Push onto the queue

Notice two differences from BFS:

  • Stack instead of queue
  • Mark as visited after pop instead of after push

DFS Example

Graph:

    0 --- 1 --- 4
    |     |
    2 --- 3

DFS from vertex 0:

Step 1: Stack = [0], Visited = {}
        Pop 0, mark as visited, push neighbors 2, 1
        Stack = [2, 1], Visited = {0}
Step 2: Pop 1, mark as visited, push unvisited neighbors 4, 3
        Stack = [2, 4, 3], Visited = {0, 1}
Step 3: Pop 3, mark as visited, push unvisited neighbors 2
        Stack = [2, 4, 2], Visited = {0, 1, 3}
Step 4: Pop 2, mark as visited (no new neighbors - 0 and 3 already visited)
        Stack = [2, 4], Visited = {0, 1, 2, 3}
Step 5: Pop 2, already visited, skip
        Stack = [4], Visited = {0, 1, 2, 3}
Step 6: Pop 4, mark as visited, no new neighbors
        Stack = [], Done!
Order visited: 0, 1, 3, 2, 4

Different from BFS order!

DFS Implementation

#![allow(unused)]
fn main() {
use std::collections::{HashSet};

fn dfs_iterative(graph: &Vec<Vec<usize>>, start: usize) {
    let mut stack = vec![start];
    let mut visited = HashSet::new();

    while !stack.is_empty() {
        let vertex = stack.pop().unwrap();

        if visited.contains(&vertex) {
            continue;
        }

        visited.insert(vertex);
        println!("Visiting: {}", vertex);

        for &neighbor in &graph[vertex] {
            if !visited.contains(&neighbor) {
                stack.push(neighbor);
            }
        }
    }
}
}

Note: Order might differ slightly from recursive version depending on how neighbors are added

DFS Applications

1. Pathfinding

  • Find any path between two vertices
  • Not necessarily shortest (unlike BFS)

2. Cycle detection

  • If we encounter a visited vertex that's not the parent, there's a cycle

3. Topological sorting (next lecture!)

  • Order vertices in directed acyclic graph

4. Solving puzzles

  • Sudoku, N-queens (try solutions, backtrack if invalid)

DFS Complexity

Time complexity:

  • Visit each vertex once: O(V)
  • Explore each edge at most twice: O(E)
  • Total: O(V + E)

Space complexity (recursive):

  • Recursion stack: O(V) in worst case (if graph is a long chain)
  • Visited set: O(V)
  • Total: O(V)

Same as BFS!

Think about: BFS vs DFS

When to use BFS:

  • Find shortest path (unweighted)
  • Find closest/nearest items
  • Level-order traversal

When to use DFS:

  • Explore all paths
  • Detect cycles
  • Topological sort
  • Solve mazes/puzzles (with backtracking)

Both work for: Connected components, reachability

Summary

BFS (Queue):           DFS (Stack):
    0                      0
   / \                    / \
  1   2                  1   2
 / \                    / \
3   4                  3   4

Visit: 0,1,2,3,4      Visit: 0,1,3,4,2
(breadth-first)       (depth-first)
PropertyBFSDFS
Data structureQueueStack/Recursion
TimeO(V + E)O(V + E)
SpaceO(V)O(V)
Shortest pathYesNo
MemoryMore (queue can be large)Less (stack depth = path length)
ApplicationsShortest path, level-orderCycle detection, topological sort

Activity time

See website / gradescope