8

There's this game on iOS and Andriod called Puzzle Number 9 (I'm in no way affiliated with the creators). You start with a 3x3 grid where the numbers 1 to 9 are placed randomly on the board. Then you combine adjacent numbers (trace a path) to add up to 9. The final node in the path becomes a 9 and all the other numbers increase by 1. You combine the same multiples of 9 together where the end node becomes twice the number and the starting node goes back to one. For example, if you start with

1 2 3
5 4 6
7 8 9 

you can start with 2-3-4 and end up with

1 3 4
5 9 6
7 8 9

and then combine the two 9's

1 3 4
5 1 6
7 8 18

The goal of the game is to reach 1152. Basically it's like 2048 but without stochastic elements. The game ends when you run out of numbers that will sum to 9, for example

8 7 6
5 5 5
9 1 72

I wrote a simple depth-first search on python it works for some puzzles but I run out of memory for other puzzles:

import sys
import Queue

conf = "213 547 689"
grid1 = []
for y in conf.split():
    for x in y:
        grid1.append(int(x))

a = []
explored = set()
sol = Queue.LifoQueue()

def tostr(node):
    s = ''
    for i in range(0,9):
        s += str(node[i]) + ' '
    return s

def printsol():
    while not sol.empty():
        print sol.get()        

def same(x, y, grid):
    for n in neighbors(y):
        ng = grid[:]
        if grid[n] == x and n != y:
            if x == 576:
                printsol()
                sys.exit()
            ng[n] = 2*x
            ng[y] = 1
            sol.put(tostr(ng))
            same(2*x, n, ng)
            solve(ng, grid)
            sol.get()
            ng[n] = 1
            ng[y] = 2*x
            sol.put(tostr(ng))
            same(2*x, y, ng)
            solve(ng, grid)
            sol.get()

##succeeding functions are edited versions of Boggle solver from http://stackoverflow.com/questions/746082/how-to-find-list-of-possible-words-from-a-letter-matrix-boggle-solver
def solve(grid2, grid1):
    for i in range(0,9):
        if grid2[i] < 9 and tostr(grid2) not in explored:
            for result in extending(grid2[i], (i,), grid2):
                newgrid = grid2[:]
                y = len(result) - 1
                for j in range(0, y):
                    newgrid[result[j]] += 1
                newgrid[result[y]] = 9
                sol.put(tostr(newgrid))
                if tostr(newgrid) not in explored:
                    same(9, result[y], newgrid)
                    solve(newgrid, grid2)
                sol.get()
    explored.add(tostr(grid2))

def extending(add, path, grid2):
    if add == 9:
        yield path
    for n in neighbors(path[-1]):
        if n not in path:
            add1 = add + grid2[n]
            if add1 <= 9:
                for result in extending(add1, path + (n,), grid2):
                    yield result

def neighbors(n):
    for nx in range(max(0, n%3-1), min(n%3+2, 3)):
        for ny in range(max(0, n/3-1), min(n/3+2, 3)):
            yield ny*3+nx

sol.put(tostr(grid1))
solve(grid1, grid1)

How do you make this more efficient? If not that, what would be a good heuristic for an informed search? I was thinking of taking the absolute difference of the average of the numbers on the board from a certain number but what would be a good number?

Shapi
  • 5,493
  • 4
  • 28
  • 39
Bruha
  • 81
  • 3
  • `you combine adjacent numbers`. how do you define `adjacent`? do you have that when they have a common edge or side? – sve Oct 09 '15 at 10:15
  • `You combine the same multiples of 9`. is this allowed only adjacent elements? – sve Oct 09 '15 at 10:16
  • you trace a path and the numbers must be connected somehow (above, below, left, right, diagonally) and the the same multiples of 9 must be adjacent as well – Bruha Oct 09 '15 at 10:18
  • don't you combine only two number at time and not long paths paths? [video](https://www.youtube.com/watch?v=6QDzJj8ZWK8) – sve Oct 09 '15 at 14:02
  • @svs you can combine paths of any length of adjacent elements that sum to 9, or only 2 adjacent elements that are multiples of 9 (including 9). You can move up, down, left, right or diagonally. – גלעד ברקן Oct 09 '15 at 15:53
  • @svs I think its a random placement of 1,2...9 – גלעד ברקן Oct 09 '15 at 15:58
  • `if x == 576:` why do you check this? – sve Oct 09 '15 at 20:18

3 Answers3

1

The goal of the game is to reach 1152

I've managed to do it! Here is a sample output of the search. On the first row the three numbers are: score of the state, the depth of the graph, and the maximum element from the matrix. On the next three is the matrix itself:

...
-577 246 576
  36  288    9 
   1    1  576 
 144   36   72 

-577 245 576
  36  288   18 
  18    1  576 
 144    9   72 

-577 249 576
   1  288    9 
   1  288  576 
   1    1    1 

-577 245 576
  36  288    1 
  18   18  576 
 144    9   72 

-577 250 576
   1    1    9 
   1  576  576 
   1    1    1 

-1153 251 1152
   1    1    9 
   1    1 1152 
   1    1    1 

-1153 251 1152
   1    1    9 
   1 1152    1 
   1    1    1 

-577 250 576
   1  576    9 
   1    1  576 
   1    1    1 

-1153 251 1152
   1    1    9 
   1    1 1152 
   1    1    1 

-1153 251 1152
   1 1152    9 
   1    1    1 
   1    1    1 
...

As you can see in order to get score 1152 you have to explore pretty deep. Take also into account that the branching factor is very big and you could see that the problem is challenging. You have to do ~250 moves in order to get the score 1152. Assuming that it takes you 10 seconds per move you'll be playing the game for 40min!!!

What I did was I associated a score for each node/state and during the exploration I only kept the top 1000 nodes/states. This ensures that we explore only relevant nodes. So what was left was engineering the score functions/heuristics. Here what score functions I've tried:

  • maximal element from the matrix
  • depth of the node
  • sum of the elements in the matrix
  • score which tells for how many elements divisible by 9 the is neighboring element which is also divisible by 9
  • number of distinct elements in the matrix
  • number of distinct elements in the matrix above 9
  • number of distinct elements in the matrix below 2
  • score which tells for how many elements divisible by 9 the is neighboring element which is also divisible by 9 and its twice smaller

Using simple heuristics you could build up more complex. What I ended up with is the heuristics which is a sum of only the first and the last in the above-mentioned list. Picking more restrictive heuristics messes up the search. Here's the code (python 3):

import random
from queue import PriorityQueue


dx = [0,  0, -1, -1, -1,  1, 1, 1]
dy = [-1, 1, -1,  0,  1, -1, 0, 1]


class State:
    def __init__(self, a, depth):
        self.depth = depth
        if len(a) == 9:
            self.a = (tuple(a[:3]), tuple(a[3:6]), tuple(a[6:]))
        if len(a) == 3:
            self.a = tuple(map(tuple, a))

    @staticmethod
    def check(i):
        return 0 <= i < 3

    def get_9_moves(self):
        ans = []
        for i in range(3):
            for j in range(3):
                if self.a[i][j] % 9 == 0:

                    for k in range(len(dx)):
                        ni, nj = i + dx[k], j + dy[k]
                        if State.check(ni) and State.check(nj):
                            if self.a[ni][nj] % 9 == 0 and \
                               self.a[i][j] == self.a[ni][nj]:
                                ans.append(((i, j), (ni, nj)))
        return ans

    def get_sum_moves_rec(self, i, j, cur_sum, cur_cells, ans):
        if cur_sum > 9:
            return
        if cur_sum == 9 and len(cur_cells) > 1:
            ans.append(tuple(cur_cells))
        for k in range(len(dx)):
            ni, nj = i + dx[k], j + dy[k]
            pair = (ni, nj)
            if self.check(ni) and self.check(nj) and pair not in cur_cells:
                cur_cells.append(pair)
                self.get_sum_moves_rec(ni, nj, cur_sum + self.a[ni][nj],  cur_cells, ans)
                cur_cells.pop()

    def get_sum_moves(self):
        ans = []
        for i in range(3):
            for j in range(3):
                self.get_sum_moves_rec(i, j, self.a[i][j], [(i, j)], ans)
        return ans

    def get_neighbours(self):
        neighbours = []
        moves = self.get_sum_moves()
        for move in moves:
            a = list(map(list, self.a))
            for i in range(len(move)):
                x, y = move[i]
                if i == len(move)-1:
                    a[x][y] = 9
                else:
                    a[x][y] += 1
            neighbours.append(State(a, self.depth+1))
        moves = self.get_9_moves()
        for move in moves:
            a = list(map(list, self.a))
            a[move[0][0]][move[0][1]] = 1
            a[move[1][0]][move[1][1]] *= 2
            neighbours.append(State(a, self.depth+1))
        return neighbours

    def get_value(self):
        return max(map(max, self.a))

    # 576
    def get_score0(self):
        return -self.get_value()

    def get_score1(self):
        ans = 0
        for i in range(3):
            for j in range(3):
                if self.a[i][j] % 9 == 0:
                    ans += 1
        return ans

    # 36
    def get_score2(self):
        ans = 0
        for i in range(3):
            for j in range(3):
                if self.a[i][j] < 9:
                    ans += 1
        return ans

    # achieves 1152 but rather slow
    def get_score3(self):
        return -self.depth

    # 36
    def get_score4(self):
        ans = 0
        for i in range(3):
            for j in range(3):
                if self.a[i][j] == 1:
                    ans += 1
        return ans

    # 288
    def get_score5(self):
        return -sum(map(sum, self.a))

    def get_score6(self):
        t = []
        for i in range(3):
            for j in range(3):
                if self.a[i][j] % 9 == 0:
                    t.append((self.a[i][j], (i, j)))
        t.sort(reverse=True)
        ans = 0
        for i in range(len(t) - 1):
            pairi = t[i][1]
            pairj = t[i+1][1]

            if abs(pairi[0]-pairj[0]) <= 1 and abs(pairi[1]-pairj[1]) <= 1:
                ans += 1
            break
        return -ans

    def get_score7(self):
        t = []
        for i in range(3):
            for j in range(3):
                if self.a[i][j] % 9 == 0:
                    t.append(self.a[i][j])
        t.sort(reverse=True)
        ans = 0
        for i in range(len(t) - 1):

            if t[i] // t[i+1] == 2:
                ans += 1
            break
        return -ans

    def get_score8(self):
        t = []
        for e in self.a:
            t.extend(e)
        return len(list(filter(lambda x: x >= 9,t)))

    def get_score9(self):
        t = []
        for e in self.a:
            t.extend(e)
        return len(list(filter(lambda x: x <= 2,t)))

    def get_score10(self):
        t = []
        for e in self.a:
            t.extend(e)
        return len(set(filter(lambda x: x >= 9,t)))



    def get_score_mix(self):
        # achieves 1152
        return self.get_score0() + self.get_score6()

    def __repr__(self):
        ans = ''
        for i in range(3):
            for j in range(3):
                ans += '{0:4d} '.format(self.a[i][j])
            ans += '\n'
        return ans

    def __hash__(self):
        return  hash(self.a)

    def __lt__(self, other):
        # hash(self) < hash(other)
        pass


class Solver:

    @staticmethod
    def strategy1(s):
        visited = set()
        while True:
            # print(s)
            neighbours = s.get_neighbours()
            if len(neighbours) == 0:
                break
            ns = None
            for i in range(30):
                ns = random.choice(neighbours)
                if not ns in visited:
                    s = ns
                    break
            if ns is None:
                break
        print(s.get_value())

    @staticmethod
    def strategy2(s):
        visited = set()
        max_depth = 6
        max_value = 0
        calls = 0

        def dfs(state, depth):
            # print(state)
            nonlocal max_value, calls

            calls += 1
            if depth > max_depth:
                return
            if state in visited:
                return
            visited.add(state)
            max_value = max(max_value, s.get_value())

            for new_state in state.get_neighbours():
                dfs(new_state, depth + 1)

        dfs(s, 0)
        print(max_value)
        print(calls)

    @staticmethod
    def strategy3(s):

        visited = set()
        pq = PriorityQueue(maxsize=1000)
        pq.put((0, s))
        visited.add(s)
        max_value = 0

        while not pq.empty():
            score, state = pq.get()
            # print(' score0  score1  score2  score3  score4  score5  score6  score7  score8  score9 score10')
            # print('{0:7d} {1:7d} {2:7d} {3:7d} {4:7d} {5:7d} {6:7d} {7:7d} {8:7d} {9:7d} {10:7d}'.format(state.get_score0(), state.get_score1(), state.get_score2(),
            #                                                                                              state.get_score3(), state.get_score4(), state.get_score5(),
            #                                                                                              state.get_score6(), state.get_score7(), state.get_score8(),
            #                                                                                              state.get_score9(), state.get_score10()))
            print(score, state.depth, state.get_value())
            print(state)
            max_value = max(max_value, state.get_value())

            for new_state in state.get_neighbours():
                # print(random.randint(0, 10))
                if new_state not in visited:
                    visited.add(new_state)
                    pq._put((new_state.get_score_mix(), new_state))
        print(max_value)


start = list(range(1, 10))
random.shuffle(start)
s = State(start, 0)
Solver.strategy3(s)
# s  = State([1,    1,   18, 18,   18,   36, 18 ,  18,    2  ], 0)
# print(s)
#
# for n in s.get_neighbours():
#     print(n)

What's next?

Since the procedure is not very performant ( it takes ~ 1 min to find the answer) one could try to find better heuristics which would be useful for the search. It seems that they should be very loose trying to model the requirement otherwise they would mess up the search.

sve
  • 4,336
  • 1
  • 19
  • 30
  • Are you using the max size of the pqueue to limit the number of options you're trying, while still finding a close to optimum solution? If so, I might try that. I just set mine to infinite, but then I take the first solution I find, without regards to how many steps it takes. Also, I like your method "get_sum_moves." :) – Caleb Mauer Oct 11 '15 at 19:58
  • @CalebMauer yeah, were are setting max priority queue size to limit both memory usage and time exploration. – sve Oct 11 '15 at 20:00
0

I wrote something a while ago and published it here: http://sourceforge.net/p/puzzlenumber9

Its kind of a brute-force search but I sort the options returned at each depth, as well as limit the depth to make it faster. I just can't remember right now how I sort and limit the depth, LOL. (I'll take a look at the code and add that later when I have time...I think the code is just taking a set small number of the results at each depth where the last run had the highest sum.)

I do remember that after feeling satisfied to see the program return a solution, entering the path moves one by one on the device seemed quite tedious :)

What I like about this code is that it was haphazardly put-together to basically "do the job," without effort towards finding the most terse and efficient approach. I especially like the long case ix of list, iconoclastically defying functional simplification.

Haskell code:

{-# OPTIONS_GHC -O2 #-}
import Data.List (sortBy,nubBy)
import Data.Ord (compare)
import System.Time

main = do  
    putStrLn "Puzzle Number 9 Solver Copyright May 2015 alhambra1"
    putStrLn "\nEnter 'e' at any time to exit"
    putStrLn "\nEnter target number"
    target <- getLine  
    if null target  
        then main
        else if head target == 'e'    
                then return ()        
                else do 
                      putStrLn "Enter number of moves at each choice point (density, 3 to 6 recommended)"  
                      density <- getLine  
                      if null density  
                          then main
                          else if head density == 'e'    
                                  then return ()        
                                  else do 
                                        putStrLn "Enter board numbers separated by spaces"  
                                        board <- getLine  
                                        if null board  
                                            then main
                                            else if head board == 'e'    
                                                    then return ()        
                                                    else do 
                                                        putStrLn ""
                                                        time1 <- getClockTime 
                                                        let b = map (\x -> read x :: Int) (take 9 $ words board)
                                                            t = read (head (words target)) :: Int
                                                            d = read (head (words density)) :: Int
                                                        print (map reverse $ reverse $ head $ take 1 $ f t b [] d)
                                                        time2 <- getClockTime
                                                        putStrLn ""
                                                        print (timeDiffToString $ diffClockTimes time2 time1)
                                                        putStrLn ""
                                                        exit

exit = do
     putStrLn "Enter 'a' to start again or 'e' to exit"
     line <- getLine
     if null line 
        then exit
             else if head line == 'a'
                     then do putStrLn ""
                             main
                          else if head line == 'e'
                                  then return ()
                                  else exit

f target board paths toTake
  | not (null hasTarget) = [(((\(x,y,z)-> z) . head $ hasTarget):paths)]
  | null ms              = []
  | otherwise            = do (s,bd,idxs) <- take toTake (sortBy (\(x,y,z) (x',y',z') -> compare x' x) ms')
                              f target bd (idxs:paths) toTake
 where hasTarget = filter ((==target) . (\(x,y,z)-> x)) ms
       ms = moves board
       ms' = nubBy (\(x,y,z) (x',y',z') -> let a = drop 1 (init z)
                                               b = drop 1 (init z')
                                           in if not (null a) && not (null b)
                                                 then a == b
                                                 else False) ms

moves board = do j <- [1..9]
                 let num = board !! (j - 1)
                     board' = (take (j - 1) board) ++ [num + 1] ++ (drop j board)
                 moves' j board' num [j] 0 num
 where moves' ix board s idxs prev next
        | (s == 9 || multiple) && (length idxs > 1) = [(s,board',idxs)]
        | s > 9 && mod s 9 /= 0 = []
        | otherwise = case ix of
            1 -> if elem 2 idxs then [] else moves' 2 board' (s + b) (2:idxs) next b
                 ++ (if elem 4 idxs then [] else moves' 4 board' (s + d) (4:idxs) next d)
                 ++ (if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e)
            2 -> if elem 1 idxs then [] else moves' 1 board' (s + a) (1:idxs) next a
                 ++ (if elem 3 idxs then [] else moves' 3 board' (s + c) (3:idxs) next c)
                 ++ (if elem 4 idxs then [] else moves' 4 board' (s + d) (4:idxs) next d)
                 ++ (if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e)
                 ++ (if elem 6 idxs then [] else moves' 6 board' (s + f) (6:idxs) next f)
            3 -> if elem 2 idxs then [] else moves' 2 board' (s + b) (2:idxs) next b
                 ++ (if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e)
                 ++ (if elem 6 idxs then [] else moves' 6 board' (s + f) (6:idxs) next f)
            4 -> if elem 1 idxs then [] else moves' 1 board' (s + a) (1:idxs) next a
                 ++ (if elem 2 idxs then [] else moves' 2 board' (s + b) (2:idxs) next b)
                 ++ (if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e)
                 ++ (if elem 7 idxs then [] else moves' 7 board' (s + g) (7:idxs) next g)
                 ++ (if elem 8 idxs then [] else moves' 8 board' (s + h) (8:idxs) next h)
            5 -> if elem 1 idxs then [] else moves' 1 board' (s + a) (1:idxs) next a
                 ++ (if elem 2 idxs then [] else moves' 2 board' (s + b) (2:idxs) next b)
                 ++ (if elem 3 idxs then [] else moves' 3 board' (s + c) (3:idxs) next c)
                 ++ (if elem 4 idxs then [] else moves' 4 board' (s + d) (4:idxs) next d)
                 ++ (if elem 6 idxs then [] else moves' 6 board' (s + f) (6:idxs) next f)
                 ++ (if elem 7 idxs then [] else moves' 7 board' (s + g) (7:idxs) next g)
                 ++ (if elem 8 idxs then [] else moves' 8 board' (s + h) (8:idxs) next h)
                 ++ (if elem 9 idxs then [] else moves' 9 board' (s + i) (9:idxs) next i)
            6 -> if elem 2 idxs then [] else moves' 2 board' (s + b) (2:idxs) next b
                 ++ (if elem 3 idxs then [] else moves' 3 board' (s + c) (3:idxs) next c)
                 ++ (if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e)
                 ++ (if elem 8 idxs then [] else moves' 8 board' (s + h) (8:idxs) next h)
                 ++ (if elem 9 idxs then [] else moves' 9 board' (s + i) (9:idxs) next i)
            7 -> if elem 4 idxs then [] else moves' 4 board' (s + d) (4:idxs) next d
                 ++ (if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e)
                 ++ (if elem 8 idxs then [] else moves' 8 board' (s + h) (8:idxs) next h)
            8 -> if elem 4 idxs then [] else moves' 4 board' (s + d) (4:idxs) next d
                 ++ (if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e)
                 ++ (if elem 6 idxs then [] else moves' 6 board' (s + f) (6:idxs) next f)
                 ++ (if elem 7 idxs then [] else moves' 7 board' (s + g) (7:idxs) next g)
                 ++ (if elem 9 idxs then [] else moves' 9 board' (s + i) (9:idxs) next i)
            9 -> if elem 5 idxs then [] else moves' 5 board' (s + e) (5:idxs) next e
                 ++ (if elem 6 idxs then [] else moves' 6 board' (s + f) (6:idxs) next f)
                 ++ (if elem 8 idxs then [] else moves' 8 board' (s + h) (8:idxs) next h)
          where multiple = length idxs == 2 && prev == next && mod s 9 == 0
                [a,b,c,d,e,f,g,h,i] = board
                board' = if s == 9
                            then (take (headIdxs - 1) board) ++ [9] ++ (drop headIdxs board)
                            else if multiple
                                    then board''
                                    else (take (headIdxs - 1) board) ++ [next + 1] ++ (drop headIdxs board)
                board'' = map (\(x,y) -> if x == headIdxs then y * 2 else if x == last idxs then 1 else y) (zip [1..] board)
                headIdxs = head idxs
גלעד ברקן
  • 23,602
  • 3
  • 25
  • 61
0
  • Depth first - Evaluate greater depth first. Drastically reduces memory usage and solves the problem quicker. In Puzzle Number 9 there is an upper bound on the number of moves it takes to solve, so depth first is safe to use for this problem.
  • Sum of all numbers less than 9 - Prefer having more numbers to work with so that you don't end up in a state with all ones.
  • Use a set to track previous states - Use a set to keep track of which states have been visited. Cuts down on run time quite a bit.

I made a Python program to solve this and the only real intelligence in the order that it tries moves is those two things. Worst case time for solving seems to take about 30 seconds. Average is more like 3.

Depth first search combined with Python garbage collection kept memory usage below 50MB.

Caleb Mauer
  • 662
  • 6
  • 11