Optimize Days 1-8: 41% faster overall (39ms → 23ms)

This commit is contained in:
Russell Champoux committed 2025-12-09 16:16:10 -05:00
1 parent 6ff1349679
commit b067baac3d
10 files changed
+225 -179

No files matched your search

Generated
+3 -2
View File
@@ -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"
+1
View File
@@ -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
+9 -9
View File
@@ -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**
<!--- benchmarking table --->
---
+2 -4
View File
@@ -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<u64> {
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)
+8 -6
View File
@@ -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<u64> {
continue;
}
// Convert line to digits
let digits: Vec<u8> = 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<u64> {
}
/// 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<u64> {
continue;
}
let digits: Vec<u8> = line
let digits: SmallVec<[u8; 32]> = line
.chars()
.map(|c| c.to_digit(10).unwrap() as u8)
.collect();
+78 -66
View File
@@ -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<Vec<Cell>> {
/// Parse input into a grid of booleans (true = paper)
fn parse_grid(input: &str) -> Vec<Vec<bool>> {
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<Cell>], row: usize, col: usize) -> usize {
let rows = grid.len() as i32;
let cols = grid[0].len() as i32;
let mut count = 0;
/// Get valid neighbors for a position
fn get_neighbors(row: usize, col: usize, rows: usize, cols: usize) -> impl Iterator<Item = (usize, usize)> {
const DELTAS: [(i32, i32); 8] = [
(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1),
];
for dr in -1..=1 {
for dc in -1..=1 {
if dr == 0 && dc == 0 {
continue;
}
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
&& nc >= 0
&& nc < cols
&& grid[nr as usize][nc as usize].is_paper()
{
count += 1;
}
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<Cell>], 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<bool>], 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<u64> {
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<u64> {
pub fn part_two(input: &str) -> Option<u64> {
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<u8>> = 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<bool>> = 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;
}
// If no rolls are accessible, we're done
if to_remove.is_empty() {
break;
}
// Remove this cell
grid[row][col] = false;
total_removed += 1;
// Remove all accessible rolls
for (row, col) in &to_remove {
grid[*row][*col] = Cell::Empty;
// 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;
}
}
}
total_removed += to_remove.len() as u64;
}
Some(total_removed)
+40 -35
View File
@@ -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<FreshRange>, Vec<u64>) {
// 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<FreshRange> = 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<u64> = 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<u64> {
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<FreshRange>) -> 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<FreshRange> = Vec::new();
+11 -6
View File
@@ -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<u64> = (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();
+34 -26
View File
@@ -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<Vec<char>>, usize) {
}
/// Simulate tachyon beams through the manifold and count splits
/// Beams merge when they occupy the same column (HashSet)
/// Uses Vec<bool> 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<usize> = 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<usize> = 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<u64> 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<usize, u64> = 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<usize, u64> = 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<u64> {
+39 -25
View File
@@ -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 {
(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]);
pairs.push((dist, i, j));
}
}
// Parallel sort by distance using rayon
pairs.par_sort_unstable_by_key(|&(dist, _, _)| dist);
(coords, pairs)
(dist, i, j)
})
})
.collect()
}
pub fn part_one(input: &str) -> Option<u64> {
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<u64> {
}
pub fn part_two(input: &str) -> Option<u64> {
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();