After the brain-burner of day 21, day 22 came as some blessed relief. It was similar to day 6, in that solving the problem was simple enough, but getting it to run in less than many hours was the part that took the effort.
Direct solutions
Part 1 was almost trivial: just translating the calculation into Haskell
part1 :: [Int] -> Int
part1 codes = sum $ fmap (followingSecret 2000) codes
nextSecret, step1, step2, step3, prune :: Int -> Int
nextSecret = step3 . step2 . step1
step1 n = prune $ (n * 64) `mix` n
step2 n = prune $ (n `div` 32) `mix` n
step3 n = prune $ (n * 2048) `mix` n
prune n = n `mod` 16777216
mix :: Int -> Int -> Int
mix s n = s `xor` n
followingSecret :: Int -> Int -> Int
followingSecret n s = (!! n) $ iterate nextSecret s
The direct solution to part 2 was also simple. I found the sale prices of each seller, all the possible "windows" I could instruct my buyer to find, then found the price of each window on each set of prices.
The core was converting the set of prices into a set of windows. Conveniently, the divvy
function from Data.List.Split
did most of it. I found the windows and, in pricesAndWindows
, paired each one with the sale price at that position.
salePrices :: Int -> [Int]
salePrices s = take 2001 $ fmap (`mod` 10) $ iterate nextSecret s
priceChanges :: [Int] -> [Int]
priceChanges ps = zipWith (-) (tail ps) ps
windows :: [Int] -> [[Int]]
windows = divvy 4 1
pricesAndWindows :: [Int] -> [(Int, [Int])]
pricesAndWindows ps = zip (drop 4 ps) $ windows cs
where cs = priceChanges ps
Finding all possible windows was done the obvious way.
possibleWindows :: [[Int]]
possibleWindows = [ [a, b, c, d]
| a <- [-9..9]
, b <- [-9..9]
, c <- [-9..9]
, d <- [-9..9]
]
Putting it all together:
part2 codes = maximum $ fmap valueOfWindow allWindows
where allPrices = fmap salePrices codes
allWindows = possibleWindows
valueOfWindow w = sum $ fmap (valueGivenWindow w) allPrices
valueGivenWindow :: [Int] -> [Int] -> Int
valueGivenWindow window ps
| null foundPrices = 0
| otherwise = fst $ head foundPrices
where vCs = pricesAndWindows ps
foundPrices = dropWhile (\(p, w) -> w /= window) vCs
This solution is present as MainBruteForce.hs
. It gives the correct answer for the test data, but takes a minute and a half to do it. It took ten hours to generate the answer for the full dataset. (but it got the right answer!) Even pruning the number of possible windows would only about half the time. A different approach was needed.
Optimising
The above approach tries every window on every position of each seller's price list. A better approach is to limit myself to only considering the windows that exist in those price lists.
For each seller, I create a Map
going from windows to prices. I walk along the prices, building up the Map
as I go. Then I merge all those maps, and find the highest total available price.
As a further optimisation, I "hash" the window to an integer value, by treating the window values as digits of a "balanced base-20 number" [^1] . That gives the type of the Prices
map as
[^1] I originally used a base 10 encoding, but that led to hash collisions.
type Prices = M.IntMap Int
windows :: [Int] -> [Int]
windows = fmap encode . divvy 4 1
encode :: [Int] -> Int
encode xs = foldl' (\a n -> a * 20 + n) 0 xs
The price of a particular window (for a particular seller) is the first price found. I find them all with a fold
over the list of windows, using flip const
to keep the first price found.
windowsAndPrices :: [Int] -> Prices
windowsAndPrices ps = foldl' (\m (w, p) -> M.insertWith (flip const) w p m) M.empty wPs
where cs = priceChanges ps
wPs = zip (windows cs) (drop 4 ps)
Once I have the prices for each seller's windows, I merge them and find the highest value.
part2 codes = maximum $ M.elems mergedPriceValues
where allPrices = fmap salePrices codes
allPriceValues = fmap windowsAndPrices allPrices
mergedPriceValues = M.unionsWith (+) allPriceValues
Code
You can get the code from my locally-hosted Git repo, or from Codeberg.