Breadth First Search
Chapter Ninety-Four
Syllabus topic Module 2, "Graph Traversals using BFS & DFS"
Pages 302 to 306 of 411
In one line
Breadth first search visits the start vertex, then everything one edge away, then everything two edges away, using a queue to hold what has been seen but not yet explored.
The algorithm
BFS(graph, start):
mark start visited
enqueue start
while the queue is not empty:
v = dequeue
report v
for each neighbour w of v:
if w is not visited:
mark w visited
enqueue w
Three things to notice, because each one is a mark in an answer:
The queue is what makes it breadth first. A queue is first in, first out (chapter 40), so vertices come out in the order they were found, which is the order of increasing distance from the start. Swap the queue for a stack and you get depth first search, which is the whole of the next chapter.
A vertex is marked visited when it is enqueued, not when it is dequeued. Mark it on the way out of the queue and a vertex reachable by two different edges gets enqueued twice.
The visited set is compulsory. A graph has cycles, and a traversal with no memory walks a cycle for ever.
Run, with the queue printed at every step
from collections import deque
def adjacency(pairs, vertices):
graph = {v: [] for v in vertices}
for a, b in pairs:
graph[a].append(b)
graph[b].append(a)
for v in graph:
graph[v].sort() # a fixed order, so the run is reproducible
return graph
EDGES = [("A", "B"), ("A", "C"), ("B", "D"), ("B", "E"),
("C", "F"), ("E", "F"), ("E", "G"), ("F", "H")]
GRAPH = adjacency(EDGES, "ABCDEFGH")
print("the graph:")
for v in sorted(GRAPH):
print(" %s -> %s" % (v, ", ".join(GRAPH[v])))
print()
def bfs(graph, start, trace=False):
visited = {start}
queue = deque([start])
order = []
edges_read = 0
if trace:
print("%-6s %-22s %s" % ("out", "queue after", "newly found"))
while queue:
v = queue.popleft()
order.append(v)
found = []
for w in graph[v]:
edges_read += 1
if w not in visited:
visited.add(w)
queue.append(w)
found.append(w)
if trace:
print("%-6s %-22s %s"
% (v, "[" + " ".join(queue) + "]", ", ".join(found) or "none"))
return order, edges_read
order, edges_read = bfs(GRAPH, "A", trace=True)
print()
print("BFS order from A :", " ".join(order))
print("vertices visited :", len(order), "of", len(GRAPH))
print("entries read :", edges_read, "which is 2E for E =", len(EDGES))the graph:
A -> B, C
B -> A, D, E
C -> A, F
D -> B
E -> B, F, G
F -> C, E, H
G -> E
H -> F
out queue after newly found
A [B C] B, C
B [C D E] D, E
C [D E F] F
D [E F] none
E [F G] G
F [G H] H
G [H] none
H [] none
BFS order from A : A B C D E F G H
vertices visited : 8 of 8
entries read : 16 which is 2E for E = 8Breadth First Search
Read the trace, not just the answer. A comes out and finds B and C. B comes out and finds D and E. C comes out and finds F. The queue holds a whole level while the previous level is being emptied, and that is exactly why the output is in level order.
It visits in levels, and that is the useful part
from collections import deque
def adjacency(pairs, vertices):
graph = {v: [] for v in vertices}
for a, b in pairs:
graph[a].append(b)
graph[b].append(a)
for v in graph:
graph[v].sort()
return graph
EDGES = [("A", "B"), ("A", "C"), ("B", "D"), ("B", "E"),
("C", "F"), ("E", "F"), ("E", "G"), ("F", "H")]
GRAPH = adjacency(EDGES, "ABCDEFGH")
def bfs_levels(graph, start):
"""The same walk, but recording how far each vertex is from the start."""
level = {start: 0}
parent = {start: None}
queue = deque([start])
while queue:
v = queue.popleft()
for w in graph[v]:
if w not in level:
level[w] = level[v] + 1
parent[w] = v
queue.append(w)
return level, parent
level, parent = bfs_levels(GRAPH, "A")
by_level = {}
for v, d in level.items():
by_level.setdefault(d, []).append(v)
print("distance from A, in edges:")
for d in sorted(by_level):
print(" level %d : %s" % (d, " ".join(sorted(by_level[d]))))
print()
print("every vertex, with its distance and the vertex it was found from:")
for v in sorted(level):
print(" %s distance %d found from %s" % (v, level[v], parent[v] or "(start)"))
print()
def path_to(parent, v):
steps = []
while v is not None:
steps.append(v)
v = parent[v]
return list(reversed(steps))
for target in ("H", "G", "D"):
route = path_to(parent, target)
print("shortest route A to %s : %s (%d edges)"
% (target, " -> ".join(route), len(route) - 1))distance from A, in edges:
level 0 : A
level 1 : B C
level 2 : D E F
level 3 : G H
every vertex, with its distance and the vertex it was found from:
A distance 0 found from (start)
B distance 1 found from A
C distance 1 found from A
D distance 2 found from B
E distance 2 found from B
F distance 2 found from C
G distance 3 found from E
H distance 3 found from F
shortest route A to H : A -> C -> F -> H (3 edges)
shortest route A to G : A -> B -> E -> G (3 edges)
shortest route A to D : A -> B -> D (2 edges)The level number is the shortest distance in edges, and the parent map rebuilds the actual route. Chapter 97 is that fact written up as a shortest path algorithm; this chapter has already done the work.
Breadth First Search
Without the visited set it never stops
from collections import deque
GRAPH = {"A": ["B"], "B": ["A", "C"], "C": ["B"]} # a path with a cycle in it
print("the same walk with NO visited set, capped at 40 steps so it can finish:")
queue = deque(["A"])
order = []
CAP = 40
while queue and len(order) < CAP:
v = queue.popleft()
order.append(v)
for w in GRAPH[v]:
queue.append(w)
print(" first 20 vertices reported:", " ".join(order[:20]))
print(" it reported %d vertices from a graph of %d" % (len(order), len(GRAPH)))
print(" the queue is still not empty:", len(queue) > 0)
print(" so it hit the cap rather than finishing:", len(order) == CAP)
print()
print("A and B are joined both ways, so each one keeps re-enqueueing the other.")
print("the visited set is not an optimisation. it is what makes BFS terminate.")the same walk with NO visited set, capped at 40 steps so it can finish:
first 20 vertices reported: A B A C B B A C A C B B B B A C A C A C
it reported 40 vertices from a graph of 3
the queue is still not empty: True
so it hit the cap rather than finishing: True
A and B are joined both ways, so each one keeps re-enqueueing the other.
the visited set is not an optimisation. it is what makes BFS terminate.Forty reports from a graph of three vertices, and the queue still growing. That is the whole argument.
The cost
Each vertex is enqueued at most once, because it is marked before it goes in. Each vertex is dequeued at most once. Each adjacency entry is read exactly once when its owner is dequeued, which is 2E entries for an undirected graph and E for a directed one.
time = O(V + E) on an adjacency list
time = O(V^2) on an adjacency matrix, because each row scan is O(V)
space = O(V) the queue and the visited set
The O(V + E) is the figure to quote, and the reason the earlier chapter measured the representations: the same algorithm is O(V squared) on a matrix and nothing about the algorithm changed.
from collections import deque
def ring_with_chords(n):
graph = {i: [] for i in range(n)}
pairs = set()
for i in range(n):
for j in ((i + 1) % n, (i * 7 + 3) % n):
if i != j:
pairs.add((min(i, j), max(i, j)))
for a, b in sorted(pairs):
graph[a].append(b)
graph[b].append(a)
return graph, len(pairs)
def bfs_list(graph, start):
visited = {start}
queue = deque([start])
reads = 0
while queue:
v = queue.popleft()
for w in graph[v]:
reads += 1
if w not in visited:
visited.add(w)
queue.append(w)
return len(visited), reads
def bfs_matrix(matrix, start):
n = len(matrix)
visited = {start}
queue = deque([start])
reads = 0
while queue:
v = queue.popleft()
for w in range(n):
reads += 1
if matrix[v][w] and w not in visited:
visited.add(w)
queue.append(w)
return len(visited), reads
print("%8s %8s %14s %16s %16s"
% ("vertices", "edges", "list reads", "2E", "matrix reads"))
for n in (10, 100, 1000):
graph, e = ring_with_chords(n)
matrix = [[0] * n for _ in range(n)]
for v, ns in graph.items():
for w in ns:
matrix[v][w] = 1
seen_l, reads_l = bfs_list(graph, 0)
seen_m, reads_m = bfs_matrix(matrix, 0)
print("%8d %8d %14d %16d %16d" % (n, e, reads_l, 2 * e, reads_m))
assert seen_l == seen_m == n, "both must reach every vertex"
print()
print("the list reads exactly 2E entries. the matrix reads V squared cells.")
print("same algorithm, same answer, O(V + E) against O(V squared).")Breadth First Search
vertices edges list reads 2E matrix reads
10 15 30 30 100
100 194 388 388 10000
1000 1992 3984 3984 1000000
the list reads exactly 2E entries. the matrix reads V squared cells.
same algorithm, same answer, O(V + E) against O(V squared).What BFS is used for
The shortest path in an unweighted graph, which is the level number. Chapter 97.
Connectivity and connected components. One BFS reaches everything reachable from the start, so if it reaches fewer than V vertices the graph is disconnected. Chapter 96.
Level order in a tree, which is the same algorithm on a tree and was chapter 61.
Finding anything nearest. The nearest exit, the nearest matching record, the fewest moves in a puzzle: BFS reaches them in order of distance, so the first one found is the closest.
Peer to peer and crawler flooding, where a request is passed to neighbours, then their neighbours, up to a limited depth.
Quick revision
- BFS uses a queue, so vertices leave in the order found, which is the order of increasing distance.
- Mark a vertex visited when it is enqueued, never when it is dequeued, or it can be enqueued twice.
- Without a visited set the traversal never terminates: a 3 vertex cycle reported 40 vertices and was still going.
- The level number is the shortest distance in edges, and a parent map rebuilds the route.
- O(V + E) time and O(V) space on an adjacency list; O(V squared) on a matrix.
- The list reads exactly 2E entries; the matrix reads exactly V squared cells.
- Uses: unweighted shortest path, connectivity, tree level order, nearest-anything search, flooding.
- The traversal order is only unique once the neighbour order is fixed.
Test yourself
1. Which structure makes a traversal breadth first, and what happens if it is replaced by a stack? A queue. Replacing it with a stack gives depth first search.
Breadth First Search
2. When must a vertex be marked visited, and what goes wrong otherwise? When it is enqueued. If it is marked on dequeue, a vertex reachable from two different vertices is enqueued twice and reported twice.
3. Prove that the visited set is necessary, not merely helpful. On the three vertex graph A-B-C with A and B joined both ways, a BFS without a visited set reported 40 vertices before hitting a cap and the queue was still not empty: it does not terminate.
4. What does a vertex's BFS level number mean? Its shortest distance from the start measured in edges.
5. Give the time and space complexity on each representation. On an adjacency list, O(V + E) time and O(V) space. On a matrix, O(V squared) time, because each neighbour scan reads a whole row of V cells.
6. How does one BFS decide whether a graph is connected? Run it from any vertex; if it visits fewer than V vertices the graph is disconnected.
7. Why is the BFS order of a graph not unique? Because it depends on the order neighbours appear in the adjacency list. The order is fixed only once that neighbour order is specified.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself, or the past papers, for the same subject.