20 Graphs¶
What do social networks have in common with booking cheap flights around the world? You can represent both of these real-world models as graphs.
A graph is a data structure that captures relationships between objects. It’s made up of vertices connected by edges.
Circles in the graph below represent the vertices, and the edges are the lines that connect them.
A graph
Types of Graphs¶
Graphs come in a few different flavors. The following sections will describe their characteristics.
Weighted Graphs¶
In a weighted graph, every edge has a weight associated with it that represents the cost of using this edge. These weights let you choose the cheapest or shortest path between two vertices.
Take the airline industry as an example. Here’s a network with varying flight paths:
$50TokyoDetroitWashington, DCAustin, TexasSeattleSan FranciscoSingapore$300$600$300$500$450$250$292$277$337$218$297Hong KongA weighted graph
In this example, the vertices represent cities, while the edges represent a route from one city to another. The weight associated with each edge represents the airfare between the two cities. Using this network, you can determine the cheapest flights from San Francisco to Singapore for all those budget-minded digital nomads out there!
Directed Graphs¶
As well as assigning a weight to an edge, your graphs can also have direction. Directed graphs are more restrictive to traverse because an edge may only permit traversal in one direction. The diagram below represents a directed graph.
TokyoWashington, DCAustin, TexasSan FranciscoSingaporeHong KongA directed graph
You can tell a lot from this diagram:
- There’s a flight from Hong Kong to Tokyo.
- There’s no direct flight from San Francisco to Tokyo.
- You can buy a roundtrip ticket between Singapore and Tokyo.
- There is no way to get from Tokyo to San Francisco.
Undirected Graphs¶
You can think of an undirected graph as a directed graph where all edges are bi-directional.
In an undirected graph:
- Two connected vertices have edges going back and forth.
- The weight of an edge applies to both directions.
TokyoWashington, DCAustin, TexasSan FranciscoSingaporeHong KongAn undirected graph
Common Operations¶
There are a number of common operations that any graph needs to implement. Before you get to those, though, you need the basic building blocks, that is, the vertices and edges.
Open up the starter project for this chapter. Create a new lib folder in the root of the project, and create a file in there named graph.dart.
Defining a Vertex¶
The image below shows a collection of vertices. They’re not yet a graph:
TokyoWashington, DCDetroitSan FranciscoSingaporeHong KongUnconnected vertices
To represent those vertices, add the following class inside graph.dart:
class Vertex<T> {
const Vertex({
required this.index,
required this.data,
});
final int index;
final T data;
@override
String toString() => data.toString();
}
Here, you’ve defined a generic Vertex
class. A vertex has a unique index within its graph and holds a piece of data.
Defining an Edge¶
To connect two vertices, there must be an edge between them. These are the lines in the image below:
TokyoWashington, DCDetroitSan FranciscoSingaporeHong KongEdges added to the collection of vertices
Add an Edge
class to graph.dart:
class Edge<T> {
const Edge(
this.source,
this.destination, [
this.weight,
]);
final Vertex<T> source;
final Vertex<T> destination;
final double? weight;
}
Edge
connects two vertices and has an optional weight. Not too complicated, is it?
Defining a Graph Interface¶
Now it’s time to define the common operations that the various flavors of graphs all share.
Start by creating an EdgeType
enum and adding it to graph.dart:
enum EdgeType { directed, undirected }
This will allow you to specify whether the particular graph you’re constructing has directed or undirected edges.
Now add the following Graph
interface below EdgeType
:
abstract class Graph<E> {
Iterable<Vertex<E>> get vertices;
Vertex<E> createVertex(E data);
void addEdge(
Vertex<E> source,
Vertex<E> destination, {
EdgeType edgeType,
double? weight,
});
List<Edge<E>> edges(Vertex<E> source);
double? weight(
Vertex<E> source,
Vertex<E> destination,
);
}
This interface describes the common operations for a graph:
- vertices: Returns all of the vertices in the graph.
createVertex
: Creates a vertex and adds it to the graph.addEdge
: Connects two vertices in the graph with either a directed or undirected edge. The weight is optional.edges
: Returns a list of outgoing edges from a specific vertex.weight
: Returns the weight of the edge between two vertices.
In the following sections, you’ll implement this interface in two ways, first using what’s called an adjacency list, and second an adjacency matrix. Keep reading to find out what these terms mean.
Adjacency List¶
The first graph implementation that you’ll learn uses an adjacency list. For every vertex in the graph, the graph stores a list of outgoing edges.
Take the flight network you saw earlier as an example:
TokyoWashington, DCDetroitSan FranciscoSingaporeHong Kong
You can describe the relationship between the cities in this graph by listing out the adjacent cities for each location:
TokyoTokyoTokyoTokyoSingaporeSingaporeSan FranciscoDetroitHong KongWashington DCWashington DCHong KongSan FranciscoHong KongSingaporeHong KongTokyoDetroitWashington DCSan FranciscoVerticesAdjacency listsAn adjacency list
There’s a lot you can learn from this adjacency list:
- Singapore’s vertex has two outgoing edges, one to Tokyo and another to Hong Kong.
- Detroit has the smallest number of outgoing flights.
- Tokyo is the busiest airport, with the most outgoing flights.
In the next section, you’ll create an adjacency list by storing a map of lists. Each key in the map is a vertex, and the value is the corresponding list of edges.
Implementation¶
An adjacency list is a graph, so you need to implement the Graph
interface that you created earlier. Add the following code to graph.dart:
class AdjacencyList<E> implements Graph<E> {
final Map<Vertex<E>, List<Edge<E>>> _connections = {};
var _nextIndex = 0;
@override
Iterable<Vertex<E>> get vertices => _connections.keys;
// more to come ...
}
You’ve defined an AdjacencyList
class that uses a map to store the outgoing edges for each vertex. You’ll use _nextIndex
to assign a unique index to each new vertex. If you need the vertices, you can obtain them from the vertices
getter.
You still need to implement the various other methods of the Graph
interface. You’ll do that in the following sections.
Creating a Vertex¶
Add the missing createVertex
method to AdjacencyList
:
@override
Vertex<E> createVertex(E data) {
// 1
final vertex = Vertex(
index: _nextIndex,
data: data,
);
_nextIndex++;
// 2
_connections[vertex] = [];
return vertex;
}
Here’s what’s happening:
- You first create a new vertex with a unique index.
- Then you add the vertex as a key in the
_connections
map. You haven’t connected it to any other vertices in the graph yet, so the list of outgoing edges is empty.
Adding an Edge¶
To connect two vertices, you need to add an edge. Recall that there are directed and undirected edges:
DirectedUndirected
Every edge in an undirected graph can be traversed in both directions. So if it’s an undirected graph, you need to add two edges, one from the source to the destination and another from the destination to the source.
Add the following method to AdjacencyList
:
@override
void addEdge(
Vertex<E> source,
Vertex<E> destination, {
EdgeType edgeType = EdgeType.undirected,
double? weight,
}) {
// 1
_connections[source]?.add(
Edge(source, destination, weight),
);
// 2
if (edgeType == EdgeType.undirected) {
_connections[destination]?.add(
Edge(destination, source, weight),
);
}
}
Here’s what’s happening in the method body:
- Since
source
is a vertex, check if it exists in the_connections
map. If it does, create a new directed edge from thesource
to thedestination
. Then add it to the vertex’s list of edges. - If this is an undirected graph, then also add an edge going the other direction.
Retrieving the Outgoing Edges From a Vertex¶
Continue your work on implementing Graph
by adding the edges
method to AdjacencyList
:
@override
List<Edge<E>> edges(Vertex<E> source) {
return _connections[source] ?? [];
}
This gets the stored outgoing edges for the provided vertex. If source
is unknown, the method returns an empty list.
Retrieving the Weight of an Edge¶
Recall that the weight is the cost of going from one vertex to another. For example, if the cost of a ticket between Singapore and Tokyo is $500, the weight of this bidirectional edge is 500:
TokyoSingapore$300$250$500Hong Kong
Implement the missing weight
method in AdjacencyList
by adding the following code to the class:
@override
double? weight(
Vertex<E> source,
Vertex<E> destination,
) {
final match = edges(source).where((edge) {
return edge.destination == destination;
});
if (match.isEmpty) return null;
return match.first.weight;
}
Here, you search for an edge from source
to destination
. If it exists, you return its weight.
Making Adjacency List Printable¶
The required methods for AdjacencyList
are complete now, but it would also be nice to be able to print a description of your graph. To do that, override toString
like so:
@override
String toString() {
final result = StringBuffer();
// 1
_connections.forEach((vertex, edges) {
// 2
final destinations = edges.map((edge) {
return edge.destination;
}).join(', ');
// 3
result.writeln('$vertex --> $destinations');
});
return result.toString();
}
Here’s what’s going on in the code above:
- You loop through every key-value pair in
_connections
. - For every vertex, find all of the destinations and join them into a single, comma-separated string.
- Put each vertex and its destinations on a new line.
This will produce output with lines in the following format:
Singapore --> Hong Kong, Tokyo
You’ve finally completed your first graph! Try it out by building a network in the next section.
Building a Network¶
For this example, you’ll go back to the diagram you saw earlier and construct a network of flights with the prices as weights:
$50TokyoDetroitWashington, DCAustin, TexasSeattleSan FranciscoSingapore$300$600$300$500$450$250$292$277$337$218$297Hong Kong
Open bin/starter.dart and replace the contents of the file with the following code:
import 'package:starter/graph.dart';
void main() {
final graph = AdjacencyList<String>();
final singapore = graph.createVertex('Singapore');
final tokyo = graph.createVertex('Tokyo');
final hongKong = graph.createVertex('Hong Kong');
final detroit = graph.createVertex('Detroit');
final sanFrancisco = graph.createVertex('San Francisco');
final washingtonDC = graph.createVertex('Washington DC');
final austinTexas = graph.createVertex('Austin Texas');
final seattle = graph.createVertex('Seattle');
graph.addEdge(singapore, hongKong, weight: 300);
graph.addEdge(singapore, tokyo, weight: 500);
graph.addEdge(hongKong, tokyo, weight: 250);
graph.addEdge(tokyo, detroit, weight: 450);
graph.addEdge(tokyo, washingtonDC, weight: 300);
graph.addEdge(hongKong, sanFrancisco, weight: 600);
graph.addEdge(detroit, austinTexas, weight: 50);
graph.addEdge(austinTexas, washingtonDC, weight: 292);
graph.addEdge(sanFrancisco, washingtonDC, weight: 337);
graph.addEdge(washingtonDC, seattle, weight: 277);
graph.addEdge(sanFrancisco, seattle, weight: 218);
graph.addEdge(austinTexas, sanFrancisco, weight: 297);
print(graph);
}
Run that and you should get the following output:
Singapore --> Hong Kong, Tokyo
Tokyo --> Singapore, Hong Kong, Detroit, Washington DC
Hong Kong --> Singapore, Tokyo, San Francisco
Detroit --> Tokyo, Austin Texas
San Francisco --> Hong Kong, Washington DC, Seattle, Austin Texas
Washington DC --> Tokyo, Austin Texas, San Francisco, Seattle
Austin Texas --> Detroit, Washington DC, San Francisco
Seattle --> Washington DC, San Francisco
This output shows a visual description of an adjacency list graph. You can see all the outbound flights from any city. Pretty nice, huh?
Finding the Weight¶
You can also obtain other helpful information such as the cost of a flight from Singapore to Tokyo. This is the weight of the edge between those two vertices.
Add the following code at the bottom of the main
function:
final cost = graph.weight(singapore, tokyo)?.toInt();
print('It costs \$$cost to fly from Singapore to Tokyo.');
// It costs $500 to fly from Singapore to Tokyo.
Getting the Edges¶
Do you need to know what all the outgoing flights from San Francisco are? For that, you just call edges
.
Add the code below at the bottom of main
:
print('San Francisco Outgoing Flights: ');
print('-------------------------------- ');
for (final edge in graph.edges(sanFrancisco)) {
print('${edge.source} to ${edge.destination}');
}
Running than will display the flights:
San Francisco Outgoing Flights:
--------------------------------
San Francisco to Hong Kong
San Francisco to Washington DC
San Francisco to Seattle
San Francisco to Austin Texas
You’ve just created an adjacency list graph, which uses a map to store the outgoing edges for every vertex. In the next section you’ll learn a different approach to store vertices and edges.
Adjacency Matrix¶
An adjacency matrix uses a two-dimensional grid or table to implement the graph data structure. Each vertex has its own row and column in the table. The cells where rows and columns intersect hold the edge weights. If any particular cell is empty, that is, if the weight is null, then that means there is no edge between the row vertex and the column vertex.
Below is an example of a directed graph that depicts a flight network. As before, the weight represents the cost of the airfare:
TokyoWashington, DCSan FranciscoSingaporeHong Kong$300$500$300$600$250
You can represent that network in matrix form by giving each of the five cities a row and a column in a table. Edges that don’t exist between two cities are shown with a weight of 0 in the cells where the rows and columns intersect:
SingaporeHong KongTokyoWashington DCSan FranciscoVertices01234DestinationSource00000$300000$250000$300$500$500$300000000$60000011223344
Notes:
- As you recall, every vertex in a graph has its own index. These indices are used to label the rows and columns in the table.
- Read the row number as the source vertex and the column number as the destination vertex.
- There’s a red line going down the middle of the matrix. When the row and column are equal, this represents an edge between a vertex and itself, which isn’t allowed. You can’t fly from Singapore to Singapore, right?
Here are a few examples of data points that you can read from the table above:
[0][1]
is 300, so there is a flight from Singapore to Hong Kong for $300.[2][1]
is 0, so there’s no flight from Tokyo to Hong Kong.[1][2]
is 250, so there is a flight from Hong Kong to Tokyo for $250.[2][2]
is 0 because there’s no flight from Tokyo to Tokyo!
You’ll implement an adjacency matrix graph next.
Implementation¶
Add a new class to graph.dart called AdjacencyMatrix
:
class AdjacencyMatrix<E> implements Graph<E> {
final List<Vertex<E>> _vertices = [];
final List<List<double?>?> _weights = [];
var _nextIndex = 0;
@override
Iterable<Vertex<E>> get vertices => _vertices;
// more to come ...
}
In AdjacencyList
you used a map to store the vertices and edges. Here, though, you store the vertices in a list. You don’t use Edge
to store edges but rather a two-dimensional list of weights.
You’ve declared that you’re implementing Graph
, and so far you’ve only finished vertices
. The following sections will help you add the other missing methods.
Creating a Vertex¶
For every new vertex that you create, you have to add an additional column and row to the matrix.
The first step is to create a new column by adding an additional empty destination at the end of every row. The destination is empty because no other vertices have created an edge to the new vertex yet. In the following diagram you can see a new column 5 filled with empty weights:
Destination+Source00000$30000000$250000000$300$500$500$300000000$600000112233454
The next step is to add an additional row representing a new source vertex. The weights for this, too, are empty since you haven’t yet added any edges. Row 5 in the image below shows the newly added source row:
DestinationSource000000000000$3000000$2500000$300$5000$500$3000000000$60000011223345+45
To implement the description above, add the createVertex
method to AdjacencyMatrix
:
@override
Vertex<E> createVertex(E data) {
// 1
final vertex = Vertex(
index: _nextIndex,
data: data,
);
_nextIndex++;
_vertices.add(vertex);
// 2
for (var i = 0; i < _weights.length; i++) {
_weights[i]?.add(null);
}
// 3
final row = List<double?>.filled(
_vertices.length,
null,
growable: true,
);
_weights.add(row);
return vertex;
}
To create a vertex in an adjacency matrix, you perform the following tasks:
- Add a new vertex to the list.
- Append a
null
value at the end of every row. This in effect creates a new destination column in the matrix. - Add a new row to the matrix, again filled with
null
weight values.
Adding Edges¶
Creating edges is as simple as adding weights to the matrix. There’s no Edge
class to worry about.
Add the missing addEdge
method to AdjacencyMatrix
:
@override
void addEdge(
Vertex<E> source,
Vertex<E> destination, {
EdgeType edgeType = EdgeType.undirected,
double? weight,
}) {
// 1
_weights[source.index]?[destination.index] = weight;
// 2
if (edgeType == EdgeType.undirected) {
_weights[destination.index]?[source.index] = weight;
}
}
The logic here is similar to how you implemented addEdge
in AdjacencyList
previously:
- Always add a directed edge.
- If the edge type for the graph is undirected, then also add another edge going from the destination to the source.
Retrieving the Outgoing Edges From a Vertex¶
Add the edges
method to AdjacencyMatrix
:
@override
List<Edge<E>> edges(Vertex<E> source) {
List<Edge<E>> edges = [];
// 1
for (var column = 0; column < _weights.length; column++) {
final weight = _weights[source.index]?[column];
// 2
if (weight == null) continue;
// 3
final destination = _vertices[column];
edges.add(Edge(source, destination, weight));
}
return edges;
}
Remember that the columns represent destinations and that a source is a row:
- To find all the edges for some source, you loop through all the values in a row.
- Check for weights that aren’t
null
. Every non-null weight implies an outgoing edge. - Use the column to look up the destination vertex.
Retrieving the Weight of an Edge¶
It’s easy to get the weight of an edge. Simply look up the value in the adjacency matrix.
Implement weight
like so:
@override
double? weight(Vertex<E> source, Vertex<E> destination) {
return _weights[source.index]?[destination.index];
}
Making an Adjacency Matrix Printable¶
Finally, override toString
so you can print out a readable description of your graph:
@override
String toString() {
final output = StringBuffer();
// 1
for (final vertex in _vertices) {
output.writeln('${vertex.index}: ${vertex.data}');
}
// 2
for (int i = 0; i < _weights.length; i++) {
for (int j = 0; j < _weights.length; j++) {
final value = (_weights[i]?[j] ?? '.').toString();
output.write(value.padRight(6));
}
output.writeln();
}
return output.toString();
}
Here are the steps:
- You first create a list of the vertices.
- Then you build up a grid of weights, row by row.
Building a Network¶
You will reuse the same example from AdjacencyList
:
$50TokyoDetroitWashington, DCAustin, TexasSeattleSan FranciscoSingapore$300$600$300$500$450$250$292$277$337$218$297Hong Kong
Go back to the main
method in bin/starter.dart and replace this:
final graph = AdjacencyList<String>();
with the following:
final graph = AdjacencyMatrix<String>();
AdjacencyMatrix
and AdjacencyList
conform to the same Graph
interface, so the rest of the code stays the same.
Run the code. The print(graph)
portion of the code should give the following output:
0: Singapore
1: Tokyo
2: Hong Kong
3: Detroit
4: San Francisco
5: Washington DC
6: Austin Texas
7: Seattle
. 500.0 300.0 . . . . .
500.0 . 250.0 450.0 . 300.0 . .
300.0 250.0 . . 600.0 . . .
. 450.0 . . . . 50.0 .
. . 600.0 . . 337.0 297.0 218.0
. 300.0 . . 337.0 . 292.0 277.0
. . . 50.0 297.0 292.0 . .
. . . . 218.0 277.0 . .
Graph Analysis¶
This chart compares the cost of different graph operations for adjacency lists and adjacency matrices. V represents the number of vertices, and E represents the number of edges.
OperationsStorage SpaceAdd VertexAdd EdgeFinding Edges and Weight0(V + E)0(1)0(1)0(V)0(V )220(V )0(1)0(1)Adjacency ListAdjacency Matrix
An adjacency list takes less storage space than an adjacency matrix. An adjacency list simply stores the number of vertices and edges needed. As for an adjacency matrix, recall that the number of rows and columns equals the number of vertices. This explains the quadratic space complexity of O(V²).
Adding a vertex is efficient in an adjacency list: Simply create a vertex and set its key-value pair in the map. It’s amortized as O(1). When adding a vertex to an adjacency matrix, you must add a column to every row and create a new row for the new vertex. This is at least O(V), and if you choose to represent your matrix with a contiguous block of memory, it can be O(V²).
Adding an edge is efficient in both data structures since they are both constant time. The adjacency list appends to the list of outgoing edges. The adjacency matrix simply sets a value in the two-dimensional list.
Adjacency list loses out when trying to find a particular edge or weight. To find an edge in an adjacency list, you have to obtain the list of outgoing edges and loop through every edge to find a matching destination. This happens in O(V) time. With an adjacency matrix, finding an edge or weight is a constant-time lookup in the two-dimensional list.
So which data structure should you choose to construct your graph?
If there are few edges in your graph, it’s considered a sparse graph, and an adjacency list would be a good fit. An adjacency matrix would be a bad choice for a sparse graph because a lot of memory would be wasted since there aren’t many edges.
If your graph has lots of edges, it’s considered a dense graph, and an adjacency matrix would be a better fit since you’d be able to access your weights and edges far more quickly.
Note
A dense graph in which every vertex has an edge to every other vertex is called a complete graph.
In the next few chapters you’ll learn different algorithms for visiting the nodes of a graph.
Challenges¶
Here’s a challenge for you to apply your newfound knowledge of graphs. You can find the answer in the Challenge Solutions section as well as in the supplemental materials that accompany this book.
Challenge 1: Graph Your Friends¶
Megan has three friends: Sandra, Pablo and Edith. Pablo has friends as well: Ray, Luke, and a mutual friend of Megan’s. Edith is friends with Manda and Vicki. Manda is friends with Pablo and Megan. Create an adjacency list that represents this friendship graph. Which mutual friend do Pablo and Megan share?
Key Points¶
- You can represent real-world relationships through vertices and edges.
- Think of vertices as objects and edges as the relationships between the objects.
- Weighted graphs associate a number with every edge.
- Directed graphs have edges that traverse in one direction.
- Undirected graphs have edges that point both ways.
- An adjacency list is a graph that stores a list of outgoing edges for every vertex.
- An adjacency matrix uses a two-dimensional list to represent a graph.
- An adjacency list is generally good for sparse graphs, which have a low number of edges.
- An adjacency matrix is generally suitable for dense graphs, which have lots of edges.