class: center, middle # Artificial Intelligence ### Search --- class: center, middle # Representation --- # Graphs * A graph G = (V,E) consists of *vertices* (nodes) V and *edges* (connections) `\( E \subseteq V \times V \)` * Graphs can be connected, or have multiple components * Graphs can be directed (one-way streets) or undirected * Edges can have weights (costs) associated with them: `\( w: E \mapsto \mathbb{R} \)` * We can represent many things in graphs --- # Graphs * We can "walk" around our graph * Say we start at some node, and follow an edge * If we can get back to our start node without visiting any edge twice we have found a "cycle" * Cycles can complicate some things, so we like graphs without them * These graphs are called "trees" --- # Trees * An alternative view: We take some node and call it the *root* * All nodes connected to the root are its *children*, which we put in another layer * We then add another layer with the children's children, etc. * In the end, we have the root at the top, with a number of layers below it --- # Trees
Tree
1
2
3
4
5
6
7
8
9
10
11
12
--- # A note on Trees * Our choice of "root" was completely arbitrary in this case * We can redraw the same tree with a different node as the root * For many applications in AI we have some *interpretation* of the nodes, though * For example: In Single-player games the root is the current game state, and each edge is an action the player could perform --- # Sliding Puzzle Tree
--- # Tree Search * Let's say we want to win this game * We start at the root node and then we *search* for a *path* that leads to a solution state * What strategies/algorithms could we use to perform this search? --- class: center, middle # Tree Search --- # Tree Search * We start at some node * We can look at that nodes neighbors * Then we can pick one of these neighbors to continue * Or we look at all the neighbors and continue from there? --- # Uninformed Search The simplest pathfinding algorithm works like this: - Keep track of which nodes are candidates for expansion (starting with the start node), called the **(search) frontier** - Take one of these nodes and expand it, adding all its children to the frontier - If you reach the target, you have found a path --- class: medium # Breadth-First Search How do you "keep track" of nodes? Use a list/queue: - You add all neighbors of the start node to the queue - Then add the neighbors of the first, second, ... neighbor, to the end of your queue - When you are done with all neighbors, you continue with the neighbor's neighbors, etc. - This is called "breadth-first search" --- class: medium # Depth-First Search How do you "keep track" of nodes? Use a stack: - You add all neighbors of the start node to the stack (in reverse order) - You start with the first neighbor, and add all its neighbors to the stack (in reverse order) - Then you continue with the first neighbor of the first neighbor, etc. - This is called "depth-first search" --- # 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 - In our tree, this means each node will remember its parent - This will become more important when we talk about general graphs later --- # Tree Search: Summary
--- class: center, middle # Graph Search --- # Another example: Romania
--- # 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 (undirected) 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 # Uninformed 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" --- class: small # Breadth-First Search * Save the "frontier" as a list, 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
--- # 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 - Basically, we store a dictionary that remembers for each node where "it came from" - When we find the goal we walk backwards through this dictionary --- class: small # 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 remember the frontier and paths to get there * Both of them ignore costs --- class: small # 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" * To account for costs, we can use the cost as the order for Depth-First Search, and only make Breadth-First Search expand the node it can reach with the lowest cost in each iteration --- class: center, middle # More Graphs --- class: small # Another Application * Graphs are a very flexible way to represent problems! * Take, for example, Super Mario * Each action Mario performs will generate a "new" game state, and we can search for a way to beat a level this way!
--- # Search Graphs * What if we have infinite (or at least very large) graphs? * We can't really store the entire graph in memory * But do we need to? --- # Lazy Evaluation * Some programming languages (like Haskell) use something called *lazy evaluation* * This means a value is only evaluated when it is needed * For example `[1..]` is an infinite list of integers in Haskell * But as long as you don't try to access *all* elements, it does not need to generate them * We can "simulate" this behavior in other languages using functions --- # Lazy Evaluation for Graphs * Instead of representing an entire graph in memory, let's say we just store one node * Each node can, when requested, give us its neighbors * We can then even "forget" the original node, if we want to * The only requirement is that each node only has finitely many neighbors (and preferably not too many) --- # An Infinite Graph * Let's say we have a graph with one node for each integer * Each node has 2 to 4 neighbors: The predecessor and successor, twice its value, and half its value (if it is even). All distances are 1 * "1" has the neighbors "0" and "2" * "3" has the neighbors "2", "4", and "6" * "4" has the neighbors "3", "5", "8", and "2" * "16" has the neighbors "15", "17", "32", and "8" --- # Goal Conditions * We often also don't want to look for one specific (of our infinitely many) nodes, but rather a node that satisfies some condition * For example, what if we want a node that is greater than 1000 and a power of 2? * Each of the nodes "1024", "2048", "4096", etc. would be a valid solution * How could we find a path starting from "127" to find such a goal node? --- class: medium # Why are we doing this? * (Almost) anything can be described as a graph * As long as we have a representation of a "node", a way to get "neighbors", and a something that tells us what is a "goal", we can use our search algorithms * Nodes can be anything: locations, game states, sentences, etc. * Edges connect nodes: roads, game actions, adding/removing words, etc. --- class: small # References * [BFS and DFS](https://medium.com/basecs/breaking-down-breadth-first-search-cebe696709d9)