Advent of Code 2024, Day 14

Posted on December 14, 2024 by Brandon Wilson
Tags: , ,

Day 14 is another linear algebra problem with some classification spice thrown on top:

n←¯1↓⊃⊢⍤⌿⌿(', '⍪LF)⎕VFI'¯'@{⍵='-'}(⊃⎕NGET'aoc.txt'0)∩⎕D⍪'-, '⍪LF←⎕UCS 10
p←msk⌿m⊣v←(~msk←1000⍴1 0)⌿m←1000 2⍴n
r←101 103|⍤1⊢p+100×v
×⌿(50 51>⍤1⊢r)⊢∘≢⌸r⊣r⌿⍨←∧/50 51≠⍤1⊢r

The input data is helpfully regular, i.e. all information about integers can be inferred from their index, so we take the standard approach of eliding everything but integers and separators, feeding the result to ⎕VFI:

      ⊢n←¯1↓⊃⊢⍤⌿⌿(', '⍪LF)⎕VFI'¯'@{⍵='-'}(⊃⎕NGET'aoc.txt'0)∩⎕D⍪'-, '⍪LF←⎕UCS 10
67 43 80 86 47 24 ¯9 ¯96 6 38 10 81 73 92 36 ¯93 74 10 ¯35 19 57 59 ¯5 71 ···

We know the integers must encode pairs of position and velocity vectors, which is straightforward to extract:

      p←msk⌿m⊣v←(~msk←1000⍴1 0)⌿m←1000 2⍴n
      5↑p,'|',v
67 43 |  80  86
47 24 |  ¯9 ¯96
 6 38 |  10  81
73 92 |  36 ¯93
74 10 | ¯35  19

Now we get to the meat. Conceptually, we just calculate an affine offset on a torus (i.e. modulo the circumfrences), which is nicely reflected directly in the code:

      5↑r←101 103|⍤1⊢p+100×v
88 94
56  3
97  1
37 62
 8 56

After filtering out the centerline positions, note that identifying quadrants corresponds to a simple bitmask that marks which side of each centerline a position lies.

      q←50 51>⍤1⊢r←r⌿⍨∧/50 51≠⍤1⊢r
      5↑q,'|',r
0 0 | 88 94
0 1 | 56  3
0 1 | 97  1
1 0 | 37 62
1 0 |  8 56

The solution is then just a matter of counting up each quadrant:

×⌿q⊢∘≢⌸r

Performance

One reasonable objection is the use of Key () since it performs a sort, whereas tallying groups membership is sufficient. In fact, a direct tally is completely straightforward if we first convert the quadrant selector pairs into integer ids:

      +/(∪g)∘.=g←2 2⊥⍉q

Notice that this performs ≢∪q reductions over q, giving it \(\mathcal{O}(\)(≢q)×≢∪q\()\) complexity. On the other hand, the Key solution performs a radix sort over the keys q, resulting in the exact same complexity!

That said, ∘.= over scalar arrays has all kinds of opportunities for SIMD optimizations, making it generally faser than Key, and indeed, comparing the two core expressions bears this out:

      )copy dfns cmpx
      g←2 2⊥⍉q
      cmpx 'q⊢∘≢⌸r' '+/(∪g)∘.=g'
  ×⌿q⊢∘≢⌸r     → 1.5E¯6 |   0% ⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕
  ×⌿+/(∪g)∘.=g → 7.9E¯7 | -48% ⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕

However, notice that we bias in favor of ∘.= above by ignoring the overhead of calculating g, which turns out to overwhelm any advantage, making Key come out faster in the overall problem solution here:

      cmpx 'q⊢∘≢⌸r' '+/(∪g)∘.=g←2 2⊥⍉q'
  ⊢∘≢⌸q             → 1.9E¯6 |    0% ⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕
  +/(∪g)∘.=g←2 2⊥⍉q → 4.2E¯6 | +117% ⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕

Acknowledgements

Thank you to Adám for pointing out the potential speedup with ∘.=. Originally, I had a naïve outer product which slowed it down considerably:

      cmpx '×⌿q⊢∘≢⌸r' '×⌿(∪g)∘.{+⌿⍺=⍵}⊂g'
  ×⌿q⊢∘≢⌸r          → 1.5E¯6 |   0% ⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕
  ×⌿(∪g)∘.{+⌿⍺=⍵}⊂g → 2.0E¯6 | +38% ⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕⎕

Even though ∘.{+⌿⍺=⍵} and +/⍺∘.=⍵ are performing the same work, apparently the former expression falls back to a generic implementation of a scalar loop, whereas the latter is a recognized pattern with special optimized code.