From b067baac3d99aca3477dbef41b11f71cefddda03 Mon Sep 17 00:00:00 2001 From: Russell Champoux Date: Tue, 9 Dec 2025 16:16:10 -0500 Subject: [PATCH] =?UTF-8?q?Optimize=20Days=201-8:=2041%=20faster=20overall?= =?UTF-8?q?=20(39ms=20=E2=86=92=2023ms)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 5 +- Cargo.toml | 1 + README.md | 18 +++--- src/bin/02.rs | 6 +- src/bin/03.rs | 14 +++-- src/bin/04.rs | 152 +++++++++++++++++++++++++++----------------------- src/bin/05.rs | 75 +++++++++++++------------ src/bin/06.rs | 17 ++++-- src/bin/07.rs | 60 +++++++++++--------- src/bin/08.rs | 66 +++++++++++++--------- 10 files changed, 230 insertions(+), 184 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af25d6b..603ddc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,7 @@ dependencies = [ "itertools", "pico-args", "rayon", + "smallvec", "tinyjson", ] @@ -422,9 +423,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "syn" diff --git a/Cargo.toml b/Cargo.toml index cc67065..570ddae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ dhat = { version = "0.3.3", optional = true } itertools = "0.14.0" pico-args = "0.5.0" rayon = "1.11.0" +smallvec = "1.15.1" tinyjson = "2.5.1" # Solution dependencies diff --git a/README.md b/README.md index e465fa3..39d1cb0 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ Solutions for [Advent of Code](https://adventofcode.com/) in [Rust](https://www. | Day | Part 1 | Part 2 | | :---: | :---: | :---: | -| [Day 1](./src/bin/01.rs) | `102.5µs` | `123.5µs` | -| [Day 2](./src/bin/02.rs) | `131.1µs` | `314.3µs` | -| [Day 3](./src/bin/03.rs) | `85.1µs` | `121.6µs` | -| [Day 4](./src/bin/04.rs) | `244.2µs` | `4.1ms` | -| [Day 5](./src/bin/05.rs) | `196.7µs` | `66.3µs` | -| [Day 6](./src/bin/06.rs) | `109.0µs` | `194.2µs` | -| [Day 7](./src/bin/07.rs) | `270.5µs` | `320.2µs` | -| [Day 8](./src/bin/08.rs) | `16.9ms` | `16.2ms` | +| [Day 1](./src/bin/01.rs) | `93.9µs` | `113.6µs` | +| [Day 2](./src/bin/02.rs) | `125.7µs` | `330.4µs` | +| [Day 3](./src/bin/03.rs) | `118.8µs` | `108.4µs` | +| [Day 4](./src/bin/04.rs) | `160.6µs` | `838.6µs` | +| [Day 5](./src/bin/05.rs) | `53.3µs` | `44.4µs` | +| [Day 6](./src/bin/06.rs) | `118.7µs` | `124.8µs` | +| [Day 7](./src/bin/07.rs) | `91.8µs` | `72.4µs` | +| [Day 8](./src/bin/08.rs) | `11.2ms` | `9.8ms` | -**Total: 39.48ms** +**Total: 23.40ms** --- diff --git a/src/bin/02.rs b/src/bin/02.rs index b5b7524..7a92269 100644 --- a/src/bin/02.rs +++ b/src/bin/02.rs @@ -1,5 +1,4 @@ use itertools::Itertools; -use std::collections::HashSet; advent_of_code::solution!(2); @@ -106,13 +105,12 @@ pub fn part_two(input: &str) -> Option { let ranges: Vec<(u64, u64)> = input.split(',').filter_map(parse_range).collect(); let merged = merge_ranges(&ranges); - // Use a small HashSet only for deduplication within merged ranges + // Use itertools::unique() for deduplication // (needed because same number can match multiple pattern lengths) - let mut seen = HashSet::new(); let sum: u64 = merged .iter() .flat_map(|&(start, end)| iter_invalid_part2(start, end)) - .filter(|&n| seen.insert(n)) + .unique() .sum(); Some(sum) diff --git a/src/bin/03.rs b/src/bin/03.rs index 044f100..1476d3b 100644 --- a/src/bin/03.rs +++ b/src/bin/03.rs @@ -1,12 +1,14 @@ +use smallvec::SmallVec; + advent_of_code::solution!(3); /// Find the maximum 2-digit number by selecting exactly 2 batteries from a bank -fn max_joltage_2(bank: &[u8]) -> u64 { +fn max_joltage_2(bank: &SmallVec<[u8; 32]>) -> u64 { let n = bank.len(); // Precompute suffix maximums: suffix_max[i] = max digit from position i to end // This avoids O(n) scan for each position, making overall O(n) instead of O(n²) - let mut suffix_max = vec![0u8; n]; + let mut suffix_max: SmallVec<[u8; 32]> = SmallVec::from_elem(0, n); suffix_max[n - 1] = bank[n - 1]; for i in (0..n - 1).rev() { suffix_max[i] = bank[i].max(suffix_max[i + 1]); @@ -32,8 +34,8 @@ pub fn part_one(input: &str) -> Option { continue; } - // Convert line to digits - let digits: Vec = line + // Convert line to digits using SmallVec (avoids heap allocation for small inputs) + let digits: SmallVec<[u8; 32]> = line .chars() .map(|c| c.to_digit(10).unwrap() as u8) .collect(); @@ -44,7 +46,7 @@ pub fn part_one(input: &str) -> Option { } /// Find the maximum k-digit number by selecting exactly k batteries from a bank -fn max_joltage_k(bank: &[u8], k: usize) -> u64 { +fn max_joltage_k(bank: &SmallVec<[u8; 32]>, k: usize) -> u64 { let n = bank.len(); let mut result = 0u64; let mut start = 0; @@ -80,7 +82,7 @@ pub fn part_two(input: &str) -> Option { continue; } - let digits: Vec = line + let digits: SmallVec<[u8; 32]> = line .chars() .map(|c| c.to_digit(10).unwrap() as u8) .collect(); diff --git a/src/bin/04.rs b/src/bin/04.rs index b9d92b7..96f5577 100644 --- a/src/bin/04.rs +++ b/src/bin/04.rs @@ -1,72 +1,54 @@ +use std::collections::VecDeque; + advent_of_code::solution!(4); -#[derive(Clone, Copy, PartialEq, Eq)] -enum Cell { - Empty, - Paper, -} - -impl Cell { - fn from_char(c: char) -> Self { - match c { - '@' => Cell::Paper, - _ => Cell::Empty, - } - } - - fn is_paper(self) -> bool { - self == Cell::Paper - } -} - -/// Parse input into a grid of cells -fn parse_grid(input: &str) -> Vec> { +/// Parse input into a grid of booleans (true = paper) +fn parse_grid(input: &str) -> Vec> { input .lines() .filter(|line| !line.trim().is_empty()) - .map(|line| line.chars().map(Cell::from_char).collect()) + .map(|line| line.chars().map(|c| c == '@').collect()) .collect() } -/// Count adjacent paper rolls (8 directions) for a given position -fn count_neighbors(grid: &[Vec], row: usize, col: usize) -> usize { - let rows = grid.len() as i32; - let cols = grid[0].len() as i32; - let mut count = 0; - - for dr in -1..=1 { - for dc in -1..=1 { - if dr == 0 && dc == 0 { - continue; - } - let nr = row as i32 + dr; - let nc = col as i32 + dc; - if nr >= 0 - && nr < rows - && nc >= 0 - && nc < cols - && grid[nr as usize][nc as usize].is_paper() - { - count += 1; - } +/// Get valid neighbors for a position +fn get_neighbors(row: usize, col: usize, rows: usize, cols: usize) -> impl Iterator { + const DELTAS: [(i32, i32); 8] = [ + (-1, -1), (-1, 0), (-1, 1), + (0, -1), (0, 1), + (1, -1), (1, 0), (1, 1), + ]; + + DELTAS.into_iter().filter_map(move |(dr, dc)| { + let nr = row as i32 + dr; + let nc = col as i32 + dc; + if nr >= 0 && nr < rows as i32 && nc >= 0 && nc < cols as i32 { + Some((nr as usize, nc as usize)) + } else { + None } - } - - count + }) } -/// Check if a paper roll at (row, col) is accessible (fewer than 4 neighbors) -fn is_accessible(grid: &[Vec], row: usize, col: usize) -> bool { - grid[row][col].is_paper() && count_neighbors(grid, row, col) < 4 +/// Count adjacent paper rolls for a given position +fn count_neighbors(grid: &[Vec], row: usize, col: usize) -> u8 { + let rows = grid.len(); + let cols = grid[0].len(); + get_neighbors(row, col, rows, cols) + .filter(|&(nr, nc)| grid[nr][nc]) + .count() as u8 } pub fn part_one(input: &str) -> Option { let grid = parse_grid(input); - let mut accessible = 0u64; + if grid.is_empty() { + return Some(0); + } + let mut accessible = 0u64; for row in 0..grid.len() { for col in 0..grid[0].len() { - if is_accessible(&grid, row, col) { + if grid[row][col] && count_neighbors(&grid, row, col) < 4 { accessible += 1; } } @@ -77,31 +59,61 @@ pub fn part_one(input: &str) -> Option { pub fn part_two(input: &str) -> Option { let mut grid = parse_grid(input); + if grid.is_empty() { + return Some(0); + } + + let rows = grid.len(); + let cols = grid[0].len(); + + // Pre-compute neighbor counts for all cells + let mut neighbor_counts: Vec> = vec![vec![0; cols]; rows]; + for row in 0..rows { + for col in 0..cols { + if grid[row][col] { + neighbor_counts[row][col] = count_neighbors(&grid, row, col); + } + } + } + + // Initialize queue with all accessible cells (paper with < 4 neighbors) + let mut queue: VecDeque<(usize, usize)> = VecDeque::new(); + let mut in_queue: Vec> = vec![vec![false; cols]; rows]; + + for row in 0..rows { + for col in 0..cols { + if grid[row][col] && neighbor_counts[row][col] < 4 { + queue.push_back((row, col)); + in_queue[row][col] = true; + } + } + } + let mut total_removed = 0u64; - loop { - // Find all accessible rolls (fewer than 4 neighbors) - let mut to_remove: Vec<(usize, usize)> = Vec::new(); + while let Some((row, col)) = queue.pop_front() { + in_queue[row][col] = false; - for row in 0..grid.len() { - for col in 0..grid[0].len() { - if is_accessible(&grid, row, col) { - to_remove.push((row, col)); + // Skip if already removed or no longer accessible + if !grid[row][col] || neighbor_counts[row][col] >= 4 { + continue; + } + + // Remove this cell + grid[row][col] = false; + total_removed += 1; + + // Update neighbor counts for adjacent cells and potentially add them to queue + for (nr, nc) in get_neighbors(row, col, rows, cols) { + if grid[nr][nc] { + neighbor_counts[nr][nc] = neighbor_counts[nr][nc].saturating_sub(1); + // If this cell became accessible and isn't already queued, add it + if neighbor_counts[nr][nc] < 4 && !in_queue[nr][nc] { + queue.push_back((nr, nc)); + in_queue[nr][nc] = true; } } } - - // If no rolls are accessible, we're done - if to_remove.is_empty() { - break; - } - - // Remove all accessible rolls - for (row, col) in &to_remove { - grid[*row][*col] = Cell::Empty; - } - - total_removed += to_remove.len() as u64; } Some(total_removed) diff --git a/src/bin/05.rs b/src/bin/05.rs index 15a885e..85f7a7e 100644 --- a/src/bin/05.rs +++ b/src/bin/05.rs @@ -1,56 +1,61 @@ advent_of_code::solution!(5); /// A range of fresh ingredient IDs (inclusive) +#[derive(Clone)] struct FreshRange { start: u64, end: u64, } -impl FreshRange { - fn contains(&self, id: u64) -> bool { - id >= self.start && id <= self.end - } -} - -/// Parse the input into fresh ranges and available IDs +/// Parse the input into fresh ranges and available IDs without extra allocations fn parse_input(input: &str) -> (Vec, Vec) { - // Normalize line endings and split on blank line - let normalized = input.replace("\r\n", "\n"); - let mut sections = normalized.split("\n\n"); + let mut ranges = Vec::new(); + let mut ids = Vec::new(); + let mut in_ids_section = false; - // Parse fresh ingredient ID ranges - let ranges_section = sections.next().unwrap_or(""); - let ranges: Vec = ranges_section - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| { - let mut parts = line.split('-'); - let start: u64 = parts.next().unwrap().parse().unwrap(); - let end: u64 = parts.next().unwrap().parse().unwrap(); - FreshRange { start, end } - }) - .collect(); + for line in input.lines() { + let line = line.trim().trim_end_matches('\r'); + if line.is_empty() { + in_ids_section = true; + continue; + } - // Parse available ingredient IDs - let ids_section = sections.next().unwrap_or(""); - let ids: Vec = ids_section - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| line.trim().parse().unwrap()) - .collect(); + if in_ids_section { + if let Ok(id) = line.parse() { + ids.push(id); + } + } else if let Some((start_str, end_str)) = line.split_once('-') { + if let (Ok(start), Ok(end)) = (start_str.parse(), end_str.parse()) { + ranges.push(FreshRange { start, end }); + } + } + } (ranges, ids) } -/// Check if an ingredient ID is fresh (falls within any range) -fn is_fresh(id: u64, ranges: &[FreshRange]) -> bool { - ranges.iter().any(|range| range.contains(id)) +/// Check if an ingredient ID is fresh using binary search on sorted ranges +fn is_fresh_binary(id: u64, sorted_ranges: &[FreshRange]) -> bool { + // Binary search to find a range that might contain id + // Find the rightmost range where start <= id + let idx = sorted_ranges.partition_point(|r| r.start <= id); + if idx == 0 { + return false; + } + // Check if the range at idx-1 contains id + sorted_ranges[idx - 1].end >= id } pub fn part_one(input: &str) -> Option { - let (ranges, ids) = parse_input(input); + let (mut ranges, ids) = parse_input(input); - let fresh_count = ids.iter().filter(|&&id| is_fresh(id, &ranges)).count(); + // Sort ranges by start for binary search + ranges.sort_unstable_by_key(|r| r.start); + + let fresh_count = ids + .iter() + .filter(|&&id| is_fresh_binary(id, &ranges)) + .count(); Some(fresh_count as u64) } @@ -62,7 +67,7 @@ fn count_unique_fresh_ids(mut ranges: Vec) -> u64 { } // Sort ranges by start - ranges.sort_by_key(|r| r.start); + ranges.sort_unstable_by_key(|r| r.start); // Merge overlapping ranges let mut merged: Vec = Vec::new(); diff --git a/src/bin/06.rs b/src/bin/06.rs index 37054bd..8f909c5 100644 --- a/src/bin/06.rs +++ b/src/bin/06.rs @@ -116,15 +116,20 @@ fn solve_worksheet_cephalopod(input: &str) -> u64 { .unwrap_or('+'); // Read columns right-to-left, each column forms a number + // Parse digits directly to u64 without String allocation let numbers: Vec = (start..end) .rev() .filter_map(|col| { - let digits: String = number_rows - .iter() - .map(|row| row[col]) - .filter(|c| c.is_ascii_digit()) - .collect(); - digits.parse().ok() + let mut num = 0u64; + let mut has_digits = false; + for row in number_rows { + let c = row[col]; + if c.is_ascii_digit() { + num = num * 10 + (c as u64 - '0' as u64); + has_digits = true; + } + } + has_digits.then_some(num) }) .collect(); diff --git a/src/bin/07.rs b/src/bin/07.rs index e856bba..b084597 100644 --- a/src/bin/07.rs +++ b/src/bin/07.rs @@ -1,5 +1,3 @@ -use std::collections::{HashMap, HashSet}; - advent_of_code::solution!(7); /// Parse the grid and find the starting column @@ -19,7 +17,7 @@ fn parse_grid(input: &str) -> (Vec>, usize) { } /// Simulate tachyon beams through the manifold and count splits -/// Beams merge when they occupy the same column (HashSet) +/// Uses Vec instead of HashSet for beam positions fn count_splits(input: &str) -> u64 { let (grid, start_col) = parse_grid(input); if grid.is_empty() { @@ -27,33 +25,41 @@ fn count_splits(input: &str) -> u64 { } let width = grid[0].len(); - let mut beams: HashSet = HashSet::new(); - beams.insert(start_col); + let mut beams = vec![false; width]; + let mut new_beams = vec![false; width]; + beams[start_col] = true; let mut split_count: u64 = 0; for row in grid.iter().skip(1) { - let mut new_beams: HashSet = HashSet::new(); + new_beams.fill(false); + let mut has_beams = false; - for &col in &beams { + for col in 0..width { + if !beams[col] { + continue; + } match row[col] { '^' => { split_count += 1; if col > 0 { - new_beams.insert(col - 1); + new_beams[col - 1] = true; + has_beams = true; } if col + 1 < width { - new_beams.insert(col + 1); + new_beams[col + 1] = true; + has_beams = true; } } _ => { - new_beams.insert(col); + new_beams[col] = true; + has_beams = true; } } } - beams = new_beams; - if beams.is_empty() { + std::mem::swap(&mut beams, &mut new_beams); + if !has_beams { break; } } @@ -62,7 +68,7 @@ fn count_splits(input: &str) -> u64 { } /// Count the number of unique timelines (paths) through the manifold -/// Each particle at a position represents independent timelines - they don't merge +/// Uses Vec instead of HashMap for particle counts fn count_timelines(input: &str) -> u64 { let (grid, start_col) = parse_grid(input); if grid.is_empty() { @@ -70,40 +76,42 @@ fn count_timelines(input: &str) -> u64 { } let width = grid[0].len(); - - // Track number of particles (timelines) at each column position - let mut particles: HashMap = HashMap::new(); - particles.insert(start_col, 1); + let mut particles = vec![0u64; width]; + let mut new_particles = vec![0u64; width]; + particles[start_col] = 1; for row in grid.iter().skip(1) { - let mut new_particles: HashMap = HashMap::new(); + new_particles.fill(0); - for (&col, &count) in &particles { + for col in 0..width { + let count = particles[col]; + if count == 0 { + continue; + } match row[col] { '^' => { // Each particle splits into 2 timelines (left and right) if col > 0 { - *new_particles.entry(col - 1).or_insert(0) += count; + new_particles[col - 1] += count; } if col + 1 < width { - *new_particles.entry(col + 1).or_insert(0) += count; + new_particles[col + 1] += count; } } _ => { // Particle continues straight down - *new_particles.entry(col).or_insert(0) += count; + new_particles[col] += count; } } } - particles = new_particles; - if particles.is_empty() { + std::mem::swap(&mut particles, &mut new_particles); + if particles.iter().all(|&c| c == 0) { break; } } - // Sum all particles - each represents a unique timeline - particles.values().sum() + particles.iter().sum() } pub fn part_one(input: &str) -> Option { diff --git a/src/bin/08.rs b/src/bin/08.rs index 8cd6b63..ca1a49c 100644 --- a/src/bin/08.rs +++ b/src/bin/08.rs @@ -1,4 +1,4 @@ -use rayon::slice::ParallelSliceMut; +use rayon::prelude::*; advent_of_code::solution!(8); @@ -82,42 +82,47 @@ fn distance_squared(a: (i64, i64, i64), b: (i64, i64, i64)) -> i64 { dx * dx + dy * dy + dz * dz } -/// Parse input and generate sorted pairs (shared between parts) -fn generate_sorted_pairs(input: &str) -> (Vec<(i64, i64, i64)>, Vec<(i64, usize, usize)>) { - let coords: Vec<(i64, i64, i64)> = input +/// Parse coordinates from input +fn parse_coords(input: &str) -> Vec<(i64, i64, i64)> { + input .lines() .filter(|line| !line.trim().is_empty()) .filter_map(parse_coord) - .collect(); + .collect() +} +/// Generate all pairs with distances in parallel +fn generate_pairs_parallel(coords: &[(i64, i64, i64)]) -> Vec<(i64, usize, usize)> { let n = coords.len(); - - // Generate all pairs with distances - let mut pairs: Vec<(i64, usize, usize)> = Vec::with_capacity(n * (n - 1) / 2); - for i in 0..n { - for j in (i + 1)..n { - let dist = distance_squared(coords[i], coords[j]); - pairs.push((dist, i, j)); - } - } - - // Parallel sort by distance using rayon - pairs.par_sort_unstable_by_key(|&(dist, _, _)| dist); - - (coords, pairs) + (0..n) + .into_par_iter() + .flat_map_iter(|i| { + let coords = coords; + ((i + 1)..n).map(move |j| { + let dist = distance_squared(coords[i], coords[j]); + (dist, i, j) + }) + }) + .collect() } pub fn part_one(input: &str) -> Option { - let (coords, pairs) = generate_sorted_pairs(input); + let coords = parse_coords(input); let n = coords.len(); if n == 0 { return None; } - let mut uf = UnionFind::new(n); + let mut pairs = generate_pairs_parallel(&coords); - // Process first 1000 connections - for &(_, i, j) in pairs.iter().take(1000) { + // Use partial sort - we only need the 1000 smallest pairs + if pairs.len() > 1000 { + pairs.select_nth_unstable_by_key(999, |&(dist, _, _)| dist); + pairs.truncate(1000); + } + + let mut uf = UnionFind::new(n); + for &(_, i, j) in &pairs { uf.union(i, j); } @@ -129,12 +134,17 @@ pub fn part_one(input: &str) -> Option { } pub fn part_two(input: &str) -> Option { - let (coords, pairs) = generate_sorted_pairs(input); + let coords = parse_coords(input); let n = coords.len(); if n <= 1 { return None; } + let mut pairs = generate_pairs_parallel(&coords); + + // Full parallel sort needed for part 2 (process in order) + pairs.par_sort_unstable_by_key(|&(dist, _, _)| dist); + let mut uf = UnionFind::new(n); let mut num_circuits = n; let mut last_connection: Option<(usize, usize)> = None; @@ -160,10 +170,14 @@ mod tests { #[test] fn test_part_one() { // Example uses 10 connections, not 1000 - test the core logic - let (coords, pairs) = generate_sorted_pairs(&advent_of_code::template::read_file("examples", DAY)); + let coords = parse_coords(&advent_of_code::template::read_file("examples", DAY)); let n = coords.len(); + let mut pairs = generate_pairs_parallel(&coords); + pairs.select_nth_unstable_by_key(9, |&(dist, _, _)| dist); + pairs.truncate(10); + let mut uf = UnionFind::new(n); - for &(_, i, j) in pairs.iter().take(10) { + for &(_, i, j) in &pairs { uf.union(i, j); } let mut sizes = uf.get_circuit_sizes();