diff --git a/2022/day19-part1/src/main.rs b/2022/day19-part1/src/main.rs index 62b3c6d..126f781 100644 --- a/2022/day19-part1/src/main.rs +++ b/2022/day19-part1/src/main.rs @@ -1,4 +1,7 @@ -use std::{cmp::Ordering, collections::HashSet}; +use std::{ + cmp::Ordering, + collections::{HashSet, VecDeque}, +}; #[derive(Debug)] struct Blueprint { @@ -13,108 +16,140 @@ struct Blueprint { // The maximal number of geodes that can be collected by this blueprint. // Initialized to 0 and overwritten by the solver, once it concludes. optimal_geode_count: usize, - // In order to optimize the simulation we keep track of all solutions' Pareto frontier. - // This allows us to cut out redundant simulation paths. - pareto_frontier: ParetoFrontier, } impl Blueprint { - // Recursively solve a blueprint for the maximal number of geodes - // that can be found in the given time. - fn solve(&mut self, rs: &mut RecursionState) { - // Out of time? Return and check if we've improved the optimal result. - if rs.remaining_time == 0 { - if rs.geode > self.optimal_geode_count { - println!("New optimum for blueprint {}: {}", self.id, rs.geode); - self.optimal_geode_count = rs.geode; + // Solve the given blueprint. + fn solve(&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; + + // Initialize the simulation with the starting state. + next_hs.insert(RecursionState { + ore_robots: 1, + clay_robots: 0, + obsidian_robots: 0, + geode_robots: 0, + ore: 0, + clay: 0, + obsidian: 0, + geode: 0, + remaining_time: 24, + }); + + // Iterate over all timeslots. + for ts in (1usize..=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 { + // 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. + // -> Don't build anything at all. + // 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. + + // 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. + + // (1) Ore Robot + if rs.ore >= self.ore_robot_ore_cost { + let mut nrs = *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); + } + // (2) Clay Robot + if rs.ore >= self.clay_robot_ore_cost { + let mut nrs = *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); + } + // (3) Obsidian Robot + if rs.ore >= self.obsidian_robot_ore_cost + && rs.clay >= self.obsidian_robot_clay_cost + { + let mut nrs = *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); + } + // (4) Geode Robot + if rs.ore >= self.geode_robot_ore_cost + && rs.obsidian >= self.geode_robot_obsidian_cost + { + let mut nrs = *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); + } + // (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); } - return; + + // Done! + println!("Finished simulation round for t = {}", ts); + println!(" inserted elements: {}", insert_count); + println!(" initial next_hs.len(): {}", next_hs.len()); + + if next_hs.len() < 50_000 && ts > 4 { + // Next up: Reduce the number of elements in the set using the Pareto optimality comparsion. + println!("Removing redundant elements ..."); + let mut reduced_hs: HashSet = HashSet::new(); + for rs in &next_hs { + // Carry over rs if it is part of the HS's Pareto frontier. + if next_hs.iter().all(|e| rs.compare(e).is_ge()) { + reduced_hs.insert(*rs); + } + } + next_hs = reduced_hs; + println!(" done!"); + } else { + println!("Number of states too large or timeslot too late. Not optimizing."); + } + + println!(" final next_hs.len(): {}\n", next_hs.len()); } - // Process the current RS in the ParetoFrontier. - // If it is strictly inferior to an element already in the set - // we've processed something better before already, and there is no point in continuing. - let res = self.pareto_frontier.update(rs); - if res.is_lt() { - return; - } + // Only RSs with no simulation-time should be left by now. + assert!(next_hs.iter().all(|e| e.remaining_time == 0)); - // 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, and - // recurse, w/o letting any time pass. - // -> Don't build anything at all, but *do* let time pass. - // 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. - // (1) Ore Robot - if rs.ore >= self.ore_robot_ore_cost { - // Commission the robot, subtracting the resources. - rs.ore -= self.ore_robot_ore_cost; - // Let time pass. - rs.pass_time(); - // Add the commissioned robot and recurse. - rs.ore_robots += 1; - self.solve(rs); - // "Unbuild" the robot in order to check the other possible timelines. - // First remove the robot, then "unpass" the time, and then re-add the resources. - rs.ore_robots -= 1; - rs.reverse_time(); - rs.ore += self.ore_robot_ore_cost; - } - // (2) Clay Robot - if rs.ore >= self.clay_robot_ore_cost { - // Commission the robot, subtracting the resources. - rs.ore -= self.clay_robot_ore_cost; - // Let time pass. - rs.pass_time(); - // Add the commissioned robot and recurse. - rs.clay_robots += 1; - self.solve(rs); - // "Unbuild" the robot in order to check the other possible timelines. - // First remove the robot, then "unpass" the time, and then re-add the resources. - rs.clay_robots -= 1; - rs.reverse_time(); - rs.ore += self.clay_robot_ore_cost; - } - // (3) Obsidian Robot - if rs.ore >= self.obsidian_robot_ore_cost && rs.clay >= self.obsidian_robot_clay_cost { - // Commission the robot, subtracting the resources. - rs.ore -= self.obsidian_robot_ore_cost; - rs.clay -= self.obsidian_robot_clay_cost; - // Let time pass. - rs.pass_time(); - // Add the commissioned robot and recurse. - rs.obsidian_robots += 1; - self.solve(rs); - // "Unbuild" the robot in order to check the other possible timelines. - // First remove the robot, then "unpass" the time, and then re-add the resources. - rs.obsidian_robots -= 1; - rs.reverse_time(); - rs.ore += self.obsidian_robot_ore_cost; - rs.clay += self.obsidian_robot_clay_cost; - } - // (4) Geode Robot - if rs.ore >= self.geode_robot_ore_cost && rs.obsidian >= self.geode_robot_obsidian_cost { - // Commission the robot, subtracting the resources. - rs.ore -= self.geode_robot_ore_cost; - rs.obsidian -= self.geode_robot_obsidian_cost; - // Let time pass. - rs.pass_time(); - // Add the commissioned robot and recurse. - rs.geode_robots += 1; - self.solve(rs); - // "Unbuild" the robot in order to check the other possible timelines. - // First remove the robot, then "unpass" the time, and then re-add the resources. - rs.geode_robots -= 1; - rs.reverse_time(); - rs.ore += self.geode_robot_ore_cost; - rs.obsidian += self.geode_robot_obsidian_cost; - } - // (5) Build nothing and let time pass. - rs.pass_time(); - self.solve(rs); - rs.reverse_time(); + // Print and store the result. + self.optimal_geode_count = next_hs.iter().map(|e| e.geode).max().unwrap(); + println!("Found optimal geode count: {}", self.optimal_geode_count); } } @@ -147,17 +182,6 @@ impl RecursionState { self.remaining_time -= 1; } - // Reverses the simulation by one unit of time. - // Of course, it is assumed that the active robots stay the same. - fn reverse_time(&mut self) { - self.ore -= self.ore_robots; - self.clay -= self.clay_robots; - self.obsidian -= self.obsidian_robots; - self.geode -= self.geode_robots; - // One unit of time is reserved. - 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 @@ -173,19 +197,20 @@ impl RecursionState { // This comparison is used to cut off redundant simulation paths in the recursive solver. fn compare(&self, other: &Self) -> Ordering { // Collect the comparison result on all metrics. - let mut metrics: Vec = Vec::with_capacity(9); + // let mut metrics: Vec = Vec::with_capacity(9); + let mut metrics: [Ordering; 9] = [Ordering::Equal; 9]; // Resource counts - metrics.push(self.ore.cmp(&other.ore)); - metrics.push(self.clay.cmp(&other.clay)); - metrics.push(self.obsidian.cmp(&other.obsidian)); - metrics.push(self.geode.cmp(&other.geode)); + metrics[0] = self.ore.cmp(&other.ore); + metrics[1] = self.clay.cmp(&other.clay); + metrics[2] = self.obsidian.cmp(&other.obsidian); + metrics[3] = self.geode.cmp(&other.geode); // Robot counts - metrics.push(self.ore_robots.cmp(&other.ore_robots)); - metrics.push(self.clay_robots.cmp(&other.clay_robots)); - metrics.push(self.obsidian_robots.cmp(&other.obsidian_robots)); - metrics.push(self.geode_robots.cmp(&other.geode_robots)); + metrics[4] = self.ore_robots.cmp(&other.ore_robots); + metrics[5] = self.clay_robots.cmp(&other.clay_robots); + metrics[6] = self.obsidian_robots.cmp(&other.obsidian_robots); + metrics[7] = self.geode_robots.cmp(&other.geode_robots); // Remaining time - metrics.push(self.remaining_time.cmp(&other.remaining_time)); + metrics[8] = self.remaining_time.cmp(&other.remaining_time); // If one metric is strictly superior and the rest are better or equal, // this RecursionState is "strictly better". @@ -214,80 +239,6 @@ impl RecursionState { } } -#[derive(Debug)] -struct ParetoFrontier { - data: Vec>, - count: usize, -} - -impl ParetoFrontier { - fn new() -> ParetoFrontier { - // Create the new PF. - let mut pf = ParetoFrontier { - data: Vec::new(), - count: 0, - }; - // Initialize the 24 empty HashSets for each of the timeslots. - for _ in 0..24 { - pf.data.push(HashSet::new()); - } - pf - } - - // Process a new RecursionState within the frontier. - // If the RS is strictly inferior to one of the elements in the frontier, - // the frontier remains unchanged and Ordering::Less is returned. - // If the RS is "equal" to *all* elements in the frontier, - // Ordering::Equal is returned and it is added to the frontier. - // If the RS is strictly superior to one or more elements in the frontier, - // those elements will be removed from the frontier, RS will be added, - // and Ordering::Greater will be returned. - fn update(&mut self, rs: &RecursionState) -> Ordering { - // Compute the timeslot index. - let idx = 24 - rs.remaining_time; - // Run through (possibly all) elements of the current timeslot's frontier. - let mut result = Ordering::Equal; - for e in &self.data[idx] { - // Compare the current element in the frontier. - match rs.compare(e) { - Ordering::Equal => (), - Ordering::Less => { - // rs is strictly inferior to one of the set's elements. - // We're done here. - result = Ordering::Less; - break; - } - Ordering::Greater => { - // rs is striclty superior to one of the set's elements. - // We can technically already return, but we need to get rid of all elements in - // the set that rs is striclty superior to. - result = Ordering::Greater; - break; - } - } - } - // We've reached the end of the loop, or broke out of it early. - // If we've found a strictly superior element, perform cleanup now. - if result.is_gt() { - // Expensive, but presumably not invoked *too* often. - self.data[idx].retain(|e| e.compare(rs).is_eq()); - } - // If we've found an element that isn't strictly inferior, add it to the frontier. - if result.is_ge() { - self.data[idx].insert(*rs); - } - if self.count % 10000 == 0 { - for (i, hs) in self.data.iter().enumerate() { - println!("Entries for t={}: {}", i, hs.len()); - } - println!(); - } - self.count += 1; - // Return the loop's conclusion. - result - } -} - fn main() { // Use command line arguments to specify the input filename. let args: Vec = std::env::args().collect(); @@ -313,29 +264,14 @@ fn main() { geode_robot_ore_cost: l[27].parse::().unwrap(), geode_robot_obsidian_cost: l[30].parse::().unwrap(), optimal_geode_count: 0, - // Start with an empty ParetoFrontier. - pareto_frontier: ParetoFrontier::new(), }) .collect::>(); - // Construct the starting recursion state. - let mut rs = RecursionState { - ore_robots: 1, - clay_robots: 0, - obsidian_robots: 0, - geode_robots: 0, - ore: 0, - clay: 0, - obsidian: 0, - geode: 0, - remaining_time: 24, - }; - // Solve for every blueprint. for bp in &mut blueprints { // Solve every blueprint with 24 minutes of time. println!("Solving Blueprint {}", bp.id); - bp.solve(&mut rs); + bp.solve(); } println!(