2024-12-26
[Go to index] [Advent of Code website] [My code on Gitlab]
After a small break, I’ve finally got around to finishing day 21. This was a though one for me. It took me a lot of time to really understand how to properly frame the problem. Still not entirely satisfied with my solution, and I don’t want to spend more time on it.
Let’s dive in.
The inputs are very simple: it’s a list of codes, such as
179A
, which are number to press on a numerical keypad which
looks like this
+---+---+---+
| 7 | 8 | 9 |
+---+---+---+
| 4 | 5 | 6 |
+---+---+---+
| 1 | 2 | 3 |
+---+---+---+
| 0 | A |
+---+---+
The trick is that the code needs to be typed in by a robot, which is itself controlled through a directional keyboard like this:
+---+---+
| ^ | A |
+---+---+---+
| < | v | > |
+---+---+---+
Where we use the arrows to move the position on the numerical keypad,
and the A
to press a button. The robot “starts” on the
A
, so to type in 179A
we have to go – for
instance – ^<<<A
(to go to the 1
from
the A
and press it), then ^^A
for the
7
, >>A
to press the 9
, and
finally vvvA
to press the A
. So far, so good.
But then we start stacking robots, so that the first robot (operating
the numerical keypad) is operated by a second robot (on a directional
keypad) operated by a third robot (on a directional keypad) operated by
us (on a directional keypad). So if we want the first robot to press
179A
, we have this possible succession (aligning the
keypress with the A
s from the next-in-line):
1 7 9 A :: ROBOT 1
^ <<< A ^^ A >> A vvv A :: ROBOT 2
< A v < AAA >> ^ A < AA > A v AA ^ A v < AAA > A :: ROBOT 3
v<<A>>^Av<A<A>>^AAAvAA^<A>Av<<A>>^AAvA^Av<A>^AA<A>A<vA<A>>^AAAvA^A :: US
Unfortunately, there are sometimes different possible paths. The
rules specify that we can never go through the “empty cell” in each
grid, which limits a little bit the possibities, but every time we go
from ^
to >
or from A
to
v
(and vice-versa), we can either start with the vertical
or with the horizontal move.
The question of the puzzle is: for each code, what is the smallest
possible number of moves that we have to input in order to make the
first robot enter the code. To answer the puzzle, we need to compute the
sum of the number of moves multiplied by the numerical value of each
code (e.g. 179 for 179A
).
Since I had a fairly hard time understanding what was going on in
this problem, I initially tried to just “simulate” the whole thing so
that I could observe it. This led to my way-too-long-to-go-through code
for part 1 (Gitlab
link) which, to summarize, has a Keypad
class which
basically sees the keypad as a 2D grid (using some of the code from the
previous puzzles to handle such things). The most important piece of
code in it is what determines how to “expand” a key to press into a
sequence of steps for the next robot in the line:
class Keypad:
...def get_paths(self, to: str) -> set[str]:
= self.value_to_position[to]
target if target == self.current:
return [""]
= target.column - self.current.column
dh = target.row - self.current.row
dv
= ""
seqh if dh > 0:
+= ">"*dh
seqh else:
+= "<"*(-dh)
seqh = ""
seqv if dv > 0:
+= "v"*dv
seqv else:
+= "^"*(-dv)
seqv
= set([seqh+seqv, seqv+seqh])
paths return [path for path in paths if self.is_possible(path)]
Where value_to_position
encodes the position (as a
row, column
namedtuple
) on the keypad of every
key. So we look at our current position on the keypad, at where we want
to go, and determine the number of horizontal and vertical moves we need
to do. We always group the horizontal and vertical steps (as it will
always lead to a smaller sequence in the end, since the next robot will
be able to press A
a few times in a row instead of moving
back and forth between keys), so we have a maximum of two different
paths: horizontal-then-vertical, or vertical-then-horizontal. We also
have an is_possible
method which checks if we are going
over the forbidden spot on the grid.
With that, we can simulate the whole process – and get the right result for part 1 – even though it is wildly inefficient. But it was a necessary step for me to better understand the problem, which was absolutely necessary for part 2…
For part 2, we now have twenty-five robots on the directional keypads (instead of two), plus the single robot on the numerical keypad, and us. The question is the same, but if we try to run our little simulation it completely freezes somewhere around robot number 15 (on my not particularly powerful computer at least). I’m sure there is some way to optimize a little bit, parallelize it or whatever to still make the simulation reach the end, but the point of a puzzle – for me – is to think it through at least a little bit.
So, what can we actually do?
The thing that took me a really long time to notice but finally led me to a better solution is that, for the successive directional keypad, we actually only have a relatively small number of possible moves, and that we can look at pairs of moves as somewhat “independent” elements in a sequence. If we go back to our example:
1 7 9 A :: ROBOT 1
^ <<< A ^^ A >> A vvv A :: ROBOT 2
< A v < AAA >> ^ A < AA > A v AA ^ A v < AAA > A :: ROBOT 3
v<<A>>^Av<A<A>>^AAAvAA^<A>Av<<A>>^AAvA^Av<A>^AA<A>A<vA<A>>^AAAvA^A :: US
And really start from ROBOT 2
, the first move is from
A
(always the starting point) to ^
, so we now
that the next robot will have to do “move from A
to
^
, then press A
. Then we go from
^
to <
and press A
. Then from
<
to <
and press A
. Etc.,
etc. Whatever the sequence of move, we can always split it in these
pairs of moves, with a”transition + A
” pattern.
The way my simulation code was made really didn’t lend itself to this
kind of problem formulation, so I started a new
file. The still ugly part is a method that computes a matrix of all
possible moves in a keypad, so that moves[a][b]
is a list
of possible sequences of moves from a
to b
.
For instance, moves['^']['>']
on the directional
keyboard should be ['>v', 'v>']
.
First, we remake the process from part 1, but this time using this idea of pairwise transitions:
def expand(sequence, moves):
= ['']
paths for a,b in pairwise('A'+sequence):
= [p+m+'A' for p in paths for m in moves[a][b]]
paths return paths
This code takes a sequence, decomposes it in successive pairs (so
179A
is (A, 1)
, (1, 7)
,
(7, 9)
, (9, A)
, always adding an extra
A
at the front as it’s the starting position of the robot)
and expand the pair based on the list of moves, adding an A
after the transition to actually press the button. If there are multiple
possible paths, they are all returned.
The code is already a bit better, but this is still not helping us go further than about 15 iterations. For that, we need to go recursive:
def rexpand(sequence: str, n: int):
if n == 0:
return len(sequence)
= 0
total for a,b in pairwise('A'+sequence):
+= min(rexpand(m + 'A', n-1) for m in directional_moves[a][b])
total return total
The first thing to note here is that we no longer care about actually
getting the final sequence. We just keep track of its length. When we
reach the end of the recursion, we return the current length of the
sequence. Before that, we sum the lengths of the recursively expanded
pairs of moves, only keeping the minimum length when several
possibilities are present. This alone isn’t enough to go to the
solution, but now we have put ourselve in a position where we can use functools.cache
.
This is a very useful function that stores the result of any call to a
given function, so that if we call it again with the same parameters we
can just lookup the answer rather than re-computing it. Here, since we
have a very limited number of possible moves, we only need to compute
each expansion once for each level of the recursion, then in all other
possible paths we’ll just have to look-up the answer. To make this magic
happen, we just need to import functools.cache
, which is a
decorator, so we add @cache
just before our method
definition:
@cache
def rexpand(sequence: str, n: int):
if n == 0:
return len(sequence)
= 0
total for a,b in pairwise('A'+sequence):
+= min(rexpand(m + 'A', n-1) for m in directional_moves[a][b])
total return total
and that’s it.
The final bit of code to get the answer becomes very simple:
= 0
complexity for code in codes:
= expand(code, numerical_moves)
paths = min(rexpand(sequence, 25) for sequence in paths)
seqlen += int(code[:-1])*seqlen
complexity print(complexity)
For the numerical keypad, we still use the simple
expand
. Then, for the directional keypads, we find the
minimum sequence length with a 25-deep recursion. The result is almost
instantaneous, despite the sequence length being in the \(10^15\) scale.
I generally don’t like using cache
in these puzzles, as
I prefer to find a more “handmade” way of bypassing the scaling
complexity of the problem (day 11 is a prime
example, where cache
would have been a lot easier – and to
be clear I just hadn’t thought about it before I saw others’ solutions –
but I still like my solution better in the end). But in this case
putting the problem in such a way that cache
became
possible was hard enough for me that I don’t mind too much. And it
is a very useful tool to keep in my coding arsenal, so
practicing it a little bit is nice.