Katamino is a puzzle game where the goal is to fit a set of pieces on a board.

Katamino

I played a lot with this game as a kid, but I figured it would also be a good puzzle to write a program that can solve it. This is a typical problem of recursive algorithm where one can write a function that takes the current board as input as well as the remaining pieces to place on the board. This function places one piece on the board and then calls itself again with the new board and the pieces minus the one that was just placed on the board. It gives us the following solver function, which is fairly simple:

def solve_katamino(board, pieces):
   # Make sure the puzzle is solvable
   assert np.sum(board.board == 0)//5 == len(pieces)
   if len(pieces) == 0:
       return True, board
   # Pick first piece
   piece = pieces[0]
   # Finds the different possible orientations for this piece
   oriented_pieces = piece.orientations()
   for oriented_piece in oriented_pieces:
       # Finds the possible ways to place that piece on the board
       possible_boards = board.possibilities(oriented_piece)
       # Loop through each possibility and recursively call main function
       for possible_board in possible_boards:
           sol, b = solve_katamino(possible_board, pieces[1:])
           if sol:
               return True, b
   return False, None

I decided to represent each piece as a list of 0 and 1. For example the orange piece is:

'orange':[[1,0,0,0],
          [1,1,1,1]]

And I built a Piece class to be able to flip and rotate each piece in order to give all the possible orientations of a piece via the orientations method. Note here that duplicates orientations are removed by overriding the built-in method __eq__. As a result, the piece [[1,1,1,1,1]] will have 2 orientations (one horizontal and one vertical) while more complex shapes like the orange piece above have 8.

Finally, we need to implement a Board class to keep track of where we placed the pieces during the search. This is where we implement the possibilities method which is a key part of our main function and enumerates the possible boards that can be made with the oriented piece. First, we get the list of valid_locations where this piece can be placed on the board within its limits (regardless of what is on the board at this point), then loops through each location and place the piece on the board with place_piece and check whether the piece is sitting on another piece. The last (and defintely not least) thing we need to check is whether the board we have is even a valid one and worth pursuing further. Let's take an example. Imagine you placed the orange piece on the board and it looks like this:

[[0,1,0,0],
 [0,1,0,0],
 [0,1,0,0],
 [1,1,0,0],
 [0,0,0,0]]

Well this board is not worth continuing because no piece can ever fit in the hole left in the upper left corner. Because there is only 3 empty squares left and katamino pieces are all of size 5. So in order to do that, we run a Breadth-First-Search on the 0 on the board and check that the remaining holes are all multiples of 5 so that there is the possibility for a piece to fit.

import numpy as np


class Piece:
    def __init__(self, p, name, i):
        self.p = np.array(p)
        self.h, self.w = self.p.shape
        self.name = name
        self.id = i
        self.shape = self.p.shape
    
    def mirror(self):
        new_orientation = np.fliplr(self.p)
        return Piece(new_orientation, self.name, self.id)
                              
    def rotate(self, r):
        new_orientation = np.rot90(self.p, r)
        return Piece(new_orientation, self.name, self.id)
    
    def orientations(self):
        orientations = []
        for r in range(4):
            rot = self.rotate(r)
            if all(rot != p for p in orientations):
                orientations.append(rot)
        for r in range(4):
            rot = self.mirror().rotate(r)
            if all(rot != p for p in orientations):
                orientations.append(rot)
        return orientations
                   
    def __eq__(self, other):
        return self.shape == other.shape and np.all(self.p == other.p)


class Board:
    def __init__(self, size=4, b=None):
        if b is None:
            self.board = np.array([[0] * size,
                                   [0] * size,
                                   [0] * size,
                                   [0] * size,
                                   [0] * size])
        else:
            self.board = b
        self.H, self.W = self.board.shape
        self.sol = []
        
    @classmethod
    def from_solution(cls, sol):
        b = Board(size=len(sol))
        for piece, loc in sol:
            b = b.place_piece(piece, loc, use_id=True)
        return b
    
    def valid_locations(self, piece):
        valid_loc = []
        for i in range(0, self.H - piece.h + 1):
            for j in range(0, self.W - piece.w + 1):
                valid_loc.append((i,j))
        return valid_loc
    
    def place_piece(self, piece, loc, use_id=False):
        i,j = loc
        b = self.board.copy()            
        if use_id:
            b[i:i+piece.h,j:j+piece.w] = b[i:i+piece.h,j:j+piece.w] + piece.id*piece.p
        else:
            b[i:i+piece.h,j:j+piece.w] += piece.p 
        board = Board(b=b)
        board.sol = self.sol + [(piece, loc)]
        return board
    
    def is_finished(self):
        return np.all(self.board == 1)
    
    def possibilities(self, piece):
        possible_boards = []
        valid_locations = self.valid_locations(piece)
        for loc in valid_locations:
            new_board = self.place_piece(piece, loc)
            # Check if the piece is over another one
            if np.any(new_board.board == 2):
                continue
            # Check if it's even worth continuing...
            ccs = connected_components(new_board.board, 0)
            if any(len(cc) % 5 != 0 for cc in ccs):
                continue
            else:
                possible_boards.append(new_board)
        return possible_boards

def bfs(M, i, j, value=0):
    """
    Breadth First Search algorithm implementation on a binary matrix
    start_loc = (i,j)
    Returns an island = list of tuples
    """
    if M[i, j] != value:
        return []
    visited = set([(i,j)])
    queue = [(i,j)]
    while len(queue) > 0:
        x, y = queue.pop(0)
        neighbors = neighboring_squares(M, x, y, value)
        for neighbor in neighbors:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    return list(visited)

def neighboring_squares(M, i, j, value):
    """
    Returns the list of connected land from a location
    """
    m, n = M.shape
    neighboring_squares = []
    if i > 0 and M[i-1,j] == value:
        neighboring_squares.append((i-1,j))
    if i < m - 1 and M[i+1,j] == value:
        neighboring_squares.append((i+1,j))
    if j > 0 and M[i,j-1] == value:
        neighboring_squares.append((i,j-1))
    if j < n - 1 and M[i,j+1] == value:
        neighboring_squares.append((i,j+1))
    return neighboring_squares

def connected_components(M, value):
    """Return the set of connected components (a list of sets) of the matrix"""
    x, y = np.where(M == value)
    remaining_squares = set([t for t in zip(x, y)])
    connected_components = []
    while len(remaining_squares) > 0:
        random_square = remaining_squares.pop()
        remaining_squares.add(random_square)
        cc = bfs(M, random_square[0], random_square[1], value)
        connected_components.append(cc)
        for square in cc:
            remaining_squares.remove(square)
    return connected_components

Now solving the puzzles takes between 1sec and ~5min on my 2017 Mac (1.8GHz Intel Core) depending on the number of pieces in the puzzle. The results look like below:

PIECES = {'lightbrown': [[1,1,1,1,1]],
          'orange':[[1,0,0,0],
                    [1,1,1,1]],
          'darkbrown': [[0,1,0,0],
                        [1,1,1,1]],
          'purple': [[1,1,0,0],
                     [0,1,1,1]],
          'darkblue': [[1,0,0],
                       [1,0,0],
                       [1,1,1]],
          'pink': [[1,1,1],
                   [1,1,0]],
          'yellow': [[1,0,1],
                     [1,1,1]],
          'lightblue': [[1,1,0],
                        [0,1,0],
                        [0,1,1]],
          'grey': [[0,1,1],
                   [1,1,0],
                   [0,1,0]],
          'darkgreen': [[1,1,1],
                        [0,1,0],
                        [0,1,0]],
          'lightgreen':[[1,1,0],
                        [0,1,1],
                        [0,0,1]],
          'red': [[0,1,0],
                  [1,1,1],
                  [0,1,0]]
          }

PROBLEMS = [[2,3,6,11,8,4,5,10,9,1,7],
            [2,3,7,9,8,5,6,4,10,1,12]]

pieces = {i+1:Piece(p, name, i+1) for i, (name, p) in enumerate(PIECES.items())}
    
k = 0
n = 6

katamino_problem = PROBLEMS[k][:n]
board = Board(size=n)
found_solution, b = solve_katamino(board, [pieces[i] for i in katamino_problem])
if found_solution:
    board_solved = Board.from_solution(b.sol)
    print(board_solved.board)

And the output is:

[[4, 3, 3, 3, 3,11],
 [4, 8, 8, 3,11,11],
 [4, 4, 8,11,11, 6],
 [2, 4, 8, 8, 6, 6],
 [2, 2, 2, 2, 6, 6]]