1. Introduction to Java
2. Reference Types and Semantics
3. Method Specifications and Testing
4. Loop Invariants
5. Analyzing Complexity
6. Recursion
7. Sorting Algorithms
8. Classes and Encapsulation
9. Interfaces and Polymorphism
10. Inheritance
11. Additional Java Features
12. Collections and Generics
13. Linked Data
14. Iterating over Data Structures
15. Stacks and Queues
16. Trees and their Iterators
17. Binary Search Trees
18. Heaps and Priority Queues
19. Sets and Maps
20. Hashing
21. Graphs
22. Graph Traversals
23. Shortest Paths
24. Graphical User Interfaces
25. Event-Driven Programming
23. Shortest Paths

23. Shortest Paths

In our final lecture about graphs, we’ll turn our attention to the question of locating the shortest path between two vertices. Finding optimal paths is critical for transportation and navigation software (e.g., Google Maps). It also has uses in other optimization problems, such as edge detection for image segmentation, where we can recast the problem as finding the optimal dividing line between neighboring pixels. We’ll begin our discussion by revisiting the BFS algorithm. We can augment its traversal to track additional information that enables us to locate shortest paths in unweighted graphs. By expanding on these ideas, we can adapt this procedure to also work for weighted graphs, giving us the celebrated shortest path algorithm of Edsger Dijkstra.

Unweighted Graphs

In the previous lecture, we saw that a BFS discovers (and visits) a graph’s vertices in level order:

We can use this reasoning to describe an iterative process that builds up the levels of the graph one at a time.

previous

next

From this process, we can derive the following two facts about the relationship between the vertex levels and the edges in our graph.

1. For each edge \((v,w)\) in a graph, the level of \(w\) is at most one greater than the level of \(v\).

Suppose that \(v\) belongs to level \(\ell\) in the graph, and consider the point of our level-labeling process where we are considering the edges crossing out of level \(\ell\). If the edge \((v,w)\) is one of these crossing edges, then our process will place \(v\) in level \(\ell + 1\). The only way that \((v,w)\) is not one of these crossing edges is if it was already assigned to a level earlier in the process. In either case, the level of \(w\) is at most one greater than the level of \(v\).

Remark:

A common misconception is to assume that the level of \(w\) will always be exactly one more than the level of \(v\). Our example from above shows that this is not true. For example, the edge \((d,t)\) is between two vertices in level 3, and the edge \((d,b)\) crosses "back" from Level 3 to Level 1.

2. For each vertex \(w\) in Level \(\ell \geq 1\), there is some edge \((v,w)\) in the graph reaching \(w\) from a vertex \(v\) in Level \(\ell - 1\).

This follows immediately from the definition of our level-labeling process; the presence of a crossing edge from a vertex in Level \(\ell-1\) to \(w\) was what allowed us to include \(w\) in Level \(\ell\).

Remark:

Again, we should interpret this fact (in particular, its quantifiers, if you're familiar with this term from CS 2800) carefully. It guarantees the existence of at least one such incoming edge to \(w\) with this property. It does not guarantee that all incoming edges to \(w\) will have this property.

These two facts allow us to reach the following result connecting a vertex’s level and its shortest path from the source vertex \(s\).

If a vertex \(w\) belongs to level \(\ell\), then the shortest path from \(s\) to \(w\) contains exactly \(\ell\) edges.

We can use Fact 1 to show that a shorter path cannot exist. Each edge in the graph can move me up at most one level. To get from the source vertex (at Level 0) to vertex \(w\) (at Level \(\ell\)), we’ll need to traverse at least \(\ell\) edges (i.e., cross over \(\ell\) level boundaries).

Next, we can use Fact 2 to show that a path of length \(\ell\) must exist. Working backward from the end of the path, we know that there must be an edge from some vertex \(v\) in Level \(\ell-1\) to vertex \(w\). Similarly, there must be an edge from some vertex \(u\) in Level \(\ell-2\) to vertex \(v\). Repeating this reasoning \(\ell\) times (which can be formalized with a proof by induction), we will eventually conclude that there must be an edge from a vertex in Level 0 (which must be \(s\), the only vertex in Level 0), to a vertex in Level 1 that has a path to \(w\). Combining all these edges produces our desired \(s \rightsquigarrow w\) path with length \(\ell\).

Augmented BFS

The level-labeling procedure that we just described can be achieved with a minor modification to our BFS code from the previous lecture. We know that our BFS is guaranteed to discover (and visit) the vertices in level order, beginning with the source vertex at Level 0. In each outer-loop iteration, we iterate over the outgoing edges of some vertex \(v\) (where \(v\) was the vertex at the front of the frontier queue) and determine which connect to a yet-undiscovered vertex \(w\). Each of these edges \((v,w)\) is a “crossing” edge, adopting our terminology from the previous section.

If we kept track of the levels of all discovered vertices (including \(v\)), we would know how to label the level of \(w\); one more than the level of \(v\). We can perform this bookkeeping by replacing our discovered set with a map that associates each discovered vertex with its level. Then, we can return this map of levels at the end of the traversal.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * Uses a queue to carry out a BFS traversal of the vertices reachable from `source`
 * in its graph, building and returning a map that associates each discovered vertex
 * with its level.
 */
public static <V extends Vertex<E>,E extends Edge<V>> Map<String, Integer> bfsLevels(V source) {
  // Queue of discovered vertices that have not yet been visited
  Queue<V> frontier = new LinkedList<>();

  // Map associating each discovered vertex with its level
  Map<String, Integer> discovered = new LinkedHashMap<>();

  discovered.put(source.label(), 0);
  frontier.add(source);

  while(!frontier.isEmpty()) {
    V v = frontier.remove();
    for (E edge : v.outgoingEdges()) { // enqueue unvisited neighbors
      V neighbor = edge.head();
      if (!discovered.containsKey(neighbor.label())) {
        discovered.put(neighbor.label(), discovered.get(v.label()) + 1);
        frontier.add(neighbor);
      }
    }
  }
  return discovered;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * Uses a queue to carry out a BFS traversal of the vertices reachable from `source`
 * in its graph, building and returning a map that associates each discovered vertex
 * with its level.
 */
public static <V extends Vertex<E>,E extends Edge<V>> Map<String, Integer> bfsLevels(V source) {
  // Queue of discovered vertices that have not yet been visited
  Queue<V> frontier = new LinkedList<>();

  // Map associating each discovered vertex with its level
  Map<String, Integer> discovered = new LinkedHashMap<>();

  discovered.put(source.label(), 0);
  frontier.add(source);

  while(!frontier.isEmpty()) {
    V v = frontier.remove();
    for (E edge : v.outgoingEdges()) { // enqueue unvisited neighbors
      V neighbor = edge.head();
      if (!discovered.containsKey(neighbor.label())) {
        discovered.put(neighbor.label(), discovered.get(v.label()) + 1);
        frontier.add(neighbor);
      }
    }
  }
  return discovered;
}

In the lecture code, we construct the 6-vertex graph from above and run the bfsLevels() method to confirm that it computes the vertex levels correctly.

Reconstructing the Shortest Path

While our bfsLevels() method allows us to determine the length of the shortest path from the source vertex \(s\) to every vertex \(v\) in our graph, it does not tell us which edges comprise each of these paths. How can we further augment our BFS code to provide this information?

For this, we can take inspiration from our earlier observations. If \(w\) is a vertex in Level \(\ell\), then we know that the last edge in its shortest path will be some edge \((v,w)\) from a vertex \(v\) in Level \(\ell-1\). The other \(\ell-1\) edges in the shortest \(s \rightsquigarrow w\) path will connect \(s\) to \(v\), so they will form a shortest \(s \rightsquigarrow v\) path. Let’s record this observation since it will be very important going forward:

If \((v,w)\) is the last edge in the shortest \(s \rightsquigarrow w\) path in graph \(G\), then the remaining edges form a shortest \(s \rightsquigarrow v\) path in \(G\).

Using this observation, we see that all the vertices will be able to determine their shortest paths from \(s\) as long as they keep track of the last edge of this path. If \(w\) knows that its last shortest path edge is \((v,w)\), then it can ask \(v\) about the second-to-last edge (which will be the last edge of \(v\)’s shortest path), continuing this process until it retraces the path back to \(s\).

Let’s augment our BFS to keep track of these final edges. Rather than storing the edge directly, we’ll store a reference to the tail vertex of the edge, which we’ll denote by prev (the “prev"ious vertex in the shortest path). If vertex \(w\) belongs at Level \(\ell\) of the graph, then this prev vertex can be any vertex with an edge to \(w\) that sits in Level \(\ell-1\). In particular, we can choose the vertex \(v\) whose outgoing edge “discovered” vertex \(w\) during the BFS.

We’ll modify our discovered map to store both a vertex’s level and its prev vertex, which we can package up in a record class, PathInfo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

/**
 * Auxiliary vertex properties relevant to unweighted shortest paths, the `level` 
 * of this vertex in the BFS from the given `source` vertex, and the label of the 
 * `prev` vertex that had the outgoing edge that discovered this vertex.
 */
public record PathInfo(int level, String prev) {
  @Override
  public String toString() {
    return "{level = " + level + ", prev = " + prev + "}";
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

/**
 * Auxiliary vertex properties relevant to unweighted shortest paths, the `level` 
 * of this vertex in the BFS from the given `source` vertex, and the label of the 
 * `prev` vertex that had the outgoing edge that discovered this vertex.
 */
public record PathInfo(int level, String prev) {
  @Override
  public String toString() {
    return "{level = " + level + ", prev = " + prev + "}";
  }
}

We can use this PathInfo class to complete the definition of our augmented BFS procedure, bfsPaths().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
  * Uses a queue to carry out a BFS traversal of the vertices reachable from `source`
  * in its graph, building and returning a map that associates each discovered vertex 
  * with its level in the search and the `prev` vertex that enabled its discovery.
  */
public static <V extends Vertex<E>,E extends Edge<V>> Map<String, PathInfo> bfsPaths(V source) {
  // Queue of discovered vertices that have not yet been visited
  Queue<V> frontier = new LinkedList<>();

  // Map associating PathInfo to all discovered vertices
  Map<String, PathInfo> discovered = new LinkedHashMap<>();

  discovered.put(source.label(), new PathInfo(0, null)); // s does not have a "prev" vertex
  frontier.add(source);

  while(!frontier.isEmpty()) {
    V v = frontier.remove();

    for (E edge : v.outgoingEdges()) { // enqueue unvisited neighbors
      V neighbor = edge.head();
      if (!discovered.containsKey(neighbor.label())) {
        int level = discovered.get(v.label()).level + 1;
        discovered.put(neighbor.label(), new PathInfo(level, v.label()));
        frontier.add(neighbor);
      }
    }
  }
  return discovered;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
  * Uses a queue to carry out a BFS traversal of the vertices reachable from `source`
  * in its graph, building and returning a map that associates each discovered vertex 
  * with its level in the search and the `prev` vertex that enabled its discovery.
  */
public static <V extends Vertex<E>,E extends Edge<V>> Map<String, PathInfo> bfsPaths(V source) {
  // Queue of discovered vertices that have not yet been visited
  Queue<V> frontier = new LinkedList<>();

  // Map associating PathInfo to all discovered vertices
  Map<String, PathInfo> discovered = new LinkedHashMap<>();

  discovered.put(source.label(), new PathInfo(0, null)); // s does not have a "prev" vertex
  frontier.add(source);

  while(!frontier.isEmpty()) {
    V v = frontier.remove();

    for (E edge : v.outgoingEdges()) { // enqueue unvisited neighbors
      V neighbor = edge.head();
      if (!discovered.containsKey(neighbor.label())) {
        int level = discovered.get(v.label()).level + 1;
        discovered.put(neighbor.label(), new PathInfo(level, v.label()));
        frontier.add(neighbor);
      }
    }
  }
  return discovered;
}

When we run bfsPaths() on our example 6-vertex graph and print the resulting map (our full client method definition is provided with the lecture release code), we obtain:

s: {level = 0, prev = null}
a: {level = 1, prev = s}
b: {level = 1, prev = s}
c: {level = 2, prev = a}
d: {level = 3, prev = c}
t: {level = 3, prev = c}

Take some time to complete the definition of the following method that uses the Map returned by bfsPaths() to reconstruct the shortest path (modeled as a list of vertices beginning with the source vertex and ending with the destination vertex) to a given vertex.

1
2
3
4
5
6
/**
 * Reconstructs and returns the shortest path from the vertex with label `srcLabel`
 * to the vertex with label `dstLabel` using the given `info` map produced by BFS.
 */
public static List<String> reconstructPath(Map<String,PathInfo> info,
    String srcLabel, String dstLabel) { ... }
1
2
3
4
5
6
/**
 * Reconstructs and returns the shortest path from the vertex with label `srcLabel`
 * to the vertex with label `dstLabel` using the given `info` map produced by BFS.
 */
public static List<String> reconstructPath(Map<String,PathInfo> info,
    String srcLabel, String dstLabel) { ... }

Compare your implementation with ours below.

reconstructPath() definition

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/**
 * Reconstructs and returns the shortest path from the vertex with label `srcLabel`
 * to the vertex with label `dstLabel` using the given `info` map produced by BFS.
 */
public static List<String> reconstructPath(Map<String,PathInfo> info,
    String srcLabel, String dstLabel) {
  List<String> path = new LinkedList<>();
  path.add(dstLabel);
  while (!path.getFirst().equals(srcLabel)) {
    path.addFirst(info.get(path.getFirst()).prev());
  }
  return path;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/**
 * Reconstructs and returns the shortest path from the vertex with label `srcLabel`
 * to the vertex with label `dstLabel` using the given `info` map produced by BFS.
 */
public static List<String> reconstructPath(Map<String,PathInfo> info,
    String srcLabel, String dstLabel) {
  List<String> path = new LinkedList<>();
  path.add(dstLabel);
  while (!path.getFirst().equals(srcLabel)) {
    path.addFirst(info.get(path.getFirst()).prev());
  }
  return path;
}

Toward Dijkstra’s Algorithm

We’ve completed our implementation of a shortest path algorithm for unweighted graphs. Before we move on to weighted graphs, let’s stop to reflect on some of the big ideas that we’ll need for this approach. First, we’ll recall some of the terminology that we used to describe the state of a vertex at different points in a graph traversal.

We can thus visualize the state of a vertex over the course of the traversal as follows:

The shading of these time segments corresponds to how we visualize the vertices in our animations. Now, let’s imagine that we take a snapshot of our graph at some point during the traversal and consider what states its vertices are in. Since the traversal begins at the source vertex and radiates outward, the settled vertices will be closest to the source. The undiscovered vertices will be furthest from the source. Finally, and most crucially for Dijkstra’s algorithm, the frontier vertices will form a thin layer (1-vertex wide) between the settled vertices and the undiscovered vertices.

Using these pictures, we make the following observations about the properties (or invariants) maintained by BFS.

1. At any point in the algorithm, our discovered map records the shortest (known) path distance to every node that we’ve discovered.

In the case of unweighted graphs, this distance can never decrease since it is set the first time that a vertex is the head of a crossing edge, and the tails of these crossing edges are considered in increasing level order.

2. The next vertex to be removed from the frontier queue is always the unvisited vertex with the lowest level.

In other words, each outer-loop iteration visits (and settles) the closest remaining vertex to the source.

3. As soon as a vertex is visited/settled, we are guaranteed that we have located the shortest path to it from the source vertex, and this path contains only vertices that were previously settled.

This follows from the fact that BFS settles vertices in level order, and a shortest path connects vertices in increasing order of level.

Analogues of these invariants will form the basis of our design of Dijkstra's algorithm to compute shortest paths in weighted graphs.

Weighted Graphs

The setting for Dijkstra’s algorithm is a directed graph with a designated source vertex \(s\) in which each edge \((u,v)\) is labeled with a non-negative value \(w(u,v)\) that we’ll call its weight. The goal of the algorithm is to identify the shortest path (i.e., the path whose edges have the lowest possible weight sum) from \(s\) to each possible destination vertex \(t\).

Remark:

Here, the assumption that all the edge weights are non-negative will be critical, as it provides us with the guarantee that adding additional edges to the path can never decrease its total weight. When negative edges are allowed, a different dynamic programming procedure called the Bellman-Ford algorithm can be used to locate shortest paths. You'll learn about this in CS 4820.

Let’s try to mirror the ideas from BFS in an edge-weighted graph.

  1. We’ll again use a map to keep track of information about all the vertices that we’ve discovered. At any point in the algorithm’s execution, the map will record the length (i.e., edge weight sum) of the shortest known path from \(s\) to each discovered vertex \(v\), which we’ll denote by \(d(s,v)\).

  2. We’ll again group the discovered vertices into two classifications: the frontier vertices (that are queued up to be visited) and the settled vertices (that have already been visited). In each iteration of the algorithm’s main loop, we will visit one vertex in the frontier, allowing us to mark it as settled and make progress toward termination (which takes place once the frontier is empty).

  3. We would like it to be the case that as soon as a vertex \(v\) becomes settled, we are guaranteed to have located the shortest \(s \rightsquigarrow v\) path.

Understanding how to achieve the third property is the key insight for the development of Dijkstra’s algorithm.

Dijkstra’s Invariant

To formalize the key invariant of Dijkstra’s algorithm, let’s return to this picture visualizing the frontier of our graph traversal.

In this picture, we have already settled all the vertices in the dark red shaded area. Trusting property 3 from the previous section, we have identified the shortest path to each of these vertices from \(s\); that is, we know the true shortest-path distance from \(s\) to each settled vertex \(u\), which we’ll denote \(d^*(s,u)\).

Now, any path from \(s\) to any unsettled vertex \(w\) in the graph will need to include at least one frontier vertex \(v\) (which may be the same vertex as \(w\) or some other intermediary vertex along the path). Take a minute to think about why this is true.

Why must the path include a frontier vertex?

Since \(w\) is not in the settled set, our \(s \rightsquigarrow w\) path starts in the settled set and ends outside of it. This means that at some point, it must follow an edge \((u,v)\) that crosses from inside the settled set to outside of it. Since \(u\) is settled, we have already visited it, meaning all of its neighbors (including \(v\)) have been discovered. Since \(v\) is discovered but not yet settled, it is part of the frontier.

Now, let’s consider the shortest known distances to each of the frontier vertices in the discovered map. Among all of these, we’ll let \(v^*\) be the vertex with the minimum shortest known distance \(d(s,v^*)\). Dijkstra’s invariant tells us that this distance \(d(s,v^*)\) must be the shortest path distance to \(v^*\), \(d^*(s,v^*)\).

Theorem: Dijkstra's Invariant

If \(v^*\) is the vertex in the frontier set with the minimum shortest known distance from \(s\), \(d(s,v^*)\), then the shortest known \(s \rightsquigarrow v^*\) path is a shortest \(s \rightsquigarrow v^*\) path in the graph, and \[ d^*(s,v^*) = d(s,v^*). \]

Dijkstra’s invariant tells us that we can safely settle the vertex \(v^*\) and satisfy property 3 from above, which will allow us to make progress in each iteration of our traversal algorithm. Before we finish developing the algorithm, let’s understand why the invariant holds.

We want to argue that our shortest known \(s \rightsquigarrow v^*\) path, with length \(d(s,v^*)\) is the truly shortest \(s \rightsquigarrow v^*\) path. To do this, let’s consider any alternate \(s \rightsquigarrow v^*\) path. From our earlier observation, this alternate path must pass through at least one frontier vertex. Thus, we’ll let \(v'\) denote the first frontier vertex in this alternate path. We can split our alternate \(s \rightsquigarrow v^*\) path into the portion from \(s \rightsquigarrow v'\) and the portion from \(v' \rightsquigarrow v^*\). Since all edges have non-negative weights, the length of the path portion from \(v' \rightsquigarrow v^*\) must be non-negative. Thus, the length of our alternate path is at least the length of its \(s \rightsquigarrow v'\) path portion.

Since \(v'\) was the first frontier vertex on our alternate path, all vertices besides \(v'\) in this \(s \rightsquigarrow v'\) path portion are settled (i.e., this path was “known” at this point in the algorithm). Thus, this \(s \rightsquigarrow v'\) path has length at least \(d(s,v')\). Finally, our choice of \(v^*\) tells us that \(d(s,v') \geq d(s,v^*)\), so our alternate path has length at least \(d(s,v^*)\). To conclude, we note that since an arbitrary \( s \rightsquigarrow v^*\) path has length at least \(d(s,v^*)\), the length of the shortest path \( s \rightsquigarrow v^*\) must be \(d^*(s,v^*) = d(s,v^*)\).

Coding Dijkstra’s Algorithm

Our code for Dijkstra’s algorithm will largely follow the same structure as the bfsPaths() method. We will maintain a collection of vertices in the frontier and visit one frontier vertex in each iteration. When we visit a frontier vertex, we’ll explore its outgoing edges to potentially discover new vertices, adding them to the frontier. We’ll maintain a discovered map to track information during our traversal, which will allow us to reconstruct the shortest paths at the end. Now, let’s identify how our traversal must be modified to account for edge weights.

Tracking Distances

Rather than storing the traversal level of discovered vertices in our map, we’ll store the distance of the shortest known path to that vertex from \(s\). We’ll update our PathInfo record class accordingly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
 * Auxiliary vertex properties relevant to unweighted shortest paths, 
 * the best known `distance` of this vertex from the source `s` (i.e., 
 * the length of its shortest known `s -> v` path), and the label of the 
 * `prev` vertex on this shortest known `s -> v` path.
 */
public record PathInfo(double distance, String prev) {
  @Override
  public String toString() {
    return "{distance = " + distance + ", prev = " + prev + "}";
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
 * Auxiliary vertex properties relevant to unweighted shortest paths, 
 * the best known `distance` of this vertex from the source `s` (i.e., 
 * the length of its shortest known `s -> v` path), and the label of the 
 * `prev` vertex on this shortest known `s -> v` path.
 */
public record PathInfo(double distance, String prev) {
  @Override
  public String toString() {
    return "{distance = " + distance + ", prev = " + prev + "}";
  }
}

In the notation from the previous section, our PathInfo object associated with a vertex \(v\) stores the quantity \(d(s,v)\), along with the pointer to the previous path vertex. When we discover a new path to a vertex \(v'\) during our visit of vertex \(v\), this offers a new candidate for the shortest \(s \rightsquigarrow v'\) path. Namely, we can follow the shortest known path from \(s \rightsquigarrow v\), which we now know is the shortest \(s \rightsquigarrow v\) path by Dijkstra’s invariant, and then follow the \((v,v')\) edge. The length of this path is \(d(s,v) + w(v,v')\), which we can easily compute since \(d(s,v)\) is stored in our discovered map and \(w(v,v')\) can be queried from the graph.

If this is the first path we’ve found to \(v'\), we’ll add \(v'\) to the frontier and the discovered map. Otherwise, it’s possible that this path may be shorter than the previous best-known path, in which case we should update the discovered map. Otherwise, if this path is the same length or longer than the previous best-known path, we don’t need to make any updates.

Modeling the Frontier

For BFS, we modeled the frontier as a Queue. Our analysis of BFS guaranteed us that the elements were removed from the Queue in level-order, so the dequeued() element was always the closest currently enqueued element to the source. In the case of a weighted graph, it is true that vertices are initially added to the frontier in increasing distance order. However, the possibility of updating distances when a shorter candidate path is discovered means that this guarantee would not extend to the removal order from a Queue. Instead, we’d like another data structure that can guarantee removals in increasing distance order and will allow efficient insertions and updates of its elements. The DynamicPriorityQueue from a few lectures ago provides exactly these guarantees.

We can put these pieces together to complete the definition of Dijkstra’s algorithm. Note that WeightedEdge is a subtype of the Edge interface that introduces a method to access an edge’s weight().

1
2
3
4
5
/** Models an edge labeled with a weight of type `W` in a directed graph. */
public interface WeightedEdge<V> extends Edge<V> {
  /** Return the weight of this edge. */
  double weight();
}
1
2
3
4
5
/** Models an edge labeled with a weight of type `W` in a directed graph. */
public interface WeightedEdge<V> extends Edge<V> {
  /** Return the weight of this edge. */
  double weight();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
 /**
 * An implementation of Dijkstra's shortest path algorithm for a directed graph 
 * with non-negative edge weights. Returns a map associating each vertex `v` in 
 * this graph reachable from the given `source` vertex with a `PathInfo` object 
 * containing the shortest `source -> v` path `distance` and the `prev` vertex 
 * in this shortest path.
 */
public static <V extends Vertex<? extends WeightedEdge<V>>> Map<String, PathInfo> 
  dijkstra(V source) {
  // Queue of discovered vertices that have not yet been visited
  DynamicPriorityQueue<V> frontier = _ ; // initialize this with a DynamicPriorityQueue class constructor

  // Map associating PathInfo to all discovered vertices
  Map<String, PathInfo> discovered = new LinkedHashMap<>();

  discovered.put(source.label(), new PathInfo(0, null)); // s does not have a "prev" vertex
  frontier.add(source, 0); // s has distance 0 from itself

  while(!frontier.isEmpty()) {
    V v = frontier.remove(); // v is frontier element that is closest to s
    for (WeightedEdge<V> edge : v.outgoingEdges()) { // iterate over v's neighbors
      V neighbor = edge.head();
      double dist = discovered.get(v.label()).distance() + edge.weight(); // neighbor's shortest-path distance via v
      if (!discovered.containsKey(neighbor.label())) { // neighbor is first discovered
        discovered.put(neighbor.label(), new PathInfo(dist, v.label())); // add to discovered map
        frontier.add(neighbor, dist); // add to frontier priority queue
      } else if (discovered.get(neighbor.label()).distance() > dist)  { // we found a shorter path to neighbor
        discovered.put(neighbor.label(), new PathInfo(dist, v.label())); // update discovered map
        frontier.updatePriority(neighbor, dist); // update priority to reflect this new distance
      }
    }
  }
  return discovered;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
 /**
 * An implementation of Dijkstra's shortest path algorithm for a directed graph 
 * with non-negative edge weights. Returns a map associating each vertex `v` in 
 * this graph reachable from the given `source` vertex with a `PathInfo` object 
 * containing the shortest `source -> v` path `distance` and the `prev` vertex 
 * in this shortest path.
 */
public static <V extends Vertex<? extends WeightedEdge<V>>> Map<String, PathInfo> 
  dijkstra(V source) {
  // Queue of discovered vertices that have not yet been visited
  DynamicPriorityQueue<V> frontier = _ ; // initialize this with a DynamicPriorityQueue class constructor

  // Map associating PathInfo to all discovered vertices
  Map<String, PathInfo> discovered = new LinkedHashMap<>();

  discovered.put(source.label(), new PathInfo(0, null)); // s does not have a "prev" vertex
  frontier.add(source, 0); // s has distance 0 from itself

  while(!frontier.isEmpty()) {
    V v = frontier.remove(); // v is frontier element that is closest to s
    for (WeightedEdge<V> edge : v.outgoingEdges()) { // iterate over v's neighbors
      V neighbor = edge.head();
      double dist = discovered.get(v.label()).distance() + edge.weight(); // neighbor's shortest-path distance via v
      if (!discovered.containsKey(neighbor.label())) { // neighbor is first discovered
        discovered.put(neighbor.label(), new PathInfo(dist, v.label())); // add to discovered map
        frontier.add(neighbor, dist); // add to frontier priority queue
      } else if (discovered.get(neighbor.label()).distance() > dist)  { // we found a shorter path to neighbor
        discovered.put(neighbor.label(), new PathInfo(dist, v.label())); // update discovered map
        frontier.updatePriority(neighbor, dist); // update priority to reflect this new distance
      }
    }
  }
  return discovered;
}

Here, we omit the particular DynamicPriorityQueue implementation from the method definition. The lecture release code does a “hacky” patch to Java’s provided PriorityQueue class to support priority updates (note that Java does not include a standard DynamicPriorityQueue implementation in the language library). We encourage you to replace this with DynamicPriorityQueue described in Lecture 19, which utilizes a heap and a map.

Example Dijkstra’s Algorithm Execution

Step through the following animation that visualizes an execution of Dijkstra’s algorithm.

previous

next

Complexity Analysis

Finally, let’s analyze the complexity of Dijkstra’s algorithm.

Space Complexity

The method constructs two data structures as local variables, the discovered map and the frontier priority queue. The discovered map includes one entry per vertex. Each entry stores a distance and a reference to the prev vertex, which each use \(O(1)\) space. Thus, the overall size of the discovered map is \(O(|V|)\).

The frontier priority queue stores a vertex reference and a double priority value in each entry, and requires \(O(1)\) space per entry. At worst, the frontier can contain all the vertices (technically, all but the source vertex if the source vertex had an outgoing edge to each other vertex). Thus, its overall size is also \(O(|V|)\).

The remaining local variables each occupy \(O(1)\) space. Assuming that our priority queue and map implement their operations iteratively, the stack space required will be \(O(1)\); even recursive implementations would never need to exceed an \(O(|V|)\) depth, so would not dominate the space complexity.

Overall, we find that Dijkstra’s algorithm has an \(O(|V|)\) space complexity.

Time Complexity

Next, let’s analyze the time complexity. To do this, we’ll focus on the main loop, which will dominate the runtime.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
 while(!frontier.isEmpty()) {
  V v = frontier.remove();
  for (WeightedEdge<V> edge : v.outgoingEdges()) { 
    V neighbor = edge.head();
    double dist = discovered.get(v.label()).distance() + edge.weight(); 
    if (!discovered.containsKey(neighbor.label())) { 
      discovered.put(neighbor.label(), new PathInfo(dist, v.label())); 
      frontier.add(neighbor, dist); 
    } else if (discovered.get(neighbor.label()).distance > dist)  { 
      discovered.put(neighbor.label(), new PathInfo(dist, v.label())); 
      frontier.updatePriority(neighbor, dist);
    }
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
 while(!frontier.isEmpty()) {
  V v = frontier.remove();
  for (WeightedEdge<V> edge : v.outgoingEdges()) { 
    V neighbor = edge.head();
    double dist = discovered.get(v.label()).distance() + edge.weight(); 
    if (!discovered.containsKey(neighbor.label())) { 
      discovered.put(neighbor.label(), new PathInfo(dist, v.label())); 
      frontier.add(neighbor, dist); 
    } else if (discovered.get(neighbor.label()).distance > dist)  { 
      discovered.put(neighbor.label(), new PathInfo(dist, v.label())); 
      frontier.updatePriority(neighbor, dist);
    }
  }
}

Let’s analyze the runtime line by line.

In total, lines 8 and 11 dominate the runtime. We find that our implementation of Dijkstra’s algorithm has an expected worst-case runtime of \(O(|E| \log |V|)\).

Remark:

By using an alternate data structure for the DynamicPriorityQueue called a Fibonacci heap, one can reduce the runtime of Dijkstra's algorithm to \(O(|E| + |V| \log |V|)\).

Main Takeaways:

  • Breadth-first search is guaranteed to visit the vertices in increasing distance order from the source vertex.
  • We can augment a traversal by using a map to keep track of extra information about the vertices as we visit them. By tracking the incoming edges that discovered the vertices, we can reconstruct paths to the vertices from the traversal source.
  • Dijkstra's invariant tells us an order in which we can settle the vertices with a guarantee that we have located the shortest path to them.
  • We use a DynamicPriorityQueue to manage the frontier in Dijkstra's algorithm. Vertices' best-known distances may decrease as we locate new incoming paths while settling other vertices.

Exercises

Exercise 23.1: Check Your Understanding
(a)
Which of the following is a valid stopping condition for all graph traversal algorithms that we have learned (BFS, DFS, or Dijkstra’s algorithm)?
Check Answer
(b)

Using Dijkstra’s algorithm to find the shortest paths from vertex “A” to all reachable vertices, we obtain the following table of auxiliary vertex data.

vertex A B C D E F G H I
distance 0 1 5 2 7 6 11 15 12
prev null A A B C D D E G

State the shortest path that was found to "I".

A \(\to\) B \(\to\) D \(\to\) G \(\to\) I

(c)

Suppose we are running Dijkstra’s algorithm to find the shortest distance between Melbourne and various other cities. At some point during the search, the distances and back-pointers in the frontier set look like this:

CityDistanceBackpointer
Perth3500kmAdelaide
Brisbane1800kmCanberra
Sydney900kmCanberra
Which of the following statements are true, based only on the above information?
Check Answer
(d)

Consider a directed graph in which vertices represent bus stops, edges represent portions of bus routes, and edge weights correspond to average travel time between stops. Suppose we use Dijkstra’s algorithm to find the fastest way to get from the stop at Helen Newman to all other stops (assuming no wait time between buses). As we run the algorithm, we record details about all the stops discovered so far in a table. At some point during the execution of the algorithm, our table looks like this:

Bus StopFastest known time from Helen NewmanPrevious stopSettled?
Rockefeller2 minHelen NewmanYes
Uris Hall8 minRockefellerYes
Baker Flagpole7 minHelen NewmanYes
Dairy Bar10 minUris HallYes
Collegetown11 minBaker FlagpoleNo
Vet School20 minDairy BarNo
Commons15 minUris HallNo
Let $t_C$ be the shortest time it takes to get from Helen Newman to the Commons. Based on what we know so far, what is the most we can say about $t_C$?
Check Answer
(e)
Which of the following edge(s) between two currently discovered bus stops could have a weight that would introduce a shorter traveling time from Helen Newman to the Vet School than the current known one? This edge must be consistent with the currently known information.
Check Answer
Exercise 23.2: Tracing Dijkstra’s Algorithm
Consider executing Dijkstra's algorithm on the following graph, starting from vertex \(a\).

Trace the algorithm until the conclusion of the loop iteration in which vertex \(d\) is settled, so that the invariant has been restored, recording its state in the table below. If properties change over the course of running the algorithm, you should neatly cross out their old values.
vertex \(a\) \(b\) \(c\) \(d\) \(e\)
distance 0
prev null
discovered?
settled?
Exercise 23.3:
Consider the table in 23.1(b).
(a)
Draw the edges in this PathInfo map with their associated weights.
(b)
Notice that this structure forms a tree. In fact, all PathInfos will form such a tree. What properties of a tree ensure this?
(c)
Consider any pair of vertices, \(u, v\), in this tree. Argue that the path between \(u\) to \(v\) in the tree is also the shortest such path in the graph. Consider what Dijkstra’s algorithm would have done if there was supposedly a shorter path from \(u\) to some vertex on the path from \(u\) to \(v\).
Exercise 23.4: Improving Dijkstra’s Algorithm Complexity
To achieve a runtime of \(O(|E|\log|V|)\), the lecture notes assumed that we had a DynamicPriorityQueue implementation backed by a map and a heap.
(a)
Use Exercise 19.10 as a guide to implement this MinHeapDynamicPriorityQueue. Note that here we desire a min heap rather than a max heap.
(b)
A Fibonacci heap implementation of a dynamic priority queue provides amortized \(O(1)\) insertions and updates. How does this reduce the expected runtime to \(O(|E| + |V|\log|V|)\)?
Exercise 23.5: Negative Edges
We've stated earlier that the correctness of Dijkstra's algorithm requires the assumption of non-negative edge weights.
(a)
Construct a counterexample where Dijkstra’s invariant fails when some edge weights are negative.
(b)
Suppose we have a graph with negative edge weights. Let \(m\) be the smallest (i.e., most negative) edge weight. We construct a new graph such that each edge weight is increased by \(m\) and run Dijkstra’s algorithm on the new graph. Note that this new graph has non-negative edge weights. Does this return a valid map of PathInfos?
Exercise 23.6: Dynamic Shortest Paths Updates
Suppose that you have run Dijkstra's algorithm on a graph and know the shortest distance from the source, \(s\), to every other vertex in the graph. For each of the following graph modifications, can we efficiently, i.e. without rerunning Dijkstra's algorithm, determine which vertices have a new shortest path from \(s\)?
(a)
A new edge is added to the graph.
(b)
An edge is removed from the graph.
(c)
The weight of each edge in the graph is increased by a constant amount \(c > 0\).
(d)
The weight of each edge in the graph is multiplied by a constant factor \(k > 1\).
Exercise 23.7: Early Stopping Algorithm
Recall that Dijkstra's algorithm allows us to locate the shortest paths from \(s\) to all vertices (reachable along paths from \(s\)). Suppose we only care about the shortest path to some particular destination vertex \(t\).
(a)
Can we stop the algorithm early once we discover \(t\)?
(b)
Can we stop the algorithm early once we settle \(t\)?
Exercise 23.8: Shortest Paths on Modified Edge Weights
For each of the following, our graphs have some restrictions. Determine a shortest path algorithm that runs strictly faster than Dijkstra's algorithm. State the time complexity of this algorithm.
(a)
All edges have weight \(k\), with \(k > 1\).
(b)
All edges have weights of 1 or 2.