Day 24 was far too fiddly, and used far too much linear algebra, for my taste. To my shame, I resorted to just looking up standard algorithms and other people's solutions to solve this one.
I used the Linear
library a lot here.
Representation and parsing
I did my usual thing of creating a new data type for the Hailstone
and a parser for it. The only complication here was the symbolP
parser that consumed the varying amounts of whitespace.
type Position2 = V2 Rational
type Position3 = V3 Rational
type Segment = (Position2, Position2)
data Hailstone = Hailstone { _pos :: V3 Rational, _vel :: V3 Rational }
deriving (Show, Eq, Ord)
makeLenses ''Hailstone
stonesP = stoneP `sepBy` endOfLine
stoneP = Hailstone <$> (vertexP <* symbolP "@") <*> vertexP
vertexP = vecify <$> signed decimal <*> (symbolP "," *> signed decimal) <*> (symbolP "," *> signed decimal)
where vecify x y z = V3 (x % 1) (y % 1) (z % 1)
symbolP :: Text -> Parser Text
symbolP s = (skipSpace *> string s) <* skipSpace
Part 1
This approach used the test for intersecting line segments, given the two end points of the two line segments.
For each hailstone, I found the times when that stone would cross each of the four (infinitely-extended) boundary lines of the test area. For each of those times, and time 0, I filtered out the times that represented points outside the test area. The segment lies between the positions at the minimum and maximum such times. If the hailstone never entered the test area in positive time, there was no segment.
boundaryPoints :: (Position2, Position2) -> Hailstone -> Maybe Segment
boundaryPoints b@((V2 minX minY), (V2 maxX maxY)) h
| null ts = Nothing
| tMax < 0 = Nothing
| otherwise = Just (V2 xMin yMin, V2 xMax yMax)
where V2 x y = h ^. pos . _xy
V2 vx vy = h ^. vel . _xy
tMinX = (minX - x) / vx
tMaxX = (maxX - x) / vx
tMinY = (minY - y) / vy
tMaxY = (maxY - y) / vy
ts = filter withinT $ filter (>= 0) [tMinX, tMinY, tMaxX, tMaxY, 0]
tMin = minimum ts
tMax = maximum ts
xMin = x + tMin * vx
xMax = x + tMax * vx
yMin = y + tMin * vy
yMax = y + tMax * vy
withinT t = within b (V2 (x + t * vx) (y + t * vy))
within :: (Position2, Position2) -> Position2 -> Bool
within ((V2 minX minY), (V2 maxX maxY)) (V2 x y) =
x >= minX && x <= maxX && y >= minY && y <= maxY
Given a pair of line segments, I can detect if they intersect.
intersects :: Segment -> Segment -> Bool
intersects ((V2 x1 y1), (V2 x2 y2)) ((V2 x3 y3), (V2 x4 y4)) = ad && pd
where a = (x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)
p = (x1 - x3) * (y1 - y2) - (y1 - y3) * (x1 - x2)
d = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
ad = if d < 0 then 0 >= a && a >= d
else 0 <= a && a <= d
pd = if d < 0 then 0 >= p && p >= d
else 0 <= p && p <= d
The whole solution finds the segments of all hailstones (using catMaybes
to drop the out-of-bounds ones), finds all possible pairs of segments, and counts how many intersect.
part1, part2 :: [Hailstone] -> Int
part1 stones = length $ filter (uncurry intersects) (pairs bps)
where boundary = (V2 2e14 2e14 , V2 4e14 4e14) -- ((V2 7 7), (V2 27 27))
bps = catMaybes $ fmap (boundaryPoints boundary) stones
pairs :: Ord a => [a] -> [(a,a)]
pairs xs = [(x,y) | x <- xs, y <- xs, x < y]
Originally, all this used floating-point Double
numbers, but I kept getting rounding errors where the boundary detection was off by one part in 1014. I then realised that I was only doing basic arithmetic with these numbers, so I quickly converted to using the arbitrary-precision Rational
number type. It may be fractionally slower, but it's more accurate.
Part 2
Here, I most definitely skipped any solution and just looked up how others' did it. The best explanation I found was in this comment by ReimannIntegirl, referencing this code by Fleur Kelpin.
Hailstone i is described by the pair \( \langle \mathbf{p}_i, \mathbf{v}_i \rangle \) = \( \langle [ x_i, y_i, z_i ], [ \dot x_i, \dot y_i, \dot z_i] \rangle \). The position of hailstone i at time t is \( [ x_i + \dot x_i t, y_i + \dot y_i t, z_i + \dot z_i t ] \).
The hailstones are numbered 1 to 300. The rock we throw is hailstone 0.
Hailstone i and hailstone 0 intersect at some time t when
\begin{eqnarray}
x_i + \dot x_i t & = & x_0 + \dot x_0 t \\
y_i + \dot y_i t & = & y_0 + \dot y_0 t \\
z_i + \dot z_i t & = & z_0 + \dot z_0 t
\end{eqnarray}
Rearranging a bit, you get
\begin{eqnarray}
(x_i - x_0) + (\dot x_i - \dot x_0) t & = & 0 \\
(y_i - y_0) + (\dot y_i - \dot y_0) t & = & 0 \\
(z_i - z_0) + (\dot z_i - \dot z_0) t & = & 0
\end{eqnarray}
which leads to
\[ \frac{x_i - x_0}{\dot x_i - \dot x_0} = \frac{y_i - y_0}{\dot y_i - \dot y_0} = \frac{z_i - z_0}{\dot z_i - \dot z_0} \]
If you take only the x and y dimensions and cross-multiply, you get
\[ (x_i - x_0)(\dot y_i - \dot y_0) = (y_i - y_0)(\dot x_i - \dot x_0) \]
Expanding gives
\[ x_i \dot y_i - x_i \dot y_0 - x_0 \dot y_i + x_0 \dot y_0 = y_i \dot x_i - y_i \dot x_0 - y_0 \dot x_i + y_0 \dot x_0 \]
If you take two such hailstones i and j, and subtract the equations and rearrange, you get
\[ (\dot y_j - \dot y_i) x_0 - (\dot x_j - \dot x_i) y_0 + (y_j - y_i) \dot x_0 + (x_j - x_i) \dot y_0 = y_i \dot x_i - y_j \dot x_j +x_j \dot y_i - x_i \dot y_i \]
If you now take three other pairs hailstones, you will get three other equations. You can treat each equation as a row in the vector equation \(\mathbf{A} \mathbf{x} = \mathbf{b} \) and solve for x. That will give \(x_o, y_o, \dot x_0, \dot y_0 \). If you do the same again, but looking at \(z, \dot z \) rather than (y, \dot y \), you get solutions for all the components of the rock.
That's the theory, now back to the code. The Linear
library has a module for Linear.Matrix
as well as vectors. It represents a matrix as a vector of vectors. It also has a simple linear equation solver.
coeffs
takes a pair of hailstones and a lens (either _y
or _z
) and returns the row of the A matrix and the element of the b vector. buildMatrix
builds the matrix equation.
buildMatrix :: [Hailstone] -> Lens' Position3 Rational -> (M44 Rational, V4 Rational)
buildMatrix hs l = (V4 a1 a2 a3 a4, V4 b1 b2 b3 b4)
where ps = take 4 $ chunks2 hs
[(a1, b1), (a2, b2), (a3, b3), (a4, b4)] = [coeffs p l | p <- ps]
coeffs :: (Hailstone, Hailstone) -> Lens' Position3 Rational -> (V4 Rational, Rational)
coeffs (h1, h2) l =
(V4 (h2 ^. vel . l - h1 ^. vel . l)
(h1 ^. vel . _x - h2 ^. vel . _x)
(h2 ^. pos . l - h1 ^. pos . l)
(h2 ^. pos . _x - h1 ^. pos . _x)
, h1 ^. pos . l * h1 ^. vel . _x -
h2 ^. pos . l * h2 ^. vel . _x +
h2 ^. pos . _x * h2 ^. vel . l -
h1 ^. pos . _x * h1 ^. vel . l
)
chunks2 :: [a] -> [(a, a)]
chunks2 [] = []
chunks2 [_] = []
chunks2 (x:y:xs) = (x,y) : chunks2 xs
The solution takes these two systems of equations, solves them for the position and velocity of the thrown rock, and builds the solution. There's some fiddling around to convert back from the Rational
numbers to a humble Int
.
part2 stones = fromIntegral $ numerator solution `div` denominator solution
where rock = Hailstone (pure 0) (pure 0) -- Hailstone (V3 0 0 0) (V3 0 0 0)
(ay, by) = buildMatrix stones _y
(az, bz) = buildMatrix stones _z
solY = luSolveFinite ay by
solZ = luSolveFinite az bz
rock' = rock & pos . _x .~ (solY ^. _x)
& pos . _y .~ (solY ^. _y)
& pos . _z .~ (solZ ^. _y)
& vel . _x .~ (solY ^. _z)
& vel . _y .~ (solY ^. _w)
& vel . _z .~ (solZ ^. _w)
solution = (rock' ^. pos . _x) + (rock' ^. pos . _y) + (rock' ^. pos . _z)
And that's it. I'm glad that one's over!
Code
You can get the code from my locally-hosted Git repo, or from Gitlab.