Practical 19: Graph Representations and Traversals
Chapter Twenty-Eight
Syllabus topic Module 2, "Graph Representations and Traversals: Represent graphs using adjacency matrices and lists. Implement BFS and DFS to explore graph components. Use graphs for mapping routes or exploring social networks."
Pages 258 to 271 of 300
Aim
To represent a graph as an adjacency matrix and as an adjacency list, to insert and delete vertices and edges, to implement breadth-first and depth-first search, to find the components, and to use a graph for routes and for a social network.
What you need to know before you start
A graph is a set of vertices and a set of edges, each edge joining two vertices. That is all, and it is why graphs model so much: stations and lines, people and friendships, web pages and links, tasks and their prerequisites.
| Word | Meaning |
|---|---|
| Vertex, or node | one of the things |
| Edge | a connection between two vertices |
| Adjacent, or neighbours | two vertices joined by an edge |
| Degree of a vertex | how many edges touch it |
| Path | a sequence of vertices each joined to the next |
| Cycle | a path that returns to where it started |
| Connected | there is a path between every pair of vertices |
| Component | a maximal piece that is connected |
| Directed | the edges have a direction, so A to B is not B to A |
| Weighted | the edges carry a number, such as a distance |
The graph in this chapter is undirected and unweighted, which is what MU's exercise asks for and what a railway map or a friendship network is.
A tree is a graph. A tree is exactly a connected graph with no cycle, which is why [Practical 17: Binary Search Trees and Tree Traversals] is a special case of this chapter and why level-order traversal turns out to be breadth-first search.
The handshaking lemma, which is a free check
In an undirected graph, the sum of all the degrees is twice the number of edges, because every edge contributes 1 to each of its two ends. The last lines of the third program check it: 9 friendships and the degrees add to 18.
That is the cheapest possible test on an adjacency list, and it belongs in the journal: if your degrees do not sum to twice your edges, you have added an edge in one direction only.
The two representations
from collections import deque
class GraphMatrix:
"""An adjacency MATRIX: a square grid of 0 and 1."""
def __init__(self, labels):
self.labels = list(labels)
self.index = {name: i for i, name in enumerate(self.labels)}
n = len(self.labels)
self.m = [[0] * n for _ in range(n)]
def add_edge(self, a, b):
i, j = self.index[a], self.index[b]
self.m[i][j] = self.m[j][i] = 1 # undirected: both cells
def remove_edge(self, a, b):
i, j = self.index[a], self.index[b]
self.m[i][j] = self.m[j][i] = 0
def has_edge(self, a, b):
"""ONE lookup, whatever the size of the graph."""
return self.m[self.index[a]][self.index[b]] == 1
def neighbours(self, a):
i = self.index[a]
return [self.labels[j] for j in range(len(self.labels)) if self.m[i][j]]
def degree(self, a):
return sum(self.m[self.index[a]])
def cells(self):
return len(self.labels) ** 2
def show(self):
head = " " + " ".join(f"{x:>3}" for x in self.labels)
print(head)
for name, row in zip(self.labels, self.m):
print(f" {name:>3} " + " ".join(f"{v:>3}" for v in row))
class GraphList:
"""An adjacency LIST: for each vertex, the vertices it joins."""
def __init__(self, labels=()):
self.adj = {name: [] for name in labels}
def add_vertex(self, a):
self.adj.setdefault(a, [])
def add_edge(self, a, b):
self.add_vertex(a)
self.add_vertex(b)
if b not in self.adj[a]:
self.adj[a].append(b)
self.adj[b].append(a)
def remove_edge(self, a, b):
if b in self.adj.get(a, []):
self.adj[a].remove(b)
self.adj[b].remove(a)
def remove_vertex(self, a):
"""A matrix cannot do this without rebuilding itself."""
for other in self.adj.pop(a, []):
self.adj[other].remove(a)
def has_edge(self, a, b):
"""A SCAN of a's neighbours, so it costs the degree of a."""
return b in self.adj.get(a, [])
def neighbours(self, a):
return list(self.adj.get(a, []))
def degree(self, a):
return len(self.adj.get(a, []))
def entries(self):
return sum(len(v) for v in self.adj.values())
def show(self):
for name in self.adj:
print(f" {name:>3} -> {', '.join(self.adj[name]) or '(none)'}")
# three-letter codes, so that the matrix fits on a page:
# CST Chhatrapati Shivaji Terminus, BYC Byculla, DDR Dadar, KUR Kurla,
# THN Thane, BAN Bandra, AND Andheri, BOR Borivali, PNV Panvel, VSH Vashi
STATIONS = ["CST", "BYC", "DDR", "KUR", "THN", "BAN",
"AND", "BOR", "PNV", "VSH"]
EDGES = [("CST", "BYC"), ("BYC", "DDR"), ("DDR", "KUR"),
("KUR", "THN"), ("DDR", "BAN"), ("BAN", "AND"),
("AND", "BOR"), ("KUR", "AND"),
("PNV", "VSH")]
print("a graph of ten stations and nine lines between them")
print()
print("as an adjacency matrix, 1 where there is a line")
mat = GraphMatrix(STATIONS)
for a, b in EDGES:
mat.add_edge(a, b)
mat.show()
print()
print("as an adjacency list")
lst = GraphList(STATIONS)
for a, b in EDGES:
lst.add_edge(a, b)
lst.show()
print()
print("the same questions of both")
for a, b in (("DDR", "KUR"), ("CST", "THN")):
print(f" is there a line from {a} to {b}? matrix {mat.has_edge(a, b)}"
f" list {lst.has_edge(a, b)}")
for a in ("DDR", "PNV"):
print(f" {a:<8} degree {mat.degree(a)}, neighbours {mat.neighbours(a)}")
print(" the two agree everywhere:",
all(mat.neighbours(x) == sorted(lst.neighbours(x), key=STATIONS.index)
for x in STATIONS))
print()
print("what each one costs to store, for this graph")
v, e = len(STATIONS), len(EDGES)
print(f" vertices {v}, edges {e}")
print(f" matrix: {v} x {v} = {mat.cells()} cells, and {2 * e} of them are 1")
print(f" list : {lst.entries()} entries, which is 2 per edge")
print(f" the matrix is {mat.cells() / lst.entries():.1f} times the size here,")
print(" and this is a SPARSE graph: most pairs of stations have no line.")Practical 19: Graph Representations and Traversals
a graph of ten stations and nine lines between them
as an adjacency matrix, 1 where there is a line
CST BYC DDR KUR THN BAN AND BOR PNV VSH
CST 0 1 0 0 0 0 0 0 0 0
BYC 1 0 1 0 0 0 0 0 0 0
DDR 0 1 0 1 0 1 0 0 0 0
KUR 0 0 1 0 1 0 1 0 0 0
THN 0 0 0 1 0 0 0 0 0 0
BAN 0 0 1 0 0 0 1 0 0 0
AND 0 0 0 1 0 1 0 1 0 0
BOR 0 0 0 0 0 0 1 0 0 0
PNV 0 0 0 0 0 0 0 0 0 1
VSH 0 0 0 0 0 0 0 0 1 0
as an adjacency list
CST -> BYC
BYC -> CST, DDR
DDR -> BYC, KUR, BAN
KUR -> DDR, THN, AND
THN -> KUR
BAN -> DDR, AND
AND -> BAN, BOR, KUR
BOR -> AND
PNV -> VSH
VSH -> PNV
the same questions of both
is there a line from DDR to KUR? matrix True list True
is there a line from CST to THN? matrix False list False
DDR degree 3, neighbours ['BYC', 'KUR', 'BAN']
PNV degree 1, neighbours ['VSH']
the two agree everywhere: True
what each one costs to store, for this graph
vertices 10, edges 9
matrix: 10 x 10 = 100 cells, and 18 of them are 1
list : 18 entries, which is 2 per edge
the matrix is 5.6 times the size here,
and this is a SPARSE graph: most pairs of stations have no line.Practical 19: Graph Representations and Traversals
The adjacency matrix
A square grid with one row and one column per vertex, holding 1 where there is an edge.
For an undirected graph the matrix is symmetric: m[i][j] and m[j][i] are both set, which is the two lines in add_edge. A program that sets only one has built a directed graph by accident, and the symptom is that a route works in one direction and not the other.
Its strength is one question, answered instantly. "Is there an edge from A to B" is one array lookup, O(1), whatever the size of the graph.
Its weakness is everything else. It always occupies n squared cells whether they are used or not, listing a vertex's neighbours means scanning a whole row of n cells, and adding a vertex means building a bigger grid and copying.
The adjacency list
For each vertex, the list of vertices it joins. Here it is a dictionary from a name to a list of names, which is the usual Python form.
Its strength is that it stores only what exists. Nine edges give eighteen entries, two per edge, and listing a vertex's neighbours is reading its list. Adding a vertex is adding one empty list.
Its weakness is the one question the matrix answers instantly. "Is there an edge from A to B" is a scan of A's neighbours, so it costs the degree of A.
Practical 19: Graph Representations and Traversals
The comparison, which is the answer to MU's first bullet
V is the number of vertices, E the number of edges.
| Adjacency matrix | Adjacency list | |
|---|---|---|
| Space | O(V squared), always | O(V + E) |
| Is there an edge A to B | O(1) | O(degree of A) |
| List A's neighbours | O(V), a whole row | O(degree of A) |
| Add an edge | O(1) | O(1) |
| Remove an edge | O(1) | O(degree) |
| Add a vertex | O(V squared), rebuild | O(1) |
| Remove a vertex | rebuild the grid | O(degree) |
| Iterate over every edge | O(V squared) | O(V + E) |
| Best for | dense graphs, and edge tests | sparse graphs, which is nearly all of them |
Nearly every real graph is sparse. Ten stations here have nine lines between them, not forty-five. The matrix needed 100 cells to hold 18 ones, which is 5.6 times the list's size, and that ratio grows with the graph: a million web pages with ten links each is 10 million list entries and a million million matrix cells.
The rule: matrix for a dense graph or when you ask about single edges constantly; list for everything else. A social network, a road map, a web crawl and a dependency graph are all lists.
Breadth first and depth first
The two traversals are the same program with a different container, and that is the single most useful thing to know about them.
from collections import deque
class GraphMatrix:
"""An adjacency MATRIX: a square grid of 0 and 1."""
def __init__(self, labels):
self.labels = list(labels)
self.index = {name: i for i, name in enumerate(self.labels)}
n = len(self.labels)
self.m = [[0] * n for _ in range(n)]
def add_edge(self, a, b):
i, j = self.index[a], self.index[b]
self.m[i][j] = self.m[j][i] = 1 # undirected: both cells
def remove_edge(self, a, b):
i, j = self.index[a], self.index[b]
self.m[i][j] = self.m[j][i] = 0
def has_edge(self, a, b):
"""ONE lookup, whatever the size of the graph."""
return self.m[self.index[a]][self.index[b]] == 1
def neighbours(self, a):
i = self.index[a]
return [self.labels[j] for j in range(len(self.labels)) if self.m[i][j]]
def degree(self, a):
return sum(self.m[self.index[a]])
def cells(self):
return len(self.labels) ** 2
def show(self):
head = " " + " ".join(f"{x:>3}" for x in self.labels)
print(head)
for name, row in zip(self.labels, self.m):
print(f" {name:>3} " + " ".join(f"{v:>3}" for v in row))
class GraphList:
"""An adjacency LIST: for each vertex, the vertices it joins."""
def __init__(self, labels=()):
self.adj = {name: [] for name in labels}
def add_vertex(self, a):
self.adj.setdefault(a, [])
def add_edge(self, a, b):
self.add_vertex(a)
self.add_vertex(b)
if b not in self.adj[a]:
self.adj[a].append(b)
self.adj[b].append(a)
def remove_edge(self, a, b):
if b in self.adj.get(a, []):
self.adj[a].remove(b)
self.adj[b].remove(a)
def remove_vertex(self, a):
"""A matrix cannot do this without rebuilding itself."""
for other in self.adj.pop(a, []):
self.adj[other].remove(a)
def has_edge(self, a, b):
"""A SCAN of a's neighbours, so it costs the degree of a."""
return b in self.adj.get(a, [])
def neighbours(self, a):
return list(self.adj.get(a, []))
def degree(self, a):
return len(self.adj.get(a, []))
def entries(self):
return sum(len(v) for v in self.adj.values())
def show(self):
for name in self.adj:
print(f" {name:>3} -> {', '.join(self.adj[name]) or '(none)'}")
# three-letter codes, so that the matrix fits on a page:
# CST Chhatrapati Shivaji Terminus, BYC Byculla, DDR Dadar, KUR Kurla,
# THN Thane, BAN Bandra, AND Andheri, BOR Borivali, PNV Panvel, VSH Vashi
STATIONS = ["CST", "BYC", "DDR", "KUR", "THN", "BAN",
"AND", "BOR", "PNV", "VSH"]
EDGES = [("CST", "BYC"), ("BYC", "DDR"), ("DDR", "KUR"),
("KUR", "THN"), ("DDR", "BAN"), ("BAN", "AND"),
("AND", "BOR"), ("KUR", "AND"),
("PNV", "VSH")]
def bfs(g, start, trace=False):
"""Breadth first: a QUEUE. Visits everything at distance 1, then 2, ..."""
seen = {start}
order = []
q = deque([start])
if trace:
print(f" {'take':<6}{'new neighbours':<22}queue after")
while q:
here = q.popleft()
order.append(here)
added = []
for n in g.neighbours(here):
if n not in seen:
seen.add(n)
q.append(n)
added.append(n)
if trace:
print(f" {here:<6}{', '.join(added) or '-':<22}{list(q)}")
return order
def dfs_iterative(g, start, trace=False):
"""Depth first: the same code with a STACK instead of a queue."""
seen = {start}
order = []
stack = [start]
if trace:
print(f" {'take':<6}{'new neighbours':<22}stack after")
while stack:
here = stack.pop()
order.append(here)
added = []
for n in g.neighbours(here):
if n not in seen:
seen.add(n)
stack.append(n)
added.append(n)
if trace:
print(f" {here:<6}{', '.join(added) or '-':<22}{stack}")
return order
def dfs_recursive(g, start, seen=None, order=None):
if seen is None:
seen, order = {start}, []
order.append(start)
for n in g.neighbours(start):
if n not in seen:
seen.add(n)
dfs_recursive(g, n, seen, order)
return order
def components(g, labels):
"""Every part of the graph that is joined up."""
seen, out = set(), []
for v in labels:
if v not in seen:
part = bfs(g, v)
seen.update(part)
out.append(part)
return out
def shortest_path(g, start, goal):
"""BFS again, remembering where each vertex was reached FROM.
On an unweighted graph this gives the fewest hops."""
if start == goal:
return [start]
came_from = {start: None}
q = deque([start])
while q:
here = q.popleft()
for n in g.neighbours(here):
if n in came_from:
continue
came_from[n] = here
if n == goal:
path = [goal]
while came_from[path[-1]] is not None:
path.append(came_from[path[-1]])
return list(reversed(path))
q.append(n)
return None
g = GraphList(STATIONS)
for a, b in EDGES:
g.add_edge(a, b)
print("breadth first from CST, step by step")
order = bfs(g, "CST", trace=True)
print(" visited:", order)
print()
print("depth first from CST, the SAME code with a stack")
order2 = dfs_iterative(g, "CST", trace=True)
print(" visited:", order2)
print()
print("depth first again, recursively:", dfs_recursive(g, "CST"))
print(" the recursive and iterative walks differ, and both are correct DFS:")
print(" the stack reverses the order the neighbours are pushed in.")
print()
print("the graph is not all joined up")
for part in components(g, STATIONS):
print(" a component:", part)
print(" two components, so no route exists between them at all")
print()
print("fewest hops, which is what BFS gives on an unweighted graph")
for a, b in (("CST", "BOR"), ("CST", "THN"), ("BYC", "AND"), ("CST", "PNV")):
p = shortest_path(g, a, b)
if p is None:
print(f" {a} to {b}: no route")
else:
print(f" {a} to {b}: {' -> '.join(p)} ({len(p) - 1} hops)")
print()
print("and why DFS is the wrong tool for that question")
path = dfs_recursive(g, "CST")
print(" a depth-first walk from CST visits", path)
print(" BOR is reached, but not by the shortest route: DFS follows one branch")
print(" to its end before trying another, so the path it finds may be long.")Practical 19: Graph Representations and Traversals
breadth first from CST, step by step
take new neighbours queue after
CST BYC ['BYC']
BYC DDR ['DDR']
DDR KUR, BAN ['KUR', 'BAN']
KUR THN, AND ['BAN', 'THN', 'AND']
BAN - ['THN', 'AND']
THN - ['AND']
AND BOR ['BOR']
BOR - []
visited: ['CST', 'BYC', 'DDR', 'KUR', 'BAN', 'THN', 'AND', 'BOR']
depth first from CST, the SAME code with a stack
take new neighbours stack after
CST BYC ['BYC']
BYC DDR ['DDR']
DDR KUR, BAN ['KUR', 'BAN']
BAN AND ['KUR', 'AND']
AND BOR ['KUR', 'BOR']
BOR - ['KUR']
KUR THN ['THN']
THN - []
visited: ['CST', 'BYC', 'DDR', 'BAN', 'AND', 'BOR', 'KUR', 'THN']
depth first again, recursively: ['CST', 'BYC', 'DDR', 'KUR', 'THN', 'AND', 'BAN', 'BOR']
the recursive and iterative walks differ, and both are correct DFS:
the stack reverses the order the neighbours are pushed in.
the graph is not all joined up
a component: ['CST', 'BYC', 'DDR', 'KUR', 'BAN', 'THN', 'AND', 'BOR']
a component: ['PNV', 'VSH']
two components, so no route exists between them at all
fewest hops, which is what BFS gives on an unweighted graph
CST to BOR: CST -> BYC -> DDR -> KUR -> AND -> BOR (5 hops)
CST to THN: CST -> BYC -> DDR -> KUR -> THN (4 hops)
BYC to AND: BYC -> DDR -> KUR -> AND (3 hops)
CST to PNV: no route
and why DFS is the wrong tool for that question
a depth-first walk from CST visits ['CST', 'BYC', 'DDR', 'KUR', 'THN', 'AND', 'BAN', 'BOR']
BOR is reached, but not by the shortest route: DFS follows one branch
to its end before trying another, so the path it finds may be long.Practical 19: Graph Representations and Traversals
The difference is the queue against the stack
Put the two loops side by side:
here = q.popleft() # breadth first: a QUEUE, first in first out
here = stack.pop() # depth first: a STACK, last in first outPractical 19: Graph Representations and Traversals
Everything else is identical. And the behaviour that follows is exactly what the two disciplines mean:
- Breadth first takes the oldest waiting vertex, so it finishes everything one hop away before
it looks at anything two hops away. It spreads out in rings.
- Depth first takes the newest, so it follows one branch as far as it goes before coming back.
It plunges.
Read the two traces. BFS from CST visits CST, BYC, DDR, then KUR and BAN together, which are both three hops out. DFS visits CST, BYC, DDR, then dives BAN, AND, BOR to the end of that branch, and only then comes back for KUR and THN.
| Breadth first | Depth first | |
|---|---|---|
| Container | queue | stack, or recursion |
| Visits | nearest first, in rings | one branch to its end |
| Finds the shortest path | yes, on an unweighted graph | no |
| Memory | the whole frontier, which can be wide | the current path, which is at most V |
| Good for | fewest hops, levels, nearest anything | cycles, connectivity, topological order, mazes |
| In a tree | level-order traversal | pre-order traversal |
Three things the run shows
The recursive and iterative depth-first walks differ. Recursive gives CST, BYC, DDR, KUR, THN, AND, BAN, BOR; the stack version gives CST, BYC, DDR, BAN, AND, BOR, KUR, THN. Both are correct depth-first traversals, and the reason they differ is that a stack reverses the order the neighbours were pushed in, so the stack version takes the last neighbour first. An examiner asking for "the" DFS order should say which; if not, say which convention you used.
seen is checked when a vertex is pushed, not when it is taken. That is why no vertex appears twice in the queue or the stack. Marking on removal instead lets a vertex be queued several times before it is first taken, which still gives the right answer and wastes memory; on a graph with cycles, forgetting to mark at all never terminates.
The graph has two components. CST's component has eight stations and Panvel and Vashi are a separate pair, so there is no route between them at all. Finding the components is BFS from every vertex not yet seen, which is what components does, and it is the standard way to answer "is this graph connected".
The shortest path, and why it has to be BFS
shortest_path is BFS with one addition: a dictionary remembering which vertex each one was reached from. When the goal is reached, following those back gives the path.
It gives the fewest hops, and the reason is the ring behaviour: BFS reaches every vertex at distance 1 before any at distance 2, so the first time it reaches the goal it has come by a shortest route. Depth-first search has no such property, and the last lines of the run make the point: a depth-first walk from CST does reach BOR, but by whatever branch it happened to dive down.
Practical 19: Graph Representations and Traversals
This is only true on an unweighted graph. Add distances to the edges and the fewest hops is no longer the shortest journey, and the answer becomes Dijkstra's algorithm, which is BFS with a priority queue instead of a plain queue, taking the nearest unvisited vertex rather than the oldest. That is the priority queue of [Practical 18 continued: Heaps and Priority Queues], and the connection is worth stating: Dijkstra is BFS with the heap swapped in for the queue, exactly as DFS is BFS with a stack swapped in.
MU's application: a social network
MU names route mapping and social networks; the routes are above and this is the other.
from collections import deque
class GraphMatrix:
"""An adjacency MATRIX: a square grid of 0 and 1."""
def __init__(self, labels):
self.labels = list(labels)
self.index = {name: i for i, name in enumerate(self.labels)}
n = len(self.labels)
self.m = [[0] * n for _ in range(n)]
def add_edge(self, a, b):
i, j = self.index[a], self.index[b]
self.m[i][j] = self.m[j][i] = 1 # undirected: both cells
def remove_edge(self, a, b):
i, j = self.index[a], self.index[b]
self.m[i][j] = self.m[j][i] = 0
def has_edge(self, a, b):
"""ONE lookup, whatever the size of the graph."""
return self.m[self.index[a]][self.index[b]] == 1
def neighbours(self, a):
i = self.index[a]
return [self.labels[j] for j in range(len(self.labels)) if self.m[i][j]]
def degree(self, a):
return sum(self.m[self.index[a]])
def cells(self):
return len(self.labels) ** 2
def show(self):
head = " " + " ".join(f"{x:>3}" for x in self.labels)
print(head)
for name, row in zip(self.labels, self.m):
print(f" {name:>3} " + " ".join(f"{v:>3}" for v in row))
class GraphList:
"""An adjacency LIST: for each vertex, the vertices it joins."""
def __init__(self, labels=()):
self.adj = {name: [] for name in labels}
def add_vertex(self, a):
self.adj.setdefault(a, [])
def add_edge(self, a, b):
self.add_vertex(a)
self.add_vertex(b)
if b not in self.adj[a]:
self.adj[a].append(b)
self.adj[b].append(a)
def remove_edge(self, a, b):
if b in self.adj.get(a, []):
self.adj[a].remove(b)
self.adj[b].remove(a)
def remove_vertex(self, a):
"""A matrix cannot do this without rebuilding itself."""
for other in self.adj.pop(a, []):
self.adj[other].remove(a)
def has_edge(self, a, b):
"""A SCAN of a's neighbours, so it costs the degree of a."""
return b in self.adj.get(a, [])
def neighbours(self, a):
return list(self.adj.get(a, []))
def degree(self, a):
return len(self.adj.get(a, []))
def entries(self):
return sum(len(v) for v in self.adj.values())
def show(self):
for name in self.adj:
print(f" {name:>3} -> {', '.join(self.adj[name]) or '(none)'}")
# three-letter codes, so that the matrix fits on a page:
# CST Chhatrapati Shivaji Terminus, BYC Byculla, DDR Dadar, KUR Kurla,
# THN Thane, BAN Bandra, AND Andheri, BOR Borivali, PNV Panvel, VSH Vashi
STATIONS = ["CST", "BYC", "DDR", "KUR", "THN", "BAN",
"AND", "BOR", "PNV", "VSH"]
FRIENDSHIPS = [("Aarti", "Bhavesh"), ("Aarti", "Chetna"),
("Bhavesh", "Devang"), ("Chetna", "Devang"),
("Chetna", "Esha"), ("Devang", "Farhan"),
("Esha", "Farhan"), ("Farhan", "Gauri"),
("Hemant", "Ishita")]
PEOPLE = ["Aarti", "Bhavesh", "Chetna", "Devang", "Esha",
"Farhan", "Gauri", "Hemant", "Ishita"]
def distances(g, start):
"""Every vertex, and how many hops away it is. BFS, counting levels."""
far = {start: 0}
q = deque([start])
while q:
here = q.popleft()
for n in g.neighbours(here):
if n not in far:
far[n] = far[here] + 1
q.append(n)
return far
net = GraphList(PEOPLE)
for a, b in FRIENDSHIPS:
net.add_edge(a, b)
print("a small social network")
net.show()
print()
me = "Aarti"
far = distances(net, me)
print(f"how far everybody is from {me}")
by_level = {}
for who, d in far.items():
by_level.setdefault(d, []).append(who)
for d in sorted(by_level):
label = {0: "herself", 1: "friends", 2: "friends of friends"}.get(
d, f"{d} hops away")
print(f" {d} hop(s), {label:<20}{', '.join(sorted(by_level[d]))}")
unreachable = [p for p in PEOPLE if p not in far]
print(f" not connected at all {', '.join(unreachable)}")
print()
print(f"people to suggest to {me}: at distance 2, so friends of friends")
print(" and not already friends")
suggestions = sorted(w for w, d in far.items() if d == 2)
print(" suggestions:", suggestions)
for who in suggestions:
shared = sorted(set(net.neighbours(who)) & set(net.neighbours(me)))
print(f" {who:<9} through {', '.join(shared)}")
print()
print("mutual friends of any two people")
for a, b in (("Aarti", "Devang"), ("Bhavesh", "Chetna"), ("Aarti", "Gauri")):
shared = sorted(set(net.neighbours(a)) & set(net.neighbours(b)))
print(f" {a} and {b}: {', '.join(shared) or 'none'}")
print()
print("the degree of each person, which is how many friends they have")
for who in sorted(PEOPLE, key=lambda p: (-net.degree(p), p)):
print(f" {who:<9}{net.degree(who)}")
print(" the sum of all the degrees is", sum(net.degree(p) for p in PEOPLE),
"which is twice the", len(FRIENDSHIPS), "friendships.")
print(" That is the handshaking lemma, and it is a useful check on any")
print(" adjacency list: the entries must come to twice the edges.")Practical 19: Graph Representations and Traversals
a small social network
Aarti -> Bhavesh, Chetna
Bhavesh -> Aarti, Devang
Chetna -> Aarti, Devang, Esha
Devang -> Bhavesh, Chetna, Farhan
Esha -> Chetna, Farhan
Farhan -> Devang, Esha, Gauri
Gauri -> Farhan
Hemant -> Ishita
Ishita -> Hemant
how far everybody is from Aarti
0 hop(s), herself Aarti
1 hop(s), friends Bhavesh, Chetna
2 hop(s), friends of friends Devang, Esha
3 hop(s), 3 hops away Farhan
4 hop(s), 4 hops away Gauri
not connected at all Hemant, Ishita
people to suggest to Aarti: at distance 2, so friends of friends
and not already friends
suggestions: ['Devang', 'Esha']
Devang through Bhavesh, Chetna
Esha through Chetna
mutual friends of any two people
Aarti and Devang: Bhavesh, Chetna
Bhavesh and Chetna: Aarti, Devang
Aarti and Gauri: none
the degree of each person, which is how many friends they have
Chetna 3
Devang 3
Farhan 3
Aarti 2
Bhavesh 2
Esha 2
Gauri 1
Hemant 1
Ishita 1
the sum of all the degrees is 18 which is twice the 9 friendships.
That is the handshaking lemma, and it is a useful check on any
adjacency list: the entries must come to twice the edges.Practical 19: Graph Representations and Traversals
Distance is the whole idea
A breadth-first search from one person, counting levels, gives everything a social network shows you:
| Distance | What it means | Here, from Aarti |
|---|---|---|
| 0 | yourself | Aarti |
| 1 | your friends | Bhavesh, Chetna |
| 2 | friends of friends, the suggestions | Devang, Esha |
| 3 and more | the rest of your component | Farhan, then Gauri |
| unreachable | a different component | Hemant, Ishita |
People to suggest are exactly those at distance 2. Distance 1 is already a friend, and distance 3 is too far to be interesting; distance 2 means you have a friend in common and are not yet connected, which is precisely what "people you may know" means. And the program says through whom, by intersecting the two neighbour sets, which is what a real network shows beside the suggestion.
Mutual friends are a set intersection, one line, because an adjacency list gives a vertex's neighbours directly. That operation is why a social network is stored as an adjacency list and not as a matrix: a matrix would need a scan of two whole rows.
Hemant and Ishita are unreachable. They are a separate component, so no number of hops connects them to Aarti. A network with several components is the normal state of affairs, and a program that assumes the graph is connected reports a distance of infinity as a bug.
And the handshaking lemma checks the data. Nine friendships, degrees summing to 18. If a friendship had been added in one direction only the sum would be odd, which is impossible in an undirected graph, and the check would catch it.
Where graphs are used
| Problem | The graph | The algorithm |
|---|---|---|
| Fewest stations between two stops | stations and lines | BFS |
| Shortest journey with distances | stations and lines with lengths | Dijkstra, BFS with a heap |
| People you may know | people and friendships | BFS to distance 2 |
| Is this network all joined up | anything | BFS or DFS from every unseen vertex |
| In what order can these tasks run | tasks and prerequisites | DFS, topological sort |
| Is there a cycle in this dependency | modules and imports | DFS |
| Solving a maze | cells and openings | DFS to find any route, BFS for the shortest |
| Web crawling | pages and links | BFS, so that nearby pages come first |
| Garbage collection | objects and references | traversal from the roots |
Practical 19: Graph Representations and Traversals
The last row is worth a moment: a garbage collector is a graph traversal over the objects in memory, starting from the variables in scope, and anything not reached is freed. Every program a student writes is already using one.
The cost of the traversals
V vertices, E edges, on an adjacency list.
| Operation | Cost | Why |
|---|---|---|
| BFS or DFS, whole graph | O(V + E) | every vertex once, every edge twice |
| The same on a matrix | O(V squared) | every row must be scanned |
| Shortest path, unweighted | O(V + E), BFS | |
| Shortest path, weighted | O((V + E) log V), Dijkstra | the heap |
| Components | O(V + E) | one traversal in total, not one per vertex |
| Memory, BFS | O(V), the widest frontier | |
| Memory, DFS | O(V), the deepest path | recursion uses the call stack for it |
O(V + E) is the figure to remember, and it is the reason the adjacency list wins: the same traversal on a matrix is O(V squared), which for a sparse graph is enormously worse.
Procedure
- Write both representations and run the program. Check three cells of the matrix against three
lines of the adjacency list by hand.
- Set only
m[i][j]inadd_edgeand confirm that the matrix is no longer symmetric and that a
route works one way only.
- Add a station to the list in O(1). Then work out what adding one to the matrix would take.
- Write the traversals. Follow both traces on paper and confirm the ring against the dive.
- Swap
q.popleft()forq.pop()in the BFS and confirm you have written a DFS. - Remove the
if n not in seentest and run it on the graph, which has cycles. The program never
ends; stop it with Ctrl+C.
- Write the social network. Add a friendship between Aarti and Hemant and confirm the two
components become one and that everybody now has a distance.
Result
A graph of ten vertices and nine edges was represented as an adjacency matrix and as an adjacency list, and the two were shown to give the same neighbours for every vertex. The matrix occupied 100 cells to hold 18 ones, 5.6 times the list's 18 entries, on a graph that is sparse. Breadth-first and depth-first search were implemented as the same loop with a queue and with a stack, and their traces printed: breadth first visited the vertices in rings and depth first followed one branch to its end. The graph was found to have two components, so that some pairs have no route. Shortest paths by fewest hops were produced by breadth-first search with a record of where each vertex was reached from. The same structure was used as a social network, where the people to suggest were exactly those at distance 2, mutual friends were a set intersection, and the degrees were checked to sum to twice the number of friendships.
Practical 19: Graph Representations and Traversals
Where marks are lost
- Setting only one cell of the matrix for an undirected edge. The matrix must be symmetric.
- Saying the matrix is better because a lookup is O(1) without saying it costs O(V squared) in
space. Both halves are the answer.
- BFS with a stack, or DFS with a queue. The container is the definition.
- Not marking a vertex as seen, which never terminates on a graph with a cycle.
- Marking on removal instead of on insertion, which queues vertices several times.
- Claiming DFS finds the shortest path. Only BFS does, and only on an unweighted graph.
- Assuming the graph is connected. Find the components, or say that you assumed it.
- Using BFS on a weighted graph and calling the answer the shortest path. That needs Dijkstra.
- Not printing the queue or the stack. It is the working, and it is what distinguishes the two
algorithms on paper.
- Forgetting that a vertex's own list must not contain itself unless the graph really has a
self-loop.
For the journal
Write the aim, MU's own wording, and the graph drawn as a picture with its ten vertices and nine edges. Then the matrix and the adjacency list of that same graph, side by side, with the cell count against the entry count. Then both traversals with their step tables showing the queue and the stack, because those two tables are what an examiner marks, and one sentence saying that the only difference in the program is which end the next vertex comes from. Then the components, the shortest paths, and the social network with the levels from one person and the suggestions at distance 2. Close with the handshaking check: degrees 18, edges 9. The conclusion: a graph is vertices and edges, an adjacency list costs O(V + E) where a matrix costs O(V squared), and breadth first and depth first are one algorithm with a queue or a stack in it.
Quick revision
- A graph is vertices and edges. Degree is how many edges touch a vertex. A component is a piece
that is joined up.
- Handshaking lemma: the degrees sum to twice the number of edges. Use it to check your data.
- Adjacency matrix: O(V squared) space always, O(1) edge test, O(V) to list neighbours, symmetric
for an undirected graph.
- Adjacency list: O(V + E) space, O(degree) edge test, O(degree) to list neighbours, O(1) to add a
vertex. Right for nearly every real graph, because nearly every real graph is sparse.
- BFS uses a queue and visits in rings. DFS uses a stack, or recursion, and follows one
Practical 19: Graph Representations and Traversals
branch to its end. They are the same program otherwise.
- Mark a vertex as seen when it is pushed, not when it is taken. Without marking at all, a
graph with a cycle never terminates.
- BFS finds the path with the fewest hops, because it reaches everything at distance k before
anything at k + 1. DFS does not.
- On a weighted graph the shortest path needs Dijkstra, which is BFS with a priority queue
instead of a queue.
- Both traversals are O(V + E) on a list and O(V squared) on a matrix.
- Components: BFS or DFS from every vertex not yet seen. One traversal in total, O(V + E).
- A social network's suggestions are the vertices at distance 2; mutual friends are a set
intersection of two neighbour lists.
- A tree is a connected graph with no cycle. Level-order is BFS and pre-order is DFS.
Questions you should be able to answer
1. Give the space cost of each representation and say which suits a sparse graph. A matrix is O(V squared) whatever the number of edges; a list is O(V + E). A sparse graph, which is nearly every real one, suits the list. Here ten vertices and nine edges needed 100 matrix cells against 18 list entries.
2. What must be true of an adjacency matrix for an undirected graph? It must be symmetric: m[i][j] and m[j][i] are both set for every edge.
3. What is the only difference between BFS and DFS in a program? Whether the next vertex is taken from the front of a queue or the top of a stack. Everything else is identical.
4. Which finds the shortest path, and under what condition? Breadth-first search, and only on an unweighted graph. It reaches everything at distance k before anything at distance k + 1, so the first time it reaches the goal it has come the shortest way.
5. Why must a vertex be marked as seen when it is pushed rather than when it is taken? Because otherwise it can be pushed several times before it is first taken, which wastes memory. Without marking at all, a graph with a cycle is traversed for ever.
6. What is a component, and how are the components found? A maximal part of the graph that is joined up. Run a traversal from every vertex that has not yet been seen; each run is one component. In total it costs O(V + E).
7. What is the cost of a full traversal on each representation? O(V + E) on an adjacency list and O(V squared) on a matrix, because the matrix makes you scan a whole row per vertex.
Practical 19: Graph Representations and Traversals
8. In a social network, which people should be suggested to somebody, and why? Those at distance 2: they have a friend in common and are not already friends. Distance 1 is already a friend and distance 3 has nothing in common.
9. State the handshaking lemma and say what it is good for. The sum of all the degrees is twice the number of edges, because every edge adds 1 to each of its ends. It is a free check on an adjacency list: an odd sum means an edge was added in one direction only.
10. What is the relation between BFS, DFS and Dijkstra's algorithm? All three are the same traversal with a different container: a queue gives breadth first, a stack gives depth first, and a priority queue keyed on distance gives Dijkstra, which is the shortest path on a weighted graph.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.