13.10 Specialised algorithms on weighted graphs

In this section we present two specialised algorithms on weighted directed graphs:

Floyd’s algorithm
This algorithm finds the shortest paths between all pairs of vertices in a graph.
Topological sort
This algorithm sorts the vertices of a directed acyclic graph (DAG), so that no edges point “backwards”. This is crucial to solve various scheduling problems.

13.10.1 All-pairs shortest paths: Floyd’s algorithm

We next consider the problem of finding the shortest distance between all pairs of vertices in the graph, called the all-pairs shortest paths problem. To be precise, for every v,w𝐕v, w \in \mathbf{V}, calculate d(v,w)d(v, w).

One solution is to run Dijkstra’s algorithm |𝐕||\mathbf{V}| times, each time computing the shortest path from a different start vertex. If 𝐆\mathbf{G} is sparse (that is, |𝐄|O(|𝐕|)|\mathbf{E}| \in O(|\mathbf{V}|)) then this is a good solution, because the total cost will be O(|𝐕|2+|𝐕||𝐄|log|𝐕|)=O(|𝐕|2log|𝐕|)O(|\mathbf{V}|^2 + |\mathbf{V}||\mathbf{E}| \log |\mathbf{V}|) = O(|\mathbf{V}|^2 \log |\mathbf{V}|) for the version of Dijkstra’s algorithm based on priority queues. For a dense graph, the priority queue version of Dijkstra’s algorithm yields a cost of O(|𝐕|3log|𝐕|)O(|\mathbf{V}|^3 \log |\mathbf{V}|), but the version using minVertex yields a cost of O(|𝐕|3)O(|\mathbf{V}|^3).

Another solution that limits processing time to O(|𝐕|3)O(|\mathbf{V}|^3) regardless of the number of edges is known as Floyd’s algorithm. It is an example of dynamic programming. The chief problem with solving this problem is organising the search process so that we do not repeatedly solve the same subproblems. We will do this organisation through the use of the kk-path. Define a k-path from vertex vv to vertex ww to be any path whose intermediate vertices (aside from vv and ww) all have indices less than kk. A 0-path is defined to be a direct edge from vv to ww. Figure 13.13 illustrates the concept of kk-paths.

Figure 13.13: An example of k-paths in Floyd’s algorithm. Path 1, 3 is a 0-path by definition. Path 3, 0, 2 is not a 0-path, but it is a 1-path (as well as a 2-path, a 3-path, and a 4-path) because the largest intermediate vertex is 0. Path 1, 3, 2 is a 4-path, but not a 3-path because the intermediate vertex is 3. All paths in this graph are 4-paths.

Define Dk(v,w)\mathrm{D}_k(v, w) to be the length of the shortest kk-path from vertex vv to vertex ww. Assume that we already know the shortest kk-path from vv to ww. The shortest (k+1)(k+1)-path either goes through vertex kk or it does not. If it does go through kk, then the best path is the best kk-path from vv to kk followed by the best kk-path from kk to ww. Otherwise, we should keep the best kk-path seen before. Floyd’s algorithm simply checks all of the possibilities in a triple loop. Here is the implementation for Floyd’s algorithm. At the end of the algorithm, array D stores the all-pairs shortest distances.

function floyd(graph):
    distances = new Map() of vertex pairs to distances
    for each v in graph.vertices():
        for each w in graph.vertices():
            distances.put((v, w), ∞)
        distances.put((v, v), 0)
        for each e in G.outgoingEdges(v):
            distances.put((v, e.end), e.weight)

    // Compute all k-paths
    for each k in graph.vertices():
        for each v in graph.vertices():
            for each w in graph.vertices():
                dist = distances.get(v, k) + distances.get(k, w)
                if dist < distances.get(v, w):
                    distances.put((v, w), dist)
    return distances

Clearly this algorithm requires O(|𝐕|3)O(|\mathbf{V}|^3) running time, and it is the best choice for dense graphs because it is (relatively) fast and easy to implement.

13.10.2 Acyclic graphs: Topological sort

Assume that we need to schedule a series of tasks, such as classes or construction jobs, where we cannot start one task until after its prerequisites are completed. We wish to organise the tasks into a linear order that allows us to complete them one at a time without violating any prerequisites. We can model the problem using a DAG. The graph is directed because one task is a prerequisite of another – the vertices have a directed relationship. It is acyclic because a cycle would indicate a conflicting series of prerequisites that could not be completed without violating at least one prerequisite. The process of laying out the vertices of a DAG in a linear order to meet the prerequisite rules is called a topological sort.

Figure 13.14 illustrates the problem. An acceptable topological sort for this example is J1, J2, J3, J4, J5, J6, J7. However, other orders are also acceptable, such as J1, J3, J2, J6, J4, J5, J7.

Figure 13.14: An example graph for topological sort. Seven tasks have dependencies as shown by the directed graph

Depth-first algorithm

A topological sort may be found by performing a depth-first search (DFS) on the graph. When a vertex is visited, no action is taken (i.e., function preVisit from Section 13.4.1 does nothing). When the recursion pops back to that vertex, function postVisit adds the vertex to a stack. In the end, the stack is returned to the caller.

The reason that we use a stack is that this algorithm produces the vertices in reverse topological order. And if we pop the elements in the stack, they will be popped in topological order.

So the DFS algorithm yields a topological sort in reverse order. It does not matter where the sort starts, as long as all vertices are visited in the end. Here is implementation for the DFS-based algorithm.

function topsortDFS(graph):
    visited = new Set()
    sortedVertices = new Stack()
    for each v in graph.vertices():
        if v not in visited:
            topsortHelperDFS(graph, v, sortedVertices, visited)
    return sortedVertices

function topsortHelperDFS(graph, v, sortedVertices, visited):
    if v not in visited:
        visited.add(v)
        for each e in graph.outgoingEdges(v):
            topsortHelperDFS(graph, e.end, sortedVertices, visited)
        sortedVertices.push(v)  // postVisit

If we use this algorithm starting at vertex J1 in Figure 13.14 and visit adjacent neighbours in alphabetic order, the vertices of the graph are pushed to the stack in the order J7, J5, J4, J6, J2, J3, J1. If we now pop them one by one we get the topological sort J1, J3, J2, J6, J4, J5, J7.

Here is another example.

Queue-based algorithm

We can also implement topological sort using a queue instead of recursion, as follows.

First visit all edges, counting the number of edges that lead to each vertex (i.e., count the number of prerequisites for each vertex). All vertices with no prerequisites are placed on the queue. We then begin processing the queue. When vertex vv is taken off of the queue, it is added to a list containing the topological order, and all neighbours of vv (that is, all vertices that have vv as a prerequisite) have their counts decremented by one. Place on the queue any neighbour whose count becomes zero. If the queue becomes empty without having added all vertices to the final list, then the graph contains a cycle (i.e., there is no possible ordering for the tasks that does not violate some prerequisite). The order in which the vertices are added to the final list is the correct one, so if traverse the final list we will get the elements in topological order.

Applying the queue version of topological sort to the graph of Figure 13.14 produces J1, J2, J3, J6, J4, J5, J7. Here is an implementation of the algorithm.

function topsortBFS(graph):
    // Initialise the prerequisite counts
    counts = new Map() of vertices to prerequisite count
    for each v in graph.vertices():
        counts.put(v, 0)
    for each v in graph.vertices():
        for each edge in graph.outgoingEdges(v):
            // Increase v's prerequisite count
            newCount = counts.get(edge.end) + 1
            counts.put(edge.end, newCount)

    // Initialise the queue
    queue = new Queue()
    for each v in graph.vertices():
        // Only add vertices that have no prerequisites
        if counts.get(v) == 0:
            queue.enqueue(v)

    // Process the vertices
    sortedVertices = new Queue()
    while not queue.isEmpty():
        v = queue.dequeue()
        sortedVertices.enqueue(v)  // preVisit
        for each e in graph.outgoingEdges(v):
            // Decrease v's prerequisite count
            newCount = counts.get(edge.end) - 1
            counts.put(edge.end, newCount)
            if newCount == 0:
                queue.enqueue(e.end)
    return sortedVertices

Here is an illustration of the queue-based algorithm.