6
\$\begingroup\$

This is the Google Foobar challenge "Prepare the Bunnies' Escape":

You have maps of parts of the space station, each starting at a prison exit and ending at the door to an escape pod. The map is represented as a matrix of 0s and 1s, where 0s are passable space and 1s are impassable walls. The door out of the prison is at the top left \$(0,0)\$ and the door into an escape pod is at the bottom right \$(w-1,h-1)\$.

Write a function answer(map) that generates the length of the shortest path from the prison door to the escape pod, where you are allowed to remove one wall as part of your remodeling plans. The path length is the total number of nodes you pass through, counting both the entrance and exit nodes. The starting and ending positions are always passable (0). The map will always be solvable, though you may or may not need to remove a wall. The height and width of the map can be from 2 to 20. Moves can only be made in cardinal directions; no diagonal moves are allowed.

Test cases

Input:
    maze = [[0, 1, 1, 0], [0, 0, 0, 1], [1, 1, 0, 0], [1, 1, 1, 0]]
Output:
    7

Input:
    maze = [[0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 0], [0, 0, 0, 0, 0, 0], [0, 1, 1, 1, 1, 1], [0, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0]]
Output:
    11

I found a couple solutions but none seem to be optimal enough.

Solution in Python: BFS shortest path for Google Foobar "Prepare the Bunnies' Escape"

I tried the recommendations in this answer but I am still getting a "time-limit-exceeded" error and I did not have any means to confirm whether the optimisations happened and to what extent they worked. I am fairly new to Python so I might have missed some basic details. Is there any more room for optimisation?

My code after following the instructions is as follows:

from collections import deque

def memodict(f):
     """ Memoization decorator for a function taking a single argument """
     class memodict(dict):

        def __missing__(self, key):
           ret = self[key] = f(key)
           return ret
     return memodict().__getitem__


@memodict
def adjacent_to((maze_dim, point)):
     neighbors = (
         (point[0] - 1, point[1]),
         (point[0], point[1] - 1),
         (point[0], point[1] + 1),
         (point[0] + 1, point[1]))

     return [p for p in neighbors if 0 <= p[0] < maze_dim[0] and 0 <= p[1] < maze_dim[1]]




def removable(maz, ii, jj):
     counter = 0
     for p in adjacent_to(((len(maz), len(maz[0])), (ii, jj))):
         if not maz[p[0]][p[1]]:
             if counter:
                 return True
             counter += 1
     return False


def answer(maze):

     path_length = 0

     if not maze:
         return

     dims = (len(maze), len(maze[0]))
     end_point = (dims[0]-1, dims[1]-1)

     # list of walls that can be removed
     passable_walls = set()
     for i in xrange(dims[0]):
         for j in xrange(dims[1]):
             if maze[i][j] == 1 and removable(maze, i, j):
                 passable_walls.add((i, j))

     shortest_path = 0
     best_possible = dims[0] + dims[1] - 1

     path_mat = [[None] * dims[1] for _ in xrange(dims[0])]  # tracker      matrix for shortest path
     path_mat[dims[0]-1][dims[1]-1] = 0  # set the starting point to destination (lower right corner)

     for wall in passable_walls:
         temp_maze = maze
         if wall:
             temp_maze[wall[0]][wall[1]] = 0

         stat_mat = [['-'] * dims[1] for _ in xrange(dims[0])]  # status of visited and non visited cells

         q = deque()
         q.append(end_point)

         while q:
             curr = q.popleft()

             if curr == (0,0):
                 break

             for next in adjacent_to((dims, curr)):
                 if temp_maze[next[0]][next[1]] == 0:  # Not a wall
                     temp = path_mat[curr[0]][curr[1]] + 1
                     if temp < path_mat[next[0]][next[1]] or path_mat[next[0]][next[1]] == None:  # there is a shorter path to this cell
                         path_mat[next[0]][next[1]] = temp
                     if stat_mat[next[0]][next[1]] != '+':  # Not visited yet
                         q.append(next)

             stat_mat[curr[0]][curr[1]] = '+'  # mark it as visited

         if path_mat[0][0]+1 <= best_possible:
             break        

     if shortest_path == 0 or path_mat[0][0]+1 < shortest_path:
         shortest_path = path_mat[0][0]+1

     return shortest_path
\$\endgroup\$
2
  • 1
    \$\begingroup\$ You can always use timeit or cProfile on both version to check if improvements happened. \$\endgroup\$ Jan 6, 2017 at 9:43
  • \$\begingroup\$ the changes do improve performance but for my test case it seems to be not enough. \$\endgroup\$
    – Devflovv
    Jan 6, 2017 at 22:30

2 Answers 2

11
\$\begingroup\$

The algorithm in the post is to iterate over the walls, and for each wall, to remove that wall from the maze, and then solve the maze using breadth-first-search.

If the maze is \$n×n\$, then there are \$Θ(n^2)\$ walls, and it takes \$Θ(n^2)\$ to do a breadth-first search on the resulting maze, for \$Θ(n^4)\$ overall runtime. No wonder you are exceeding the time limit.

Instead, consider the following approach:

  1. Run a breadth-first search starting at the prison door, to find the distance of each passable space from the prison door.

  2. Run another breadth-first search starting at the escape pod, to find the distance of each passable space from the escape pod.

  3. Now iterate over the walls, and consider removing each wall in turn. You know the distance of each passable space from the prison door and the escape pod, so you can immediately work out the length of the shortest route that passes through the space left by the wall you just removed.

This would have runtime \$Θ(n^2)\$.

\$\endgroup\$
1
  • \$\begingroup\$ Hi Gareth. What is the intuition behind this approach. Please advise. \$\endgroup\$
    – Pbd
    Jun 11, 2019 at 20:17
0
\$\begingroup\$

The approach in the question requires BFS in a loop and essentially ends up with a time complexity of O(m x n x j) where m and n are the number of rows and columns respectively and j is the number of walls.

The currently accepted solution suggests to perform 2 BFS, one from the beginning and other one from the end and then loop over all walls. That will have a complexity of O(2mn + j).

The approach below is better than both because it solves the problem with a single BFS traversal and is therefore the fastest.

Logic:

The question can be solved using BFS with the following modifications:

  1. Each path that'd otherwise have all 0s in a standard BFS algo can have a single 1.

  2. In the BFS queue, each node, in addition to keeping a track of the x and y coordinates, will also store the number of steps (path length) taken to reach there and if the path for that node has already traversed over a 1 or not. So, each node in the queue will have the format - [[row, col], num_steps, one_traversed].

    So while it may look like we need to store the whole paths in the BFS traversal, what we actually need to store is if a 1 has been traversed in the path or not. So, every node will store that info for itself.

  3. We will maintain a visited grid that will keep a track of all nodes that have been visited (to avoid loops) but here's the tricky part - instead of having only 1s and 0s to represent visited or not, this grid will have 4 possible values:

    • -1 is the initial value
    • 0 means visited by a path having NO 1s (all 0s)
    • 1 means visited by a path having a 1
    • 2 means visited by both types of paths

    Reason:

    While in usual BFS questions, we avoid visiting a node after it has been visited once, in this problem every node in the maze can be visited twice before being marked for not visiting again. This is because:

    • If a node is being visited for the first time by a path consisting of only 0s, it should not be visited again by any another path consisting of only 0s as that other path would either be of the same length (in which case it doesn't matter) or longer (in which case we anyway wouldn't want to consider it as that path is reaching a node which has already been traversed by a shorter path consisting of all 0s).

      Same logic goes for a node that has been visited by a path which had traversed a 1 and is now again being visited by a path having a 1 (we will reject the later path as it is either same or longer than the first path).

    • However if a node that was earlier visited by a path having a 1 is now being visited by a path having only 0s, we want to consider this later path too. Why? Because it is possible that the earlier path which reached this node after traversing a 1 may not be able to reach the destination as it may reach a point where it needs to traverse an additional 1 for reaching the destination but as it has already traversed a 1, it cannot proceed further. So, this new path which may be longer than the earlier one, but hasn't traversed a 1 yet, may be able to traverse the additional 1.

      Example

      Example Image

      In this example while the red path reaches the node [2, 0] first, it is not the correct path as it gets blocked at [3, 0]. Therefore, we have to also consider the green path to pass through [2, 0] even if it is longer than the red one.

And finally, the base case is to stop when you reach the bottom right node in the maze. (The question states that there will always be a solution, so no check for the no solution case.)

Code:

grid = [[0, 0, 0, 0],
[1, 1, 1, 0],
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 1, 1, 1],
[0, 0, 0, 0]]  # using the name grid instead of maze

num_rows = len(grid)
num_cols = len(grid[0])

def is_valid(r, c):
    return True if (0 <= r < num_rows and 0 <= c < num_cols) else False

def get_neighbours(r, c):
    up = [r - 1, c]
    down = [r + 1, c]
    left = [r, c - 1]
    right = [r, c + 1]

    neighbours = [down, right, up, left]
    valid_neighbour = list()

    for neighbour in neighbours:
        if is_valid(*neighbour):
            valid_neighbour.append(neighbour)

    return valid_neighbour

# queue format is [[row, col], num_steps, one_traversed]
queue = [[[0, 0], 1, 0]]

cols = list()
visited = list()

# visited matrix is used to keep track of visited nodes:
# -1 is default
# 0 means visited by a path having no 1s
# 1 means visited by a path having a 1
# 2 means visited by both paths - having 1 and 0s
for j in range(num_rows):
    visited.append([-1] * num_cols)

visited[0][0] = 0

# BFS
while queue:
    current_node = queue.pop(0)

    r, c, num_steps, one_traversed = current_node[0][0], current_node[0][
        1], current_node[1], current_node[2]

    # Base Case
    if r == num_rows - 1 and c == num_cols - 1:
        print(num_steps)

    neighbours = get_neighbours(r, c)

    for neighbour in neighbours:
        if visited[neighbour[0]][neighbour[1]] in [0, 1]:
            # the current node was previously visited with opposite one_traversed value, so consider it
            if visited[neighbour[0]][neighbour[1]] != one_traversed:
                one_traversed_now = 1 if grid[neighbour[0]][neighbour[1]] == 1 else one_traversed
                visited[neighbour[0]][neighbour[1]] = 2

                queue.append([[neighbour[0], neighbour[1]], num_steps + 1, one_traversed_now])
        elif visited[neighbour[0]][neighbour[1]] == -1:
            if grid[neighbour[0]][neighbour[1]] == 1 and one_traversed == 1:
                continue

            one_traversed_now = 1 if grid[neighbour[0]][neighbour[1]] == 1 else one_traversed

            visited[neighbour[0]][neighbour[1]] = one_traversed_now
            queue.append([[neighbour[0], neighbour[1]], num_steps + 1, one_traversed_now])

Example test cases:

enter image description here

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.