Vastly improved solution for 2022/day19-part1

This commit is contained in:
Tobias Marschner 2024-04-10 09:43:37 +02:00
parent ede5141783
commit 0e1053ec5e

View File

@ -1,5 +1,3 @@
use std::{cmp::Ordering, collections::HashSet};
#[derive(Debug)]
struct Blueprint {
// The id and costs parsed from the input.
@ -16,17 +14,16 @@ struct Blueprint {
}
impl Blueprint {
// Solve the given blueprint.
fn solve(&mut self) {
// Solve the given blueprint using BFS.
fn solve_bfs(&mut self) {
// For performance reasons we will search the solution space using breadth-first search.
// The HashSet for the next timeslot built in the current loop iteration.
let mut next_hs: HashSet<RecursionState> = HashSet::new();
// The HashSet *of* the current timeslot that is being processed.
let mut hs: HashSet<RecursionState>;
// vec_a has the RecursionStates for the current timeslot, while vec_b has the next slot's states.
let mut vec_a: Vec<RecursionState> = Vec::with_capacity(2u64.pow(20) as usize);
let mut vec_b: Vec<RecursionState> = Vec::with_capacity(2u64.pow(20) as usize);
// Initialize the simulation with the starting state.
next_hs.insert(RecursionState {
vec_a.push(RecursionState {
ore_robots: 1,
clay_robots: 0,
obsidian_robots: 0,
@ -35,23 +32,17 @@ impl Blueprint {
clay: 0,
obsidian: 0,
geode: 0,
remaining_time: 24,
});
// Iterate over all timeslots.
for ts in (1usize..=24).rev() {
// Building a robot at t=1 cannot influence the final geode-count,
// so it's omitted from the simulation here.
for ts in (2usize..=24).rev() {
// println!("Now at {} remaining time. Processing {} input RSs ...", ts, next_hs.len());
// Make the new HashSet the current one and
// initialize a new one for this loop iteration.
hs = next_hs;
next_hs = HashSet::new();
let mut insert_count = 0usize;
// Process every RS of the past timeslot
// to find all the states for the current timeslot.
for rs in &hs {
for rs in &vec_a {
// Go through all five options and branch down them, if possible.
// Specifically, we can either:
// -> Build one of the four robot types, if resources permit.
@ -59,152 +50,90 @@ impl Blueprint {
// It's important to note that the optimal solution may include waiting in the middle,
// i.e. letting resources accumulate so we can build one of the more expensive robots
// down the line instead of immediately spending the resources on a cheaper robot type.
// Copy over the current state and let time for it pass.
// This is the same no matter what type of robot we build since the robot will
// go live at the end of the timeslot, not at its beginning.
let mut next_rs = *rs;
next_rs.ore += next_rs.ore_robots as u16;
next_rs.clay += next_rs.clay_robots as u16;
next_rs.obsidian += next_rs.obsidian_robots as u16;
next_rs.geode += next_rs.geode_robots as u16;
// In branches (1) through (4) we copy the state, commission the robot, let time pass,
// actually add the robot, and add the resulting state to the queue.
// Check whether we can build the different robots, using `rs` and not `next_rs`
// since the resources have to be allocated at the beginning of the turn.
// (1) Ore Robot
if rs.ore >= self.ore_robot_ore_cost {
let mut nrs = *rs;
let mut nrs = next_rs;
nrs.ore -= self.ore_robot_ore_cost;
nrs.pass_time();
nrs.ore_robots += 1;
next_hs.insert(nrs);
insert_count += 1;
// checked_insert(&mut q, nrs);
vec_b.push(nrs);
}
// (2) Clay Robot
if rs.ore >= self.clay_robot_ore_cost {
let mut nrs = *rs;
let mut nrs = next_rs;
nrs.ore -= self.clay_robot_ore_cost;
nrs.pass_time();
nrs.clay_robots += 1;
next_hs.insert(nrs);
insert_count += 1;
// checked_insert(&mut q, nrs);
vec_b.push(nrs);
}
// (3) Obsidian Robot
if rs.ore >= self.obsidian_robot_ore_cost
&& rs.clay >= self.obsidian_robot_clay_cost
{
let mut nrs = *rs;
let mut nrs = next_rs;
nrs.ore -= self.obsidian_robot_ore_cost;
nrs.clay -= self.obsidian_robot_clay_cost;
nrs.pass_time();
nrs.obsidian_robots += 1;
next_hs.insert(nrs);
insert_count += 1;
// checked_insert(&mut q, nrs);
vec_b.push(nrs);
}
// (4) Geode Robot
if rs.ore >= self.geode_robot_ore_cost
&& rs.obsidian >= self.geode_robot_obsidian_cost
{
let mut nrs = *rs;
let mut nrs = next_rs;
nrs.ore -= self.geode_robot_ore_cost;
nrs.obsidian -= self.geode_robot_obsidian_cost;
nrs.pass_time();
nrs.geode_robots += 1;
next_hs.insert(nrs);
insert_count += 1;
// checked_insert(&mut q, nrs);
vec_b.push(nrs);
}
// (5) Build nothing and let time pass.
let mut nrs = *rs;
nrs.pass_time();
next_hs.insert(nrs);
insert_count += 1;
// checked_insert(&mut q, nrs);
vec_b.push(next_rs);
}
// Done!
println!("Finished simulation round for t = {}", ts);
println!(" inserted elements: {}", insert_count);
println!(" initial next_hs.len(): {}", next_hs.len());
println!(" inserted elements: {}", vec_b.len());
// For the last few timesteps, pruning isn't worth it. Just calculate the rest in that case.
let worst_case = next_hs.len() * 5usize.pow(ts as u32);
if worst_case > 100_000_000 && ts > 4 {
// Pruning runs in a nested loop over the elements of the set.
// In the inner loop we collect strictly inferior elements until we've met a
// threshold of the original count.
let initial_elems = next_hs.len() as f32;
// If the inner loop has found strictly inferior elements making up 10% of the
// original count, exit out of it and actually remove them before continuing to
// prune.
const INNER_LOOP_CUTOFF: f32 = 0.1;
// Count the total number of comparisons performed.
let mut comp_count = 0usize;
// This is the cutoff. Once we've reached this number of comparisons, we quit.
const TOTAL_COMP_COUNT: usize = 400_000_000usize;
// Prune elements.
prune_states(&mut vec_b, &mut vec_a);
println!(" elements after prune: {}", vec_a.len());
// Next up: Reduce the number of elements in the set using the Pareto optimality comparsion.
println!("Pruning redundant elements ...");
loop {
let mut strictly_inferior_hs: HashSet<RecursionState> = HashSet::new();
for ars in &next_hs {
// Run a nested loop and mark any strictly inferior elements.
// Also keep track of whether ars may be part of the frontier.
for brs in &next_hs {
comp_count += 1;
match ars.compare(brs) {
Ordering::Greater => {
strictly_inferior_hs.insert(*brs);
}
Ordering::Less => {
strictly_inferior_hs.insert(*ars);
}
Ordering::Equal => (),
}
}
// Have we found enough elements yet?
// Or have we reached the limit in terms of comparisons?
if (strictly_inferior_hs.len() as f32) / initial_elems >= INNER_LOOP_CUTOFF
|| comp_count >= TOTAL_COMP_COUNT {
// if comp_count >= TOTAL_COMP_COUNT {
break;
}
}
// No more inferior elements? Then we're done.
if strictly_inferior_hs.is_empty() {
println!("No more inferior elements. Fully pruned.");
println!("comp_count: {}", comp_count);
break;
} else {
// Actually remove the strictly inferior elements from next_hs.
next_hs = next_hs
.difference(&strictly_inferior_hs)
.cloned()
.collect::<HashSet<_>>();
// And reset the strictly_inferior set.
strictly_inferior_hs.clear();
// If we've hit the limit in terms of no. of comparsions, just move on.
if comp_count >= TOTAL_COMP_COUNT {
println!("Comparison limit reached. Continuing.");
break;
}
}
}
} else {
println!(
"{} calculations left / timeslot {} - no need to prune.",
worst_case, ts
);
}
println!(" final next_hs.len(): {}\n", next_hs.len());
// Clear vec_b since all the relevant states have been copied over to vec_a.
vec_b.clear();
// for e in &vec_a {
// e.print();
// println!();
// }
// println!();
//
// if ts == 15 {
// std::process::exit(1);
// }
}
// Only RSs with no simulation-time should be left by now.
assert!(next_hs.iter().all(|e| e.remaining_time == 0));
// Print and store the result.
self.optimal_geode_count = next_hs.iter().map(|e| e.geode).max().unwrap();
// Collect and print the final geode count.
// Remember that we still have to simulate the geode-collection for t=1,
// hence `e.geode + e.geode_robots as u16`.
self.optimal_geode_count = vec_a.iter().map(|e| e.geode + e.geode_robots as u16).max().unwrap();
println!("Found optimal geode count: {}", self.optimal_geode_count);
}
}
// Store all of the state that's passed up and down the recursion in one struct.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
// Derive PartialOrd + Ord for lexicographic sorting, something we'll use during pruning.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct RecursionState {
// The currently active fleet of robots.
ore_robots: u8,
@ -216,124 +145,66 @@ struct RecursionState {
clay: u16,
obsidian: u16,
geode: u16,
// How much time is left in the simulation.
remaining_time: u8,
}
impl RecursionState {
// Let one unit of time pass on this RecursionState.
// Collects all the resources from the currently active robots.
fn pass_time(&mut self) {
self.ore += self.ore_robots as u16;
self.clay += self.clay_robots as u16;
self.obsidian += self.obsidian_robots as u16;
self.geode += self.geode_robots as u16;
// One unit of time passes.
self.remaining_time -= 1;
}
// Compare two recursion states.
// This method essentially checks for Pareto improvements. An example:
// RS1: 2 ore, 2 clay, 1 ore robot, 1 clay robot, 20 minutes left
// RS2: 2 ore, 1 clay, 1 ore robot, 1 clay robot, 20 minutes left
// RS1 is just as "good" in terms of ore, robot counts and time left
// but is "strictly better" in terms of clay. It makes no sense to continue
// running the simulation for RS2 b/c it cannot possibly produce a better
// outcome than RS1.
// Having more resources, robots or time can only ever lead to better outcomes.
// If, however, RS1 had, say, one more ore but less clay than RS2 we cannot say
// that RS1 is "strictly better". It is different, having made a different tradeoff in
// resource collection, which may or may not lead to a better outcome overall.
// This comparison is used to cut off redundant simulation paths in the recursive solver.
#[allow(clippy::comparison_chain)]
fn compare(&self, other: &Self) -> Ordering {
// This used to be a lot more idiomatic.
// However, to help improve performance, it now looks the way it does.
let mut less = false;
let mut greater = false;
if self.ore < other.ore {
less = true;
} else if self.ore > other.ore {
greater = true;
}
if self.clay < other.clay {
less = true;
} else if self.clay > other.clay {
greater = true;
}
if self.obsidian < other.obsidian {
less = true;
} else if self.obsidian > other.obsidian {
greater = true;
}
if self.geode < other.geode {
less = true;
} else if self.geode > other.geode {
greater = true;
}
// Short-circuit.
if less && greater {
return Ordering::Equal;
}
if self.ore_robots < other.ore_robots {
less = true;
} else if self.ore_robots > other.ore_robots {
greater = true;
}
if self.clay_robots < other.clay_robots {
less = true;
} else if self.clay_robots > other.clay_robots {
greater = true;
}
if self.obsidian_robots < other.obsidian_robots {
less = true;
} else if self.obsidian_robots > other.obsidian_robots {
greater = true;
}
if self.geode_robots < other.geode_robots {
less = true;
} else if self.geode_robots > other.geode_robots {
greater = true;
}
if self.remaining_time < other.remaining_time {
less = true;
} else if self.remaining_time > other.remaining_time {
greater = true;
}
if (!less && !greater) || (less && greater) {
Ordering::Equal
} else if less {
Ordering::Less
} else {
Ordering::Greater
}
}
#[allow(dead_code)]
fn print(&self) {
print!("{:>3} O, ", self.ore);
print!("{:>3} C, ", self.clay);
print!("{:>3} B, ", self.obsidian);
print!("{:>3} G, ", self.geode);
print!("{:>3} OR, ", self.ore_robots);
print!("{:>3} CR, ", self.clay_robots);
print!("{:>3} BR, ", self.obsidian_robots);
print!("{:>3} GR, ", self.geode_robots);
println!("{:>3} T", self.remaining_time);
print!("{:>3} O, ", self.ore);
print!("{:>3} C, ", self.clay);
print!("{:>3} B, ", self.obsidian);
print!("{:>3} G, ", self.geode);
}
}
// Copy over states for the next simulation round, pruning a lot of (but not all)
// RecursionStates that are "strictly inferior" in terms of Pareto optimality.
// Will clear any previously present elements in dest.
//
// A few words on the general idea here:
// The goal here is to check for Pareto improvements. An example:
// RS1: 2 ore, 2 clay, 1 ore robot, 1 clay robot, 20 minutes left
// RS2: 2 ore, 1 clay, 1 ore robot, 1 clay robot, 20 minutes left
// RS1 is just as "good" in terms of ore, robot counts and time left
// but is "strictly better" in terms of clay. It makes no sense to continue
// running the simulation for RS2 b/c it cannot possibly produce a better
// outcome than RS1.
// Having more resources, robots or time can only ever lead to better outcomes.
// If, however, RS1 had, say, one more ore but less clay than RS2 we cannot say
// that RS1 is "strictly better". It is different, having made a different tradeoff in
// resource collection, which may or may not lead to a better outcome overall.
// This comparison is used to cut off redundant simulation paths in the solver.
fn prune_states(source: &mut [RecursionState], dest: &mut Vec<RecursionState>) {
// Begin by sorting the source lexicrgraphically and clearing the destination.
source.sort_unstable();
dest.clear();
// Add one more element to the source, at the very end,
// that is a copy of the last element +1 ore.
// This is to ensure that the actual last element
// Iterate through it from smallest to largest element and look at every pair of states.
for (a, b) in source.iter().zip(source.iter().skip(1)) {
// Don't copy a over if it is strictly inferior or equal to b.
// Compare from bottom to top.
if a.geode > b.geode
|| a.obsidian > b.obsidian
|| a.clay > b.clay
|| a.ore > b.ore
|| a.geode_robots > b.geode_robots
|| a.obsidian_robots > b.obsidian_robots
|| a.clay_robots > b.clay_robots
|| a.ore_robots > b.ore_robots
{
dest.push(*a);
}
}
// Copy over the very last element, guaranteed to be part of the frontier.
dest.push(*source.last().unwrap());
}
fn main() {
// Use command line arguments to specify the input filename.
let args: Vec<String> = std::env::args().collect();
@ -366,7 +237,7 @@ fn main() {
for bp in &mut blueprints {
// Solve every blueprint with 24 minutes of time.
println!("Solving Blueprint {}", bp.id);
bp.solve();
bp.solve_bfs();
}
println!(