BYU logo Computer Science

Spatial Logic

Remember the happy puppies problem? A puppy is happy if there is another puppy next to it (up, down, left, right, diagonally):

None, 'p', 'p'
None, 'p', None
None, None, 'p'
'p', None, None

We wrote code to check all 8 directions and it was messy:

def is_happy(grid, row, column):
    num_rows = len(grid)
    num_columns = len(grid[0])

    # up
    if row - 1 >= 0 and is_puppy(grid, row - 1, column):
        return True
    # down
    if row + 1 < num_rows and is_puppy(grid, row + 1, column):
        return True
    # left
    if column - 1 >= 0 and is_puppy(grid, row, column - 1):
        return True
    # right
    if column + 1 < num_columns and is_puppy(grid, row, column + 1):
        return True

    # diagonal up left
    if row - 1 >= 0 and column - 1 >= 0 and is_puppy(grid, row - 1, column - 1):
        return True
    # diagonal down left
    if row + 1 < num_rows and column - 1 >= 0 and is_puppy(grid, row + 1, column - 1):
        return True
    # diagonal up right
    if row - 1 >= 0 and column + 1 < num_columns and is_puppy(grid, row - 1, column + 1):
        return True
    # diagonal down right
    if row + 1 < num_rows and column + 1 < num_columns and is_puppy(grid, row + 1, column + 1):
        return True

    return False

Using ranges

Let’s rethink this problem. Here is the case where we check a puppy in the middle of the grid:

a puppy in the middle of the grid

The puppy we are checking is in dark blue, and the puppies we need to check are in all the surrounding squares, in light blue. Given the (row, column) of a puppy, we can check all of the space around him with:

def is_happy(grid, row, column):
    for r in range(row - 1, row + 2):
        for c in range(column - 1, column + 2):
            # middle position doesn't count, because that is our puppy
            if r == row and c == column: 
                continue
            if is_puppy(grid, r, c):
                return True

    return False

We loop through every possible row from row - 1 to row + 2. We need + 2 because range is not inclusive for the upper end. We likewise loop through every possible column from column - 1 to column + 2. We have to be sure to skip the middle, because that is our puppy and he can’t be happy with just himself.

If we find a puppy anywhere in this range (except the middle), then we return True.

But what about when the puppy is near the edge?

a puppy near the edge of the grid

Here the puppy to check is at the bottom left. We need to be sure not to check the squares to its left and below it, because those are outside the grid.

We can do this by limiting the range:

def is_happy(grid, row, column):

    # setup number of rows and columns
    num_rows = len(grid)
    num_columns = len(grid[0])

    # find edges of the range for rows
    left = max(0, row - 1)
    right = min(row + 2, num_rows)

    # find edges of the range for columns
    up = max(0, column - 1)
    down = min(column + 2, num_columns)

    # check the area
    for r in range(left, right):
        for c in range(up, down):
            # middle position doesn't count, because that is our puppy
            if r == row and c == column: 
                continue
            if is_puppy(grid, r, c):
                return True

    return False

We can combine this with our is_puppy() function:

def is_puppy(grid, row, column):
    if grid[row][column] == 'p':
        return True

    return False

and our find_happy() function:

def find_happy(grid):
    happy = []
    for row in range(len(grid)):
        for column in range(len(grid[row])):
            if is_puppy(grid, row, column) and is_happy(grid, row, column):
                happy.append((row, column))
    return happy

We can run this with:

grid = [
    [None, 'p', 'p'],
    [None, 'p', None],
    [None, None, 'p'],
    ['p', None, None],
]
print(find_happy(grid))

and we will get:

[(0, 1), (0, 2), (1, 1), (2, 2)]

Using ranges and a guard

There is a different way to solve this problem! Instead of restricting our range, let’s always check all the positions around a puppy:

def is_happy(grid, row, column):
    for r in range(row - 1, row + 2):
        for c in range(column - 1, column + 2):
            # middle position doesn't count, because that is our puppy
            if r == row and c == column: 
                continue
            if is_puppy(grid, r, c):
                return True

    return False

and then let’s build a guard into our is_puppy() function:

def is_puppy(grid, row, column):
    num_rows = len(grid)
    num_columns = len(grid[0])

    # check if the row is off the grid
    if row < 0 or row >= num_rows:
        return False

    # check if the column is off the grid
    if column < 0 or column >= num_columns:
        return False

    if grid[row][column] == 'p':
        return True

    return False

We can return False any time we are off the grid — no puppy can possibly be there!

Replace the is_happy() functions and is_puppy() functions as shown above, and the code still works.

Checking larger ranges

Let’s imagine that a puppy is happy if there is a puppy within 3 cells, in any direction. After all, puppies are pretty good at running and finding each other.

We could “hard code” the number 3 into our is_happy() function. But instead, we can use a keyword argument:

def is_happy(grid, row, column, distance=1):
    for r in range(row - distance, row + distance + 1):
        for c in range(column - distance, column + distance + 1):
            # middle position doesn't count, because that is our puppy
            if r == row and c == column: 
                continue
            if is_puppy(grid, r, c):
                return True

    return False

If we use this new version of is_happy() and re-run our code, we will get the same result, because the default distance is 1. But we can change find_happy() as follows:

def find_happy(grid):
    happy = []
    for row in range(len(grid)):
        for column in range(len(grid[row])):
            if is_puppy(grid, row, column) and is_happy(grid, row, column, distance=3):
                happy.append((row, column))
    return happy

We put a 3 in for the distance. Now if we re-run the code, we get:

[(0, 1), (0, 2), (1, 1), (2, 2), (3, 0)]

All of the puppies are happy!