AoC 2023 Day 2: Playinator

Source: Day 2: Cube Conundrum

Full solution for today (spoilers!). Note: I did slightly change my solutions template after writing this blog post, so the final solution is structured slightly differently than the code in this post. The functionality itself hasn’t changed.

Part 1

Play a game where you have some number of red, green, and blue dice in a cup, which you draw and roll (without replacement). Which game is possible with only 12 red, 13 gree, and 14 blue cubes?

Input will look like: Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green

Okay, start with the data. What do we expect the (parsed) simulation would look like?

// A game consists of an ID and some number of rounds each with some number of dice
#[derive(Debug, PartialEq)]
pub struct Game {
    id: u32,
    rounds: Vec<Round>,
}

// A single round can have some number each of red/green/blue dice
#[derive(Debug, PartialEq)]
pub struct Round {
    red: u32,
    green: u32,
    blue: u32,
}

// Represents colors of dice
#[derive(Debug, PartialEq)]
pub enum Colors {
    Red,
    Green,
    Blue,
}

Next step to get there, I’ve heard good things about this nom crate. Let’s try parsing with it.

In order to not pollute my main namespace with imports from nom, I’m going to make an internal parse module:

mod parse {
    use crate::*;
    use nom::{
        branch::*, bytes::complete::*, character::complete, character::complete::newline,
        combinator::*, multi::*, sequence::*, *,
    };

    pub fn games(s: &str) -> IResult<&str, Vec<Game>> {
        separated_list1(newline, game)(s)
    }

    fn game(s: &str) -> IResult<&str, Game> {
        let (s, _) = tag("Game ")(s)?;
        let (s, id) = complete::u32(s)?;
        let (s, _) = tag(": ")(s)?;

        let (s, rounds) = separated_list1(tag("; "), round)(s)?;

        Ok((s, Game { id, rounds }))
    }

    fn round(s: &str) -> IResult<&str, Round> {
        let (s, counts) = separated_list1(
            tag(", "),
            sequence::tuple((complete::u32, preceded(tag(" "), color))),
        )(s)?;

        let mut red = 0;
        let mut green = 0;
        let mut blue = 0;

        for (count, color) in counts {
            match color {
                Colors::Red => red = count,
                Colors::Green => green = count,
                Colors::Blue => blue = count,
            }
        }

        Ok((s, Round { red, green, blue }))
    }

    fn color(s: &str) -> IResult<&str, Colors> {
        alt((
            map(tag("red"), |_| Colors::Red),
            map(tag("green"), |_| Colors::Green),
            map(tag("blue"), |_| Colors::Blue),
        ))(s)
    }

    #[cfg(test)]
    mod tests {
        // ...
    }
}

You know, I can of like that! It’s a bit more verbose than a pile of split and parse calls, but it’s fairly self contained and directly returns the data structure. Plus, with this we can just call parse::games to get the Vec<Game>, find any that match the conditions (there should be exactly one) and return it:

fn part1(filename: &Path) -> Result<String> {
    let input = read_to_string(filename)?;
    let (_, games) = parse::games(&input).unwrap();

    // Return sum of ID of game that contained no more than
    // 12 red cubes, 13 green cubes, and 14 blue cubes
    Ok(games
        .into_iter()
        .filter(|game| {
            game.rounds
                .iter()
                .all(|round| round.red <= 12 && round.green <= 13 && round.blue <= 14)
        })
        .map(|game| game.id)
        .sum::<u32>()
        .to_string())
}

I did a sum, but a .next().unwrap() would have worked much the same.

Part 2

Find the fewest number of cubes possible for each game. For each game, multiply those three numbers together and then sum these products.

We already have everything we need here, if we add a power method on Game:

// The power of a game is the product of the minumum number of cubes of each color
impl Game {
    fn power(&self) -> u32 {
        self.rounds
            .iter()
            .fold([0, 0, 0], |acc, round| {
                [
                    acc[0].max(round.red),
                    acc[1].max(round.green),
                    acc[2].max(round.blue),
                ]
            })
            .into_iter()
            .product()
    }
}

We’re going to fold across the games, keeping track of each maximum (red, green, and blue) independently, then product at the end. Sum those and we’re golden:

fn part2(filename: &Path) -> Result<String> {
    let input = read_to_string(filename)?;
    let (_, games) = parse::games(&input).unwrap();

    // Calculate the sum of powers of the rounds
    Ok(games
        .into_iter()
        .map(|game| game.power())
        .sum::<u32>()
        .to_string())
}

Performance

Still microseconds:

$ cargo run --release --bin 02-playinator 1 data/02.txt

    Finished release [optimized] target(s) in 0.00s
     Running `target/release/02-playinator 1 data/02.txt`
2061
took 217.709µs

$ cargo run --release --bin 02-playinator 2 data/02.txt

    Finished release [optimized] target(s) in 0.00s
     Running `target/release/02-playinator 2 data/02.txt`
72596
took 58.167µs