Lecture 38 - Shortest Paths

Logistics

  • Last topic lecture of the semester!
  • HW6 corrections due Friday (there are no HW7 corrections)
  • Tuesday discussion and Wednesday lecture will be review
  • Final exam is 12pm-2 on Wed, 12/17

Heads-up about the final

Exam draft as I have it now (subject to change):

Part 1: Rust Fundamentals Fill-ins (23 pts, 1 point per blank) Part 2: What Does This Do? (8 pts, 2 points each, mix of old and new) Part 3: Shell and Git Commands (12 pts, 2 points each) Part 4: Stack and Heap Diagram (10 points) Part 5: Hand-Coding Problems (18 points, 6 points each)

  • 5.1 - Write a basic function (old content)
  • 5.2 - Write a function with closures (new content)
  • 5.3 - Write two tests (new content)

Part 6: Computational Complexity Analysis (12 points)

  • 3x "What's the computational complexity of this code" - 2 points each
  • A table of algorithms asking time complexity and key data structure - 6 points Part 7: Algorithms Tracing (~ 25 points, 5 problems with 5 points each)
  • BST, max-heap, BFS/DFS, Topological sort and MST, Dijkstra's Part 8: Data Structures and Algorithms Fill-ins (21 pts, 1 point per blank)

So total:

  • Old ~ 42%, New ~ 58%

Learning objectives

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

  • Explain how Dijkstra's algorithm solves the single-source shortest path problem
  • Trace Dijkstra's algorithm by hand
  • Analyze the time complexity of Dijkstra's algorithm
  • Know Dijkstra's algorithm uses a priority queue

Motivation for the shortest path problem

Everyday problem: Find the fastest/shortest route from A to B

Examples:

  • GPS navigation (minimize time or distance or cost)
  • Network routing (minimize latency)
  • Flight planning (minimize cost or time)
  • Many, many others

Why BFS doesn't work for weighted graphs

BFS finds shortest path in UNWEIGHTED graphs

Example where BFS fails:

    A --100-- B
    |         |
    1         1
    |         |
    C ---1--- D

BFS from A: visits B first (fewer edges)
  Path A → B = 100

But better path exists:
  Path A → C → D → B = 1 + 1 + 1 = 3

BFS considers number of edges, not total weight!

Think-pair-share: Shortest path intuition

Graph:

       A
      3/ \4
     B    C
    3|  1/|3
     | /  |
     D    E
    4|    |2
     F    G

Question: What's the shortest path from A to F?

Dijkstra's algorithm - The idea

Dijkstra's Algorithm (1959): Greedy algorithm for finding shortest paths

Key idea: Repeatedly pick the closest unvisited vertex, update distances to its neighbors

Intuition: If you know the shortest way to get somewhere, you can use that to find shortest ways to places nearby

The algorithm (informal)

Maintain:

  • Distance to each vertex (initially ∞, except source = 0)
  • Visited set (vertices with finalized shortest distance)
  • Priority queue of (vertex, distance) pairs

Repeat:

  1. Pick unvisited vertex with smallest distance
  2. Mark it as visited (distance is now finalized)
  3. Update distances to neighbors: if going through this vertex is shorter, update!

Continue until all vertices visited

Visual intuition: Expanding frontier

Like water spreading from source:

https://www.youtube.com/shorts/X7EMDd82ZmI

Dijkstra systematically explores by increasing distance

Example: Dijkstra's algorithm trace

Graph:

       A
     2/ \5
     B   C
    1| 1/|3
     | / |
     D   E

Find shortest paths from A:

Initial:

Distances: A=0, B=∞, C=∞, D=∞, E=∞
Visited: {}
Priority Queue: [(A, 0)]

Step 1: Process A (distance 0)

Visit A
Update neighbors:
  A → B: 0 + 2 = 2 (update B from ∞ to 2)
  A → C: 0 + 5 = 5 (update C from ∞ to 5)

Distances: A=0, B=2, C=5, D=∞, E=∞
Visited: {A}
Priority Queue: [(B, 2), (C, 5)]

Step 2: Process B (distance 2)

Visit B
Update neighbors:
  B → D: 2 + 1 = 3 (update D from ∞ to 3)

Distances: A=0, B=2, C=5, D=3, E=∞
Visited: {A, B}
Priority Queue: [(D, 3), (C, 5)]

Step 3: Process D (distance 3)

Visit D
Update neighbors:
  D → C: 3 + 1 = 4 < 5 (update C from 5 to 4!)

Distances: A=0, B=2, C=4, D=3, E=∞
Visited: {A, B, D}
Priority Queue: [(C, 4), (C, 5-old)]  # Will extract 4

Step 4: Process C (distance 4)

Visit C
Update neighbors:
  C → E: 4 + 3 = 7 (update E from ∞ to 7)

Distances: A=0, B=2, C=4, D=3, E=7
Visited: {A, B, D, C}
Priority Queue: [(E, 7)]

Step 5: Process E (distance 7)

Visit E
No unvisited neighbors

Distances: A=0, B=2, C=4, D=3, E=7
Visited: {A, B, D, C, E}
Done!

Final shortest distances from A:

  • A: 0
  • B: 2 (path: A → B)
  • C: 4 (path: A → B → D → C)
  • D: 3 (path: A → B → D)
  • E: 7 (path: A → B → D → C → E)

Another demo

https://www.cs.usfca.edu/~galles/visualization/Dijkstra.html

Why does it work?

Greedy choice: Always process the closest unvisited vertex

Correctness argument:

  1. When we visit a vertex v with distance d, d is the shortest distance to v
  2. Why? Any other path to v must go through an unvisited vertex u
  3. But u has distance ≥ d (we chose v as closest!)
  4. So path through u has length ≥ d

Key assumption: All edge weights are non-negative!

  • Negative weights can break the algorithm (need Bellman-Ford instead)

Think about: Negative weights

What goes wrong with negative weights?

Drawing on the board

Dijkstra assumes: No benefit to detouring through other vertices

  • True with non-negative weights
  • False with negative weights

Implementation in Rust (for your reference)

#![allow(unused)]
fn main() {
use std::collections::{BinaryHeap, HashMap};
use std::cmp::{Ordering, Reverse};

fn dijkstra(
    graph: &HashMap<usize, Vec<(usize, i32)>>,  // vertex = [(neighbor, weight)]
    source: usize,
    num_vertices: usize
) -> Vec<Option<i32>> {
    let mut distances = vec![None; num_vertices];
    distances[source] = Some(0);

    let mut pq = BinaryHeap::new();
    pq.push(Reverse((0, source)));  // (distance, vertex) - min-heap
}
#![allow(unused)]
fn main() {
    while !pq.is_empty() {
        let Reverse((dist, u)) = pq.pop().unwrap();

        // Skip if we found a better path already
        if let Some(current_dist) = distances[u] {
            if dist > current_dist {
                continue;
            }
        }

        // Process neighbors
        if graph.contains_key(&u) {
            let neighbors = graph.get(&u).unwrap();
            for &(v, weight) in neighbors {
                let alt = dist + weight;

                // Update if shorter path found
                if distances[v].is_none() || alt < distances[v].unwrap() {
                    distances[v] = Some(alt);
                    pq.push(Reverse((alt, v)));
                }
            }
        }
    }

    distances
}
}

Dijkstra's complexity analysis

Time complexity:

  • Each vertex added to priority queue once: O(V)
  • Each edge causes at most one priority queue update: O(E)
  • Each priority queue operation: O(log V)
  • Total: O((V + E) log V) = O(E log V) (assuming connected graph)

Space complexity:

  • Distance array: O(V)
  • Priority queue: O(V) vertices at once in worst case
  • Total: O(V)

Efficient! Much better than trying all paths (exponential!)

Complexity comparison

Finding shortest paths in a graph with V vertices, E edges:

AlgorithmProblemTime
BFSUnweightedO(V + E)
DijkstraNon-negative weightsO(E log V)
Bellman-FordAny weights (detects negative cycles)O(VE)

(You're not responsible for anything about Bellman-Ford)

Trade-off: More general algorithms are slower

Dijkstra's algorithm summary

Key steps:

  1. Initialize distances (source=0, others=∞)
  2. Use priority queue (min-heap) of (distance, vertex)
  3. Extract minimum, mark visited
  4. Update neighbors if shorter path found
  5. Repeat until all visited

Why it works:

  • Greedy: always process closest vertex first
  • Optimal substructure: shortest path consists of shortest paths
  • Non-negative weights ensure no benefit to detouring

Activity - Dijkstra's practice and confidence quiz