munotes®

Breadth First Search

Get access to whole semester resourcesSemester Pass

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 = 8
munotes.in302

Breadth 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.

munotes.in303

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).")
munotes.in304

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.

munotes.in305

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.

munotes.in306

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.

Issue
Done!