day14_part1.py (4816B)
1 #!/usr/bin/env python 2 """Advent of Code 2021, day 14 (part 1): Extended Polymerization 3 Perform 'pair insertions' on a 'polymer' string""" 4 5 # Since the length of the string basically doubles every step of the problem, 6 # we need to make sure we have an efficient representation of the data. And 7 # once again, I'm going to use pandas (a Series) even when a dict would do just 8 # fine so that I can continue to familiarize myself with it 9 10 from typing import List, Tuple, cast 11 from functools import reduce 12 import pandas as pd 13 from utils import get_puzzle_input 14 15 EXAMPLE_INPUT = \ 16 """NNCB 17 18 CH -> B 19 HH -> N 20 CB -> H 21 NH -> C 22 HB -> C 23 HC -> B 24 HN -> C 25 NN -> C 26 BH -> H 27 NC -> B 28 NB -> B 29 BN -> B 30 BB -> N 31 BC -> B 32 CC -> N 33 CN -> C 34 """ 35 36 def split_instructions(instructions_list: List[str]) -> pd.Series: 37 """parses a list of the form ["AB", "C"] and returns a Series equivalent to 38 [("A", "C"), ("C", "B")]""" 39 return pd.Series([(instructions_list[0][0], instructions_list[1]), 40 (instructions_list[1], instructions_list[0][1])]) 41 42 def parse_input(input_string: str) -> Tuple[pd.Series, pd.Series, Tuple[str, str]]: 43 """Read the polymer template and the pair insertion instructions and 44 return: 45 * A pandas series giving the count of each pair (pairs are the index 46 elements and counts are the values) 47 * A pandas series representing insertion instructions (start pair is 48 the index and the values are new pairs) 49 * The identity of the first and last elements in the chain, since every 50 element but these will be double-counted""" 51 # Again, python's built-in types are perfectly sufficient for this job, but 52 # I'm still getting a handle on pandas's indexing. 53 lines = input_string.rstrip('\n').split('\n') 54 55 first_last = (lines[0][0], lines[0][-1]) 56 57 # Using a Series as a Tuple[str, str] -> int dictionary 58 pair_counts = pd.Series(1, 59 index=zip(lines[0][0:-1], lines[0][1:]), 60 name='pair_count') 61 62 # Using a Series to map Tuple[str, str] -> two Tuple[str, str] entries, 63 # representing the new set of pairs created by an insertion 64 instruction_pairs = pd.Series(lines[2:]).str.split(' -> ') 65 pair_insertions = ( 66 instruction_pairs.apply(split_instructions) 67 .set_axis(instruction_pairs.map(lambda x: (x[0][0], x[0][1]))) 68 .melt(value_vars=[0, 1], ignore_index=False, value_name='new_pairs') 69 .loc[:, 'new_pairs'] 70 ) 71 72 return (pair_counts, pair_insertions, first_last) 73 74 def insert_at_pairs(pair_counts: pd.Series, 75 pair_insertions: pd.Series) -> pd.Series: 76 """Perform the pair insertions and return an updated series of pair 77 counts""" 78 # Merge the counts and insertions series to get a data frame 79 return ( 80 pd.merge(pair_insertions, pair_counts, 81 left_index=True, right_index=True) 82 .groupby('new_pairs') 83 .sum() 84 .rename_axis(None) 85 .loc[:, 'pair_count'] 86 ) 87 88 def count_elements(pair_counts: pd.Series, 89 first_last: Tuple[str, str]) -> pd.Series: 90 """Given the count of the pairs and the identity of the first and last 91 element in the string, return the count of each individual element""" 92 # Everything was double-counted except we're missing one instance each of 93 # the first and last element (that's why we preserve them) 94 # Note (to self?): `cast` is just here to make mypy happy 95 element_count = ( 96 pd.concat([pair_counts.rename(lambda x: cast(tuple, x)[0]), 97 pair_counts.rename(lambda x: cast(tuple, x)[1])]) 98 .groupby(level=0) # Group by indices 99 .sum() 100 .rename('element_count') # Superflous, but nice 101 ) 102 element_count[list(first_last)] += 1 103 element_count /= 2 104 return element_count 105 106 def apply_insertions(pair_counts: pd.Series, 107 pair_insertions: pd.Series, 108 first_last: Tuple[str, str], 109 num_insertions: int) -> pd.Series: 110 """Apply `num_insertions` insertions and get the final count of the 111 _elements_""" 112 return count_elements( 113 reduce(lambda x, y: insert_at_pairs(x, pair_insertions), 114 range(num_insertions), 115 pair_counts), 116 first_last 117 ) 118 119 def solve_puzzle(input_string: str) -> int: 120 """Return the numeric solution to the puzzle""" 121 element_count = apply_insertions(*(parse_input(input_string) + (10,))) 122 return int(element_count.max() - element_count.min()) 123 124 def main() -> None: 125 """Run when the file is called as a script""" 126 assert solve_puzzle(EXAMPLE_INPUT) == 1588 127 print("Difference between most and least common elements:", 128 solve_puzzle(get_puzzle_input(14))) 129 130 if __name__ == "__main__": 131 main()