★Advent of Code 202225 days solved.Source ↗
Day 19 · finished September 2024

Not Enough Minerals

“I needed A LOT of hints and references from the Advent of Code Reddit threads. I would not have solved this without them.”README
from helpers import load_data
from math import ceil
import time
from functools import lru_cache, reduce
from operator import mul
import heapq


# constants
RESOURCE_INDICES = {'geode': 0, 'obsidian': 1, 'clay': 2, 'ore': 3}
RESOURCE_TYPES = ['geode', 'obsidian', 'clay', 'ore']
RESOURCE_MASKS = {'geode': (1, 0, 0, 0), 'obsidian': (0, 1, 0, 0), 'clay': (0, 0, 1, 0), 'ore': (0, 0, 0, 1)}
START_TIME = 1
# TOTAL_TIME = 24
TOTAL_TIME = 32 # part 2


# the triangular numbers for getting geode upper bounds
NATURAL_NUMBERS = [i + 1 for i in range(TOTAL_TIME + 1)]
TRIANGULAR_NUMBERS = [sum(NATURAL_NUMBERS[:i]) for i in range(1, TOTAL_TIME + 1)]

"""
min heap to help order our pruning better
"""
def state_weight(state):
	# (-geodes, -obsidian, -clay, remaining_time)
	time, resource_counts, _ = state
	return (-resource_counts[0], -resource_counts[1], -resource_counts[2], time)

"""
tuple utility functions
"""
def addT(t1: tuple, t2: tuple):
	return (t1[0] + t2[0], t1[1] + t2[1], t1[2] + t2[2], t1[3] + t2[3])


def subT(t1: tuple, t2: tuple):
	return (t1[0] - t2[0], t1[1] - t2[1], t1[2] - t2[2], t1[3] - t2[3])


def scaleT(t1: tuple, scalar: int):
	return (t1[0] * scalar, t1[1] * scalar, t1[2] * scalar, t1[3] * scalar)


def lessThanOrEqualT(t1: tuple, t2: tuple):
	return t1[0] <= t2[0] and t1[1] <= t2[1] and t1[2] <= t2[2] and t1[3] <= t2[3]


def clampT(t1: tuple, t2: tuple):
	return (min(t1[0], t2[0]), min(t1[1], t2[1]), min(t1[2], t2[2]), min(t1[3], t2[3]))


# determine the next time that a robot could be built based on current robots and resources
@lru_cache(maxsize=None)
def time_to_build_robot(robot_cost, resource_counts, robot_counts):
	# if already have the resources to build robot, return early
	if lessThanOrEqualT(robot_cost, resource_counts):
		return 0
	
	# check if we have existing robots that can eventually mine us the resources for the new robot
	required_resource_types = {i for i in range(4) if robot_cost[i] > 0 and robot_cost[i] > resource_counts[i]}
	obtainable_resource_types = {i for i in range(4) if robot_counts[i] > 0}
	if not required_resource_types.issubset(obtainable_resource_types):
		return None
	
	# TODO likely a cleaner, integer way of doing this without ceiling function
	# compute how many time steps it will take to acquire enough resources to build the new robot
	resource_differences = subT(robot_cost, resource_counts)
	time_deltas = [
		ceil(resource_differences[required_resource] / robot_counts[required_resource])
		for required_resource 
		in required_resource_types
	]

	return max(time_deltas)


def get_max_robot_counts(blueprint):
	# find the maximum number of each robot we would ever want
	# if we can only spend X ore per turn, we would never want X + 1 ore robots
	# this is the main thing that reduces our search space
	max_geode = 10_000
	max_obsidian = max([blueprint[resource_type][1] for resource_type in RESOURCE_TYPES])
	max_clay = max([blueprint[resource_type][2] for resource_type in RESOURCE_TYPES])
	max_ore = max([blueprint[resource_type][3] for resource_type in RESOURCE_TYPES])
	max_robot_counts = (max_geode, max_obsidian, max_clay, max_ore)
	return max_robot_counts


# runs logic via DFS queue
def breadth_first_search(blueprint):
	# help caching states by limiting max number of robots
	max_robot_counts = get_max_robot_counts(blueprint)

	# help caching states by limiting max number of resources
	max_resource_usage = {k: tuple(i * (TOTAL_TIME - k) for i in max_robot_counts) for k in range(0, TOTAL_TIME + 1)}

	# tracks most geodes found at each time step
	# gets compared to the max number of possible remaining geodes to reduce search space
	# if most geodes at time t is already more than theoretical max, skip searching that branch
	# our heap hopefully surfaces good candidates first, making this a very useful pruning check
	most_geodes_at_time = {i: 0 for i in range(TOTAL_TIME + 1)}

	# initialize resources and robots
	resource_counts = (0, 0, 0, 0)
	robot_counts = (0, 0, 0, 1)
	starting_state = (START_TIME, resource_counts, robot_counts)

	# actual BFS queue logic (and heap for priority queueing)
	best_result = 0
	underlying_heap_list = []
	heapq.heappush(underlying_heap_list, (state_weight(starting_state), starting_state))
	seen_states = set()
	while underlying_heap_list:
		# grab next state off queue
		_, state = heapq.heappop(underlying_heap_list)

		# continue immediately if we've already seen this state
		if state in seen_states:
			continue
		else:
			seen_states.add(state)

		# exit early if end of simulation
		time, resource_counts, robot_counts = state
		if time == TOTAL_TIME:
			# save best results up to this point and at this specific time (for caching)
			updated_resource_counts = addT(resource_counts, robot_counts)
			best_result = max(best_result, updated_resource_counts[0])
			most_geodes_at_time[time] = max(most_geodes_at_time[time], updated_resource_counts[0])
			continue


		# compute absolute upper bound of geodes we could get from this current state
		# this assumes you could build one new geode robot at every remaining time step
		# trim this branch off the search, if it clearly will not beat the current best
		current_geodes = resource_counts[0] + robot_counts[0]
		geode_upper_bound = current_geodes + ((TOTAL_TIME - time + 1) * robot_counts[0]) + TRIANGULAR_NUMBERS[TOTAL_TIME - (time + 1)]
		if geode_upper_bound < most_geodes_at_time[time]:
			continue

		# possible robots that could be built and when
		robot_build_times = [
			(
				robot_resource_type,
				time_to_build_robot(blueprint[robot_resource_type], resource_counts, robot_counts)
			)
			for robot_resource_type
			in RESOURCE_MASKS.keys()
			if robot_counts[RESOURCE_INDICES[robot_resource_type]] < max_robot_counts[RESOURCE_INDICES[robot_resource_type]]
		]

		can_build_something_by_waiting = False
		for robot_resource_type, child_time_offset in robot_build_times:
			# if impossible to build the robot type at all or before time's up, continue
			# if robot built at time 24, it doesn't contribute any mining
			if child_time_offset == None or time + child_time_offset > TOTAL_TIME:
				continue
				
			# we could build at least one robot type later by waiting, so we should add a new state where we simply wait this turn
			can_build_something_by_waiting = True

			# if the child offset is 0, build immediately and go to next state
			if child_time_offset == 0:
				# update resources
				child_resource_counts = addT(resource_counts, robot_counts)
				child_resource_counts = subT(child_resource_counts, blueprint[robot_resource_type])

				# clamp resources so that we don't track inconsequential states
				max_remaining_spend = max_resource_usage[time]
				remaining_to_gather = scaleT(robot_counts, TOTAL_TIME - (time + 1))
				clamp_limit = subT(max_remaining_spend, remaining_to_gather)
				child_resource_counts = clampT(child_resource_counts, clamp_limit)

				# add new robot to counts
				child_robot_counts = addT(robot_counts, RESOURCE_MASKS[robot_resource_type])
				child_robot_counts = clampT(child_robot_counts, max_robot_counts)

				# add new state to BFS queue
				new_state = (time + 1, child_resource_counts, child_robot_counts)
				heapq.heappush(underlying_heap_list, (state_weight(new_state), new_state))

			# we build the robot at a future time, so just collect resources and queue next state
			else:
				# add resources, new robot isn't available to mine resources until end of this step
				newly_mined_resource_counts = scaleT(robot_counts, child_time_offset)
				child_resource_counts = addT(resource_counts, newly_mined_resource_counts)

				# clamp resources so that we don't track inconsequential states
				max_remaining_spend = max_resource_usage[time]
				remaining_to_gather = scaleT(robot_counts, TOTAL_TIME - (time + 1))
				clamp_limit = subT(max_remaining_spend, remaining_to_gather)
				child_resource_counts = clampT(child_resource_counts, clamp_limit)

				# add new state to BFS queue
				new_state = (time + child_time_offset, child_resource_counts, robot_counts)
				heapq.heappush(underlying_heap_list, (state_weight(new_state), new_state))

		# collect resources from this turn
		updated_resource_counts = addT(resource_counts, robot_counts)

		# queue the state where we build nothing, but only if we can evenutally build SOMETHING by waiting
		if can_build_something_by_waiting:
			# clamp resources so that we don't track inconsequential states
			max_remaining_spend = max_resource_usage[time]
			remaining_to_gather = scaleT(robot_counts, TOTAL_TIME - (time + 1))
			clamp_limit = subT(max_remaining_spend, remaining_to_gather)
			child_resource_counts = clampT(updated_resource_counts, clamp_limit)

			# add new state to BFS queue
			new_state = (time + 1, child_resource_counts, robot_counts)
			heapq.heappush(underlying_heap_list, (state_weight(new_state), new_state))

		# save best results up to this point and at this specific time (for caching)
		best_result = max(best_result, updated_resource_counts[0])
		most_geodes_at_time[time] = max(most_geodes_at_time[time], updated_resource_counts[0])

	return best_result


# returns array of dictionaries like blueprint['ore']['obsidian'] = 5
# i.e., this blueprint specifies that an ore robot needs 5 obsidian
def build_blueprint_dictionaries(lines):
	blueprints = []
	for line in lines:
		robots = line.split(" Each ")[1:]
		robots = [robot.replace(".", "") for robot in robots]
		blueprint = {resource_type: {resource_type: 0 for resource_type in RESOURCE_TYPES} for resource_type in RESOURCE_TYPES}
		for robot in robots:
			robot_type, resource_costs = robot.split(" robot costs ")
			resource_costs = resource_costs.split(' and ')

			blueprint[robot_type] = [0 for _ in range(4)]
			for resource_cost in resource_costs:
				cost, resource_type = resource_cost.split(" ")
				blueprint[robot_type][RESOURCE_INDICES[resource_type]] = int(cost)
			blueprint[robot_type] = tuple(blueprint[robot_type])

		blueprints.append(blueprint)
	return blueprints


def process_blueprint(i, blueprint):
	start_time = time.time()
	print(f"Blueprint {i + 1}:")

	# get max geodes that the blueprint could produce from simulation start
	max_geodes = breadth_first_search(blueprint)

	# compute relevant quality metric
	# result = (i + 1) * max_geodes # part 1
	result = max_geodes # part 2

	# view cache efficacy
	print(f"Robot build cache: {time_to_build_robot.cache_info()}") 

	# measure elapsed time for this blueprint
	end_time = time.time()
	elapsed_time = end_time - start_time
	print(f"Elapsed time: {elapsed_time} seconds.")
	print(f"Result: {result}\n")

	return result


if __name__ == "__main__":
	overall_start_time = time.time()
	print(f"Start time: {overall_start_time}\n")

	# put blueprints in global namespace so we can cache methods
	lines = load_data("day-19-input.txt")
	blueprints = build_blueprint_dictionaries(lines)
	blueprints = blueprints[:3] # part 2
	
	# sum up all the results
	results = [process_blueprint(i, blueprint) for i, blueprint in enumerate(blueprints)]
	# answer = sum(results)
	answer = reduce(mul, results) # part 2

	# part 1 (answer: 1127)
	# part 2: 21, 27, 37 => 20979 (got this hint by using someone else's solution on my input...)
	# part 2: 21, 27, 38 => 21546 (turns out their solution wasn't exactly correct for my input though!)
	# part 2 (answer: 21546)
	print(f"Answer: {answer}\n")

	# print overall run time
	overall_end_time = time.time()
	overall_elapsed_time = overall_end_time - overall_start_time
	print(f"Total elapsed time: {overall_elapsed_time} seconds.\n")
  • Created an entire custom class which overloaded +, *, <=, etc. Could have just used a 4-Tuple, which already works intuitively.
  • Main issue is reducing the problem space to a reasonable size. Heuristics are necessary, at least from what I can tell (no obvious, optimal solution that is fast).
  • Needed to add profiling with this command: python3 -m cProfile -s time day-19.py > day-19-profiler-results.txt
    • Turns out tuples have been very inefficient.
    • Ultimately, pruning the search space was the only thing that made tangible improvements on execution time.
  • Interestingly, the example input takes an indefinite amount of time to run for part 2. Ran it for ~10 mins before killing.
    • My solution worked for the actual challenge input though.
  • I needed A LOT of hints and references from the Advent of Code Reddit threads. I would not have solved this without them.