4

If anyone is interested, I have posted this question to Code Review with a bounty.

View it here


This is not a traditional maze where you find the shortest path (like the gif on the left). In order to solve it, you need to visit every available node before reaching the end, where once a node is visited it turns into a wall (like the gif on the right).

Incorrect maze gif       Correct maze gif

My current solution works quickly for smaller mazes or ones with a lot of walls, such as this, usually finding a path within a couple seconds. But it takes a lot longer as the size of the maze increases or has more open space, such as this (nearly 5 minutes to find a path). Ideally I would like solve mazes up to a 15x20 size in ~30 seconds.

Here is an overview:

  1. Input the maze (2D list of Tile objects), start node , and end node to the MazeSolver class.

  2. A neighboring node is chosen (up, right, down, left).

  3. If that node is_open(), then check if it is_safe() to visit. A node is safe if visiting it will not obstruct our path to any other open node in the maze. This involves an A* search from that node to every other open node, to quickly check if the path exists (every node returned in the path can be skipped for its own search to reduce the number of A* searches).

  4. If it is_safe(), visit the node and link their next and prev attributes.

  5. If the node is not open or not safe, add it to the closed list.

  6. If all 4 neighbors are in closed, backtrack to the previous node.

  7. Repeat 2-6 until end is reached, return the path if found.

At this point I am unsure how to improve the algorithm. I am aware of techniques like cython to speed up the execution time of the your code, but my real goal is to add some logic to make the solution smarter and faster. (Although feel free to recommend these techniques as well, I don't imagine multiprocessing could work here?).

I believe adding some logic as to how a neighbor is chosen may be the next step. Currently, a direction is picked from the list MazeSolver.map, and is used until the neighbor in that direction is not open. Then the next one in the list is chosen, and it just cycles through in order. So there is no intelligent decision making for choosing the neighbor.

Many path finding algorithms assign weights and scores, but how can I tell if one neighbor is more important now than another? The start and end positions can be anywhere in the maze, and you have to visit every node, so the distance to the end node seems insignificant. Or is there a way to predict that a node is not safe without having to do an A* search with each other node? Perhaps separating the maze into smaller chunks and then combining them afterwards would make a difference? All suggestions are welcome, even an entirely new method of solving.

Here is the code.

class Tile:

    def __init__(self, row, column):
        self.position = (row, column)
        self.mode = 1    # 1 = open, 0 = closed (wall)
        self.next = self.prev = None
        self.closed = []

    def __add__(self, other):
        return (self.position[0] + other[0], self.position[1] + other[1])


class MazeSolver:

    def __init__(self, maze, start, end):
        self.maze = maze
        self.h, self.w = len(maze) - 1, len(maze[0]) - 1
        self.start = maze[start[0]][start[1]]
        self.end = maze[end[0]][end[1]]
        self.current = self.start
        self.map = [(-1, 0), (0, 1), (1, 0), (0, -1)] # Up, right, down, left

    def solve(self):
        i = 0
        while self.current != self.end:
            node = self.current + self.map[i]
            if self.is_open(node):
                if self.is_safe(node):
                    # Link two nodes like a Linked List 
                    self.current.next = self.maze[node[0]][node[1]]
                    self.current.next.prev = self.current
                    self.current.mode -= 1
                    self.current = self.current.next
                    continue
                else:
                    self.current.closed.append(node)
            else:
                i += 1 if i < 3 else -3 # Cycle through indexes in self.map

            if len(self.current.closed) == 4:
                if self.current == self.start:
                    # No where to go from starting node, no path exists.
                    return 0
                self.current.closed = []
                self.current = self.current.prev
                self.current.mode += 1
                self.current.closed.append(self.current.next.position)

        return self.get_path()

    def is_open(self, node):
        '''Check if node is open (mode = 1)'''
        if node in self.current.closed:
            return 0
        elif any([node[0]>self.h, node[0]<0, node[1]>self.w, node[1]<0]):
            # Node is out of bounds
            self.current.closed.append(node)
            return 0
        elif self.maze[node[0]][node[1]].mode == 0:
            self.current.closed.append(node)
        return self.maze[node[0]][node[1]].mode

    def is_safe(self, node):
        '''Check if path is obstructed when node is visitied'''
        nodes = [t.position for row in self.maze for t in row if t.mode > 0]
        nodes.remove(self.current.position)
        # Sorting positions by greatest manhattan distance (which reduces calls to astar)
        # decreases solve time for the small maze but increases it for the large maze.
        # Thus at some point the cost of sorting outweighs the benefit of fewer A* searches.
        # So I have left it commented out:
        #nodes.sort(reverse=True, key=lambda x: abs(node[0] - x[0]) + abs(node[1] - x[1]))
        board = [[tile.mode for tile in row] for row in self.maze]
        board[self.current.position[0]][self.current.position[1]] = 0
        checked = []

        for goal in nodes:
            if goal in checked:
                continue
            sub_maze = self.astar(board, node, goal)
            if not sub_maze:
                return False
            else:
                checked = list(set(checked + sub_maze))
        return True

    def astar(self, maze, start, end):
        '''An implementation of the A* search algorithm'''
        start_node = Node(None, start)
        end_node = Node(None, end)
        open_list = [start_node]
        closed_list = []

        while len(open_list) > 0:
            current_node = open_list[0]
            current_index = 0
            for index, item in enumerate(open_list):
                if item.f < current_node.f:
                    current_node = item
                    current_index = index

            open_list.pop(current_index)
            closed_list.append(current_node)

            if current_node == end_node:
                path = []
                current = current_node
                while current is not None:
                    path.append(current.position)
                    current = current.parent
                return path

            children = []
            for new_position in [(0, -1), (0, 1), (-1, 0), (1, 0)]:
                node_position = (current_node.position[0] + new_position[0], current_node.position[1] + new_position[1])
                if node_position[0] > (len(maze) - 1) or node_position[0] < 0 or node_position[1] > (len(maze[0]) -1) or node_position[1] < 0:
                    continue
                if maze[node_position[0]][node_position[1]] == 0:
                    continue
                new_node = Node(current_node, node_position)
                children.append(new_node)

            for child in children:
                if child in closed_list:
                    continue
                child.g = current_node.g + 1
                child.h = ((child.position[0] - end_node.position[0])**2) + ((child.position[1] - end_node.position[1])**2)
                child.f = child.g + child.h
                if child in open_list:
                    if child.g > open_list[open_list.index(child)].g:
                        continue
                open_list.append(child)

        return []

    def get_path(self):
        path = []
        pointer = self.start
        while pointer is not None:
            path.append(pointer.position)
            pointer = pointer.next
        return path


class Node:
    '''Only used by the MazeSolver.astar() function'''
    def __init__(self, parent=None, position=None):
        self.parent = parent
        self.position = position
        self.g = self.h = self.f = 0

    def __eq__(self, other):
        return self.position == other.position

If you would like to run the mazes from the pictures I linked above (Small, Large):

import time

def small_maze():
    maze = [[Tile(r, c) for c in range(11)] for r in range(4)]
    maze[1][1].mode = 0
    for i in range(11):
        if i not in [3, 8]:
            maze[3][i].mode = 0
    return maze

def large_maze():
    maze = [[Tile(r, c) for c in range(15)] for r in range(10)]
    for i in range(5, 8):
        maze[4][i].mode = 0
    maze[5][5].mode = maze[5][7].mode = maze[6][5].mode = 0
    return maze

C = MazeSolver(small_maze(), (3, 8), (3, 3)) #(large_maze(), (2, 2), (5, 6))
t = time.time()
path = C.solve()
print(round(time.time() - t, 2), f'seconds\n\n{path}')

# The end node should always have some walls around it to avoid
# the need to check if it was reached prematurely.
alec
  • 5,799
  • 1
  • 7
  • 20
  • Interesting. I can see the logic in my head on how one would go about hitting all fields but not quite sure how to translate that into code. It would start with a 2d array and then you would have to write the logic to find the shortest path to the wall and then from there some kind of logic to accomplish the rest. Maybe. There are a lot of variables in a larger grid. It may be difficult to find an efficient path. – Mike - SMT Feb 25 '20 at 18:11
  • 1
    I am thinking this question might be better asked on Code Review. From my testing there are no errors so nothing "wrong" with your code and because of this I am not sure it is suited for SO. – Mike - SMT Feb 25 '20 at 18:20
  • @Mike-SMT Thank you for the reply, locating the nearest wall might be a good start. I understand why you say it might be better for Code Review. I've spent a long time on this problem and was hoping to place a bounty on this question in 2 days when it's eligible to increase the chances of getting a good answer. And I can only do that here because I don't have any points on Code Review. – alec Feb 25 '20 at 18:27
  • Well good luck. It might get moved though. That said maybe even finding the nearest corner would be a good start. – Mike - SMT Feb 25 '20 at 18:29
  • Y'all shouldn't have closed this question. It literally contains desired behavior (faster solution to problem), specific problem (maze explained at the top) and the shortest code to reproduce the example. – alec Feb 25 '20 at 18:46
  • Once again Stack Overflow is of no help despite my crafting a good, detailed, complete question. – alec Feb 25 '20 at 18:49
  • 2
    Again this reads more like a Code Review post than an SO post. I personally didn't vote to close but I can see why they did. Thought I am not sure the reason for the close vote was accurate. But it may still be ok for SO. There is the specific issue of speed on larger mazes. Although I am not sure that qualifies as a problem. I have voted to reopen as I think this post deserves to be looked at. – Mike - SMT Feb 25 '20 at 18:54
  • Thanks dude. Anyways I have gone ahead and posted it to Code Review. – alec Feb 25 '20 at 19:26

0 Answers0