1
\$\begingroup\$

Part 1:

The challenge involves analyzing recorded games where an Elf reveals subsets of cubes in a bag, each identified by a game ID. The goal is to determine which games would be possible if the bag contained specific quantities of red, green, and blue cubes.

For example, the record of a few games might look like this:

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

In game 1, three sets of cubes are revealed from the bag (and then put back again). The first set is 3 blue cubes and 4 red cubes; the second set is 1 red cube, 2 green cubes, and 6 blue cubes; the third set is only 2 green cubes.

The Elf would first like to know which games would have been possible if the bag contained only 12 red cubes, 13 green cubes, and 14 blue cubes?

In the example above, games 1, 2, and 5 would have been possible if the bag had been loaded with that configuration. The solution involves summing up the IDs of these possible games. In the provided example, the answer was 8, corresponding to games 1, 2, and 5.

#!/usr/bin/env python3


import sys
from typing import Iterable

MAX_RED = 12
MAX_GREEN = 13
MAX_BLUE = 14

GID_SEP = ": "
DRAW_SEP = "; "
PICK_SEP = ", "


def parse_cubes(cubes: str) -> dict:
    counts = {"green": 0, "red": 0, "blue": 0}

    for cube in cubes:
        count, name = cube.split()
        counts[name] += int(count)
    return counts


def is_eligible_game(counts: dict) -> bool:
    return (
        counts["red"] <= MAX_RED
        and counts["blue"] <= MAX_BLUE
        and counts["green"] <= MAX_GREEN
    )


def play_game(line: str) -> int:
    # Game N: 3 X, 4 Y; .... ; ..
    round_num, round_sets = map(str.strip, line.split(GID_SEP))
    round_num = round_num.split()[-1]
    round_sets = round_sets.split(DRAW_SEP)

    if all(
        is_eligible_game(parse_cubes(turns.split(PICK_SEP))) for turns in round_sets
    ):
        return int(round_num)
    return 0


def total_eligible_games(lines: Iterable[str]) -> int:
    return sum(map(play_game, lines))


def main():
    if len(sys.argv) != 2:
        sys.exit("Error - No file provided.")

    with open(sys.argv[1], "r") as f:
        total = total_eligible_games(f)

    print(f"Total: {total}")


if __name__ == "__main__":
    main()

Part 2:

In Part Two, the Elf poses a second question: finding the fewest number of cubes for each color to make a game possible. The power of a set of cubes is calculated by multiplying the numbers of red, green, and blue cubes together.

Again consider the example games from earlier:

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

The power of a set of cubes is equal to the numbers of red, green, and blue cubes multiplied together. The power of the minimum set of cubes in game 1 is 48. In games 2-5 it was 12, 1560, 630, and 36, respectively. The solution involves summing up the power of the minimum set of cubes of these possible games. In the provided example, the answer was 2286.

#!/usr/bin/env python3

import sys
from typing import Iterable

GID_SEP = ": "
DRAW_SEP = "; "
PICK_SEP = ", "


def parse_cubes(cubes: str) -> dict:
    counts = {"green": 0, "red": 0, "blue": 0}

    for cube in cubes:
        count, name = cube.split()
        counts[name] += int(count)
    return counts


def play_game(line: str) -> int:
    # Game N: 3 X, 4 Y; .... ; ..
    round_num, round_sets = map(str.strip, line.split(GID_SEP))
    round_num = round_num.split()[-1]
    round_sets = round_sets.split(DRAW_SEP)
    max_counts = {"green": 0, "red": 0, "blue": 0}

    for round_set in round_sets:
        cur_counts = parse_cubes(round_set.split(PICK_SEP))

        for color in ["green", "red", "blue"]:
            max_counts[color] = max(max_counts[color], cur_counts[color])

    return max_counts["green"] * max_counts["red"] * max_counts["blue"]


def total_powers(lines: Iterable[str]) -> int:
    return sum(map(play_game, lines))


def main():
    if len(sys.argv) != 2:
        sys.exit("Error - No file provided.")

    with open(sys.argv[1], "r") as f:
        total = total_powers(f)

    print(f"Total: {total}")


if __name__ == "__main__":
    main()

Review Goals:

General coding comments, style, etc.

What are some possible simplifications? What would you do differently?

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

three variables for one concept

MAX_RED = 12
MAX_GREEN = 13
MAX_BLUE = 14

My first impression is that this will likely turn out to be inconvenient, with three if statements. Consider throwing these magic numbers into a single dict:

MAX_CUBES = {
    'red': 12,
    'green': 13,
    'blue': 14,
}

Oh, now I see in is_eligible_game that it didn't turn out so bad at all, nevermind. The conjuncts could have been an all() expression, but the OP code is perfectly clear as-is.

Down in the part-2 play_game we might prefer product *= max_counts[color], but three is such a small number that it makes little difference.


cracking argv

Kudos, nice __main__ guard, and nice CLI error checking. And mapping play_game across the input lines is lovely.

Consider making things easier on yourself with typer:

from pathlib import Path
import typer

def main(games_input_file: Path) -> None:
    with open(games_input_file, "r") as f:
        total = ...

if __name__ == "__main__":
    typer.run(main)

You get --help "for free"!


line parsing

def play_game(line: str) -> int:
    # Game N: 3 X, 4 Y; .... ; ..

Thank you kindly for that comment. It is quite helpful.

In general the tuple unpack, the mapping, the consistent split() on some delimiter, all work smoothly.

    round_num = round_num.split()[-1]

That seemed slightly off for two reasons. Maybe [1] would have been more natural than asking for last element, given that this is always a pair? Maybe a re.compile(r'^Game (\d+)') would have been slightly clearer? IDK, it's all six vs. half-dozen.

But then it would be preferable to immediately store an int( ... ) expression into something that you're claiming is a round number, rather than deferring that until the return.

Maybe spell out that GID_SEP is GAME_ID_SEP? Again, these are all tiny nits.


summary

Two things impressed me as I was reading this code:

  1. small helper functions that do a single thing
  2. very clear and helpful identifier names

Good job. Keep it up!

This code achieves its design goals.

I would be willing to delegate or accept maintenance tasks on it.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.