Traversal¶
Imagine a video game; you have a map of every area you must visit, see the map below. Every arrow symbolizes a one-way route from one area to another. Every area is a node. Every route between two areas is an edge associated with a weight which is the travel time. Let us assume, since this is a video game, that one can teleport at any previously visited node. How would you plan the visit of every node?
Graph traversal example¶
From a starting node, the three most obvious way to explore the map:
the go-getter way: Depth First, the exploration follows one branch as deeply as possible before going back.
1Choose a starting node.
2Mark it as visited.
3Select one of its unvisited children and continue the exploration from that child.
4Repeat this process, always going deeper along unvisited children.
5When a node has no unvisited children, return to its parent node.
6Continue this backtracking process until every reachable node has been visited.
the no-unexplored-route-left-behind way: Breadth First, the exploration spreads outward from the start, visiting nodes in order of decreasing family relationship proximity.
1Choose a starting node.
2Mark it as visited.
3Explore all of its unvisited children.
4Then explore the unvisited children of those nodes.
5Continue expanding the search level by level.
6Stop when all reachable nodes have been visited.
the speed-runner way: Uniform Cost, the exploration always expands the least costly path discovered so far.
1Choose a starting node and assign it a total cost of zero.
2Among the not-yet-visited-children of the visited nodes, select the one with the lowest total accumulated cost from the start.
3Mark this node as visited.
4For each of its children, compute the total cost required to reach them through the current node.
5If this new cost is lower than a previously known cost, update it.
6Repeat this process until all reachable nodes have been visited.
These traversal algorithms (especially the uniform cost) will be very useful in the future lecture about state problem. It will be used to determine the succession of actions with a minimum cost in order to reach a goal state.
Note
The uniform cost algorithm is only valid for positive weights.
Exercise (easy)
Apply (by hand) the three strategies outlined above to the graph above starting from “a”. Assume a lexicographic order when looping over children (in reality this lexicographic order is not enforced).
Correction
With the teleportation between brackets:
Depth first:
[a, b, e, d, g, f, (g), (e), (a), c]
Breadth first:
[a, b, (a), c, (a), d, (b), e, (c), (d), g, f]
Uniform cost:
[a, b, (a), c, e, (a), d, (e), f, (d), g]
The following types can be defined to annotate the future traversal functions:
1type Node = str | int
2type Weight = float | int
3type DirectedWeightedGraph = dict[Node, dict[Node, Weight]]
4type PathGraph = list[Node]
5type PredecessorGraph = dict[Node, Node | None]
Exercise (intermediate)
Implement a generator for the DFS traversal of a directed graph from a starting node. Test it.
Recursively: direct implementation of the DFS above.
Iteratively: the notion of a queue is used, meaning that the node to be visited are appended to a list.
Yield also the total distance and path from the starting node for the recursive version.
Same as previously but for the iterative version.
Two solutions are given the previous question, what are the key differences?
Correction
recursive
1def depth_first_traversal_recursive_simple_print(
2 graph: DirectedWeightedGraph, starting_node: Node
3) -> None:
4 visited = set()
5
6 def _df_recurse(node: Node) -> None:
7 visited.add(node)
8 print(node)
9 for child in graph[node]:
10 if child not in visited:
11 _df_recurse(child)
12
13 _df_recurse(starting_node)
1@pytest.fixture
2def graph_example() -> DirectedWeightedGraph:
3 return {
4 "a": {"b": 1.0, "c": 2.0, "d": 4.0},
5 "b": {"e": 3.0},
6 "c": {"e": 1.0},
7 "d": {"g": 5.0},
8 "e": {"d": 2.0, "f": 3.0},
9 "f": {},
10 "g": {"a": 1.0, "f": 8.0},
11 }
1def test_depth_first_traversal_recursive_simple_print(
2 capsys, graph_example: DirectedWeightedGraph
3) -> None:
4 depth_first_traversal_recursive_simple_print(graph_example, "a")
5 captured = capsys.readouterr().out
6 assert captured == "a\nb\ne\nd\ng\nf\nc\n"
1def depth_first_traversal_recursive_yield_nodes(
2 graph: DirectedWeightedGraph, starting_node: Node
3) -> Generator[Node]:
4 visited = set()
5
6 def _df_recurse(node: Node) -> Generator[Node]:
7 visited.add(node)
8 yield node
9 for child in graph[node]:
10 if child not in visited:
11 yield from _df_recurse(child)
12
13 yield from _df_recurse(starting_node)
1@pytest.mark.parametrize(
2 "starting_node, expected",
3 [
4 ("a", ["a", "b", "e", "d", "g", "f", "c"]),
5 ("e", ["e", "d", "g", "a", "b", "c", "f"]),
6 ],
7)
8def test_depth_first_traversal_recursive_yield_nodes(
9 graph_example: DirectedWeightedGraph, starting_node: Node, expected: list[Node]
10) -> None:
11 obtained = list(
12 depth_first_traversal_recursive_yield_nodes(graph_example, starting_node)
13 )
14 assert obtained == expected
iterative
1def depth_first_traversal_iterative_yield_nodes(
2 graph: DirectedWeightedGraph, starting_node: Node
3) -> Generator[Node]:
4 visited = {starting_node}
5 queue: list[Node] = [starting_node]
6
7 while queue:
8 node = queue.pop()
9 yield node
10
11 for child in graph[node]:
12 if child not in visited:
13 visited.add(child)
14 queue.append(child)
1@pytest.mark.parametrize(
2 "starting_node, expected",
3 [
4 ("a", ["a", "d", "g", "f", "c", "e", "b"]),
5 ("e", ["e", "f", "d", "g", "a", "c", "b"]),
6 ],
7)
8def test_depth_first_traversal_iterative_yield_nodes(
9 graph_example: DirectedWeightedGraph,
10 starting_node: Node,
11 expected: PathGraph,
12) -> None:
13 obtained = list(
14 depth_first_traversal_iterative_yield_nodes(graph_example, starting_node)
15 )
16 assert obtained == expected
with predecessors:
src¶1def depth_first_traversal_recursive_yield_node_and_predecessors( 2 graph: DirectedWeightedGraph, starting_node: Node 3) -> Generator[tuple[Node, PredecessorGraph]]: 4 predecessors: PredecessorGraph = {starting_node: None} 5 6 def _df_recurse(node: Node) -> Generator[tuple[Node, PredecessorGraph]]: 7 yield node, predecessors 8 for child in graph[node]: 9 if child not in predecessors: 10 predecessors[child] = node 11 yield from _df_recurse(child) 12 13 yield from _df_recurse(starting_node)
src¶1def rebuild_path_and_distance_from_predecessors( 2 graph: DirectedWeightedGraph, 3 predecessors: PredecessorGraph, 4 source: Node, 5 target: Node, 6) -> tuple[Node, PathGraph, Weight]: 7 if target not in predecessors: 8 return target, [], float("inf") # unreachable, because never visited 9 10 path: PathGraph = [] 11 distance: Weight = 0.0 12 13 current = target 14 15 while current != source: # go back until the source is found 16 parent = predecessors[current] 17 18 if parent is None: 19 return target, [], float("inf") # unreachable 20 21 distance += graph[parent][current] 22 path.append(current) 23 current = parent 24 25 path.append(source) 26 path.reverse() 27 28 return target, path, distance
tests¶1def test_depth_first_traversal_recursive_yield_node_and_predecessors( 2 graph_example: DirectedWeightedGraph, 3) -> None: 4 expected = [ 5 ("a", {"a": None}), 6 ("b", {"a": None, "b": "a"}), 7 ("e", {"a": None, "b": "a", "e": "b"}), 8 ("d", {"a": None, "b": "a", "e": "b", "d": "e"}), 9 ("g", {"a": None, "b": "a", "e": "b", "d": "e", "g": "d"}), 10 ("f", {"a": None, "b": "a", "e": "b", "d": "e", "g": "d", "f": "g"}), 11 ("c", {"a": None, "b": "a", "e": "b", "d": "e", "g": "d", "f": "g", "c": "a"}), 12 ] 13 obtained = [ 14 (node, predecessors.copy()) 15 for node, predecessors in depth_first_traversal_recursive_yield_node_and_predecessors( 16 graph_example, "a" 17 ) 18 ] 19 assert expected == obtained
tests¶1def test_rebuild_path_and_distance_from_predecessors( 2 graph_example: DirectedWeightedGraph, 3) -> None: 4 predecessors: PredecessorGraph = { 5 "a": None, 6 "b": "a", 7 "e": "b", 8 "d": "e", 9 "g": "d", 10 "f": "g", 11 "c": "a", 12 } 13 target = "g" 14 distance_expected = 11.0 15 target_obtained, path, distance = rebuild_path_and_distance_from_predecessors( 16 graph_example, predecessors, "a", target 17 ) 18 assert target == target_obtained 19 assert path == ["a", "b", "e", "d", "g"] 20 assert pytest.approx(distance_expected) == distance
with explicit paths:
src¶1def depth_first_traversal_recursive_with_paths_and_distances( 2 graph: DirectedWeightedGraph, starting_node: Node 3) -> Generator[tuple[Node, PathGraph, Weight]]: 4 visited = set() 5 path: PathGraph = [starting_node] 6 starting_distance: Weight = 0.0 7 8 def _df_recurse( 9 node: Node, path: list[Node], distance: Weight 10 ) -> Generator[tuple[Node, PathGraph, Weight]]: 11 visited.add(node) 12 yield node, path, distance 13 for child, distance_child in graph[node].items(): 14 if child not in visited: 15 yield from _df_recurse(child, path + [child], distance + distance_child) 16 17 yield from _df_recurse(starting_node, path, starting_distance)
tests¶1def test_depth_first_traversal_recursive_with_paths_and_distances( 2 graph_example: DirectedWeightedGraph, 3) -> None: 4 expected = [ 5 ("a", ["a"], 0.0), 6 ("b", ["a", "b"], 1.0), 7 ("e", ["a", "b", "e"], 4.0), 8 ("d", ["a", "b", "e", "d"], 6.0), 9 ("g", ["a", "b", "e", "d", "g"], 11.0), 10 ("f", ["a", "b", "e", "d", "g", "f"], 19.0), 11 ("c", ["a", "c"], 2.0), 12 ] 13 obtained = list( 14 depth_first_traversal_recursive_with_paths_and_distances(graph_example, "a") 15 ) 16 assert expected == obtained
with predecessors:
src¶1def depth_first_traversal_iterative_yield_node_and_predecessors( 2 graph: DirectedWeightedGraph, starting_node: Node 3) -> Generator[tuple[Node, PredecessorGraph]]: 4 predecessors: PredecessorGraph = {starting_node: None} 5 queue: list[Node] = [starting_node] 6 7 while queue: 8 node = queue.pop() 9 yield node, predecessors 10 11 for child in graph[node]: 12 if child not in predecessors: 13 predecessors[child] = node 14 queue.append(child)
tests¶1def test_depth_first_traversal_iterative_yield_node_and_predecessors( 2 graph_example: DirectedWeightedGraph, 3) -> None: 4 expected = [ 5 ("a", {"a": None}), 6 ("d", {"a": None, "b": "a", "c": "a", "d": "a"}), 7 ("g", {"a": None, "b": "a", "c": "a", "d": "a", "g": "d"}), 8 ("f", {"a": None, "b": "a", "c": "a", "d": "a", "g": "d", "f": "g"}), 9 ("c", {"a": None, "b": "a", "c": "a", "d": "a", "g": "d", "f": "g"}), 10 ("e", {"a": None, "b": "a", "c": "a", "d": "a", "g": "d", "f": "g", "e": "c"}), 11 ("b", {"a": None, "b": "a", "c": "a", "d": "a", "g": "d", "f": "g", "e": "c"}), 12 ] 13 obtained = [ 14 (node, predecessors.copy()) 15 for node, predecessors in depth_first_traversal_iterative_yield_node_and_predecessors( 16 graph_example, "a" 17 ) 18 ] 19 assert expected == obtained
with explicit paths:
src¶1def depth_first_traversal_iterative_yield_paths_and_distances( 2 graph: DirectedWeightedGraph, starting_node: Node 3) -> Generator[tuple[Node, PathGraph, Weight]]: 4 visited = set() 5 path: PathGraph = [starting_node] 6 starting_distance: Weight = 0.0 7 queue: list[tuple[Node, PathGraph, Weight]] = [ 8 (starting_node, path, starting_distance) 9 ] 10 11 while queue: 12 node, path, distance = queue.pop() 13 if node not in visited: 14 visited.add(node) 15 yield node, path, distance 16 for child, distance_child in graph[node].items(): 17 queue.append((child, path + [child], distance + distance_child))
tests¶1def test_depth_first_traversal_iterative_yield_paths_and_distances( 2 graph_example: DirectedWeightedGraph, 3) -> None: 4 expected = [ 5 ("a", ["a"], 0.0), 6 ("d", ["a", "d"], 4.0), 7 ("g", ["a", "d", "g"], 9.0), 8 ("f", ["a", "d", "g", "f"], 17.0), 9 ("c", ["a", "c"], 2.0), 10 ("e", ["a", "c", "e"], 3.0), 11 ("b", ["a", "b"], 1.0), 12 ] 13 obtained = list( 14 depth_first_traversal_iterative_yield_paths_and_distances(graph_example, "a") 15 ) 16 assert expected == obtained
The use of a predecessor tree enables to decrease the memory requirement. Indeed, the other solution duplicates all the paths for every visited node. The main drawback of the predecessor tree is that the path has to be reconstructed a posteriori.
Exercise (intermediate)
Study the implementation below of the breadth first and uniform cost traversal. Test them.
Explain briefly the choice of the queue in both.
What is the main difference between the uniform cost implementation and the outlined algorithm above?
1def breadth_first_traversal_iterative_yield_node(
2 graph: DirectedWeightedGraph, starting_node: Node
3) -> Generator[Node]:
4 visited = set()
5 queue = deque([starting_node])
6
7 while queue:
8 node = queue.popleft()
9 if node not in visited:
10 visited.add(node)
11 yield node
12 queue.extend(graph[node])
1def breadth_first_traversal_iterative_yield_node_and_distances(
2 graph: DirectedWeightedGraph, starting_node: Node
3) -> Generator[tuple[Node, Weight]]:
4 visited = set()
5 starting_distance: Weight = 0.0
6 queue = deque([(starting_node, starting_distance)])
7 while queue:
8 node, distance = queue.popleft()
9 if node not in visited:
10 visited.add(node)
11 yield node, distance
12 for child, child_distance in graph[node].items():
13 queue.append((child, distance + child_distance))
1def uniform_cost_traversal_iterative_yield_node_and_distances(
2 graph: DirectedWeightedGraph, starting_node: Node
3) -> Generator[tuple[Node, Weight]]:
4 visited = set()
5 node = starting_node
6 starting_distance: Weight = 0.0
7 index: int = 0
8 queue = [(starting_distance, index, starting_node)]
9
10 while queue:
11 distance, _, node = heapq.heappop(queue)
12 if node not in visited:
13 visited.add(node)
14 yield node, distance
15 for child, child_distance in graph[node].items():
16 index += 1
17 heapq.heappush(queue, (child_distance + distance, index, child))
Correction
1@pytest.mark.parametrize(
2 "starting_node, expected",
3 [
4 ("a", ["a", "b", "c", "d", "e", "g", "f"]),
5 ("g", ["g", "a", "f", "b", "c", "d", "e"]),
6 ],
7)
8def test_breadth_first_traversal_iterative_yield_node(
9 graph_example: DirectedWeightedGraph,
10 starting_node: Node,
11 expected: PathGraph,
12) -> None:
13 obtained = list(
14 breadth_first_traversal_iterative_yield_node(graph_example, starting_node)
15 )
16 assert obtained == expected
1def test_breadth_first_traversal_iterative_yield_node_and_distances(
2 graph_example: DirectedWeightedGraph,
3) -> None:
4 expected = [
5 ("a", 0.0),
6 ("b", 1.0),
7 ("c", 2.0),
8 ("d", 4.0),
9 ("e", 4.0),
10 ("g", 9.0),
11 ("f", 7.0),
12 ]
13 obtained = list(
14 breadth_first_traversal_iterative_yield_node_and_distances(graph_example, "a")
15 )
16 assert expected == obtained
1def test_uniform_cost_traversal_iterative_yield_node_and_distances(
2 graph_example: DirectedWeightedGraph,
3) -> None:
4 expected = [
5 ("a", 0.0),
6 ("b", 1.0),
7 ("c", 2.0),
8 ("e", 3.0),
9 ("d", 4.0),
10 ("f", 6.0),
11 ("g", 9.0),
12 ]
13 obtained = list(
14 uniform_cost_traversal_iterative_yield_node_and_distances(graph_example, "a")
15 )
16 assert expected == obtained
BF: the deque acts as a First-In-First-Out queue meaning that nodes are popped in the order of addition. By comparison, the simple list of the DF implementation acts as a Last-In-First-Out queue. The use of a deque is required to maintain a constant complexity of the pop operation on the left of the queue.
UC: the priority queue maintains an order based on the first parameter of the tuple (total distance). When a node is popped, one gets the node not yet visited with the smallest distance from the starting node.
In the outlined algorithm, it is said that “If this new cost is lower than a previously known cost, update it”. However, in this implementation, the node is just re-added in the priority queue with the new (shorter) distance. The previous value is still in the queue, not updated. The visit of the node will be done with the shortest total distance. When, the old distance will pop later on, the node will be already visited and thus ignored.
A second change is the introduction of an index to avoid comparing nodes if the total distances where to be equal.
Exercise (intermediate)
Implement a uniform cost search from a starting to a target node which returns the path and the distance. Test it. Use explicit paths (not a predecessor tree).
One caveat is that since this not a simple traversal, but a search, the case where the target is not reachable must be considered. It means that a convention must be adopted for the return value in this case: for example, an empty path and an infinite distance.
Correction
1def uniform_cost_search_iterative_path_and_distance(
2 graph: DirectedWeightedGraph, starting_node: Node, target_node: Node
3) -> tuple[PathGraph, Weight]:
4 visited = set()
5 node = starting_node
6 starting_distance: Weight = 0.0
7 index: int = 0
8 queue = [(starting_distance, index, [starting_node])]
9 inf = float("inf")
10
11 while queue:
12 distance, _, nodes = heapq.heappop(queue)
13 node: Node = nodes[-1]
14 if node == target_node:
15 return nodes, distance
16 if node in visited:
17 continue
18
19 visited.add(node)
20 for child, child_distance in graph[node].items():
21 index += 1
22 total_distance: Weight = child_distance + distance
23 heapq.heappush(queue, (total_distance, index, nodes + [child]))
24
25 return [], inf
1@pytest.mark.parametrize(
2 "starting_node, target_node, expected",
3 [
4 ("a", "f", (["a", "c", "e", "f"], 6.0)),
5 ("g", "d", (["g", "a", "d"], 5.0)),
6 ],
7)
8def test_uniform_cost_search_iterative_yield_path_and_distance(
9 graph_example: DirectedWeightedGraph,
10 starting_node: Node,
11 target_node: Node,
12 expected: PathGraph,
13) -> None:
14 obtained = list(
15 uniform_cost_search_iterative_path_and_distance(
16 graph_example, starting_node, target_node
17 )
18 )
19 assert expected[0] == obtained[0]
20 assert pytest.approx(expected[1]) == obtained[1]
Note
The three traversal algorithms differ only by the choice of the queue:
DF:
list, last scheduled -> poppedBF:
deque, first scheduled -> poppedUC:
heapq, shortest distance from start -> popped
Note
Two choices for the path:
predecessor tree (memory efficient, but requires a post treatment to get the path)
explicit paths (memory costly, every node has his own copy of the path to it)