diff --git a/2022/day19-part1/src/main.rs b/2022/day19-part1/src/main.rs index 33297a3..eed6914 100644 --- a/2022/day19-part1/src/main.rs +++ b/2022/day19-part1/src/main.rs @@ -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 = HashSet::new(); - // The HashSet *of* the current timeslot that is being processed. - let mut hs: HashSet; + // vec_a has the RecursionStates for the current timeslot, while vec_b has the next slot's states. + let mut vec_a: Vec = Vec::with_capacity(2u64.pow(20) as usize); + let mut vec_b: Vec = 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 = 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::>(); - // 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) { + // 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 = 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!(