From d8a67cf5159abdba15f7d6f7551d18085b8cc39d Mon Sep 17 00:00:00 2001 From: Russell Champoux Date: Mon, 5 Jan 2026 16:13:55 -0500 Subject: [PATCH] Add Day 12 solution (ft. an unused DLX library) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns out two simple heuristics solve the whole problem in 4ms: 1. Does the area fit? No → invalid 2. Are there enough 3x3 spaces for all presents? Yes → valid But hey, at least we learned how to use Dancing Links for a problem that didn't need it. The dlx crate thanks us for the download stats. --- Cargo.lock | 17 +++ Cargo.toml | 1 + data/examples/12.txt | 34 +++++ src/bin/12.rs | 326 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 data/examples/12.txt create mode 100644 src/bin/12.rs diff --git a/Cargo.lock b/Cargo.lock index 603ddc6..f409972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,7 @@ version = "0.12.0" dependencies = [ "chrono", "dhat", + "dlx", "itertools", "pico-args", "rayon", @@ -45,6 +46,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "autocfg" version = "1.1.0" @@ -154,6 +161,16 @@ dependencies = [ "thousands", ] +[[package]] +name = "dlx" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b53f18c5d4079f2e73765df0516f171cc4d7291a3fdc3a5cbb815178255c1a" +dependencies = [ + "anyhow", + "num-traits", +] + [[package]] name = "either" version = "1.15.0" diff --git a/Cargo.toml b/Cargo.toml index 570ddae..5102b3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ test_lib = [] # Template dependencies chrono = { version = "0.4.38", optional = true } dhat = { version = "0.3.3", optional = true } +dlx = "0.8.5" itertools = "0.14.0" pico-args = "0.5.0" rayon = "1.11.0" diff --git a/data/examples/12.txt b/data/examples/12.txt new file mode 100644 index 0000000..f915eb7 --- /dev/null +++ b/data/examples/12.txt @@ -0,0 +1,34 @@ +0: +### +##. +##. + +1: +### +##. +.## + +2: +.## +### +##. + +3: +##. +### +##. + +4: +### +#.. +### + +5: +### +.#. +### + +4x4: 0 0 0 0 2 0 +12x5: 1 0 1 0 2 2 +12x5: 1 0 1 0 3 2 + diff --git a/src/bin/12.rs b/src/bin/12.rs new file mode 100644 index 0000000..4e494c0 --- /dev/null +++ b/src/bin/12.rs @@ -0,0 +1,326 @@ +advent_of_code::solution!(12); + +use std::collections::HashSet; +use dlx::choose::{mrv_chooser, no_tiebreak, prefer_any}; +use dlx::x::{INodes, ONodes, Problem}; +use dlx::{OptOrder, Solver, Uint}; + +/// Represents a shape as a set of (row, col) offsets from the top-left corner +type Shape = Vec<(i32, i32)>; + +/// Parse a shape from its visual representation +fn parse_shape(lines: &[&str]) -> Shape { + let mut cells = Vec::new(); + for (row, line) in lines.iter().enumerate() { + for (col, ch) in line.chars().enumerate() { + if ch == '#' { + cells.push((row as i32, col as i32)); + } + } + } + cells +} + +/// Normalize a shape so its minimum row and col are 0 +fn normalize(shape: &Shape) -> Shape { + if shape.is_empty() { + return shape.clone(); + } + let min_row = shape.iter().map(|&(r, _)| r).min().unwrap(); + let min_col = shape.iter().map(|&(_, c)| c).min().unwrap(); + shape.iter().map(|&(r, c)| (r - min_row, c - min_col)).collect() +} + +/// Rotate a shape 90 degrees clockwise +fn rotate(shape: &Shape) -> Shape { + // (r, c) -> (c, -r) + normalize(&shape.iter().map(|&(r, c)| (c, -r)).collect()) +} + +/// Flip a shape horizontally +fn flip(shape: &Shape) -> Shape { + // (r, c) -> (r, -c) + normalize(&shape.iter().map(|&(r, c)| (r, -c)).collect()) +} + +/// Get all unique orientations (rotations and flips) of a shape +fn all_orientations(shape: &Shape) -> Vec { + let mut orientations: Vec = Vec::new(); + let mut seen: HashSet> = HashSet::new(); + + let mut current = normalize(shape); + + // Try all 4 rotations + for _ in 0..4 { + let mut sorted = current.clone(); + sorted.sort(); + if seen.insert(sorted) { + orientations.push(current.clone()); + } + current = rotate(¤t); + } + + // Flip and try all 4 rotations + current = flip(shape); + for _ in 0..4 { + let mut sorted = current.clone(); + sorted.sort(); + if seen.insert(sorted) { + orientations.push(current.clone()); + } + current = rotate(¤t); + } + + orientations +} + +/// Parse the input into shapes and regions +fn parse_input(input: &str) -> (Vec>, Vec<(usize, usize, Vec)>) { + let mut shapes: Vec> = Vec::new(); + let mut regions: Vec<(usize, usize, Vec)> = Vec::new(); + + let mut current_shape_lines: Vec<&str> = Vec::new(); + let mut in_shape = false; + + for line in input.lines() { + let line = line.trim(); + + if line.is_empty() { + // Finalize current shape if any + if !current_shape_lines.is_empty() { + let base_shape = parse_shape(¤t_shape_lines); + shapes.push(all_orientations(&base_shape)); + current_shape_lines.clear(); + } + in_shape = false; + continue; + } + + // Check if this is a shape header (e.g., "0:", "1:", etc.) + if line.ends_with(':') && line.len() <= 3 && line.chars().next().unwrap().is_ascii_digit() { + // Finalize previous shape if any + if !current_shape_lines.is_empty() { + let base_shape = parse_shape(¤t_shape_lines); + shapes.push(all_orientations(&base_shape)); + current_shape_lines.clear(); + } + in_shape = true; + continue; + } + + // Check if this is a region line (e.g., "38x36: 46 32 39 35 27 29") + if line.contains('x') && line.contains(':') { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() == 2 && parts[0].contains('x') { + let dims: Vec = parts[0] + .split('x') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + if dims.len() == 2 { + let quantities: Vec = parts[1] + .split_whitespace() + .filter_map(|s| s.parse().ok()) + .collect(); + + regions.push((dims[0], dims[1], quantities)); + continue; + } + } + } + + // Otherwise, it's part of a shape definition + if in_shape { + current_shape_lines.push(line); + } + } + + // Finalize last shape if any + if !current_shape_lines.is_empty() { + let base_shape = parse_shape(¤t_shape_lines); + shapes.push(all_orientations(&base_shape)); + } + + (shapes, regions) +} + +/// Solve using Dancing Links (DLX) algorithm +/// Returns true if all pieces can be placed in the grid +fn can_fit_dlx(shapes: &[Vec], width: usize, height: usize, quantities: &[usize]) -> bool { + // Quick area check first + let total_cells: usize = quantities + .iter() + .enumerate() + .map(|(i, &qty)| { + if i < shapes.len() && !shapes[i].is_empty() { + qty * shapes[i][0].len() + } else { + 0 + } + }) + .sum(); + + let area = width * height; + if total_cells > area { + return false; + } + + // Count total pieces to place + let total_pieces: usize = quantities.iter().sum(); + if total_pieces == 0 { + return true; + } + + // DLX formulation: + // - Primary items (0..total_pieces): one for each piece copy we must place + // - Secondary items (total_pieces..total_pieces+area): grid cells (can be covered 0 or 1 times) + let num_primary = total_pieces; + let num_secondary = area; + let num_items = num_primary + num_secondary; + + // Build piece copy indices for each shape type + // piece_starts[i] = starting index in primary items for shape i + let mut piece_starts: Vec = Vec::with_capacity(quantities.len() + 1); + let mut idx = 0; + for &qty in quantities { + piece_starts.push(idx); + idx += qty; + } + piece_starts.push(idx); + + // Generate all options (placements) + let mut options: Vec> = Vec::new(); + + for (shape_idx, orientations) in shapes.iter().enumerate() { + if shape_idx >= quantities.len() || quantities[shape_idx] == 0 { + continue; + } + + let qty = quantities[shape_idx]; + let piece_start = piece_starts[shape_idx]; + + for orientation in orientations { + // Try all positions + for row in 0..height { + for col in 0..width { + // Check if this placement is valid (all cells within bounds) + let mut cells: Vec = Vec::new(); + let mut valid = true; + + for &(dr, dc) in orientation { + let r = row as i32 + dr; + let c = col as i32 + dc; + + if r < 0 || r >= height as i32 || c < 0 || c >= width as i32 { + valid = false; + break; + } + + // Cell index in secondary items + let cell_idx = num_primary + (r as usize) * width + (c as usize); + cells.push(Uint(cell_idx as u64)); + } + + if !valid { + continue; + } + + // Create options for each copy of this piece type + // Sort cells for consistent ordering + cells.sort(); + + for copy in 0..qty { + let piece_item = Uint((piece_start + copy) as u64); + let mut option = vec![piece_item]; + option.extend(cells.iter().cloned()); + options.push(option); + } + } + } + } + } + + if options.is_empty() { + return total_pieces == 0; + } + + // Create DLX problem + // INodes::new(np, ns) where np = primary items, ns = secondary items + let items = INodes::new(Uint(num_primary as u64), Uint(num_secondary as u64)); + // ONodes::new(n, np, ...) where n = total items, np = number of options + let opts = ONodes::new(Uint(num_items as u64), Uint(options.len() as u64), &options, OptOrder::Seq); + let mut problem = Problem::new(items, opts); + let mut solver = Solver::new(&mut problem); + let mut chooser = mrv_chooser(prefer_any(), no_tiebreak()); + + // Find any solution + solver.next_solution(&mut chooser) +} + +pub fn part_one(input: &str) -> Option { + let (shapes, regions) = parse_input(input); + + let mut count = 0; + + for (_idx, (width, height, quantities)) in regions.iter().enumerate() { + // Calculate total cells needed by all presents + let total_cells: usize = quantities + .iter() + .enumerate() + .map(|(i, &qty)| { + if i < shapes.len() && !shapes[i].is_empty() { + qty * shapes[i][0].len() + } else { + 0 + } + }) + .sum(); + + let area = width * height; + + // Heuristic 1: If total cells needed > area available, region is INVALID + if total_cells > area { + continue; + } + + // Count total number of presents + let total_presents: usize = quantities.iter().sum(); + + // Heuristic 2: If we have >= as many non-overlapping 3x3 spaces as presents, region is VALID + // Since all shapes fit in a 3x3 bounding box, we can definitely place them + let num_3x3_spaces = (width / 3) * (height / 3); + if num_3x3_spaces >= total_presents { + count += 1; + continue; + } + + // For edge cases, run DLX to verify + if can_fit_dlx(&shapes, *width, *height, quantities) { + count += 1; + } + } + + Some(count as u64) +} + +pub fn part_two(input: &str) -> Option { + let _ = input; + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_part_one() { + let result = part_one(&advent_of_code::template::read_file("examples", DAY)); + assert_eq!(result, Some(2)); + } + + #[test] + fn test_part_two() { + let result = part_two(&advent_of_code::template::read_file("examples", DAY)); + assert_eq!(result, None); + } +}