Advent of Code: Day 15

Source

Part 1: Input is a list of ingredients of the form:

Frosting: capacity 4, durability -2, flavor 0, texture 0, calories 5
Candy: capacity 0, durability 5, flavor -1, texture 0, calories 8

A recipe score is a product of the positive quantity scores (ignoring calories), where each quantity score is the product of the quantity and that product for each product.

For example, 4 Frosting and 2 Candy above, would have a score of of -2 * 4 + 5 * 2 = 2 for durability and 0 * 4 + -1 * 2 = -2 (and thus ignored as we only accept positive scores) for a total thus far of 2.

The code is getting a bit longer, but a brute force solution still works great:

try:
    TOTAL_QUANTITY = int(sys.argv[1])
except:
    print('Usage: cat input.txt | ./part-1.py [TOTAL_QUANTITY]')
    sys.exit(0)

ingredients = collections.defaultdict(lambda : collections.defaultdict(lambda : 0))
properties = set()

for line in sys.stdin:
    item, pairs = line.split(':')
    for pair in pairs.split(','):
        property, amount = pair.strip().split(' ')
        ingredients[item.lower()][property] = int(amount)
        properties.add(property)

items = list(sorted(ingredients.keys()))
properties.remove('calories')

def calculate_score(quantities):
    score = 1

    for property in properties:
        property_score = sum(
            quantities[item] * ingredients[item][property]
            for item in quantities
        )

        if property_score > 0:
            score *= property_score

    return score

def splits(amount, count):
    if count <= 1:
        yield [amount]
    else:
        for i in range(amount + 1):
            for subsplit in splits(amount - i, count - 1):
                yield [i] + subsplit

best_score = 0
best_quantities = None

for split in splits(TOTAL_QUANTITY, len(items)):
    quantities = dict(zip(items, split))
    score = calculate_score(quantities)

    if score > best_score:
        best_score = score
        best_quantities = quantities

print(best_score)
print(best_quantities)]

Essentially, there are four parts. First, we load all of the ingredients and their scores into memory. Then, calculate_score will take the logic described in the problem and return a score for any given quantities. Third, splits is a generator that will return all possible ways to divide amount items into count buckets:

print(list(splits(5, 3)))
[[0, 0, 5],
 [0, 1, 4],
 [0, 2, 3],
 [0, 3, 2],
 [0, 4, 1],
 [0, 5, 0],
 [1, 0, 4],
 [1, 1, 3],
 [1, 2, 2],
 [1, 3, 1],
 [1, 4, 0],
 [2, 0, 3],
 [2, 1, 2],
 [2, 2, 1],
 [2, 3, 0],
 [3, 0, 2],
 [3, 1, 1],
 [3, 2, 0],
 [4, 0, 1],
 [4, 1, 0],
 [5, 0, 0]]

Finally, we use all of those pieces to iterate over all of the possible scores. dict(zip(items, split)) is a quick way to take two lists (of keys and values) and combine them into a dictionary.

I also tried to work out a solution using a genetic algorithm: part-1-genetic.py. It does work (in that it finds a pretty good solution), but the solution isn’t optimal and it takes just as long as just brute forcing the problem. It’s interesting though.

Part 2: Do the same thing, only throw out any solutions that do not have a sum calorie count of exactly 500.

The tweak for this part is actually fairly elegant:

def calculate_score(quantities):
    ...

    calorie_count = sum(
        quantities[item] * ingredients[item]['calories']
        for item in quantities
    )
    if calorie_count != TOTAL_CALORIES:
        return float("-inf")

    ...

Since we’re looking for the largest score, returning a score that is infinitely low will skip over any invalid solutions. Other than that (and a new try block at the top), everything remains the same.

This was an interesting problem. Not quite as elegant a solution as I’d like, but it’s still well within my self imposed (originally from Project Euler) 1 minute limit.