Dazbo's Advent of Code solutions, written in Python
OMG! It’s Christmas day.
It was a relief that this problem was easy to solve. I’ve got presents to open!! I say easy… Not completely trivial, and there is at least one gotcha.
We need to land our sub on the seafloor. But the seafloor is covered in sea cucumbers. Specifically, there are two herds of cucumbers:
>
.v
.Our input data is map of the starting locations of these migrating sea cucumbers. E.g.
v...>>.vv>
.vv>>.vv..
>>.>v>...v
>>v>>.>.v.
v>v.vv.v..
>.>>..v...
.vv..>.>v.
v.v..>>v.v
....v..v.>
The grid uses .
to denote empty spaces.
The migration rules:
v...>>.vv>
v...>.>vv>
v....>>vv>
What is the first step on which no sea cucumbers move?
The cucumbers will stop moving when they’re all blocked.
I’ll create a Grid
class, that represents all the sea cucumber locations, and can simulate their movement with each step:
class Grid():
""" Store locations of sea cucumbers. """
def __init__(self, data: list[str]) -> None:
""" Take input data and convert from list-of-str to list-of-list for easier manipulation. """
self._grid = [list(row) for row in data] # Now a nested list of list.
self._row_len = len(self._grid[0])
self._grid_len = len(self._grid)
self._changed_last_cycle = True
@property
def changed_last_cycle(self):
return self._changed_last_cycle
def cycle(self):
""" Performs a migration cycle for our east-moving and south-moving herds
Performs all east, and then performs all south. Then updates the grid. """
self._changed_last_cycle = False
# Make a copy of the grid. Shallow copy won't work, as we have a nested list. Deepcopy is too slow.
tmp_grid = [[self._grid[y][x] for x in range(self._row_len)] for y in range(self._grid_len)]
# process east herd, row by row
for y in range(self._grid_len):
for x in range(self._row_len):
next_x = (x+1)%self._row_len # get right / wrap-around
if self._grid[y][x] + self._grid[y][next_x] == ">.":
tmp_grid[y][x] = "."
tmp_grid[y][next_x] = ">"
self._changed_last_cycle = True
self._grid = tmp_grid
east_migrated = [[tmp_grid[y][x] for x in range(self._row_len)] for y in range(self._grid_len)]
for y in range(self._grid_len):
for x in range(self._row_len):
next_y = (y+1)%self._grid_len # get below / wrap-around
if tmp_grid[y][x] + tmp_grid[next_y][x] == "v.":
east_migrated[y][x] = "."
east_migrated[next_y][x] = "v"
self._changed_last_cycle = True
self._grid = east_migrated
def __repr__(self) -> str:
return "\n".join("".join(char for char in row) for row in self._grid)
This is how it works:
list
of str
- convert it to a list
of lists
, and store in the instance variable _grid
._row_len
from the length of the first row._grid_len
from the number of rows in the grid.property
called changed_last_cycle
.cycle()
method, which is exposed by the class:
_changed_last_cycle
to False
.list comprehension
, which is much faster than using the deepcopy()
method. We need a copy, because we always need to evaluate each existing cucumber positions based on the grid configuration at the start of the cycle; but we need to be updating the new configuration one cucumber at a time.x
, and the next horizontal cucumber position is given by next_x
. Crucially, next_x
is equivalent to (x+1) % row_len
, to allow for the next position wrapping over to the left.">."
, since these represent location pairs where a cucumber can migrate one space to the east. Wherever we find this:
".>"
in the new copy._changed_last_cycle
to True
.y
position, allowing for the next position to roll around back to the top."v."
at each horizontal position, across pairs of rows. If we find any:
".v"
in the new copy._changed_last_cycle
to True
._grid
attribute to reflect the current state.Now we just need to read in the data, construct a Grid
class from it, then cycle
our Grid
until there are no more changes:
with open(INPUT_FILE, mode="rt") as f:
data = f.read().splitlines()
grid = Grid(data)
i = 0
while grid.changed_last_cycle:
i += 1
grid.cycle()
logger.info("We've stopped migrating at iteration %d", i)
It works!! Hurrah!
22:57:03.946:DEBUG:__main__: Created Grid
22:57:06.336:INFO:__main__: We've stopped migrating at iteration 360
22:57:06.336:INFO:__main__: Execution time: 2.3969 seconds
There’s nothing to do. Turns out we’ve saved Christmas!!
Woop, woop!!
It would be great to build an animation to visualise our migrating sea cucumber herds. Here’s my strategy for building the animation:
Here’s our modified Grid
class:
class Grid():
""" Store locations of sea cucumbers. """
def __init__(self, data: list[str], animator: Animator=None) -> None:
""" Take input data and convert from list-of-str to list-of-list for easier manipulation. """
init_str = "Created Grid"
if animator:
init_str += " with Animator"
logger.debug(init_str)
self._grid = [list(row) for row in data] # Now a nested list of list.
self._row_len = len(self._grid[0])
self._grid_len = len(self._grid)
self._changed_last_cycle = True
self._animator = animator
self._plot_info = self.setup_fig() # does no work if no Animator
self._render_frame()
@property
def changed_last_cycle(self):
return self._changed_last_cycle
def cycle(self):
""" Performs a migration cycle for our east-moving and south-moving herds
Performs all east, and then performs all south. Then updates the grid. """
self._changed_last_cycle = False
# Make a copy of the grid. Shallow copy won't work, as we have a nested list. Deepcopy is too slow.
tmp_grid = [[self._grid[y][x] for x in range(self._row_len)] for y in range(self._grid_len)]
# process east herd, row by row
for y in range(self._grid_len):
for x in range(self._row_len):
next_x = (x+1)%self._row_len # get right / wrap-around
if self._grid[y][x] + self._grid[y][next_x] == ">.":
tmp_grid[y][x] = "."
tmp_grid[y][next_x] = ">"
self._changed_last_cycle = True
self._grid = tmp_grid
self._render_frame()
east_migrated = [[tmp_grid[y][x] for x in range(self._row_len)] for y in range(self._grid_len)]
for y in range(self._grid_len):
for x in range(self._row_len):
next_y = (y+1)%self._grid_len # get below / wrap-around
if tmp_grid[y][x] + tmp_grid[next_y][x] == "v.":
east_migrated[y][x] = "."
east_migrated[next_y][x] = "v"
self._changed_last_cycle = True
self._grid = east_migrated
self._render_frame()
def __repr__(self) -> str:
return "\n".join("".join(char for char in row) for row in self._grid)
def _render_frame(self):
""" Only renders an animation frame if we've attached an Animator """
if not self._animator:
return
east = set()
south = set()
for y in range(self._grid_len):
for x in range(self._row_len):
if self._grid[y][x] == ">":
east.add((x,y))
elif self._grid[y][x] == "v":
south.add((x,y))
east_x, east_y = zip(*east)
south_x, south_y = zip(*south)
axes, mkr_size = self._plot_info
axes.clear()
min_x, max_x = -0.5, self._row_len - 0.5
min_y, max_y = -0.5, self._grid_len - 0.5
axes.set_xlim(min_x, max_x)
axes.set_ylim(max_y, min_y)
axes.scatter(east_x, east_y, marker=">", s=mkr_size, color="black")
axes.scatter(south_x, south_y, marker="v", s=mkr_size, color="white")
# save the plot as a frame; store the frame in-memory, using a BytesIO buffer
frame = BytesIO()
plt.savefig(frame, format='png') # save to memory, rather than file
self._animator.add_frame(frame)
def setup_fig(self):
if not self._animator:
return
my_dpi = 120
fig, axes = plt.subplots(figsize=(1024/my_dpi, 768/my_dpi), dpi=my_dpi, facecolor="black") # set size in pixels
axes.get_xaxis().set_visible(False)
axes.get_yaxis().set_visible(False)
axes.set_aspect('equal') # set x and y to equal aspect
axes.set_facecolor('xkcd:orange')
min_x, max_x = -0.5, self._row_len - 0.5
min_y, max_y = -0.5, self._grid_len - 0.5
axes.set_xlim(min_x, max_x)
axes.set_ylim(max_y, min_y)
# dynamically compute the marker size
fig.canvas.draw()
mkr_size = ((axes.get_window_extent().width / (max_x-min_x) * (45/fig.dpi)) ** 2)
return axes, mkr_size
__init__()
method:
animator
.
Animator
object, then we’ll render the animation.
_animator
instance variable.setup_fig()
to do some initialisation for Matplotlib.Grid
.cycle()
method:
_render_frame()
at the end of the east migration, and at the end of the south migration. Thus, adding two new frames per cycle.setup_fig()
method:
_render_frame()
method:
Grid
class.Grid
has no Animator
, then this method immediately returns. Else…(x,y)
tuples for each east cucumber in an east
set.(x,y)
tuples for each south cucumber in a south
set.zip()
to turn each set of (x,y)
tuples in a tuple of all x
values, and a tuple of all y
values. (Matplotlib needs the coordinates to be passed in this format.)>
markers for the east cucumbers.v
markets for the south cucumbers.plt.savefig()
to save our plot as a file. My first version of this animation actually saved each frame as a file, requiring me to stich the frames together, and then delete these temporary files. But instead, we can save to an in-memory BytesIO object; essentially, an in-memory file. This turns out to be an order of magnitude faster, even though I was writing my temporary files to an SSD.Then I created an Animator
class, to do the job of storing our frames, and then stiching them together as an animation gif:
class Animator():
""" Creates an animation file of specified target size. """
def __init__(self, file: Path, fps: int, loop=1) -> None:
""" Create an Animator. Suggest the file should be a .gif.
Set frames per second (fps).
Set loop to 0, to loop indefinitely. Default is 1. """
self._outputfile = file
self._frames = [] # can be in-memory BytesIO objects, or files
self._fps = fps
self._loop = loop
dir_path = Path(self._outputfile).parent
if not Path.exists(dir_path):
Path.mkdir(dir_path)
def save_anim(self):
""" Takes animation frames, and converts to a single animated file. """
logger.debug("Saving animation...")
with imageio.get_writer(self._outputfile, mode='I', fps=self._fps, loop=self._loop) as writer:
for frame in tqdm(self._frames):
image = imageio.imread(frame)
writer.append_data(image)
logger.info("Animation saved to %s", self._outputfile)
def add_frame(self, frame):
""" Add a frame to the animation.
The frame can be in the form of a BytesIO object, or a file Path
"""
self._frames.append(frame)
Some things to note about this class:
__init__()
is passed an output file, as well as the desired frames per second in the animation.
add_frame()
method is called by our Grid’s _render_frame()
method. We can pass in a path to an actual file, or we can pass an BytesIO object.save_anim()
method converts our set of frames to a single animation gif file.
writer
context manager.for
loop that creates the animation with tqdm
, in order to generate a progress bar. That’s because generating the animation with 360 cycles (and thus 721 frames) takes 20 seconds or so. So I wanted some sort of visual feedback.And here’s what these animations look like:
Well, that was a touch AoC. Tougher than any I’ve done before.
I hope you found these walkthroughs useful. I’ve really enjoyed creating them.
If you want to get in touch, e.g. to offer any feedback, find me through my GitHub Profile.