Graph Algorithms

The following materials are notes on graph algorithms. They assume knowledge of basic graph theory.

  1. Introduction
  2. Implementations
    1. Adjacency Matrices
    2. Adacency Lists
    3. Edge Lists
  3. Common Problem Patterns
  4. Depth First Search (DFS)

Introduction

Graph algorithms pervade modern life: Maps, web links, circuits, schedules, matching, computer networks, road systems, program structures, company hierarchies, are just a few examples. In fact, trees and linked lists are just graphs. Any connected data structure is the realization of a mathematical graph. The most common graphs in computer science, however, simple graphs.

graph. A graph G{G} is a structure ((VG,EG),graph).{((V_G, E_G),\df{graph}).} The tuple (VG,EG){(V_G,E_G)} is a pair of two sets. The set VG{V_G} is a set of distinct objects called vertices or nodes, and the set EGβŠ†VGΓ—VG{E_G \subseteq V_G \times V_G} is a set of distinct pairs called edges such that, if v∈VG,{v \in V_G,} then (v,v)βˆ‰EG.{(v,v) \notin E_G.} For all edges (a,b)∈EG,{(a,b) \in E_G,} we may denote an edge with a variable e=(a,b){e = (a,b)} and describe it as, "The edge connecting a{a} and b.{b.}"

Later, we will include more operations (e.g., operations on vertices). Importantly, a practical API would also include some method of removing parallel edges. We will define these operations more elaborately as we proceed. In analyzing graph algorithms, we assume that #V/#E{\card{V}/\card{E}} β€” the proportion of vertices to edges for some graph G=(V,E){G=(V,E)} β€” is bounded above by a some constant C∈R.{C \in \reals.} For this first section, we'll only be working with sets of edges. An edge is defined as follows.

Implementations

The graph definitions provided previously are abstractions of implementation details. Accordingly, it's at our discretion how the implementation should be handled. For some algorithms, we might want an edge implementation, in which case we will use the following definition.

  • structure edge{\df{edge}} contains
    1. variableΒ {\var} head{\tx{head}}
    2. variableΒ {\var} tail{\tx{tail}}
    3. newΒ edge(d1,d2):(variable ↦edge){\df{new edge}(d_1, d_2):\ar{\var \mapsto \df{edge}}}
      1. out←malloc(sizeofΒ edge){\let{out}{\df{malloc}(\df{sizeof}~\df{edge})}}
      2. out[head]←malloc(sizeofΒ d1){\let{out[\tx{head}]}{\df{malloc}(\df{sizeof}~d_1)}}
      3. out[tail]←malloc(sizeofΒ d1){\let{out[\tx{tail}]}{\df{malloc}(\df{sizeof}~d_1)}}
      4. return out{out}
  • end

Adjacency Matrices

One approach is to use an adjacency matrix, a nested Boolean array.

a{a}b{b}c{c}d{d}e{e}
a{a}01011
b{b}00100
c{c}00010
d{d}00001
e{e}00000
f{f}01000

Here, the leftmost column represents the edge sources, and the topmost row represents destinations. The edge (d,a),{(d,a),} for example, connects vertex d{d} (the source) and vertex a{a} (the destination), so we mark the corresponding cell as 1.{1.}

As is usually the case with nested arrays, there are costs and benefits to this approach. As nested arrays, adjacency matrices are Simple to implement and maintain (and are conducive to linear algebra applications). They're also particularly efficient, compared to other approaches, for dense graphs. Moreover, for weighted graphs, the time complexity of edge weight lookup is O(1).{\bigO{1}.} On the other hand, instantiating the structure takes (#V)2{(\card V)^2} time, and if #V{\card V} is small enough, memory allocation calls will likely predominate. They also require Θ(V2){\bigTheta{V^2}} space, and iteration takes Θ(V2){\bigTheta{V^2}} time.

Adacency Lists

An alternative approach is to use an adacency list: Use a connected data structure (typically, a hash table), where each key is a node (the source-nodes of an edge), and each value is an array of nodes (the end-nodes of the edges).

a ↦ [b,c]b ↦ [c]c ↦ [d]d ↦ [a] {\eqs{ a~&\mapsto~\ix{b,c} \\ \hdashline b~&\mapsto~\ix{c} \\ \hdashline c~&\mapsto~\ix{d} \\ \hdashline d~&\mapsto~\ix{a} \\ }}

For algorithms involving adjacency lists, we will use the structure below.

structure graph{\df{graph}} contains

  1. variables
    1. hash-table:(variableΒ Β ,variableΒ Β array){\df{hash-table}:(\var~,\var~\df{array})} V{V}
    2. N{\nat} order{\tx{order}}
  2. constructor
    1. new(Β ):βˆ…β†¦graph{\df{new}\texttt{( )}: \nil \mapsto \df{graph}}
      1. out←malloc(sizeofΒ graph){\let{out}{\df{malloc}(\df{sizeof}~\df{graph})}}
      2. return out{out}
  3. binary operations
    1. init
      1. variableΒ d,a,b{\df{variable}~d,a,b}
      2. graphΒ G{\df{graph}~G}
    2. GΒ add-vertexΒ d{G \df{ add-vertex } d} add a vertex
      1. V[d]=newΒ array{V\ix{d}=\df{new array}}
    3. GΒ add-edgeΒ (a,b){G \df{ add-edge } (a,b)} add an edge
      1. V[a]β‡šb{V\ix{a} \push b}
      2. V[b]β‡ša{V\ix{b} \push a}
    4. GΒ remove-edgeΒ (a,b){G \df{ remove-edge } (a,b)} remove an edge
      1. if a∈V{a \in V} then
        1. A←V[a]{\let{A}{V\ix{a}}}
        2. L←lengthΒ Β V[a]{\let{L}{\len~V\ix{a}}}
        3. i←0{\let{i}{0}}
        4. while i<L{i \lt L}
          1. if A[i]=a{A \ix{i} = a} then AΒ deleteΒ a{A \df{ delete } a}
          2. +(i,1){+(i,1)}
          3. goto(lineΒ 20){\goto{20}}
      2. if b∈V{b \in V} then
        1. B←V[b]{\let{B}{V\ix{b}}}
        2. L←lengthΒ Β V[b]{\let{L}{\len~V\ix{b}}}
        3. i←0{\let{i}{0}}
        4. while i<L{i \lt L}
          1. if B[i]=a{B \ix{i} = a} then BΒ deleteΒ a{B \df{ delete } a}
          2. +(i,1){+(i,1)}
          3. goto(lineΒ 29){\goto{29}}
    5. GΒ remove-vertexΒ v{G \df{ remove-vertex } v} remove vertex
      1. if v∈V{v \in V} then
        1. βˆ€(key,value)∈V{\forall(key,value) \in V}
          1. if v∈value{v \in value} then value delete v{value \df{ delete } v}
        2. Vβˆ–v{V \rid v}

In general: Adjacency lists are particularly space efficient for for space graphs. On the other hand, they become much less space efficient for denser graphs because of their connected nature. In contrast to the adjacency matrix, iterating over is much faster, but because edges are represented as key-array pairs, we have an edge weight lookup of O(E).{\bigO{E}.} Adjacency lists are also slightly more complicated to implement and maintain, and aren't nearly as conducive to linear algebra applications as adjacency matrices.

Edge Lists

Yet another representation is the edge list. This is little more than a list (or array) of edges:

[(a,b),(a,c),(b,c),(c,d),(d,a)]. [(a,b), (a,c), (b,c), (c,d), (d,a)].

As we can likely tell, this approach is rarely used in practice. There isn't any notion of ordering, and edge lookup comes at O(n).{\bigO{n}.} That said, they're often used as auxiliary data structures for certain algorithms, and we'll seem repeatedly.

Common Problem Patterns

Whenever we work with graphs, we want to always ask the following set of questions:

  1. Is the graph directed or undirected?
  2. Are the edges weighted?
  3. Is the graph sparse or dense?
  4. Should we use an adjacency matrix, adjacency list, edge list, or some other structure?

Additionally, it's helpful to spot some common problem patterns that are squarely in the domain of graph theory.

shortest path problem. Given a graph G{G} with vertices a{a} and b,{b,} find the shortest path from a{a} to b.{b.}

The shortest path problem is addressed by numerous algorithms. For unweighted graphs, we have breadth-first search and depth-first search. For weighted graphs, we have Dijkstra's Algorithm, A*, the list goes on. All of these algorithms have different tradeoffs. Some problems, however, ask us to just find a path.

connectivity problem. Given a graph G{G} with vertices a{a} and b,{b,} is there a path between a{a} and b?{b?}

This problem appears benign on its face, until we're asked to find a path between two nodes among millions of interconnections.

negative cycle problem. Given a weighted graph G,{G,} is there a negative cycle?

A negative cycle occurs when an edge has a negative value. This can lead to wrong computations involving edge weights. This is particularly problematic, given the fact that the results of those computations are often used as intermediary results for other graph algorithms (e.g., a shortest path solution). In other applications, negative cycles are desirable. For example, FOREX trading applications have a particular interest in these cycles, as negative edge weights can be interpreted as measures of risk reduction. To common algorithms for this problem are the Bellman-Ford Algorithm and the Floyd-Warshall Algorithm.

strong-connectivity problem. Let D{D} be a directed graph with C0,C1,…,Ckβˆ’1{C_0, C_1, \ldots, C_{k-1}} self-contained cycles with i,k∈N.{i,k \in \nat.} For all self-contained cycles Ci,{C_i,} is there directed path from a vertex v∈Ci{v \in C_i} to every other vertex w∈Ci?{w \in C_i?}

The strong-connectivity problem usually arises as an intermediary problem in implementing some of the algorithms mentioned thus far. Popular solutions include Tarjan's Algorithm and Kosaraju's Algorithm.

traveling salesman problem. Given a weighted graph wG=(V,E),{^{w}G = (V,E),} what is the shortest Hamiltonian cycle of wG?{^wG?}

In plain English: We're given a list of addresses. The addresses are separated by varying distances β€” some are closer to others, others farther. Our task: Visit every address exactly once, then return to the address we started on. What's the shortest route to accomplishing this task correctly? This problem is NP-hard. Some common algorithms include the Held-Karp Algorithm, Branch-and-bound, and many approximation algorithms.

edge-cutting problem. Let i,n,k∈N,{i, n, k \in \nat,} where n<k,{n \lt k,} and a graph G=(V,E){G = (V,E)} with n{n} connected components. Suppose E={ e1,e2,…,en }{E = \set{e_1, e_2, \ldots, e_n}} and that Eβˆ–{ ei }{E \smallsetminus \set{e_i}} implies n>k.{n \gt k.} What is the value of i?{i?}

vertex-cutting problem. Let i,n,k∈N,{i, n, k \in \nat,} where n<k,{n \lt k,} and a graph G=(V,E){G = (V,E)} with n{n} connected components. Suppose V={ v1,v2,…,vn }{V = \set{v_1, v_2, \ldots, v_n}} and that Vβˆ–{ vi }{V \smallsetminus \set{v_i}} implies n>k.{n \gt k.} What is the value of i?{i?}

When we remove an edge from some graph G,{G,} we potentially end up with two or more connected components of G.{G.} The edge-cutting problem asks which edge, when removed, causes the number of connected components to increase beyond some tolerance maximum k∈N.{k \in \nat.} Edges that causes this increase are called bridges.

The vertex-cutting problem is similar to the edge-cutting problem, but the focus here is on cutting edges. The question then, is, which vertex, if cut, would cause an increase to the tolerance maximum k?{k?} Vertices that cause these increases are called articulation points. Both the edge- and vertex-cutting problems are particularly important because they reveal bottlenecks and vulnerabilities in a given graph.

minimum spanning tree problem. Let W=(V,E){W = (V,E)} be a weighted graph with a total edge weight T.{T.} Let S={ w1,w2,…,wn, }{S = \set{w_1, w_2, \ldots, w_n,}} be a set of graphs such that for all i,n∈N:i≀n,{i,n \in \nat:i \le n,} each wi{w_i} is a graph with the vertex set vi=V{v_i = V} and an edge set eiβŠ†E{e_i \subseteq E} and a total edge weight ti.{t_i.} Construct the graph wi∈S{w_i \in S} such that ti=min⁑{ t1,t2,…,tn }.{t_i = \min\set{t_1, t_2, \ldots, t_n}.}

In plain English: We're given a weighted graph W.{W.} We want to create a version of W{W} that includes all its vertices, but with the smallest possible total weight (the sum of all the edge weights in W{W}). Common algorithms for this problem include Kruskal's Algorithm, Prim's Algorithm, and Boruvka's Algorithm.

Depth First Search (DFS)

Depth First Search (DFS) is an algorithm for traversing a graph's nodes and edges. The algorithm has a time complexity of order O(V+E).{\bigO{V+E}.} The algorithm itself is often used as an auxiliary, so it's often used on its own. Instead, it's often augmented to another algorithm β€” searching, counting, deleting, etc. The name "depth first" comes from the algorithm plunging into a graph without regard to which edge it traverses next. It's primary driver is a check: Has this node been visited? If the node has been visited, the algorithm backtracks.

For tracking, we'll fill in the node we're currently on green. After a node is visited, we'll fill the visited node grey. First, we pick any node to start. This is where the name depth first comes from: We plunge into a graph without regard of where we might go next. Here, we'll start with the node { 0 }.{\set{0}.}
We mark { 0 }{\set{0}} visited, and plunge. There's only one option, { 9 },{\set{9},} so we visit this node next.
Mark { 9 }{\set{9}} visited and plunge. We only have one option again, so visit { 8 }.{\set{8}.}
Now we have two options, { 1 }{\set{1}} and { 8 }.{\set{8}.} Again β€” plunge. It doesn't matter which node we visit. We just dive into the deep end. We'll take { 7 }{\set{7}} purely for illustrative purposes.
At { 7 },{\set{7},} we have many options, but again, we pick arbitrarily. We'll choose { 10 }.{\set{10}.}
We only have one option, { 11 },{\set{11},} so we go there.
This is where things get interesting. We only have one option, { 7 }.{\set{7}.} But remember, we don't want to "revisit" nodes. So, we backtrack to { 7 }.{\set{7}.} To denote backtracking, we'll fill the nodes beige and the edges red. Backtracking to 7, we still have edges to choose.
Back at 7, we still have edges to choose. We pick { 3 }.{\set{3}.}
We pick another node arbitrarily. We'll go { 2 }.{\set{2}.}
We hit a deadend, so we backtrack to { 3 }{\set{3}} and pick another node. We only have one option, { 5 },{\set{5},} so we visit that node.
Now visit { 6 }.{\set{6}.}
We can't visit { 7 }{\set{7}} since we've already visited that node, so we backtrack to { 8 }.{\set{8}.} Once we're there, the only node we can visit is { 1 }.{\set{1}.} Visting that node, we've traversed the entire graph.

Below are two possible implementations of DFS, one recursive, the other iterative.

rdfs

  • Input: a graph G,{\G,} a vertex v,{\v,} and a function f{\f} operating on v.{\v.}
  • Output: the graph G{G}
  1. visited←{   }{\let{visited}{\set{~}}}
  2. function Ξ»(v){\lambda(v)}
    1. if v=βˆ…{v = \nil} return βˆ…{\nil}
    2. visited[v]←1{\let{visited \ix{v}}{1}}
    3. f(v){\f(v)}
    4. foreach neighbor∈G[V][v]{neighbor \in G\ix{V}\ix{v}}
      1. if visited[neighbor]{visited \ix{neighbor}} then
        1. return Ξ»(neighbor){\lambda(neighbor)}
  3. return Ξ»(v){\lambda(\v)}

idfs

  • Input: a graph G,{\G,} a vertex v,{\v,} and a function f{\f} operating on v.{\v.}
  • Output: the graph G{G}
  1. init
    1. stack←newΒ stack{\let{stack}{\df{new stack}}}
    2. visited←{   }{\let{visited}{\set{~}}}
    3. visited[v]←1{\let{visited\ix{\v}}{1}}
    4. vcβ†βˆ…{\let{v_c}{\nil}} current vertex
  2. while stackβ‰ βˆ…{stack \neq \nil}
    1. vc←popΒ stack{\let{v_c}{\df{pop}~stack}}
    2. f(vc){\f(v_c)}
    3. foreach neighbor∈G[V][vc]{neighbor \in G\ix{V}\ix{v_c}}
      1. if visited[neighbor]=0{visited \ix{neighbor}=0} then
        1. visited[neighbor]←1{\let{visited\ix{neighbor}}{1}}
        2. stackΒ pushΒ neighbor{stack ~\df{push}~ neighbor}
  3. return Ξ»(v){\lambda(\v)}