Home > Artificial Intelligence > Flower disk rotation puzzle solver: Python vs Prolog

Flower disk rotation puzzle solver: Python vs Prolog

This post is a followup to the Equation puzzle solver: Python vs Prolog. We again compare Python and Prolog, but look at a different problem: a disk rotation puzzle.

The flower disk rotation puzzle

The flower disk rotation puzzle consists of 4 wooden, stacked disks. The disks are connected at their center via a pole, so that they can be rotated. Each disk contains holes that are arranged around the disk center in the form of a flower. The holes are uniformly spread, so that there is space for 12 holes – but each disk only has 9 of these 12 possible holes (the position of holes differ per disk). The remaining 3 areas are instead made of solid wood. The goal is to rotate the disks so that all holes are covered by at least one of the disks (as we have a total amount of 4*3=12 solid areas for a total of 12 holes, each solid area must cover exactly one hole).

For purpose of purchasing, this puzzle can be found online in appropriate stores, and there exist online descriptions looking at the “hardware” puzzle in more detail (e.g. here).

Solving the flower disk rotation puzzle

There exist 12 possible rotations per disk and 4 disks in total, which leads to a solution space of 12^4 (20.7K solutions and 14.3 bit entropy). But as one disk can be thought of being fixed (because rotation of the other disks is relative to this disk), the relevant solution space is 12^3 (1728 solutions and 10.8 bit entropy). As the solution space is so small, a solver using pure, uninformed brute force search is sufficient (even a breath first search will not cause memory troubles). When adding branch cutting to the search, the solution space gets even smaller: in fact so small, that even a human player can perform an exhaustive search. For our puzzle, branch cutting is done via skipping search building on top of an already invalid solution, e.g. when one hole is covered by more than one solid area.

Of course different “harware” versions of the puzzle exist (holes in the disks are arranged differently). However, the puzzle I purchased was special: when playing around with it I noticed I couldn’t come up with a solution, although the solution space is very small. My puzzle has the following disk setup (each disk is represented by one line, holes are represented by 0, solid areas by 1, and all disks are rotated randomly):

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

Why is that puzzle special? Later we’re going to see: it’s because it has no solution. My guess would be that the first/last disk (depending on the side of the puzzle you’re looking at) was flipped before it got attached to the other disks. When flipping the first/last disk, we later on see that the puzzle has exactly one solution:

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

Python approach

The Python script implements a breadth first search to explore the complete solution space. During search, the implementation counts the nodes in the search tree it visits. The final amount of nodes can be used to evaluate the implementation, as we know there are exactly 12^3 leave nodes (if we keep one of the disks fixed) and 12^0+12^1+12^2 branching nodes on the way (node counting is only implemented in Python and left out in Prolog). When commenting in the line in the script that sorts the “states list” by the state heuristic, the search becomes a best first search – which is implemented in a way that it effectively is a depth first search with branch cutting. If we do branch cutting we of course visit and count less nodes during search.

# Implementation of the flower disk rotation puzzle solver: 4 disks stacked on top of each other, each disk containing 12 fields (3 of them are solid and 9 are holes). The task is to rotate the disks so that all holes are covered by at least one layer of wood. As the disks contain 12 fields, and the sum of all solid fields on all disks is 12 too, each field will be coverd by a single solid field. This scripts solves for possible rotations so that all fields are covered. Solid disk fields are represented by 1, holes by 0.
# Besides searching for solutions this script counts the nodes visited in the search three for evaluation purposes
# 
# Rainhard Findling
# 2014/10
# 
class State():
    """represents a state in the state search tree"""
    def __init__(self, gamefield, rotations=None):
        # sanity checks: gamefield not empty and all rows are of same length
        if(len(gamefield)==0):
            raise ValueError('gamefield empty.')
        l = len(gamefield[0])
        if(l == 0):
            raise ValueError('row length is 0.')
        for row in gamefield:
            if(len(row) != l):
                raise ValueError('rows differ in length.')
        self.gamefield = gamefield
        self.rotations = rotations
        self.heuristics_cache = None
        if(rotations!=None and (len(rotations) != len(gamefield))):
            raise ValueError('rotations and gamefield differ in length.')
        if(rotations==None):
            rotations = []
            for _ in gamefield:
                rotations+=[None]
            # fix first rotation to one to not get redundant solutions
            rotations[0] = 0
            self.__init__(gamefield, rotations)
        
    def shift(self, seq, n):
        n = n % len(seq)
        return seq[n:] + seq[:n]
        
    def __str__(self):
        s = '\n====================================\n'
        for row in self.gamefield:
            s+=str(row)
            s+='\n'
        s+='rotations='
        s+=str(self.rotations)
        s+='\n'
        rows = self.rows_rotated()
        for row in rows:
            s+=str(row)
            s+='\n'
        s+='\n===================================='
        return s
    
    def rows_rotated(self):
        """get rows = gamefield in the rotation caused by this state"""
        rows = []
        for row_index in range(0, len(self.gamefield)):
            row = self.gamefield[row_index]
            rot = self.rotations[row_index]
            if rot == None:
                rot = 0
            rows+=[self.shift(row, rot)]
        return rows
        
    def __repr__(self):
        return self.__str__()
    
    def successors(self):
        """derive all successors of this state by shifting the next row to all possible positions. does not shift the first row, as this would be redundant"""
        # get next row to shift
        row_index = -1
        for r in range(0, len(self.gamefield)):
            if self.rotations[r] == None:
                row_index = r
                break
        if row_index == -1:
            # no more rows to shift, return empty = no successors
            return []
        # generate all successors
        successors = []
        for rot in range(0,len(gamefield[row_index])):
            # generate new rotation set
            rotations = self.rotations[:]
            rotations[row_index] = rot
            suc = State(self.gamefield, rotations) 
            successors+=[suc]
        return successors
    
    def amount_covered(self, col_nr):
        """count how often a certain position is covered by the levels [0,N]. only takes into account levels that have been rotated already."""
        amount = 0
        rows = self.rows_rotated()
        for row_nr in range(0, len(rows)):
            # only count if this level has already been rotated
            if self.rotations[row_nr] != None:
                row = rows[row_nr]
                if row[col_nr] == 1:
                    amount += 1
        return amount
    
    def amount_opened_total(self):
        """amount of fields still openend with current lock position"""
        amount = 0
        for col_nr in range(0,len(self.gamefield[0])):
            if self.amount_covered(col_nr) == 0:
                # this col does not contain a stopper
                amount += 1
        return amount
    
    def is_leave(self):
        """check if the position of all levels is set, so this is a leave state"""
        for r in self.rotations:
            if r == None:
                return False
        return True
    
    def is_solved(self):
        """check if lock is closed so that all positions are covered"""
        return self.amount_opened_total()==0
    
    def is_valid(self):
        """heuristics: check if a solution makes sense. e.g. if a part is covered by two levels the solution is invalid, no matter how much levels have already been aligned."""
        for col_nr in range(0, len(self.gamefield[0])):
            if self.amount_covered(col_nr) > 1:
                # at least one position is covered by multiple layers, solution must be invalid
                return False
        return True
    
    def heuristics(self):
        """ heuristics of how good this solution is [-1,1], with the lowest value being the worst solution and the highest value being the best solution. neutral value = starting value = 0. value is cached after first calculation for performance reasons."""
        if self.heuristics_cache == None:
            # calculate and cache
            if not self.is_valid():
                # cannot be good as it is invalid
                return -1
            h = 0
            # the deeper the better
            h += (self.level() / len(self.gamefield))
            # cache and return
            self.heuristics_cache = h
        return self.heuristics_cache
    
    def level(self):
        """return the level a state is on. if no level is rotated yet, the level is 0, if 1 level is rotated, the level is 1. if all levels are rotated, the level is the amount of levels in total."""
        for row_nr in range(0, len(self.gamefield)):
            if self.rotations[row_nr] == None:
                return row_nr
        return len(self.gamefield)

## original gamefield : no solution
#gamefield = [[0,0,0,0,0,1,0,1,0,0,1,0],
             #[1,0,0,1,0,0,0,0,0,0,0,1],
             #[0,1,0,0,0,0,0,1,0,1,0,0],
             #[0,0,0,1,0,0,1,0,0,0,0,1]] 
# modified gamefield: has one solution
gamefield = [[0,0,0,0,0,1,0,1,0,0,1,0],
             [1,0,0,1,0,0,0,0,0,0,0,1],
             [0,1,0,0,0,0,0,1,0,1,0,0],
             [1,0,0,0,0,1,0,0,1,0,0,0]] 
# measure some stuff
histogram_amount = [0] * (len(gamefield[0]) + 1)
histogram_rotation = []
histogram_tried = [0] * (len(gamefield) + 1) 
for row in gamefield:
    histogram_rotation += [[0]*len(row)]
# do best first search
init_state = State(gamefield)
states = [init_state]
while len(states) > 0:
#     states.sort(key=lambda s:s.heuristics(), reverse=True) # sort states by heuristics. comment out to perform a breadth first instead of a best first search
    # process best solution
    s = states.pop(0)
    histogram_tried[s.level()] += 1
    # do measurements for leave nodes
    if s.is_leave():
        histogram_amount[s.amount_opened_total()] += 1
        rot = s.rotations
        for r_index in range(0, len(rot)):
            r = rot[r_index]
            histogram_rotation[r_index][r] += 1
    # check if this is a leave state - and if, if it solved the problem
    if s.is_leave() and s.is_solved():
        print 'found solution:'
        print s
#         break
    successors = s.successors()
    for suc in successors:
#         if suc.is_valid(): # only produce children of valid solutions. commend out to process full state tree and see statistics for all solutions
        states += [suc]
print 'done, histogram (tried solutions per level):', histogram_tried, ', total=', sum(histogram_tried)
print 'histogram (amount opened): ', histogram_amount
print 'histogram (rotations per row)', histogram_rotation

When we run the Python implementation with our initial disk setup we don’t find a solution:

done, histogram (tried solutions per level): [0, 1, 12, 144, 1728] , total= 1885
histogram (amount opened):  [0, 16, 126, 515, 672, 336, 61, 2, 0, 0, 0, 0, 0]
histogram (rotations per row) [[1728, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144], [144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144], [144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144]]

… but when we use the modified disk setup we find one solution:

found solution:

====================================
[0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0]
[1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1]
[0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0]
[1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0]
rotations=[0, 0, 5, 11]
[0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0]
[1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1]
[0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0]
[0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0]

====================================
done, histogram (tried solutions per level): [0, 1, 12, 144, 1728] , total= 1885
histogram (amount opened):  [1, 9, 146, 485, 697, 325, 63, 2, 0, 0, 0, 0, 0]
histogram (rotations per row) [[1728, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144], [144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144], [144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144, 144]]

Prolog approach

With Prolog, we state what a valid solution – given 4 disks – needs to look like: a) the solution’s first disk will be equal to the initial first disk, all other of the solution’s disks will be rotated versions of the initial disks. b) each hole must be covered by one of the solution’s stacked 4 disks.

% Implementation of the flower disk rotation puzzle solver: 4 disks stacked on top of each other, each disk containing 12 fields (3 of them are solid and 9 are holes). The task is to rotate the disks so that all holes are covered by at least one layer of wood. As the disks contain 12 fields, and the sum of all solid fields on all disks is 12 too, each field will be coverd by a single solid field. This scripts solves for possible rotations so that all fields are covered. Solid disk fields are represented by 1, holes by 0.

% Rainhard Findling
% 10/2014
%
% sample call with no solution:
% solve([0,0,0,0,0,1,0,1,0,0,1,0],[1,0,0,1,0,0,0,0,0,0,0,1],[0,1,0,0,0,0,0,1,0,1,0,0],[0,0,0,1,0,0,1,0,0,0,0,1],R1,R2,R3,R4).
%
% sample call with one solution:
% solve([0,0,0,0,0,1,0,1,0,0,1,0],[1,0,0,1,0,0,0,0,0,0,0,1],[0,1,0,0,0,0,0,1,0,1,0,0],[1,0,0,0,0,1,0,0,1,0,0,0],R1,R2,R3,R4).
% 
solve(L1,L2,L3,L4,R1,R2,R3,R4) :-
  % solution needs to be a rotated version of the original levels (we're not interested in amount of rotation)
  L1=R1,
  rotate(L2,R2,_),
  rotate(L3,R3,_),
  rotate(L4,R4,_),
  % each field needs to be covered by at least one solid field of 1 of the 4 disks (exactly one "1" in our case)
  solid(R1,R2,R3,R4).

% check that each field is coverd by at least one solid part of a disk (exactly one in our case)
solid([],[],[],[]).
solid(H1,H2,H3,H4) :- count([H1,H2,H3,H4],1,1).
solid([H1|D1],[H2|D2],[H3|D3],[H4|D4]) :- solid(H1,H2,H3,H4),
					  solid(D1,D2,D3,D4).

% rotate(+List, +N, -RotatedList)
% True when RotatedList is List rotated N positions to the right
rotate(List, RotatedList, N) :-
    length(List, Length),      % length of list
    append(Front, Back, List), % split L
    length(Back, N),	       % create a list of variables of length N
    Length > N,		       % ensure we don't rotate more than necessary
    append(Back, Front, RotatedList).

% count how often an element occurs in a list
count([],_,0).
count([X|T],X,Y):- count(T,X,Z), Y is 1+Z.
count([X1|T],X,Z):- X1\=X,count(T,X,Z).

As for the Python implementation, the Prolog implementation cannot find a solution to the original disk setup:

?- solve([0,0,0,0,0,1,0,1,0,0,1,0],[1,0,0,1,0,0,0,0,0,0,0,1],[0,1,0,0,0,0,0,1,0,1,0,0],[0,0,0,1,0,0,1,0,0,0,0,1],R1,R2,R3,R4).
false.

… but for the modified disk setup, we find the same single solution as with python:

?- solve([0,0,0,0,0,1,0,1,0,0,1,0],[1,0,0,1,0,0,0,0,0,0,0,1],[0,1,0,0,0,0,0,1,0,1,0,0],[1,0,0,0,0,1,0,0,1,0,0,0],R1,R2,R3,R4).
R1 = [0, 0, 0, 0, 0, 1, 0, 1, 0|...],
R2 = [1, 0, 0, 1, 0, 0, 0, 0, 0|...],
R3 = [0, 0, 1, 0, 1, 0, 0, 0, 1|...],
R4 = [0, 1, 0, 0, 0, 0, 1, 0, 0|...] ;
false.

Conlcusion

As for the last problem, the problem can be solved using both Python and Prolog. And again, Prolog saves us from explicitly implementing the search algorithm, which we need to state explicitly in Python.

  1. No comments yet.
  1. November 2, 2014 at 10:30
  2. November 8, 2014 at 10:38

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: