ASPN ActiveState Programmer Network  
ActiveState, a division of Sophos
/ Home / Perl / PHP / Python / Tcl / XSLT /
/ Safari / My ASPN /
Cookbooks | Documentation | Mailing Lists | Modules | News Feeds | Products | User Groups
Submit Recipe
My Recipes

All Recipes
All Cookbooks


View by Category

Title: Amaze - An object-oriented maze generator/solver in Python
Submitter: Anand Pillai (other recipes)
Last Updated: 2006/07/26
Version no: 1.9
Category: Algorithms

 

Not Rated yet


Description:

This is a simple maze generator & solver written in Python. It is written as a game, consisting of classes which can read mazes from STDIN or a file. It provides a a random maze generator game, which can generate mazes of any dimension and solve it. Use it for fun and learning.

Source: Text Source

"""
Amaze - A completely object-oriented Pythonic maze generator/solver.
This can generate random mazes and solve them. It should be
able to solve any kind of maze and inform you in case a maze is
unsolveable.

This uses a very simple representation of a mze. A maze is
represented as an mxn matrix with each point value being either
0 or 1. Points with value 0 represent paths and those with
value 1 represent blocks. The problem is to find a path from
point A to point B in the matrix.

The matrix is represented internally as a list of lists.

Have fun :-)

"""

import sys
import random
import optparse

class MazeReaderException(Exception):
    pass

class MazeReader(object):
    """ Read a maze from different input sources """

    STDIN = 0
    FILE = 1
    SOCKET = 2

    def __init__(self):
        self.maze_rows = []
        pass

    def readStdin(self):
        """ Read a maze from standard input """

        print 'Enter a maze'
        print 'You can enter a maze row by row'
        print
        
        data = raw_input('Enter the dimension of the maze as Width X Height: ')
        w, h = data.split()
        w, h  = int(w), int(h)
        
        for x in range(h):
            row = ''
            while not row:
                row = raw_input('Enter row number %d: ' % (x+1))
            row = row.split()
            if len(row) != w:
                raise MazeReaderException,'invalid size of maze row'
            self.maze_rows.append(row)

    def readFile(self):
        """ Read a maze from an input file """

        fname = raw_input('Enter maze filename: ')
        try:
            f = open(fname)
            lines = f.readlines()
            f.close()
            # See if this is a valid maze
            lines = [ line for line in lines if line.strip() ]
            w = len(lines[0].split())
            for line in lines:
                row = line.split()
                if len(row) != w:
                    raise MazeReaderException, 'Invalid maze file - error in maze dimensions'
                else:
                    self.maze_rows.append(row)
        except (IOError, OSError), e:
            raise MazeReaderException, str(e)

    def getData(self):
        return self.maze_rows

    def readMaze(self, source=STDIN):
        """ Read a maze from the input source """

        if source==MazeReader.STDIN:
            self.readStdin()
        elif source == MazeReader.FILE:
            self.readFile()

        return self.getData()

class MazeFactory(object):
    """ Factory class for Maze object """

    def makeMaze(self, source=MazeReader.STDIN):
        """ Create a maze and return it. The source is
        read from the 'source' argument """

        reader = MazeReader()
        return Maze(reader.readMaze(source))

    def makeRandomMaze(self, dimension):
        """ Generate a random maze of size dimension x dimension """

        rows = []
        for x in range(dimension):
            row = []
            for y in range(dimension):
                row.append(random.randrange(2))
            random.shuffle(row)
            rows.append(row)

        return Maze(rows)

        
    
class MazeError(Exception):
    """ An exception class for Maze """
    
    pass

class Maze(object):
    """ A class representing a maze """

    def __init__(self, rows=[[]]):
        self._rows = rows
        self.__validate()
        self.__normalize()

    def __str__(self):

        s = '\n'
        for row in self._rows:
            for item in row:
                s = ''.join((s,' ',str(item),' '))
            s = ''.join((s,'\n\n'))

        return s
                
    def __validate(self):
        """ Validate the maze """

        # Get length of first row
        width = len(self._rows[0])
        widths = [len(row) for row in self._rows]
        if widths.count(width) != len(widths):
            raise MazeError, 'Invalid maze!'

        self._height = len(self._rows)
        self._width = width

    def __normalize(self):
        """ Normalize the maze """

        # This converts any number > 0 in the maze to 1
        for x in range(len(self._rows)):
            row = self._rows[x]
            row = map(lambda x: min(int(x), 1), row) 
            self._rows[x] = row

    def getHeight(self):
        """ Return the height of the maze """

        return self._height

    def getWidth(self):
        """ Return the width of the maze """

        return self._width

    def validatePoint(self, pt):
        """ Validate the point pt """

        x,y = pt
        w = self._width
        h = self._height
        
        # Don't support Pythonic negative indices
        if x > w - 1 or x<0:
            raise MazeError, 'x co-ordinate out of range!'

        if y > h - 1 or y<0:
            raise MazeError, 'y co-ordinate out of range!'        

        pass
    
    def getItem(self, x, y):
        """ Return the item at location (x,y) """

        self.validatePoint((x,y))
        
        w = self._width
        h = self._height

        # This is based on origin at bottom-left corner
        # y-axis is reversed w.r.t row arrangement
        # Get row
        row = self._rows[h-y-1]
        return row[x]

    def setItem(self, x, y, value):
        """ Set the value at point (x,y) to 'value' """

        h = self._height
        
        self.validatePoint((x,y))
        row = self._rows[h-y-1]
        row[x] = value
        

    def getNeighBours(self, pt):
        """ Return a list of (x,y) locations of the neighbours
        of point pt """

        self.validatePoint(pt)

        x,y = pt
        
        h = self._height
        w = self._width
        
        # There are eight neighbours for any point
        # inside the maze. However, this becomes 3 at
        # the corners and 5 at the edges
        poss_nbors = (x-1,y),(x-1,y+1),(x,y+1),(x+1,y+1),(x+1,y),(x+1,y-1),(x,y-1),(x-1,y-1)

        nbors = []
        for xx,yy in poss_nbors:
            if (xx>=0 and xx<=w-1) and (yy>=0 and yy<=h-1):
                nbors.append((xx,yy))

        return nbors
        
    def getExitPoints(self, pt):
        """ Return a list of exit points at point pt """

        # Get neighbour list and return if the point value
        # is 0

        exits = []
        for xx,yy in self.getNeighBours(pt):
            if self.getItem(xx,yy)==0:
                exits.append((xx,yy))

        return exits

    def getRandomExitPoint(self, pt):
        """ Return a random exit point at point (x,y) """

        return random.choice(self.getExitPoints(pt))

    def getRandomStartPoint(self):
        """ Return a random point as starting point """

        return random.choice(self.getAllZeroPoints())

    def getRandomEndPoint(self):
        """ Return a random point as ending point """

        return random.choice(self.getAllZeroPoints())

    def getAllZeroPoints(self):
        """ Return a list of all points with
        zero value """
        
        points = []
        for x in range(self._width):
            for y in range(self._height):
                if self.getItem(x,y)==0:
                    points.append((x,y))

        return points
        
    def calcDistance(self, pt1, pt2):
        """ Calculate the distance between two points """

        # The points should be given as (x,y) tuples
        self.validatePoint(pt1)
        self.validatePoint(pt2)        
        
        x1,y1 = pt1
        x2,y2 = pt2

        return pow( (pow((x1-x2), 2) + pow((y1-y2),2)), 0.5)

    def calcXDistance(self, pt1, pt2):
        """ Calculate the X distance between two points """

        # The points should be given as (x,y) tuples
        self.validatePoint(pt1)
        self.validatePoint(pt2)
        
        x1, y1 = pt1
        x2, y2 = pt2

        return abs(x1-x2)

    def calcYDistance(self, pt1, pt2):
        """ Calculate the Y distance between two points """

        # The points should be given as (x,y) tuples
        self.validatePoint(pt1)
        self.validatePoint(pt2)
        
        x1, y1 = pt1
        x2, y2 = pt2

        return abs(y1-y2)

    def calcXYDistance(self, pt1, pt2):
        """ Calculate the X-Y distance between two points """

        # The points should be given as (x,y) tuples
        self.validatePoint(pt1)
        self.validatePoint(pt2)
        
        x1, y1 = pt1
        x2, y2 = pt2

        return abs(y1-y2) + abs(x1-x2)
        
    def getData(self):
        """ Return the maze data """

        return self._rows

class MazeSolver(object):
    """ Maze solver class """
    
    def __init__(self, maze):
        self.maze = maze
        self._start = (0,0)
        self._end = (0,0)
        # Current point
        self._current = (0,0)
        # Solve path
        self._path = []
        # Number of cyclic loops
        self._loops = 0
        # Solvable flag
        self.unsolvable = False        
        # xdiff
        self._xdiff = 0.0
        # ydiff
        self._ydiff = 0.0
        # List keeping cycles (generations)
        self.cycles = []
        # Number of retraces
        self._numretrace = 0
        # Map for exit points
        self._pointmap = {}
        # Number of all zero points
        self._numzeropts = 0
        # Map for retraced points
        self._retracemap = {}
        # Cache for keys of above
        self._retracekeycache = []
        # Number of times retracemap is not updated
        # with a new point
        self._lastupdate = 0
        
    def setStartPoint(self, pt):

        self.maze.validatePoint(pt)
        self._start = pt

    def setEndPoint(self, pt):

        self.maze.validatePoint(pt)
        self._end = pt

    def boundaryCheck(self):
        """ Check boundaries of start and end points """

        exits1 = self.getExitPoints(self._start)
        exits2 = self.getExitPoints(self._end)        

        if len(exits1)==0 or len(exits2)==0:
            return False

        return True

    def setCurrentPoint(self, point):
        """ Set the current point """

        # print 'Current point is',point
        self._current = point
        self._xdiff = abs(self._current[0] - self._end[0])
        self._ydiff = abs(self._current[1] - self._end[1])
        
        self._path.append(point)

    def isSolved(self):
        """ Whether the maze is solved """

        return (self._current == self._end)

    def checkDeadLock(self, point1, point2):

        pt1 = self.getClosestPoint(self.getExitPoints(point1))
        pt2 = self.getClosestPoint(self.getExitPoints(point2))

        if pt1==point2 and pt2==point1:
            return True

        return False

    def getExitPoints(self, point):
        """ Get exit points for 'point' """

        points = self._pointmap.get(point)

        if points==None:
            # We are using shortest-distance algorithm
            points = self.maze.getExitPoints(point)
            self._pointmap[point] = points

        return points
        
    def getNextPoint(self):
        """ Get the next point from the current point """

        points = self.getExitPoints(self._current)
        point = self.getBestPoint(points)
        
        while self.checkClosedLoop(point):

            if self.endlessLoop():
                point = None
                break
            
            # Save point
            point2 = point

            point = self.getNextClosestPointNotInPath(points, point2)
            if not point:
                # Try retracing path
                point = self.retracePath()
                
        return point

    def retracePath(self):

        # Retrace point by point in path, till
        # you get to a point which provides an
        # alternative.
        
        print 'Retracing...'
        path = self._path[:]
        path.reverse()

        self._numretrace += 1
        
        try:
            idx = path[1:].index(self._current)
        except:
            idx = path.index(self._current)            

        pathstack = []
        
        for x in range(idx+1, len(path)):
            p = path[x]
            if p in pathstack: continue

            pathstack.append(p)
            if p != self._path[-1]:
                # print 'Current point is',p
                self._path.append(p)

            if p != self._start:
                points = self.getExitPoints(p)
                point = self.getNextClosestPointNotInPath(points, self.getClosestPoint(points))
                self._retracemap[p] = self._retracemap.get(p, 0) + 1
            else:
                # Add path to cycle
                path = self.sortPointsByXYDistance(self._path[:-1])
                self.cycles.append((path, self._path[:-1]))
                # Reset solver state
                self._path = []
                self._loops = 0
                self._retracemap[p] = self._retracemap.get(p, 0) + 1
                
                return p

    def endlessLoop(self):

        
        if self._retracemap:
            # If the retrace map has not been updated for a while
            # increment lastupdate count
            if self._retracemap.keys() == self._retracekeycache:
                self._lastupdate += 1
            self._retracekeycache = self._retracemap.keys()

        # If lastupdate count reaches the total number of points
        # it could mean we exhausted all possible paths.
        if self._lastupdate > self.maze.getWidth()*self.maze.getHeight():
            print 'Exited because of retracekeycache overflow'
            return True

        # If retrace has gone through all zero points and not
        # yet found a solution, then we are hitting a dead-lock.
        elif len(self._retracemap.keys())> self._numzeropts - 1:
            print 'Exited because number of points exceeded zero points'            
            return True
        else:
            # If the retrace path contains only one point
            if len(self._retracemap.keys())==1:
                val = self._retracemap.get(self._retracemap.keys()[0])
                # If we hit the same point more than the number of
                # zero points in the maze, it signals a dead-lock.
                if val>self._numzeropts:
                    print 'Exited because we are oscillating'                    
                    return True
        
        return False
        
    def checkClosedLoop(self, point):
        """ See if this point is causing a closed loop """

        l = range(0, len(self._path)-1, 2)
        l.reverse()
        
        for x in l:
            if self._path[x] == point:
                self._loops += 1
                # loop = tuple(self._path[x:])
                # print 'Point=>',point, len(self._path)
                return True

        return False
    
    def getBestPoint(self, points):
        """ Get the best point """

        if len(self.cycles)==0:
            # First try min point
            point = self.getClosestPoint(points)
            # Save point
            point2 = point
            # Point for trying alternate
            altpoint = point

            point = self.getNextClosestPointNotInPath(points, point2)
            if not point:
                point = point2
        else:
            allcycles=[]
            map(allcycles.extend, [item[0] for item in self.cycles])
            if self._current==self._start or self._current in allcycles:
                # print 'FOUND IT =>',self._current
                history = []
                for path in [item[1] for item in self.cycles]:
                    path.reverse()
                    try:
                        idx = path.index(self._current)
                        next = path[idx-1]
                        history.append(next)
                    except:
                        pass
                point = self.getDifferentPoint(points, history)
                if not point:
                    point = self.getAlternatePoint(points, history[-1])
            else:
                # Not there 
                point2 = self.getClosestPoint(points)
                point = self.getNextClosestPointNotInPath(points, point2)
                if not point:
                    point = point2
                
            altpoint = point
            
        return point

    def sortPointsByXYDistance(self, points):
        """ Sort list of points by X+Y distance """

        distances = [(self.maze.calcXYDistance(point, self._end), point) for point in points]
        distances.sort()
            
        points2 = [item[1] for item in distances]

        return points2
    
    def sortPointsByDistances(self, points):
        """ Sort points according to distance from end point """

        if self._xdiff>self._ydiff:
            distances = [(self.maze.calcXDistance(point, self._end), point) for point in points]
        elif self._xdiff<self._ydiff:
            distances = [(self.maze.calcYDistance(point, self._end), point) for point in points]
        else:
            distances = [(self.maze.calcXYDistance(point, self._end), point) for point in points]

        distances.sort()
        points2 = [item[1] for item in distances]

        return points2

    def sortPoints(self, points):

        return self.sortPointsByDistances(points)
        
    def getClosestPoint(self, points):
        """ Return the closest point from current
        to the end point from a list of exit points """

        points2 = self.sortPoints(points)
        
        # Min distance point
        closest = points2[0]
        return closest

    def getDifferentPoint(self, points1, points2):
        """ Return a random point in points1 which is not
        in points2 """

        l = [pt for pt in points1 if pt not in points2]
        if l:
            return random.choice(l)

        return None
        
    def getAlternatePoint(self, points, point):
        """ Get alternate point """

        print 'Trying alternate...',self._current, point
        points2 = points[:]

        if point in points2:
            points2.remove(point)
        if points2:
            return random.choice(points2)
        else:
            return point
        
        return None

    def getNextClosestPoint(self, points, point):
        """ After point 'point' get the next closest point
        to the end point from list of points """

        points2 = self.sortPoints(points)
        idx = points2.index(point)

        try:
            return points2[idx+1]
        except:
            return None

    def getNextClosestPointNotInPath(self, points, point):

        point2 = point
        while point2 in self._path:
            point2 = self.getNextClosestPoint(points, point2)
            
        return point2

    def solve(self):
        """ Solve the maze """

        print 'Starting point is', self._start
        print 'Ending point is', self._end        
        
        # First check if both start and end are same
        if self._start == self._end:
            print 'Start/end points are the same. Trivial maze.'
            print [self._start, self._end]
            return None
        
        # Check boundary conditions
        if not self.boundaryCheck():
            print 'Either start/end point are unreachable. Maze cannot be solved.'
            return None

        # Proper maze
        print 'Maze is a proper maze.'

        # Initialize solver
        self.setCurrentPoint(self._start)
        self._numzeropts = len(self.maze.getAllZeroPoints())
        
        self.unsolvable = False

        print 'Solving...'
        while not self.isSolved():
            pt = self.getNextPoint()
            
            if pt:
                self.setCurrentPoint(pt)
            else:
                print 'Dead-lock - maze unsolvable'
                self.unsolvable = True
                break

        if not self.unsolvable:
            print 'Final solution path is',self._path
            print 'Length of path is',len(self._path)
        else:
            print 'Path till deadlock is',self._path
            print 'Length of path is',len(self._path)            

        # if self.cycles:
        #    print 'Path with all cycles is',
        #    l = []
        #    map(l.extend, [item[1] for item in self.cycles])
        #    l.extend(self._path)
        #    print l
            
        self.printResult()

    def printResult(self):
        """ Print the maze showing the path """

        
        for x,y in self._path:
            self.maze.setItem(x,y,'*')

        self.maze.setItem(self._start[0], self._start[1], 'S')
        self.maze.setItem(self._end[0], self._end[1], 'E')        

        if self.unsolvable:
            print 'Maze with final path'
        else:
            print 'Maze with solution path'
            
        print self.maze

        
class MazeGame(object):

    def __init__(self, w=0, h=0):
        self._start = (0,0)
        self._end = (0,0)
        
    def createMaze(self):
        return None

    def getStartEndPoints(self, maze):
        return None
    
    def runGame(self):

        maze = self.createMaze()
        if not maze:
            return None
        
        print maze
        self.getStartEndPoints(maze)
        
        # Dump it to maze.txt
        open('maze.txt','w').write(str(maze))
        
        solver = MazeSolver(maze)

        open ('maze_pts.txt','w').write(str(self._start) + ' ' + str(self._end) + '\n')
        solver.setStartPoint(self._start)
        solver.setEndPoint(self._end)
        solver.solve()

class InteractiveMazeGame(MazeGame):

    def createMaze(self):
        f = MazeFactory()
        return f.makeMaze()

    def getStartEndPoints(self, maze):

        while True:
            try:
                pt1 = raw_input('Enter starting point: ')
                x,y = pt1.split()
                self._start = (int(x), int(y))
                maze.validatePoint(self._start)
                break
            except:
                pass

        while True:
            try:
                pt2 = raw_input('Enter ending point: ')
                x,y = pt2.split()
                self._end = (int(x), int(y))        
                maze.validatePoint(self._end)
                break
            except:
                pass        
        
class FilebasedMazeGame(MazeGame):

    def createMaze(self):
        f = MazeFactory()
        return f.makeMaze(MazeReader.FILE)

    def getStartEndPoints(self, maze):

        filename = raw_input('Enter point filename: ')        
        try:
            line = open(filename).readlines()[0].strip()
            l = line.split(')')
            self._start = eval(l[0].strip() + ')')
            self._end = eval(l[1].strip() + ')')
        except (OSError, IOError, Exception), e:
            print e
            sys.exit(0)
        
class RandomMazeGame(MazeGame):

    def __init__(self, w=0, h=0):
        super(RandomMazeGame, self).__init__(w, h)
        self._dimension = w
        
    def createMaze(self):
        f = MazeFactory()
        return f.makeRandomMaze(self._dimension)    

    def getStartEndPoints(self, maze):

        pt1, pt2 = (0,0), (0,0)
        while pt1 == pt2:
            pt1 = maze.getRandomStartPoint()
            pt2 = maze.getRandomEndPoint()

        self._start = pt1
        self._end = pt2

class RandomMazeGame2(RandomMazeGame):
    """ Random maze game with distant points """

    def getStartEndPoints(self, maze):

        pt1, pt2 = (0,0), (0,0)
        flag = True
        while flag:
            pt1 = maze.getRandomStartPoint()
            pt2 = maze.getRandomEndPoint()
            # Try till points are at least
            # half maze apart
            xdist = maze.calcXDistance(pt1, pt2)
            ydist = maze.calcYDistance(pt1, pt2)            
            if xdist>=float(maze.getWidth())/2.0 or \
               ydist>=float(maze.getHeight())/2.0:
                flag = False
            
        self._start = pt1
        self._end = pt2    

def main():
    
    p = optparse.OptionParser()
    p.add_option('-r','--random',help='Play the random game',
             dest='random',action='store_true',default=False)
    p.add_option('-R','--random2',help='Play the random game with distant points',
             dest='Random',action='store_true',default=False)    
    p.add_option('-f','--file',help='Play the file-based game',
             dest='file',action='store_true',default=False)
    p.add_option('-i','--interactive',help='Play the interactive game',
             dest='interact',action='store_true',default=False)
    p.add_option('-d','--dimension',help='Matrix dimension (required for random games)',
             dest='dimension', metavar="DIMENSION")
             
    options, args = p.parse_args()
    d = options.__dict__

    if d.get('random') or d.get('Random'):
        dim = d.get('dimension')
        if not dim:
            sys.exit('Random games require -d or --dimension option.')
        if d.get('random'):
            game = RandomMazeGame(int(dim))
            game.runGame()
        elif d.get('Random'):
            game = RandomMazeGame2(int(dim))
            game.runGame()
    elif d.get('file'):
        game = FilebasedMazeGame()
        game.runGame()
    elif d.get('interactive'):
        game = InteractiveMazeGame()
        game.runGame()        
        
    
if __name__ == "__main__":
    if len(sys.argv)==1:
        sys.argv.append('-h')
    
    main()

Discussion:

I recently had an interview with a well-known software company when the interviewer asked me a generic algorithm to solve a maze. I came up with a quick one and he seemed to have been satisfied by it. Later on I felt I shoud implement a maze solver in pure Python from first principles to see if I could actually write one that ain't broken. The result is the above piece of code.

It also illustrates a few creational design patterns.

The program seems to be able to solve all mazes it generates correctly. At least in 40 consecutive tests, it did not break!

[Update] - Modified logic. Removed random point selection and updated with
getting the best point not in path and fall-back to closest point. Also added
start point retracing logic in case of deadlocks. Seems to work a lot better now.

[Updated, 16/07/06] - Fixed an error in passing point to getAlternatePoint.

[Updated, 20/07/06] - Completely rewrote logic. Use short x,y distance logic with refinement in every step. Backtracking logic now keeps history of previous failed paths and uses it to find new paths so we don't hit the same path again and again. Dead-lock detection uses better rules instead of heuristics or magic numbers. Added command line arguments.

[Updated, 24/7/06] - Updated logic for exit using retracekey cache.
[Updated, 26/7/06] - Removed logic of tryalternate.

Have fun!



Add comment

No comments.



Highest rated recipes:

1. A simple XML-RPC server

2. Web service accessible ...

3. IPy Notify

4. Treat the Win32 Registry ...

5. a friendly mkdir()

6. Wrapping template engine ...

7. Assignment in expression

8. Changing return value ...

9. Implementation of sets ...

10. bag collection class




Privacy Policy | Email Opt-out | Feedback | Syndication
© 2006 ActiveState Software Inc. All rights reserved.