AoC 2022 D19: Not Enough Minerals
| Problem statement | Source code | Tags: Dynamic programming
β Previous Back to AoC Index Next β
Part 1
This is very similar to day 16. We also want to explore a decision space and maximize the output, where the score of each decision is dependent on how early we made it. Let's start by defining the states of our space.
Note that I don't keep track of geodeBots in the state, because they don't affect state transitions. Each time we build a geode bot, we can immediately add the geodes it will produce to the score.
I see some people basing their state transitions on "what should I do the next minute?", but the key observation is that most of these recursion steps result in nothing happening because we don't have enough resources to build anything. Instead, like day 16 (where we directly decide which valve to open next, not which valve to go to next), we can base our state transitions on "what should I build next?" This way, we can skip over all the states where we just wait for resources to accumulate.
I use a DFS, like day 16. For each state, we try building each type of bot. If no bot can be built (probably because not enough time left to gather enough resources), then the only choice is to sit and wait for the remaining time to elapse.
The core logic is in the canBuildBot and buildBot functions. The most important part about state transitioning is figuring out how long it will take to build the next bot, which includes gathering the resources and actually building the bot. For example, if we have 2 ores and 2 ore bots, and we need 7 ores, then we need 5 more ores, which is going to take 3 minutes to mine (ceiling division), and then another minute to build a bot. When building obsidian and geode bots, we need two resources to be simultaneously available.
How do we know if we can and should build a bot? This is the most important bit because edge pruning happens here. For part 1 I didn't do any aggressive pruning, so there are only 3 conditions:
- We can't build a bot if we have no bots producing its required resources. For example, we can't build an obsidian bot if we have no clay bots, because we will never be able to gather the required clay. This prevents division by zero in the
timeToBuildBotfunction. - We can't build a bot if we don't have enough time to gather the required resources and build the bot, including if the bot is finished right at the time limit, since it can't mine more things.
- (Optimization) We don't need to build more bots than the maximum required resources for a bot, since we can only build one bot per minute. For example, if we need 4 clays per obsidian bot, then we don't need more than 4 clay bots, because we can't spend more than 4 clays per minute. For ores, we take the maximum of all ore requirements.
Now for actually building the bot. We first run the state forward by the time it takes to build the bot. Then we subtract the resources we spent to build the bot, and add the new bot to the state. As stated before, if we build a geode bot, we can immediately add the geodes it will produce to the score, so we don't need to keep track of geode bots in the state.
Performance is okay: it runs in ~350ms.
Part 2
Because the state space grows exponentially with time, adding another 8 minutes to the time limit roughly means multiplying the runtime by ! In reality, because of the "no more bots than necessary" pruning, we actually don't explore that many states after a given time point. Also because the number of blueprints went from 30 to 3, the actual runtime is about 41s, a 130x increase. However, I believe I can do better than 40s.
I just implemented a single pruning condition: if we can't do better than the current best by building a geode bot every minute, then we can stop exploring this branch. With t minutes left, we can build at most t - 1 geode bots, each producing 1, 2, ..., t - 1 geodes, so the maximum number of geodes we can produce is t * (t - 1) / 2. If this plus the current geodes is less than the best score, then we can stop exploring this branch.
Now we add this single condition to the DFS:
And π₯π₯π₯ part 2's runtime drops to 40ms (1000x speedup), and part 1's drops to 82ms! I guess this is the beauty of edge pruning.
(Note: I tagged this one as "dynamic programming", because DFS on a state space with memoization is just DP. At this point I just think DP is an extremely uninformative branding.)