## ~sgeisenh/personal_site

e02f227283d6417d53e2d6278259a7c382e038e5 — Samuel Eisenhandler 1 year, 6 months ago
```AoC day 2
```
```1 files changed, 484 insertions(+), 0 deletions(-)

A content/series/aoc2022/day02.md
```
`A content/series/aoc2022/day02.md => content/series/aoc2022/day02.md +484 -0`
```@@ 0,0 1,484 @@
++++
+title = "Day 2"
+date = 2023-01-23
+weight = 1
++++
+
+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:
+
+```python
+# 2022/py/day02/sol.py
+
+
+
+def main() -> int:
+    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:
+
+```python
+# 2022/py/day02/sol.py
+
+from typing import Final, Iterable, Mapping
+
+
+
+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:
+    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
+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!
+
+```python
+# 2022/py/day02/sol.py
+
+from time import perf_counter
+from typing import Callable, Final, Iterable, Mapping, Sequence
+
+
+
+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:
+    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`](https://crates.io/crates/phf) crate to build our lookup table.
+
+```
+```
+
+```rust
+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:
+
+```rust
+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:
+
+```rust
+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)
+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.

```