Skip to main content

aoc/year2021/
day21.rs

1//! # Dirac Dice
2use crate::util::iter::*;
3use crate::util::parse::*;
4
5type Pair = (usize, usize);
6type State = (Pair, Pair);
7
8/// Rolling the Dirac dice 3 times results in 27 quantum universes. However, the dice total is
9/// one of only 7 possible values. Instead of handling 27 values, we encode the possible dice
10/// totals with the number of times that they occur. For example, a score of 3 (1 + 1 + 1) only
11/// happens once in the 27 rolls, but a score of 6 happens a total of 7 times.
12const DIRAC: [Pair; 7] = [(3, 1), (4, 3), (5, 6), (6, 7), (7, 6), (8, 3), (9, 1)];
13
14/// Extract the starting position for both players converting to zero-based indices.
15pub fn parse(input: &str) -> State {
16    let [_, one, _, two]: [usize; 4] = input.iter_unsigned().chunk::<4>().next().unwrap();
17    ((one - 1, 0), (two - 1, 0))
18}
19
20/// The initial deterministic dice roll total is 6 (1 + 2 + 3) and increases by 9 each turn.
21/// An interesting observation is that since the player's position is always modulo 10, we can
22/// also increase the dice total modulo 10, as (a + b) % 10 = (a % 10) + (b % 10). Additionally,
23/// both players end up back in the same position every 10 moves, so we can compute the score per
24/// batch of 10 moves before simulating only the remainder.
25pub fn part1(input: &State) -> usize {
26    let mut dice = 6;
27    let ((player_position, _), (other_position, _)) = *input;
28
29    // Utilize the periodic visitation pattern to compute the 10-turn score increase per player.
30    let batch_score = |position, offsets: &[usize]| -> usize {
31        offsets.iter().map(|offset| (position + offset) % 10 + 1).sum()
32    };
33    let player_batch = batch_score(player_position, &[6, 0, 2, 2, 0, 6, 0, 2, 2, 0]);
34    let other_batch = batch_score(other_position, &[5, 8, 9, 8, 5, 0, 3, 4, 3, 0]);
35
36    let batches = 999 / player_batch.max(other_batch);
37    let mut rolls = batches * 60; // 2 players * 3 dice * 10 turns per batch.
38    let mut state =
39        ((player_position, player_batch * batches), (other_position, other_batch * batches));
40
41    loop {
42        // Player position is 0 based from 0 to 9, but score is 1 based from 1 to 10.
43        let ((player_position, player_score), (other_position, other_score)) = state;
44        let next_position = (player_position + dice) % 10;
45        let next_score = player_score + next_position + 1;
46
47        dice = (dice + 9) % 10;
48        rolls += 3;
49
50        // Return the score of the losing player times the number of dice rolls.
51        if next_score >= 1000 {
52            break other_score * rolls;
53        }
54
55        // Swap the players so that they take alternating turns.
56        state = ((other_position, other_score), (next_position, next_score));
57    }
58}
59
60/// [Memoization](https://en.wikipedia.org/wiki/Memoization) is the key to solving part two in a
61/// reasonable time. For each possible starting universe we record the number of winning and losing
62/// recursive universes so that we can reuse the result and avoid unnecessary calculations.
63///
64/// Each player can be in position 1 to 10 and can have a score from 0 to 20 (as a score of 21
65/// ends the game). This is a total of (10 × 21)² = 44,100 possible states. For speed this
66/// can fit in an array with perfect hashing, instead of using a slower `HashMap`.
67pub fn part2(input: &State) -> usize {
68    let mut cache = vec![None; 44_100];
69    let (win, lose) = dirac(*input, &mut cache);
70    win.max(lose)
71}
72
73fn dirac(state: State, cache: &mut [Option<Pair>]) -> Pair {
74    let ((player_position, player_score), (other_position, other_score)) = state;
75
76    // Calculate the perfect hash of the state and lookup the cache in case we've seen this before.
77    let index = player_position + 10 * other_position + 100 * player_score + 2100 * other_score;
78    if let Some(result) = cache[index] {
79        return result;
80    }
81
82    let helper = |(win, lose), &(dice, frequency)| {
83        let advance = player_position + dice;
84        let next_position = if advance >= 10 { advance - 10 } else { advance };
85        let next_score = player_score + next_position + 1;
86
87        if next_score >= 21 {
88            (win + frequency, lose)
89        } else {
90            // Sneaky trick here to handle both players with the same function.
91            // We swap the order of player's state each turn, so that turns alternate
92            // and record the result as (lose, win) instead of (win, lose).
93            let next_state = ((other_position, other_score), (next_position, next_score));
94            let (next_lose, next_win) = dirac(next_state, cache);
95            (win + frequency * next_win, lose + frequency * next_lose)
96        }
97    };
98
99    // Compute the number of wins and losses from this position and add to the cache.
100    let result = DIRAC.iter().fold((0, 0), helper);
101    cache[index] = Some(result);
102    result
103}