AoC 2022 D16: Proboscidea Volcanium
| Problem statement | Source code | Tags: BFS/DFSDynamic programming
← Previous Back to AoC Index Next →
Part 1
First, to dispel a few common misunderstandings:
- Always going for the biggest valve first doesn't work. If the biggest is far away, you may waste too much time getting there.
- Always going for the closest valve first doesn't work. Because the earlier you open a valve, the more reward it gives, you may want to open a bigger but slightly farther valve first.
So we can't greedily choose the next valve to open. Instead, we need to consider all possible sequences of valves to open, and calculate the score for each sequence.
Instead of using text labels to represent valves, I take the typical approach of mapping them to integers—more precisely, as 1-hot encodings so that we can represent sets of valves as bitmasks. This makes it easier to do all sorts of set operations later, like maintaining the set of opened valves, and for part 2, checking if the sets of valves opened by the two actors are disjoint.
Most of the broken valves (except the starting point) can be ignored, since they don't contribute to the score. Our graph should only contain the working valves. More precisely, we need to know the distance between each pair of valves, so we can quickly calculate how much time it takes to get from one valve to another. When we do the planning, we need to quickly move to the next valve to open—we don't want to waste time jumping through intermediate valves that we don't actually do anything with.
The reduceGraph function takes the original graph and produces a new graph that only contains the working valves, and the distances between them. This is the graph we will use for planning.
As for the actual optimization, I took a DFS approach. (Dijkstra should have worked here, but it would make part 2 much trickier.) My goal is to calculate every single combination of valves that can be opened within the time limit, and the score for each combination. The state of the search contains: the current node, the time left, the set of opened valves, and the total score so far. Each time when starting at a state, the valve is considered open (it has been added to the open set), but the score has not been added yet. We first compute the score for the current state. Then we try to move to each of the neighboring valves, which takes time distance + 1 (1 minute to open the valve). It's only a valid state to move to if:
- The valve is not already open.
- The time left is greater than the time it takes to get there and open it. (If the time is up just as we open it, then the score is 0 anyway.)
Note that I still explore subsequent states even if the currently opened valve set has been seen before with a higher reward. This is because my memo doesn't contain the time left or the current node, so it's possible that the more-reward path actually spent more time and would be eventually worse. (I tried pruning and it still worked, but I cannot prove its safety.)
The return value of allPaths can be read as "if the actor is to open these valves within time, the highest possible score is this". Part 1 is just to find the maximum reward among all paths.
Part 2
The key observation is that the two actors' paths are independent, except for the fact that they cannot open the same valve. So with allPaths, we just need to find a pair of disjoint valve sets with the highest total score. In part 1, I was careful not to implement much pruning for allPaths, which turns out to be useful for part 2. In particular, the paths we collected allow the actor to stop and do nothing, even if there are still valves that can be opened. This avoids the case where one actor greedily maximizes their score, forcing the other actor into more inferior paths.
Technically, finding the best pair of disjoint valve sets can be done with SOS DP by memoizing the best score for each valve subset (the actual set opened by the actor may be a further subset of that subset). Then for each opened set of actor 1, look up dp[complement] for actor 2. This would take time where is the number of paths (~4000) and is the number of valves (16). However, since isn't too large, I just used a brute-force approach. 16,000,000 pairs is still manageable, especially because my bitset operations are fast with disjointness checks.
The performance is very satisfactory: a mere 270ms, which is merely 100ms more than part 1.