/wakamoleguy

Advent of Code 2022 - Day 1: Calorie Counting

It's that time of year again! We have 25 days to collect 50 stars and save Christmas. That's right: it's Advent of Code. This year I am solving the puzzles using Haskell, and I plan to write about each day's solution here.

Day 1: Calorie Counting is a bit of a warm-up, as usual for the first day's challenge. This year, we have input like this:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000

Each grouping represents the snacks carried by a single elf, and each line is the calorie count for an individual snack. In Part 1, we need to sum up each group and find the greatest. In Part 2, we need to find the three (3) greatest.

First Solution

Here's my first attempt at solving the problem.

import Data.List
import Data.List.Split
import Input
import Test.Hspec

input :: IO String
input = readDay 2022 1

spec :: IO ()
spec = hspec $ do
  describe "Day 01" $ do
    describe "Part 01" $ do
      it "runs on custom input" $ do
        myInput <- input
        part1 myInput `shouldBe` 0 -- redacted
    describe "Part 02" $ do
      it "runs on custom input" $ do
        myInput <- input
        part2 myInput `shouldBe` 0 -- redacted

part1 :: String -> Int
part1 = maximum . calorieCounter

part2 :: String -> Int
part2 = sum . take 3 . reverse . sort . calorieCounter

calorieCounter :: String -> [Int]
calorieCounter = fmap (sum . fmap read . lines) . splitOn "\n\n"

I use Hspec to run my code as a test suite. Once I find the correct answer, I add it as an expectation. This allows me to refactor my code more easily, either in solving Part 2 or in cleanup afterwards. Input.readDay is a simple helper method that reads my input file from a local directory.

Polishing

There were a few things I wanted to improve with this solution:

  1. Part 1 and Part 2 can both be viewed as "Take the N greatest elves and sum them". But the code for Part 1 and Part 2 were pretty different. I wanted to move the sorting into the calorieCounter, so that it could be used in both parts.
  2. reverse forces the full list to be evaluated, so reverse . sort sorts the whole list even when we only need a few top items. The Data.Ord module provides a Down type which can sort in descending order. Combined with sortOn, this allows us to lazily sort only as much of the list as we need to take.
  3. Is there any way to simplify the fmap? splitOn can operate on any list, not just Strings (aka, [Char]). We should be able to use lines first, and then split the result into groups, rather than split into groups first, and then fmap lines.

Instead of splitOn "\n\n", we would use splitOn [""]. Since each item in the list is a line, our delimiter is now one item long. We can replace splitOn [""] with splitWhen null.

The final code looks like this:

import Data.List
import Data.List.Split

part1 :: String -> Int
part1 = head . calorieCounter

part2 :: String -> Int
part2 = sum . take 3 . calorieCounter

calorieCounter :: String -> [Int]
calorieCounter = sortOn Down . fmap (sum . fmap read) . splitWhen null . lines

Polish I didn't do:

I'm still using head in part1 instead of sum . take 1. It's not quite equivalent! While head is more concise, it could bottom on an empty input.

sum . take 1 will always return an Int, defaulting to 0 for an empty list. With this option, we could add the number of items to take as an argument to calorieCounter: part1 = calorieCounter 1 and part2 = calorieCounter 3

A third option is listToMaybe, which would return Just 69836 for my input and None for an empty input.

Advent of Code 2022 Series

This post is part of a series describing my Haskell solutions to Advent of Code 2022.

Next: Day 2: Rock Paper Scissors

Cheers!