AoC 2023 Day 3: Gearinator

Source: Day 3: Gear Ratios

Full solution for today (spoilers!). Note: I did slightly change my solutions template after writing this blog post, so the final solution is structured slightly differently than the code in this post. The functionality itself hasn’t changed.

Part 1

Take as input a 2D grid of numbers or symbols (. represents empty space). Numbers will be 1 or more digits written horizontally which should be concatenated (.467* is the number 467 followed by the symbol *).

Sum all numbers that are adjacent (including diagonally) to at least one symbol.

Okay, first our data structure (including an is_neighbor function I’m sure will be helpful):

#[derive(Debug)]
struct Number {
    value: usize,
    x_min: usize,
    x_max: usize,
    y: usize,
}

impl Number {
    fn is_neighbor(&self, x: usize, y: usize) -> bool {
        x + 1 >= self.x_min && x <= self.x_max && y + 1 >= self.y && y <= self.y + 1
    }
}

#[derive(Debug)]
struct Symbol {
    value: char,
    x: usize,
    y: usize,
}

#[derive(Debug)]
struct Schematic {
    numbers: Vec<Number>,
    symbols: Vec<Symbol>,
}

In this case, the x_min is inclusive and x_max is exclusive, so in the example above of .467*, x_min = 1 and x_max = 4.

Next, a function to read a String into this Schematic:

impl From<String> for Schematic {
    fn from(value: String) -> Self {
        let mut numbers = Vec::new();
        let mut symbols = Vec::new();

        fn finish_number(
            numbers: &mut Vec<Number>,
            digits: &mut String,
            x_min: usize,
            x_max: usize,
            y: usize,
        ) {
            if digits.is_empty() {
                return;
            }

            let value = digits.parse::<usize>().unwrap();
            digits.clear();
            numbers.push(Number {
                value,
                x_min,
                x_max,
                y,
            });
        }

        for (y, line) in value.lines().enumerate() {
            let mut digits = String::new();
            let mut x_min = 0;

            for (x, c) in line.chars().enumerate() {
                if c.is_ascii_digit() {
                    if digits.is_empty() {
                        x_min = x;
                    }
                    digits.push(c);
                } else if c == '.' {
                    finish_number(&mut numbers, &mut digits, x_min, x, y);
                } else {
                    finish_number(&mut numbers, &mut digits, x_min, x, y);
                    symbols.push(Symbol { value: c, x, y });
                }
            }
            finish_number(&mut numbers, &mut digits, x_min, line.len(), y);
        }

        Schematic { numbers, symbols }
    }
}

I wish I could have done a nom parser for this as well, but I’ve real idea how to keep track of row/column in that case, so this will have to do.

One interesting thing here is the finish_number function. Since a number might end with a ., a symbol, or the end of a line, it helps to e4xtract that into a helper function to avoid duplicating code.

Okay, we have the data, so how do we find Numbers adjacent to Symbols?

fn part1(filename: &Path) -> Result<String> {
    let schematic = Schematic::from(read_to_string(filename)?);

    Ok(schematic
        .numbers
        .iter()
        .filter(|n| schematic.symbols.iter().any(|s| n.is_neighbor(s.x, s.y)))
        .map(|n| n.value)
        .sum::<usize>()
        .to_string())
}

I love functional style Rust. :D We literally filter by is_neighbor, map out the value (I could have used a filter_map, but I think in this case, this is more readable) and sum them.

Voila.

Part 2

Find all numbers adjacent to exactly two * Symbols. Multiply the two numbers and sum all of these products.

fn part2(filename: &Path) -> Result<String> {
    let schematic = Schematic::from(read_to_string(filename)?);

    Ok(schematic
        .symbols
        .iter()
        .filter(|s| s.value == '*')
        .map(|s| {
            schematic
                .numbers
                .iter()
                .filter_map(|n| {
                    if n.is_neighbor(s.x, s.y) {
                        Some(n.value)
                    } else {
                        None
                    }
                })
                .collect::<Vec<_>>()
        })
        .filter(|ratios| ratios.len() == 2)
        .map(|ratios| ratios[0] * ratios[1])
        .sum::<usize>()
        .to_string())
}

Not going to lie, it’s a bit funny looking. Basically, we’re going to take the list of symbols, filter out any that aren’t *, map to get the neighboring numbers, filter out any that don’t have exactly two, and map to calculate the product.

Fun!

Performance

$ cargo run --release --bin 03-gearinator 1 data/03.txt

    Finished release [optimized] target(s) in 0.01s
     Running `target/release/03-gearinator 1 data/03.txt`
549908
took 411.292µs

$ cargo run --release --bin 03-gearinator 2 data/03.txt

    Finished release [optimized] target(s) in 0.00s
     Running `target/release/03-gearinator 2 data/03.txt`
81166799
took 509.75µs

Still micros! Onward!