Alright, day 2.
Problem statement
Rock Paper Scissors!
We are given a "strategy guide" that looks like:
A Y
B X
C Z
and we need to follow the provided scheme.
We save the example as input/02/example.txt
and our input as input/02/input.txt
.
Python time!
We create our solution in 2022/py/day02/sol.py
and add a blank 2022/py/day02/__init__.py
.
Software engineering best practices be damned, let's hard code everything! Since we know the score associated with each combination of moves, we can create a dictionary that maps each combination to the final score:
# 2022/py/day02/sol.py
from py.read_input import read_input
def main() -> int:
input = read_input(2)
scores = {
"A X": 4, # 3 + 1
"A Y": 8, # 6 + 2
"A Z": 3, # 0 + 3
"B X": 1, # 0 + 1
"B Y": 5, # 3 + 2
"B Z": 9, # 6 + 3
"C X": 7, # 6 + 1
"C Y": 2, # 0 + 2
"C Z": 6, # 3 + 3
}
result = sum(scores[line.strip()] for line in input.splitlines())
print(f"Part one: {result}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Part two involves a different scoring scheme, so we can take the same approach
but with a different scores
table:
# 2022/py/day02/sol.py
from typing import Final, Iterable, Mapping
from py.read_input import read_input
P1_SCORES: Final[dict[str, int]] = {
"A X": 4, # 3 + 1
"A Y": 8, # 6 + 2
"A Z": 3, # 0 + 3
"B X": 1, # 0 + 1
"B Y": 5, # 3 + 2
"B Z": 9, # 6 + 3
"C X": 7, # 6 + 1
"C Y": 2, # 0 + 2
"C Z": 6, # 3 + 3
}
P2_SCORES: Final[dict[str, int]] = {
"A X": 3, # 3 + 0
"A Y": 4, # 1 + 3
"A Z": 8, # 2 + 6
"B X": 1, # 1 + 0
"B Y": 5, # 2 + 3
"B Z": 9, # 3 + 6
"C X": 2, # 2 + 0
"C Y": 6, # 3 + 3
"C Z": 7, # 1 + 6
}
def score(games: Iterable[str], table: Mapping[str, int]) -> int:
return sum(table[game] for game in games)
def main() -> int:
input = read_input(2)
games = [line.strip() for line in input.splitlines()]
print(f"Part one: {score(games, P1_SCORES)}")
print(f"Part two: {score(games, P2_SCORES)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
I guess one interesting note is the use of the Iterable
type instead of
list[str]
for the games
argument and the Mapping
type instead of
dict[str, int]
for the table
argument. In general when using type
hints, it can be nice to get in the habit of accepting more general abstract types.
It's not likely for this program, but you could imagine that someone might
swap to using a set
or frozenset
to represent some collection. By using
argument types like Iterable
, you can enable users of functions to pass in a
larger variety of inputs without additional copies or allocations. Additionally,
immutable container types are covariant on their value types. Using immutable
container argument types often makes your interfaces much more flexible.
Efficiency
This solution is optimal in terms of asymptotic complexity (it runs in linear time
over the length of the input). But one idea we could try would be to use a
match
statement instead of a lookup table. It's unlikely that this will yield
performance benefits since dictionary lookups are well optimized in Python. I'm
more curious to see how these approaches fare in Rust, but let's try it in Python,
just to see!
# 2022/py/day02/sol.py
from time import perf_counter
from typing import Callable, Final, Iterable, Mapping, Sequence
from py.read_input import read_input
P1_SCORES: Final[dict[str, int]] = {
"A X": 4, # 3 + 1
"A Y": 8, # 6 + 2
"A Z": 3, # 0 + 3
"B X": 1, # 0 + 1
"B Y": 5, # 3 + 2
"B Z": 9, # 6 + 3
"C X": 7, # 6 + 1
"C Y": 2, # 0 + 2
"C Z": 6, # 3 + 3
}
P2_SCORES: Final[dict[str, int]] = {
"A X": 3, # 3 + 0
"A Y": 4, # 1 + 3
"A Z": 8, # 2 + 6
"B X": 1, # 1 + 0
"B Y": 5, # 2 + 3
"B Z": 9, # 3 + 6
"C X": 2, # 2 + 0
"C Y": 6, # 3 + 3
"C Z": 7, # 1 + 6
}
def table_score(games: Iterable[str], table: Mapping[str, int]) -> int:
return sum(table[game] for game in games)
def table_sol(games: Sequence[str]) -> tuple[int, int]:
return table_score(games, P1_SCORES), table_score(games, P2_SCORES)
def score_fn_p1(game: str) -> int:
match game:
case "A X":
return 4
case "A Y":
return 8
case "A Z":
return 3
case "B X":
return 1
case "B Y":
return 5
case "B Z":
return 9
case "C X":
return 7
case "C Y":
return 2
case "C Z":
return 6
case _:
raise ValueError(f"Invalid game: {game}")
def score_fn_p2(game: str) -> int:
match game:
case "A X":
return 3
case "A Y":
return 4
case "A Z":
return 8
case "B X":
return 1
case "B Y":
return 5
case "B Z":
return 9
case "C X":
return 2
case "C Y":
return 6
case "C Z":
return 7
case _:
raise ValueError(f"Invalid game: {game}")
def match_score(games: Iterable[str], score_fn: Callable[[str], int]) -> int:
return sum(score_fn(game) for game in games)
def match_sol(games: Sequence[str]) -> tuple[int, int]:
return match_score(games, score_fn_p1), match_score(games, score_fn_p2)
def main() -> int:
input = read_input(2)
games = [line.strip() for line in input.splitlines()]
expected_output = table_sol(games)
print(f"Part one: {expected_output[0]}")
print(f"Part two: {expected_output[1]}")
def benchmark(solution: Callable[[Sequence[str]], tuple[int, int]]) -> float:
start = perf_counter()
for _ in range(1000):
assert solution(games) == expected_output
end = perf_counter()
return end - start
print(f"Table solution takes {benchmark(table_sol)}s")
print(f"Match solution takes {benchmark(match_sol)}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Running this on my machine yields this result:
$ python -m py.day02.sol
Part one: 11906
Part two: 11186
Table solution takes 0.1719587080006022s
Match solution takes 0.38245958300103666s
So the match
-based solution takes more than twice as long as the lookup table
approach. I'm glad that Python now has some form of structural pattern matching, but
a little bit sad about the performance gap to lookup tables, especially when compared
with compiled languages like Rust or Standard ML.
So for Python, we'll stick with our original lookup table approach.
Rust!
Let's try the same exercise in Rust.
You know most of the boilerplate from day 1, so I'll gloss over most of those details.
We'll use the phf
crate to build our lookup table.
$ cargo add phf phf_macros
use phf_macros::phf_map;
fn parse_games(input: &str) -> Vec<&str> {
input.lines().map(str::trim).collect::<Vec<_>>()
}
static P1_SCORES: phf::Map<&'static str, i32> = phf_map! {
"A X" => 3 + 1,
"A Y" => 6 + 2,
"A Z" => 0 + 3,
"B X" => 0 + 1,
"B Y" => 3 + 2,
"B Z" => 6 + 3,
"C X" => 6 + 1,
"C Y" => 0 + 2,
"C Z" => 3 + 3,
};
static P2_SCORES: phf::Map<&'static str, i32> = phf_map! {
"A X" => 3 + 0,
"A Y" => 1 + 3,
"A Z" => 2 + 6,
"B X" => 1 + 0,
"B Y" => 2 + 3,
"B Z" => 3 + 6,
"C X" => 2 + 0,
"C Y" => 3 + 3,
"C Z" => 1 + 6,
};
fn table_score(games: &[&str], table: &phf::Map<&'static str, i32>) -> i32 {
games
.iter()
.map(|game| table.get(game).expect("invalid game"))
.sum()
}
pub fn table_sol(input: &str) -> (i32, i32) {
let games = parse_games(input);
(
table_score(&games, &P1_SCORES),
table_score(&games, &P2_SCORES),
)
}
fn score_fn_p1(game: &str) -> i32 {
match game {
"A X" => 3 + 1,
"A Y" => 6 + 2,
"A Z" => 0 + 3,
"B X" => 0 + 1,
"B Y" => 3 + 2,
"B Z" => 6 + 3,
"C X" => 6 + 1,
"C Y" => 0 + 2,
"C Z" => 3 + 3,
_ => panic!("invalid game: {game}"),
}
}
fn score_fn_p2(game: &str) -> i32 {
match game {
"A X" => 3 + 0,
"A Y" => 1 + 3,
"A Z" => 2 + 6,
"B X" => 1 + 0,
"B Y" => 2 + 3,
"B Z" => 3 + 6,
"C X" => 2 + 0,
"C Y" => 3 + 3,
"C Z" => 1 + 6,
_ => panic!("invalid game: {game}"),
}
}
fn match_score(games: &[&str], score_fn: impl Fn(&str) -> i32) -> i32 {
games.iter().cloned().map(score_fn).sum()
}
pub fn match_sol(input: &str) -> (i32, i32) {
let games = parse_games(input);
(
match_score(&games, score_fn_p1),
match_score(&games, score_fn_p2),
)
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r#"A Y
B X
C Z"#;
#[test]
fn test_table_sol() {
assert_eq!(table_sol(EXAMPLE), (15, 12));
}
#[test]
fn test_match_sol() {
assert_eq!(match_sol(EXAMPLE), (15, 12));
}
}
Let's hook it all up in our main
function:
use std::error::Error;
use day02::{match_sol, table_sol};
fn main() -> Result<(), Box<dyn Error>> {
let input = include_str!("../../../../input/02/input.txt");
let (tp1, tp2) = table_sol(input);
println!("Table sol p1: {tp1}, p2: {tp2}");
let (mp1, mp2) = match_sol(input);
println!("Match sol p1: {mp1}, p2: {mp2}");
Ok(())
}
We can run it and get our expected results:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `/Users/sgeisenh/projects/aoc/2022/rust/day02/target/debug/main`
Table sol p1: 11906, p2: 11186
Match sol p1: 11906, p2: 11186
Similarly, we add a benchmark file for criterion
to use:
use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
static INPUT: &str = include_str!("../../../input/02/input.txt");
pub fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("table_sol", |b| {
b.iter(|| day02::table_sol(black_box(INPUT)))
});
c.bench_function("match_sol", |b| {
b.iter(|| day02::match_sol(black_box(INPUT)))
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
And finally, we run our benchmarks:
$ cargo bench
Finished bench [optimized] target(s) in 0.03s
Running unittests src/lib.rs (/Users/sgeisenh/projects/aoc/2022/rust/day02/target/release/deps/day02-14b54ca39dda7916)
running 2 tests
test tests::test_match_sol ... ignored
test tests::test_table_sol ... ignored
test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/bin/main.rs (/Users/sgeisenh/projects/aoc/2022/rust/day02/target/release/deps/main-ea2d4c751f6a8144)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running benches/my_benchmark.rs (/Users/sgeisenh/projects/aoc/2022/rust/day02/target/release/deps/my_benchmark-24b0eacb110149e1)
Gnuplot not found, using plotters backend
table_sol time: [105.59 µs 105.63 µs 105.69 µs]
change: [-0.1857% -0.0658% +0.0629%] (p = 0.31 > 0.05)
No change in performance detected.
Found 4 outliers among 100 measurements (4.00%)
1 (1.00%) high mild
3 (3.00%) high severe
match_sol time: [47.056 µs 47.131 µs 47.222 µs]
change: [-6.3413% -5.8155% -5.1458%] (p = 0.00 < 0.05)
Performance has improved.
Found 5 outliers among 100 measurements (5.00%)
1 (1.00%) high mild
4 (4.00%) high severe
As expected, the match
-based solution is significantly faster than the table
lookup solution in Rust.
That's it!
I'm a little bit sad that day 1 had some more interesting algorithmic ideas to explore than day 2. Oh well. I'll probably jump to something like day 20 for my next post so we can get into some juicy ideas.