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?

graphs_traversal_example

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:

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

  1. Recursively: direct implementation of the DFS above.

  2. Iteratively: the notion of a queue is used, meaning that the node to be visited are appended to a list.

  3. Yield also the total distance and path from the starting node for the recursive version.

  4. Same as previously but for the iterative version.

  5. Two solutions are given the previous question, what are the key differences?

Correction
  1. recursive

src
 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)
tests
 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    }
tests
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"
src
 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)
tests
 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
  1. iterative

src
 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)
tests
 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
  1. 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
    
  2. 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
    
  3. 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)

  1. Study the implementation below of the breadth first and uniform cost traversal. Test them.

  2. Explain briefly the choice of the queue in both.

  3. What is the main difference between the uniform cost implementation and the outlined algorithm above?

src
 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])
src
 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))
src
 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
tests
 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
tests
 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
tests
 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
  1. 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.

  2. 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
src
 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
tests
 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 -> popped

  • BF: deque, first scheduled -> popped

  • UC: 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)