def parse(inp, xmax=10):
= inp[2 : (len(inp) - 1)]
stripped = len(stripped)
ymax
stripped.reverse()= list(zip(*stripped))
stripped = stripped[1 : (xmax + 1)]
stripped = {i: set() for i in range(4)}
mapping
for i in range(min(ends) + 2, max(ends) - 1, 2):
= stripped[i]
this for j in range(ymax):
= values_map[this[j]]
val # Add position to set
mapping[val].add((i, j)) return mapping, ymax
This post contains spoilers for the day 23 puzzle of Advent of Code 2021.
In an earlier post, I related my long but successful effort to obtain every last star in Advent of Code’s 2021 edition. It ended in surprising anticlimax when I solved day 23, a puzzle that looked daunting to tackle with code, using paper, pen, and some cut-up sticky notes. I could have stopped there. I should have stopped there. But good stories don’t end with an anticlimax, and the feeling that I had somehow cheated nagged at me. With plenty of free time as I hunted for a job, I decided to solve the puzzle the right way.
The Problem
Day 23’s puzzle is a variation of the classic Towers of Hanoi problem. Instead of disks, the puzzle has players move different types of amphipod, in keeping with the year’s ocean theme. The goal is to move each amphipod of each type into the correct “side room” connected to the main board, or “hall”, as efficiently as possible. The second part of the puzzle doubles the number of amphipods, making it much tougher to solve by hand.
A post on the subreddit suggested using the A* (“A star”) algorithm. A*https://en.wikipedia.org/wiki/A*_search_algorithm) is a classic pathfinding algorithm that calculates the shortest path between two given nodes of a graph. Implementations rely on two helper functions. d
measures distances between nodes, and a heuristic function called h
estimates the distance between a node and the goal. The algorithm is mathematically certain to return the correct path so long as h
never overestimates the distance to the goal.
In the problem at hand, the nodes were clearly game states (legal configurations of the board). Two nodes shared a connection if one could be reached from the other by a legal move. (Luckily, no legal moves are reversible, so the graph is acyclic — loops are impossible). Also, I only needed to find the distance of the shortest path, not the path itself.
Still, I faced several hard tasks:
- Create a data structure capable of representing any valid game state
- Implement
d
(to measure distances between nodes) andh
(to conservatively estimate any node’s distance from the goal). - Write an
A_star
function that used these routines to find the minimal cost
Into the Fray
As is usual with Advent of Code, the first task was parsing the input. My input looked like this:
#############
#...........#
###A#D#B#D###
#B#C#C#A#
#########
(This is the sample input given with the puzzle description, not the real one, which players are discouraged from sharing). I made the crucial decision to represent positions on the board as tuples of (x, y)
coordinates. Since I was using Python, I decided to use a zero-based index, with the leftmost hall space as the x
origin and the bottom side room spaces as the y
origin. So the leftmost A
amphipod on the board above would be located at (2, 2)
. Using complex numbers instead of tuples would have made my life much easier. In any case, I wrote a crude function to map each amphipod type to a set containing its positions:
Then I wrote a complicated that computed the coordinates of the spaces between each pair of coordinates it was legal to move between. That way, I could discard moves if I detected an amphipod blocking them.
Next came designing an object to represent game states. It should own h
and d
, the second of which would take another game state as argument. I decided it should also be responsible for finding adjacent nodes and creating objects to represent them. I decided to call the class State
.
From here, my work only got kludgier. State
ended up mapping each amphipod type to a set of the positions it occupied as well as tracking the occupants of each side room - a redundancy I couldn’t seem to avoid. From there, d
and h
were surprisingly simple. d
would only ever be called on states that differed by the position of just one amphipod, so all I had to do was find the two coordinate tuples that disagreed in the instances’ coordinate sets, measure the distance between them, and multiply by the cost of moving the relevant amphipod type one space. h
was a bit trickier, but hardly brutal — I just computed the distance from each amphipod to the target side room, a simple approach that would never underestimate the true cost.
The real bear turned out to be finding the valid neighbors of each instance. It took me an embarrassingly long time to figure out the unstated rules:
Amphipods in side rooms may only move out if they are in the side room of the wrong type, or if an amphipod of the wrong type is positioned behind them.
Amphipods in side rooms that meet one or both criteria can move to any hall space to which the path is clear, or the innermost open space of their side room if it complies with rule 4.
Amphipods in hall rooms may only move into the side room of their type, and only if a path to it is clear.
A side room may only admit amphipods if has an open space and contains no amphipods of a different type.
Translating these directives into conditions was pure hell, and the result turned into pure write-only code. Here’s a representative excerpt:
if coord[0] in self.sides_idx:
= self.side_idx2type(coord[0])
x_type if (x_type == k and self.sides[k]["completed"]) or (
1] < self.ymax - 1
coord[and self.sides[x_type]["room"][coord[1] + 1] is not None
):continue
Somehow, I finished it.
That left only the A_star
function that did the real work. Translating Wikipedia’s pseudocode for the algorithm into Python was simple:
def A_star(start, goal, debug=False):
= hash(start)
start_k = {start_k: start}
open_set
# For node k, node preceding it on cheapest known path to k
= {}
came_from
# g_score[k] is cost of cheapest known path to k
= defaultdict(lambda: inf)
g_score = 0
g_score[start_k] # gscore[k] + k.h() - best estimate of total cost (default to infinity if node unknown)
= defaultdict(lambda: inf)
f_score = g_score[start_k] + start.h()
f_score[start_k]
while open_set:
= inf
min_cost # h = hash
for h, node in open_set.items():
= f_score[h]
score = min(min_cost, score)
this_cost if this_cost < min_cost and h in open_set.keys():
= node
current # print(current)
# print("\n")
if current == goal:
return g_score[hash(current)] # Cheapest cost to goal
= this_cost
min_cost = hash(current)
current_k
current.find_neighbors()if debug:
print(hash(current))
print(current)
print("-------------------\n")
for n in current.neighbors:
print(n)
print(current.d(n))
input("Continue: ")
print("\n\n\n")
open_set.pop(current_k)for neighbor in current.neighbors:
# print(neighbor.neighbors)
# Distance from start to neighbor through current
= g_score[current_k] + current.d(neighbor)
g_score_new # print(f"distance: {current.d(neighbor)}")
# print(neighbor)
= hash(neighbor)
neighbor_k # This path to neighbor cheaper than any known, so record it
if g_score_new < g_score[neighbor_k]:
= current
came_from[neighbor_k] # New estimate of cost from this neighbor
# Forgot this line
= g_score_new
g_score[neighbor_k] = g_score_new + neighbor.h()
f_score[neighbor_k] if neighbor not in open_set.values():
= neighbo open_set[neighbor_k]
My only addition, naturally, was a debug mode. Then came the really hard part.
I spent an embarrassing amount of time in the debugger getting everything to work correctly. I fell into in a dispiriting loop of scanning output for evidence of bugs, stepping through the debugger to track them down, and making painstaking changes to fix them. I came close to giving up, and several times regretted starting. Then, as I worked one fine Monday morning in July, the code spat out a plausible-looking answer. Not expecting success, I checked the Advent of Code website and gasped when I saw it was correct.
I wasn’t home free; my inefficient kludge algorithm might well be too slow for the second half of the problem. I modified State
to handle a larger game board, crossed my fingers, and ran the script again. It took a few minutes longer, but it returned the correct answer for part 2. I had done it.
I savored the feeling of blissful triumph, knowing it would not last. I might have just finished the worst implementation of A*
of all time, but it was my implementation, and it solved the problem. Somehow, writing your own intricate kludge is far more satisfying then copying someone else’s elegant solution. In any case, I was at last done: I had finished all 25 puzzles for Advent of Code 2021 by myself. Perhaps an achievement in pointlessness, but an achievement nonetheless.