Theme

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.

InteractiveMirror sandbox
The day 16 sample grid, 10 by 10, with the beam entering top-left heading right. Energised cells are tinted; 46 of 100 light up.

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. Vec handles 3D and beyond by construction; complex has exactly a real and an imaginary part. A day with a z axis needs a different type.
  • Exactness depends on staying on integers. Every trick above (hashability, d * 1j as an exact 90° turn, the mirror formulas) relies on directions being unit steps on the grid axes. Scale a complex by 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 by i turns right.” A DIRECTIONS dict 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.