diff --git a/search.py b/search.py index ff39abdf9..c4f90be01 100644 --- a/search.py +++ b/search.py @@ -532,6 +532,102 @@ def h(self, node): # ______________________________________________________________________________ +class NPuzzle(Problem): + """Generalization of the Eight Puzzle problem. The problem consists of sliding tiles numbered from 1 to N ^2 on a NxN board, + where one of the squares is a blank. The state is represented as a tuple of length N^2, + where element at index i represents the tile number at index i , where '0' denotes empty square index""" + def __init__(self, initial=None, goal=None, size=3, heuristic='hamming', shuffle=10): + """Args: + intial: intiail state of puzzle. If not passed will default to goal state + size: number of rows and columns in puzzle + heuristic: heuristic used in astat search + shuffle: number of times to perfrom random action from the initial state. + Returns: + Problem instance modeling any size of sliding puzzle""" + self.size = size + self.heuristic = heuristic + goal = goal or (tuple(range(1,size*size, 1)) + (0, )) + initial = initial or (tuple(range(1,size*size, 1)) + (0, )) + if shuffle: + initial = self.shuffle_state(shuffle, list(initial)) + assert len(goal) == size*size, "length of goal tuple should be {length}".format(length=size*size) + assert len(initial) == size * size, "length of initial tuple should be {length}".format(length=size * size) + assert self.check_solvability(initial), "The puzzle is not solvable from the given initial state!" + Problem.__init__(self, initial, goal) + + def shuffle_state(self, shuffle, state): + """Perform random action from the initial state""" + for _ in range(shuffle): + action = random.sample(self.actions(state),1)[0] + state = self.result(state,action) + return state + + def find_blank_square(self, state): + """Return the index of the blank square in a given state""" + return state.index(0) + + def actions(self, state): + """Return the actions that can be executed in the given state. + The result would be a list of maximally four directions, since there are only four possible actions + in any given state of the environment""" + possible_actions = ['UP', 'DOWN', 'LEFT', 'RIGHT'] + index_blank_square = self.find_blank_square(state) + if index_blank_square % self.size == 0: + possible_actions.remove('LEFT') + if index_blank_square < self.size: + possible_actions.remove('UP') + if index_blank_square % self.size == self.size -1 : + possible_actions.remove('RIGHT') + if index_blank_square > self.size*self.size - self.size - 1: + possible_actions.remove('DOWN') + return possible_actions + + def result(self, state, action): + """Given state and action, return a new state that is the result of the action. + Action is assumed to be a valid action in the state""" + index_blank_square = self.find_blank_square(state) + new_state = list(state) + delta = {'UP': -self.size, 'DOWN': self.size, 'LEFT': -1, 'RIGHT': 1} + neighbor = index_blank_square + delta[action] + new_state[index_blank_square], new_state[neighbor] = new_state[neighbor], new_state[index_blank_square] + return tuple(new_state) + + def goal_test(self, state): + """Given a state, return True if state is a goal state or False otherwise""" + return state == self.goal + + def check_solvability(self, state): + """Checks if the given state is solvable. Whether or not puzzle is solvable depends on size being even or odd. + If N is odd, then puzzle instance is solvable if number of inversions is even in the input state. + If N is even, puzzle instance is solvable if the blank is on an even row counting from the bottom (second-last, fourth-last, etc.) and number of inversions is odd + or the blank is on an odd row counting from the bottom (last, third-last, fifth-last, etc.) and number of inversions is even." + reference : https://www.geeksforgeeks.org/check-instance-15-puzzle-solvable/""" + solvable = True + inversions = 0 + for i in range(len(state)-1): + for j in range(i+1, len(state)): + if state[i] != 0 and state[j] != 0 and state[i] > state[j]: + inversions += 1 + if self.size % 2 != 0: + solvable = True if (inversions % 2 == 0 ) else False + elif self.find_blank_square(state) // self.size % 2 == 0 and inversions % 2 != 0 : + solvable = True + elif self.find_blank_square(state) // self.size % 2 != 0 and inversions % 2 == 0: + solvable = True + else: + solvable = False + return solvable + + def h(self, node): + """Return the heuristic value for a given state. Default heuristic function used is + h(n) = number of misplaced tiles""" + heuristics = {'hamming':self.hamming_distance_heuristic(node)} + return heuristics.get(self.heuristic) + + def hamming_distance_heuristic(self, node): + return hamming_distance(node.state, self.goal) + + class TravelingSalesman(Problem): """ The problem of finding the shortest Hamiltonian cycle (tour) that visits every city exactly once and returns to the start.""" diff --git a/tests/test_search.py b/tests/test_search.py index 52d1f6a66..52c54c9ed 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -110,6 +110,15 @@ def test_pour_problem(): assert any(level == 4 for level in solution.state) +def test_n_puzzle(): + # NPuzzle generalizes EightPuzzle; a one-move-from-goal 3x3 instance is solved by A* + npuzzle = NPuzzle(initial=(1, 2, 3, 4, 5, 6, 0, 7, 8), size=3, shuffle=0) + assert astar_search(npuzzle).solution() == ['RIGHT', 'RIGHT'] + # a shuffled instance is always solvable and reaches the goal + solved = astar_search(NPuzzle(size=3, shuffle=15)) + assert solved is not None + + def test_find_blank_square(): assert eight_puzzle.find_blank_square((0, 1, 2, 3, 4, 5, 6, 7, 8)) == 0 assert eight_puzzle.find_blank_square((6, 3, 5, 1, 8, 4, 2, 0, 7)) == 7