Sean McLemon | Advent of Code

Home | Czech | Blog | GitHub | Advent Of Code | Notes


2020-12-20 - Jurassic Jigsaw

(original .ipynb)

Day 20 input is a sequence of "tiles" - grids composed of # and . characters (mine is here). The tiles can be combined to form a grid, where a tile can be beside another tile if its edges match up. They can be rotated or flipped (think of them like a jigsaw piece). Part 1 involves combining them to find the corner tiles. Part 2 involves producing an output image and looking for a certain "monster" pattern.

from collections import defaultdict
from functools import reduce

puzzle_input_str = open("puzzle_input/day20.txt", "r").read()

test_input_str = """Tile 2311:
..##.#..#.
##..#.....
#...##..#.
####.#...#
##.##.###.
##...#.###
.#.#.#..##
..#....#..
###...#.#.
..###..###

Tile 1951:
#.##...##.
#.####...#
.....#..##
#...######
.##.#....#
.###.#####
###.##.##.
.###....#.
..#.#..#.#
#...##.#..

Tile 1171:
####...##.
#..##.#..#
##.#..#.#.
.###.####.
..###.####
.##....##.
.#...####.
#.##.####.
####..#...
.....##...

Tile 1427:
###.##.#..
.#..#.##..
.#.##.#..#
#.#.#.##.#
....#...##
...##..##.
...#.#####
.#.####.#.
..#..###.#
..##.#..#.

Tile 1489:
##.#.#....
..##...#..
.##..##...
..#...#...
#####...#.
#..#.#.#.#
...#.#.#..
##.#...##.
..##.##.##
###.##.#..

Tile 2473:
#....####.
#..#.##...
#.##..#...
######.#.#
.#...#.#.#
.#########
.###.#..#.
########.#
##...##.#.
..###.#.#.

Tile 2971:
..#.#....#
#...###...
#.#.###...
##.##..#..
.#####..##
.#..####.#
#..#.#..#.
..####.###
..#.#.###.
...#.#.#.#

Tile 2729:
...#.#.#.#
####.#....
..#.#.....
....#..#.#
.##..##.#.
.#.####...
####.#.#..
##.####...
##..#.##..
#.##...##.

Tile 3079:
#.#.#####.
.#..######
..#.......
######....
####.#..#.
.#...#.##.
#.#####.##
..#.###...
..#.......
..#.###..."""


def parse_tile(tile_str):
    tile_lines = tile_str.split("\n")
    
    id_str = tile_lines[0].strip().removeprefix("Tile ").removesuffix(":")
    tile_lines = tile_lines[1:]
    
    tile = [ 
        [c for c in row]
        for row in tile_lines
    ]
    
    return int(id_str), tile     

def parse_input(input_str):
    tile_strs = input_str.split("\n\n")
    return [parse_tile(tile_str) for tile_str in tile_strs]

def tile_side_patterns(tile, step=1):
    top =    "".join(tile[0])
    bottom = "".join(tile[len(tile)-1])
    left =   "".join([row[0] for row in tile])
    right =  "".join([row[len(row)-1] for row in tile])
    
    return top[::step], bottom[::step], left[::step], right[::step]

def tile_is_corner(tile_id, sides_by_tiles, tiles_by_sides):
    shared_sides = 0
    for side in sides_by_tiles[tile_id]:
        shared_sides += len(tiles_by_sides[side]) - 1
    
    return shared_sides == 2

def get_side_mappings(tiles):
    sides_by_tiles = defaultdict(lambda:[])
    tiles_by_sides = defaultdict(lambda:[])
    
    for tile_id, tile in tiles:
        # normal
        for side in tile_side_patterns(tile):
            tiles_by_sides[side].append(tile_id)
            sides_by_tiles[tile_id].append(side)
            
        # flipped
        for side in tile_side_patterns(tile, -1):
            tiles_by_sides[side].append(tile_id)
            
    return sides_by_tiles, tiles_by_sides

def find_corner_tiles(tiles):
    sides_by_tiles, tiles_by_sides = get_side_mappings(tiles)
    return [
        tile_id 
        for tile_id,tile in tiles 
        if tile_is_corner(tile_id, sides_by_tiles, tiles_by_sides)
    ]

def part1(input_str):
    tiles = parse_input(input_str)
    corner_tiles = find_corner_tiles(tiles)
    return reduce(lambda a,b: a*b, corner_tiles)
    
assert 20899048083289 == part1(test_input_str)

part1(puzzle_input_str)
20033377297069
import math
from pprint import pprint

def rotate(tile):
    # this zip(...) expr to rotate a 2D array is stupendous.
    # obviously I didn't come up with it
    return [list(row) for row in zip(*tile[::-1])]

def flip(tile):
    # this expr to flip is very basic
    # however, I did write this one    
    return tile[::-1]

def rotations(lst):
    for _ in range(4):
        yield lst
        yield flip(lst)
        lst = rotate(lst)

def fit_below(placed_tile, target_tile):
    top_placed, bottom_placed, left_placed, right_placed = tile_side_patterns(placed_tile)
    top_target, bottom_target, left_target, right_target = tile_side_patterns(target_tile)

    return bottom_placed == top_target
    
def fit_right(placed_tile, target_tile):
    top_placed, bottom_placed, left_placed, right_placed = tile_side_patterns(placed_tile)
    top_target, bottom_target, left_target, right_target = tile_side_patterns(target_tile)
    
    return right_placed == left_target

def topleft_align(tile, tile_id, tiles_by_sides):
    for tile in rotations(tile):
        top, bottom, left, right = tile_side_patterns(tile)
    
        top = len(tiles_by_sides[top]) == 1
        left = len(tiles_by_sides[left]) == 1
    
        if top and left:
            return tile

    raise Exception(f"Not possible to align tile {tile_id} to top-left, likely it does not belong there")

def find_fit_alignment(solution, row, col, base_tile):
    up_tile   = solution[row-1][col] if row > 0 else None
    left_tile = solution[row][col-1] if col > 0 else None
    
    next_new_line = col == len(solution) - 1
    next_col = 0 if next_new_line else col + 1
    next_row = row + 1 if next_new_line else row
    
    for tile in rotations(base_tile):
        aligned = True
        
        if left_tile != None:
            aligned = aligned and fit_right(left_tile[1], tile)
            
        if up_tile != None:
            aligned = aligned and fit_below(up_tile[1], tile)

        if aligned:
            return tile, next_row, next_col      
    
    return None, row, col

def trim_tile(tile):
    return [
        "".join(line[1:-1])
        for line
        in tile[1:-1]
    ]

def render_tiles(tiles):
    # start of by trimming the borders of each tile
    #print(tiles[0][0][0])
    draw_tiles = []
    for row in tiles:
        new_row = []
        for tile_id, tile in row:
            new_row.append(trim_tile(tile))
        draw_tiles.append(new_row)
            
    # ok now draw_tiles has the border-less tiles, we
    # can start putting the image together. it'll be
    # an array of lines that we'll "".join("\n") at 
    # the end
    image = []
    for row in draw_tiles:
        lines = [[] for n in range(len(row[0]))]
        for tile in row:
            for i, line in enumerate(tile):
                lines[i].append(line)
    
        image += [
            [ c for c in "".join(line) ]
            for line
            in lines
        ]
                 
    return image

monster_str = "\n".join([
    "                  # ",
    "#    ##    ##    ###",
    " #  #  #  #  #  #   "
])

def get_monster_data():
    monster_coords = []
    monster_lines = monster_str.split("\n")
    height = len(monster_lines) # well yes it's three but :shrug:
    width = 0
    for r, row in enumerate(monster_lines):
        for c, col in enumerate(row):
            if col == "#":
                if c > width:
                    width = c
                monster_coords.append((r,c))
    return monster_coords, width, height

def monster_match(image, r, c, monster_data):
    monster_coords, monster_width, monster_height = monster_data
    too_far_down = r + monster_height >= len(image)
    too_far_right = c + monster_width >= len(image[0])

    if too_far_down or too_far_right:
        return []
    
    monster_found = all(image[r+mr][c+mc] == "#" for mr,mc in monster_coords)
    if not monster_found:
        return []

    return [(r+mr, c+mc) for mr, mc in monster_coords]

def part2(input_str):
    tiles = parse_input(input_str)
    tile_lookup = {tile_id:tile for tile_id,tile in tiles}
    
    sides_by_tiles, tiles_by_sides = get_side_mappings(tiles)
    corner_tiles = find_corner_tiles(tiles)
    dimension = int(math.sqrt(len(tiles)))

    # alright start off building the empty grid
    solution = []
    for r in range(dimension):
        row = []
        for c in range(dimension):
            row.append(None)
        solution.append(row)
    
    # select a corner, align it and set it to [0][0] (top-left)
    start_tile_id = corner_tiles[0]
    start_tile = topleft_align(tile_lookup[corner_tiles[0]], corner_tiles[0], tiles_by_sides)
    solution[0][0] = (start_tile_id, start_tile)    
    tiles = [t for t in tiles if t[0] != start_tile_id]
    
    # next (very inefficiently) move through the list trying to find a tile that fits
    row = 0
    col = 1
    while tiles:
        tile_id, tile = tiles.pop(0)
        aligned_tile, next_row, next_col = find_fit_alignment(solution, row, col, tile)
        
        if aligned_tile:
            solution[row][col] = (tile_id, aligned_tile)
            row, col = next_row, next_col            
        else:
            tiles.append([tile_id, tile])

    # show the solution
    for r in solution:
        print([t[0] if t else None for t in r])
        
    base_image = render_tiles(solution)
    monster_data = get_monster_data()
    
    for image in rotations(base_image):
        monster_positions = []
        
        for r, row in enumerate(image):
            for c, col in enumerate(row):
                monster_positions += monster_match(image, r, c, monster_data)
                    
        if len(monster_positions) > 0:
            for r,c in monster_positions:
                image[r][c] = "O"
            #pprint(["".join(line) for line in image])
            roughness = 0
            for row in image:
                for col in row:
                    if col == "#":
                        roughness += 1
            return roughness
            
    # we shouldn't get here but what the hell
    return -1
 
assert 273 == part2(test_input_str)

part2(puzzle_input_str)
[1951, 2311, 3079]
[2729, 1427, 2473]
[2971, 1489, 1171]
[1831, 1423, 1069, 1877, 1871, 3989, 3851, 2087, 1747, 2663, 2633, 2789]
[3607, 3671, 2179, 1973, 3467, 2423, 2053, 3517, 2069, 1171, 1597, 1549]
[2677, 3461, 2903, 3001, 2887, 3931, 3217, 2297, 3041, 1303, 1487, 2381]
[3449, 2971, 2621, 3673, 1433, 1453, 3797, 1087, 3769, 3631, 3547, 3617]
[1451, 3023, 2549, 2897, 3343, 2917, 2089, 2221, 3643, 2311, 3943, 2729]
[1013, 1249, 2083, 3221, 3697, 1949, 3259, 1481, 2459, 3079, 3581, 2647]
[2953, 2687, 3209, 2081, 3413, 3331, 1759, 1459, 2467, 3533, 1039, 2797]
[2999, 3539, 1697, 1693, 1511, 3433, 2341, 1471, 1103, 3919, 1907, 2417]
[2731, 3313, 1657, 2927, 2129, 3911, 3011, 2237, 1279, 1151, 1109, 2029]
[3583, 2593, 2963, 2437, 1951, 1307, 1153, 2281, 2203, 2389, 2671, 2767]
[3529, 3329, 1499, 2939, 2339, 2267, 1123, 2357, 1847, 1789, 3557, 3391]
[1699, 2251, 1367, 3767, 2693, 3929, 2441, 2017, 2011, 1667, 1559, 2309]
2084