Day 2 wasn't as bad as I first thought. My first attempt at a brute-force solution to part 2 failed, but a bit more thinking gave a more elegant solution.

In this puzzle, I made use of the built-in Data.Ix module, that defines ranges and some functions for using them. I also spent a lot of time converting between numbers and their string representations.

A quick look at the input data showed that all the values would fit nicely in a 64-bit Int value, so I could define a Range as a pair of Ints. Reading the data was direct splitting of the input.

type Range = (Int, Int)

readRanges :: String -> [Range]
readRanges text = readRange <$> splitOn "," text

readRange :: String -> Range
readRange chunk = (read l, read u)
  where [l, u] = splitOn "-" chunk

Part 1

From the problem description, it was pretty clear that the challenge was going to be making the solution fast. However, I decided to try brute force initially, on the basis that there wasn't a lot of point building a complex solution based on what I guessed the part 2 puzzle would be.

The brute force solution is to use the range function to generate all the members of a range, then filter them to find the invalid numbers.

Testing for an invalid number is a direct translation of the problem specification: take the string representation of the number, split it in half, and check the two halves are the same.

part1 :: [Range] -> Int
part1 ranges = sum [n | r <- ranges
                      , let invalids = invalidsInRange r
                      , n <- invalids
                   ]
  
invalidsInRange :: Range -> [Int]
invalidsInRange r = filter isInvalid $ range r

isInvalid :: Int -> Bool
isInvalid n = prefix == suffix
  where sn = show n
        (prefix, suffix) = splitAt ((length sn) `div` 2) sn

That was good enough for part 1, but even with a modified version of isInvalid, it was too slow for part 2.

Part 2

For part 2, the problem moves from finding numbers where "the first half of the ID is the same as the second half" to numbers where "the ID is some repeat of a prefix of the number".

That means an extension of the previous idea. Rather than always splitting a number at the midpoint, I split the number into a prefix and a suffix, and check that the repeated prefix equals the suffix.

Splitting a list into all pairs of prefix and suffix is an idiom using the built-in inits and tails functions. I want to take only those pairs where the prefix is non-empty, and first few so the prefix is no longer than the suffix.

silliesInRange :: Range -> [Int]
silliesInRange r = filter isSilly $ range r

isSilly :: Int -> Bool
isSilly n = any isRepeat pairs
  where sn = show n
        pairs = take (length sn `div` 2) $ tail $ zip (inits sn) (tails sn)

All that's left is to check if a prefix, repeated, is the same as the suffix. For that, the length of the suffix has to be a multiple of the length of the prefix, and the repeated prefix has to be the same as the suffix.

isRepeat :: (String, String) -> Bool
isRepeat (p, s) = 
  ((length s) `mod` (length p) == 0) && 
  (all (uncurry (==)) $ zip (cycle p) s)

If you look in the code, you can see the remains of my first attempt at part 2, which was much less functional and much more constructive. It was a bit quicker to run, but a lot longer to write.

Code

You can get the code from Codeberg.