Advent of Code 2023 day 02

    Day 2 was a day where a declarative approach to the problem paid off. Just writing down some definitions led to a neat solution.

    Part 1

    I started by writing down some things I know. A Game has an ID and a set of Revelations. A Revelation is a group of red, green, and blue reveals.

    data Game = Game {getID :: Int, getRevelations :: [Revelation]} 
      deriving (Eq, Show)
    data Revelation = Revelation Int Int Int deriving (Eq, Show)

    A Revelation is compatibleWith a limit if all of the red, green, blue shown are less than the corresponding quantities in the limit. (This is the same as defining a partial order on revelations, but I don't need all of that here.) A game is possible (given a limit) if all its revelations are compatibleWith that limit.

    compatibleWith :: Revelation -> Revelation -> Bool
    compatibleWith (Revelation r0 g0 b0) (Revelation r1 g1 b1) = 
      (r0 <= r1) && (g0 <= g1) && (b0 <= b1)
    
    possible :: Game -> Revelation -> Bool
    possible game limit = 
      all (`compatibleWith` limit) $ getRevelations game

    I can solve Part 1 by filtering the games that are possible, extracting the IDs, and adding them.

    part1 :: [Game] -> Int
    part1 games = sum $ fmap getID $ filter (`possible` limit) games
      where limit = Revelation 12 13 14

    Part 2

    This is all about combining revelations to find the minimum bounds on a game. Combining objects is what monoids are all about.

    The bounds implied by two revelations are the maximums of each element. The bounds of no revelations is zero of each element.

    instance Semigroup Revelation where
      (Revelation r0 g0 b0) <> (Revelation r1 g1 b1) = 
        Revelation (max r0 r1) (max g0 g1) (max b0 b1)
    
    instance Monoid Revelation where
      mempty = Revelation 0 0 0

    That gives me mconcat for free, and the bounds of a game is the combination of all the revelations.

    required :: Game -> Revelation
    required = mconcat . getRevelations

    To solve part 2, I find the power of the requirements of each game, then add them up.

    power :: Revelation -> Int
    power (Revelation r g b) = r * g * b
    
    part2 :: [Game] -> Int
    part2 = sum . fmap (power . required)

    Parsing

    All the above needs Games and Revelations, which isn't what's in the input file. I decided to take a two-stage approach to handling the input.

    1. Parse the input into a structure that follows that of the input.
    2. Convert that structure into the one of Game and Revelation.

    I could do it all in one, but I think this approach is easier to test and debug, keeps the code easier to debug, and allows for dual-use of the input if part 2 ended up doing something very different with the same input (as 2022 day 2 did).

    Reading the input

    I need new data structures to hold the results of parsing, so I define a ParsedGame, a Showing, a Cube, and the Colours.

    data ParsedGame = ParsedGame Int [Showings] deriving (Eq, Show)
    type Showings = [Cube]
    data Cube = Cube Colour Int deriving (Eq, Show)
    data Colour = Red | Green | Blue deriving (Eq, Show)

    Parsing pretty much follows the pattern of the input file, using attoparsec. The only interesting part is the use of flip in cubeP to change the order of arguments.

    gamesP = gameP `sepBy` endOfLine
    gameP = ParsedGame <$> (("Game " *> decimal) <* ": ") <*> showingsP
    
    showingsP = showingP `sepBy` "; "
    showingP = cubeP `sepBy` ", "
    cubeP = (flip Cube) <$> (decimal  <* " ") <*> colourP 
    
    colourP = redP <|> greenP <|> blueP
    redP = Red <$ "red"
    greenP = Green <$ "green"
    blueP = Blue <$ "blue"

    Converting the parsed data

    My overall approach here is to convert each Cube into a Revelation of only one colour, then merge those Revelations into the actual Revelation of this round of the Game.

    Merging revelations again implies a monoid, but now I want to use a different combining operation. The idiomatic way to do that is to wrap the Revelation in a newtype and define my merging monoid on that newtype.

    newtype Merging = Merging { getMerging :: Revelation } deriving (Eq, Show)
    
    instance Semigroup Merging where
      (Merging (Revelation r0 g0 b0)) <> (Merging (Revelation r1 g1 b1)) = 
        Merging (Revelation (r0 + r1) (g0 + g1) (b0 + b1))
    
    instance Monoid Merging where
      mempty = Merging (Revelation 0 0 0)

    Revealing one Cube produces a Merging (Revelation ...), which I can merge with mconcat and extract the Revelation with getMerging.

    engame :: ParsedGame -> Game
    engame (ParsedGame n showings) = Game n (fmap revealify showings)
    
    revealify :: Showings -> Revelation
    revealify = getMerging . mconcat . (fmap reveal)
    
    reveal :: Cube -> Merging
    reveal (Cube Red   n) = Merging (Revelation n 0 0)
    reveal (Cube Green n) = Merging (Revelation 0 n 0)
    reveal (Cube Blue  n) = Merging (Revelation 0 0 n)

    Code

    You can get the code from my locally-hosted Git repo, or from Gitlab.

    Neil Smith

    Read more posts by this author.