There is something relaxing about diving into day one of the Advent of Code. It's a time to get your feet wet and get a feel for the puzzles that are to come. This year, as I usually do, I am going to be using Rust to solve the puzzles. I have been using Rust for a while now, and I am excited to see how it performs in solving these puzzles in a WebAssembly context!
Jumping into part one, it starts off pretty simple: find the first and last numerical digit in a list of strings, combine them into a two-digit number, and add the collection of two-digit numbers together. Depending on the language, this can be very straightforward. To be honest, I don't do a huge amount of string manipulation in Rust, but it's a good chance to warm up.
I will be building some helper functions along the way. They should be self-explanatory.
Something has gone wrong with snow production, and we need to help the Elves! We are being sent off into the sky using a trebuchet, but the calibrations are off. We get a list of strings that contain at least two numbers. We want to find the first and last numbers, combine them into a two-digit number, and add them all together.
The team is nice enough to give us an example of input and expected values. This is a good time to write a test.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_solve_part_one() {
let input = String::from(
r#"1abc2
pqr3stu8vwx
a1b2c3d4e5f
treb7uchet"#,
);
let trebuchet = Trebuchet;
let _result = trebuchet.solve_part_one(input);
assert_eq!(_result, "142");
}
}
Nice and simple. We have a multiline string, and we expect the result to be 142
. Let's write the code to make this
pass. Rust makes this easy for us with some helper methods on String
. We need to take each line and find the first
and last number. We can use some simple character searching to find the first, and then apply the same logic but with
a reversed character set to find the last number.
fn find_part_one_number(&self, line: &String) -> Result<u64, ()> {
let first_digit = line
.chars()
.find(|c| c.is_digit(10))
.map_or(Err(()), |c| Ok(c.to_string()))?;
let last_digit = line
.chars()
.rev()
.find(|c| c.is_digit(10))
.map_or(Err(()), |c| Ok(c.to_string()))?;
let value = format!("{}{}", first_digit, last_digit)
.parse::<u64>()
.unwrap();
Ok(value)
}
Now let's iterate over the lines and sum the values.
pub fn part_one_process(&self, input: String) -> Result<String, ()> {
let input_as_lines = split_multiline_string(input);
let mut numbers = Vec::new();
for line in input_as_lines.iter() {
let value = self.find_part_one_number(line)?;
numbers.push(value);
}
Ok(numbers.iter().sum::<u64>().to_string())
}
Done! We have a simple solution to part one. Let's run the tests and see if they pass.
cargo test
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
Running unittests src/lib.rs (target/debug/deps/adventofcode-b93bf933d44cfd61)
running 1 tests
test y2023::day1::tests::test_solve_part_one ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adventofcode
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Awesome! We have a passing test! When processing the actual input that AoC provides, my result
came to 55172
. Your results will depend on your input.
But there's a problem! It turns out that the assumption that we only need the numerical characters to calibrate the Trebuchet is incorrect. The input text is actually a series of strings containing numbers in both numerical form and words representing numbers. The calculation is correct, but our input is wrong. So, let's implement part two.
First, let's write a test. Like before, AoC provides a simple input-output pair so we can create a test. Let's make use of that.
#[test]
fn test_solve_part_two() {
let input = String::from(
r#"two1nine
eightwothree
abcone2threexyz
xtwone3four
4nineeightseven2
zoneight234
7pqrstsixteen"#,
);
let trebuchet = Trebuchet;
let _result = trebuchet.solve_part_two(input);
assert_eq!(_result, "281");
}
Now, this is where AoC really likes to challenge us. We know our calculation is correct, but how do we handle numbers that could be represented by words? I like to start with what we know. We know the words we can encounter and what numbers they represent. We also know that we need to find the first and last numbers and convert them into a two-digit numerical value. So, let's start there.
fn find_part_two_number(&self, line: &String) -> Result<u64, ()> {
let numbers_map: HashMap<&str, u64> = [
("one", 1),
("two", 2),
("three", 3),
("four", 4),
("five", 5),
("six", 6),
("seven", 7),
("eight", 8),
("nine", 9),
]
.iter()
.cloned()
.collect();
let first_number = Self::find_first_number(line, &numbers_map)?;
let last_number = Self::find_second_number(line, numbers_map)?;
let output = format!("{}{}", first_number, last_number)
.parse::<u64>()
.unwrap();
Ok(output)
}
Nice, we're already halfway there. Now we need to find the first and last numbers. However, we can't use the same logic as before. The previous implementation used a simple character search to find the first and last numbers. While we could use a search method to find the first occurrence of all the values and then grab the one with the lowest index, we cannot reverse the input naturally as that would break the word numbers. So, we need a different approach.
Since this is a relatively simple problem with low computational overhead, I'm going to brute-force it.
fn find_first_number(line: &String, numbers_map: &HashMap<&str, u64>) -> Result<u64, ()> {
let mut first_number = None;
let mut working_line = line.clone();
while first_number.is_none() {
for (&word, &number) in numbers_map.iter() {
if working_line.starts_with(word) || working_line.starts_with(&number.to_string()) {
first_number = Some(number);
}
}
working_line = working_line[1..].to_string();
}
first_number.map_or(Err(()), |n| Ok(n))
}
In this function, we iterate over the line and check if it starts with any of the words or numbers. If it does, we have our first number and return it. If we don't find it, we remove the first character and try again. This is pretty inefficient, but it works for our use case. We can do the same for the last number.
fn find_second_number(line: &String, numbers_map: HashMap<&str, u64>) -> Result<u64, ()> {
let mut last_number = None;
let mut working_line = line.clone();
while last_number.is_none() {
for (&word, &number) in numbers_map.iter() {
if working_line.ends_with(word) || working_line.ends_with(&number.to_string()) {
last_number = Some(number);
}
}
working_line = working_line[..working_line.len() - 1].to_string();
}
last_number.map_or(Err(()), |n| Ok(n))
}
And with that, let's run our tests again.
cargo test
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src/lib.rs (target/debug/deps/adventofcode-b93bf933d44cfd61)
running 2 tests
test y2023::day1::tests::test_solve_part_one ... ok
test y2023::day1::tests::test_solve_part_two ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adventofcode
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Very nice! Now we can run our real input through and get our final answer. For me, I got 54925
, but as always,
your input may give you different results.