From 671d2b236a6581c4f7958db3af9e9e906c7104b9 Mon Sep 17 00:00:00 2001 From: maneatingape <44142177+maneatingape@users.noreply.github.com> Date: Sat, 31 Aug 2024 14:37:32 +0200 Subject: [PATCH] Year 2018 Day 24 --- README.md | 1 + benches/benchmark.rs | 1 + src/lib.rs | 1 + src/main.rs | 1 + src/year2018/day24.rs | 242 +++++++++++++++++++++++++++++++++++ tests/test.rs | 1 + tests/year2018/day24_test.rs | 22 ++++ 7 files changed, 269 insertions(+) create mode 100644 src/year2018/day24.rs create mode 100644 tests/year2018/day24_test.rs diff --git a/README.md b/README.md index 6cf3af7..156109a 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ Performance is reasonable even on older hardware, for example a 2011 MacBook Pro | 21 | [Chronal Conversion](https://adventofcode.com/2018/day/21) | [Source](src/year2018/day21.rs) | 65 | | 22 | [Mode Maze](https://adventofcode.com/2018/day/22) | [Source](src/year2018/day22.rs) | 3410 | | 23 | [Experimental Emergency Teleportation](https://adventofcode.com/2018/day/23) | [Source](src/year2018/day23.rs) | 515 | +| 24 | [Immune System Simulator 20XX](https://adventofcode.com/2018/day/24) | [Source](src/year2018/day24.rs) | 2054 | ## 2017 diff --git a/benches/benchmark.rs b/benches/benchmark.rs index 6c181eb..850e881 100644 --- a/benches/benchmark.rs +++ b/benches/benchmark.rs @@ -147,6 +147,7 @@ mod year2018 { benchmark!(year2018, day21); benchmark!(year2018, day22); benchmark!(year2018, day23); + benchmark!(year2018, day24); } mod year2019 { diff --git a/src/lib.rs b/src/lib.rs index 03a7b3b..ef5327e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -135,6 +135,7 @@ pub mod year2018 { pub mod day21; pub mod day22; pub mod day23; + pub mod day24; } /// # Rescue Santa from deep space with a solar system voyage. diff --git a/src/main.rs b/src/main.rs index 13edb38..1ecdf6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -203,6 +203,7 @@ fn year2018() -> Vec { solution!(year2018, day21), solution!(year2018, day22), solution!(year2018, day23), + solution!(year2018, day24), ] } diff --git a/src/year2018/day24.rs b/src/year2018/day24.rs new file mode 100644 index 0000000..80ba8c3 --- /dev/null +++ b/src/year2018/day24.rs @@ -0,0 +1,242 @@ +//! # Immune System Simulator 20XX +//! +//! Similar to [`Day 15`] we implement the rules precisely, paying attention to edge cases. +//! +//! In particular during part two, it's possible for a fight to end in a draw, if both armies +//! become too weak to destroy any further units. As each fight is independent, we find the +//! minimum boost value with a multithreaded parallel search. +//! +//! [`Day 15`]: crate::year2018::day15 +use crate::util::hash::*; +use crate::util::parse::*; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::mpsc::{channel, Sender}; +use std::thread; + +pub struct Input { + immune: Vec, + infection: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Immune, + Infection, + Draw, +} + +#[derive(Clone, Copy)] +struct Group { + units: i32, + hit_points: i32, + damage: i32, + initiative: i32, + weak: u32, + immune: u32, + attack: u32, + chosen: u32, +} + +/// Convenience functions. +impl Group { + fn effective_power(&self) -> i32 { + self.units * self.damage + } + + /// Attack types are stored as a bitmask for quick comparison. + fn actual_damage(&self, other: &Group) -> i32 { + if self.attack & other.weak != 0 { + 2 * self.effective_power() + } else if self.attack & other.immune == 0 { + self.effective_power() + } else { + 0 + } + } + + fn target_selection_order(&self) -> (i32, i32) { + (-self.effective_power(), -self.initiative) + } + + fn attack(&self, defender: &mut Self) -> i32 { + // Clamp damage to 0 as units may be negative, + // if this unit was wiped out in an earlier attack. + let damage = self.actual_damage(defender).max(0); + let amount = damage / defender.hit_points; + defender.units -= amount; + amount + } +} + +/// Shared between threads for part two. +struct Shared { + done: AtomicBool, + boost: AtomicI32, + tx: Sender<(i32, i32)>, +} + +pub fn parse<'a>(input: &'a str) -> Input { + // Use a bitmask to store each possible attack type. + let mut elements = FastMap::new(); + let mut mask = |key: &'a str| { + let next = 1 << elements.len(); + *elements.entry(key).or_insert(next) + }; + + let (first, second) = input.split_once("\n\n").unwrap(); + let immune = parse_group(first, &mut mask); + let infection = parse_group(second, &mut mask); + Input { immune, infection } +} + +pub fn part1(input: &Input) -> i32 { + let (_, units) = fight(input, 0); + units +} + +pub fn part2(input: &Input) -> i32 { + let (tx, rx) = channel(); + let shared = Shared { done: AtomicBool::new(false), boost: AtomicI32::new(1), tx }; + + // Use as many cores as possible to parallelize the search. + thread::scope(|scope| { + for _ in 0..thread::available_parallelism().unwrap().get() { + scope.spawn(|| worker(input, &shared)); + } + }); + + // Hang up the channel. + drop(shared.tx); + // Find lowest possible power. + rx.iter().min_by_key(|&(boost, _)| boost).map(|(_, units)| units).unwrap() +} + +fn worker(input: &Input, shared: &Shared) { + while !shared.done.load(Ordering::Relaxed) { + // Get the next attack boost, incrementing it atomically for the next fight. + let boost = shared.boost.fetch_add(1, Ordering::Relaxed); + + // If the reindeer wins then set the score and signal all threads to stop. + // Use a channel to queue all potential scores as another thread may already have sent a + // different value. + let (kind, units) = fight(input, boost); + + if kind == Kind::Immune { + shared.done.store(true, Ordering::Relaxed); + let _unused = shared.tx.send((boost, units)); + } + } +} + +fn fight(input: &Input, boost: i32) -> (Kind, i32) { + let mut immune = input.immune.clone(); + let mut infection = input.infection.clone(); + let mut attacks = vec![None; immune.len() + infection.len()]; + + // Boost reindeer's immmune system. + immune.iter_mut().for_each(|group| group.damage += boost); + + for turn in 1.. { + // Target selection phase. + let mut target_selection = |attacker: &[Group], defender: &mut [Group], kind: Kind| { + for (from, group) in attacker.iter().enumerate() { + let target = (0..defender.len()) + .filter(|&to| { + defender[to].chosen < turn && group.actual_damage(&defender[to]) > 0 + }) + .max_by_key(|&to| { + ( + group.actual_damage(&defender[to]), + defender[to].effective_power(), + defender[to].initiative, + ) + }); + + if let Some(to) = target { + // Attacks happen in descending order of initiative. + let index = attacks.len() - group.initiative as usize; + defender[to].chosen = turn; + attacks[index] = Some((kind, from, to)); + } + } + }; + + // Turn order is important. + immune.sort_unstable_by_key(Group::target_selection_order); + infection.sort_unstable_by_key(Group::target_selection_order); + + target_selection(&immune, &mut infection, Kind::Immune); + target_selection(&infection, &mut immune, Kind::Infection); + + // Attacking phase. + let mut killed = 0; + + for next in &mut attacks { + if let Some((kind, from, to)) = *next { + if kind == Kind::Immune { + killed += immune[from].attack(&mut infection[to]); + } else { + killed += infection[from].attack(&mut immune[to]); + } + *next = None; + } + } + + // It's possible to deadlock if groups become too weak to do any more damage. + if killed == 0 { + return (Kind::Draw, 0); + } + + // Check for winner. + immune.retain(|group| group.units > 0); + infection.retain(|group| group.units > 0); + + if immune.is_empty() { + return (Kind::Infection, infection.iter().map(|group| group.units).sum()); + } + if infection.is_empty() { + return (Kind::Immune, immune.iter().map(|group| group.units).sum()); + } + } + + unreachable!() +} + +/// Parsing the input relatively cleanly is a challenge by itself. +fn parse_group<'a>(input: &'a str, mask: &mut impl FnMut(&'a str) -> u32) -> Vec { + let delimiters = [' ', '(', ')', ',', ';']; + input + .lines() + .skip(1) + .map(|line| { + let tokens: Vec<_> = line.split(delimiters).collect(); + + let units = tokens[0].signed(); + let hit_points = tokens[4].signed(); + let damage = tokens[tokens.len() - 6].signed(); + let initiative = tokens[tokens.len() - 1].signed(); + let attack = mask(tokens[tokens.len() - 5]); + let weak = parse_list(&tokens, "weak", mask); + let immune = parse_list(&tokens, "immune", mask); + let chosen = 0; + + Group { units, hit_points, damage, initiative, weak, immune, attack, chosen } + }) + .collect() +} + +/// There can be any amount of weaknesses or immunities. +fn parse_list<'a>(tokens: &[&'a str], start: &str, mask: &mut impl FnMut(&'a str) -> u32) -> u32 { + let end = ["weak", "immune", "with"]; + let mut elements = 0; + + if let Some(index) = tokens.iter().position(|&t| t == start) { + let mut index = index + 2; + while !end.contains(&tokens[index]) { + elements |= mask(tokens[index]); + index += 1; + } + } + + elements +} diff --git a/tests/test.rs b/tests/test.rs index 00628ac..8e000e8 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -136,6 +136,7 @@ mod year2018 { mod day21_test; mod day22_test; mod day23_test; + mod day24_test; } mod year2019 { diff --git a/tests/year2018/day24_test.rs b/tests/year2018/day24_test.rs new file mode 100644 index 0000000..aef4c4d --- /dev/null +++ b/tests/year2018/day24_test.rs @@ -0,0 +1,22 @@ +use aoc::year2018::day24::*; + +const EXAMPLE: &str = "\ +Immune System: +17 units each with 5390 hit points (weak to radiation, bludgeoning) with an attack that does 4507 fire damage at initiative 2 +989 units each with 1274 hit points (immune to fire; weak to bludgeoning, slashing) with an attack that does 25 slashing damage at initiative 3 + +Infection: +801 units each with 4706 hit points (weak to radiation) with an attack that does 116 bludgeoning damage at initiative 1 +4485 units each with 2961 hit points (immune to radiation; weak to fire, cold) with an attack that does 12 slashing damage at initiative 4"; + +#[test] +fn part1_test() { + let input = parse(EXAMPLE); + assert_eq!(part1(&input), 5216); +} + +#[test] +fn part2_test() { + let input = parse(EXAMPLE); + assert_eq!(part2(&input), 51); +}