Advent Of Code Day 11: It's Alive!
For Day 11, the puzzle is Conway’s Game of Life with modified rules, applied to changing seats in a restaurant. The basic idea of the game is that given an initial configuration, the world is updated according to a set of rules, either forever or until some stable state or number of iterations is reached (depending on the goal of the simulation). For part 1, the rules for the simulation are:
- An
empty
with no adjacent occupied seats becomesoccupied
. - An
occupied
seat with four or more adjacent occupied seats becomesempty
.
I used NumPy’s fancy array indexing to solve this puzzle, starting
with turning the input grid (L
is empty, .
is a floor, and #
is
occupied):
L.LL.LL.LL
LLLLLLL.LL
L.L.L..L..
LLLL.LL.LL
L.LL.LL.LL
L.LLLLL.LL
..L.L.....
LLLLLLLLLL
L.LLLLLL.L
L.LLLLL.LL
Into a NumPy array:
with open(args.input, "r") as in_file:
grid = []
for line in in_file:
row = []
for c in line.strip():
row.append(c)
grid.append(row)
grid = np.array(grid)
grid_orig = grid.copy()
To update the grid using the rules for part 1, I wrote a make_grid
function:
def make_grid(grid, iterations):
ret = grid.copy()
for index, state in np.ndenumerate(grid):
row, col = index
adjacent = grid[
max(row - 1, 0) : min(row + 2, grid.shape[0]),
max(col - 1, 0) : min(col + 2, grid.shape[1]),
]
occupied = np.sum(adjacent == "#") - (grid[index] == "#")
if state == "L" and occupied == 0:
# Empty seats become occupied if there are no adjacent occupied seats
ret[index] = "#"
elif state == "#" and occupied >= 4:
ret[index] = "L"
return ret, iterations + 1
This function iterates over each individual index in the NumPy array,
yielding (row, column)
tuples and the value at the index:
for index, state in np.ndenumerate(grid):
row, col = index
It uses NumPy array indexing to check all adjacent neighbors and count the number of occupied seats:
adjacent = grid[
max(row - 1, 0) : min(row + 2, grid.shape[0]),
max(col - 1, 0) : min(col + 2, grid.shape[1]),
]
occupied = np.sum(adjacent == "#") - (grid[index] == "#")
Note that the grid indexing takes care of the edge of the grid using
some min/max
operations and the grid shape.
Finally, each state is updated in the return array ret
:
if state == "L" and occupied == 0:
# Empty seats become occupied if there are no adjacent occupied seats
ret[index] = "#"
elif state == "#" and occupied >= 4:
ret[index] = "L"
This function also returns the number of iterations, to help keep track of how long the simulation had to run to reach a stable state. Here is a visualization of the game:
For part 2, the rules of the simulation change:
- Instead of adjacent seats, consider the first visible seat in each direction.
- An
empty
with no visible occupied seats becomesoccupied
. - An
occupied
seat with five or more visible occupied seats becomesempty
.
To do this, I wrote a few new funcions. in_bounds
is a simple bounds
check, to make sure we don’t try to index outside of the array:
def in_bounds(row, col, shape):
if row < 0 or col < 0:
return False
if row >= shape[0] or col >= shape[1]:
return False
return True
count_los
is a function that counts the line-of-sight seats given a
location and a delta for the row and column:
def count_los(row, col, dr, dc, grid):
row_iter = row + dr
col_iter = col + dc
while in_bounds(row_iter, col_iter, grid.shape):
if grid[row_iter, col_iter] == "#":
return 1
if grid[row_iter, col_iter] == "L":
return 0
row_iter += dr
col_iter += dc
return 0
make_los_grid
is the line-of-sight analog to make_grid
from part
1:
def make_los_grid(grid, iterations):
ret = grid.copy()
occ_grid = np.zeros(grid.shape)
for index, state in np.ndenumerate(grid):
row, col = index
occupied = 0
# Up
occupied += count_los(row, col, -1, 0, grid)
# Down
occupied += count_los(row, col, 1, 0, grid)
# Left
occupied += count_los(row, col, 0, -1, grid)
# Right
occupied += count_los(row, col, 0, 1, grid)
# UpRight
occupied += count_los(row, col, -1, 1, grid)
# DownRight
occupied += count_los(row, col, 1, 1, grid)
# UpLeft
occupied += count_los(row, col, -1, -1, grid)
# DownLeft
occupied += count_los(row, col, 1, -1, grid)
if grid[row, col] == "L" and occupied == 0:
ret[row, col] = "#"
elif grid[row, col] == "#" and occupied >= 5:
ret[row, col] = "L"
if grid[row, col] == "L" or grid[row, col] == "#":
occ_grid[index] = occupied
return ret, iterations + 1, occ_grid
Other than the changed rules, the simulation for part 2 is identical to part 1:
states_p2 = []
grid = grid_orig.copy()
if args.visualize:
states_p2.append(GameState(grid.copy(), 0))
new_grid, iterations, occ = make_los_grid(grid, 0)
if args.visualize:
states_p2.append(GameState(new_grid.copy(), iterations))
while np.any(new_grid != grid):
grid = new_grid.copy()
new_grid, iterations, occ = make_los_grid(grid, iterations)
if args.visualize:
states_p2.append(GameState(new_grid.copy(), iterations))
p2 = np.sum(new_grid == "#")
Visualization of part 2: