# Notes on Advent of Code 2022

I've been having fun solving Advent of Code problems every December for a few years now. Advent of Code is an advent calendar of programming puzzles.

All my solutions are on my GitHub here. First, a quick disclaimer:

Disclaimer on my solutionsI use Python because I find it easiest for this type of coding. I treat solving these as a write-only exercise. I do it for the problem-solving bit, so I don't comment the code & once I find the solution I consider it

done- I don’t revisit and try to optimize even though sometimes I strongly feel like there is a better solution. I don't even share code between part 1 and part 2 - once part 1 is solved, I copy/paste the solution and change it to solve part 2, so each can be run independently. I also rarely use libraries, and when I do it's some standard ones like`re`

,`itertools`

, or`math`

. The code has no comments and is littered with magic numbers and strange variable names. This is not how I usually code, rather my decadent holiday indulgence. I wasn't thinking I will end up writing a blog post discussing my solutions so I would like to apologize for the code being hard to read.

With that long disclaimer out of the way, let's talk Advent of Code 2022. I figured I'll cover a few problems that seemed interesting to me during this round, before they fade in my memory. The first couple of weeks are usually easy, so I'll start from day 15.

## Day 15: Beacon Exclusion Zone

Problem statement is here.

### Part 1

Part 1 is pretty easy. We use taxicab geometry and for each sensor, we can find
its scan radius by computing the Manhattan distance between its coordinates and
the closest beacon it sees. Once we have this, we intersect each (taxicab)
circle with the row `y=2000000`

. This gives as a bunch of segments defined by
`(x0, x1)`

pairs.

```
import re
y, segments = 2000000, set()
for line in open('input').readlines():
m = re.match('Sensor at x=(-?\d+), y=(-?\d+).*x=(-?\d+), y=(-?\d+)$', line)
sx, sy, bx, by = map(int, m.groups())
radius = abs(sx - bx) + abs(sy - by)
if abs(sy - y) <= radius:
segments.add(((sx - (radius - abs(sy - y)),
(sx + (radius - abs(sy - y))))))
```

We need to figure out where these overlap so we don't double-count so for each pair of segments, if they intersect, we replace them by their union until no segments intersect anymore. Then we simply sum the length of each segment:

```
def intersect(s1, s2):
return s1[1] >= s2[0] and s2[1] >= s1[0]
def union(s1, s2):
return (min(s1[0], s2[0]), max(s1[1], s2[1]))
done = False
while not done:
done = True
for s1 in segments:
for s2 in segments:
if s1 == s2:
continue
if intersect(s1, s2):
segments.remove(s1)
segments.remove(s2)
segments.add(union(s1, s2))
done = False
break
if not done:
break
print(sum([s[1] - s[0] for s in segments]))
```

### Part 2

Part 2 is more interesting. We need to scan a quite large area (both `x`

and `y`

between `0`

and `4000000`

). We know that all points except one are covered by at
least one sensor. We start from `(0, 0)`

and scan like this: for each point,
find the first sensor that sees

it (Manhattan distance from sensor <= sensor
radius). If no scanner can see it, we found our point. Otherwise, again relying
on taxicab geometry, we can tell how many additional points to the right
(increasing `x`

) are still in range of this sensor. We move `x`

beyond these
(\(x = x_sensor + radius - abs(y_sensor - y) + 1\)). If `x`

goes beyond
`4000000`

, we reset it to `0`

and increment `y`

. This is not blazingly fast, but
does the job in a reasonable amount of time (around 20 seconds on my machine).

```
import re
sensors = []
for line in open('input').readlines():
m = re.match('Sensor at x=(-?\d+), y=(-?\d+).*x=(-?\d+), y=(-?\d+)$', line)
sx, sy, bx, by = map(int, m.groups())
radius = abs(sx - bx) + abs(sy - by)
sensors.append((sx, sy, radius))
def in_range(x, y):
for sensor in sensors:
if abs(sensor[0] - x) + abs(sensor[1] - y) <= sensor[2]:
return True, sensor
return False, None
x, y = 0, 0
while True:
found, sensor = in_range(x, y)
if not found:
break
x = sensor[0] + sensor[2] - abs(sensor[1] - y) + 1
if x > 4_000_000:
x = 0
y += 1
print(x * 4_000_000 + y)
```

## Day 16: Proboscidea Volcanium

Problem statement is here.

### Part 1

Part 1 is again pretty easy: we can model the valves and tunnels as a graph, then use the Floyd-Warshall algorithm to find the distances between each pair of valves:

```
import re
dist, flows, to_open = {}, {}, set()
for line in open('input').readlines():
m = re.match(
'Valve (\w+) has flow rate=(\d+); tunnels? leads? to valves? (.*)$', line)
src, flow, *dst = m.groups()
dst = [d.strip() for d in dst[0].split(',')]
dist[src] = {d: 1 for d in dst} | {src: 0}
flows[src] = int(flow)
if flows[src] > 0:
to_open.add(src)
for i in dist:
for j in dist:
if j not in dist[i]:
dist[i][j] = 1000
for k in dist:
for i in dist:
for j in dist:
if dist[i][j] > dist[i][k] + dist[k][j]:
dist[i][j] = dist[i][k] + dist[k][j]
```

We can then search for the best solution recursively: we start from `AA`

and
keep track of which valves we opened (none for starters). Then at each step,
pick one of the unopened valves. If we have enough time to reach them, recurse
with updated location and set of opened nodes. We also compute the total
pressure released so far at each step and keep track of the highest value we
found. This gives us the solution.

```
best = 0
def search(current='AA', opened=set(), time=30, score=0):
global best
score += time * flows[current]
if score >= best:
best = score
for node in to_open - opened:
if time - dist[current][node] - 1 >= 0:
search(node, opened | {node}, time -
dist[current][node] - 1, score)
search()
print(best)
```

### Part 2

Part 2 is more fun. We now have an elephant to help us, which makes it a bit
more complicated. My solution now keeps track of a few more things: which valve
am I headed to and how many more minutes I have to get there; which valve is the
elephant headed to and how many more minutes until it gets there. We both start
at `AA`

with an ETA of `0`

. Then for each node, if my ETA is 0, I'll be heading
that way. If not, the elephant will be heading there. But since we're dealing
with two ETAs, we need to figure out which of us will reach their destination
first, and recurse to that time.

```
best = 0
def search(me=('AA', 0), elephant=('AA', 0), opened=set(), time=26, score=0):
global best
if score > best:
best = score
for node in to_open - opened:
me_next, elephant_next, score_next = me, elephant, score
if me[1] == 0:
me_next = (node, dist[me[0]][node] + 1)
score_next += (time - dist[me[0]][node] - 1) * flows[node]
else:
elephant_next = (node, dist[elephant[0]][node] + 1)
score_next += (time - dist[elephant[0]][node] - 1) * flows[node]
dt = min(me_next[1], elephant_next[1])
me_next = (me_next[0], me_next[1] - dt)
elephant_next = (elephant_next[0], elephant_next[1] - dt)
if time - dt >= 0:
search(me_next, elephant_next, opened |
{node}, time - dt, score_next)
search()
print(best)
```

This works but takes a long time, so I added some caching: since both the elephant and I move around a bunch, we can cache the score for each combination of my destination and ETA, the elephant's destination and ETA, and the time. If at a given minute, both the elephant and I were already in this situation but with a better score, we no longer need to keep searching this branch as we already found a better solution. This prunes enough of the search tree to easily find the answer. Updated search with cache:

```
best = 0
cache = {}
def search(me=('AA', 0), elephant=('AA', 0), opened=set(), time=26, score=0):
global best
if score > best:
best = score
key = str(me) + str(elephant) + str(time)
if key in cache:
if cache[key] >= score:
return
cache[key] = score
for node in to_open - opened:
me_next, elephant_next, score_next = me, elephant, score
if me[1] == 0:
me_next = (node, dist[me[0]][node] + 1)
score_next += (time - dist[me[0]][node] - 1) * flows[node]
else:
elephant_next = (node, dist[elephant[0]][node] + 1)
score_next += (time - dist[elephant[0]][node] - 1) * flows[node]
dt = min(me_next[1], elephant_next[1])
me_next = (me_next[0], me_next[1] - dt)
elephant_next = (elephant_next[0], elephant_next[1] - dt)
if time - dt >= 0:
search(me_next, elephant_next, opened |
{node}, time - dt, score_next)
search()
print(best)
```

## Day 17: Pyroclastic Flow

Problem statement is here.

### Part 1

For part 1 we can simply simulate the falling blocks and find the answer. This gives us some of the building blocks needed for part 2.

```
jets = open('input').read()
rocks = [{(0, 0), (1, 0), (2, 0), (3, 0)},
{(0, 1), (1, 0), (1, 1), (1, 2), (2, 1)},
{(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)},
{(0, 0), (0, 1), (0, 2), (0, 3)},
{(0, 0), (0, 1), (1, 0), (1, 1)}]
grid = set({(i, 0) for i in range(1, 8)})
def intersects(rock, grid):
for block in rock:
if block in grid or block[0] <= 0 or block[0] >= 8:
return True
return False
def move(rock, dx, dy):
return {(i + dx, j + dy) for i, j in rock}
rock_i, jet_i = 0, 0
for _ in range(2022):
top = max(grid, key=lambda pt: pt[1])[1]
rock = move(rocks[rock_i], 3, top + 4)
while True:
new_pos = move(rock, 1 if jets[jet_i] == '>' else -1, 0)
jet_i += 1
if jet_i == len(jets):
jet_i = 0
if not intersects(new_pos, grid):
rock = new_pos
new_pos = move(rock, 0, -1)
if intersects(new_pos, grid):
break
rock = new_pos
grid |= rock
rock_i += 1
if rock_i == len(rocks):
rock_i = 0
print(max(grid, key=lambda pt: pt[1])[1])
```

### Part 2

Part 2 makes it obvious simulating everything is not an option as we need to
look at a thousand billion rocks. The key here is to find a pattern: we are
bound to end up simulating the same rock and initial move instruction over and
over. If we do and we see the same gain in height between repeats, it means we
found our repeating pattern. We know that starting from this position, we have a
period of length `period`

in which our tower of rocks grows by `growth`

. We
subtract the number of rocks we already simulated from 1000000000000, we divide
by `period`

and multiply by `growth`

. We'll call this `delta_top`

.

We are close to the final answer. The only thing left to do is simulate a few
more steps: 1000000000000 minus the number of rocks we already simulated modulo
`period`

. Now we get the height of the top of the tower we simulated and add
`delta_top`

to it to find the final answer.

```
def top():
return max(grid, key=lambda pt: pt[1])[1]
rock_i, jet_i = 0, 0
cache, delta_top = {}, 0
i = 0
while i < 10_000:
rock = move(rocks[rock_i], 3, top() + 4)
while True:
new_pos = move(rock, 1 if jets[jet_i] == '>' else -1, 0)
jet_i += 1
if jet_i == len(jets):
jet_i = 0
if not intersects(new_pos, grid):
rock = new_pos
new_pos = move(rock, 0, -1)
if intersects(new_pos, grid):
break
rock = new_pos
grid |= rock
rock_i += 1
if rock_i == len(rocks):
rock_i = 0
i += 1
if not delta_top:
if (rock_i, jet_i) not in cache:
cache[(rock_i, jet_i)] = []
c = cache[(rock_i, jet_i)]
c.append([i, top()])
if len(c) > 2 and c[-1][1] - c[-2][1] == c[-2][1] - c[-3][1]:
period, growth = c[-1][0] - c[-2][0], c[-1][1] - c[-2][1]
delta_top = (1_000_000_000_000 - i) // period * growth
i = 10_000 - (1_000_000_000_000 - i) % period
print(top() + delta_top)
```

## Day 18: Boiling Boulders

Problem statement is here.

### Part 1

Part is trivial so I won't discuss it here.

### Part 2

Part 2 is also very easy, but I found a really neat solution worth sharing:
since all boulders are within `(0, 0, 0)`

and `(20, 20, 20)`

, I look at a grid
encompassing everything (`(-1, -1, -1) to (21, 21, 21)`

) and starting from ```
(-1,
-1, -1)
```

, flood fill. We use a queue and at each step we dequeue a triple of
coordinates. If already visited or out of bounds, we ignore it and continue.
Otherwise if it is a boulder, it means we found a new surface area. We mark
these coordinates as visited and enqueue all the neighbors. I like how whenever
we run into a boulder gives us exactly the area we are looking for. The full
solution is:

```
cubes = [tuple(map(int, l.strip().split(','))) for l in open('input').readlines()]
visited, queue, area = set(), [(-1, -1, -1)], 0
while queue:
(x, y, z) = queue.pop(0)
if (x, y, z) in visited:
continue
if not (-1 <= x <= 22 and -1 <= y <= 22 and -1 <= z <= 22):
continue
if (x, y, z) in cubes:
area += 1
continue
visited.add((x, y, z))
queue.append((x - 1, y, z))
queue.append((x + 1, y, z))
queue.append((x, y - 1, z))
queue.append((x, y + 1, z))
queue.append((x, y, z - 1))
queue.append((x, y, z + 1))
print(area)
```

## Day 19: Not Enough Minerals

Problem statement is here.

I used the same solution for part 1 and part 2: a recursive search where we keep track of the bots and resources we have, and the time. The problem is it takes too long to simulate minute by minute. If we try deciding at each minute whether to build any of the bots we can build or keep collecting resources, then recurse to next minute, we end up with too much combinatorial complexity. My solution instead does something like this: for the current moment in time, for each type of robot, say we want to build that one next - based on costs and available resources, we can calculate how many minutes from now that robot be built. We can then recurse (jump ahead in time) there updating available resources, since we know other robots won't be built until then.

As an additional optimization, we can keep track of how many geodes we collected at each minute and if our current search has fewer geodes, it means we already found a better solution and it is not worth going down this branch. There's probably smarter caching/pruning we can do but this seems to be good enough.

This tames the combinatorial complexity enough to get a reasonable run time and going from simulating 24 minutes in part 1 to simulating 32 minutes for fewer blueprints in part 2 doesn't seem to require changing the algorithm. Both parts take around 2 minutes to run. It can probably be optimize further.

```
import re
import math
def run(bots, costs, resources, time):
if best[time] > resources[3]:
return
best[time] = resources[3]
if time == 0:
return
for bot_type in range(4):
dt = math.ceil((costs[bot_type][0] - resources[0]) / bots[0])
if bot_type >= 2:
if bots[bot_type - 1] == 0:
continue
dt = max(dt, math.ceil((costs[bot_type][1] -
resources[bot_type - 1]) / bots[bot_type - 1]))
dt = max(dt, 0) + 1
if time < dt:
continue
new_resources = [resources[i] + bots[i] * dt for i in range(4)]
new_resources[0] -= costs[bot_type][0]
if bot_type >= 2:
new_resources[bot_type - 1] -= costs[bot_type][1]
bots[bot_type] += 1
run(bots, costs, new_resources, time - dt)
bots[bot_type] -= 1
score = 1
for line in open('input').readlines()[:3]:
m = re.match(
'.*(\d+) ore.*(\d+) ore.*(\d+) ore and (\d+) clay.*(\d+) ore and (\d+) obsidian', line)
costs = list(map(int, m.groups()))
costs = [[costs[0]], [costs[1]], [
costs[2], costs[3]], [costs[4], costs[5]]]
best = [0] * 33
run([1, 0, 0, 0], costs, [0] * 4, 32)
score *= best[0]
print(score)
```

## Day 20: Grove Positioning System

Problem statement is here.

Day 20 was very easy so I won't cover it here.

## Day 21: Monkey Math

Problem statement is here.

### Part 1

Another easy one. For part 1, we parse the input in an expression tree (with values at leaf nodes and operators at non-leaf nodes) and we recursively evaluate it from root.

```
tree = {}
for line in open('input').readlines():
key, value = line.strip().split(': ')
value = value.split(' ')
if len(value) == 1:
value = int(value[0])
tree[key] = value
def get(key):
if isinstance(tree[key], int):
return tree[key]
v1, v2 = get(tree[key][0]), get(tree[key][2])
match tree[key][1]:
case '+': return v1 + v2
case '-': return v1 - v2
case '*': return v1 * v2
case '/': return v1 // v2
print(get('root'))
```

### Part 2

Part 2 effectively makes the root be `==`

and asks us to find the value for the
`humn`

node. For this, we can update our recursive evaluation to either compute
a value or return `None`

if `humn`

is part of the subtree we're trying to
evaluate (so if either left or right subtree evaluates to `None`

, return
`None`

). We add another recursive function `solve()`

which takes a node and an
expected value (we expect the node to end up equal to the value) then we can
recursively solve: evaluate left and right. Depending on which of them returns
`None`

, we recurse down that subtree with an updated expected value. For
example, if we expect `left + right`

to be `10`

and we get `5`

and `None`

back,
then we recurse down the `right`

subtree, with an expected value of `10 - left`

.

```
tree = {}
for line in open('input').readlines():
key, value = line.strip().split(': ')
value = value.split(' ')
if len(value) == 1:
value = int(value[0])
tree[key] = value
def get(key):
if tree[key] == None or isinstance(tree[key], int):
return tree[key]
v1, v2 = get(tree[key][0]), get(tree[key][2])
if v1 == None or v2 == None:
return None
match tree[key][1]:
case '+': return v1 + v2
case '-': return v1 - v2
case '*': return v1 * v2
case '/': return v1 // v2
def solve(key, eq):
if tree[key] == None:
return eq
k1, k2 = tree[key][0], tree[key][2]
v1, v2 = get(k1), get(k2)
if v1 == None:
match tree[key][1]:
case '+': return solve(k1, eq - v2)
case '-': return solve(k1, eq + v2)
case '*': return solve(k1, eq // v2)
case '/': return solve(k1, eq * v2)
if v2 == None:
match tree[key][1]:
case '+': return solve(k2, eq - v1)
case '-': return solve(k2, v1 - eq)
case '*': return solve(k2, eq // v1)
case '/': return solve(k2, v1 // eq)
tree['humn'] = None
tree['root'][1] = '-'
print(solve('root', 0))
```

## Day 22: Monkey Map

Problem statement is here.

### Part 1

This one was fun but a bit tedious. Part 1 is very easy, we implement movement with wrap-around and stopping when we hit `#`

.

```
import re
grid = [line.strip('\n').ljust(150, ' ') for line in open('input').readlines()]
dirs, grid = [m.group() for m in re.finditer('(\d+)|L|R', grid[-1])], grid[:-2]
dirs = [int(d) if str.isdecimal(d) else d for d in dirs]
facing = [(1, 0), (0, 1), (-1, 0), (0, -1)]
x, y, d = grid[0].index('.'), 0, 0
def move(x, y, d):
nx = (x + d[0]) % len(grid[0])
ny = (y + d[1]) % len(grid)
match grid[ny][nx]:
case ' ':
nx, ny = move(nx, ny, d)
return (nx, ny) if grid[ny][nx] != ' ' else (x, y)
case '#': return (x, y)
case '.': return (nx, ny)
for step in dirs:
if isinstance(step, int):
while step > 0:
x, y = move(x, y, facing[d])
step -= 1
elif step == 'L':
d = (d - 1) % 4
else:
d = (d + 1) % 4
print(1000 * (y + 1) + 4 * (x + 1) + d)
```

### Part 2

For part 2, we need to figure out how the various facets connect into a cube and map movement from one face to another. Personally, I made a paper cutout of the input shape, folded it, and used that to figure out the transitions:

The algorithm is pretty easy if the mappings are right. While on the same facet,
we simply move in the direction we are supposed to move. We can encode a facet
as a pair of `(region_x, region_y)`

coordinates where ```
region_x, region_y = x //
50, y // 50
```

. Of course, some pairs of coordinates are not part of any facet of
the cube (e.g. `(0, 0)`

) but that doesn't matter. Using this encoding, we can
tell when a movement gets us outside the current region. When that happens, we
have a helper function which helps figure out where we end up and what is the
new orientation.

```
import re
grid = [line.strip('\n').ljust(150, ' ') for line in open('input').readlines()]
dirs, grid = [m.group() for m in re.finditer('(\d+)|L|R', grid[-1])], grid[:-2]
dirs = [int(d) if str.isdecimal(d) else d for d in dirs]
size = 50
facing = [(1, 0), (0, 1), (-1, 0), (0, -1)]
connections = {
(1, 0): [(2, 0, 0), (1, 1, 1), (0, 2, 0), (0, 3, 0)],
(2, 0): [(1, 2, 2), (1, 1, 2), (1, 0, 2), (0, 3, 3)],
(1, 1): [(2, 0, 3), (1, 2, 1), (0, 2, 1), (1, 0, 3)],
(0, 2): [(1, 2, 0), (0, 3, 1), (1, 0, 0), (1, 1, 0)],
(1, 2): [(2, 0, 2), (0, 3, 2), (0, 2, 2), (1, 1, 3)],
(0, 3): [(1, 2, 3), (2, 0, 1), (1, 0, 1), (0, 2, 3)],
}
x, y, d = grid[0].index('.'), 0, 0
def move(x, y, d):
nx = x + facing[d][0]
ny = y + facing[d][1]
nd = d
if (x // size, y // size) != (nx // size, ny // size):
nx, ny, nd = switch_region(x, y, d)
match grid[ny][nx]:
case '#': return (x, y, d)
case '.': return (nx, ny, nd)
def switch_region(x, y, d):
nrx, nry, nd = connections[(x // size, y // size)][d]
nx, ny = nrx * size, nry * size
rx, ry = x % size, y % size
if (d, nd) in [(0, 0), (1, 3), (2, 2), (3, 1)]:
return nx + size - rx - 1, ny + ry, nd
if (d, nd) in [(0, 2), (1, 1), (2, 0), (3, 3)]:
return nx + rx, ny + size - ry - 1, nd
if (d, nd) in [(0, 1), (1, 0), (2, 3), (3, 2)]:
return nx + size - ry - 1, ny + size - rx - 1, nd
if (d, nd) in [(0, 3), (1, 2), (2, 1), (3, 0)]:
return nx + ry, ny + rx, nd
for step in dirs:
if isinstance(step, int):
while step > 0:
x, y, d = move(x, y, d)
step -= 1
elif step == 'L':
d = (d - 1) % 4
else:
d = (d + 1) % 4
print(1000 * (y + 1) + 4 * (x + 1) + d)
```

## Day 23: Unstable Diffusion

Problem statement is here.

This is a cellular automaton. In general, when implementing cellular automata,
the trick

is to not change things in place, rather use a new copy for each
generation. I represented the elves as a set of `(x, y)`

coordinates. We can use
set intersection to see if an elf has other elves nearby or whether two elves
would end up moving in the same spot. I won't go into more detail as this was
another pretty easy problem. The code is on my GitHub.

## Day 24: Blizzard Basin

Problem statement is here.

### Part 1

I liked this one. For both part 1 and part 2, this becomes easy to solve with a couple of interesting observations.

First the blizzards move in a repeating pattern so we can map which squares are
occupied at a given point in time and we know the occupancy repeats every
`lcm(height, width)`

where `height`

and `width`

are the height and width of the
valley. We can compute this many generations and store the occupancy map in a
lookup.

```
import math
blizzards = []
lines = [line.strip() for line in open('input').readlines()]
for y, line in enumerate(lines):
for x, c in enumerate(line):
if c in '<^>v':
blizzards.append((x, y, c))
maxx, maxy = len(lines[0]) - 1, len(lines) - 1
move = {'<': (-1, 0), '^': (0, -1), '>': (1, 0), 'v': (0, 1)}
def step(blizzards):
new = []
for b in blizzards:
x, y = b[0] + move[b[2]][0], b[1] + move[b[2]][1]
if x == 0: x = maxx - 1
if x == maxx: x = 1
if y == 0: y = maxy - 1
if y == maxy: y = 1
new.append((x, y, b[2]))
return new
def occupancy(blizzards):
return {(x, y) for x, y, c in blizzards}
steps, lcm = {}, math.lcm(maxx - 1, maxy - 1)
for i in range(lcm):
steps[i] = {(x, y) for x, y, _ in blizzards}
blizzards = step(blizzards)
```

Next, we can do a breadth-first search to find the closest path from one side to
the other. Since a possible move is waiting one, its pretty hard to find bounds
for a depth-first search. On the other hand, at every step the elves can occupy
one of the at most `height * width`

positions. Of course, most of these will be
occupied by blizzards. So for a BFS, we start from the initial position and time
(step `0`

) and use a queue. We pop the first move and enqueue all possible moves
from this position (taking into account valley bounds and blizzard occupancy)
for the next step. As long as we ensure not to enqueue duplicates, the queue
stays small. Since this is BFS, as soon as the position we dequeue is our
destination, we know this is the earliest we can get there.

```
def solve():
queue = [(1, 0, 0)]
while True:
x, y, step = queue.pop(0)
for x, y in [(x + m[0], y + m[1]) for m in move.values()] + [(x, y)]:
if (x, y) == (maxx - 1, maxy):
return step + 1
if (x, y) != (1, 0):
if x <= 0 or x >= maxx or y <= 0 or y >= maxy:
continue
if (x, y) in steps[(step + 1) % lcm]:
continue
if (x, y, step + 1) not in queue:
queue.append((x, y, step + 1))
print(solve())
```

### Part 2

The extra trips are no problem since this is very fast. The only changes I had
to make from part 1 to part 2 were modifying `solve()`

to parameterize start,
destination, and initial point in time, then call it 3 times for each trip:

```
def solve(src, dest, step):
queue = [(src[0], src[1], step)]
while True:
x, y, step = queue.pop(0)
for x, y in [(x + m[0], y + m[1]) for m in move.values()] + [(x, y)]:
if (x, y) == (dest[0], dest[1]):
return step + 1
if (x, y) != (src[0], src[1]):
if x <= 0 or x >= maxx or y <= 0 or y >= maxy:
continue
if (x, y) in steps[(step + 1) % lcm]:
continue
if (x, y, step + 1) not in queue:
queue.append((x, y, step + 1))
trip1 = solve((1, 0), (maxx - 1, maxy), 0)
trip2 = solve((maxx - 1, maxy), (1, 0), trip1)
trip3 = solve((1, 0), (maxx - 1, maxy), trip2)
print(trip3)
```

## Day 25: Full of Hot Air

Problem statement is here.

Another easy one that I won't discuss in detail, we just need to implement conversion from decimal to SNAFU and back:

```
def to_dec(n):
digits = {'0': 0, '1': 1, '2': 2, '-': -1, '=': -2}
return sum([5 ** i * digits[d] for i, d in enumerate(n[::-1])])
def to_snafu(n):
s = ''
while n:
s = ['0', '1', '2', '=', '-'][n % 5] + s
n = n // 5 + (1 if s[0] in '-=' else 0)
return s
print(to_snafu(sum([to_dec(line.strip()) for line in open('input').readlines()])))
```

In Advent of Code tradition, day 25 has only 1 part.

This was another very fun set of problems and I am looking forward to Advent of Code 2023.