Adrián Arroyo Calle

Segunda semana del Advent of Code. En esta semana ya hemos tenido algunos problemas muy interesantes. Intentaré comentarlos de la mejor manera posible. Todo el código está aquí y en este otro post comenté mis soluciones de la primera semana.

Día 8

El día 8 se nos propone un problema de grafos también. Básicamente se nos define un árbol, donde cada nodo puede tener hijos y metadatos. En la primera parte nos piden sumar todos los metadatos.

Aquí al contrario que en el día 7, no voy a usar networkx. Era más difícil adaptar networkx al problema que hacer el árbol a mano. Uno de los puntos complicados de este problema es el parseo de la entrada, que hice de forma recursiva. Cada nodo es un diccionario con las propiedades tamaño, una lista de metadatos y una lista de nodos hijo.

En la segunda parte se define el concepto de valor de nodo y como calcularlo. También es bastante sencillo de implementar. Finalmente hacemos un recorrido por el árbol de tipo primero en anchura (BFS) con una deque de Python.

from collections import deque def read_node(start,numbers): length = 2 child_nodes = numbers[start] metadata_entries = numbers[start+1] children = list() while child_nodes > 0: child_node = read_node(start+length,numbers) children.append(child_node) length += child_node["length"] child_nodes -= 1 metadata = list() while metadata_entries > 0: metadata.append(numbers[start+length]) length += 1 metadata_entries -= 1 node = dict([("length",length),("metadata",metadata),("children",children)]) return node def read_file(file): with open(file) as f: line = f.readline() numbers = [int(x) for x in line.split()] G = read_node(0,numbers) return G def node_value(N): if len(N["children"]) == 0: return sum(N["metadata"]) else: s = 0 for i in N["metadata"]: if i-1 < len(N["children"]): s += node_value(N["children"][i-1]) return s def day8(file): G = read_file(file) to_visit = deque() to_visit.append(G) metadata_sum = 0 while len(to_visit) > 0: N = to_visit.popleft() metadata_sum += sum(N["metadata"]) to_visit.extend(N["children"]) print("METADATA SUM: %d" % metadata_sum) print("NODE VALUE: %d" % node_value(G))

Día 9

Este día fue muy interesante. Se nos explica un juego, que consiste en ir añadiendo canicas en una circunferencia y cuando el número de canica que añadimos es múltiplo de 23, obtenemos puntos y quitamos una canica 7 puestos por detrás.

Aquí tuve una mala decisión de diseño ya que al principio quise hacer esto con una lista de Python (el equivalente a vector en otros lenguajes de programación). La idea era sencilla y funcionaba hasta que llegó la parte 2. La parte 2 te pedría calcular los puntos teniendo en cuenta 100 veces más canicas. Esto fue un grave problema para mi código. Calculo que tardaría 6 horas en calcularlo, pero antes optimicé. La optimización consistía en usar una lista circular doblemente enlazada. ¿Esto qué es? Se trata de una lista enlazada, doblemente, porque cada nodo tiene referencia al elemento siguiente y al anterior. Y es circular porque ambos extremos están unidos. Esto permite las inserciones y borrados en O(1). Además los movimientos relativos (en este problema todos son así) son extremadamente sencillos. La implementación de esta estructura de datos en Python es muy sencilla (en otros lenguajes es más complicado). No me molesté en hacer funciones que me hiciesen sencilla la vida y las conexiones/desconexiones las hago a mano directamente en el código del problema.

from collections import defaultdict class Marble: def __init__(self,value,left=None,right=None): self.value = value self.left = left self.right = right if self.left == None: self.left = self if self.right == None: self.right = self def day9(PLAYERS,LAST_MARBLE): SCORE = defaultdict(lambda: 0) player = 0 marble = 0 current_marble_pos = 0 current_marble = None while marble <= LAST_MARBLE: if marble > 0 and marble % 23 == 0: SCORE[player] += marble pivote = current_marble.left.left.left.left.left.left.left SCORE[player] += pivote.value pivote.left.right = pivote.right pivote.right.left = pivote.left current_marble = pivote.right else: if current_marble == None: current_marble = Marble(marble) else: current_marble = Marble(marble,current_marble.right,current_marble.right.right) current_marble.left.right = current_marble current_marble.right.left = current_marble player += 1 player = player % PLAYERS marble += 1 return max(SCORE.values())

Curiosamente, en la propia librería de Python deque tiene una operación llamada rotate que permite hacer este problema en poquísimas líneas y de forma muy eficiente. Pero desconocía la existencia de esa función (que lo que hace es mover la "cabeza" de la lista enlazada que es deque).

Día 10

Este problema es muy interesante. Se nos da una serie de puntos que van moviéndose por la pantalla. En un determinado momento estos puntos se juntan y forman un mensaje en pantalla.

Aquí lo interesante no es mover los puntos, eso es trivial, simplemente es sumar la velocidad cada vez las coordenadas. Lo interesante es saber cuando parar. Existen varias ideas:

Revisión humana de cada iteración

Comprobar que no haya puntos separados del resto (con grafos)

Comprobar que el área de concentración de puntos es mínima

Y alguna más. Para el ejemplo la primera idea servía. Pero en la prueba real, era más complicado. A mí se me ocurrió la tercera opción, la cuál es bastante eficiente. En cada iteración calculamos el área que contiene a todos los puntos, cuando ese área ya no se reduce más, hemos llegado al mensaje.

import re def read_file(file): stars = list() p = re.compile("position=<([ -][0-9]+), ([ -][0-9]+)> velocity=<([ -][0-9]+), ([ -][0-9]+)>") with open(file) as f: lines = f.readlines() for line in lines: m = p.match(line.strip()) try: pos_x = int(m.group(1)) except: print(line) pos_y = int(m.group(2)) vel_x = int(m.group(3)) vel_y = int(m.group(4)) stars.append([pos_x,pos_y,vel_x,vel_y]) return stars def print_stars(stars): stars = sorted(stars,key=lambda x: x[0],reverse=True) min_width = stars[-1][0] max_width = stars[0][0] min_height = min(stars,key=lambda x: x[1])[1] max_height = max(stars,key=lambda x: x[1])[1] s = str() for j in range(min_height,max_height+1): p = [star for star in stars if star[1] == j] for i in range(min_width,max_width+1): if len(p) == 0: s += "." else: if any(map(lambda star: star[0] == i and star[1] == j,p)): s += "#" else: s += "." s += "

" return s def step(stars): a = map(lambda x: [x[0]+x[2],x[1]+x[3],x[2],x[3]],stars) return list(a) # LA RESPUESTA CORRECTA TIENE AREA MINIMA def area(stars): stars = sorted(stars,key=lambda x: x[0], reverse=True) min_width = stars[-1][0] max_width = stars[0][0] min_height = min(stars,key=lambda x: x[1])[1] max_height = max(stars,key=lambda x: x[1])[1] area = (max_width-min_width)*(max_height-min_height) return area def day10(file): stars = read_file(file) a = area(stars) steps = 0 while area(step(stars)) < a: stars = step(stars) steps += 1 a = area(stars) print_stars(stars) print(steps)

La parte de dibujado me costó y ahí tuve un fallo que me costó media hora aproximadamente en resolver. Una mejor opción, pero que no se me ocurrió, hubiese sido usar Pillow y crear una imagen. Es mucho más fácil que dibujar sobre una terminal (y posiblemente más rápido).

Día 11

Para este problema hay 3 posibles algoritmos. En la primera parte nos piden que de una matriz extraigamos el cuadrado de 3x3 con mayor valor. La matriz hay que construirla pero es trivial. Yo decido usar un diccionario, con clave la tupla de coordenadas. Vamos recorriendo todas las posiciones y calculamos el valor. Ahora para buscar el cuadrado, simplemente vamos probando todos los posibles cuadrados.

En la segunda parte nos dicen que bsuquemos el cuadrado máximo pero el tamaño puede ser cualquiera. Aquí con la fuerza bruta ya tarda demasiado. Mi solución fue usar programación dinámica, para ello la clave pasa a tener un valor más, el tamaño del cuadrado. Cuando creamos la tabla estamos asignando valor al cuadrado 1x1 de posición X,Y. Representado es la tupla (x,y,1). Según vamos avanzando hasta 300x300 vamos guardando los resultados intermedios, de modo que podamos reutilizarlos. Por ejemplo, el valor de (x,y,4) solamente es la suma de (x,y,2), (x+2,y,2), (x,y+2,2) y (x+2,y+2,2). Evidentemente esto solo funciona en los tamaños pares. En los tamaños impares decidí coger el cuadrado de dimensión inmediatamente menor y calcular los laterales con los cuadrados de tamaño 1. Este sistema funciona mucho mejor que la fuerza bruta pero es lento. Los profesionales usaron el algoritmo Summed Area Table (del que desconocía su existencia). Este algoritmo es el óptimo para este problema.

def generate_fuel(x,y,idg): fuel = (((x+10)*y)+idg)*(x+10) fuel %= 1000 fuel = (fuel // 100) - 5 return fuel def generate_table(idg): fuel = {(x,y,size):0 for x in range(1,301) for y in range(1,301) for size in range(1,301)} for x in range(1,301): for y in range(1,301): fuel[(x,y,1)] = generate_fuel(x,y,idg) return fuel def find_best(fuel): max_point = [-1,-1] max_score = -1 for x in range(1,301): for y in range(1,301): if x+3 > 301 or y+3 > 301: continue score = fuel[(x,y,1)]+fuel[(x+1,y,1)]+fuel[(x+2,y,1)]+fuel[(x,y+1,1)]+fuel[(x+1,y+1,1)]+fuel[(x+2,y+1,1)]+fuel[(x,y+2,1)]+fuel[(x+1,y+2,1)]+fuel[(x+2,y+2,1)] if score > max_score: max_score = score max_point = [x,y] return max_point[0],max_point[1] def find_best_any_size(fuel): max_score = -1 max_point = [-1,-1,-1] for size in range(2,300+1): for x in range(1,301): for y in range(1,301): if x+size > 301 or y+size > 301: continue if size % 2 == 0: mid = size // 2 fuel[(x,y,size)] = fuel[(x+mid,y,mid)]+fuel[(x,y+mid,mid)]+fuel[(x+mid,y+mid,mid)]+fuel[(x,y,mid)] else: fuel[(x,y,size)] = fuel[(x,y,size-1)] for i in range(x,x+size-1): fuel[(x,y,size)] += fuel[(i,y+size-1,1)] for j in range(y,y+size-1): fuel[(x,y,size)] += fuel[(x+size-1,j,1)] fuel[(x,y,size)] += fuel[(x+size-1,y+size-1,1)] score = fuel[(x,y,size)] if score > max_score: max_score = score max_point = [x,y,size] return max_point[0],max_point[1],max_point[2] def day11(): fuel = generate_table(1133) x,y = find_best(fuel) print("BEST POINT: %d,%d" % (x,y)) x,y,size = find_best_any_size(fuel) print("BEST POINT ANY SIZE: %d,%d,%d" % (x,y,size)) if __name__ == "__main__": day11()

Día 12

El día 12 me trajo recuerdos de un algoritmo con el que me peleé mucho, el denominado HashLife. El problema es un autómata celular unidimensional. Las reglas vienen dadas como patrones. La única diferencia es que hay que guardar su posición para luego calcular un número. La primera parte es bastante sencilla.

import re from collections import defaultdict def read_file(file): rules = defaultdict(lambda: ".") rule_prog = re.compile("([.#]+) => ([.#])") with open(file) as f: lines = f.readlines() state = lines[0].split(": ")[1].strip() for line in lines[2:]: m = rule_prog.match(line.strip()) rules[m.group(1)] = m.group(2) return state,rules def parse_state(pots): state = dict() for i,p in enumerate(pots): state[i] = p return state def find(rules,current): if current in rules: return rules[current] else: size = len(current) mid = size // 2 left = find(rules,current[0:mid]) right = find(rules,current[mid:]) rules[current] = left + right return rules[current] def iter(state,rules): new_state = dict() xmin = min(state.keys()) xmax = max(state.keys()) for x in range(xmin-2,xmax+3): current = ("%c%c%c%c%c" % ( state.get(x-2,"."), state.get(x-1,"."), state.get(x,"."), state.get(x+1,"."), state.get(x+2,".") )) new = rules[current] if new == "#" or xmin <= x <= xmax: new_state[x] = new return new_state def sum_pots(state): n = 0 for pot in state: if state[pot] == "#": n += pot return n def print_state(state): xmin = min(state.keys()) xmax = max(state.keys()) s = str("XMIN %d : " % xmin) for x in range(xmin-2,xmax+3): s += state.get(x,".") print(s) def day12(file): state,rules = read_file(file) state = parse_state(state) for i in range(20): #print_state(state) state = iter(state,rules) #print_state(state) n = sum_pots(state) return n if __name__ == "__main__": day12("input.txt")

La segunda parte nos pedía lo mismo pero para el número ¡50000000000! Inmediatamente pensé en optimizarlo de forma similar a HashLife. La idea consiste en almacenar patrones mayores a los de las reglas (que son todos de tamaño 5), para poder evitar cálculos innecesarios.Además añadí un recolector de basura para ir eliminando por la izquierda las celdas inútiles.

No obstante, y aunque es muchísimo más eficiente, sigue sin ser capaz de procesar tal bestialidad de número en un tiempo razonable.

Y he aquí lo que me ha cabreado, porque no he podido sacarlo. A partir de cierto momento, el dibujo siempre es el mismo pero desplazándose a la derecha. De modo que el valor del siguiente paso siempre es la suma de una constante. Finalmente modifiqué el código para que buscase una situación en la que el número fuese resultado de una suma de una constante. Una vez hecho eso, calcula con una multiplicación lo que valdría cuando llegase a 50000000000.

import re from collections import defaultdict XMIN = -2 def find(rules,current): if len(current) < 5: return "" if current in rules: return rules[current] elif len(current) == 5: return "." else: size = len(current) left=find(rules,current[0:size-1]) right=find(rules,current[size-5:]) rules[current] = left+right return rules[current] def read_file(file): rules = defaultdict(lambda: ".") rule_prog = re.compile("([.#]+) => ([.#])") with open(file) as f: lines = f.readlines() state = lines[0].split(": ")[1].strip() for line in lines[2:]: m = rule_prog.match(line.strip()) rules[m.group(1)] = m.group(2) return state,rules def print_state(state): print(state) def sum_pots(state): n = 0 for i,c in enumerate(state): if c == "#": n += i + XMIN return n def day12(file): global XMIN state,rules = read_file(file) XMAX = len(state)+1 state = "..%s.." % state sums = list() i = 0 while len(sums) < 3 or sums[-1]-sums[-2] != sums[-2]-sums[-3]: state = find(rules,"..%s.." % state) if state[0] == "." and state[1] == "." and state[2] == "." and state[3] == ".": state = state[2:] XMIN += 2 if state[0] == "#" or state[1] == "#": state = "..%s" % state XMIN -= 2 if state[-1] == "#" or state[-2] == "#": state = "%s.." % state sums.append(sum_pots(state)) i += 1 diff = sums[-1]-sums[-2] missing = 50000000000 - i n = missing*diff + sums[-1] return n

Y con esto pude finalmente calcular el resultado.

Día 13

El día 13 teníamos unas vías de tren. En estas vías había unos trenecitos que se desplazaban siguiendo unas normas. El objetivo en la primera parte era conocer el donde se producía el primer choque.