Day 2: Cube Conundrum

 17th December 2023 at 6:14pm

Part 1

I've been a bit busy - and also questioning the motivation for programming... - so I didn't go at this one right away. Yesterday (the 12th! Oh my.) I had a bit of spare time, and hacked at it. I tried to stick with functional principles, and also did some tests. Of course, I remembered how I should probably be a bit more intentional with my testing.

Tests

TEST_DATA = """Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
Game 3: 8 green, 6 blue, 20 red; 5 blue, 4 red, 13 green; 5 green, 1 red
Game 4: 1 green, 3 red, 6 blue; 3 green, 6 red; 3 green, 15 blue, 14 red
Game 5: 6 red, 1 blue, 3 green; 2 blue, 1 red, 2 green"""

class Test202302_01(unittest.TestCase):
    def test_transform_input_into_game_data(self):
        gen = get_game_data_from_input(TEST_DATA)
        self.assertEqual(next(gen),
                         "Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green")
        self.assertEqual(next(gen),
                         "Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue")

    def test_parse_round_into_ball_counts(self):
        rounds_gen = get_game_data_from_input(TEST_DATA)
        self.assertEqual(
                get_round_ball_count(next(get_round_strings(next(rounds_gen)))),
                     (('b', 3),
                      ('r', 4)),
                )

    def test_parse_game_input_into_tuple(self):
        gen = get_game_data_from_input(TEST_DATA)
        self.assertEqual(
                game_to_round_tuple(next(gen)),
                (
                    ('game', 1),
                     (('b', 3),
                      ('r', 4)),
                     (('r', 1),
                      ('g', 2),
                      ('b', 6)),
                     (('g', 2),)
                 )
                )

    # function must return the ID of the valid games
    def test_filter_valid_games(self):
        rounds_gen = get_game_data_from_input(TEST_DATA)
        max_rgb = (12, 13, 14)
        # for each game, take the maximum of the ball counts in a functional manner;
        # each game has a given number of rounds, and the maximum across all rounds must
        # be calculated for all balls

        self.assertEqual(
                is_valid_game(max_rgb, next(rounds_gen)),
                1,
                )
        self.assertEqual(
                is_valid_game(max_rgb, next(rounds_gen)),
                2,
                )
        self.assertEqual(
                is_valid_game(max_rgb, next(rounds_gen)),
                0,
                )
        self.assertEqual(
                is_valid_game(max_rgb, next(rounds_gen)),
                0,
                )
        self.assertEqual(
                is_valid_game(max_rgb, next(rounds_gen)),
                5,
                )

    def test_sum_of_possible_games(self):
        rounds_gen = get_game_data_from_input(TEST_DATA)
        max_rgb = (12, 13, 14)
        self.assertEqual(sum_of_id_of_valid_games(max_rgb, rounds_gen),
                         8)

Code

This was not a difficult exercise, albeit I probably over-engineered stuff. It feels like the workflow I strive for is writing a test, writing code that passes that test, refactoring, and then move along.

from typing import Generator
from re import findall
from itertools import chain

def get_game_data_from_input(data: str) -> Generator[str, None, None]:
    yield from data.splitlines()

def get_round_strings(game_round: str) -> Generator[str, None, None]:
    yield from game_round.split(';')

def get_round_ball_count(round_string: str) -> tuple:
    return (tuple((y, int(x))
            for x, y in findall(r'\s(\d+)\s(\w)[,]*',
                             round_string)))

def game_to_round_tuple(game_round: str) -> tuple:
    game = int(findall(r'Game (\d+)', game_round)[0])
    ball_counts = tuple(map(get_round_ball_count,
                            get_round_strings(game_round)))
    return (('game', game),) + ball_counts

def is_valid_game(max_balls_counts: tuple,
                  game_round: str
                  ) -> int:
    round_tuple = game_to_round_tuple(game_round)
    game_id = round_tuple[0][1]
    rounds = round_tuple[1:]
    flattened_rounds = list(chain.from_iterable(rounds))
    max_counts = tuple(max(t[1] for t in flattened_rounds
                           if t[0] == ball)
                  for ball in 'rgb')
    if all(max_count >= count
           for max_count, count 
           in zip(max_balls_counts, max_counts)):
        return game_id
    else:
        return 0

def sum_of_id_of_valid_games(max_balls_counts: tuple,
                       games: Generator
                       ) -> int:
    return sum(map(lambda game: is_valid_game(max_balls_counts,
                                              game),
                   games))

with open('input', 'r') as file:
    data = file.read().strip()
    print(sum_of_id_of_valid_games((12, 13, 14),
                             get_game_data_from_input(data)))

Part 2

For part 2, I could still rely on some util functions, and instead of checking the validity of the game, it was only necessary to get the minimum number of balls that would make a game possible. Much of the exercise logic was already done. Tests were a bit more succint.

Tests

class Test202302_02(unittest.TestCase):
    
    def test_get_minimum_balls_for_game(self):
        rounds_gen = get_game_data_from_input(TEST_DATA)
        self.assertEqual(
                get_minimum_balls_for_game(next(rounds_gen)),
                (4, 2, 6),
                )
    def test_power_set_of_cubes(self):
        self.assertEqual(
                power_set_of_cubes((4, 2, 6)),
                48
                )
    def test_sum_of_power_set(self):
        rounds_gen = get_game_data_from_input(TEST_DATA)
        self.assertEqual(
                sum_of_power_set_of_games(rounds_gen),
                2286
                )

Code

from typing import Generator
from re import findall
from itertools import chain
from functools import reduce

def get_game_data_from_input(data: str) -> Generator[str, None, None]:
    yield from data.splitlines()

def get_round_strings(game_round: str) -> Generator[str, None, None]:
    yield from game_round.split(';')

def get_round_ball_count(round_string: str) -> tuple:
    return (tuple((y, int(x))
            for x, y in findall(r'\s(\d+)\s(\w)[,]*',
                             round_string)))

def game_to_round_tuple(game_round: str) -> tuple:
    game = int(findall(r'Game (\d+)', game_round)[0])
    ball_counts = tuple(map(get_round_ball_count,
                            get_round_strings(game_round)))
    return (('game', game),) + ball_counts

def get_minimum_balls_for_game(
                  game_round: str
                  ) -> tuple[int, int, int]:
    rounds = game_to_round_tuple(game_round)[1:]
    flattened_rounds = list(chain.from_iterable(rounds))
    max_counts = tuple(max(t[1] for t in flattened_rounds
                           if t[0] == ball)
                       for ball in 'rgb')
    return max_counts

def power_set_of_cubes(cubes: tuple[int, int, int]) -> int:
    return reduce(lambda x, y: x * y, cubes)

def sum_of_power_set_of_games(
        games: Generator
        ) -> int:
    return sum(map(lambda game: power_set_of_cubes(get_minimum_balls_for_game(game)),
                   games))

with open('input', 'r') as file:
    data = file.read().strip()
    print(sum_of_power_set_of_games(get_game_data_from_input(data)))