class: center, middle # Artificial Intelligence ### Graph Search --- class: center, middle # Graph Search --- # How could we find a path?
--- # Graph Search * Instead of a tree, many problems are actually better represented as a (general) graph * That means we may have loops * During the search process we need to avoid "running in circles" * Other than that, we can use the same approach! --- # The Pathfinding problem Given a graph G = (V,E), with edge weights w, a start node `\( s \in V \)`, a destination node `\( d \in V \)`, find a sequence of vertices `\( v_1, v_2, \ldots, v_n \)`, such that `\(v_1 = s, v_n = d \)` and `\( \forall i: (v_i, v_{i+1}) \in E \)` We call the sequence `\( v_1, v_2, \ldots, v_n \)` a *path*, and the *cost* of the path is `\( \sum_i w((v_i,v_{i+1})) \)` -- This means what you would expect: To find a path from a start node to a destination node means to find vertices to walk through that lead from the start to the destination by being connected with edges. The cost is the sum of the costs of edges that need to be traversed. --- class: medium # Recall: Tree Search - The simplest pathfinding algorithm(s) works like this: - Keep track of which nodes are candidates for expansion (starting with the start node) - Take one of these nodes and expand it - If you reach the target, you have found a path - How do you "keep track" of nodes? - Use a list/queue: "Breadth-first search" - Use a stack: "Depth-first search" --- class: medium # Graph Search - The simplest pathfinding algorithm(s) works like this: -
Remember which nodes have already been expanded
- Keep track of which nodes are candidates for expansion (starting with the start node) - Take one of these nodes and expand it
(avoid revisiting expanded nodes)
- If you reach the target, you have found a path - How do you "keep track" of nodes? - Use a list/queue: "Breadth-first search" - Use a stack: "Depth-first search" --- # Graph Search ```python def search(source, destination): frontier = Frontier(source) while frontier: # expand next = frontier.pop_next() neighbors = next.get_neighbors() neighbors = remove_expanded(neighbors) # check for solution if destination in neighbors: return path # add to frontier frontier.add_list(neighbors) ``` --- class: small # Breadth-First Search * Save the "frontier" as a queue, initialized with the starting node * If the goal is in the frontier, we are done * Else, for the first node in the frontier, add all of its neighbors
that have not yet been expanded
to the frontier * Repeat Basically: Add all neighbors, then add all neighbor's neighbors, then add all neighbor's neighbor's neighbors, etc. --- # Breadth-First Search
--- # Breadth-First Search
--- # Breadth-First Search
--- # Breadth-First Search
--- class: small # Depth-First Search * Save the "frontier" as a stack, initialized with the starting node * If the goal is in the frontier, we are done * Else, for the first node in the frontier, add all of its neighbors
that have not yet been expanded
to the frontier * Repeat Basically: Add all neighbors, then add the first neighbor's neighbors, then add the first neighbor's first neighbor's neighbors, etc. --- # Depth-First Search
--- # Depth-First Search
75 --- # Depth-First Search
75 + 71 = 146 --- # Depth-First Search
75 + 71 + 151 = 297 --- # Depth-First Search
75 + 71 + 151 + 99 = 396 --- # Depth-First Search
75 + 71 + 151 + 99 + 211 = 607 --- # Depth-First Search: What if?
--- # Depth-First Search: Revisiting nodes
--- # Depth-First Search: What if?
--- # Depth-First Search: Long way
--- # BFS and DFS: Summary The simplest pathfinding algorithm(s) works like this: - Remember which nodes have already been expanded - Keep track of which nodes are candidates for expansion (starting with the start node) - Take one of these nodes and expand it (avoid revisiting expanded nodes) - If you reach the target, you have found a path How do you "keep track" of nodes? - Use a list/queue: "Breadth-first search" - Use a stack: "Depth-first search" --- # Implementation Details * How do we identify nodes? * "You have found a path" is great, but how do we get it? * How fast/slow are these algorithms? * What about memory usage? --- class: medium # Node Identification * In our examples we basically said a node is just a state we can be in (a location, game state, etc.) * In practice, our nodes can be associated with any complex state of the world * We also "came" from somewhere when we reached a node * What if there are two ways to reach the same **state**? * It may be important to be able to give nodes unique IDs corresponding to states, as we will see below --- # Path Extraction When we reach the target, we have "found a path". How do we get it? - Whenever we "add" a node, we also remember which node we just expanded to get there - We store a dictionary that remembers for each node where "it came from" - When we find the goal we walk backwards through this dictionary --- # Path Extraction: Example
--- # Breadth-First Search Performance Let's assume we have a tree where every node (except the leafs) has the same number of children `\(b\)` The goal node can be found at depth `\(d\)` Our algorithm has to visit `\(b^0 + b^1 + b^2 + \cdots + b^d = \mathcal{O}(b^d)\)` nodes (time complexity) Additionally, when expanding the nodes, it adds `\(b^1 + b^2 + \cdots + b^d = \mathcal{O}(b^d)\)` nodes to the search frontier (space complexity) This is "bad" (or "scary", according to the textbook): Exponential growth! --- # Depth-First Search Performance Let's again assume we have a tree where every node (except the leafs) has the same number of children `\(b\)` However, now we need to consider the **total** depth of the tree `\(m\)` What's the worst that could happen? The node we are looking for is the last node in the tree, which means we will look at all `\(\mathcal{O}(b^m)\)` nodes (time complexity) On the other hand, as we continue deeper and deeper into the tree, we only add `\(b\)` nodes to the search frontier every time. Once we reach the leafs, we backtrack upwards. Our frontier will never have more than `\(\mathcal{O}(bm)\)` nodes in it. --- # What about graphs with loops? * If we have to store **all** nodes that have been visited that's bad news for DFS * What we can do instead is to **only** check the current path for loops * As the current path is at most `\(m\)` steps long, this requires no significant extra time or space! * But we may visit some nodes twice in different branches ... --- class: medium # Limitations * Depth-First Search can lead to some very long paths * If we have an infinite graph, Depth-First Search will probably even fail completely * Breadth-First Search may need a lot of memory to store the frontier (and paths to get there) * Both of them ignore costs --- class: medium # Some improvements * We can limit the depth that Depth-First Search should explore - Now it will "work" on infinite graphs - We may also avoid some of the long paths * Idea: start with a depth limit of 1, run Depth-First Search, and if it can't find a path, increase the depth limit * This is called "Iterative Deepening" --- # Iterative Deepening * This seems inefficient? Instead of searching once, we search `\(d\)` times ... * But recall: Time complexity of DFS is `\(\mathcal{O}(b^m)\)` * Iterative deepening needs `\(\mathcal{O}(b^1) + \mathcal{O}(b^2) + \cdots + \mathcal{O}(b^d) = \mathcal{O}(b^d)\)` * In many cases `\(d \ll m\)` --- # Which way are we going?