advent_of_code_2021

My attempts to work through the 2021 Advent of Code problems.
git clone https://git.eamoncaddigan.net/advent_of_code_2021.git
Log | Files | Refs | README | LICENSE

commit ae50a2184f97659c33b8f5fdf8c09fa3616a6dde
parent 3f6cc45aa78990392a25e6dc7ff69e6b5070a82c
Author: Eamon Caddigan <eamon.caddigan@gmail.com>
Date:   Wed, 22 Dec 2021 08:28:07 -0500

Solution to day 21, part 2

Diffstat:
Aday21_part2.py | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 188 insertions(+), 0 deletions(-)

diff --git a/day21_part2.py b/day21_part2.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +"""Advent of Code 2021, day 21 (part 2): Dirac Dice +Simulating an Advent of Code board game (with a multiverse die!)""" + +# Well I'm glad I didn't overengineer part 1 because I didn't expect this. +# +# It's infeasible to enumerate all of the possible outcomes. I think the +# "trick" is to recognize that on each turn, a player can only advance 3, 4, +# ..., 9 spaces, but each of these outcomes is associated with a different +# number of "universes" (e.g., only one universe results in a move of 3 spaces, +# but 3 different universes are associated with a 4-space move). +# +# The state space is strictly less than 2 x 10 x 10 x 30 x 30 possible player +# turn / player 1 position / player 2 position / player 1 score / player 2 +# score combinations, as many of these are impossible (no score above 30 needs +# to be considered, because a winning score above 30 isn't possible). So we can +# recursively explore the number of universes that wind up at each possible +# winning condition. The number of universes at state n = the sum of the +# product of the number of universes from each previous state by the number of +# universes that arrive at _that_ state. The base condition is the starting +# state of the game: both players have a score of 0 and their pawn at their +# respective starting position, this state has exactly 1 universe (and +# "impossible" states have 0 universes). So this is a perfect application for +# memoization, which is fairly easy with functools.lru_cache. + +from typing import Tuple, Dict, cast +from functools import lru_cache +from itertools import product + +from day21_part1 import EXAMPLE_INPUT, parse_input +from utils import get_puzzle_input + +# I know globals are ugly but whatcha gonna do? +UNIVERSES_PER_MOVE = tuple((k + 3, v) for k, v in \ + enumerate([1, 3, 6, 7, 6, 3, 1])) + +@lru_cache(maxsize=None) +def count_universes_at(player_turn: int, + winning_score: int, + start_positions: Tuple[int, int], + positions: Tuple[int, int], + scores: Tuple[int, int]) -> int: + """Find the number of universes that lead to the current state recursively. + `player_turn` indicates which player just _finished_ their turn, and we're + treating the starting state of the game as a though player 2 just finished + (since player 1 goes first)""" + player_idx = player_turn - 1 + + # First, check if it's an "impossible" state + impossible_state = min(scores) > winning_score \ + or max(scores) > winning_score + 9 + for player in range(2): + # No player can have a score less than zero, and if the score _is_ + # zero, their position must be their starting position + impossible_state |= scores[player] < 0 + if scores[player] == 0: + impossible_state |= positions[player] != start_positions[player] + # Player 2 can't have points before player 1 + impossible_state |= scores[0] == 0 and scores[1] > 0 + # If both players have 0 points, then player 2 just "finished" + if scores == (0, 0): + impossible_state |= player_turn != 2 + + # The player's previous score was their current score minus their + # current position (e.g., if they're on space 4 with a score of 12, + # then their last score had to be 8) + scores_prev = list(scores) + scores_prev[player_idx] = scores[player_idx] - positions[player_idx] + + # If this is a winning score, the previous score could not also have been a + # winning score, which means that the previous score could never have been + # a winning score. + impossible_state |= scores_prev[player_idx] >= winning_score + + universes_at_this_state = 0 + if not impossible_state: + if scores == (0, 0): + # Base case + universes_at_this_state = 1 + else: + # We entered the current state following a move from the current + # player. + for die_roll, num_universes in UNIVERSES_PER_MOVE: + positions_prev = list(positions) + positions_prev[player_idx] = (positions[player_idx] - 1 + - die_roll) % 10 \ + + 1 + + universes_at_prev_state = count_universes_at( + (player_idx + 1) % 2 + 1, + winning_score, + start_positions, + tuple(positions_prev), + tuple(scores_prev) + ) + universes_at_this_state += num_universes \ + * universes_at_prev_state + return universes_at_this_state + +def count_winning_universes(start_positions: Tuple[int, int], + winning_score: int) -> \ + Tuple[int, int]: + """For the given pair of starting positions, count all of the winning + universes for player 1 and player 2 and return the results as a tuple""" + count_universes_at.cache_clear() # Shouldn't be necessary + player_1_wins = 0 + player_2_wins = 0 + for winner_score in range(winning_score, winning_score+10): + for loser_score in range(winning_score): + for player_positions in product(range(1, 11), repeat=2): + player_1_wins += count_universes_at(1, + winning_score, + start_positions, + player_positions, + (winner_score, + loser_score)) + player_2_wins += count_universes_at(2, + winning_score, + start_positions, + player_positions, + (loser_score, + winner_score)) + return (player_1_wins, player_2_wins) + +# The next two functions aren't part of the solution, but they did help me get +# there so I'm saving them. + +def step_simulation(game_state: Tuple[int, Tuple[int, int], + Tuple[int, int], Tuple[int, int]], + winning_score: int, + game_state_dict: Dict) -> Dict: + """Advance the game state through one player's turn""" + # Previous puzzles offered simpler input to facilitate debugging, but this + # one is trash, so this function brute forces a few steps of the game, + # counting the paths to each game state + player_turn, start_positions, positions, scores = game_state + + # `player_turn` indicates the player who just finished, so we advance that + # first + current_player = player_turn % 2 + 1 + + updated_game_state_dict = game_state_dict.copy() + if max(scores) >= winning_score: + return updated_game_state_dict + + universes_here = game_state_dict[game_state] + player_idx = current_player - 1 + new_positions = list(positions) + new_scores = list(scores) + + for die_roll, num_universes in UNIVERSES_PER_MOVE: + new_positions[player_idx] = (positions[player_idx] - 1 + die_roll) \ + % 10 + 1 + new_scores[player_idx] = scores[player_idx] + new_positions[player_idx] + new_game_state = (current_player, + start_positions, + cast(Tuple[int, int], + tuple(new_positions)), + cast(Tuple[int, int], + tuple(new_scores))) + if new_game_state not in updated_game_state_dict: + updated_game_state_dict[new_game_state] = 0 + updated_game_state_dict[new_game_state] += universes_here \ + * num_universes + updated_game_state_dict = step_simulation(new_game_state, winning_score, + updated_game_state_dict) + return updated_game_state_dict + +def run_simulation(start_positions: Tuple[int, int], winning_score: int) -> Dict: + """Initialize the simulation and run it""" + # A procedure like this _could_ be used to solve the puzzle, but it takes + # too long + initial_game_state = (2, start_positions, start_positions, (0, 0)) + return step_simulation(initial_game_state, winning_score, + {initial_game_state: 1}) + +def solve_puzzle(input_string: str) -> int: + """Return the numeric solution to the puzzle""" + return max(count_winning_universes(parse_input(input_string), 21)) + +def main() -> None: + """Run when the file is called as a script""" + assert solve_puzzle(EXAMPLE_INPUT) == 444356092776315 + print("Total universes for winner:", + solve_puzzle(get_puzzle_input(21))) + +if __name__ == "__main__": + main()