Blog · Puzzles ·
Complex numbers are the right type for a grid
Advent of Code 2023 day 16 is a beam bouncing off mirrors on a grid: the kind of problem a 301-line N-dimensional vector class was built for the season before, and then quietly abandoned in favour of two lines using Python's built-in complex. A mirror sandbox you can draw your own maze into.
- Interactive
- advent-of-code
- complex-numbers
- grid-algorithms
- python
Most of an Advent of Code December is grid code: a 2D array, a current position, a
direction, a rule for what happens when you step. And most of the bugs are the same bug:
a row and a column swapped, an x += 1 where you needed y += 1, a DIRECTIONS list
where “turn right” was hand-typed and one entry has the wrong sign. libaoc hit that bug
often enough that somewhere among its earliest puzzles (the a2015 directory, solved as
practice alongside the live 2023 event, not literally in 2015), I wrote a proper fix for
it: a 301-line Vec class, N-dimensional, every arithmetic dunder implemented, so a
direction was a real object instead of a tuple you eyeballed. By the time I was ten days
into the actual 2023 puzzles, I had stopped using it, in favour of two lines built on a
type Python already shipped: complex.
Attempt one: a vector class general enough for anything
libaoc/vec.py is the library’s most ambitious file. Vec stores its components as a
tuple and implements essentially the full numeric protocol: __add__, __sub__,
__mul__, __truediv__, __floordiv__, __pow__, the bitwise operators, comparisons,
and, so instances can live in a set, __hash__:
# libaoc/vec.py L142-152
def __add__(self, __value: Iterable | object) -> "Vec":
value_iter = self._get_other_iter(__value)
return Vec(*(vi1+vi2 for vi1, vi2 in zip(self,value_iter)))
def __radd__(self, __value: Iterable | object) -> "Vec":
return self.__add__(__value)
def __mul__(self, __value: Iterable | object) -> "Vec":
value_iter = self._get_other_iter(__value)
return Vec(*(vi1*vi2 for vi1, vi2 in zip(self,value_iter)))
def __rmul__(self, __value: Iterable | object) -> "Vec":
return self.__mul__(__value)
# libaoc/vec.py L232-233
def __hash__(self) -> int:
return hash(self.v)
It has named constants for both 2D and 3D directions, and a mapping from arrow keys, WASD,
and compass letters to Vec instances, so a puzzle’s direction characters convert in one
line:
# libaoc/vec.py L242-275
Vec.UP2 = Vec.UP(2)
Vec.DOWN2 = Vec.DOWN(2)
Vec.LEFT2 = Vec.LEFT(2)
Vec.RIGHT2 = Vec.RIGHT(2)
...
Vec.COMMON_MAPPINGS_2 = {
"^": Vec.UP2,
"v": Vec.DOWN2,
"<": Vec.LEFT2,
">": Vec.RIGHT2,
"u": Vec.UP2,
...
"w": Vec.UP2,
"s": Vec.DOWN2,
"a": Vec.LEFT2,
"d": Vec.RIGHT2,
...
}
And it works. aoc/a2015/d03/main.py (Santa visiting houses from a string of arrows) is
the one file in either AoC repo that actually builds Vec instances and adds them:
# aoc/a2015/d03/main.py L14-21
txt = p.get_data(-1, ["^>v<", "^v^v^v^v^v"])
dirs = p.Convert(vec.Vec.COMMON_MAPPINGS_2)(txt)
loc = vec.Vec(0,0)
seen = {loc}
for v in dirs:
loc = loc + v
seen.add(loc)
len(seen)
loc + v reads exactly like the vector addition it is, seen.add(loc) works because
__hash__ and __eq__ are implemented, and there is no way to mistype a sign here: UP2
and DOWN2 are named constants, not two entries in a list you have to keep straight by eye.
What it cost: 301 lines, most of them dunder methods for operators this puzzle never
uses (__xor__, __pow__, __divmod__, __lshift__…), to get addition, subtraction,
and hashing for 2D integer points. What it bought: those three operations, done safely,
plus genuine N-dimensionality: Vec doesn’t care if you hand it 2 components or 12.
That generality is real. It is also, for a grid puzzle, mostly unused.
Attempt two: the type was already there
By aoc/a2023/d02/main.py, a season later, the tell is a single dead line: a type hint
for a variable that is never actually a Vec:
# aoc/a2023/d02/main.py L77
all_vs: list[vec.Vec] = []
Nothing in that file ever constructs a Vec; the annotation is a fossil of the old habit,
outliving the code that would have made it true. By day 10 the switch is complete and
explicit: grid positions and directions are plain Python complex values, real for the
column, imaginary for the row:
# aoc/a2023/d16/main.py L40-43
up = -1j
down = 1j
left = -1
right = 1
complex already has addition: pos + d moves a point, no class needed. It already has
multiplication, and because the imaginary axis here runs down the grid (a row-major
array, not a Cartesian plane), multiplying a direction by 1j is a 90° turn to the right:
up * 1j == right, right * 1j == down. Rotation is arithmetic, not a lookup table, and
it stays exact for as long as your directions stay unit steps on the axes. The components
never leave the integers, so there’s no floating-point creep to worry about. And complex
is hashable for free; the __hash__ and __eq__ methods Vec defines to earn a place in
a set (L218-219, L232-233), on top of the eight ordering comparisons above them,
__lt__, __gt__ and their reflected forms (L210-228), that a grid point never uses at
all, are simply not needed.
The reflection is two lines
Day 16, the cleanest file in either AoC repo, is a beam bouncing around a maze of / and
\ mirrors and |/- splitters. A tuple-and-DIRECTIONS-list version of this needs an
explicit case for every (incoming direction, mirror type) pair, eight cases, easy to get
one sign wrong in. The complex version is two lines, one per mirror:
# aoc/a2023/d16/main.py L62-76
if ch == "|" and d.real != 0:
to_explore.append((pos+up, up))
to_explore.append((pos+down, down))
elif ch == "-" and d.imag != 0:
to_explore.append((pos+left, left))
to_explore.append((pos+right, right))
elif ch == "/":
new_dir = complex(-d.imag, -d.real)
to_explore.append((pos+new_dir, new_dir))
elif ch == "\\":
new_dir = complex(d.imag, d.real)
to_explore.append((pos+new_dir, new_dir))
else:
to_explore.append((pos+d, d))
complex(-d.imag, -d.real) and complex(d.imag, d.real) are the whole mirror table. A
beam heading up, d = -1j (real 0, imaginary −1), that hits a \: the new direction is
complex(d.imag, d.real) = complex(-1, 0) = -1, heading left. That’s the transition the
widget below will show you happening, live, the first time your beam meets a backslash
while travelling up: -1j → -1. The splitter guards (d.real != 0 for |, d.imag != 0
for -) are doing real work too, they only fire when the beam is travelling across the
splitter’s grain; a beam already travelling along a | passes straight through, into the
else branch, no special case needed.
With JavaScript on, this becomes an editable grid: paint mirrors, splitters or empty cells, pick a border cell as the beam’s entry point, then Step or Play to watch it bounce, with a live readout of the current direction as a complex number and the exact reflection arithmetic being applied. A second button sweeps every possible entry point and shades the border by how many cells each one energises, literally the puzzle’s part two.
Entering at the top-left heading right on the sample grid above energises 46 cells, the
same number get_energised_count(0, right) returns for that call in the source. Try
drawing your own layout, or hit Randomise, and watch the arithmetic panel next to the
canvas: every mirror hit prints its d = … → formula = … line, so -1j → -1 (or whichever
transition your beam actually makes) is something you see happen, not something you have
to take on faith from the two lines above.
Packing extra state into the same number
Day 17 needs more than a direction: the crucible can’t travel more than three blocks in a
straight line, so the search has to remember how far it has come in its current heading,
not just which way it’s facing. Rather than a (direction, run_length) pair, day 17 packs
both into one complex value, using its phase for direction and its magnitude for the run:
# aoc/a2023/d17/main.py L56-64
up = -1j
down = 1j
left = -1
right = 1
directions = [up, down, left, right]
def complex_dot(c1:complex,c2:complex):
return c1.real*c2.real + c1.imag*c2.imag
def mag(c1:complex):
return c1.real + c1.imag
# aoc/a2023/d17/main.py L85-92
for d in directions:
dot_product = complex_dot(last_dir, d)
if dot_product < 0: continue # Going backwards
# Store the new length
next_dir = d*dot_product+d
if mag(next_dir) > 3: continue
new_pos = pos+d
heapq.heappush(to_explore, PrioritizedItem(pos_cost,new_pos,next_dir))
Continue straight and next_dir is the same direction with its magnitude bumped by one
(right for two steps becomes 2, three steps 3); turn 90° and the dot product is zero,
so next_dir resets to a fresh unit step. One complex is doing the job of a struct with
two fields, because direction and magnitude were always separate axes of the same number.
(Day 17 in full, why Dijkstra’s usual “cheapest way to reach a cell” assumption breaks
here, and the state-space fix, is its own post, written alongside this one.)
Where it breaks down
complex is a genuinely narrow tool, and it’s worth being honest about the edges:
- It’s stuck at two dimensions.
Vechandles 3D and beyond by construction;complexhas exactly a real and an imaginary part. A day with azaxis needs a different type. - Exactness depends on staying on integers. Every trick above (hashability,
d * 1jas an exact 90° turn, the mirror formulas) relies on directions being unit steps on the grid axes. Scale acomplexby something non-integer, or rotate by an angle that isn’t a multiple of 90°, and you’re back to floating-point comparisons and epsilon tolerances, exactly the bugs the integer version avoided. - It costs a reader who doesn’t already know the trick.
complex(-d.imag, -d.real)is two lines instead of an eight-case table, but it’s opaque until you’ve internalised “real is column, imaginary is row, multiplying byiturns right.” ADIRECTIONSdict with named keys is more code and self-documenting; this is less code and requires the reader to already be in on the convention. For a one-off AoC solution that’s a fine trade. For code someone else has to review cold, it’s a real cost.
The honest lesson
The lesson here isn’t “complex numbers are a clever trick”, plenty of AoC write-ups make
that point and stop. The more useful, less flattering one is that I reached for a general
abstraction (301 lines of N-dimensional vector machinery) before I’d noticed that the
concrete problem in front of me, a 2D grid with axis-aligned steps, was already exactly
the shape of a type the standard library ships. Vec isn’t bad code; the d03 solution
using it is clean, correct, and arguably more self-documenting than the complex version
above. It’s just more machine than a 2D integer grid needs, and the standard library had
already built the machine I actually needed. That’s a cheaper lesson to learn from your own
abandoned class than from a code review.