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 04d38c9e24dc4c40c3668400ea86389961920c3f
parent 805a0082908f673dccd443df9cea48490d59899e
Author: Eamon Caddigan <eamon.caddigan@gmail.com>
Date:   Sat, 18 Dec 2021 10:24:44 -0500

Solution to day 18, part 1

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

diff --git a/day18_part1.py b/day18_part1.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +"""Advent of Code 2021, day 18 (part 1): Snailfish math +Snilfish math!""" + +# Trying to see how simple I can keep this one: can a named tuple and global +# functions tackle this, or do I need a custom class with methods? + +from typing import List, Tuple, Union, NamedTuple, cast +from math import ceil +from string import digits +from functools import reduce +from utils import get_puzzle_input + +EXAMPLE_INPUT = \ +"""[[[0,[5,8]],[[1,7],[9,6]]],[[4,[1,2]],[[1,4],2]]] +[[[5,[2,8]],4],[5,[[9,9],0]]] +[6,[[[6,2],[5,6]],[[7,6],[4,7]]]] +[[[6,[0,7]],[0,9]],[4,[9,[9,0]]]] +[[[7,[6,4]],[3,[1,3]]],[[[5,5],1],9]] +[[6,[[7,3],[3,2]]],[[[3,8],[5,7]],4]] +[[[[5,4],[7,7]],8],[[8,3],8]] +[[9,3],[[9,9],[6,[4,9]]]] +[[2,[[7,7],7]],[[5,8],[[9,3],[0,2]]]] +[[[[5,2],5],[8,[3,7]]],[[5,[7,5]],[4,4]]] +""" + +# mypy can't handle recursive types yet (it's been an open issue since 2015 +# (see issue #731), but that doesn't mean that we can't or shouldn't use them! +# Using TypedDict over NamedTuple because I want to use item assignment. +class MathNode(NamedTuple): + """Simple typed container for our snail math constructs""" + left: Union['MathNode', int] + right: Union['MathNode', int] + +def parse_node(input_string: str) -> Union[MathNode, int, None]: + """Recursive parsing function""" + # Hey it's cool how we already did a problem where we kept track of + # brackets. Anyway, given a string that starts and ends with a bracket, use + # a counter to find the comma that splits it, and parse each side + # separately. If we're given an integer string, return that + if input_string in digits: + return int(input_string) + bracket_counter = 0 + for idx, character in enumerate(input_string): + if character == "[": + bracket_counter += 1 + elif character == "]": + bracket_counter -= 1 + elif character == "," and bracket_counter == 1: + return MathNode(parse_node(input_string[1:idx]), + parse_node(input_string[(idx+1):-1])) + return None + +def parse_input(input_string: str) -> List[MathNode]: + """Parses a list of snail math expressions, line-by-line""" + return [cast(MathNode, parse_node(line)) for line in \ + input_string.rstrip('\n').split('\n')] + +def apply_to_leftmost(input_node: Union[MathNode, int], + value: Union[int, None]) -> Union[MathNode, int]: + """Apply the value to the leftmost value in the tree""" + if value is None: + return input_node + if isinstance(input_node, int): + return input_node + value + return MathNode(left = apply_to_leftmost(input_node.left, value), + right = input_node.right) + +def apply_to_rightmost(input_node: Union[MathNode, int], + value: Union[int, None]) -> Union[MathNode, int]: + """Apply the value to the rightmost value in the tree""" + if value is None: + return input_node + if isinstance(input_node, int): + return input_node + value + return MathNode(left = input_node.left, + right = apply_to_rightmost(input_node.right, value)) + +def explode_node(branch_node: Union[MathNode, int], + recursion_level: int = 0) -> \ + Tuple[Union[MathNode, int], bool, Union[int, None], Union[int, None]]: + """Recursively performs node "explosion". Returns a new (potentially) + exploded tree, a flag indicating whether explosions occurred, and values to + be applied to the left and right of the exploded node (if no values need to + be applied, these are None)""" + if isinstance(branch_node, int): + return (branch_node, False, None, None) + + # Here is where explosion actually occurrs + if recursion_level == 4: + return (0, True, branch_node.left, branch_node.right) + + # Handle the left child + left_child, did_explode, left_value, right_value = explode_node( + branch_node.left, recursion_level + 1 + ) + if did_explode: + return (MathNode(left = left_child, + right = apply_to_leftmost(branch_node.right, + right_value)), + did_explode, left_value, None) + + # Handle the right child + right_child, did_explode, left_value, right_value = explode_node( + branch_node.right, recursion_level + 1 + ) + if did_explode: + return (MathNode(left = apply_to_rightmost(branch_node.left, + left_value), + right = right_child), + did_explode, None, right_value) + + # If we made it here, this node and none of its children exploded, so it + # can return safely + return (branch_node, False, None, None) + +def split_node(input_node: Union[MathNode, int]) -> \ + Tuple[Union[MathNode, int], bool]: + """Searching from left to right recursively, finds the first node to split (if any) and + splits it. Returns a tuple containing a (potentially) new tree structure + and a boolean indicating whether splitting occurred""" + if isinstance(input_node, int): + if input_node >= 10: + return (MathNode(left = input_node // 2, + right = ceil(input_node / 2)), + True) + # If it's an integer below 10 return it safely + return (input_node, False) + + # Descend the tree, left-branch first + left_child, did_split = split_node(input_node.left) + if did_split: + return (MathNode(left = left_child, + right = input_node.right), + did_split) + + right_child, did_split = split_node(input_node.right) + if did_split: + return (MathNode(left = input_node.left, + right = right_child), + did_split) + + return (input_node, False) + +def reduce_nodes(input_node: MathNode) -> MathNode: + """Launch the reduction steps""" + # From the puzzle text: "To reduce a snailfish number, you must repeatedly + # do the first action [between explode and split] that applies to the + # snailfish number". The text is a little ambiguous, but I'm currently + # understanding it to mean that no splits occur until all explosions are + # carried out, but that if a split creates an explosion condition, then all + # explosions must be carried out before we go back to splitting. This lends + # itself to a nested structure, with splitting occurring in the outer loop, + # and exploding happening in the inner loop. + output_node = input_node + reducing_done = False + while not reducing_done: + output_node, did_explode, _, _ = explode_node(output_node) + if not did_explode: + # If exploding didn't happen on this iteration of the loop, check + # to see if splitting is necessary + output_node, did_split = split_node(output_node) + if not did_split: + # If neither exploding NOR splitting happened, then we're done + # reducing. Otherwise, splitting occurred, and we need to check + # for exploding or splitting again + reducing_done = True + return output_node + +def add_nodes(left_operand: MathNode, right_operand: MathNode) -> MathNode: + """Performs addition _and_ reduction on the given pair of MathNodes""" + return reduce_nodes(MathNode(left = left_operand, right = right_operand)) + +def find_magnitude(input_node: Union[MathNode, int]) -> int: + """Find the magnitude of the final sum""" + if isinstance(input_node, int): + return input_node + return 3 * find_magnitude(input_node.left) + \ + 2 * find_magnitude(input_node.right) + +def solve_puzzle(input_string: str) -> int: + """Return the numeric solution to the puzzle""" + return find_magnitude( + reduce(add_nodes, + parse_input(input_string)) + ) + +def main() -> None: + """Run when the file is called as a script""" + assert solve_puzzle(EXAMPLE_INPUT) == 4140 + print("Magnitude of the final sum:", + solve_puzzle(get_puzzle_input(18))) + +if __name__ == "__main__": + main()