Day 21

So we're given a kind of splitting pattern problem. The rules of the problem are this:

  • We have a square grid of on and off switches that will be iterated over
  • If the side length is divisible by 2, we break the thing into 2x2 grids, then transform them all into 3x3 grids.
  • If the side length is divisible by 3, break the grid into 3x3 pieces then transform them to 4x4s.
  • At each shift (from 2->3 and 3->4) there are various rules about how particular patterns will be transformed. The patterns, however, apply to each combination of flips and rotations flips to the first stage (it's not clear to me whether or not combinations are allowed at the moment though...)

This one seems like it's really going to test my understanding of np.array. Let's start by just getting the input into the computer and trying to transform all the instructions into np.arrays

In [1]:
import numpy as np
import math
original = np.array([[0, 1, 0], [0, 0, 1], [1, 1, 1]])
enhancements = open('/Users/Sven/py_files/aoc_2017/day21_input.txt').readlines()
enhancements = [x.replace('.', '0').replace('#', '1').replace('\n', '') for x in enhancements]
enhancements[:5]
Out[1]:
['00/00 => 001/100/010',
 '10/00 => 100/100/000',
 '11/00 => 111/101/100',
 '01/10 => 111/110/010',
 '11/10 => 000/010/001']
In [2]:
enh_in = [x.split(' => ')[0] for x in enhancements]
enh_out = [x.split(' => ')[1] for x in enhancements]
print(enh_in[:5])
print(enh_out[:5])
['00/00', '10/00', '11/00', '01/10', '11/10']
['001/100/010', '100/100/000', '111/101/100', '111/110/010', '000/010/001']

K so we need to maybe make a funcion just to format these to arrays:

In [3]:
def split_string(string):
    return([int(x) for x in string])

def code_to_array(code):
    # split the rows
    rows = code.split('/')
    rows = [split_string(x) for x in rows]
    #y for x in non_flat for y in x
    return(rows)
code_to_array(enh_in[0])
Out[3]:
[[0, 0], [0, 0]]

Now apply to the whole list of inputs and outputs

In [4]:
enh_in = np.array([np.array(code_to_array(x)) for x in enh_in])
enh_out = np.array([np.array(code_to_array(x)) for x in enh_out])

for i in range(5):
    print(enh_in[i], end = '\n\n')
[[0 0]
 [0 0]]

[[1 0]
 [0 0]]

[[1 1]
 [0 0]]

[[0 1]
 [1 0]]

[[1 1]
 [1 0]]

So if I can remember my abstract algebra correctly this is the dihedral group D8. It has 8 possible orientations which are formed by the original position, 1-3 rotations in the same direction, the two reflections as well as one rotation and either reflection. I think we'll want to compute all these transformations for each of the enhancements.

In [5]:
# First type of flip
def flip1(mat):
    flipped = [np.flip(x).tolist() for x in mat]
    return(np.array(flipped))  
print(enh_in[13], end = '\n\n')
print(flip1(enh_in[13]))
[[1 1 0]
 [1 0 0]
 [0 0 0]]

[[0 1 1]
 [0 0 1]
 [0 0 0]]
In [6]:
# second kind of flip. Not quite sure how I intuitioned this but
# I think a transpose flip1 transpose is the same as a vertical flip
def flip2(mat):
    flipped = np.transpose(flip1(np.transpose(mat)))
    return(flipped)
print(enh_in[13])
print(flip2(enh_in[13]))
[[1 1 0]
 [1 0 0]
 [0 0 0]]
[[0 0 0]
 [1 0 0]
 [1 1 0]]
In [7]:
# Now for a rotation, I think we'll just worry about the 2x2 and 3x3 cases.. 
# I'm pretty sure I can program the general one but... ya know... laziness
rota3 = [2, 5, 8, 1, 4, 7, 0, 3, 6]
rota2 = [1, 3, 0, 2]
def flatten(mat):
    return([y for x in mat for y in x])
def rotate(mat):
    # kinda think this will be easier if we just flatten
    # and cheater my way through this
    l = len(mat)
    flattened = flatten(mat)
    
    if l == 3:
        flattened = [flattened[i] for i in rota3]
    else:
        flattened = [flattened[i] for i in rota2]
    mat = np.array(flattened)
    mat.shape = (l, l)
    return(mat)
print(enh_in[13], rotate(enh_in[13]), sep = '\n\n')
print()
[[1 1 0]
 [1 0 0]
 [0 0 0]]

[[0 0 0]
 [1 0 0]
 [1 1 0]]

Great! Now we need to apply the 7 transformations to each matrix and take the unique values

In [8]:
def get_transformations(mat):
    transformations = [
        mat,
        rotate(mat),
        rotate(rotate(mat)),
        rotate(rotate(rotate(mat))),
        flip1(mat),
        flip2(mat),
        flip1(rotate(mat)),
        flip2(rotate(mat))
        
    ]
    transformations = np.unique(np.array(transformations), axis = 0)
    return(transformations)
print(enh_in[14])
print('\nTransformations:\n')
ww = get_transformations(enh_in[14])
for t in ww:
    print(t, end = '\n\n')
[[0 0 1]
 [1 0 0]
 [0 0 0]]

Transformations:

[[0 0 0]
 [0 0 1]
 [1 0 0]]

[[0 0 0]
 [1 0 0]
 [0 0 1]]

[[0 0 1]
 [0 0 0]
 [0 1 0]]

[[0 0 1]
 [1 0 0]
 [0 0 0]]

[[0 1 0]
 [0 0 0]
 [0 0 1]]

[[0 1 0]
 [0 0 0]
 [1 0 0]]

[[1 0 0]
 [0 0 0]
 [0 1 0]]

[[1 0 0]
 [0 0 1]
 [0 0 0]]

Lovely, now we have to apply this to every input and make the association with the output arrays. I've learned that lists are 'not hashable' and thus cannot be the keys to dictionaries. Instead I think we'll use a tuple version of the matrix as the keys.

In [9]:
def make_associations(old, new):
    old = get_transformations(old)
    new = [new for i in range(len(old))]
    old = [tuple(flatten(mat)) for mat in old]
    return dict(zip(old, new))
make_associations(enh_in[1], enh_out[1])
Out[9]:
{(0, 0, 0, 1): array([[1, 0, 0],
        [1, 0, 0],
        [0, 0, 0]]), (0, 0, 1, 0): array([[1, 0, 0],
        [1, 0, 0],
        [0, 0, 0]]), (0, 1, 0, 0): array([[1, 0, 0],
        [1, 0, 0],
        [0, 0, 0]]), (1, 0, 0, 0): array([[1, 0, 0],
        [1, 0, 0],
        [0, 0, 0]])}

Now we make a big dict (heh) of all the associations.

In [10]:
mapping = list(map(make_associations, enh_in, enh_out))
enh_dict = dict()
for i in range(len(mapping)):
    enh_dict.update(mapping[i])
    
# here to shortcut the dictionary access:   
def enhance_array(arr):
    return(enh_dict[tuple(flatten(arr))])
test = enhance_array(original)
test2 = enhance_array(original)
print(test, test2, sep = '\n\n')
[[1 1 1 0]
 [1 1 1 1]
 [1 0 1 1]
 [0 0 1 0]]

[[1 1 1 0]
 [1 1 1 1]
 [1 0 1 1]
 [0 0 1 0]]

Alright that's mighty grand. I think the hardest part of this will probably be figuring out how to put the arrays back together after we find their enhancements.

In [11]:
#import random
#ww = np.random.choice([0, 1], 36)
#ww = np.array(ww)
#ww.shape = (6, 6)
#ww
In [12]:
def split_array(arr):
    # which stage are we at?
    if len(arr) % 2 == 0:
        divisor = 2
    else:
        divisor = 3
    
    # this is how many 'blocks' each side will have
    quotient = int(len(arr) / divisor)
    
    # Now make the subarrays
    subarrays = []
    for i in range(quotient):
        for j in range(quotient):
            #print(arr[divisor*i:divisor*(i+1), divisor*j:divisor*(j+1)])
            subarrays.append(arr[divisor*i:divisor*(i+1), divisor*j:divisor*(j+1)])
    return(subarrays)
# Note that I returned a list of arrays, not a np.array
print(test)
print(split_array(test))
for x in split_array(test):
    print(x)
[[1 1 1 0]
 [1 1 1 1]
 [1 0 1 1]
 [0 0 1 0]]
[array([[1, 1],
       [1, 1]]), array([[1, 0],
       [1, 1]]), array([[1, 0],
       [0, 0]]), array([[1, 1],
       [1, 0]])]
[[1 1]
 [1 1]]
[[1 0]
 [1 1]]
[[1 0]
 [0 0]]
[[1 1]
 [1 0]]

Maybe this won't be so hard? Now this to recombine:

In [13]:
def recombine_array(arr):
    
    # heres the small pieces that make up the whole
    subarr_len = len(arr[0])
    # Here's the number of such pieces
    n_blocks = len(arr)
    n_blocks_rt = int(math.sqrt(n_blocks))

    # this is what we'll spit out
    recombined = []

    # let the looping begin.. first go across blocks of size subarr_len
    for first_ind in range(n_blocks_rt):
        # To get the alternating pattern right we'll use two smaller loops
        for second_ind in range(subarr_len):
            new_row = []
            for third_ind in range(n_blocks_rt):
                sublock = arr[first_ind*n_blocks_rt + third_ind][second_ind]
                new_row += sublock.tolist()
            recombined.append(new_row)
    return(np.array(recombined))
print(test)
print(recombine_array(split_array(test)))
np.equal(test, recombine_array(split_array(test))).all()
[[1 1 1 0]
 [1 1 1 1]
 [1 0 1 1]
 [0 0 1 0]]
[[1 1 1 0]
 [1 1 1 1]
 [1 0 1 1]
 [0 0 1 0]]
Out[13]:
True

Now it looks like it's loop time. Time for 5 iterations:

In [14]:
five_its = np.copy(original)
for i in range(5):
    five_its = split_array(five_its)
    five_its = [enhance_array(x) for x in five_its]
    five_its = recombine_array(five_its)
In [15]:
np.sum(five_its)
Out[15]:
117

Okay, this turned out to be one of the hardest ones for me. I think it has taught me the most about np.arrays though. I had a lot of issues with accidentally making arrays of arrays and having the dimensions not line up. Some of the loops I wrote seem very easy to me now but I struggled WAY too much with the recombining array function.

Part 2

We are now simply asked about after 18 iterations... I wonder if either my method will break or other people solved this in a sneakier way than literally mimicking the process.

In [16]:
eighteen_its = np.copy(original)
for i in range(18):
    eighteen_its = split_array(eighteen_its)
    eighteen_its = [enhance_array(x) for x in eighteen_its]
    eighteen_its = recombine_array(eighteen_its)
np.sum(eighteen_its)
Out[16]:
2026963