I implemented a problem solving agent program, to solve the classic 8-puzzle problem, using a Node and Problem class as described in Peter Norvig's "AI a Modern Approach 3rd Ed."
I then solve the puzzle with both a Breadth-First-Search (BFS) and then a Depth-First-Search (DFS) of the state space. Although both find a solution, I am confused about the solution arrived at by the DFS. The BFS solves the puzzle with 12 moves, but the DFS solves it with over a 100 moves and that does not seem right.
Part of my implementation traces the route from the goal or final node back to the root node. I would expect both algorithms to produce the same result (i.e. 12 moves) as they both use the same class method to trace the route; and I assume that both algorithms would identify the same node as the goal node. So unless my assumption here is wrong, I either have an error in my code or there are multiple solutions to the puzzle.
import numpy as np
initial_board = np.array([[1,2,3],
[4,0,5],
[6,7,8]])
initial_space = (1,1)
initial_state=[initial_action,(initial_board,initial_space)]
goal_board = np.array([[1,2,3],
[4,5,6],
[0,7,8]])
goal_space = (2,0)
goal_state = [goal_board,goal_space]
class Problem:
"""The abstract class for a formal problem. You should subclass
this and implement the methods actions and result, and possibly
__init__, goal_test, and path_cost. Then you will create instances
of your subclass and solve them with the various search functions."""
def __init__(self, initial, goal=None):
"""The constructor specifies the initial state, and possibly a goal
state, if there is a unique goal. Your subclass's constructor can add
other arguments."""
self.initial = initial
self.goal = goal
def actions(self, state):
"""Return the actions that can be executed in the given
state. The result would typically be a list, but if there are
many actions, consider yielding them one at a time in an
iterator, rather than building them all at once."""
raise NotImplementedError
def result(self, state, action):
"""Return the state that results from executing the given
action in the given state. The action must be one of
self.actions(state)."""
raise NotImplementedError
def goal_test(self, state):
"""Return True if the state is a goal. The default method compares the
state to self.goal or checks for state in self.goal if it is a
list, as specified in the constructor. Override this method if
checking against a single self.goal is not enough."""
if isinstance(self.goal, list):
return is_in(state, self.goal)
else:
return state == self.goal
def path_cost(self, c, state1, action, state2):
"""Return the cost of a solution path that arrives at state2 from
state1 via action, assuming cost c to get up to state1. If the problem
is such that the path doesn't matter, this function will only look at
state2. If the path does matter, it will consider c and maybe state1
and action. The default method costs 1 for every step in the path."""
return c + 1
def value(self, state):
"""For optimization problems, each state has a value. Hill Climbing
and related algorithms try to maximize this value."""
raise NotImplementedError
class PuzzleProblem(Problem):
def actions(self, state):
"""Return the actions that can be executed in the given
state. The result would typically be a list, but if there are
many actions, consider yielding them one at a time in an
iterator, rather than building them all at once."""
actions = []
(row,col) = state[1][1]
if row > 0 :
actions.append('U')
if row < 2:
actions.append('D')
if col > 0:
actions.append('L')
if col < 2:
actions.append('R')
return actions
def result(self, state, action):
"""Return the state that results from executing the given
action in the given state. The action must be one of
self.actions(state)."""
mat = state[1][0]
(row, col) = state[1][1]
if action == 'U':
mat1 = np.copy(mat)
mat1[row][col] = mat1[row-1][col]
mat1[row-1][col] = 0
return [action,(mat1,(row-1,col))]
if action == 'D':
mat1 = np.copy(mat)
mat1[row][col] = mat1[row+1][col]
mat1[row+1][col] = 0
return [action,(mat1,(row+1,col))]
if action == 'L':
mat1 = np.copy(mat)
mat1[row][col] = mat1[row][col-1]
mat1[row][col-1] = 0
return [action,(mat1,(row,col-1))]
if action == 'R':
mat1 = np.copy(mat)
mat1[row][col] = mat1[row][col+1]
mat1[row][col+1] = 0
return [action,(mat1,(row,col+1))]
def goal_test(self, state):
"""Return True if the state is a goal. The default method compares the
state to self.goal or checks for state in self.goal if it is a
list, as specified in the constructor. Override this method if
checking against a single self.goal is not enough."""
#print('State to test: ')
#print(state[1][0])
#file1.write(str(state[1][0]))
if isinstance(self.goal, list):
if (np.all(state[1][0] == self.goal[0])) and (state[1][1] == self.goal[1]):
print('GOAL REACHED')
return True
else:
return False
puzzle = PuzzleProblem(initial_state,goal_state)
from collections import deque
class Node:
"""A node in a search tree. Contains a pointer to the parent (the node
that this is a successor of) and to the actual state for this node. Note
that if a state is arrived at by two paths, then there are two nodes with
the same state. Also includes the action that got us to this state, and
the total path_cost (also known as g) to reach the node. Other functions
may add an f and h value; see best_first_graph_search and astar_search for
an explanation of how the f and h values are handled. You will not need to
subclass this class."""
def __init__(self, state, parent=None, action=None, path_cost=0):
"""Create a search tree Node, derived from a parent by an action."""
self.state = state
self.parent = parent
self.action = action
self.path_cost = path_cost
self.depth = 0
if parent:
self.depth = parent.depth + 1
def __repr__(self):
return "<Node {}>".format(self.state)
def __lt__(self, node):
return self.state < node.state
def expand(self, problem):
"""List the nodes reachable in one step from this node."""
#return [self.child_node(problem, action) for action in problem.actions(self.state)]
actions = problem.actions(self.state)
action_results = []
children = []
for action in actions:
action_results.append(problem.result(self.state,action))
for action_state in action_results:
children.append(self.child_node(problem,action_state[0]))
return children
def child_node(self, problem, action):
"""[Figure 3.10]"""
next_state = problem.result(self.state, action)
next_node = Node(next_state, self, action, problem.path_cost(self.path_cost, self.state, action, next_state))
return next_node
def solution(self):
"""Return the sequence of actions to go from the root to this node."""
#file2 = open("BFSNodes.txt","a")
file4 = open("DFSNodes.txt","a")
sol = [node.state[1][0] for node in self.path()[1:]]
file4.writelines(str(sol))
file4.close()
return [node.action for node in self.path()[1:]]
def path(self):
"""Return a list of nodes forming the path from the root to this node."""
node, path_back = self, []
while node:
path_back.append(node)
node = node.parent
return list(reversed(path_back))
# We want for a queue of nodes in breadth_first_graph_search or
# astar_search to have no duplicated states, so we treat nodes
# with the same state as equal. [Problem: this may not be what you
# want in other contexts.]
def __eq__(self, other):
return isinstance(other, Node) and self.state == other.state
def __hash__(self):
# We use the hash value of the state
# stored in the node instead of the node
# object itself to quickly search a node
# with the same state in a Hash Table
return hash(self.state)
def not_in_explored(node,explored_list):
for n in explored_list:
if (n.state[1][0] == node.state[1][0]).all():
return False
return True
def breadth_first_tree_search(problem):
"""
[Figure 3.7]
Search the shallowest nodes in the search tree first.
Search through the successors of a problem to find a goal.
The argument frontier should be an empty queue.
Repeats infinitely in case of loops.
"""
frontier = deque([Node(problem.initial)]) # FIFO queue
explored_nodes = []
breaker = 0
file1 = open("BFSsol.txt","a")
while frontier:
breaker +=1
#print('BREAKER: ',breaker)
if breaker > 1000000:
print('breaking')
break
node = frontier.popleft()
if not_in_explored(node,explored_nodes):
explored_nodes.append(node)
if problem.goal_test(node.state):
solution = node.solution()
file1.write(str(solution))
file1.close()
return solution
frontier.extend(node.expand(problem))
return None
def depth_first_tree_search(problem):
"""
[Figure 3.7]
Search the deepest nodes in the search tree first.
Search through the successors of a problem to find a goal.
The argument frontier should be an empty queue.
Repeats infinitely in case of loops.
"""
file3 = open("DFSsol.txt","a")
explored_nodes = []
frontier = [Node(problem.initial)] # Stack
breaker = 0
while frontier:
breaker +=1
#print(breaker)
if breaker > 1000000:
print('breaking')
break
node = frontier.pop()
if not_in_explored(node,explored_nodes):
explored_nodes.append(node)
if problem.goal_test(node.state):
solution = node.solution()
file3.write(str(solution))
file3.close()
return solution
frontier.extend(node.expand(problem))
return None
breadth_first_tree_search(puzzle)
depth_first_tree_search(puzzle)
The BFS derives this solution:
['R', 'D', 'L', 'L', 'U', 'R', 'D', 'R', 'U', 'L', 'L', 'D']
But the DFS derives many, many more moves.
I would expect both algorithms to derive at the 12-move solution and therefor do not understand why the DFS produces so many moves for a solution.
Can anyone point me to an error in the code; or explain the outcomes if it is correct?
What you describe is expected: DFS is not optimal, it will find solutions that are not necessarily the shortest. It just, as the name says, runs down the search tree in a depth-first manner, meaning it will never backtrack unless it has to. It returns the first solution it finds, using the first guess at every junction in the tree, and if there is none, tries the second, third, etc. So what it finds is not necessarily the shortest.
The upside of DFS is that it is more memory efficient. It doesn't need to keep track of several unfinished candidate plans because it always only considers one option.
Upshot: your code is quite possibly correct.
You will be adding to the classes Node and Tree that we developed in our lectures. There are several short methods that you will have to write.
Write a method is_similar() that takes as input two binary trees and returns true if the nodes have the same key values and are arranged in the same order and false otherwise.
def is_similar (self, pNode):
Write a method print_level() that takes as input the level and prints out all the nodes at that level. If that level does not exist for that binary search tree it prints nothing. Use the convention that the root is at level 1.
def print_level (self, level):
Write a method get_height() that returns the height of a binary tree. Recall that the height of a tree is the longest path length from the root to a leaf.
def get_height (self):
Write a method num_nodes() that returns the number of nodes in the left subtree from the root and the number of nodes in the right subtree from the root and the root itself. This function will be useful to determine if the tree is balanced.
def num_nodes (self):
Input: The input will read from a file. The file will be formatted as follows:
Line 1: Several integers separated by spaces to be inserted into Tree 1
Line 2: Several integers separated by spaces to be inserted into Tree 2
You will read both lines of data. Create two Trees and insert the integers in the order given. Then you will use these two trees to test the methods that you have written.
Output: The output will be formatted as follows:
The Trees are similar: (True or False)
Levels of Tree 1:
print each level on its own line
Levels of Tree 2:
print each level on its own line
Height of Tree 1:
Nodes in Tree 1:
Height of Tree 2:
Nodes in Tree 2:
For example, given the following input file:
14 17 1
14 17 1
This would be the output:
The Trees are similare: True
Levels of Tree 1:
14
1 17
Levels of Tree 2:
14
1 17
Height of Tree 1: 1
Nodes in Tree 1: 3
Height of Tree 2: 1
Nodes in Tree 2: 3
You will be writing helper methods for the Tree class that we developed. The following is the outline of the code that you will be submitting. You may include other functions that we developed for completeness. You may add helper functions as needed.
Below is the code that I need help finishing. Not entirely sure how to start the helper functions or main so any help would be appreciated.
import os
class Node (object):
def __init__ (self, data):
self.data = data
self.lchild = None
self.rchild = None
class Tree (object):
def __init__ (self):
self.root = None
# insert data into the tree
def insert (self, data):
new_node = Node (data)
if (self.root == None):
self.root = new_node
return
else:
current = self.root
parent = self.root
while (current != None):
parent = current
if (data < current.data):
current = current.lchild
else:
current = current.rchild
# found location now insert node
if (data < parent.data):
parent.lchild = new_node
else:
parent.rchild = new_node
# Returns true if two binary trees are similar
def is_similar (self, pNode):
pass
# Prints out all nodes at the given level
def print_level (self, level):
pass
# Returns the height of the tree
def get_height (self):
pass
# Returns the number of nodes in tree which is
# equivalent to 1 + number of nodes in the left
# subtree + number of nodes in the right subtree
def num_nodes (self):
pass
def main():
# write code here
main()
As a hint, think of how you would need to traverse the binary tree in the implementation of each helper method.
For num_nodes, I am not sure what "and the number of nodes in the right subtree from the root and the root itself." means. Should we return the number of nodes in the right subtree + 1?
#classmethod
def count_below(node):
count=0
if (node == None):
return 0 # if one of the root's childs was None
if (node.lchild == None and node.rchild == None): # leaf
return 1 # base case
if (node.lchild != None):
count+=count_below(node.lchild)
if (node.rchild != None):
count+=count_below(node.rchild)
return count
def num_nodes(self):
if (self.root == None):
return 0
return count_below(self.root.lchild), count_below(self.root.rchild) + 1
#classmethod
def depth_below(node):
if node is None:
return 0 # base case
# Compute the depth of each subtree
ldepth = depth_below(node.lchild) # recurse left
rdepth = depth_below(node.rchild) # recurse right
# once all the recursive calls performed on this node's childs resolve
# return the depth of the subtree of this node with the greater depth
if (ldepth > rdepth):
return ldepth+1
else:
return rdepth+1
def get_height(self):
return depth_below(self.root) # depth from root
#classmethod
def explore_childs(node, current_level, target_level):
if (node.lchild==None and node.rchild==None):
return # base case
if (current_level == target_level):
if (node.lchild!=None):
print(node.lchild.data)
if (node.rchild!=None):
print(node.rchild.data)
return # base case
if (node.lchild!=None):
explore_childs(node.lchild, current_level+1, target_level) # recurse left
if (node.rchild!=None):
explore_childs(node.rchild, current_level+1, target_level) # recurse right
def print_level(self, level):
if (level > self.get_height()):
pass # throw error
explore_childs(root, 0, level)
Node contains:
State(an integer unique ID of the node of the graph), Parent (a node in the search tree that generated this node), Path cost(a cost associated with current node)
"""A node in a search tree. Contains a pointer to the parent (the node
that this is a successor of) and to the state for this node
representing actual graph node (graph node ID)."""
#given function
def __init__(self, state, parent=None, path_cost=0):
"""Create a search tree Node"""
self.state = state
self.parent = parent
self.path_cost = path_cost
self.depth = 0
if parent:
self.depth = parent.depth + 1
#given function
def child_node(self, graph, state):
"""Create a new search node and mark it as a child.
The child node will have current node as a parent."""
next_node = Node(state,
parent=self,
path_cost=self.path_cost + 1)
return next_node
# expand function was given to us
def expand(self, graph):
"""List the nodes reachable in one step from this node i.e
neighbours of the current node."""
return [self.child_node(graph, state)
for state in graph.neighbors(self.state)]
Here we have implement the bfs function as our assignment.
def bfs(graph, start, dest):
to_check = []
visited = set()
to_check.insert(0, start)
#while to_check has nodes
while to_check:
next_node = queue.pop()
visited.add(next_node)
for sibling in next_node.expand(graph):
if sibling not in visited and sibling not in to_check:
if sibling == dest:
return sibling
else:
to_check.insert(0, next_node)
The code I ran using the above functions:
start_graph_state = 3470524959
destination_graph_state = 5674155097
start = Node(state=start_graph_state)
dest = Node(state=destination_graph_state)
solution_node = bfs(graph, start, dest)
I'm supposed to plot an appropriate graph, but I've commented out and instead I was running function to check whether the nodes were being iterated correctly. But I keep getting stuck on the starting node because it is never placed in set nor is it removed from the to_check queue even after I have popped it out. (Even when the destination node is directly one edge away, I get an infinite loop) I'm very new to Python and I feel that I'm just stuck on simple syntax problems, but I'm confused. Any help would be appreciated!
You should make sure that you use a FIFO queue (such as the one available in collections).
The general structure of the traversal would be as follows:
from collections import deque
q = deque()
q.append(firstNode)
visited = set()
while q:
node = q.popleft()
if node in visited: continue
visited.add(node)
for nextNode in node.neighbours:
q.append(nextNode)
# perform required processing on node
.getSuccessorStates(node) returns the correct successor states (children). I have tested this method on its own using the graph provided in this problem. The problem lies when I add recursion to the mix for this iterative deepening depth-first search algorithm. When I check the value of the self.parent dictionary after everything has been returned in order to backtrack and find the shortest path, some nodes in the dictionary are matched with an incorrect parent. I'm 99% sure it has to do with recursion when I store self[parent] = node, because getSuccessorStates gives the correct children for the given node, but self.parent contains some values with wrong parent to children pairings. I'm wondering if I put self.parent[child] = node in the wrong place in the code?
def __init__(self, graph):
self.graph = graph
self.nodesCreated = 0
self.maxFrontier = 0
self.maxExplored = 0
self.parent = {}
def recursive_depth_limited_search(self, node, limit):
if node == self.getGraph().getGoalState():
return node
elif limit == 0:
return "cutoff"
else:
cutoff_occurred = False
for child in self.getGraph().getSuccessorStates(node):
self.setNodesCreated(self.getNodesCreated() + 1)
self.parent[child] = node
result = self.recursive_depth_limited_search(child, limit - 1)
if result == "cutoff":
cutoff_occurred = True
elif result != "No solution found":
return result
if cutoff_occurred:
return "cutoff"
else:
return "No solution found"
BFS requires O(b^d) memory, whereas IDDFS is known to run in only O(bd) memory. However, when I profile these two implementations they turn out to use exactly the same amount of RAM - what am I missing?
I'm using a Tree class with a branching factor or 10 to run the tests:
class Tree(object):
def __init__(self, value):
self.key = value
self.children = [ ]
def insert(self, value):
if len(self.children) == 0:
self.children = [ Tree(value) for x in range(10) ]
else:
for ch in self.children:
ch.insert(value)
My implementation of iddfs:
def iddfs(t):
for i in range(0,8):
printGivenLevel(t, i)
def printGivenLevel(t, level):
if not t:
return
if level == 1:
pass
elif level > 1:
for ch in t.children:
printGivenLevel(ch, level - 1)
BFS is
def bfs(t):
currLevel = [t]
nextLevel = []
while currLevel:
for node in currLevel:
if node:
nextLevel.extend([ x for x in node.children ])
currLevel = nextLevel
nextLevel = []
The code is not really doing anything, just looping through the whole tree.
I'm using https://github.com/fabianp/memory_profiler to profile the code.
IDDFS's memory benefits only apply to an implicit tree, where nodes are generated as they're reached and discarded soon after. With a tree represented completely in memory, the tree itself already takes O(b^d) memory, and the memory required for either IDDFS or BFS is minor in comparison.