Florentin Putz December 2024

Advent of Code 2024, using Rust

The Advent of Code is an annual programming competition with fun problems for each day in December leading up to Christmas. In 2024, I teamed up again with some colleagues and friends to participate. In the previous years, I have enjoyed using J (2021) and Clojure (2022), but this year I wanted to try something else and use the puzzles as an opportunity to learn Rust.

Rust

Rust is a compiled and strongly-typed programming language with a focus on performance and memory safety. Compared to C and C++, Rust enforces memory safety using the ownership system, which tracks references at compile time and automatically frees variables when the owner goes out of scope, without requiring a garbage collector. Rust also has some functional elements, for example you can operate on iterators using the classics like map, filter, and fold.

My goals for this AoC

Besides my obvious goal of learning Rust, I also arranged an informal competition with friends and colleagues. It was motivating to brainstorm about each day’s puzzle and discuss our individual approaches after having solved the puzzle. Every year, we have an automatic benchmark system that builds and runs each of our solutions directly from our shared repository using GitHub Runners and the benchmarking tool hyperfine.

Rust is a good candidate for writing fast code, but I gave myself the constraint of trying to come up with elegant and concise code, and only as a secondary goal optimize for performance. Sometimes I could have written faster code using unsafe or packing multiple bits into single values, but at the cost of many unreadable lines of code. So I decided against that, instead trying to go for maintainable and compact code where possible.

This year, five others also used Rust, one tried C, another went for C++, three used Python, and two tried out Zig.

From a performance perspective, Zig often came out ahead with a slight lead before Rust. C and C++ where usually a bit slower and the Python solutions were usually the slowest. From a readability perspective though, Python had some really elegant and concise solutions. But I was also surprised how well Rust fared in that regard.

Initial reservations about Rust

In the days before AoC 2024, I was thinking about which programming language to use. There were a few languages I wanted to learn more about. Among them was Rust, although the last time I’ve used it, Rust felt very verbose and cumbersome to program in. But I decided to still give it a fair chance and see how I would like it.

Performance optimization in Rust

It turns out, Rust can be more concise and elegant than I thought. Also from a performance standpoint, I quickly learned which patterns to avoid by regularly benchmarking my code. For simple benchmarking, I used Rust’s built-in benchmarking tests, which unfortunately only work on nightly Rust. It felt a bit weird that after only one hour of Rust programming, I already had to switch to nightly on day one. But that wasn’t really a problem – everything worked just fine on nightly throughout the whole AoC.

For more detailed performance tuning, I used samply as a sampling profiler to view a flamegraph of my code, which helped to indentify time-consuming function calls.

For most days, I managed to improve the runtime of my initial implementation from hundreds of milliseconds down to tens of microseconds. I quickly learned which patterns to avoid and made the following observations:

  1. Avoid memory allocations. Especially in loops. I often had the most significant performance improvements by allocating a vector up front and then reusing this vector, instead of always allocating a new vector (e.g., day 5).
  2. Memoize expensive functions using a HashMap. Caching results significantly improved performance, especially for the dynamic programming solutions (e.g., day 11).
  3. Use FxHashMap instead of HashMap. Rust’s built-in hash function is optimized for collision resistance, but is comparatively slow. The rustc-hash crate provides a drop-in replacement for HashMap and HashSet with a much faster hash function, whose cryptographical properties are totally fine for Advent of Code (e.g., day 10).
  4. When the state space is small, use a fixed-size array instead of a HashSet. For example, when you know there are only 1000 possible keys in your HashSet, it is faster to allocate an array with 1000 elements and access this instead of the HashSet (e.g., day 19).
  5. Identify hot functions and optimize them first. Some functions are called more often than others, and these have the highest impact on your overall performance. If a function is only called once at the beginning of your program, it is not as important compared to a function that gets called a million times within a loop (e.g., function concat on day 7).

For my build configuration, I used a release build with the following settings, which seem to be best practice for performance:

[profile.release]
codegen-units = 1
lto = "fat"

Advent of Code

Check out the problem descriptions for each day on the AoC website. Each of my solutions will print two numbers, corresponding to the two parts of the problem. My solutions are also available on GitHub.

For each day, I wrote a function advent with a function signature like this:

fn advent(filename: &str) -> (u32, u32)

It returns a tuple with the answers for part one and two. They don’t have to be u32 or even integers, it can also be something like a string. I calculate both parts in a single function, because often the solution for part two reuses or extends some aspect of part one.

At the end of each day’s solution, I had the following code block, which included tests for both parts, and for both the example and final inputs. This allowed me to easily find regressions where my solution no longer works when optimizing the performance.

#[cfg(test)]
mod tests {
    use super::*;
    use test::Bencher;
    #[test] fn example_input() { dbg!(advent("example.txt")); }
    #[test] fn final_input() { dbg!(advent("input.txt")); }
    #[test] fn ex1() { assert_eq!(advent("example.txt").0, 11) }
    #[test] fn final1() { assert_eq!(advent("input.txt").0, 1666427) }
    #[test] fn ex2() { assert_eq!(advent("example.txt").1, 31) }
    #[test] fn final2() { assert_eq!(advent("input.txt").1, 24316233) }

    #[bench] fn bench_advent(b: &mut Bencher) { b.iter(|| advent("input.txt")); }
}

My IDE allowed me to easily run each test with a single mouse click. The last function is a benchmarking function which benchmarks both parts on the final input and shows the average wall clock runtime as well as the sample standard deviation. To run the tests yourself, you need to save the inputs from the AoC website into example.txt and input.txt.

Day 1

All AoC problems basically involve two steps:

  • Parsing the input file.
  • Solving the problem.

For parsing, I wrote a utility function extract_numbers, which returns an iterator that yields all numbers in a string. This will be very useful also for the upcoming days.

The actual solution involves sorting the two vectors. I initially used Rust’s sort function, but then I learned about sort_unstable, which is faster at the cost of potentially reordering equal elements (which we do not care about).

// Expected number of lines (but also works if more lines)
const N: usize = 1000;

fn advent(filename: &str) -> (u32, u32) {
    let contents = read_to_string(filename)
        .expect("Something went wrong reading the input file");

    let (mut part1, mut part2) = (0, 0);
    let mut left: Vec<u32> = Vec::with_capacity(N);
    let mut right: Vec<u32> = Vec::with_capacity(N);

    for (a,b) in extract_numbers::<u32>(&contents).tuples() {
        // Destructure a line like
        //     123   456
        // into the variables a=123 and b=456
        left.push(a);
        right.push(b);
    }

    assert_eq!(left.len(), right.len());
    left.sort_unstable();
    right.sort_unstable();

    for i in 0..left.len() {
        part1 += left[i].abs_diff(right[i]);
        let occurrences = right.iter().filter(|&&x| x == left[i]).count() as u32;
        part2 += left[i] * occurrences;
    }

    (part1,part2)
}

Day 2

For part two, we have to check something on a vector and repeat that check when removing exactly one element from the vector. Initially, I allocated a new vector for each removed element, but then I looked deeper into iterators and it was much faster to work on iterators that skip over exactly one element each.

fn advent(filename: &str) -> (u32, u32) {
    let contents = read_to_string(filename)
        .expect("Something went wrong reading the input file");
    let (mut part1, mut part2) = (0,0);

    for line in contents.lines() {
        let report = extract_numbers::<i32>(line).collect_vec();

        // Part 1: Check if the levels are safe
        if is_safe_report(report.iter().cloned()) {
            part1 += 1;
        }

        // Part 2: Check if the levels are safe, including when removing one level
        for i in 0..report.len() {
            // Create an iterator that skips over one level
            let skipped_iter = report.iter().enumerate()
                .filter_map(|(idx, &val)| if idx != i { Some(val) } else { None });

            if is_safe_report(skipped_iter) {
                part2 += 1;
                break; // Break once we find one level to remove safely
            }
        }
    }

    (part1, part2)
}

// Checks whether a report contains only safe levels.
// 
// The argument is an iterator over the levels to consider.
// 
// A report counts as safe if both of the following are true:
//  - The levels are either all increasing or all decreasing.
//  - Any two adjacent levels differ by at least one and at most three.
fn is_safe_report<I>(iter: I) -> bool
where
    I: Iterator<Item = i32> + Clone,
{
    // Differences between adjacent levels in the report
    let mut diffs = iter.tuple_windows().map(|(x, y)| y - x);
    // Check the safety condition
    diffs.clone().all(|x| x >=  1 && x <=  3)
         || diffs.all(|x| x >= -3 && x <= -1)
}

Day 3

Initially, I used a regex to parse the string according to the rules, which worked fine but was quite slow. I was able to significantly improve the performance using standard string operations (mostly split). I also learned about function pointers and wrote my own higher-order function between_sums which takes a function as an argument.

fn advent(filename: &str) -> (u32, u32) {
    let mem = read_to_string(filename)
        .expect("Something went wrong reading the input file");
    let part1 = muls_and_sum(&mem);
    let part2 = between_sums(&mem, "do()", "don't()", muls_and_sum);
    (part1, part2)
}

// Multiplies all occurences of "mul(a,b)" (= a*b) in [text] and sums them.
//
// Non-digits within the "mul(...)"" get correctly ignored.
// But "mul(a)" or "mul(a,b,c)" are not discarded.
//
// Note: If the beginning of [text] up until the first "mul(" is a number,
// or a comma separated list of numbers, these get multiplied and added to the result.
// Fortunately, this does not happen in my AoC input.
fn muls_and_sum(text: &str) -> u32 {
    between_sums(text, "mul(", ")",
        |s| s.split(",")
             // ignore non-digits in mul() instructions
             .map(|num| num.parse::<u32>().unwrap_or(0))
             .product::<u32>())
}

// Maps a function [f] on all substrings in [text] between sequential
// occurrences of [from] and [to]. Afterwards it sums the results.
// It also applies [f] to the beginning of the [text] up until the
// first occurence of [from].
// 
// Example:
//   Inputs: text = "(abc)def(ghi)j(klm)", from = "(", to = ")"
//   Applies [f] to "", "abc", "ghi", and "klm", and sums the results.
fn between_sums(text: &str, from: &str, to: &str, f: impl Fn(&str) -> u32) -> u32 {
    text.split(from).filter_map(|s| s.split(to).next()).map(f).sum()
}

Day 4

This is the first problem in 2024 where the input is a 2D matrix. As I expected many more tasks like that, I wrote some utility functions for parsing a file as a 2D matrix and indexing elements in the matrix. Initially, I used a 2D vector as a data structure, but indexing a 1D vector is much faster. The interface of my Matrix struct abstracts the underlying 1D vector away and provides useful operations such as array indexing (e.g., matrix[coordinate] = b'.').

const MAS: [u8; 3] = [b'M', b'A', b'S'];
const SAM: [u8; 3] = [b'S', b'A', b'M'];

fn advent(filename: &str) -> (u32, u32) {
    let map = Matrix::from_bytes(&read(filename).expect("could not read input file"));
    let (mut part1, mut part2) = (0,0);

    for r in 0..map.height {
        for c in 0..map.width {
            let p = Point::new(r,c);
            // Summing directions for part1
            for &dir in &[LEFT, RIGHT, UP, DOWN, NORTHEAST, NORTHWEST, SOUTHEAST, SOUTHWEST] {
                part1 += direction_contains_word(&map, p, dir, "XMAS") as u32;
            }
            // Check mas function for part2
            part2 += mas_pattern(&map, p) as u32;
        }
    }

    (part1, part2)
}

fn direction_contains_word(grid: &Matrix<u8>, p: Point, dir: Point, word: &str) -> bool {
    let mut chars = word.chars();
    (0..word.len() as i32).all(|i| {
        grid.get(p + dir*i).is_some_and(|&c| c == chars.next().unwrap() as u8)
    })
}

fn mas_pattern(grid: &Matrix<u8>, p: Point) -> bool {
    if !(grid.contains(p+SOUTHEAST) && grid.contains(p+NORTHWEST)) {return false;}

    let forward = [
        grid[p+SOUTHWEST],
        grid[p],
        grid[p+NORTHEAST],
    ];
    let backward = [
        grid[p+NORTHWEST],
        grid[p],
        grid[p+SOUTHEAST],
    ];
    (forward == MAS || forward == SAM) && (backward == MAS || backward == SAM)
}

Day 5

For this day I learned about using custom orderings for Rust’s built-in sorting functions. This day is a good example of how clearing and reusing an allocated vector improved the performance.

const MAX_NUMBER: usize = 99;

fn advent(filename: &str) -> (u32, u32) {
    let input = read_to_string(filename).expect("could not read input file");
    let (rules, updates) = input.split_once("\n\n").expect("invalid input format");
    let (mut part1, mut part2) = (0, 0);

    // The 2D array [page_before] defines the order of the pages.
    // page_before[A][B] is true if page A should be printed before page B.
    // Otherwise it is false.
    //
    // The input seems to contain N different numbers between 11 and 99.
    // To define the order between every pair we need a rule for every subset of size 2.
    // For the example input, we have 7 different numbers, and indeed have C(7,2) = 21 rules.
    // As every rule is specified, we can safely assign a default value.
    // 
    // The rule format does not allow for equal order, therefore there are only two
    // possible orders (before/after).
    let mut page_before = [[false; MAX_NUMBER]; MAX_NUMBER];
    for (a,b) in extract_numbers::<usize>(&rules).tuples() {
        page_before[a][b] = true; // the other direction [b][a] is false by default
    }

    let mut update_buf = Vec::new();
    for update_line in updates.lines() {
        // Reuse a vector to avoid repeated memory allocations (~30 µs)
        update_buf.clear();
        update_buf.extend(extract_numbers::<usize>(&update_line));

        if update_buf.is_sorted_by(|&a, &b| page_before[a][b]) {
            part1 += update_buf[update_buf.len() / 2];
        } else {
            update_buf.sort_unstable_by(|&a, &b| if page_before[a][b] {Ordering::Less} else {Ordering::Greater});
            part2 += update_buf[update_buf.len() / 2];
        }
    }

    (part1 as u32, part2 as u32)
}

Day 6

Again a task with a 2D Matrix as input. Writing the utility function already payed itself off. I also used a

const WIDTH: usize = 130; // maximum width
const HEIGHT: usize = WIDTH;
type Generation = u16;

pub fn advent(filename: &str) -> (u32, u32) {
    let mut map =  Matrix::from_bytes(&read(filename).expect("could not read input file"));
    assert_eq!(map.height, map.width);
    assert!(WIDTH>=map.width as usize);
    
    let mut pos = map.find(b'^').expect("no start position found");
    let mut dir = Direction::Up;
    let mut gen: Generation = 0;

    // This array keeps track of map positions + directions that have been visited before.
    // Initialize all to zero. We will store generation numbers here, which get incremented for each
    // separate cycle check.
    let mut visited: [Generation; WIDTH * HEIGHT * 4] = [gen; WIDTH * HEIGHT * 4];

    let (mut part1, mut part2) = (1,0);

    while let Some(next_cell) = map.get(pos + dir.to_point()) {
        match next_cell {
            b'#' => dir = dir.clockwise(),
            b'.' => {
                let next = pos + dir.to_point();
                part1 += 1;

                map[next] = b'#';
                gen+=1;
                if is_cyclic(&map, &mut visited, gen, pos, dir) {
                    part2 += 1;
                }
                map[next] = b'^';
                pos = next;
            },
            b'^' => pos = pos + dir.to_point(),
            _ => break,
        }
    }
    
    (part1, part2)
}

// Check if continuing on this path results in a loop.
// We have a loop if we visit a previosuly visited tile.
#[inline]
fn is_cyclic(map: &Matrix<u8>, visited: &mut [Generation; WIDTH * HEIGHT * 4], gen: Generation, mut pos: Point, mut dir: Direction) -> bool {
    while let Some(next_cell) = map.get(pos+dir.to_point()) {
        if *next_cell == b'#' {
            let state_idx = ((pos.x as usize) * WIDTH + (pos.y as usize)) * 4 + dir.to_index();
            if visited[state_idx] == gen {
                return true;
            }
            visited[state_idx] = gen;
            dir = dir.clockwise();
            continue;
        }

        pos = pos + dir.to_point();
    }

    false
}

Day 7

For this task I used an iterative solution instead of a recursive solution, because I assumed the overhead for recursion would be too high. When I looked it up, it also seemed like there is no guarantee that tail-recursion will be optimized by the Rust compiler to avoid allocating new stack frame. But a friend of mine used a recursive approach for this day and it was just fine, so a recursive approach would probably have been better.

For my solution, I iteratively fill a vector with every possible combination of operators (including the new concat operator for part two). Because I fill up the vector deterministically, I can find out if a correct solution at a given index involved a concatenation operation.

type Num = u64;
const MAX_NUMBERS_PER_LINE: u32 = 14;

pub fn advent(filename: &str) -> (u64, u64) {
    let input = read_to_string(filename).expect("could not read input file");
    let (mut part1, mut part2) = (0,0);

    // Buffer for tokens of each line (reused for each line)
    let mut tokens = Vec::with_capacity(MAX_NUMBERS_PER_LINE as usize);
    // Buffer that collects all operator combinations (reused for each line)
    let mut buf = vec![0 as Num; 3usize.pow(MAX_NUMBERS_PER_LINE-2)]; 

    for line in input.lines() {
        tokens.clear();
        tokens.extend(extract_numbers::<Num>(&line));
        let test = tokens[0];

        // First operand
        buf[0] = tokens[1];

        let mut prev_size = 1;
        for &op2 in &tokens[2..] {
            // Expand backwards to avoid overwriting unread values
            for idx in (0..prev_size).rev() {
                let op1 = buf[idx];
                let base = idx * 3;
                buf[base]     = op1 + op2;
                buf[base + 1] = op1 * op2;
                buf[base + 2] = concat(op1, op2);
            }
            prev_size *= 3;
        }

        // Now all results are in buf[0..prev_size].
        // Check if one operator combination results in the test value
        let mut part1_found = false;
        let mut part2_found = false;
        for (index, &val) in buf[..prev_size].iter().enumerate() {
            if val == test {
                if !part2_found {
                    part2 += test;
                    part2_found = true;
                }

                // part1 includes only those without any concat
                if !part1_found && !involved_concat(index)  {
                    part1 += test;
                    part1_found = true;
                }

                if part1_found && part2_found {
                    break;
                }
            }
        }
    }

    (part1, part2)
}

// Concatenates the numbers `n1` and `n2`.
// Example: concat(12, 345) = 12345`
const fn concat(n1: Num, n2: Num) -> Num {
    match n2 {
        0..=9 => n1 * 10 + n2,
        10..=99 => n1 * 100 + n2,
        100..=999 => n1 * 1000 + n2,
        _ => panic!("concat not implemented for numbers >= 1000"),
    }
}

// Find out based on the index if this result involved a concatenation.
#[inline]
fn involved_concat(index: usize) -> bool {
    if index == 0 {
        false
    } else if index % 3 == 2 {
        true
    } else {
        involved_concat(index / 3)
    }
}

Day 8

Another 2D matrix input where my utility Matrix function was very helpful to make my code easier to read.

pub fn advent(filename: &str) -> (u32, u32) {
    let map =  Matrix::from_bytes(&read(filename).expect("could not read input file"));
    assert_eq!(map.height, map.width);

    // Map to store antinodes with a '#'
    let mut antinodes1 = map.clone(); // part1
    let mut antinodes2 = map.clone(); // part2
    let mut antenna_positions: FxHashMap<u8, Vec<Point>> = FxHashMap::default();
    let (mut part1, mut part2) = (0,0);

    for x in 0..map.width {
        for y in 0..map.height {
            let p1 = Point::new(x,y);
            if map[p1] == b'.' { continue; }

            if let Some(positions) = antenna_positions.get(&map[p1]) {
                for &p2 in positions {
                    update_antinodes(&mut antinodes1, &mut antinodes2, p1, p2, &mut part1, &mut part2);
                    update_antinodes(&mut antinodes1, &mut antinodes2, p2, p1, &mut part1, &mut part2);
                }
            }
            antenna_positions.entry(map[p1]).or_default().push(p1);
        }
    }

    (part1, part2)
}

// Find and count all antinodes for the given antenna positions.
fn update_antinodes(antinodes1: &mut Matrix<u8>, antinodes2: &mut Matrix<u8>, p1: Point, p2: Point, part1: &mut u32, part2: &mut u32) {
    let dp = p2 - p1;
    for i in 0..antinodes1.width {
        let pos = p1 + dp*i;
        if !antinodes1.contains(pos) { break; }
        if i == 2 {
            *part1 += if antinodes1.update(pos, b'#') {1} else {0};
        }
        *part2 += if antinodes2.update(pos, b'#') {1} else {0}
    }
}

Day 9

Iterative solution, but I’m not quite happy with it. There probably is a more elegant solution.

const FREE: u32 = u32::MAX;

pub fn advent(filename: &str) -> (usize, usize) {
    let input = read_to_string(filename).expect("could not read input file");
    let diskmap: Vec<u32> =  input.trim().chars().filter_map(|c| c.to_digit(10)).collect();
    let (mut part1, mut part2) = (0,0);
    let mut blocks1: Vec<u32> = Vec::new(); // Input format for part 1
    let mut blocks2: Vec<(u32,u32)> = Vec::new(); // Input format for part 2
    let mut file_id = 0;

    for (i, &len) in diskmap.iter().enumerate() {
        if i % 2 == 0 { // is a file
            blocks1.extend(vec![file_id; len as usize]);
            blocks2.push((file_id, len));
            file_id += 1;
        } else { // is free space
            blocks1.extend(vec![FREE; len as usize]);
            blocks2.push((FREE, len));
        }
    }

    // Part 1
    let mut last_idx_front = 0;
    // Iterate from the end of the vector
    for idx_back in (0..blocks1.len()).rev() {
        if last_idx_front >= idx_back { break; }
        if blocks1[idx_back] == FREE { continue; }

        for idx_front in last_idx_front..idx_back {
            // Find the next free space from the beginning
            if blocks1[idx_front] == FREE {
                //println!("Swapping {} (index {i}) to index {j}", blocks1[j]);
                blocks1.swap(idx_front, idx_back);
                last_idx_front = idx_front;
                break;
            }
        }
    }

    // Checksum part 1
    for (idx, &file_id) in blocks1.iter().enumerate() {
        if file_id == FREE { break; }
        part1 += idx*(file_id as usize);
    }

    // Part 2
    // Iterate from the end of the vector
    for idx_back in (0..blocks2.len()).rev() {
        let (back_id, back_len) = blocks2[idx_back];
        if back_id == FREE { continue; }

        // Find the next free space from the beginning
        for idx_front in 0..idx_back {
            let (front_id, front_len) = blocks2[idx_front];

            if front_id == FREE && front_len >= back_len {
                //println!("Shifting block {back_id} to beginning");
                blocks2[idx_back].0 = FREE;

                if front_len == back_len {
                    blocks2[idx_front].0 = back_id;
                } else {
                    blocks2[idx_front].1 -= back_len;
                    blocks2.insert(idx_front, (back_id, back_len));
                }

                break;
            }

        }
    }

    // Checksum part 2
    let mut idx: usize = 0; 
    for (id, size) in blocks2 {
        if id != FREE {
            for _ in 0..size {
                part2 += idx*id as usize;
                idx += 1;
            }
        } else {
            idx += size as usize;
        }
    }

    (part1, part2)
}

Day 10

For this 2D matrix task, I used a recursive DFS approach. The following steps helped optimize the performance while keeping the code concise and elegant:

  • Solve both parts using only one search to find all trails. For part one, count the unique trails. For part two, count the total amount of trails.
  • Search backwards from 9 to 0 (instead of 0 to 9) as there seem to be less nines than zeros on the map.
  • Reuse buffer for the trails.
pub fn advent(filename: &str) -> (usize, usize) {
    let map =  Matrix::from_bytes_as_digits(&read(filename).expect("could not read input file"));
    let (mut part1, mut part2) = (0,0);
    let mut trailends: Vec<Point> = Vec::new();
    let mut unique_trailends: FxHashSet<Point> = FxHashSet::default();

    // Look backwards from 9->0 as my map contains less `9` than `0`
    for trailhead in map.find_all(9) {
        // Collects distinct trailends in `trailends`.
        distinct_trails(trailhead, 9, &map, &mut trailends);
        // Part 1: Unique trails
        part1 += trailends.iter().collect_into::<FxHashSet<Point>>(&mut unique_trailends).len();
        // Part 2: Total trails
        part2 += trailends.len();
        // Clear buffers for next iteration
        trailends.clear();
        unique_trailends.clear();
    }

    (part1, part2)
}

// Collects distinct trailends in `trailends`.
fn distinct_trails<'a>(start: Point, height: u8, map: &Matrix<u8>, trailends: &'a mut Vec<Point>) -> &'a mut Vec<Point> {
    for neighbor in [start+LEFT, start+RIGHT, start+UP, start+DOWN] {
        let target_height = height-1;
        if map.contains(neighbor) && map[neighbor] == target_height {
            if target_height == 0 {
                trailends.push(neighbor);
            } else {
                distinct_trails(neighbor, target_height, map, trailends);
            }
        }
    }
    trailends
}

Day 11

I used a recursive approach and cached the expensive calls using a HashMap.

pub fn advent(filename: &str) -> (u64, u64) {
    let input =  read_to_string(filename).expect("could not read input file");
    let (mut part1, mut part2) = (0,0);

    let mut cache: FxHashMap<(u64, u32), u64> = FxHashMap::default();

    for num in extract_numbers::<u64>(&input) {
        part1 += length_after_blinks(num, 25, &mut cache);
        part2 += length_after_blinks(num, 75, &mut cache);
    }

    (part1, part2)
}

fn length_after_blinks(stone: u64, blinks: u32, cache: &mut FxHashMap<(u64, u32), u64>) -> u64 {
    if blinks == 0 { 1 }
    else if stone == 0 {
        length_after_blinks(1, blinks-1, cache)
    } else {
        let ndigits = stone.ilog10() + 1;
        if ndigits&1 == 0 { // even?
            // Cache this expensive path
            if let Some(cached) = cache.get(&(stone, blinks)) {
                return *cached;
            }
            let split = 10u64.pow(ndigits / 2);
            let len = length_after_blinks(stone / split, blinks - 1, cache)
                    + length_after_blinks(stone % split, blinks - 1, cache);
            cache.insert((stone, blinks), len);
            len
        } else {
            length_after_blinks(stone*2024, blinks-1, cache)
        }
    }  
}

Day 12

Again a 2D map problem, which I solved using BFS.

pub fn advent(filename: &str) -> (usize, usize) {
    let map =  Matrix::from_bytes_as_digits(&read(filename).expect("could not read input file"));
    let mut seen = Matrix::new(map.width, map.height, false);
    let (mut part1, mut part2) = (0,0);
    let mut current_region: VecDeque<Point> = VecDeque::new();

    for y in 0..map.height {
        for x in 0..map.width {
            let cur = Point::new(x, y);
            if seen[cur] { continue; }
            seen[cur] = true;

            let (mut area, mut perimeter, mut sides) = (0, 0, 0);
            let plant_type = map[cur];
            current_region.push_front(Point::new(x, y));

            while let Some(cur) = current_region.pop_front() {
                area += 1;
                for dir in [LEFT, RIGHT, UP, DOWN] {
                    let neighbor = cur + dir;
                    if map.get(neighbor) == Some(&plant_type) {
                        if !seen[neighbor] {
                            current_region.push_back(neighbor);
                            seen[neighbor] = true;
                        }
                    } else {
                        perimeter += 1;
                        sides += (map.get(cur+dir.clockwise()) != Some(&plant_type)
                               || map.get(cur+dir.clockwise()+dir) == Some(&plant_type)) as usize;
                    }
                }
            }
            //println!("Plant {plant_type} has area {area} and perimeter {perimeter}.");
            part1 += area*perimeter;
            part2 += area*sides;
        }
    }

    (part1, part2)
}

Day 13

This problem involves solving a system of two linear equations, which I did by inverting the corresponding 2D matrix.

type Num = i64;

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read_to_string(filename).expect("could not read input file");
    let lines = input.lines().collect::<Vec<_>>();
    let (mut part1, mut part2) = (0,0);

    let extract_pair = |l: &str| {
        let mut nums = extract_numbers::<Num>(l);
        (nums.next().unwrap(), nums.next().unwrap())
    };

    for config in lines.chunks(4) {
        let a = extract_pair(config[0]);
        let b = extract_pair(config[1]);
        let d = extract_pair(config[2]);
        let d2 = (d.0 + 10000000000000, d.1 + 10000000000000);

        part1 += tokens(a, b, d).unwrap_or(0);
        part2 += tokens(a, b, d2).unwrap_or(0);
    }

    (part1, part2)
}

// Computes the integer coefficients `i` and `j` that solve the 2D vector
// equation `i*a + j*b = d`.
//
// The function attempts to find integers `i` and `j` such that the linear
// combinations of vectors `a` and `b` result in vector `d`. Specifically,
// it solves the equations:
// 
//   `ax * i + bx * j = dx`
//   `ay * i + by * j = dy`
// 
// The function returns the amount of tokens `3*i + j`. Here:
//   `i` corresponds to the amount of A button presses, and
//   `j` corresponds to the amount of B button presses.
#[inline]
fn tokens(a: (Num, Num), b: (Num, Num), d: (Num, Num)) -> Option<Num> {
    let det = a.0*b.1 - a.1*b.0; // Determinant (should not be zero)
    let j = (d.1*a.0 - a.1*d.0) / det;
    let i = (d.0 - b.0*j) / a.0;

    if a.0*i + b.0*j == d.0 && a.1*i + b.1*j == d.1 {
        Some(3*i + j)
    } else {
        None
    }
}

Day 14

Initially, I solved part two manually by printing the map after each iteration and looking for oddities. I found two types of patterns:

  • The first pattern after 43s, 146s, and 249s (increment of 103s).
  • The second pattern after 68s, 169s, and 270s (increment of 101s).

Then I only looked at those increments and eventually found the christmas tree. I noticed the christmas tree has a lot of consecutive ones in the row, which did not occur at other iterations. So to automate part two, I looked for the first iteration with 30 consecutive ones in a row.

type Num = i32;

pub fn advent(filename: &str, width: Num, height: Num) -> (Num, Num) {
    let input =  read_to_string(filename).expect("could not read input file");
    let (thresh_x, thresh_y) = (width / 2, height / 2);
    let (mut northwest, mut northeast, mut southwest, mut southeast) = (0,0,0,0);
    let mut robots = Vec::new();
    let mut buf = Vec::new();

    for line in input.lines() {
        buf.clear();
        extract_numbers_signed::<Num>(line).collect_into(&mut buf);
        let (px, py, vx, vy) = (buf[0], buf[1], buf[2], buf[3]);
        robots.push((px, py, vx, vy));

        // Calculate future positions
        let px = (px + 100*vx).rem_euclid(width);
        let py = (py + 100*vy).rem_euclid(height);

        if px < thresh_x {
            if py < thresh_y { northwest += 1; }
            else if py > thresh_y { northeast += 1; }
        } else if px > thresh_x {
            if py < thresh_y { southwest += 1; }
            else if py > thresh_y { southeast += 1; }
        }
    }
    let part1 = northwest * northeast * southwest * southeast;

    // Part 2
    let mut part2 = 0;
    for i in 101..10000 {
        let mut map = vec![vec![0u32; width as usize]; height as usize];
        for &(px, py, vx, vy) in &robots {
            let px = (px + i * vx).rem_euclid(width);
            let py = (py + i * vy).rem_euclid(height);
            map[py as usize][px as usize] += 1;
        }

        if map.iter().any(|row| row.iter()
            .fold((0,0), |(consec, max), &val|
                if val == 1 { (consec + 1, max.max(consec+1)) }
                else { (0, max) }).1 >= 30)
            { part2 = i; break; }
    }

    (part1, part2)
}

Day 15

A 2D matrix problem which I solved using BFS.

type Num = i32;

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read_to_string(filename).expect("could not read input file");
    let (map, moves) = input.split_once("\n\n").expect("cannot find map and moves");

    let mut map1 = Matrix::from_str(map); // Map for part 1
    let mut map2 = Matrix::new(2 * map1.width, map1.height, b'.'); // Map for part 2

    for y in 0..map1.height {
        for x in 0..map1.width {
            let (c1, c2) = (Point::new(2 * x, y), Point::new(2 * x + 1, y));
            match map1[Point::new(x, y)] {
                b'#' => { map2[c1] = b'#'; map2[c2] = b'#' }
                b'O' => { map2[c1] = b'['; map2[c2] = b']' }
                b'@' => { map2[c1] = b'@' }
                _ => {}
            };
        }
    }

    let mut pos1 = map1.find(b'@').expect("cannot find robot in map");
    let mut pos2 = Point::new(2 * pos1.x, pos1.y);

    let mut seen = FxHashSet::default();
    let mut buf = Vec::new();

    for dir_char in moves.lines().flat_map(str::chars) {
        let dir = match dir_char {
            '^' => UP,
            '<' => LEFT,
            'v' => DOWN,
            '>' => RIGHT,
            _ => unreachable!("invalid direction character")
        };

        move_robot(&mut map1, &mut pos1, dir, &mut buf, &mut seen);
        move_robot(&mut map2, &mut pos2, dir, &mut buf, &mut seen);
    }

    let part1 = map1.find_all(b'O').map(|b| 100 * b.y + b.x).sum::<i32>();
    let part2 = map2.find_all(b'[').map(|b| 100 * b.y + b.x).sum::<i32>();
    (part1, part2)
}

fn move_robot(map: &mut Matrix<u8>, pos: &mut Point, dir: Point, bfs: &mut Vec<Point>, seen: &mut FxHashSet<Point>) {
    seen.clear();
    bfs.clear(); bfs.push(*pos);

    let mut i = 0;
    while i < bfs.len() {
        let next = bfs[i] + dir; i+=1;

        match map[next] {
            b'#' => return, // blocked by a wall
            b'[' | b']' | b'O' => {
                // If we haven't seen this box yet, enqueue it.
                if seen.insert(next) {
                    bfs.push(next);
                    // For vertical pushes, if it's part of a two-tile box, enqueue its partner.
                    if dir.y != 0 && (map[next] == b'[' || map[next] == b']') {
                        let other = next + if map[next] == b'[' { RIGHT } else { LEFT };
                        if seen.insert(other) { bfs.push(other); }
                    }
                }
            }
            _ => continue // empty space
        }
    }

    // Move all boxes in reverse order to avoid overwriting
    for &tile in bfs.iter().rev() {
        (map[tile], map[tile + dir]) = (map[tile + dir], map[tile]);
    }
    *pos += dir;
    //println!("Moving {:?}. Robot now at {:?}", dir, pos);
    //map.print();
}

Day 16

For this task, I implemented Dijkstra’s algorithm for finding the path with the minimal score using a MinHeap. Once we arrive at the destination, we can stop searching as at that point all minimum paths have already been found. For part two, I used a backwards BFS to trace back the steps according to the scores from part one.

type Num = usize;

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read(filename).expect("could not read input file");
    let map = Matrix::from_bytes(&input);
    let start = map.find(b'S').expect("no start position");
    let mut scores = Matrix::new(map.width, map.height, [Num::MAX; 4]);

    // Part 1: Dijkstra to find path with minimum score.
    // Using a minheap, check all tiles in order of minimum score to construct a
    // minimum score path to the destination.
    let mut todo = BinaryHeap::new();
    todo.push(MinHeapEntry {key: 0, data: (start, Direction::Right) });
    scores[start][Direction::Right.to_index()] = 0;

    let mut part1 = Num::MAX;
    while let Some(MinHeapEntry{ key: score, data: (pos, dir)}) = todo.pop() {
        if map[pos] == b'E' { part1 = score; break; } 
        if score > scores[pos][dir.to_index()] || map[pos] == b'#' { continue; }

        // Queue the different movement options
        let forward = (pos + dir.to_point(), dir,               score + 1);
        let right =   (pos,                  dir.clockwise(),   score + 1000);
        let left =    (pos,                  dir.counterwise(), score + 1000);

        for &(npos, ndir, nscore) in &[forward, right, left] {
            // Only queue this movement if it doesn't move into a wall or if it is a more costly path
            if map[npos] != b'#' && nscore < scores[npos][ndir.to_index()] {
                scores[npos][ndir.to_index()] = nscore;
                todo.push(MinHeapEntry{key: nscore, data: (npos, ndir)});
            }
        }
    }

    // Part 2: BFS backwards from the end, marking minimal-path cells
    // based on whether the path matches the calculated scores in `scores`.
    let end = map.find(b'E').expect("no end position");
    let mut seen = FxHashSet::default();
    let mut todo = VecDeque::new();
    for dir in Direction::VALUES {
        if scores[end][dir.to_index()] == part1 {
            todo.push_back((end, dir));
            seen.insert(end);
        }
    }

    while let Some((pos, dir)) = todo.pop_front() {
        let score = scores[pos][dir.to_index()];
        let back_forward = (pos - dir.to_point(), dir,               1);
        let back_right =   (pos,                  dir.clockwise(),   1000);
        let back_left =    (pos,                  dir.counterwise(), 1000);

        for &(npos, ndir, diff) in &[back_forward, back_right, back_left] {
            if diff <= score && scores[npos][ndir.to_index()] == score - diff {
                todo.push_back((npos, ndir));
                seen.insert(npos);
            }
        }
    }

    (part1, seen.len())
}

Day 17

For part one, I implemented an interpreter for the program that can run all specified instructions. Part two was quite tricky, as the task was to find a quine (see code comment).

type Num = i64;

pub fn advent(filename: &str) -> (String, Num) {
    let input =  read_to_string(filename).expect("could not read input file");
    let lines = input.lines().collect::<Vec<_>>();

    let a = extract_numbers::<Num>(lines[0]).next().unwrap();
    let b = extract_numbers::<Num>(lines[1]).next().unwrap();
    let c = extract_numbers::<Num>(lines[2]).next().unwrap();
    let insts: Vec<_> = extract_numbers::<Num>(lines[4]).collect();

    let mut out: Vec<Num> = Vec::new();
    run_program(&insts, a, b, c, &mut out);
    let part1 = out.iter().join(",");

    let part2 = find_a_quine(0, 0, b, c, &insts, &mut out).unwrap_or_default();

    (part1, part2)
}

// Brute force `a` with three bits at a time, from LSB to MSB.
// The lowest three bits must be 000, so start with that. Then,
// find all higher three bits that result in the next correct output
// value from left to right.
fn find_a_quine(i: usize, a: Num, b: Num, c: Num, insts: &[Num], out: &mut Vec<Num>) -> Option<Num> {
    run_program(insts, a, b, c, out);
    if out == insts { return Some(a); } // We found the quine
    if i >= insts.len() {return None; }
    if i==0 || out[0] == insts[insts.len()-i] {
        for n in 0..8 {
            if let Some(found) = find_a_quine(i + 1, (a << 3) + n, b, c, insts, out) {
                return Some(found);
            }
        }
    }
    None
}

fn run_program(insts: &[Num], a: Num, b: Num, c: Num, output: &mut Vec<Num>) {
    output.clear();
    let mut ip: usize = 0;
    let mut a = a;
    let mut b = b;
    let mut c = c;

    while ip+1<insts.len() {
        let (opcode, operand) = (insts[ip], insts[ip+1]);
        let combo = || match operand {
            0..=3 => operand,
            4 => a,
            5 => b,
            6 => c,
            _ => unreachable!()
        };

        //println!("IP={ip}: opcode {opcode}, operand {operand}");
        match opcode {
            0 => a = a >> combo(),                              // ADV instruction
            1 => b ^= operand,                                  // BXL instruction
            2 => b = combo() % 8,                               // BST instruction
            3 => if a!=0 {ip = operand as usize; continue;},    // JNZ instruction
            4 => b ^= c,                                        // BXC instruction
            5 => output.push(combo() % 8),                      // OUT instruction
            6 => b = a >> combo(),                              // BDV instruction
            7 => c = a >> combo(),                              // CDV instruction
            _ => unreachable!()
        }
        ip += 2;
    }
}

Day 18

For this 2D matrix problem I used BFS, which can be seen as a special case of Dijkstra’s algorithm on unweighted graphs where the priority queue turns into a simple FIFO queue. When needing all paths, BFS with a normal queue is more efficient than Dijkstra with a priority queue.

type Num = i32;

pub fn advent(filename: &str, width: usize, part1_bytes: usize) -> (Num, String) {
    let input =  read_to_string(filename).expect("could not read input file");
    let mut map = Matrix::new(width as i32, width as i32, b'.');

    let extract_pair = |l: &str| {
        let mut nums = extract_numbers::<Num>(l);
        (nums.next().unwrap(), nums.next().unwrap())
    };

    let mut part1 = 0;
    let mut part2: String = String::new();

    let mut todo = VecDeque::new();
    let mut seen = Matrix::new(map.width, map.height, Num::MAX);
    let mut current_min_length = 0;


    for (i, line) in input.lines().enumerate() {
        let (x,y) = extract_pair(line);
        let pos = Point::new(x,y);
        map[pos] = b'#';

        if i == part1_bytes - 1 {
            // Part 1: Shortest path length after given number of obstacles
            let start = Point::new(0, 0);
            todo.push_back(start);
            seen[start] = 0;
            part1 = shortest_path(&mut map, &mut todo, &mut seen).expect("end must be reachable for part 1");
            current_min_length = part1;
        } else if i >= part1_bytes {
            // Part 2: Find after which new obstacle there is no possible path anymore
            let obstacle_step = seen[pos];
            // If the obstacle is on an unreachable tile, it won't harm
            if obstacle_step == Num::MAX { continue; }
            // If the obstacle is somewhere after having reached the end, it won't harm
            if obstacle_step >= current_min_length { continue; }

            // Reset all tiles with higher step count than obstacle (so they can be explored again)
            for steps in seen.elements.iter_mut() {
                if *steps > obstacle_step {
                    *steps = Num::MAX;
                }
            }

            // Continue search from all tiles with same step count as obstacle.
            todo.clear();
            let mut alternative_route_available = false;
            for npos in seen.find_all(obstacle_step) {
                if npos != pos {
                    alternative_route_available = true;
                    todo.push_back(npos);
                }
            }
            // If there are no other tiles with the same step counts, this obstacle blocks the only path.
            if !alternative_route_available {
                part2 = format!("{x},{y}");
                break;
            }
            // See if there is a route remaining
            if let Some(result) = shortest_path(&mut map, &mut todo, &mut seen) {
                current_min_length = result;
            } else {
                part2 = format!("{x},{y}");
                break;
            }
        }
    }
    (part1, part2)
}

// Compute shortest paths from top-left to all reachable cells using BFS.
// Returns shortest path steps to the bottom-right if reachable.
fn shortest_path(map: &mut Matrix<u8>, todo: &mut VecDeque<Point>, seen: &mut Matrix<Num>) -> Option<Num> {
    let end = Point::new(map.width - 1, map.height - 1);
    let mut minlength = None;

    while let Some(pos) = todo.pop_front() {
        let steps = seen[pos];
        if pos == end && minlength.is_none() { minlength = Some(steps); }
        if map[pos] == b'#' { continue; }

        for &npos in &[pos + LEFT, pos + RIGHT, pos + UP, pos + DOWN] {
            let nsteps = steps + 1;
            if map.contains(npos) && map[npos] != b'#' && nsteps < seen[npos] {
                seen[npos] = nsteps;
                todo.push_back(npos);
            }
        }
    }
    minlength
}

Day 19

Dynamic programming, where I cache intermediate results not using a HashMap, but using a static vector instead. I reused the result of part two for part one, which made my solution faster and more compact. I also noticed during benchmarking that a for loop is faster than an iterator chain in this case.

type Num = usize;

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read_to_string(filename).expect("could not read input file");
    let mut lines = input.lines();
    let available: Vec<_> = lines.next().unwrap().split(", ").collect();
    lines.next(); // skip empty line

    let (mut part1, mut part2) = (0,0);
    let mut cache = vec![usize::MAX; 100];
    for pattern in lines {
        cache.fill(usize::MAX);
        let c = num_combinations(&pattern, 0, &available, &mut cache);
        part1 += (c > 0) as usize;
        part2 += c;
    }

    (part1, part2)
}

fn num_combinations(target: &str, idx: usize, available: &[&str], cache: &mut [usize]) -> usize {
    if idx == target.len() {return 1}
    if cache[idx] != usize::MAX {return cache[idx]}
    let mut total = 0;
    for &p in available {
        if target[idx..].starts_with(p) {
            total += num_combinations(target, idx+p.len(), available, cache);
        }
    }
    cache[idx] = total;
    total
}

Day 20

This is again a 2D matrix problem, where I used BFS to find a shortest path (because the graph is unweighted).

type Num = usize;
const MIN_PICO: usize = 100;

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read(filename).expect("could not read input file");
    let map = Matrix::from_bytes(&input);
    let start = map.find(b'S').expect("no start position");
    let end = map.find(b'E').expect("no end position");
    let mut picos = Matrix::new(map.width, map.height, Num::MAX);
    picos[start] = 0;
    let mut next_pico = 1;

    // BFS to find path from start to end
    let mut todo = VecDeque::new();
    todo.push_back(start);
    let mut path = vec![start];
    while let Some(pos) = todo.pop_front() {
        if pos == end { break; } 
        for &npos in &[pos + LEFT, pos + RIGHT, pos + UP, pos + DOWN] {
            if map.contains(npos) && map[npos] != b'#' && picos[npos] > next_pico {
                picos[npos] = next_pico;
                todo.push_back(npos);
                path.push(npos);
            }
        }
        next_pico+=1;
    }

    let (mut part1, mut part2) = (0,0);

    // Check every pair in path: If distance is smaller than 20, it's a potential cheat.
    let total_time = path.len();
    for i in 0..total_time.saturating_sub(MIN_PICO) {
        let start = path[i];
        for j in (i+MIN_PICO)..total_time {
            let end = path[j];
            let cheat_time = (start.x-end.x).abs() + (start.y-end.y).abs();
            if cheat_time > 20 {continue;}
            let old_time = (j-i) as i32;
            if old_time - cheat_time >= MIN_PICO as i32 {
                part2 += 1;
                if cheat_time <= 2 {
                    part1 += 1;
                }
            }
        }
    }

    (part1, part2)
}

Day 21

I used a dynamic programming approach to find the shortest sequence after a number of intermediate layers from one button to another. I was able to save a lot of time by hardcoding the search for the optimal way to get from one button to another by minimizing the number of direction changes.

type Num = usize;
const NUMPAD: &str = "789\n456\n123\n 0A";
const DIRPAD: &str = " ^A\n<v>";

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read_to_string(filename).expect("could not read input file");

    let numpad = Matrix::from_str(NUMPAD);
    let dirpad = Matrix::from_str(DIRPAD);

    let (mut part1, mut part2) = (0,0);
    let mut seq_cache = FxHashMap::default();

    for line in input.lines() {
        if line.is_empty() {break;}
        let numeric_part: usize = extract_numbers::<usize>(line).next().unwrap();

        let (mut seq_len1, mut seq_len2) = (0, 0);
        let mut prev_key = b'A';
        
        for key in line.bytes() {
            let mut prev_dir = b'A';
            for dir in seq_from_to(prev_key, key, &numpad) {
                seq_len1 += shortest_seq_len(prev_dir, dir, &dirpad, 2, &mut seq_cache);
                seq_len2 += shortest_seq_len(prev_dir, dir, &dirpad, 25, &mut seq_cache);
                prev_dir = dir;
            }
            prev_key = key;
        }
        part1 += numeric_part * seq_len1;
        part2 += numeric_part * seq_len2;
    }

    (part1, part2)
}

// DP[from][to][layers]. The parameter [layers] holds the remaining number
// of dirpad robot layers to consider. Memoization using [cache].
// 
// Returns the length of the shortest sequence after that many layers
// from start to finish.
fn shortest_seq_len(from: u8, to: u8, pad: &Matrix<u8>, layers: u8, cache: &mut FxHashMap<(u8,u8,u8), usize>) -> usize {
    if let Some(result) = cache.get(&(from, to, layers)) {return *result}
    let mut result = 0;

    let seq = seq_from_to(from, to, &pad);
    if layers == 1 { result = seq.len() }
    else {
        let mut prev = b'A';
        for key in seq {
            result += shortest_seq_len(prev, key, pad, layers-1, cache);
            prev = key;
        }
    }
    cache.insert((from, to, layers), result);
    result
}

// The optimal way to get from start to end is by minimizing the direction
// changes along the path. So either move all the way horizontally first
// and then vertically or the other way round (in case that would cross the
// missing corner in the pad). Second, prefer going left (horizontal) first
// if we have to go left. Otherwise, go vertical first.
fn seq_from_to(from: u8, to: u8, pad: &Matrix<u8>) -> Vec<u8> {
    let start = pad.find(from).expect("no start found");
    let end = pad.find(to).expect("no end found");
    
    let d = end-start;
    let vert = if d.y >=0 {b'v'} else {b'^'}; // vertical direction
    let hori = if d.x >=0 {b'>'} else {b'<'}; // horizontal direction

    let mut resulting_path = Vec::with_capacity(d.x.abs()as usize+d.y.abs()as usize);
    if (d.x < 0 && pad[Point::new(end.x, start.y)] != b' ')
       || pad[Point::new(start.x, end.y)] == b' ' {
        for _ in 0..d.x.abs() { resulting_path.push(hori) } // horizontal first
        for _ in 0..d.y.abs() { resulting_path.push(vert) }
    } else {
        for _ in 0..d.y.abs() { resulting_path.push(vert) } // vertical first
        for _ in 0..d.x.abs() { resulting_path.push(hori) }
    }
    resulting_path.push(b'A');
    resulting_path
}

Day 22

For this task we have to identify a certain sequence of four elements, which represent the diffs of a sliding window over input sequences. I iterate over all underlying sequences and for each of the four-element sequences accumulate the amount of bananas that will be bought with that sequence. Finally, I select the sequence that results in the highest amount.

type Num = usize;
const ITERATIONS: usize = 2000;
const DIFF_RANGE: usize = 19;

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read_to_string(filename).expect("could not read input file");
    let mut part1 = 0;
    let mut total = vec![vec![vec![vec![0; DIFF_RANGE]; DIFF_RANGE]; DIFF_RANGE]; DIFF_RANGE];
    let mut seen = vec![vec![vec![vec![Num::MAX; DIFF_RANGE]; DIFF_RANGE]; DIFF_RANGE]; DIFF_RANGE];
    let mut seq = VecDeque::with_capacity(4); // stores the last four sequence elements (= diff)

    for (lineno,line) in input.lines().enumerate() {
        let mut secret = line.parse::<Num>().unwrap();
        let mut price = 0;
        for i in 0..ITERATIONS {
            secret = next_secret_number(secret);
            let next_price = secret % 10;
            if seq.len() == seq.capacity() { seq.pop_front(); } // only keep last four
            seq.push_back(next_price + 9 - price);
            price = next_price;

            if i >= 3 {
                let idx = (seq[0], seq[1], seq[2], seq[3]);
                if  seen[idx.0][idx.1][idx.2][idx.3] != lineno {
                    seen[idx.0][idx.1][idx.2][idx.3]  = lineno;
                    total[idx.0][idx.1][idx.2][idx.3] += price;
                }
            }
        }
        part1 += secret;
    }
   
    let part2 = *total.iter().flatten().flatten().flatten().max().unwrap();
    
    (part1, part2)
}

#[inline]
fn next_secret_number(mut num: Num) -> Num {
    num = num ^ (num << 6) & 0xFFFFFF;
    num = num ^ (num >> 5) & 0xFFFFFF;
          num ^ (num << 11) & 0xFFFFFF
}

Day 23

For part two, I use a greedy algorithm to find the max clique in the graph.

type Num = usize;

pub fn advent(filename: &str) -> (Num, String) {
    let input =  read_to_string(filename).expect("could not read input file");
    let mut part1 = 0;

    // Build adjacency list
    let mut adj = FxHashMap::<String, FxHashSet<String>>::default();
    let mut links = Vec::new();

    for line in input.lines() {
        let (a, b) = line.split_once('-').unwrap();
        links.push((a,b));
        adj.entry(a.into()).or_default().insert(b.into());
        adj.entry(b.into()).or_default().insert(a.into());
    }

    let mut seen = Vec::new();
    let triangle_seen = |s: &Vec<_>, a, b, c| s.contains(&(a,b,c)) || s.contains(&(a,c,b)) || s.contains(&(b,a,c)) || s.contains(&(b,c,a)) || s.contains(&(c,a,b)) || s.contains(&(c,b,a));

    // Part 1: Find all triangles (containing a computer starting with 't')
    for &(a,b) in &links {
        if !(a.starts_with('t') || b.starts_with('t')) {continue};
        for c in adj.get(a).unwrap() {
            if adj.get(b).unwrap().contains(c) && !triangle_seen(&seen,a,b,c) {
                seen.push((a,b,c));
                part1 += 1;
            }
        }
    }

    // Part 2: Greedy find max clique
    let mut max_clique = Vec::new();
    let mut clique = FxHashSet::default();

    for initial in adj.keys() {
        clique.clear();
        clique.insert(initial);
        for c in adj.get(initial).unwrap() {
            if clique.iter().all(|&d| adj.get(c).unwrap().contains(d)) {
                clique.insert(c);
            }
        }
        if clique.len() > max_clique.len() {
            max_clique = clique.drain().collect();
        }
    }

    max_clique.sort_unstable();
    let part2 = max_clique.iter().join(",");
    
    (part1, part2)
}

Day 24

The circuit is a standard ripple-carry adder using two XOR gates, two AND gates and one OR gate for each bit.

For part two, my idea was to check if the gates conform to this standard structure. I automatically implemented the easy condition that all output bits have to come directly out of XOR gates (except the last bit). This got me three out of four swaps. For the remaining swap, I manually searched through the input file using Ctrl+F to find the error by comparing the output of AND and OR gates.

type Num = u64;

pub fn advent(filename: &str) -> (Num, Num) {
    let input =  read_to_string(filename).expect("could not read input file");
    let (initial, gates) = input.split_once("\n\n").expect("wrong file format");

    let (mut part1, mut part2) = (0,0);

    let mut wires = FxHashMap::default();

    for wire in initial.lines() {
        let (name, val) = wire.split_once(": ").expect("wrong initial wire value");
        wires.insert(name, val.parse::<u8>().unwrap());
    }

    let mut todo = VecDeque::new();
    todo.extend(gates.lines());

    while let Some(gate) = todo.pop_front() {
        let mut elem = gate.split(" ");
        let in1 = elem.next().unwrap();
        let op = elem.next().unwrap();
        let in2 = elem.next().unwrap();
        elem.next(); // skip "->"
        let out = elem.next().unwrap();
        if !(wires.contains_key(in1) && wires.contains_key(in2)) {
            todo.push_back(gate);
            continue;
        }
        let in1val = wires.get(in1).unwrap();
        let in2val = wires.get(in2).unwrap();
        let outval = match op {
            "AND" => {in1val & in2val},
            "OR" => {in1val | in2val},
            "XOR" => {in1val ^ in2val},
            _ => unreachable!()
        };

        wires.insert(out, outval);

        if out.starts_with("z") {
            //println!("{out} = {outval}");
            let numeric = out[1..].parse::<Num>().expect("cannot parse output wire");
            part1 += (outval as Num) << numeric;
        }

        // Solved part 2 manually.
    }
    
    (part1, part2)
}

Day 25

For the final day, there is only part one to solve, which again has a 2D matrix as input. The input is always a valid key or lock, so we do not need to validate it. Instead, we can just count the number of # characters in all columns (except the top and bottom column). To identify whether it’s a lock or a key, just check whether the bottom or top row is filled with #.

ype Num = i32;
const HEIGHT: Num = 6;
const WIDTH: Num = 5;
const TOP: Point = Point::new(0,0);

pub fn advent(filename: &str) -> (Num, Num) {
    let input = read_to_string(filename).expect("could not read input file");
    let mut keys = Vec::with_capacity(250);
    let mut locks = Vec::with_capacity(250);

    let (mut part1, part2) = (0,0);

    for schematic in input.split("\n\n") {
        let mat = Matrix::from_str(schematic);
        let mut heights = [0; 5];
        for x in 0..WIDTH {
            for y in 1..HEIGHT { // count number of # in all cols except top/bottom
                if mat[Point::new(x,y)] == b'#' {
                    heights[x as usize] += 1;
                }
            }
        }
        if mat[TOP] == b'#' { // Lock
            locks.push(heights);
        } else { // Key
            keys.push(heights);
        }
    }

    // Try all lock combinations
    for key in &keys {
        'LOCKS: for lock in &locks {
            for x in 0..WIDTH {
                if key[x as usize] + lock[x as usize] > 5 { continue 'LOCKS }
            }
            part1 += 1;
        }
    }
    
    (part1, part2)
}