Advent of Code - 2021
This is a solution to Day 11 of Advent of Code 2021.
Day 11 - Dumbo Octopus
You enter a large cavern full of rare bioluminescent dumbo octopuses! They seem to not like the Christmas lights on your submarine, so you turn them off for now.
There are 100 octopuses arranged neatly in a 10 by 10 grid. Each octopus slowly gains energy over time and flashes brightly for a moment when its energy is full. Although your lights are off, maybe you could navigate through the cave without disturbing the octopuses if you could predict when the flashes of light will happen.
Each octopus has an energy level - your submarine can remotely measure the energy level of each octopus (your puzzle input). For example:
5483143223 2745854711 5264556173 6141336146 6357385478 4167524645 2176841721 6882881134 4846848554 5283751526
The energy level of each octopus is a value between 0 and 9. Here, the top-left octopus has an energy level of 5, the bottom-right one has an energy level of 6, and so on.
You can model the energy levels and flashes of light in steps. During a single step, the following occurs:
- First, the energy level of each octopus increases by 1.
- Then, any octopus with an energy level greater than 9 flashes. This increases the energy level of all adjacent octopuses by 1, including octopuses that are diagonally adjacent. If this causes an octopus to have an energy level greater than 9, it also flashes. This process continues as long as new octopuses keep having their energy level increased beyond 9. (An octopus can only flash at most once per step.)
- Finally, any octopus that flashed during this step has its energy level set to 0, as it used all of its energy to flash.
Adjacent flashes can cause an octopus to flash on a step even if it begins that step with very little energy. Consider the middle octopus with 1 energy in this situation:
Before any steps: 11111 19991 19191 19991 11111
After step 1: 34543 40004 50005 40004 34543
After step 2: 45654 51115 61116 51115 45654
Read input
from utils import read_input
def transformer(line):
return [int(num) for num in list(line)]
grid = read_input(11, transformer)
Part 1
I can already tell there's gonna be a lot of debugging steps happening to figure out where I might be wrong and the puzzle maker was kind enough to provide step-by-step grids for many of the steps up to 100 with the example input so to compare to those, I'll create a debugging printing function so it's easy to print the grid at any point.
def print_grid(grid):
for x in range(len(grid)):
for y in range(len(grid[x])):
print(grid[x][y], end="")
print()
At the beginning of each step, one thing guaranteed to happen is that every dumbo octopus's energy level will increase by one so let's have a function for that.
(There's a lot of for loops in this solution and some of it could probably be refactored out with some smart tricks.)
def initial_increase(grid):
for x in range(len(grid)):
for y in range(len(grid[x])):
grid[x][y] += 1
return grid
Neighbors? That sounds familiar. Last time (in Day 9) we only cared about adjacent neighbors and now we care about diagonal ones too. Luckily did this already once so easy to adjust (and improve!) my last solution for today.
def get_neighbors(position, max_height, max_width):
x, y = position
neighbors = [
(x-1, y-1),
(x-1, y),
(x-1, y+1),
(x, y-1),
(x, y+1),
(x+1, y-1),
(x+1, y),
(x+1, y+1)
]
def valid(x, y, max_height, max_width):
return x >= 0 and y >= 0 and x < max_height and y < max_width
return [(x, y) for (x, y) in neighbors if valid(x, y, max_height, max_width)]
Few helper functions:
increase_neighbors
is a helper function to run through a list of coordinates and increase everyone's energy level by one.
reset
sets the energy level of any dumbo octopus to 0 if it flashed (was over 9).
def increase_neighbors(grid, neighbors):
for (x, y) in neighbors:
grid[x][y] += 1
return grid
def reset(grid):
for x in range(len(grid)):
for y in range(len(grid[x])):
if grid[x][y] > 9:
grid[x][y] = 0
flash_increase
is the main logic of this simulation.
We loop over the main part until there were no more flashes on a given round.
On each loop round, we check every octopus:
- if it already flashed, we ignore it
- if it's gonna flash, we record that flash into
flashes
, mark it flashed intoalready_flashed
and increase the levels of all of its neighbors
If we had any flashes, we record those to our step flash counter all_flashes
, set round flashes to 0 and repeat until there were none.
Finally, we "reset" the grid by making the energy level of any flashed octopus into 0.
def flash_increase(grid):
max_height, max_width = len(grid), len(grid[0])
flashes = 0
all_flashes = 0
already_flashed = set()
while True:
for x in range(len(grid)):
for y in range(len(grid[x])):
value = grid[x][y]
if (x, y) in already_flashed:
continue
elif value > 9:
flashes += 1
already_flashed.add((x, y))
neighbors = get_neighbors((x, y), max_height, max_width)
increase_neighbors(grid, neighbors)
if flashes == 0:
break
else:
all_flashes += flashes
flashes = 0
reset(grid)
return all_flashes
Our simulation function runs n
steps of simulation, running both the initial increase and flashing increases on each step and counts flashes.
In a previous puzzle we created a copy of a list with list2 = list1[:]
but since that only works on the first level (ie. it's a shallow copy) and we have a list of lists, we need to deep copy it to prevent future simulations from affecting the original input.
To do that, we use deepcopy
method from copy library.
import copy
def simulate(n, grid):
grid = copy.deepcopy(grid)
score = 0
for step in range(n):
initial_increase(grid)
score += flash_increase(grid)
return score
Given the starting energy levels of the dumbo octopuses in your cavern, simulate 100 steps. How many total flashes are there after 100 steps?
result = simulate(100, grid)
assert result == 1546 # If assert failes, re-read the input
print(f'Solution: {result}')
Solution: 1546
Part 2
It seems like the individual flashes aren't bright enough to navigate. However, you might have a better option: the flashes seem to be synchronizing!
If you can calculate the exact moments when the octopuses will all flash simultaneously, you should be able to navigate through the cavern. What is the first step during which all octopuses flash?
An easy way to see if an simultaneous flash happened is to check if the grid is all zeroes after a step has happened.
We can keep everything else the same and count for steps and after each step, check for a synchronous flash.
import copy
def check_for_sync(grid):
for x in range(len(grid)):
for y in range(len(grid[x])):
if grid[x][y] != 0:
return False
return True
def simulate_until_syncronous(grid):
grid = copy.deepcopy(grid)
step = 1
while True:
initial_increase(grid)
flash_increase(grid)
if check_for_sync(grid):
break
else:
step += 1
return step
result = simulate_until_syncronous(grid)
print(f'Solution: {result}')
Solution: 471
Alternative solution: Octopus
class
After completing my two stars and enjoying some breakfast, I wanted to see if I could make the code nicer by using some classes.
I really like how this solution turned out. First of all, no more for x in range(len(grid)): for y in range(len(grid[x]))
which can be cumbersome to read and write. Second, the octopus can take care of itself now, making the rest of the logic cleaner.
We start with Octopus
class.
It knows a few things about itself: it's energy_level
, if it recently flashed
and its position
in the swarm.
Because it knows its place, it also knows all the possible neighbors (notice here that I don't filter the possible neighbors at all, I do that in the main logic).
And it knows if it should reset or not based on its energy_level
.
class Octopus:
def __init__(self, position, energy_level):
self.energy_level = energy_level
self.position = position
self.flashed = False
def energize(self):
self.energy_level += 1
if self.energy_level > 9:
self.flashed = True
def reset(self):
if self.energy_level > 9:
self.energy_level = 0
self.flashed = False
def get_neighbors(self):
x, y = self.position
return [
(x-1, y-1),
(x-1, y),
(x-1, y+1),
(x, y-1),
(x, y+1),
(x+1, y-1),
(x+1, y),
(x+1, y+1)
]
def __repr__(self):
return f'{self.position}: {self.energy_level}'
We start by creating the octopus bunch from the input data.
Sync check for part 2 is simple: we check every octopus to see if their level is 0.
step
function is the main logic here again and it's quite simple but much easier to read than the previous one.
We got rid of the nested for
loops (for x
and y
) because we store each octopus in a dictionary now.
I also put the initial increase from the previous solution into this function because it became so much simpler that I didn't feel it needed a separate function anymore.
In
try:
octopi[neighbor].energize()
except KeyError: # If such neighbor doesn't exist, skip
continue
I'm using a trick that I probably wouldn't use outside a puzzle setting like this. As I mentioned earlier, I don't filter out invalid neighbor positions at an octopus level but rather here. I try to energize and octopus at given position but if that position doesn't exist, I just ignore it with try
/catch
.
Most of the other code is same as in the original solution.
def initialize():
raw_data = copy.deepcopy(grid)
octopi = {
(x,y): Octopus((x, y), value)
for x, _ in enumerate(raw_data)
for y, value in enumerate(raw_data[x])
}
return octopi
def check_for_sync(octopi):
return all(octopus.energy_level == 0 for octopus in octopi.values())
def step(octopi):
already_flashed = set()
flashes = 0
all_flashes = 0
# Increase all energy levels by one
for octopus in octopi.values():
octopus.energize()
# Increase neighbors levels by one until no more flashes happen
while True:
for octopus in octopi.values():
if octopus in already_flashed:
continue
elif octopus.flashed:
flashes += 1
already_flashed.add(octopus)
for neighbor in octopus.get_neighbors():
try:
octopi[neighbor].energize()
except KeyError: # If such neighbor doesn't exist, skip
continue
if flashes == 0:
break
else:
all_flashes += flashes
flashes = 0
# After the round, reset every octopus
for octopus in octopi.values():
octopus.reset()
return all_flashes
def simulate_octopi(n):
octopi = initialize()
score = 0
for _ in range(n):
score += step(octopi)
return score
def simulate_until_sync():
octopi = initialize()
step_count = 1
while True:
step(octopi)
if check_for_sync(octopi):
return step_count
step_count += 1
print(f'Solution, part 1: {simulate_octopi(100)}')
print(f'Solution, part 2: {simulate_until_sync()}')
Solution, part 1: 1546
Solution, part 2: 471