Last modified: 2024-12-08 @ c2d5eb0
Advent of Code 2024: Day 07
This post is part of the following series: Advent of Code 2024, Advent of Code.
Code for solutions available on GitHub.
Bridge Repair
What would an Advent of Code be without some dynamic programming at some point? For today’s input, we have a bunch of numbers like this:
190: 10 19
3267: 81 40 27
83: 17 5
156: 15 6
7290: 6 8 6 15
161011: 16 10 13
192: 17 8 14
21037: 9 7 18 13
292: 11 6 16 20
The first number in each line is the target. The remaining numbers on the line
are numbers we have to use to create the target number by using the addition
(+
) and multiplication (*
) operators. Each line is known as an equation.
The task is to figure out which equations are possible to create using the two
operators. They are always evaluated left-to-right, so we don’t have to worry
about precedence rules.
First, a simple struct:
struct Equation {
target: u64,
numbers: Vec<u64>,
}
And now to figure out whether an equation is valid or not. You could do this by
considering the gap between each number as a bit in a binary number, mapping
e.g. 0
to +
and 1
to *
and simply trying all 2^n possible solutions.
However, this would do a lot of repeated work and a lot of unnecessary work.
Consider if the result is already too large halfway through the list of numbers.
Then the equation is invalid and there’s no point in continuing. And changing
operators in the later part of the list of numbers repeats all the work from the
start of the list.
Instead, we can approach this in a dynamic programming-inspired way, and going backwards through the list of numbers. So instead of adding and multiplying to get to the target value, we subtract or divide to get the target number down to 0 or 1, depending on operation. We save all – and I mean all – valid intermediate solutions in a table to avoid duplicate work. If the subtraction would underflow, or the target number is not divisible by the current number in the iteration, we don’t save that result. This cuts down on a lot of unnecessary work.
For each iteration, we subtract the current number from all valid
intermediate solutions from the previous iteration (where possible), and divide
all valid intermediates by the current number (where possible). Repeating
this until we reach the last iteration (the first number), we can then figure
out whether the equation is possible by looking at the second to last
iteration’s results. If the first element of self.numbers
appears anywhere
there, it’s possible! And which row of the table it appears in tells us what the
first operation is!
That’s a lot of words, and this concept can be a bit difficult to explain with words, so instead I’ll explain with code:
impl Equation {
fn is_valid(&self) -> bool {
// Our table. 2 rows: one for each kind of operation.
let mut dp = [const { Vec::new() }; 2];
// Each column of the table rows is a Vec
// containing intermediate results.
dp[0].resize(self.numbers.len(), Vec::<u64>::new());
dp[1].resize(self.numbers.len(), Vec::<u64>::new());
// Iterate backwards, keep track of indices.
for (i, number) in self.numbers.iter().enumerate().rev() {
// Next intermediate results
let next = if i == self.numbers().len() - 1 {
// The first result should be `self.target`.
vec![self.target]
} else {
// Otherwise, the next result should
// be a Vec of all intermediate results
// from the previous iteration
// (from both rows).
let mut next = dp[0][i + 1].clone();
next.extend_from_slice(&dp[1][i + 1]);
next
}
// Check all subtractions, save intermediate.
dp[0][i] = next
.iter()
.filter_map(|n| n.checked_sub(*number))
.collect();
// Check all divisions, save intermediate.
dp[1][i] = next
.iter()
.filter(|n| *n % number == 0)
.map(|n| n / *number)
.collect();
}
// There's a path where the first operation is
// addition.
dp[0][1].contains(&self.numbers[0]) ||
// There's a path where the first operation is
// multiplication.
dp[1][1].contains(&self.numbers[0])
}
}
An alternative return code is to check whether the first column of the first row
contains a 0
, or the first element of the second row contains a 1
. If one is
true, the other is as well. But I’m being explicit here with which operation is
found first.
For the sake of completeness, here’s a visualization of what happens in this code:
Consider the equation line "292: 11 6 16 20".
Iterating backwards
i = 3:
numbers = [11, 6, 16, 20]; 292 >= 20
↑ 292 % 20 != 0
dp = [
+ [ [], [], [], [272] ], 272 (292 - 20) inserted
* [ [], [], [], [] ], nothing inserted, not divisible
↑
]
i = 2:
numbers = [11, 6, 16, 20]; 272 >= 16
↑ 272 % 16 == 0
dp = [
+ [ [], [], [256], [272] ], 256 (272 - 16) inserted
* [ [], [], [17], [] ], 17 (272 / 16) inserted
↑
]
For i = 1
, we have to consider both Vec
s at i = 2
:
i = 1:
numbers = [11, 6, 16, 20]; 256 >= 6
↑ 17 >= 6
256 % 6 != 0
17 % 6 != 0
dp = [
+ [ [], [250, 11], [256], [272] ], 250 (256 - 6),
11 (17 - 6) inserted
* [ [], [], [17], [] ], nothing inserted
↑
]
i = 0:
numbers = [11, 6, 16, 20]; 250 >= 11
↑ 11 >= 11
250 % 11 != 0
11 % 11 == 0
dp = [
+ [ [239, 0], [250, 11], ... ], 239 (250 - 11),
0 (11 - 11) inserted
* [ [1], [], ... ], 1 (11 / 11) inserted
↑
]
A bit convoluted still, but because the table row for +
has a Vec
that
contains our first number (11
) in the second column, there’s a way to realize
the equation with the first operation being +
. Staring long enough at the
final table, you can come to the conclusion that the way to make 292
from 11 6 16 20
is as follows:
292 = (((11 + 6) * 16) + 20)
I added parentheses to clarify that the equations are evaluated left-to-right.
Here’s how to stare at the final table:
0 | 1 | (op) | 2 | (op) | 3 | (op) |
---|---|---|---|---|---|---|
[239, 0] | [250, 11] | (+6) | [256] | [272] | (+20) | |
[1] | [] | [17] | (*16) | [] |
Looking at the second column (heading 1), we have the first number in the
sequence 11
at the first row. Because we’re in the first row, the next
operation is addition. Adding the next number (6
); we get 17
. Going to the
next column, we find 17
in the second (multiplication) row. So the next
operation is multiplication. Multiplying what we have so far (17
), with the
next number (16
), we get 272
. This is found in the first row, so the next
operation is addition. Finally, adding the next and final number (20
) to
272
, we end up at 292
, which was the target number! And thus the sequence is
11+6*16+20
.
Here’s a visualization of the path (first column included, even though it’s actually superfluous) with all irrelevant entries removed.
11 +6 *16 +20 = 292
(+11) (+20)
0 --------> 11 272------------> 292
| | ^
(*11)| | (+6) | (*16)
| | |
1 ----´ `--> 17 ---´
Phew! That’s a lot of typing and diagrams. And I’m still not quite happy with any explanation. It’s a difficult thing both to explain and to visualize. This final excursion into recreating the path is completely irrelevant to the task. It just wanted to know which equations are possible, disregarding the details.
Oh, right! The task!
Once we have a Vec<Equation>
, and the Equation::is_valid
method from waaay
before, actually getting the result is easy:
let result = equations
.into_iter()
.filter(Equation::is_valid)
.map(|equation| equation.target)
.sum();
Part 2
Of course, part 2 predictably adds another operation into the mix:
concatenation. It’s exactly what you’d expect, 15 || 6
would create 156
.
Everything is still left-to-right, so this is just a matter of adding another
row to the table in a new method:
impl Equation {
fn is_valid_with_concatenation(&self) -> bool {
// ---<snip>---
for (i, number) in self.numbers.iter().enumerate().rev() {
// ---<snip>---
dp[2][i] = next
.iter()
.filter(|n| {
let num_digits = number.ilog10() + 1;
let modulus = 10u64.pow(num_digits);
if *n % modulus == *number {
Some(*n / modulus)
} else {
None
}
})
.collect();
// ---<snip>---
}
dp[0][1].contains(&self.numbers[0]) ||
dp[1][1].contains(&self.numbers[0]) ||
dp[2][1].contains(&self.numbers[0])
}
}
This code just checks whether the target number of the intermediate solution
ends with the same digits as the current number in the list. If so, it shifts
those digits off and stores the result as a new intermediate result. Getting the
full result is then just a matter of changing .filter(Equation::is_valid)
from
part 1 to .filter(Equation::is_valid_with_concatenation)
.
Visualizing the path works in the exact same way as before, except there’s a third row which represents that the next operation is concatenation.
A quick excursion into the table for the equation 192: 17 8 14
:
0 | 1 | (op) | 2 | (op) |
---|---|---|---|---|
[153, 0] | [170] | [178] | (+20) | |
[10, 1] | [] | [] | ||
[0] | [17] | (|| 8 ) | [] |
In the second column, we have the first number (17
) in the third row. So
concatenation is the first operation. We concatenate our second number (8
) to
get 178
. This is in the third column of the first row, so our next operation
is addition. We add the final number (14
) to 178
and wouldn’t you know, it’s
192
! The target number! So the equation is 192 = ((17 || 8) + 14)
.
Full code on GitHub, if you prefer to read code with much fewer explaining comments. :)
Other posts in Advent of Code 2024
- Advent of Code 2024: Day 01
- Advent of Code 2024: Day 02
- Advent of Code 2024: Day 03
- Advent of Code 2024: Day 04
- Advent of Code 2024: Day 05
- Advent of Code 2024: Day 06
- Advent of Code 2024: Day 07
- Advent of Code 2024: Day 08
- Advent of Code 2024: Day 09
- Advent of Code 2024: Day 10
- Advent of Code 2024: Day 11
Other posts in Advent of Code
- Advent of Code 2023: Day 01
- Advent of Code 2023: Day 02
- Advent of Code 2023: Day 03
- Advent of Code 2023: Day 04
- Advent of Code 2023: Day 05
- Advent of Code 2023: Day 06
- Advent of Code 2023: Day 07
- Advent of Code 2023: Day 08
- Advent of Code 2023: Day 09
- Advent of Code 2023: Day 10
- Advent of Code 2023: Day 11
- Advent of Code 2024: Day 01
- Advent of Code 2024: Day 02
- Advent of Code 2024: Day 03
- Advent of Code 2024: Day 04
- Advent of Code 2024: Day 05
- Advent of Code 2024: Day 06
- Advent of Code 2024: Day 07
- Advent of Code 2024: Day 08
- Advent of Code 2024: Day 09
- Advent of Code 2024: Day 10
- Advent of Code 2024: Day 11