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 40ae9ac2d3f794b51685a439516e4b220b8b703e
parent 4a8670dc62efc98f30b853c6c4c3753bd83e3e1b
Author: Eamon Caddigan <eamon.caddigan@gmail.com>
Date:   Sun, 19 Dec 2021 14:38:13 -0500

Solution to day 19, part 1

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

diff --git a/day19_part1.py b/day19_part1.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python +"""Advent of Code 2021, day 19 (part 1): Beacon Scanner +Given positions (but not consistent frame of reference), find the overlap +between a set of beacons detected by several scanners""" + +# Looking at the leaderboard, this one has been hard for folks. My first +# impression is that its pretty doable if I can get away with brute forcing a +# few parts of it, but if I need to be sophisticated (i.e., if a bunch of +# polynomial time approaches dont scale), then this could be a huge slog. +# +# Numpy and SciPy are helping out here, but neither are doing any algorithmic +# heavy lifting (as was the case when using optimization or graph search from +# SciPy in previous puzzles). +# +# Note: I'm using _augmented matrices_ to represent the beacon locations and +# transformations. 3D data is represented by 4D vectors padded with a 1, +# allowing translations to be implemented with matrix multiplication. + +from typing import List, Union, Tuple +from itertools import product +import numpy as np +from scipy.spatial.transform import Rotation as R +from scipy.spatial.distance import cdist +from utils import get_puzzle_input + +EXAMPLE_INPUT = \ +"""--- scanner 0 --- +404,-588,-901 +528,-643,409 +-838,591,734 +390,-675,-793 +-537,-823,-458 +-485,-357,347 +-345,-311,381 +-661,-816,-575 +-876,649,763 +-618,-824,-621 +553,345,-567 +474,580,667 +-447,-329,318 +-584,868,-557 +544,-627,-890 +564,392,-477 +455,729,728 +-892,524,684 +-689,845,-530 +423,-701,434 +7,-33,-71 +630,319,-379 +443,580,662 +-789,900,-551 +459,-707,401 + +--- scanner 1 --- +686,422,578 +605,423,415 +515,917,-361 +-336,658,858 +95,138,22 +-476,619,847 +-340,-569,-846 +567,-361,727 +-460,603,-452 +669,-402,600 +729,430,532 +-500,-761,534 +-322,571,750 +-466,-666,-811 +-429,-592,574 +-355,545,-477 +703,-491,-529 +-328,-685,520 +413,935,-424 +-391,539,-444 +586,-435,557 +-364,-763,-893 +807,-499,-711 +755,-354,-619 +553,889,-390 + +--- scanner 2 --- +649,640,665 +682,-795,504 +-784,533,-524 +-644,584,-595 +-588,-843,648 +-30,6,44 +-674,560,763 +500,723,-460 +609,671,-379 +-555,-800,653 +-675,-892,-343 +697,-426,-610 +578,704,681 +493,664,-388 +-671,-858,530 +-667,343,800 +571,-461,-707 +-138,-166,112 +-889,563,-600 +646,-828,498 +640,759,510 +-630,509,768 +-681,-892,-333 +673,-379,-804 +-742,-814,-386 +577,-820,562 + +--- scanner 3 --- +-589,542,597 +605,-692,669 +-500,565,-823 +-660,373,557 +-458,-679,-417 +-488,449,543 +-626,468,-788 +338,-750,-386 +528,-832,-391 +562,-778,733 +-938,-730,414 +543,643,-506 +-524,371,-870 +407,773,750 +-104,29,83 +378,-903,-323 +-778,-728,485 +426,699,580 +-438,-605,-362 +-469,-447,-387 +509,732,623 +647,635,-688 +-868,-804,481 +614,-800,639 +595,780,-596 + +--- scanner 4 --- +727,592,562 +-293,-554,779 +441,611,-461 +-714,465,-776 +-743,427,-804 +-660,-479,-426 +832,-632,460 +927,-485,-438 +408,393,-506 +466,436,-512 +110,16,151 +-258,-428,682 +-393,719,612 +-211,-452,876 +808,-476,-593 +-575,615,604 +-485,667,467 +-680,325,-822 +-627,-443,-432 +872,-547,-609 +833,512,582 +807,604,487 +839,-516,451 +891,-625,532 +-652,-548,-490 +30,-46,-14 +""" + +def parse_input(input_string: str) -> List[np.ndarray]: + """Given the puzzle input, return a list comprising all the + beacons detected by each scanner""" + # I do not love how hacky this is + scanner_beacons = [] + beacon_start = 0 + # Depends on the trailing newline in the puzzle input. Hacky! + input_lines = input_string.split('\n') + for idx, line in enumerate(input_lines): + if line.startswith('---'): + beacon_start = idx + 1 + elif line == '': + # End of a probe's beacons + scanner_beacons.append( + np.array([l.split(',') + ['1'] for l in input_lines[beacon_start:idx]]) + .astype(float) + ) + return scanner_beacons + +def augment_matrix(rotation_matrix: np.ndarray) -> np.ndarray: + """Given a 3x3 transformation matrix, return a 4x4 augmented matrix + representing the same transformation""" + return np.vstack(( + np.hstack((rotation_matrix, np.zeros((3, 1)))), + np.array([0, 0, 0, 1]) + )) + +def create_rotations() -> List[np.ndarray]: + """Create the 24 distinct rotation matrices for a probe that can be + oriented to point along any direction of the x, y, and z axis, with its top + aligned along one of the other two axes""" + # This function is inefficient; it considers all possible 90 degree + # rotations along each of the three axes (i.e., 4^3 = 64 combinations), and + # then only returns the 24 unique ones. One could probably reason out how + # to more directly generate the 24 that matter, but this works! + # This first step creates a Rotation instance that stacks all 64 rotations + rotations = R.from_euler( + 'zyx', + np.array(list(product(*(((0, 90, 180, 270),) * 3)))), + degrees=True + ) + # This next step creates a list of non-duplicate rotation matricies + final_rotations = [augment_matrix( + rotations[0].as_matrix().round() + )] + for rot_obj in rotations[1:]: + # In our case, all of the elements of the rotation matrix should be -1, + # 0, or 1, so we'll round everything to the nearest value to make + # equality checking easier + rot_mat = augment_matrix(rot_obj.as_matrix().round()) + for prev_rot_mat in final_rotations: + if (rot_mat == prev_rot_mat).all(): + break + else: + # We get here if the for loop completed without breaking, which + # means we have a unique rotation matrix + final_rotations.append(rot_mat) + return final_rotations + +def translation_matrix(offset: np.ndarray) -> np.ndarray: + """Given a length-three array representing the x, y, and z offset, return a + 4x4 translation matrix (if the offset array is longer than three elements, + only the first three are used, so it's safe to lazily pass length-four + arrays too)""" + return np.vstack(( + np.identity(4)[0:3, :], + np.append(offset[0:3], 1) + )) + +def count_overlapping_beacons(scanner_1: np.ndarray, + scanner_2: np.ndarray) -> int: + """Given a pair of (appropriately rotated and translated) beacon positions, + count how many from each scanner are at approximately the same location""" + return (cdist(scanner_1, scanner_2) < 1e-8).sum() + +def find_best_overlap(scanner_1: np.ndarray, + scanner_2: np.ndarray) -> Union[np.ndarray, None]: + """Find the combined rotation/translation matrix for scanner 2 that results + in (magic number) 12 overlapping beacons, or return None if no such + transformation exists""" + # The algorithm: for each possible rotation, calculate the distance matrix + # (using Euclidean distance) between the two sets of beacons. If (magic + # number) 12 or more beacon pairs have the same distance, translate + # scanner_2's beacons so that the first such pair has a distance of zero. + # If the remainder of the same-distance pairs don't also go to zero, + # continue to the next pair, until there are fewer than twelve remaining + # pairs to check. + for rot_mat in create_rotations(): + scanner_2_rot = scanner_2.dot(rot_mat) + dist_mat = cdist(scanner_1, scanner_2_rot) + # We'll round the distance matrix to get a sense of the mode + beacon_dist_count = np.bincount( + np.reshape(dist_mat.round(), dist_mat.size) + .astype(int) + ) + if beacon_dist_count.max() >= 12: + # There are at least 12 beacon pairs with similar distances. + # Iterate through the pairs to see if translating one to the other + # provides a match. + potential_beacon_matches = np.nonzero( + dist_mat.round() == np.argmax(beacon_dist_count) + ) + # Technically we can stop iterating when there are fewer than 12 + # possible matches but I don't feel like programming that in unless + # I have to. + for scanner_1_beacon_idx, scanner_2_beacon_idx in \ + zip(*potential_beacon_matches): + scanner_1_beacon = scanner_1[scanner_1_beacon_idx, :] + scanner_2_beacon = scanner_2_rot[scanner_2_beacon_idx, :] + trans_mat = translation_matrix( + scanner_1_beacon - scanner_2_beacon + ) + num_overlapping = count_overlapping_beacons( + scanner_1, + scanner_2_rot.dot(trans_mat) + ) + if num_overlapping >= 12: + return rot_mat.dot(trans_mat) + return None + +def create_beacon_network(scanner_beacons: List[np.ndarray]) -> \ + Tuple[np.ndarray, List[np.ndarray]]: + """Given a list of scanner beacons, find all of the unique beacons across + all scanners by rotating and translating the scanner coordinates (relative + to the first beacon in the list). Returns the final beacon locations and a + list of translation matrices.""" + scanner_beacon_list: Union[None, np.ndarray] = scanner_beacons.copy() + translation_list: Union[None, np.ndarray] = [None] * len(scanner_beacon_list) + + final_beacons = scanner_beacon_list[0] + scanner_beacon_list[0] = None + translation_list[0] = np.identity(4) + + # The way the problem is written suggests that not every pair of scanners + # has an overlapping set of beacons. This algorithm loops through all + # scanners sequentially. When it finds overlap with the network, its + # beacons are added to the network and it's removed from further + # consideration. Eventually, all of the beacons should get added! + while sum([x is not None for x in scanner_beacon_list]) > 0: + for scanner_id, beacons in enumerate(scanner_beacon_list): + if beacons is not None: + trans_mat = find_best_overlap(final_beacons, beacons) + if trans_mat is not None: + final_beacons = np.unique( + np.vstack(( + final_beacons, + beacons.dot(trans_mat) + )), + axis=0 + ) + + scanner_beacon_list[scanner_id] = None + translation_list[scanner_id] = trans_mat + + return final_beacons, translation_list + +def solve_puzzle(input_string: str) -> int: + """Return the numeric solution to the puzzle""" + return create_beacon_network(parse_input(input_string))[0].shape[0] + +def main() -> None: + """Run when the file is called as a script""" + assert solve_puzzle(EXAMPLE_INPUT) == 79 + print("Total number of beacons:", + solve_puzzle(get_puzzle_input(19))) + +if __name__ == "__main__": + main()