Home / Algorithms

Intro to Graph Algorithms

Introduction

A graph is an abstraction representing some objects and connections between pairs of objects. In its simplest form, a graph has just two pieces:

More generally, a graph contains information about what pairs of objects are related to each other in some way.

Let us first define some terminology that will make talking about graphs less cumbersome. If GG has an edge e=(u,v)e = (u, v), we say uu and vv are adjacent. We also say uu is a neighbor of vv and vv is a neighbor of uu. The edge ee is also referred to as being incident on uu (and vv, as well).

The degree of a node vv (denoted deg(v)\text{deg}(v)) is the number of other nodes adjacent to it, or in other words, the number of neighbors it has. Equivalently, deg(v)\text{deg}(v) is the number of edges incident on vv.

We define a path as a sequence of nodes such that any two adjacent nodes in the sequence are adjacent in GG. Two nodes uu and vv are then reachable from each other if there is a path that starts at uu and ends at vv (or vice versa).

As with most sufficiently complex mathematical notions, there is an infinite black hole of graph theory terminology, much of which we will see in the future. For now, however, these terms are sufficient for making basic claims about graphs. The following is one of the most important facts about graphs, especially for the purpose of runtime analysis.

The Handshaking Lemma. For any graph GG, the sum of the degrees of every node is twice the number of edges. That is,

vVdeg(v)=2E\sum_{v \in V} \text{deg}(v) = 2|E|

Intuition. It is more useful to consider the edge-incidence definition of degree here. First, observe that every edge is incident on exactly two vertices. Thus, in our sum of degrees, any given edge contributes to exactly two deg(v)\text{deg}(v) terms by 1. Therefore, we would expect this sum to be equal to twice the number of edges.

Proof. We can formalize this intuitive idea. Let δij=1\delta_{ij} = 1 if and only if eje_j is incident on vertex viv_i; otherwise, it is defined as 0. So, we can now rewrite deg(vi)=j=1mδij\text{deg}(v_i) = \sum_{j=1}^{m} \delta_{ij}. So, the sum of all of these degrees is

i=1nj=1mδij=j=1mi=1nδij=i=1n2=2m\sum_{i=1}^{n} \sum_{j=1}^{m} \delta_{ij} = \sum_{j=1}^{m} \sum_{i=1}^{n} \delta_{ij} = \sum_{i=1}^{n} 2 = 2m

Here we simply rearrange the sum and use the fact that there are exactly 2 vertices incident on any edge, so i=1nδij=2\sum_{i=1}^{n} \delta_{ij} = 2.

Representations

We have discussed the mathematical idea of a graph, but it remains to define the abstract data type for use in our code:

Operation Description
Vertex iteration Iterate over every vertex vVv \in V.
Edge iteration Iterate over every edge eEe \in E.
Edge detection For two vertices uu and vv, determine whether (u,v)E(u, v) \in E.
Neighbor iteration For some specified uu, iterate over every vVv \in V where (u,v)E(u, v) \in E.

There are two common ways of implementing this data type.

Adjacency Matrix

One way to interpret a graph is that it is simply a binary relation on the vertices – that is, any two vertices are either adjacent or they aren’t adjacent. This leads to a natural programmatic representation – for every pair of vertices, keep track of whether they are adjacent or not. We can use any data type that acts as a two-place boolean predicate, but the most obvious choice is a two dimensional array. True to the relation interpretation, we could use a two-dimensional boolean array, but for reasons that will make sense later, it is more typical to use an integer array and store a 11 for adjacency and 00 for non-adjacency. This array is known as an adjacency matrix.

Figure 2: A graph (left) and its adjacency matrix representation (right). Hover over vertices to see their corresponding row and column in the matrix.

Adjacency matrices perform one operation very quickly: to detect whether two vertices are connected by an edge, we can simply test whether the adjacency matrix has a 1 in the corresponding position. This can be done with (approximately) two pointer deferences, so this operation takes Θ(1)\Theta(1) time.

Let us investigate the other operations. To iterate over every neighbor of viv_i, we can iterate through the ii-th row and report all the entries which are 1. Since a row is size nn, this always takes Θ(n)\Theta(n) time. Similarly, to iterate over all edges, we can go through the entire matrix and return all of the entries with a 1 in them, taking Θ(n2)\Theta(n^2) time. As we will see, neither of these are particularly good – this is because we waste a lot of time iterating over entries which don’t actually represent any edges, but are just empty placeholds for where edges could be. The next representation, the adjacency list, addresses this issue.

Adjacency List

One useful metric of graphs is their density, which is how many edges they have relative to the number of vertices they have. A graph is called dense if its number of edges is asymptotically close to the maximum. A graph is called sparse if it is not dense; that is, if its number of edges is far less than the maximum. Some formalizations of these terms exist (see tangent

TODO!
), but for now just think of

In practice, many graphs are sparse. For example, imagine a graph representing locations in the real world. You could imagine a complex enough graph of this type having, say, a million different locations. For any particular location, however, the maximum number of edges between that location and adjacent locations is probably no more than 4 – maybe up to 5, 6, if you have some really crazy intersection. It is certainly nowhere close to a million. We would expect, then, that this graph probably has no more than 5 to 6 million edges, even though it could in principle have up to (1062)1012{10^6 \choose 2} \approx 10^{12} edges.

Sparse graphs benefit from a more efficient representation called an adjacency list. Here, for each vertex, we simply store a “list” of adjacent vertices. I put list in scare quotes since it really makes more sense to think of this as a set (there are no duplicates, nor is there any particular order), but conventionally it’s just called a list anyway.

Figure 3: A graph (left) and its adjacency list representation (right). Hover over vertices to see their corresponding list in the map.

Comparing the two figures, it should hopefully clear where the speedup comes from: instead of checking every node to see if it is adjacent to some vertex viv_i, we can just instantly have access to a list of neighbors and iterate over it. So, neighbor iteration for adjacency lists is in Θ(deg(vi))\Theta(\text{deg}(v_i)). Since there are deg(vi)\text{deg}(v_i) neighbors anyway, this is literally the best we can do. We can also iterate over all edges quickly by iterating every list; this takes Θ(n+m)\Theta(n + m) time.

On the other hand, edge detection is not as nice. In order to determine if (u,v)E(u, v) \in E, we have to go through uu’s list and check if vv is in it, or vice versa. As long as we know the length of each list, we can pick the shortest, so edge detection takes Θ(min(deg(u),deg(v)))\Theta(\text{min}(\text{deg}(u), \text{deg(v)})). Note that for dense graphs, this can be quite bad… Θ(n)\Theta(n), in the worst case! Much worse than the Θ(1)\Theta(1) we had for adjacency matrices. But in sparse graphs, where we expect the degree to be relatively small, this is still quite efficient.

So, we can see that there is a tradeoff – by using lists, we get faster neighbor and edge iteration, but slower edge detection. By using matrices, we get faster edge detection, but slower neighbor and edge iteration. As it turns out, however, neighbor iteration is used wayyy more than any other operation. This operation is essentially the fundamental operation used when searching through graphs, which is kind of the thing you do with graphs. Hence, adjacency lists are generally used unless there is a good reason in particular to use adjacency matrices.

Searching

So far, we have only answered local questions about graphs:

But not yet questions which answer higher level, global properties of graphs. Here is an example:

The reachability problem: Given a graph GG and two vertices uu and vv, determine whether there is a path between uu and vv using edges in GG.

It is easy to see why this is a practically useful problem to have a solution for.

The most common way to solve the reachability problem is with a searching algorithm. There are many different ways to search a graph, but almost all of them are basic variations of the following high level algorithm.

As it turns out, it’s not much harder to solve a more general problem: which vertices are reachable from uu? This is the problem searching algorithms actually solve. Here’s how: starting at uu, we begin building a set XX of explored vertices, which we know for sure are reachable from uu. We repeatedly expand XX by finding vertices which are connected to already explored vertices. Eventually, we run out of vertices to add, and we stop – at this point, XX contains all of the vertices reachable from uu!

Let’s try to describe this in a way that is a little closer to an actual algorithm. Let’s say a vertex vv is a fringe vertex if v∉Xv \not\in X, but (u,v)E(u, v) \in E for some uXu \in X. Basically, vv is a fringe vertex if it hasn’t been explored, but it is one edge away from a vertex that has been explored. Then our algorithm should repeatedly find a fringe vertex and add it to XX. Algorithm 1 summarizes this approach, and Figure 4 visualizes the idea.

Algorithm 1: A high level overview of searching using a fringe.
Figure 4: A visualization of the high level graph search algorith. Orange vertices are those that are one edge away from the explored set. Click an edge to add it and its fringe vertex to the explored set.

We have left out a lot of details here, but let’s first prove that this high-level algorithm accomplishes our goal before we try to flesh it out.

Theorem. After Algorithm 1 terminates, XX contains exactly the vertices in GG which are reachable from uu.

Let’s first prove the following invariant: all vertices in XX are reachable from uu .This certainly holds before the loop starts (uu is reachable from itself), so we just need to show that it holds after each iteration of the loop. We need to show that the new element we’ve added to XX, the fringe vertex vv, is reachable from uu. By definition of fringe vertex, there is some wXw \in X such that (w,v)E(w, v) \in E. By the invariant, ww is reachable from uu, and so there is some path

p=u,p1,,pn1,wp = \langle u, p_1, \dots, p_{n-1}, w \rangle

If we construct a new path by simply appending the edge (w,v)(w, v), we get the path

p=u,p1,,pn1,w,vp' = \langle u, p_1, \dots, p_{n-1}, w, v \rangle

and so vv is reachable from uu. Thus, the invariant is preserved.

So, when the algorithm terminates, XX contains only vertices rachable from uu. Aren’t we done, then? Not quite. We have shown that if a vertex is in XX, it is reachable from uu, but we still need to show that if a vertex is reachable from uu, it is in XX. Essentially, thus far we have only shown that XX is a subset of the vertices reachable from uu, but we need to show it is equal.

TODO!
THINK ABOUT THIS. I think it can be salvaged, bit it's not quite right.

This direction is slightly more involved. There are many ways to proceed, but here is one idea: for each vertex vv that is uu-reachable, we can prove the following invariant: XX either contains vv or there is a fringe vertex ww such that there is a path from uu to vv that passes through ww.

In other words, the invariant says that we have either found vv, or we are on our way to finding it through some fringe node ww. Let us now prove the invariant, again by induction. First, the base case: when X={u}X = \{ u \}, does this invariant hold? If u=vu = v, then the first part of the “or” is clearly true, since vXv \in X. Otherwise, consider any path from uu to vv. Since uvu \neq v, the path consists of more than one path, and so there must be some node ww such that (u,w)E(u, w) \in E. But since uu is the only explored node, ww must be a fringe node.

Now, assuming the invariant is true for some iteration, we need to show that it holds for the next as well. Every iteration we add some fringe node \ell to XX. If =v\ell = v, then clearly the invariant is still true, since now vXv \in X. If w\ell \neq w, then ww is stil a fringe node, and so the second part of the or “or” is still true. The more complicated case is when =w\ell = w. We know there is some path p=u,,w,w,vp = \langle u, \dots, w, w', \dots v \rangle that connects uu and vv and contains ww. Notice the suspiciously placed ww', which is the node after ww in the path. Since pp is a path, (w,w)E(w, w') \in E. After exp

So we’ve shown that the algorithm correctly finds exactly the vertices we need to find. However, we haven’t specified completely how we even find fringe vertices in the first place. Until we do that, we can’t even implement this algorithm, let alone analyze its runtime.

TODO!

Breadth-first Search

Algorithm 2: Breadth-first-search.
Figure 5: A demonstration of breadth first search. Click to advance one iteration.

Depth-first Search

TODO!

Path Reconstruction

TODO!