Uninformed Search Part 1

Open in GitLab
Lesson thumbnail

By Brian Williams, Viraj Parimi, Alisha Fong, and Cameron Pittman. It takes approximately 1 hour to complete this lesson.

Our agent will need to select specific actions and maneuvers from a large set of possibilities. In effect, it needs to decide what to do and when to do it. Search algorithms are necessary components for answering both questions. Uninformed search, or "blind" search, forms the basis on which more advanced search algorithms are built. The goal of this lesson is to make the abstract idea of search concrete by giving you hands on experience implementing two blind search algorithms, Depth-First Search and Breadth-First Search.

Imagine being given a box of Legos with no instructions and being tasked to build a car. There may not even been a correct answer. There's a large, but finite number of bricks. They only fit together in specific orientations. How would you go about building the car?

A Lego car kit Maybe the box looks like this? Source.

Regardless of the way the car looks or the types of components, you'll have to systematically compare bricks and their combinations to work towards something that resembles a car.

A state-space search is one way to frame the systematic approach to finding the right pieces and combinations. In this lesson, we'll dive into what it means to perform a state-space search, look at depth-first search and breadth-first search as two algorithms, and then you'll implement both algorithms to solve a simpler problem (compared to building a Lego car) called the 8-Tile Puzzle.

State-Space Search

A decision problem is framed by three things: 1) a set of inputs, 2) a set of outputs, and 3) a correct mapping between inputs and outputs.

The input to a state-space search problem is a graph with a start and a goal. The output is a path from the start to the gaol.

Graphs and Inputs

Simple graph

Graphs are data structures like the one you see above. They consist of vertices (or nodes), which in our case are represented by circled letters. The black arrows between the vertices represent edges.

In mathematical shorthand, we refer to the graph as

, the set of all vertices as
, and the set of all edges as
. The vertex that represents the starting state is called
, and the vertex that represents the goal state is
. (Capitalization is important!
and
do not represent the same thing!).

The vertices

of
denote the states of the problem, and its edges,
, denote the operators of the problem.

Output

The output of a state space search problem is a path (or sequence) of vertices,

, in
. Not all sequences of vertices are valid solutions. Output
is a correct solution if
denotes a simple path (no loops) in
that starts at
and terminates at
.

For the example above, one output may be

, or a path that starts at
, goes through
and
, and ends at
. That's the mathematical way of writing a ordered path. Other shorthand notations you may see might use arrows, eg. S → A → D → G, or just list the vertices, eg. SADG.

Are there any other paths in this graph that connect

and
? Think about it.

Question: What is another path between the start and goal?
Answer: (click to reveal)
S → B → G is one option. Another might be S → B → D → G. If you said S → A → C → D → G, note the arrow is pointing from D to C. For a directed graph, you must follow the direction of the edges. C is effectively a dead end. Once you arrive at C, you cannot leave.

Graphs in Depth

There are several types of graphs and terms that are important to round out your understanding of graphs.

For example, the graph above was a directed graph, which you can infer from the fact that there are arrows with arrowheads between the vertices. The arrowheads imply that edges are one-way streets. Valid paths must follow the directions of the arrows. A directed graph is strongly connected if a directed path exists in the graph from every vertex to every other vertex in the graph.

Likewise, undirected graphs are not drawn with arrowheads. The lack of arrowheads imply that the edges are two-way streets, meaning valid paths can go in either direction between vertices. They are connected (not "strongly connected") if an undirected path exists in the graph between every pair of vertices in the graph.

Graphs can represent a huge range of phenomena. For instance a graph could represent a simple road map of highways between major US cities.

roadmap graph

Or a graph may represent the outcomes of a series of actions. This graph denotes the operations that can be performed to stack three blocks, A, B and C.

stacking graph

Let's think about what these graphs are telling us.

Question: Of the two examples of graphs you just saw, the US road map and the block stacking operations, which is directed and which is undirected? Why?
Answer: (click to reveal)
The road map is undirected. Vehicles can travel in either direction along a highway. Also, the road map graph was drawn without arrowheads. The block stacking graph is directed. You could reasonably infer that if you could put one block on another then you could just as easily take one off. However, the action of taking blocks off one another is not represented in the graph, so the edges between states are one-way. Also, it was drawn with arrowheads.

A graph is complete if all pairs of vertices in the graph appear as edges, that is, all vertices are adjacent.

One graph is a subgraph of another graph if it contains a subset of the vertices and edges of the second graph. A subgraph of an undirected graph is called a clique if it denotes a complete graph, that is, all its vertices are adjacent.

Describing Vertices

We can also describe individual vertices. The degree of a vertex is the number of edges that impinge upon that vertex. Vertices in both directed and undirected graphs have degrees.

directed graph degree

For instance, the vertex

in this directed graph has two edges pointed to it and one edge pointed away. It has an in degree of 2 and and out degree of 1.

undirected graph degree

In the undirected version of the same graph,

simply has a degree of 3 because of the three edges connected to it.

Specifying Graphs

The last thing you need to know about graphs before you start writing search algorithms is how we specify them, or in other words, the mathematical language we use to describe them.

Mathematically we represent a graph with sets and sequences

We denote an unordered set of elements starting with

and
and going through
, by
. Each element listed in the set is distinguished, that is, an element is not listed more than once. The idea that sets are unordered means that the set
is equivalent to
.

Sequences are also called lists. Lists are ordered. The notation for paths we already saw, eg.

, represents an ordered list of elements starting with
and
and going through
. The idea that a list is ordered means that
is not equivalent to
.

A pair is a list of two elements, a triple is a list of three, and an

-tuple is a list of
elements.

Putting it all togther, we represent a directed graph by a pair, whose first element is a set of vertices and whose second element is a set of edges. Each directed edge is represented by a pair, whose first element is the edge's head (where the arrow starts), and second element is the edge's tail (where the arrow ends).

Let's look at this definition in practice with the following directed graph.

a, b, c graph

The set of vertics,

, is the unordered set
.

The set of edges,

, consists of all the pairs representing the graph's directed edges. Looking at the graph, we can see five arrows, so
should be comprised of five pairs.

The entire graph is defined as a pair of the vertices and edges,

.

Note that it is perfectly acceptable to nest sequences and sets. You can have a set of sequences or a sequence of sets, or sets of sets of sequences, or any combination thereof.

Let's think about the difference between how we define directed and undirected graphs.

Question: You just saw the definition of a directed graph. How would we define an undirected graph? Or, in other words, what is the difference between the definition of an undirected graph and a directed graph?
Answer: (click to reveal)
The vertices of an undirected graph are sets, not lists.

Search Trees

To make this approach precise we need to define search tree. A search tree is a directed graph with the property that there is unique path between every pair of vertices in the graph. More precisely, there is exactly one undirected path between each pair of vertices. We accomplish this by limiting the in degree of each vertex in the tree to be at most one.

a graph as a family tree

The terminology for elements of a search tree are adopted from that of a "family" tree. A family tree is a tree that grows from top to bottom. The top is a single vertex, called the root. The bottom of the tree is a set of vertices with no out degree, which are called leaves. The edges of the tree are called branches, and the vertices of the tree are called nodes.

If we look at the path from the root to vertex

, the vertices along this path ( except for
) are called
's ancestors.

Conversely, every vertex along a path from

to a leaf in the tree is a descendant of
.

Uninformed Search

Next, we turn to the problem of defining a search algorithm that solves a state space search problem. To do so we enumerate a candidate space of partial paths that start at

and we test each candidate to see if it is (1) simple and (2) ends at the goal
. By simple, we mean there are no loops, ie. vertices only appear in
at most once.

We would like to represent this candidate space compactly. To do so we note that all these candidate paths share the start vertex in common, and many paths share a prefix. Hence, we can represent the candidate set compactly using a tree, called a search tree. A candidate path is then a path through the tree from its top (root) to one of its bottom vertices (leaves).

an example candidate path

In this example, the search tree is on the right. It enumates the paths we can take from each vertex, starting from

and going to
until we find the solution,
. The representation is partial, however. For instance, it does not enumerate any paths from
to
.

The full search tree starting from

is as follows.

full search tree

Question: Can a vertex appear more than once in a search tree? Why or why not?
Answer: (click to reveal)
Yes! You can see that C, D, and G appear multiple times in the search tree. Going down from S in the search tree represents a path. There are multiple paths to reach each of these vertices, hence why they appear multiple times.

This tree represents all paths starting from

and going to the leaves of the tree.

Given a precise statement of a state space search problem, we can design algorithms to solve it. The algorithm varies depending on additional features of the problem and its solution. There are many classes of search problems. These are divided into algorithms that generate all simple paths, and algorithms that focus on finding the shortest simple paths.

Algorithms that find any simple path are called "blind" or uninformed search algorithms. They are uninformed in the sense that they ignore path length. Algorithms that find the shortest paths are called optimal, or informed, algorithms. We'll have lessons on informed search algorithms for you later.

We imagine the space of possible solutions to a problem with a search tree. The nodes of the tree are states in

. The tree is rooted in the start state
. For every parent / child pair of nodes, there is an edge in the search problem between the corresponding states. We look are looking for leaves in the tree that contain the goal state
. A solution is then a path from the root to the leaf containing
.

A search algorithm explores the search tree in a particular order. To illustrate this process, I'll give you the intuition behind two uninformed search algorithms, depth-first and breadth-first search, using search trees.

For the following examples and walkthrough, we'll use the search tree below. We'll be starting at the root node,

.

search tree

Depth-First Search

We can think of search algorithms as navigating a search tree in particular order. This order is defined by a set of local rules. In depth-first search (DFS), the search algorithm generates candidate paths by trying to go as deeply in the tree as can be reached, and only backing up whenever it can't explore further. We will assume that the algorithm explores the tree from left to right.

We can capture this behavior through two rules.

  1. Given a vertex
    that the search algorithm has reached, the first rule says to visit each child of
    first, before vising any sibling of
    . This causes the algorithm to push deeper.
  2. When at vertex
    , visit its children from left to right. This has the effect of exploring the search tree from the left-hand side to the right-hand side.

A DFS search tree

Applying the two rules yields a search that traverses the tree in the order you see in the numbers above. It starts at

, then goes as far down the left side as possible, visiting
, then
, then
. From there, it snakes to the right nodes,
and
, back up, and over to the right side of the tree.

We haven't walked through the complete algorithm yet, but I want you to build an intuition of how DFS works. Try the following question. Use the example DFS search tree above as a guide.

Question: Let's think back to the Lego car example from the introduction. What would someone would do if they wanted apply DFS to the task of building a car without instructions? For simplicity's sake, assume that bricks can only fit together one way.
Answer: (click to reveal)
They would choose a brick to start with. Then they would choose another brick and connect it. Then they would choose another brick and connect it. They would continue picking pieces and connecting them until every brick has been used, or there are no more connections to be made. If the result is not a car, they would take off the last brick and try another one. Roughly speaking, they would then repeatedly remove one brick at a time and build until all pieces have been used or there are no more connections. If the result is not a car, then they would undo their work, remove the brick, try a different one, and restart the build process. If no pieces work, then they will undo one brick more than before and keep trying. They would keep going until some combination is a car. Whew! I'm exhausted after typing out this answer.

Just because building a Lego car with DFS probably isn't the best idea, doesn't mean that DFS never makes sense. There are other scenarios where it is useful. Thinking back to the block stacking graph, what if your goal was to find a set of actions to make a stack of three blocks in any order? DFS would work great then.

Breadth-First Search

Breadth-first search (BFS) enumerates candidate paths along an even front. It first enumerates all paths with one edge, then two edges, then three edges and so forth.

It accomplishes this with two local rules.

  1. Unlike DFS, visit all siblings of the current visited node
    before visiting any of its children. The net affect is to spread enumeration wide rather than deep.
  2. Like DFS, order enumeration so that paths are generated from left to right in the tree.

A BFS search tree

Starting at

, the first rule has the effect of visiting all of its children,
and
, before traversing deeper down the tree.

Question: Let's think back to the Lego car example from the introduction. What would someone would do if they wanted apply BFS to the task of building a car without instructions? For simplicity's sake, assume that bricks can only fit together one way.
Answer: (click to reveal)
They would choose a piece to start with. Then they would pick a second piece and try connecting it. Then they would detach the second piece and try another. Then they would detach that one and try yet another piece. They would continue trying all combinations of two pieces until all of them have been tried. Then they'll go back to the second piece they tried first and add on a third brick. Then they would detach that brick and try another third brick, and so on until all combinations of three bricks have been tried. Note that this would include detaching the second brick, attaching a new second brick, and then trying all third brick combinations. At each combination, they would check to see if the result is a car.

If you've been answering all the questions so far, you've been thinking a lot about systematically building Legos. It should be clear that neither DFS nor BFS is the most effective search algorithm, broadly speaking. Rather, they are extremely useful to learn because they form the backbone of a wide range of search algorithms with real-world applications that we'll be exploring later on.

With DFS and BFS in hand, you're almost ready to implement a solution to solve a search problem. But first, we a detailed look at the algorithm for state space search.

State Space Search Walkthrough

Recall that a state space search problem consists of a set of states, which describe where we are in the search, and operators, which describe how to move between states. Our search state keeps track of the candidates that still need to be enumerated. The operator generates candidates, checks if they are solutions, and returns an updated search state.

How do we maintain search state? The basic operation of search is to extend partial paths, beginning at

. In this case, partial refers to the fact that the paths do not include the goal. Extensions refer to growing a path by following the search tree.

For example, suppose that path

from the graph above is expanded.
has two children,
and
. The expansion produces two new partial paths that extend
to its children, creating
and
.

In order to perform search we must maintain a set of partial paths that are yet to be expanded. We call this a queue,

. We start with the search state
, where
is a partial path that is the beginning of all valid solutions (you must start at
!). As search is performed, partial paths are added to this queue. We will see an example in a moment.

The basic operation that search performs takes a search state as input and returns an updated search state. It should also terminate when search is complete. To do so, the operation removes some path

from
, expands
and adds all expansions to
. If
ends at the goal,
, then the operator returns
as a solution. If
is empty, then no candidates remain, and the operator terminates search with no solution.

At a high-level, state space search:

  • Maintains a queue,
    , which is a list of partial paths it has not expanded yet
  • Repeatedly performs the following operations:
    1. Selects a partial path,
      , from
    2. Expands
      by following the edges on the search tree
    3. Adds expansions to
  • Terminates when the goal,
    , is found, or when
    is empty

There are a few edge cases for search we haven't covered yet.

Question: Looking at the search tree above, what do you think should happen after we remove the partial path SADC from the queue and try to expand it?
Answer: (click to reveal)
The tail of the path is C. C is a leaf node because is has 0 out degree, or more simply, no arrows leave C. No new paths can be produced, so we do not add anything to the queue.

State space search is complete, that is, the algorithm always finds a solution if one exists. We can also say the algorithm is systematic because it never expands a candidate path twice.

One last note before we look at the full algorithm. We'll store paths in reverse order, eg. instead of

representing a partial path, we'll store it as
. This simplifies looking up the last visited node of the list because it is always the head. We will use the head(P) operator to represent the first node in
.

Simple Search Algorithm Pseudocode

Let

be a list of partial paths

Let

be the start node

Let

be the goal node

  1. Initialize the queue with the start node
  2. If
    is empty: return fail
  3. Else, pick a partial path
    from
  4. If head(N) is
    , return
    as the solution
  5. Else
    1. Remove
      from
    2. Find all children of head(N)
    3. For each child, create a one-step extension of
      to the child
    4. Add all expansions to
    5. GOTO step 2

Steps 2 and 4 check if we have exhausted our search or if the goal has been reached. Steps 5.2 and 5.3 are the expansion process described before.

DFS Walkthrough

We'll now walk through an implementation of DFS on the search tree seen above. We'll use a table to represent

at each iteration.

Iteration 1

graph at iteration 1

is initialized to
.

IterationQueue
1S
2
3
4
5
6

The queue is not empty, so we execute step 3. The only path is

. head(N) is not
, so we execute step 5

For step 5.1, we remove

from
.

IterationQueue
1S
2
3
4
5
6

The strikethrough of

means that it has been removed from
.

For step 5.2, we find

and
are the children of
. For step 5.3, we create
and
(remember, partial paths are backwards so we can use the head operator).

For step 5.4, we add the extensions to

. According to DFS we should add partial paths to
in the order they appear from left to right. Our graph is skewed sideways, so we'll add them to
from top to bottom.

IterationQueue
1S
2AS, BS
3
4
5
6

Then we go back to step 2 and begin a new iteration.

Iteration 2

graph at iteration 2

Green means the node has already been visited. Red nodes are the heads of paths that have been newly added to

.

is not empty, so we execute step 3. We pop the first path off
,
. head(N) is not
, so we execute step 5.

For step 5.1, we remove

from
.

IterationQueue
1S
2AS, BS
3
4
5
6

has children
and
. For step 5.3, we create
and
.

Question: Don't scroll down yet! What should the queue look like after we add the new partial paths? (step 5.4)
Answer: (click to reveal)
CAS, DAS, BS. Read on to learn why.

For step 5.4, we add the expansions of

to the head of
. Remember, DFS searches children before siblings. We want the path expansions of
,
and
, to be popped off the head of the queue before its sibling,
.

IterationQueue
1S
2AS, BS
3CAS, DAS, BS
4
5
6

Once again, we go back to step 2 and begin a new iteration.

Iteration 3

graph at iteration 3

Light red represents a node that is one the queue but has not been expanded. Light green represents a node that is still on the queue and it has been expanded.

For step 3, we pick

from the head of
. head(N) is not
, so we continue to step 5 and pop
off
.

IterationQueue
1S
2AS, BS
3CAS, DAS, BS
4
5
6

head(N) is

. There are no children of
, so we cannot execute steps 5.2, 5.3, or 5.4. We move on to the next iteration.

Iteration 4

graph at iteration 4

IterationQueue
1S
2AS, BS
3CAS, DAS, BS
4DAS, BS
5
6

For step 3, we pick

from the head of
. head(N) is not
, so we continue to step 5 and pop
off
.

IterationQueue
1S
2AS, BS
3CAS, DAS, BS
4DAS, BS
5

has two children,
and
. For steps 5.2, 5.3, and 5.4, we add
and
to Q.

IterationQueue
1S
2AS, BS
3CAS, DAS, BS
4DAS, BS
5CDAS, GDAS, BS
6
Question: Why did we add CDAS before GDAS to the queue?
Answer: (click to reveal)
By our rules for DFS, we add new paths from top to bottom in the order they appear. C is above G.

We then move on to the next iteration.

Iteration 5

It's your turn. Don't scroll down yet! Instead, walk through the algorithm yourself for this iteration.

Question: Will the algorithm end on this iteration? If not, what will be on the queue for the 6th iteration? Why? (PS. We're not above adding a 6th row to the table just to trick you into thinking there's a 6th iteration. Maybe there is one, maybe there isn't!)
Answer: (click to reveal)
No, the algorithm will not end on this iteration. The queue is not empty. We will pop CDAS off the queue, which does not include G. Just like we saw on the third iteration, C does not have any children, so we cannot add any new partial paths to the queue. The queue for the 6th iteration will be: GDAS, BS.

Iteration 6

Did you answer the question above yet? If not, do it now!

IterationQueue
1S
2AS, BS
3CAS, DAS, BS
4DAS, BS
5CDAS, GDAS, BS
6GDAS, BS

For step 2,

is not empty. For step 3, we pick
. For step 4, we find that head(N) is
! We return
. With that, the algorithm stops and we've finished this walkthrough.

Implementing BFS vs DFS

Remember that BFS differs from DFS in that we generally want to explore siblings before children. The overall algorithm for BFS is almost exactly the same as we just saw for DFS, with only one difference.

Question: How does an implementation of BFS differ from DFS? Which step changes? Why?
Answer: (click to reveal)
Recall that during the queue growing process, steps 5.2 - 5.4, the siblings of head(N) are already on the queue at the time that the children of head(N) are added to the queue (see iteration 2 above for an example). Hence to explore siblings before children, we append the expanded paths to the end of the queue. Contrast this procedure to DFS, which prepended expanded paths to the head of the queue. Thus, BFS only modifies step 5.4.

It is time to write some code! You'll implement both DFS and BFS to solve a new problem.

Decision Making: Solving an 8-Tile Puzzle

As a simple example of a state space search problem, we consider the 8-Tile Puzzle. Most of us are familiar with this puzzle from our childhood. We are given the tile puzzle in some start configuration and are challenged to move the tiles until the puzzle reaches a specified goal configuration. Start and Goal configurations are shown to the left and to the right below.

8-tile game depiction

States: Integer locations for all tiles (can you think of anything else?)

Operators: Move empty square up, down, left, right

Initial and Goal States: See above

We frame the 8-Puzzle as a state space search problem by defining a set of states, a set of operators for moving between states, the initial state, and the goal state. The state space is the set of possible configurations of the 8-Tile Puzzle, that is to say, the possible placements of tiles.

The size and simplicity of the state and operator encoding can have a dramatic impact on the efficiency of the problem solver. In this example, one way of encoding a state (configuration) is to associate a variable with each tile 1-8. and assign each of these tile variables an integer, 1-9, denoting the tiles position in a particular configuration. We number positions from left to right and top to bottom. We note that one position in a configuration is always empty. We will treat the “empty square” as if it is another tile and assign it an integer position as well.

The reason to give the empty square a position is that it will make it easy to describe the problem’s operator. The operator involves moving a tile into an adjacent empty square. We can equivalently view this operation as moving the empty square to a neighboring position. From this perspective, the empty square can move up, down, left or right, and has the effect of swapping positions with the tile that it overtakes. Given this encoding, the initial and goal states are specified as integer assignments to tile variables that correspond to the pictures above.

Python Implementation

Are you ready to try solving this problem? Here's a link to a Jupyter notebook where you can put your Python skills to the test: link.


Is there something you'd like to change about this lesson? You can because it's open source! Here is a link to the lesson's markdown on gitlab.com: link. Here are instructions for forking and submitting a merge request: link.