6

I'm having trouble solving a Google Foobar question involving path-finding. My solution fails 2 test cases, the inputs and outputs of which are hidden.

The prompt:

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

Inputs: (int) maze = [[0, 1, 1, 0], [0, 0, 0, 1], [1, 1, 0, 0], [1, 1, 1, 0]]

Output: (int) 7

Inputs: (int) 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: (int) 11

My code:

from queue import PriorityQueue

# Grid class
class Grid:
    # Initialized with dimensions to check later if all neighbor points are actually within the graph
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.walls = []
        self.weights = {}
        self.wall_count = 0

    # Find the cost of a certain destination node
    # Cost is reported as a tuple to account for going across a wall: (# of moves through a wall, # of normal moves)
    def cost(self, from_node, to_node):
        if to_node in self.walls:
            return self.weights.get(to_node, (1, 0))
        else:
            return self.weights.get(to_node, (0, 1))

    # Check if the location is actually within the graph
    def in_bounds(self, id):
        (x, y) = id
        return 0 <= x < self.width and 0 <= y < self.height

    # Find the adjacent nodes of a node (ie. the places it can go to)
    # Filters out any result which isn't on the graph using self.in_bounds
    def neighbors(self, id):
        (x, y) = id
        results = [(x+1, y), (x, y-1), (x-1, y), (x, y+1)]
        if (x + y) % 2 == 0: results.reverse() # aesthetics
        results = filter(self.in_bounds, results)
        return results

# Find the dimensions of the 2D list by finding the lengths of the outer and inner lists
def dimensions_2d(xs):
    width = len(xs)
    height = len(xs[0])
    return (width, height)

# Returns all the positions of an element in a 2D list
# In this case it's used to find all walls (occurences of 1) to pass to the Grid object
def index_2d(xs, v):
    results = [(x, y) for y, ls in enumerate(xs) for x, item in enumerate(ls) if item == v]
    return results

# Djikstra search algorithm; mistakenly named "a_star" before
# Returns both a dictionary of "destination" locations to "start" locations (tuples) as well as a dictionary of the calculated cost of each location on the grid
def djikstra_search(graph, start, goal):
    # Priority Queue to select nodes from
    frontier = PriorityQueue()
    # Place our starting cost in
    frontier.put(start, (0, 0))

    came_from = {}
    cost_so_far = {}
    came_from[start] = None
    cost_so_far[start] = (0, 0)

    while not frontier.empty():
        # Get the element with the highest priority from the queue
        current = frontier.get()

        if current == goal:
            break

        # For every neighbor of the selected node
        for next in graph.neighbors(current):
            # The new cost of the neighbor node is current cost plus cost of this node - (1, 0) if it goes through a wall, (0, 1) otherwise
            new_cost = (cost_so_far[current][0] + graph.cost(current, next)[0], cost_so_far[current][1] + graph.cost(current, next)[1])
            # If the node has not cost currently
            # OR if the number of walls traveled through is less than the current cost
            # AND if the number of normal steps taken is less than or the same as the current number
            if next not in cost_so_far or (new_cost[0] < cost_so_far[next][0] and sum(new_cost) <= sum(cost_so_far[next])):
                # Record it in both the cost and came_from dicts
                cost_so_far[next] = new_cost
                # Place the cost in the queue
                priority = new_cost
                frontier.put(next, priority)
                came_from[next] = current

    return came_from, cost_so_far

# Find the length of the calculated path
# Using the returned list of edges from djikstra_search, move backwards from the target end and increment the length until the start element is reached
def path(grid, start, end):
    # Perform the search
    path = djikstra_search(grid, start, end)
    search = path[0]

    # If the end element's cost travels through more than 1 wall return 0
    if path[1].get(end)[0] > 1:
        return 0

    # Otherwise move backwards from the end element and increment length each time
    # Once the start element has been reached, we have our final length
    length = 1
    last = end
    while last != start:
        last = search.get(last)
        length += 1

    return length

# The "main" function
def answer(maze):
    # Find all occurences of walls (1) in the 2D list
    walls = index_2d(maze, 1)
    # Find the x and y dimensions of the maze (required for the Grid object)
    dims = dimensions_2d(maze)
    # Create a new grid with our found dimensions
    grid = Grid(dims[0], dims[1])

    # The start point will always be at (0,0) and the end will always be at the bottom-right so we define those here
    start = (0, 0)
    end   = (dims[0] - 1, dims[1] - 1)

    # the walls variable's locations are flipped, so reverse each of them to get the right wall positions
    grid.walls = [(y, x) for x, y in walls]

    # Return the length
    return path(grid, start, end)

In my own testing (grids up to 7x7) this solution seems to work without problems.

Any help (or failing cases) would be much appreciated!

dcao
  • 81
  • 2
  • 7
  • I'd expect a breadth-search/Dijkstra and find no corresponding keywords in your code. Maybe if you ease the understanding by explaining the ideas behind your code that would urge more people into debugging it for you ;-) – Alfe Oct 09 '16 at 23:49
  • 1
    I've got another idea on how to (automatically) find failing test cases: Write a (hopefully) simpler solution for a maze without the wall removal option, create random mazes, measure their cost (way length), insert a random obstacle into this solution way, give the new maze to your algorithm here and see if it still comes up with the same cost (as it should because it should be able to remove the one obstacle). The cases where the costs differ are interesting (not necessarily but likely the test case you search for). – Alfe Oct 10 '16 at 20:39
  • @Alfe I've tried making a blank grid and then adding 2 walls adjacent to the exit - the number of steps is the same for both (perhaps I need more complex mazes?). Interestingly, using a simple cost calculation which simply increases the cost by a large number (eg. 1000) whenever a wall is passed through passes one of the test cases, but not the other... – dcao Oct 10 '16 at 22:52
  • 1
    @cmdd I had the same problem and I asked it here: http://codereview.stackexchange.com/questions/143152/bfs-shortest-path The answer that I got helped in making the solution much faster and more efficient. Take a look. It might be what you're looking for. – oxtay Oct 12 '16 at 01:33
  • @oxtay Did you add any additional optimizations on top of the answerer's solution? – dcao Oct 12 '16 at 22:33
  • What classes of tests *have* you used? The two you posted are relatively simple. For instance, use a maze in which there are three ready solutions: one removing no walls, and two shorter ones removing one wall each. The shorter ones will include removals near each end of the resulting path. Invert the maze for a second test. Your algorithm should find the same wall removal in each case. – Prune Apr 05 '17 at 23:40

1 Answers1

0

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

Anirudh Goel
  • 134
  • 3
  • 15