Jonathan. Frech’s WebBlog

Advent of Code 2024: A little past half­time. (#293)

Jonathan Frech,

Twenty twenty-four is the first year I actively participate in Advent of Code: Each day, I open my dig­i­tal advent calendar’s door and brood over how best to help the Christmas Elves find their Chief Historian. Although I had heard of Advent of Code and a fellow student had once bothered me two years past with aoc/​2022/​16/1, where I couldn’t come up with any­thing smarter than applying brute force and he later mentioned in passing to not have seen any­thing smarter on forums he lightly perused, this year is the first time I go to bed each day in joyous anticipation of waking up, rushing to code some discrete solver.

Aoc/​2024/​1

Day one expectedly was but a warm-up; a kata in the clas­si­cal sense. Solvable in a Haskell one-liner (the second part I didn’t elegantly get transformed into point-free notation):

{- aoc/2024/1/1 -}
main = sum . fmap abs . uncurry (zipWith (-)) . swap . fmap (snd <$>) . swap
  . fmap (snd <$>) . break fst . sort . zipWith ($) (cycle [(False,), (True,)])
  . fmap (read :: String -> Integer) . words <$> getContents >>= print

Aoc/​2024/​2

For the second day, I really wanted to use Ivy as Russ Cox had done so ef­fort­less­ly in 2021 (though I un­for­tu­nate­ly drive Ivy not with Acme but with Shell). Yet my APL un­der­stand­ing was too lacklustre to get it to work, making me give up and whip up a straight-forward Go solution (not shown herein). Already on day two, I had taken longer than a day to submit a solution. But I also wasn’t too interested in the early days.

# aoc/2024/2/1
{ cat <<EOI
op incordec rep = and/ ((up rep) == iota rho rep) | (down rep) == iota rho rep
op inrange rep =
  srt = rep[down rep]
  δ = 1 drop (-1 rot srt) - srt
  and/ (δ >= 1), (δ <= 3)
op safe rep = (incordec rep) & inrange rep
EOI
sed 's!^!safe !'
} | ivy | grep 1 | wc -l

Aoc/​2024/​3

On the third day, I realized everything in Shell. To sum everything up (Legacy sum computes a deprecated file checksum and does not sum up lists of dec­i­mal numbers!), I modified my nascent, unit-aware arbitrary-precision calculator dreisatz to read from stdin and not space-join its arguments (the ad-hoc, hacky solution cho­sen for any new command im­ple­men­ta­tion).

# aoc/2024/3/2
grep -Po "(mul\((0|[1-9][0-9]{0,2}),(0|[1-9][0-9]{0,2})\)|do\(\)|don't\(\))" \
  | awk '/^do/{DISABLED=0}; /^don/{DISABLED=1}; /^m/{if(!DISABLED)print}' \
  | sed 's![mul()]!!g; s!,!*!g; a+' | xargs | sed 's! !!g; s!\+$!!' \
  | sed 's!$!=x!' | xargs dreisatz | grep -o '[0-9]*'

From day four onwards, I primarily wrote Go (aoc/​2024/​17/2 sees a little C).

Aoc/​2024/​4

Day four wasn’t too interesting⸺fid­dling with two-di­men­sion­al char arrays.
Days three and four were coded in swift succession on the ICE from Hamburg to Tübingen. On-board WiFi worked flawlessly, if of a tad high latency.

Aoc/​2024/​5

Day five, on the oth­er hand, lead me to pass a receiver-bound meth­od to Go’s slices.SortFunc for an elegant core:

// aoc/2024/5/2

type PageOrdering struct { before, after map[int][]int }

func main() {
  var tot int

  po := NewPageOrdering()
  scanner := bufio.NewScanner(os.Stdin)
  for scanner.Scan() {
    // ...
    po.Leq(atoi(nm[:k]), atoi(nm[k+1:]))
  }

  for scanner.Scan() {
    ns := Fmap(strings.Split(scanner.Text(), ","), atoi)
    if slices.IsSortedFunc(ns, po.Cmp()) {
      continue
    }

    slices.SortFunc(ns, po.Cmp())
    tot += middle(ns)
  }
  must(scanner.Err())

  fmt.Println(tot)
}

Aoc/​2024/​6

Problems where you don’t need to op­ti­mize but on­ly execute hide all of their complexity in parsing. Day six was nothing more. Yet me crudely im­ple­ment­ing a (buggy!) rep­re­sent­ation of one infinite Cartesian plane quadrant *Quadrant[T] would cause confusion the com­ing days.

Aoc/​2024/​7

For day seven’s first part, I encoded all possible expressions inside an in­te­ger’s bits (since there were on­ly ‘+’ and ‘*’ to choose from). When ‘||’ got added, I made good use of Go’s fresh-out-of-the-oven coroutines:

// aoc/2024/7/2

func Cartesian[S ~[]T, T interface{~int8 | ~int}](x T, n int) iter.Seq[S] {
  return func(yield func(S) bool) {
    if x <= 0 || n < 0 {
      return
    }

    xs := make(S, n)
    for {
      if !yield(xs) {
        return
      }

      j := len(xs)-1
      xs[j]++
      for xs[j] >= x {
        if j == 0 {
          return
        }
        xs[j] = 0
        j--
        xs[j]++
      }
    }
  }
}

Aoc/​2024/​8

Day eight was fun: type Pt = image.Point is a versatile two-di­men­sion­al vector rep­re­sent­ation and stepping to the end of the board to then step back in gcd-divided steps for aoc/​2024/​8/2 made for a neat little algorithm. I was, how­ev­er, sur­prised to re­al­ize Go is missing in­te­ger-valued abs and gcd. Not that I had any reason to ex­pect their existence after my many sleepless hours poring over Go’s stdlib.

// aoc/2024/8/1

func Choose2[T any](xs []T) iter.Seq[[2]T] {
  return func(yield func([2]T) bool) {
    for i := 0; i < len(xs); i++ {
      for j := i+1; j < len(xs); j++ {
        if !yield([2]T{xs[i], xs[j]}) {
          return
        }
      }
    }
  }
}

func abs(x int) int {
  return x * sgn(x)
}

func sgn(x int) int {
  switch {
  case x < 0:
    return -1
  default:
    return 0
  case x > 0:
    return +1
  }
}
// aoc/2024/8/2

// Cf. https://en.cppreference.com/w/cpp/numeric/gcd [2024-12-08]
func gcd(x, y int) int {
  switch {
  case x < 0 && y < 0:
    return gcd(-x, -y)
  case x < 0:
    return -gcd(-x, y)
  case y < 0:
    return -gcd(x, -y)

  case x == 0:
    return y
  case y == 0:
    return x
  case x < y:
    return gcd(y, x)
  default:
    return gcd(y, x % y)
  }
}

Aoc/​2024/​9

Brains off, code! was the theme for day nine. I missed a possible “slices.LastIndex” symbol.

// aoc/2024/9/2
func slicesLastIndex[S ~[]E, E comparable](s S, v E) int {
  for j := len(s)-1; j >= 0; j-- {
    if s[j] == v {
      return j
    }
  }
  return -1
}

Aoc/​2024/​10

Re­strict­ed graph hiking lead me to im­ple­ment an iterator on day ten which proved versatile the com­ing days.

// aoc/2024/10/1
func CardinalSteps() iter.Seq[Pt] {
  return func(yield func(Pt) bool) {
    _ = yield(Pt{+1, 0}) && yield(Pt{0, -1}) &&
      yield(Pt{-1, 0}) && yield(Pt{0, 1})
  }
}

Aoc/​2024/​11

Day eleven had the affronting moment of im­ple­ment­ing aoc/​2024/​11/1 as build in-memory, then count on­ly to suffer a snub at aoc/​2024/​11/2 with me owning on­ly non-exponentially-growing memory sticks.

// aoc/2024/11/2

type (
  Pebble int
  Pebbles map[Pebble]int
)

func blink(pebbles Pebbles) Pebbles {
  evolved := make(Pebbles)
  for pebble, count := range pebbles {
    if pebble == 0 {
      evolved[1] += count
    } else if s := strconv.Itoa(int(pebble)); len(s) % 2 == 0 {
      // strconv.Atoi assumes base 10,
      // so the possible leading zeroes don't trigger an octal sniff
      evolved[Pebble(atoi(s[:len(s)/2]))] += count
      evolved[Pebble(atoi(s[len(s)/2:]))] += count
    } else {
      evolved[pebble * 2024] += count
    }
  }
  return evolved
}

func main() {
  cast := func(x int) Pebble { return Pebble(x) }
  pebbles := Count(Fmap(cast, Fmap(atoi, stdinwords())))
  for range 75 {
    pebbles = blink(pebbles)
  }
  fmt.Println(Sum(maps.Values(pebbles)))
}

Aoc/​2024/​12

Fi­nal­ly, a tougher nut to crack: I fiddled with day twelve’s edge finding so long that I had to interrupt my cod­ing to go to the doctor’s office. Fortunately, they offered an open WiFi and I could submit aoc/​2024/​12/2’s an­swer from inside the waiting room (the following is a refactored version a few hours after submission).

// aoc/2024/12/2
func main() {
  var price int
  garden := stdinbyteplane()
  step := func(Pt) iter.Seq[Pt] { return CardinalSteps() }
  for pt0 := range garden.Components(step) {
    area := iterLen(garden.FloodFill(step, pt0))
    perim := garden.CountEdges(step, pt0)
    price += area * perim
  }

  fmt.Println(price)
}

// Edge detection via four separate region expansions.
//
// pt0 is the representative of a component.
func (p *Plane[T]) CountEdges(step func(Pt) iter.Seq[Pt], pt0 Pt) int {
  var sides [4]Plane[bool]
  for pt := range p.FloodFill(step, pt0) {
    side := -1
    for enws := range CardinalSteps() {
      side++
      if p.Peek(pt.Add(enws)) != p.Peek(pt) {
        sides[side].Poke(pt.Add(enws), true)
      }
    }
  }

  var n int
  for _, side := range sides {
    n += iterLen(side.Components(step))
  }
  return n
}

Aoc/​2024/​13

That Friday was my birthday. I planned to wake up early at 5:50am to get going quickly but after falling back asleep for ten minutes, I was in such a daze that I couldn’t invert the 2x2 in­te­ger matrix this day’s prob­lem ways about. It took me a whopping 52:32 minutes to submit aoc/​2024/​13/1 and, as I didn’t brute-force it, 53:13 minutes to submit aoc/​2024/​13/2.
We suffered a power outageˣwhich brought down my cellar-dwelling Raspberry Pi 2’s by-then-glorious up­timeˣaand I felt too frail to attend a talk I badly wanted to wit­ness.
Birthday dinner with my family later that day was a vile ex­pe­ri­ence, too. Bad puz­zle, bad day.

Aoc/​2024/​14

Aoc/​2024/​14/2 was utter gar­bage: Looking at white noise until one finds some picture isn’t solving puzzles. And the resulting christmas tree for day fourteen is framed and tucked ar­bi­trari­ly some­where on the bathroom floor. By far the worst Advent of Code ques­tion I have seen in my journey (which cur­rent­ly is 107 stars long).

Aoc/​2024/​15

I love Sokoban. Just not playing it (cf. #258). So on day fifteen I wrote a generic block-pushing-puz­zle li­brary for aoc/​2024/​15/1. When the boxes grew in aoc/​2024/​15/2, I wrote a companion li­brary for ar­bi­trari­ly-shaped boxes.

// aoc/2024/15/1
import "jfrech.com/goo/puzzle/boxpush"

func main() {
  warehouse := new(boxpush.World)
  var robot Pt
  var instrs []boxpush.Dir
  // parse ...

  for _, instr := range instrs {
    multipush := true
    warehouse.Push(robot, instr, multipush)
    if warehouse.Peek(robot.Add(instr.Vec())) == boxpush.Air {
      robot = robot.Add(instr.Vec())
    }
  }

  var tot int
  bounds := warehouse.Bounds()
  for pt, _ := range warehouse.All() {
    if warehouse.Peek(pt) == boxpush.Box {
      tot += 100 * (pt.Y - bounds.Min.Y) + (pt.X - bounds.Min.X)
    }
  }

  fmt.Println(tot)
}
// aoc/2024/15/2
import "jfrech.com/goo/puzzle/boxpush/glued"

func main() {
  warehouse := new(boxpush.World)
  // ...
  for {
    // ...
    trafo := func(pt Pt) Pt { return Pt{pt.X*2, pt.Y} }
    switch c {
    case '@':
      robot = trafo(pt)
      warehouse.PokeAir(trafo(pt))
      warehouse.PokeAir(trafo(pt).Add(Pt{1, 0}))
    case '.':
      warehouse.PokeAir(trafo(pt))
      warehouse.PokeAir(trafo(pt).Add(Pt{1, 0}))
    case 'O':
      warehouse.PokeAir(trafo(pt))
      warehouse.PokeAir(trafo(pt).Add(Pt{1, 0}))
      warehouse.PokeBox(boxpush.Box{trafo(pt), trafo(pt).Add(Pt{1, 0})})
    }
  }

  // ...
}

Aoc/​2024/​16

Day sixteen had be stumped for a bit. I had seen Russ Cox solve aoc/​2021/​23’s amphipod shuffling using a pri­or­i­ty queue based on container/heap and parametric polymorphism. But when I had set up my pri­or­i­ty queue, my solver choked on the combinatorial explosion which comes about due to the many equi-costing labyrinth cycle turn options. Figuring out in aoc/​2024/​16/1 to prune the cost-guided search using a global already-visited-map was insightful. Amending said global his­to­ry with a record of alternate subsolutions to then reconstruct the union of all op­ti­mal paths in aoc/​2024/​16/2 was eye-opening.
The solver I had christened that day would later be modified to solve aoc/​2024/​18 and from there grow into a generic li­brary.

Aoc/​2024/​17

Day sev­en­teen. What a doozy! Aoc/​2024/​17/1 is as lame as unsalted broccoli. But aoc/​2024/​17/2, when read wrong, touches on undecidability. After all, defining some imperative DSL, writ­ing a pro­gram in it and asking for the inverted pro­gram’s output can’t be solved in all generality.
So I tried brute-forcing in Go. That went nowhere, even after hastily parallelizing it to four cores.
I then bypassed my Go-writ­ten aoc/​2024/​17-DSL-interpreter by hand-transpiling the DSL-described pro­gram into C, but it never output any­thing:

/* aoc/2024/17/2 */
#include <stdbool.h>
#include <stdio.h>

bool prog(long long a) {
  int force[] = {2, 4, 1, 1, 7, 5, 4, 6, 0, 3, 1, 4, 5, 5, 3, 0, -1};

  a = a > 0 ? a : 1;
  long long b = 0, c = 0;
  int printed = 0;
  while (a) {
    b = a % 8;
    b = b ^ 1;
    c = a >> b;
    b = b ^ c;
    a >>= 3;
    b = b ^ 4;
    int out = b % 8;

    if (force[printed] != out)
      return false;
    printed++;
  }

  return printed == (sizeof force / sizeof (int) - 1);
}

int main() {
  for (long long a = 1;; a++)
    prog(a) && (printf("%lld\n", a), fflush(stdout));
}

I had no angle of ap­proach. From looking at the DSL-writ­ten pro­gram in ques­tion, I knew the flow diagram was a single loop with­out prelude or epilogue:

; aoc/2024/17/2
2 4 ; bst: b <- a MOD 8
1 1 ; bxl: b <- b XOR 1
7 5 ; cdv: c <- a RSHIFT b
4 6 ; bxc: b <- b XOR c
0 3 ; adv: a <- a RSHIFT 3
1 4 ; bxl: b <- b XOR 4
5 5 ; out: PRINT (b MOD 8)
3 0 ; jzz: IF (a == 0) THEN HALT ELSE LOOP

I tried known com­pil­er techniques: re­write into SSA form (I knew I wanted to hit exactly 16 loops which I could then un­roll) and force values back­ward:

; aoc/2024/17/2
a := UNKNOWN
b := 0
c := 0

a1_0 := a
b1_0 := b
c1_0 := c

b1_1 := a1_0 MOD 8
b1_2 := b1_1 XOR 1
c1_1 := a1_0 RSHIFT b1_2
b1_3 := b1_2 XOR c1_1
a1_1 := a1_0 RSHIFT 3
b1_4 := b1_3 XOR 4
d1 := b1_4 MOD 8
force d1 to 2
force a1_1 to positive

; fourteen more times

; ...
force d16 to 0
force a16_1 to 0

But how do you force the bitwise exlusive disjunction of two non-constants? How do you force a rightwards bit shift of two non-constants? Both would imply branching in the anal­y­sis which may then again accumulate enough search space that one would be better off brute-forcing.
Think of bit vectors! I thought and calculated an RSHIFT en­cod­ing on the level of individual bits:

    zabcd >> efg
=   zabcd >> e00
  | zabcd >> 0f0
  | zabcd >> 00g
=   0000z & 0000e
  | 00zab & 00fff
  | 0zabc & 0gggg

Thus I thought of representing each in­te­ger as 64 boolean variables, using the above SSA form to construct a non-linear boolean system of equations in thousands of variables, setting all of a’s bits to zero and, starting at the least-sig­nif­i­cant end, removing these equations until a solution was found (as I needed a solution which minimizes a’s numeric value). I hoped for the somewhat linear nature of SSA as a source to the system of equations to aid in solving it (as SAT, famously, is NP-com­plete and if I had a nondeterministic Turing ma­chine, I wouldn’t be sit­ting in front of a forever-toiling C fragment).
By hand I had already proved a’s least sig­nif­i­cant bits couldn’t as a pack be 001.
But then again: a lot of bookkeeping effort with a high likelihood of producing a hard-to-solve non-linear system of boolean equations. I didn’t fol­low through on this ap­proach.

What I did is stare at the pro­gram in ques­tion. I needed to find an a. What ef­fect does changing it have on the pro­gram’s be­hav­iour?
Asking the right ques­tion is oh so often in itself the an­swer!
Line three, c1_1 := a1_0 RSHFIT b1_2, is hard to reason about. But b1_1 := a1_0 MOD 8 is known. So the shift can be at most of size seven. The on­ly oth­er place where a is mentioned is in a1_1 := a1_0 RSHIFT 3. And the pro­gram on­ly outputs d1 := b1_4 MOD 8. Thus, in each iteration, on­ly seven consecutive bits of a contribute to the output and in each iteration, said contributing bit pack shifts by three leftwards.
Trying out 1024 values for a to produce the first wanted value, locking in all possibilities for the first pack of three bits and repeating fifteen times takes on­ly a tenth of a second on my Think­Pad.

// aoc/2024/17/2
func main() {
  var prog *Prog
  // parse ...

  var force []int = prog.Text()
  b0, c0 := prog.B, prog.C

  run := func(a int) iter.Seq[int] {
    prog.A, prog.B, prog.C = a, b0, c0
    prog.Pc = 0
    return prog.Run()
  }

  as := make(map[int]struct{})
  for j := range (1 << 7) {
    as[j] = struct{}{}
  }

  for k := range len(force) {
    as2 := make(map[int]struct{})
    for left := range 8 {
      for right := range as {
        a := left << (7+3*k) | right
        if out, ok := Get(run(a), k); ok && out == prog[k] {
          as2[a] = struct{}{}
        }
      }
    }
    as = as2
  }

  a := slices.Min(slices.Collect(maps.Keys(as)))
  if !slices.Equal(slices.Collect(run(a)), force) {
    panic("unreachable")
  }
  fmt.Println(a)
}

After submission, I stopped my freshly then-yesterday unboxed Think­Pad T470s (boasting an i7!) whose tight C-writ­ten brute-forcing loop had toiled all day, on­ly covering ~0.5% of the search space (assuming 1<<45 <= a < 1<<48, for which I had a slight hunch due to the a <- a RSHIFT 3 line).

Aoc/​2024/​18

Today, the eighteenth of De­cem­ber 2024, was strikingly similar to aoc/​2024/​16. And aoc/​2024/​18/2’s maze un-solving was brute-forceable, not even requiring a primitive op­ti­mi­za­tion such as re-com­put­ing a solution on­ly when the known one has been blocked.
Watching op­ti­mal­ity’s jittery quiddity is a treat each time around (cf. #230, #161).

Aoc/​2024/​18/2