Multi-Algorithm AI Search Engine

Sophisticated 8-puzzle solver with four distinct search algorithms

Project Overview

Implemented a sophisticated 8-puzzle solver using four distinct search algorithms in Fall 2021, including Depth-First Search, Iterative Deepening Search, and A* search with Manhattan distance and misplaced tile heuristics. The system achieves optimal solutions with a command-line interface and file-based input processing for automated testing and performance comparison.

Problem Domain Analysis

8-Puzzle Game Definition

  • State Space: 9! = 362,880 possible configurations
  • Goal State: Arranged tiles 1-8 with empty space in bottom-right
  • Valid Moves: Up, Down, Left, Right (based on empty space position)
  • Optimal Solution: Minimum number of moves to reach goal state
  • Complexity: NP-complete problem requiring intelligent search strategies

State Representation

class PuzzleState:
    def __init__(self, board, empty_pos=None, parent=None, move=None, depth=0):
        self.board = board  # 3x3 numpy array
        self.empty_pos = empty_pos or self._find_empty_position()
        self.parent = parent
        self.move = move
        self.depth = depth
        self.hash_value = hash(tuple(board.flatten()))
    
    def _find_empty_position(self):
        """Find position of empty tile (represented as 0)"""
        pos = np.where(self.board == 0)
        return (pos[0][0], pos[1][0])
    
    def get_successors(self):
        """Generate all valid successor states"""
        successors = []
        row, col = self.empty_pos
        moves = [('UP', -1, 0), ('DOWN', 1, 0), ('LEFT', 0, -1), ('RIGHT', 0, 1)]
        
        for move_name, dr, dc in moves:
            new_row, new_col = row + dr, col + dc
            if 0 <= new_row < 3 and 0 <= new_col < 3:
                new_board = self.board.copy()
                # Swap empty space with adjacent tile
                new_board[row, col], new_board[new_row, new_col] = \
                    new_board[new_row, new_col], new_board[row, col]
                
                successor = PuzzleState(
                    new_board, (new_row, new_col), self, move_name, self.depth + 1
                )
                successors.append(successor)
        
        return successors

Search Algorithm Implementation

1. Depth-First Search (DFS)

class DepthFirstSearch:
    def __init__(self, max_depth=50):
        self.max_depth = max_depth
        self.visited = set()
        self.nodes_expanded = 0
    
    def search(self, initial_state, goal_state):
        self.visited.clear()
        self.nodes_expanded = 0
        
        stack = [initial_state]
        
        while stack:
            current = stack.pop()
            self.nodes_expanded += 1
            
            if self._is_goal(current, goal_state):
                return self._reconstruct_path(current)
            
            if current.depth >= self.max_depth:
                continue
                
            state_hash = current.hash_value
            if state_hash in self.visited:
                continue
            
            self.visited.add(state_hash)
            
            # Add successors to stack (reverse order for left-to-right processing)
            successors = current.get_successors()
            for successor in reversed(successors):
                if successor.hash_value not in self.visited:
                    stack.append(successor)
        
        return None  # No solution found

2. Iterative Deepening Search (IDS)

class IterativeDeepeningSearch:
    def __init__(self, max_depth=50):
        self.max_depth = max_depth
        self.nodes_expanded = 0
    
    def search(self, initial_state, goal_state):
        self.nodes_expanded = 0
        
        for depth_limit in range(self.max_depth + 1):
            visited = set()
            result = self._depth_limited_search(
                initial_state, goal_state, depth_limit, visited
            )
            
            if result is not None:
                return result
        
        return None  # No solution found within depth limit
    
    def _depth_limited_search(self, state, goal_state, depth_limit, visited):
        self.nodes_expanded += 1
        
        if self._is_goal(state, goal_state):
            return self._reconstruct_path(state)
        
        if state.depth >= depth_limit:
            return None
        
        state_hash = state.hash_value
        if state_hash in visited:
            return None
        
        visited.add(state_hash)
        
        for successor in state.get_successors():
            result = self._depth_limited_search(
                successor, goal_state, depth_limit, visited
            )
            if result is not None:
                return result
        
        return None

3. A* Search with Manhattan Distance Heuristic

class AStarManhattan:
    def __init__(self):
        self.nodes_expanded = 0
        self.goal_positions = self._compute_goal_positions()
    
    def _compute_goal_positions(self):
        """Precompute goal positions for each tile"""
        positions = {}
        goal_board = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 0]])
        
        for i in range(3):
            for j in range(3):
                if goal_board[i, j] != 0:
                    positions[goal_board[i, j]] = (i, j)
        
        return positions
    
    def _manhattan_distance(self, state):
        """Calculate Manhattan distance heuristic"""
        distance = 0
        
        for i in range(3):
            for j in range(3):
                tile = state.board[i, j]
                if tile != 0:  # Skip empty space
                    goal_i, goal_j = self.goal_positions[tile]
                    distance += abs(i - goal_i) + abs(j - goal_j)
        
        return distance
    
    def search(self, initial_state, goal_state):
        self.nodes_expanded = 0
        
        # Priority queue: (f_score, unique_id, state)
        open_set = [(self._manhattan_distance(initial_state), 0, initial_state)]
        closed_set = set()
        unique_id = 1
        
        while open_set:
            _, _, current = heapq.heappop(open_set)
            self.nodes_expanded += 1
            
            if self._is_goal(current, goal_state):
                return self._reconstruct_path(current)
            
            state_hash = current.hash_value
            if state_hash in closed_set:
                continue
            
            closed_set.add(state_hash)
            
            for successor in current.get_successors():
                successor_hash = successor.hash_value
                if successor_hash not in closed_set:
                    g_score = successor.depth
                    h_score = self._manhattan_distance(successor)
                    f_score = g_score + h_score
                    
                    heapq.heappush(open_set, (f_score, unique_id, successor))
                    unique_id += 1
        
        return None

4. A* Search with Misplaced Tiles Heuristic

class AStarMisplacedTiles:
    def __init__(self):
        self.nodes_expanded = 0
    
    def _misplaced_tiles(self, state):
        """Count number of misplaced tiles (excluding empty space)"""
        goal_board = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 0]])
        misplaced = 0
        
        for i in range(3):
            for j in range(3):
                if state.board[i, j] != 0 and state.board[i, j] != goal_board[i, j]:
                    misplaced += 1
        
        return misplaced
    
    def search(self, initial_state, goal_state):
        # Similar implementation to A* Manhattan, but using misplaced tiles heuristic
        self.nodes_expanded = 0
        
        open_set = [(self._misplaced_tiles(initial_state), 0, initial_state)]
        closed_set = set()
        unique_id = 1
        
        while open_set:
            _, _, current = heapq.heappop(open_set)
            self.nodes_expanded += 1
            
            if self._is_goal(current, goal_state):
                return self._reconstruct_path(current)
            
            state_hash = current.hash_value
            if state_hash in closed_set:
                continue
            
            closed_set.add(state_hash)
            
            for successor in current.get_successors():
                successor_hash = successor.hash_value
                if successor_hash not in closed_set:
                    g_score = successor.depth
                    h_score = self._misplaced_tiles(successor)
                    f_score = g_score + h_score
                    
                    heapq.heappush(open_set, (f_score, unique_id, successor))
                    unique_id += 1
        
        return None

Performance Analysis Framework

Algorithm Comparison Metrics

class PerformanceAnalyzer:
    def __init__(self):
        self.algorithms = {
            'DFS': DepthFirstSearch(),
            'IDS': IterativeDeepeningSearch(),
            'A*_Manhattan': AStarManhattan(),
            'A*_Misplaced': AStarMisplacedTiles()
        }
    
    def compare_algorithms(self, test_cases):
        results = {}
        
        for case_name, (initial_state, goal_state) in test_cases.items():
            results[case_name] = {}
            
            for alg_name, algorithm in self.algorithms.items():
                start_time = time.time()
                solution = algorithm.search(initial_state, goal_state)
                end_time = time.time()
                
                results[case_name][alg_name] = {
                    'solution_found': solution is not None,
                    'solution_length': len(solution) if solution else None,
                    'nodes_expanded': algorithm.nodes_expanded,
                    'execution_time': end_time - start_time,
                    'memory_usage': self._get_memory_usage()
                }
        
        return results
    
    def generate_report(self, results):
        """Generate comprehensive performance comparison report"""
        report = []
        report.append("8-Puzzle Solver Performance Analysis")
        report.append("=" * 50)
        
        for case_name, case_results in results.items():
            report.append(f"\nTest Case: {case_name}")
            report.append("-" * 30)
            
            for alg_name, metrics in case_results.items():
                report.append(f"{alg_name}:")
                report.append(f"  Solution Found: {metrics['solution_found']}")
                report.append(f"  Solution Length: {metrics['solution_length']}")
                report.append(f"  Nodes Expanded: {metrics['nodes_expanded']}")
                report.append(f"  Execution Time: {metrics['execution_time']:.4f}s")
                report.append(f"  Memory Usage: {metrics['memory_usage']:.2f}MB")
                report.append("")
        
        return "\n".join(report)

Command-Line Interface

Interactive Puzzle Solver

class PuzzleSolverCLI:
    def __init__(self):
        self.analyzer = PerformanceAnalyzer()
    
    def run(self):
        """Main command-line interface loop"""
        print("8-Puzzle Solver - Multi-Algorithm Search Engine")
        print("=" * 50)
        
        while True:
            print("\nOptions:")
            print("1. Solve puzzle interactively")
            print("2. Load puzzle from file")
            print("3. Run algorithm comparison")
            print("4. Generate random puzzle")
            print("5. Exit")
            
            choice = input("\nEnter your choice (1-5): ").strip()
            
            if choice == '1':
                self._interactive_solve()
            elif choice == '2':
                self._solve_from_file()
            elif choice == '3':
                self._run_comparison()
            elif choice == '4':
                self._generate_random_puzzle()
            elif choice == '5':
                print("Goodbye!")
                break
            else:
                print("Invalid choice. Please try again.")
    
    def _interactive_solve(self):
        """Interactive puzzle input and solving"""
        print("\nEnter the puzzle state (3x3 grid, use 0 for empty space):")
        board = []
        
        for i in range(3):
            while True:
                try:
                    row = list(map(int, input(f"Row {i+1}: ").split()))
                    if len(row) != 3:
                        raise ValueError("Each row must have exactly 3 numbers")
                    board.append(row)
                    break
                except ValueError as e:
                    print(f"Invalid input: {e}. Please try again.")
        
        initial_state = PuzzleState(np.array(board))
        goal_state = PuzzleState(np.array([[1, 2, 3], [4, 5, 6], [7, 8, 0]]))
        
        # Check if puzzle is solvable
        if not self._is_solvable(initial_state):
            print("This puzzle configuration is not solvable!")
            return
        
        print("\nSelect algorithm:")
        algorithms = ['DFS', 'IDS', 'A*_Manhattan', 'A*_Misplaced']
        for i, alg in enumerate(algorithms, 1):
            print(f"{i}. {alg}")
        
        while True:
            try:
                alg_choice = int(input("Enter algorithm choice (1-4): ")) - 1
                if 0 <= alg_choice < len(algorithms):
                    break
                else:
                    print("Invalid choice. Please enter 1-4.")
            except ValueError:
                print("Please enter a valid number.")
        
        algorithm_name = algorithms[alg_choice]
        algorithm = self.analyzer.algorithms[algorithm_name]
        
        print(f"\nSolving puzzle using {algorithm_name}...")
        start_time = time.time()
        solution = algorithm.search(initial_state, goal_state)
        end_time = time.time()
        
        if solution:
            print(f"Solution found in {len(solution)} moves!")
            print(f"Moves: {' -> '.join(solution)}")
            print(f"Nodes expanded: {algorithm.nodes_expanded}")
            print(f"Execution time: {end_time - start_time:.4f} seconds")
        else:
            print("No solution found within the search limits.")

File-Based Input Processing

Automated Testing Framework

def load_test_cases_from_file(filename):
    """Load puzzle test cases from file for batch processing"""
    test_cases = {}
    
    with open(filename, 'r') as file:
        current_case = None
        initial_board = []
        goal_board = []
        reading_initial = False
        reading_goal = False
        
        for line in file:
            line = line.strip()
            
            if line.startswith('CASE:'):
                current_case = line.split(':')[1].strip()
                initial_board = []
                goal_board = []
            elif line == 'INITIAL:':
                reading_initial = True
                reading_goal = False
            elif line == 'GOAL:':
                reading_initial = False
                reading_goal = True
            elif line == 'END':
                if current_case and initial_board and goal_board:
                    initial_state = PuzzleState(np.array(initial_board))
                    goal_state = PuzzleState(np.array(goal_board))
                    test_cases[current_case] = (initial_state, goal_state)
                reading_initial = False
                reading_goal = False
            elif reading_initial and line:
                row = list(map(int, line.split()))
                initial_board.append(row)
            elif reading_goal and line:
                row = list(map(int, line.split()))
                goal_board.append(row)
    
    return test_cases

Key Achievements

Optimality and Performance

  • Optimal Solutions: A* algorithms guarantee shortest path to goal
  • Completeness: All algorithms find solution if one exists (within limits)
  • Efficiency: Manhattan distance heuristic outperforms misplaced tiles
  • Scalability: Handles complex puzzle configurations efficiently

Algorithm Comparison Results

  • A* Manhattan: Best overall performance (fewest nodes expanded)
  • A* Misplaced Tiles: Good performance, simpler heuristic calculation
  • Iterative Deepening: Memory efficient, optimal solutions
  • Depth-First Search: Fastest per node, but may find suboptimal solutions

Technologies Used

  • Python for algorithm implementation and framework development
  • NumPy for efficient array operations and state representation
  • Heapq for priority queue implementation in A* search
  • Time/Memory Profiling for performance analysis
  • File I/O for automated testing and batch processing

The project demonstrates expertise in artificial intelligence search algorithms, algorithm analysis, performance optimization, and software engineering practices essential for AI research, game development, and optimization problem solving.