~sgeisenh/personal_site

e02f227283d6417d53e2d6278259a7c382e038e5 — Samuel Eisenhandler 1 year, 10 months ago 83076b5
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

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:

```python
# 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!

```python
# 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`](https://crates.io/crates/phf) crate to build our lookup table.

```
$ cargo add phf phf_macros
```

```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)
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.