21. Graphs
Up to this point in the course, the data structures that we have studied have had a particular predetermined structure. Linear data structures such as lists arrange their data sequentially, with each element “connected” to up to two other elements on its left and right. This linear structure enabled the fast, random access guarantee of dynamic arrays and provided a convenient framework for modeling stacks and queues. Next, we studied trees, which arrange their data hierarchically. Restricting to binary trees, each element had at most one “parent” and at most two “children”. We could impose additional invariants on these connections and the overall structure of the tree (giving rise to BSTs and heaps) to achieve good performance for certain search and update operations.
While these rigid structures work well for many applications, they are often insufficient to model the complex interconnectedness of real-world data.
- Transportation systems such as road networks, airline maps, and bus routes can include arbitrarily many connections to other waypoints, stations, or cities. We need an efficient way to represent these connections so that we can perform tasks such as traffic modeling or route planning.
- On social media platforms, users are free to connect with any number of other users (friends, relatives, colleagues, etc.). These connections form a complex web of relationship data that is useful for studying the spread of (mis-)information and effective advertising.
- A focus of neurological research is to map out the connections between different areas of the brain, as these connections offer insights for how we think and learn. Understanding and mimicking the structure of these connections have aided the design of deep learning models.
Over the next few lectures, we will introduce the Graph ADT as a tool for modeling these sorts of interconnected data. Today, we’ll introduce some basic terminology to discuss and categorize graphs, and we’ll consider different strategies for modeling graphs using other data structures that we have seen. In the following two lectures, we will turn our attention to how we can systematically traverse and navigate graphs to answer some questions about their structures. This is a rich topic of which we will barely scratch the surface. Graphs play a more prominent role in an algorithms course (e.g., CS 4820), where more time is devoted to understanding which questions about graphs can be answered efficiently, and which have no known efficient solutions.
Defining Graphs
At a high level, graphs are an ADT that we use to model connections between different entities. This means that the data of a graph primarily consists of two pieces of information.
-
We need to know the underlying set of objects that we will be relating. We call these objects the vertices, or the nodes, of the graph. For example, the users are the vertices in a social network, and addresses or cities are the vertices in a transportation network.
-
For any pair of vertices, we need to know whether they are connected or not. If they are connected, we say that there is an edge, or arc, between these vertices. For example, friendships are modeled by edges in a social network and flights are modeled with edges in an airline’s route network.
A graph, often denoted \(G\), consists of a set of vertices \(V\) and a set of edges \(E\), where the edges are a subset of all possible (ordered) pairs of vertices.
It is often helpful to draw a picture of a graph to help visualize its structure. In this picture, we represent the vertices as circles. We write labels within these circles to distinguish the different vertices. Then, we draw an arrow between two vertices if the graph includes an edge between them. For example, the following figure
and with seven edges,
\[ E = \Big\{ (a,b), (a,c), (b,d), (b,e), (c,d), (d,e), (e,f) \Big\}. \]Notice that each edge is an ordered pair, where the vertex that is listed first is the tail (non-pointy end) of the arrow and the vertex that is listed second is the head (pointy end) of the arrow. For example, the arrow from vertex \(b\) (its tail) to vertex \(d\) (its head) corresponds to the edge \((b,d)\), but would not be represented as an edge \((d,b)\) (which would model a connection starting at \(d\) and ending at \(b\)). Since each edge has a prescribed direction (or orientation), we call this a directed graph.
In a directed graph, each edge models a connection that exists in one direction between a pair of vertices, but not the other. For a directed edge \((u,v) \in E\), we refer to \(u \in V\) as the tail of the edge and \(v \in V\) as its head.
For example, a network of flights in a given time window is most naturally modeled as a directed graph since there may be a flight in one direction between two cities but not the other. Similarly, road networks (which may have one-way streets) and some social networks (where a user can “follow” a celebrity without the requirement that the celebrity “follows” them back) are naturally modeled by directed graphs. We will focus on directed graphs in CS 2110. Note that we can model bi-directional connections between a pair of vertices by including two directed edges:
Many theoretical computer scientists and mathematicians prefer to use the word "arc" to describe directed connections and reserve the word "edge" for undirected connections. This convention isn't as popular in data structures, so we'll stick with the word "edge".
Other Graph Varieties
While we will restrict our attention to simple directed graphs in CS 2110, the area of graph theory is rich with many other variants of graphs. We briefly remark on some of these here so that you’ll be familiar with this terminology.
Undirected Graphs
While we have imposed the requirement that every edge in our graph works in one specified direction (giving a directed graph), an alternative is to allow every edge to work in both directions (giving an undirected graph). When we visualize undirected graphs, we connect vertices with line segments as opposed to arrows.
Self-Loops
In some circumstances (for example, modeling the state transitions of a character in a game), it makes sense to allow an edge (either directed or undirected) to connect a vertex back to itself.
We call such an edge a self-loop.
Multigraphs
In some circumstances, it might make sense to allow multiple parallel edges (directed or undirected) between the same pair of vertices. For example, this could model multiple flights between the same pair of cities that occur over the course of one day.
When we allow parallel edges in a graph, we often refer to it as a multigraph.
The presence of self-loops and parallel edges adds complexity when reasoning about graphs. For this reason, we won’t consider them in CS 2110. Instead, we will restrict our attention to simple directed graphs.
A simple graph is one without self-loops or parallel edges.
Graph Substructures
Since graphs can grow to be very large (e.g., social network graphs include billions vertices and edges), it will be useful to have terminology to discuss smaller structures present within the graph.
Neighbors
First, when we discuss traversal of a graph, it will be useful to understand which vertices we can reach by starting at one specific vertex \(v\) and following an edge. We call the vertices that are reachable from \(v\) its (out-)neighbors, which together comprise its (out-)neighborhood.
In a directed graph \(G = (V,E)\), we say that a vertex \(v \in V\) is a neighbor (sometimes, an out-neighbor) of a vertex \(u \in V\) if the edge \((u,v)\) belongs to \(E\).
The neighborhood (or out-neighborhood) of vertex \(u \in V\), often denoted \(N_G(u)\) is the set of all of \(u\)'s neighbors,
\[
N_G(u) = \big\{ v \in V \colon (u,v) \in E \big\}.
\]
The degree (or out-degree) of vertex \(u \in V\), often denoted \(d_G(u)\) is the size of \(u\)'s neighborhood.
For example, the vertex \(c\) has neighborhood \(N_G(c) = \{a,d,e\}\) (so \(c\) has degree \(d_G(c) = 3\)) in the following graph.
Paths
Many problems on graphs involve finding a (hopefully short) way to navigate between a pair of vertices along their edges. For example, booking travel on an airline requires finding a collection of flights that take you from your starting city to your destination city. In a social network, we may wish to understand how a message spread to a particular person as it was shared between friends. The structure of interest in both of these problems is a linked sequence of edges which we call a path.
A path \(P\) is a sequence of distinct contiguous edges in a graph \(G = (V,E)\),
\[
P = \Big( (v_0, v_1), (v_1, v_2), \dots, (v_{\ell-1}, v_{\ell}) \Big)
\]
The head of each edge in the path is the same vertex as the tail of the subsequent edge.
The length of a path is the number of edges that it contains (\(\ell\)).
The tail of the first edge in the path (\(v_0\)) is its source, and the head of its last edge (\(v_\ell\)) is its destination.
Some people prefer to call the object that we just described a walk and reserve the word path for a walk that "visits" each vertex at most once (so \(v_0, v_1, \dots, v_{\ell}\) are all distinct vertices). Other people prefer to call this object a path and use the term simple path to describe those paths that do not revisit vertices.
We’ll often use the notational shorthand \(v_0 \to v_1 \to \dots \to v_{\ell}\) to describe the edges of a path, since this is less clunky than writing all the brackets and parentheses. We’ll also use the notation \(v_0 \rightsquigarrow v_{\ell}\) to describe the source and destination of a path while suppressing its intermediary vertices. We can identify paths in a graph by choosing any vertex to be its source and then following arrows (in the correct direction) to travel between vertices until we reach the desired destination. For example, in the graph
\( f \to d \to g \to e \to c \) is a path with source \(f\) and destination \(c\) (i.e., an \(f \rightsquigarrow c\) path).
Cycles
Cycles are a special case of paths.
A cycle is a path whose source is the same vertex as its destination.
In other words, a cycle loops back to connect to where it began. In the previous graph \( e \to b \to d \to g \to e \) is a cycle (which we can also represent \( b \to d \to g \to e \to b \), among other possibilities).
Labeled Graphs
Often, we may want to associate extra information with the vertices or edges in a graph. We can do this by labeling this information on our visualization of the graph. Vertex labels (in addition to distinguishing the vertices) can model various attributes such as a cost to visit a vertex. In upcoming lectures, we’ll associate extra information with vertices to keep track of our progress in different graph traversal algorithms.
It is also common to add labels to the edges in a graph. When these labels are numerical, we often refer to them as the costs or weights on the edge. These weights can model things such as a cost or time to traverse the edge or model some notion of “distance” between the connected vertices. For example, consider the following weighted graph:
In this weighted graph, the edge \((c,d)\) has weight (sometimes called length) 5. We can also talk about the weight/length of a path or cycle, which is the sum of the weights/lengths of all of its constituent edges. For example, the path \( a \to b \to d \to e \to f \) from \(a\) to \(f\) has length \( 4 + 8 + 7 + 1 = 20\). The shortest path from \(a\) to \(f\) is \(a \to b \to e \to f\) with length \(4 + 6 + 1 = 11\). We will return to the question of finding the shortest path in a weighted graph in two lectures.
Caution: We've overloaded the term "length" for a path. When the edges in the graph are not labeled with weights, a path's length is the number of edges that it contains. However, when the edges are labeled with weights, we frequently use the word "length" to describe the sum of weights of the path's edges. Some people prefer to use the term path "weight" in this latter case, but this gets clunky in "shortest" path problems, a universal term that would be more aptly described as a "lightest" path problem if we adopt this weight terminology.
ADTs for Graphs
Now that we have introduced some of the terminology for formally describing graphs, we can think about how to model directed graphs as an abstract data type. Note that Java does not provide a canonical Graph ADT, so we will need to design one ourselves. We will actually define three related types that model various parts of the graph: the Graph itself, a Vertex in the graph, and an Edge in the graph. This separation will allow us to swap in different Vertex and Edge classes to the same Graph definition to model different varieties of graphs (e.g., weighted vs. unweighted graphs). We’ll start by considering these Vertex and Edge types before considering the behaviors of the Graph ADT.
The Edge ADT
The simplest of the three abstract data types will be the Edge. This will be a generic class that is parameterized by a Vertex type V. The only behaviors that we will require of an Edge are to return its tail() and head() vertices.
|
|
|
|
Classes that implement the Edge interface may wish to add additional fields and methods to model other properties such as the weight of the edge. This will allow clients to access and interact with these properties through the Edge (subtype) objects. For example, we can define the following class to represent an int-weighted directed edges:
|
|
|
|
The Vertex ADT
Next, let’s consider the Vertex type. What behaviors should a Vertex provide? First, a Vertex should make accessible its most basic state, its associated label(). A Vertex should also be able to return information about its neighborhood, as this will enable us to traverse a graph by interacting with its vertices. What methods may be useful here? As an initial list, we’ll include:
- A method
degree()that returns the size of the vertex’s neighborhood. - A method
hasNeighbor()that returns whether this vertex has an outgoing edge to a vertex with the given label. - A method
edgeTo()that returns the edge connecting this vertex to the vertex with the given label. - Methods for iterating over either the outgoing edges from this vertex or its neighborhood.
Since some of these methods will require returning Edge objects (i.e., objects that are subtypes of the Edge interface), our Vertex type should be generic on an edge type E. We can ensure that E models an Edge using the type bound E extends Edge<?> (where here the wildcard <?> acknowledges that Edge is itself a generic type without introducing a cyclic dependency where the Vertex type is parameterized by the Edge type, which is parameterized by the Vertex type…). By using this generic type E in our method signatures, the client of our Vertex class can interact with custom methods of their Edge subclass without a need for casting.
|
|
|
|
The Graph ADT
Now, let’s consider what behaviors we might wish for our Graph type to support. We’ll categorize these behaviors into 3 groups:
Query Methods
These methods return information about various aspects of the graph without modifying its state. Some questions that we may wish to ask about a graph are:
- How many vertices does it have?
- How many edges does it have?
- Does it have a vertex with a particular label?
- Does it have an edge between two particular vertices?
- What is the degree of a particular vertex?
- What is the weight (or some other property) of a particular vertex/edge?
- Is there a path between two particular vertices in the graph?
- What are properties of a path (perhaps the shortest path) between two vertices?
- Does the graph contain cycles?
In addition to asking for existence in Questions 3-4, we may also want to receive a reference to these Vertex or Edge objects. Questions 5-6 discuss properties of vertices/edges, so they are better relegated to the Vertex and Edge types (once we have Graph methods that can return Vertex and Edge objects). Questions 7-9 concern more complicated substructures of graphs (paths and cycles), so they are probably more well-suited for an external graph utilities class.
Mutation Methods
We may also want ways to modify the structure of a graph. This includes operations such as
- Adding another vertex to the graph.
- Adding another edge to the graph between two existing vertices.
- Modifying the weight (or some other property) of a vertex/edge.
- Removing an edge from the graph.
- Removing a vertex (and all of its incident edges) from the graph.
As with the query methods, 3 is probably more appropriate in the Vertex and/or Edge classes. In the lecture, we’ll only consider mutating methods that append new structure (vertices or edges) to a graph. We leave it as an exercise to add methods to support removing edges and/or vertices (see Exercise 21.7).
Iteration Methods
Finally, we will want methods that will enable iteration over various aspects of the graph. Our Vertex ADT already enables iteration over outgoing edges, but our Graph ADT will add support for iterating over all the vertices() or all the edges() in the graph.
Together, all of these behaviors give rise to the following Graph interface. Take some time to read through its specifications.
|
|
|
|
Graph Representations
Next, let’s consider how we can combine other data structures that we have learned about to implement the Graph ADT. We’ll consider (variants of) the two most popular ways to represent graphs, adjacency matrices and adjacency lists and discuss trade-offs between these approaches.
Adjacency Matrices
As we discussed earlier, each edge in a graph corresponds to an ordered pair of vertices, its endpoints. Therefore, the maximum number of edges that a graph can have is upper-bounded by \(|V|^2\) (more specifically, \(|V|^2 - |V|\) since we disallow self-loops in simple graphs), the number of these ordered pairs. We can imagine building a table (i.e., a 2D array or a matrix) that lets us look up whether a particular pair of vertices has an edge between them. The rows and columns of this matrix correspond to particular vertices. As an example, consider the following figure.
null for vertex pairs that are not connected by an edge) within this matrix.Let’s implement our Graph interface to model a weighted directed graph using an adjacency matrix representation. Our implementation will be in the AdjMatrixGraph class. We’ll use our WeightedEdge class from earlier to model the graph’s edges. For the vertices, we’ll define a new AdjMatrixVertex class that implements our Vertex interface. To support some of the vertex behaviors (e.g., checking for the presence of a neighbor), our vertices will need access to the adjacency matrix, which will live in the AdjMatrixGraph class. Thus, it makes sense for AdjMatrixVertex to be a (non-static) inner class of AdjMatrixGraph.
|
|
|
|
Let’s consider what state our graph and vertices will need to store. First, our graph will need to store the adjacency matrix. Since our Graph ADT supports adding vertices, which will cause a resizing of the adjacency matrix, it makes sense to use dynamic arrays (i.e., ArrayLists) for its backing storage. Since the adjacency matrix is 2-dimensional, we will need a nested list structure, an ArrayList<ArrayList<WeightedEdge<AdjMatrixVertex>>> (wow, these generic types have gotten complicated!).
The client references vertices using their String labels, and our AdjMatrixGraph class will need a way to efficiently translate these labels into vertices. We can do this using a HashMap<String, AdjMatrixVertex>. Within the AdjMatrixVertex class, we’ll need to store this label to support the label() method. We’ll also need a way to keep track of which rows/columns in the adjacency matrix correspond to which vertices, which we can do through an int index field in the AdjMatrixVertex. Together, these fields allow us to track all of the state that we will need.
|
|
|
|
From here, most of the Vertex and Graph methods are fairly straightforward to implement. The full implementations are provided with the release code, but we encourage you to take some time to complete them yourself. We discuss the implementation of some of these methods below.
AdjMatrixVertex.degree()
AdjMatrixVertex.hasNeighbor()
AdjMatrixVertex.outgoingEdges()
AdjMatrixGraph.edgeCount()
AdjMatrixGraph.hasEdge()
AdjMatrixGraph.addVertex()
Benefits and Drawbacks
The primary benefit of the adjacency matrix representation is that the random access guarantee of dynamic arrays allows us fast, \(O(1)\) lookup of edges. An additional benefit, that we will not explore too deeply, is that representing connection information as a matrix allows us to leverage some ideas from linear algebra to recast some graph operations (such as searching for paths of particular lengths) as matrix multiplication, which has more efficient algorithms.
There are a few primary drawbacks of the adjacency matrix representation. First, it requires a lot of memory. Even in a very sparse graph (in which most of the possible edges are not present), the adjacency matrix will require \(O(|V|^2)\) storage, most of which simply indicates the absence of particular edges. Next, resizing the adjacency matrix when a new vertex is added to the graph is an expensive operation (especially in the worst case when the backing storage of the ArrayLists must be resized). Finally, “local search” operations (i.e., visiting all of the neighbors of a vertex) require us to iterate over an entire (likely mostly empty) row of the adjacency matrix. We’d like to have a more efficient way to directly access the neighbors of a vertex. Our second graph representation, an adjacency list, affords us this ability.
Adjacency Lists
Rather than having the graph store a centralized table of adjacency information, an adjacency list representation delegates to each of its vertices the responsibility of tracking their neighborhoods. Classically, we visualize an adjacency list representation as a linked list of vertices (the red, leftmost vertical list in the following figure), where each of these vertices has its own linked list storing all of its neighboring vertices (or all of its outgoing edges).
While linked lists offer a good visualization tool, they are not the best choice in practice. Operations such as querying for the presence of a particular neighbor (a common subroutine of many graph operations) would require a linear traversal of the neighbors list. Instead, we can use HashMaps in place of both “layers” of lists. The outer list of vertices can be replaced with a map associating vertex labels to Vertex objects (just as we had in our AdjMatrixGraph implementation). The inner list of neighbors/edges can be replaced with a map associating the tail vertex labels to Edge objects. This will provide many of the same \(O(1)\) lookup guarantees that we had for adjacency matrices.
Let’s formalize these ideas. We’ll define an AdjListGraph class that is parameterized by the same WeightedEdge class and a new AdjListVertex class. Since vertices in an adjacency list are responsible for tracking their own adjacencies, they will not need to access any fields of AdjListGraph. Thus, it makes sense for AdjListVertex to be a static nested class of AdjListGraph with its own HashMap<String, WeightedEdge<<AdjListVertex>> field (along with a String field for its label). As we noted above, the AdjListGraph class will need a HashMap<String, AdjListVertex> field to keep collect its vertices.
|
|
|
|
As with the AdjMatrixGraph, most of these methods have straightforward implementations now that we have settled on a state representation. The full implementations are provided with the release code, but we encourage you to take some time to complete them yourself. We again discuss the implementation of some of these methods below.
AdjListVertex.degree()
AdjListVertex.hasNeighbor()
AdjListVertex.outgoingEdges()
AdjListGraph.edgeCount()
AdjListGraph.addVertex()
Benefits and Drawbacks
There are many benefits of the adjacency list representation, in particular when we implement it using maps for fast lookup operations. Each of the Graph ADT operations has a runtime no worse than for our adjacency matrix representation, and many operations can be made faster since we can avoid iterating over entire rows of an adjacency matrix. In addition, the memory footprint of an adjacency list is smaller when there are not too many edges, \(O(|V|+|E|)\), as opposed to the \(O(|V|^2)\) of an adjacency matrix.
Let's pause here for a second to further consider these two complexity classes \(O(|V|+|E|)\) and \(O(|V|^2)\). Since there are two variables involved \(|V|\) and \(|E|\), it is not immediately clear how to compare them. This will depend on how "dense" the edges are in the graph.
In a sparse graph, there are relatively few edges. Typically, we think of a sparse graph as having \(O(|V|)\) edges, so each vertex has a roughly constant number of neighbors. In this case \(O(|V|+|E|) = O(|V|)\), so the adjacency list representation uses significantly less storage for large graphs.
In a dense graph, there are many edges, closer to the maximum possible number. You can think of a dense graph as having \(O(|V|^2)\) edges. In this case, \(O(|V|+|E|) = O(|V|^2)\), so the memory gains of adjacency lists are not significant; in fact, the additional memory to keep track of many more references behind the list or map representations can often make the adjacency list representation larger.
The main drawbacks of adjacency lists largely fall beyond our scope. The more decentralized storage of their data and the use of more complicated data structures (one map for each vertex) can lead to worse cache performance than an adjacency matrix. Also, the lack of a global table of adjacency information can make some more complicated graph operations slower. For our purposes, we will prefer the adjacency list representation as we continue to study graphs over the next two lectures.
Main Takeaways:
- Graphs are used to model connections, or edges, between members of a set called its vertices. Sometimes, the vertices or edges in a graph are labeled with additional information which we often call their weights.
- In a directed graph, each edge works in only one direction, pointing from its head to its tail. The neighbors of a vertex are the other vertices that it connects to via edges, and its degree is the size of its neighborhood
- We can link together edges in a graph to form paths and cycles, which are important concepts for discussing graph traversals.
- There are different ways to model the state of a graph. An adjacency matrix stores all edge information in a central 2D table. An adjacency list representation makes each vertex responsible for keeping track of its neighborhood.
- Adjacency lists implemented with hash maps have great performance, especially in sparse graphs which have relatively few edges.
Exercises
What are this graph’s vertices and edges?
Write out the adjacency matrix of this graph.
State the neighborhood of each vertex.
Consider modeling movie actors as an undirected graph. The nodes represent actors and are labeled with the actor’s Screen Actors Guild number. There is an edge between every pair of actors if they appeared in at least one movie together. The weight of the edge is a non-negative integer that represents the number of movies they have appeared in together.
Graph interface that verifies if a list of edges forms a path on the graph. Implement this method for both representations of a graph.
Graph.java
|
|
|
|
|
|
|
|
Graph to support removal of edges and vertices from our graph.
Graph.java
|
|
|
|
Graph interface.
|
|
|
|
|
|
|
|
|
|
|
|
Write a method to determine whether there is a path of length 2 between two vertices.
Graph.java
|
|
|
|
Suppose we relax the constraint of the path being length 2. Write a recursive method that determines whether there is an \(n\)-edge “trail”, which can possibly have repeating edges, between two vertices.
Graph.java
|
|
|
|
Implement the following method to determine whether a graph contains a cycle.
|
|
|
|