of Code es un calendario de adviento anual de acertijos de programación cuyo tema es ayudar a los elfos de Santa a prepararse para la Navidad. El entorno caprichoso enmascara el hecho de que muchos acertijos requieren una resolución algorítmica seria de problemas, especialmente hacia el final del calendario. En un artículo anterior, discutimos la importancia del pensamiento algorítmico para los científicos de datos incluso cuando la codificación asistida por IA se convierte en la norma. Dado que Advent of Code 2025 concluyó el mes pasado, este artículo analiza más de cerca una selección de problemas del evento que son especialmente relevantes para los científicos de datos. Esbozaremos algunos enfoques de soluciones interesantes en Python, destacando algoritmos y bibliotecas que pueden aprovecharse en una amplia gama de casos de uso de ciencia de datos del mundo real.
Navegando por colectores de taquiones con conjuntos y programación dinámica
El primer problema que veremos es el Día 7: Laboratorios. Se nos proporciona una variedad de taquiones en un archivo llamado input_d7.txt, como se muestra a continuación:
…….S……. …………….. …….^……. …………….. ……^.^…… …………….. …..^.^.^….. …………….. ….^…..^…. …………….. …^.^…^.^… …………….. ..^…^…..^.. …………….. .^…^.^…..^. ……………
Un haz de taquiones (“|”) comienza en la parte superior del colector y viaja hacia abajo. Si el haz golpea un divisor (“^”), se divide en dos haces, uno a cada lado del divisor. La primera parte del rompecabezas nos pide que determinemos el número de veces que se dividirá un haz dado un conjunto de condiciones iniciales (punto de partida del haz y diseño del colector). Tenga en cuenta que simplemente contar el número de divisores y multiplicar por dos no dará la respuesta correcta, ya que las vigas superpuestas solo se cuentan una vez y ninguna de las vigas llega a algunos divisores. Podemos aprovechar el álgebra de conjuntos para tener en cuenta estas restricciones, como se muestra en la implementación a continuación:
import functools def find_all_indexes(s, ch): """Devuelve un conjunto de todas las posiciones donde aparece el carácter ch en s.""" return {i for i, c in enumerate(s) if c == ch} with open("input_d7.txt") as f: first_row = f.readline() # fila que contiene las vigas iniciales ('S') f.readline() # saltar línea separadora filas = f.readlines() # filas restantes del colector beam_ids = find_all_indexes(first_row, "S") # posiciones de columnas de vigas activas split_counter = 0 # número total de divisiones para row_index, línea en enumerate(rows): # Solo las filas con índices pares contienen divisores si row_index % 2! = 0: continuar # Encontrar posiciones de divisor en esta fila splitter_ids = find_all_indexes(line, "^") # Vigas que golpean un divisor (intersección) hits = beam_ids.intersection(splitter_ids) split_counter += len(hits) # Nuevas vigas creadas por divisiones (izquierda y derecha) si hits: new_beams = functools.reduce(lambda acc, h: acc.union({h – 1, h + 1}), hits, set()) else: new_beams = set() # Actualizar vigas activas (agregar nuevas vigas, eliminar vigas que golpean los divisores) beam_ids = beam_ids.union(new_beams).difference(splitter_ids) print(split_counter)
Usamos la operación de intersección para identificar los divisores que son impactados directamente por haces activos que vienen desde arriba. Se crean nuevas vigas a la izquierda y a la derecha de cada divisor que se golpea, pero las vigas superpuestas solo se cuentan una vez con el operador de unión. El conjunto de haces resultantes de cada capa de divisores en la variedad de taquiones se calcula utilizando una lista de comprensión envuelta en una función de reducción, una función de orden superior que ayuda a simplificar el código y que normalmente se ve en la programación funcional. El operador de diferencia garantiza que los haces originales que inciden en el divisor no se cuentan entre el conjunto de haces activos salientes.
En un sistema clásico, si una partícula de taquión se envía a través del colector y encuentra un divisor, la partícula sólo puede continuar por un camino único hacia la izquierda o la derecha del divisor. La segunda parte del rompecabezas presenta una versión cuántica de esta configuración, en la que una partícula sigue simultáneamente los caminos izquierdo y derecho, generando efectivamente dos líneas de tiempo paralelas. Nuestra tarea es determinar el número total de líneas de tiempo que existen después de que una partícula ha atravesado todos los caminos viables en dicha variedad de taquiones cuánticos. Este problema se puede resolver de manera eficiente utilizando programación dinámica como se muestra a continuación:
from functools import lru_cache def count_timelines_with_dfs_and_memo(path): """Cuenta líneas de tiempo cuánticas distintas usando DFS + memorización (DP de arriba hacia abajo)""" con open(path) as f: líneas = [line.rstrip("n") para línea en f if line.strip()] altura = len(líneas) ancho = len(líneas[0]) # Encuentra la columna inicial start_col = next(i para i, ch en enumerate(lines[0]) if ch == "S") @lru_cache(maxsize=None) def dfs_with_memo(row, col): """Devuelve el número de líneas de tiempo desde (fila, columna) hasta el final usando DFS + memorización""" # Fuera de los límites horizontalmente si col < 0 o col >= ancho: devuelve 0 # Más allá de la fila inferior: una línea de tiempo completa if fila == altura: devuelve 1 si líneas[fila][col] == "^": # Dividir izquierda y derecha return dfs_with_memo(row+1, col-1) + dfs_with_memo(row+1, col+1) else: # Continuar hacia abajo return dfs_with_memo(row+1, col) return dfs_with_memo(1, start_col) print(count_timelines_with_dfs_and_memo("input_d7.txt"))
La búsqueda recursiva en profundidad con memorización se utiliza para configurar una forma de programación dinámica de arriba hacia abajo, donde cada subproblema se resuelve una vez y se reutiliza varias veces. Se definen dos casos base: no se crea una línea de tiempo válida si una partícula se sale de los límites horizontalmente, y una línea de tiempo completa se cuenta una vez que la partícula llega al fondo de la variedad. El paso recursivo tiene en cuenta dos casos: cada vez que la partícula llega a un divisor, se bifurca en dos líneas de tiempo; de lo contrario, continúa hacia abajo en la línea de tiempo existente. La memorización (usando el decorador @lru_cache) evita el recálculo de valores conocidos cuando múltiples rutas convergen en la misma ubicación en el colector.
En la práctica, los científicos de datos pueden utilizar las herramientas y técnicas descritas anteriormente en una variedad de situaciones. El concepto de división de haz es similar en algunos aspectos a la proliferación de paquetes de datos en una red de comunicaciones compleja. Simular el proceso en cascada es un poco como modelar interrupciones en la cadena de suministro, epidemias y difusión de información. En un nivel más abstracto, el rompecabezas se puede enmarcar como un problema de recorrido de gráfico restringido o de conteo de rutas. El álgebra de conjuntos y la programación dinámica son conceptos versátiles que los científicos de datos pueden utilizar para resolver problemas algorítmicos aparentemente difíciles.
Construyendo circuitos con búsqueda de vecino más cercano
El siguiente problema que veremos es el Día 8: Patio de recreo. Se nos proporciona una lista de tripletas que representan las coordenadas de ubicación 3D de las cajas de conexiones eléctricas en un archivo llamado input_d8.txt, como se muestra a continuación:
162.817.810 59.618.56 901.360.560 …
En la primera parte, se nos pide que identifiquemos y conectemos sucesivamente los pares de cajas de conexiones que estén más cercanas entre sí en términos de distancia en línea recta (o euclidiana). Las cajas conectadas forman un circuito a través del cual puede fluir la electricidad. En última instancia, la tarea consiste en informar el resultado de multiplicar los tamaños de los tres circuitos más grandes después de conectar los 1000 pares de cajas de conexiones más cercanas entre sí. Una solución interesante implica el uso de un montón mínimo para almacenar pares de coordenadas de cajas de conexiones. A continuación se muestra una implementación basada en un video instructivo de James Peralta:
de colecciones import defaultdict import heapq from math import dist as euclidean_dist # Cargar puntos con open("input_d8.txt") como f: puntos = [tuple(map(int, line.split(","))) for line in f.read().split()] k = 1000 # Construir min‑heap de todas las distancias por pares dist_heap = [ (euclidean_dist(points[i], puntos[j]), puntos[i], puntos[j]) for i in range(len(points)) for j in range(i + 1, len(points)) ] heapq.heapify(dist_heap) # Tome k bordes más cortos y cree una lista de adyacencia vecinos = defaultdict(list) for _ in range(k): _, a, b = heapq.heappop(dist_heap) vecinos[a].append(b) vecinos[b].append(a) # Utilice DFS para calcular el tamaño del componente def dfs(inicio, visto): pila = [inicio] visto.add(inicio) tamaño = 0 mientras pila: nodo = pila.pop() tamaño += 1 para nxt en vecinos[nodo]: si nxt no está visto: visto.add(nxt) pila.append(nxt) retorno tamaño # Calcular los tamaños de todos los componentes conectados vistos = set() tamaños = [dfs(p, visto) para p en puntos si p no se ve] # Derivar la respuesta final tamaños.sort(reverse=True) a, b, c = tamaños[:3] print("Solución:", a * b * c)
Un montón mínimo es un árbol binario en el que los nodos principales tienen valores menores o iguales que los valores de sus nodos secundarios; esto garantiza que el valor más pequeño se almacene en la parte superior del árbol y se pueda acceder a él de manera eficiente. En la solución anterior, esta útil propiedad de los min-heaps se utiliza para identificar rápidamente a los vecinos más cercanos entre las cajas de conexiones dadas. Los 1000 pares más cercanos así identificados representan un gráfico 3D. La búsqueda en profundidad se utiliza para recorrer el gráfico a partir de una caja de conexiones determinada y contar el número de cajas que se encuentran en el mismo componente del gráfico conectado (es decir, circuito).
En la segunda parte, se introduce la escasez de recursos (no hay suficientes cables de extensión). Ahora debemos continuar conectando los pares de cajas de conexiones no conectadas más cercanas hasta que formen parte de un circuito grande. La respuesta requerida es el resultado de multiplicar las coordenadas x de las dos últimas cajas de conexiones que se conectan. Para resolver este problema, podemos usar una estructura de datos de búsqueda de unión y el algoritmo de Kruskal para construir árboles de expansión mínima de la siguiente manera:
importar heapq desde matemáticas importar dist como euclidean_dist # Cargar puntos con open("input_d8.txt") como f: puntos = [tuple(map(int, line.split(","))) for line in f.read().split()] # Construir min-heap de todas las distancias por pares dist_heap = [ (euclidean_dist(a, b), a, b) for i, a in enumerate(points) for b in points[i+1:] ] heapq.heapify(dist_heap) # Definir funciones para implementar Union-Find parent = {p: p for p in points} def find(x): if parent[x] != x: parent[x] = find(parent[x]) return parent[x] def union(a, b): ra, rb = find(a), find(b) if ra == rb: return False parent[rb] = ra return True # Use el algoritmo de Kruskal para conectar puntos hasta que todos estén en un componente[0][0]* último_par[1][0]imprimir(x_producto)
Los datos de ubicación se almacenan en un montón mínimo y se construyen los componentes del gráfico conectados. Tomamos repetidamente el borde restante más corto entre dos puntos y solo mantenemos ese borde si conecta dos componentes previamente no conectados; ésta es la idea básica detrás del algoritmo de Kruskal. Pero para hacer esto de manera eficiente, necesitamos una forma de determinar rápidamente si dos puntos ya están conectados. En caso afirmativo, entonces unión(a, b) == False y nos saltamos el borde para evitar crear un ciclo. De lo contrario, fusionamos sus componentes gráficos. Union-find es una estructura de datos que puede realizar esta verificación en un tiempo casi constante. Para usar una analogía corporativa, es un poco como preguntar "¿Quién es tu jefe?" repetidamente hasta llegar al CEO y luego reescribir el valor del jefe de todos para que sea el nombre del CEO (es decir, la raíz). La próxima vez, cuando alguien le pregunte: "¿Quién es su jefe?", podrá responder rápidamente con el nombre del director ejecutivo. Si las raíces de dos nodos son iguales, los componentes respectivos se fusionan uniendo una raíz a la otra.
El problema de la construcción de circuitos se relaciona con la agrupación y la detección de comunidades, que son conceptos importantes que se deben conocer para casos de uso de ciencia de datos de la vida real. Por ejemplo, crear componentes gráficos mediante la identificación de vecinos más cercanos puede ser parte de un algoritmo práctico para agrupar clientes por similitud de preferencias, detectar comunidades en redes sociales y agrupar ubicaciones geográficas. El algoritmo de Kruskal se puede utilizar para diseñar y optimizar redes minimizando los costos de enrutamiento. Conceptos abstractos como distancias euclidianas, min-heaps y union-find nos ayudan a medir, priorizar y organizar datos a escala.
Configuración de máquinas de fábrica con programación lineal
A continuación, analizaremos el problema planteado en el Día 10: Patio de recreo. Recibimos un manual para configurar las máquinas de fábrica en un archivo llamado input_d10.txt como se muestra a continuación:
[.##.] (2) (0,3) (2) (2,3) (0,2) (0,1) {3,5,4,7} [..##.] (0,2,3) (2,3) (0,4) (0,1,2) (1,2,3,4) {7,5,12,8,2} [.###.#] (0,1,2,3) (0,3,4) (0,1,2,4,5) (1,2) {10,11,9,5,10,5}
Cada línea describe una máquina. La cantidad de caracteres entre corchetes refleja la cantidad de luces indicadoras y sus estados deseados (“.” significa apagado y “#” encendido). Inicialmente todas las luces estarán apagadas. Los esquemas de cableado de los botones se muestran entre paréntesis; por ejemplo, al presionar el botón con el esquema "(2, 3)" se cambiarán los estados actuales de las luces indicadoras en las posiciones 2 y 3 de "." a “#” o viceversa. El objetivo de la primera parte es determinar las pulsaciones mínimas de botones necesarias para configurar correctamente las luces indicadoras en todas las máquinas determinadas. A continuación se muestra una solución elegante que utiliza programación lineal entera mixta (MILP):
importar re importar numpy como np desde scipy.optimize importar milp, LinearConstraint, Bounds # Analizar una sola línea de descripción de máquina def parse_machine(line: str): # Extraer patrón de luz match = re.search(r"[([.#]+)]", línea) si no coincide: elevar ValueError(f"Línea no válida: {línea}") patrón = match.group(1) m = len(patrón) # Objetivo vector: '#' -> 1, '.' -> 0 target = np.fromiter((ch == "#" para ch en patrón), dtype=int) # Extraer botones de cableado de botones = [ [int(x) for x in grp.split(",")] if grp.strip() else[]for grp in re.findall(r"(([^)]*))", line) ] # Construir matriz de alternancia A n = len(buttons) A = np.zeros((m, n), dtype=int) for j, btn in enumerate(buttons): for idx in btn: if not (0 <= idx < m): elevar ValueError(f"Índice de botón {idx} fuera de rango for {m} luces") A[idx, j] = 1 return A, target # Resolver todas las máquinas en el archivo de entrada def solve_d10_part1(nombre de archivo): con open(nombre de archivo) como f: líneas = [line.strip() para línea en f if line.strip()] total = 0 para línea en líneas: A, destino = parse_machine(line) m, n = A.shape # Objetivo: minimizar suma(x) c = np.r_[np.ones(n), np.zeros(m)] # Especificar restricción A_eq = np.hstack([A, -2 * np.eye(m)]) lc = LinearConstraint(A_eq, target, target) # Definir límites lb = np.zeros(n + m) ub = np.r_[np.ones(n), np.full(m, np.inf)] límites = Límites(lb, ub) # Especificar integralidad integralidad = np.r_[np.full(n, 2), np.full(m, 1)] res = milp(c=c, constraints=[lc], integralidad=integralidad, límites=límites) si no res.success: elevar RuntimeError(f"No factible solución para la línea: {línea}") total += round(res.x[:n].sum()) return total print(solve_d10_part1("input_d10.txt"))
Primero, cada máquina está codificada como una matriz A en la que las filas son las luces y las columnas son los botones. A[i, j] = 1 si el botón j alterna la luz i. Las expresiones regulares se utilizan para la coincidencia de patrones en los datos de entrada. A continuación, configuramos el problema de optimización con un vector binario de pulsación de botón x, variables enteras de holgura k y un patrón de luz objetivo t. Para cada máquina, nuestro objetivo es elegir las pulsaciones de botones x, de modo que xj = 1 si se pulsa el botón j-ésimo y 0 en caso contrario. La condición “después de presionar los botones x, las luces igualan al objetivo t” refleja la congruencia Ax ≡ t (mod 2), pero como el solucionador MILP no puede tratar con mod 2 directamente, expresamos la condición como Ax – 2k = t, para algún vector k que consta únicamente de números enteros no negativos; esta reformulación funciona porque restar un número par no cambia la paridad. La especificación de integralidad dice que las primeras n variables (las pulsaciones de botones) son binarias y las m variables restantes (holgura) son números enteros no negativos. Luego ejecutamos el solucionador MILP con el objetivo de minimizar la cantidad de pulsaciones de botones necesarias para alcanzar el estado objetivo. Si el solucionador tiene éxito, res.x[:n] contiene las opciones óptimas para presionar botones y el código agrega el número de botones presionados a un total acumulado.
En la segunda parte, la tarea es alcanzar un estado objetivo descrito por los llamados requisitos de "sacudida", que se muestran entre llaves para cada máquina. Los contadores de sacudidas de una máquina se configuran inicialmente en 0 y se pueden presionar los botones tantas veces como desee para actualizar los niveles de sacudidas. Por ejemplo, la primera máquina comienza con valores de sacudida "{0, 0, 0, 0}". Al presionar el botón “(3)” una vez, “(1, 3)” tres veces, “(2,3)” tres veces, “(0,2)” una vez y (0,1) dos veces se produce el estado objetivo “{3, 5, 4, 7}”. Esta también es la menor cantidad de pulsaciones de botones necesarias para alcanzar el estado objetivo. Nuestra tarea es calcular el número mínimo de pulsaciones de botones necesarias para alcanzar los estados de sacudida objetivo para todas las máquinas. Nuevamente, esto se puede resolver usando MILP de la siguiente manera:
importar re importar numpy como np de scipy.optimize importar milp, LinearConstraint, Bounds def parse_machine(line: str): # Extraer los requisitos de sacudida coinciden = re.search(r"{([^}]*)}", línea) si no coincide: elevar ValueError(f"No hay requisitos de sacudida en línea: {línea}") target = np.fromiter((int(x) for x in match.group(1).split(",")), dtype=int) m = len(target) # Extraer botones de cableado de botones = [ [int(x) for x in grp.split(",")] if grp.strip() else[]for grp in re.findall(r"(([^)]*))", line) ] # Construir A (m × n) n = len(buttons) A = np.zeros((m, n), dtype=int) for j, btn in enumerate(buttons): for idx in btn: if not (0 <= idx < m): rise ValueError(f"Button index {idx} fuera de rango para {m} contadores") A[idx, j] += 1 return A, target def solve_machine(A, target): m, n = A.shape # Minimizar suma(x) c = np.ones(n) # Restricción: A x = target lc = LinearConstraint(A, target, target) # Límites: x ≥ 0 límites = Bounds(np.zeros(n), np.full(n, np.inf)) # Todas las x son números enteros integrality = np.ones(n, dtype=int) res = milp(c=c, constraints=[lc], integrality=integrality,bounds=bounds) if not res.success: rise RuntimeError("No hay solución factible") return int(round(res.fun)) def solve_d10_part2(filename): with abrir (nombre de archivo) como f: líneas = [line.strip() para línea en f si line.strip()] return suma(solve_machine(*parse_machine(line)) para línea en líneas) print(solve_d10_part2("input_d10.txt"))
Mientras que la primera parte fue un problema de paridad, la segunda parte es un problema de conteo. La restricción central de la segunda parte se puede capturar mediante la ecuación lineal Ax = t, y no se necesitan variables de holgura. En cierto modo, la segunda parte recuerda al problema de la mochila con números enteros, en el que una mochila debe llenarse con la combinación correcta de objetos de diferente peso y tamaño.
Problemas de optimización como estos suelen ser una característica de los casos de uso de la ciencia de datos en ámbitos como la logística, la gestión de la cadena de suministro y la gestión de carteras financieras. El objetivo subyacente es minimizar o maximizar alguna función objetivo sujeta a diversas restricciones. Los científicos de datos también harían bien en dominar el uso de la aritmética modular; consulte este artículo para obtener una descripción general conceptual de la aritmética modular y una exploración de sus casos de uso práctico en la ciencia de datos. Finalmente, existe un vínculo conceptual interesante entre MILP y la noción de selección de características con regularización en el aprendizaje automático. La selección de funciones consiste en elegir la menor cantidad de funciones para entrenar un modelo sin afectar negativamente el rendimiento predictivo. Usar MILP es como realizar una búsqueda combinatoria explícita sobre subconjuntos de características con poda y optimización. La regularización L1 equivale a una relajación continua de MILP; la penalización L1 empuja los coeficientes de características sin importancia hacia cero. La regularización L2 relaja aún más las restricciones MILP al reducir los coeficientes de características sin importancia sin establecerlos exactamente en cero.
Solución de problemas del reactor con análisis de red
El último problema que veremos es el Día 11: Reactor. Se nos proporciona una representación de diccionario de una red de nodos y bordes en un archivo llamado input_d11.txt como se muestra a continuación:
tu: hhh ccc hhh: ccc fff iii… iii: fuera
Las claves y los valores son nodos de origen y destino (o dispositivos según la historia del problema), respectivamente. En el ejemplo anterior, el nodo "usted" está conectado a los nodos "hhh" y "ccc". La tarea de la primera parte es contar el número de rutas diferentes a través de la red que van desde el nodo "tú" hasta "fuera". Esto se puede hacer utilizando la búsqueda en profundidad de la siguiente manera:
de colecciones import defaultdict def parse_input(filename): """ Analiza el archivo de entrada en un gráfico dirigido. Cada línea tiene el formato: fuente: dest1 dest2 … """ graph = defaultdict(list) con open(filename) como f: para línea en f: línea = line.strip() si no es línea: continuar src, dests = line.split(":") src = src.strip() para d en dests.strip().split(): graph[src].append(d.strip()) return graph def dfs_paths(graph, start, goal): """ Genera todas las rutas desde el inicio hasta el objetivo usando DFS. """ stack = [(start, [start])] while stack: (node, path) = stack.pop() for next_node in graph.get(node,[]): if next_node in path: # Evitar que los ciclos continúen if next_node == objetivo: generar ruta + [next_node] else: stack.append((next_node, path + [next_node])) def solve_d11_part1(nombre de archivo): gráfico = parse_input(nombre de archivo) all_paths = list(dfs_paths(graph, "you", "out")) print(len(all_paths)) solve_d11_part1("entrada_d11.txt")
Usamos una pila explícita para implementar la búsqueda. Cada entrada de la pila contiene información sobre el nodo actual y la ruta hasta el momento. Para cada vecino, lo omitimos si ya está en la ruta, obtenemos la ruta completa si el vecino es el nodo "fuera" o empujamos el vecino y la ruta actualizada a la pila para continuar nuestra exploración de la red restante. Por lo tanto, el proceso de búsqueda enumera todas las rutas válidas desde "usted" hasta "fuera" y el código final es el recuento de distintas rutas válidas.
En la segunda parte, se nos pide que cuentemos el número de rutas que van desde "svr" hasta "out" a través de los nodos "dac" y "fft". La restricción de nodos intermedios restringe efectivamente el número de rutas válidas en la red. A continuación se muestra una solución de muestra:
de colecciones importar defaultdict de functools importar lru_cache def parse_input(nombre de archivo): gráfico = defaultdict(lista) con open(nombre de archivo) como f: para línea en f: línea = línea.strip() si no es línea: continuar src, destinos = línea.split(":") src = src.strip() destinos = [d.strip() para d en destinos.strip().split()] graph[src].extend(dests) para d en destinos: si d no está en el gráfico: graph[d] =[]devolver gráfico def count_paths_with_constraints(graph, start, goal, must_visit): must_visit = frozenset(seen_required) @lru_cache(maxsize=None) def dfs(nodo, visto_required): visto_required = frozenset(visto_requerido) if nodo == objetivo: devolver 1 si visto_required == must_visit else 0 total = 0 para nxt en gráfico[nodo]: # Evite ciclos al no volver a visitar los nodos que ya están en visto_required+ruta # En lugar de rastrear la ruta completa, asumimos DAG o ciclos pequeños new_seen = visto_required | (frozenset([nxt]) & must_visit) total += dfs(nxt, new_seen) return total return dfs(start, frozenset([start]) & must_visit) def solve_d11_part2(nombre de archivo): gráfico = parse_input(nombre de archivo) must_visit = {"dac", "fft"} total_valid_paths = count_paths_with_constraints(graph, "svr", "fuera", must_visit) print(total_valid_paths) solve_d11_part2("input_d11.txt")
El código se basa en la lógica de la primera parte, de modo que ahora también realizamos un seguimiento de las visitas a los nodos intermedios "dac" y "fft" dentro de la rutina de búsqueda en profundidad. Como en el rompecabezas de la variedad de taquiones cuánticos, aprovechamos la memorización para evitar cálculos redundantes.
Los problemas relacionados con el análisis de redes son un elemento básico de la ciencia de datos. La enumeración de rutas es directamente relevante para casos de uso relacionados con telecomunicaciones, enrutamiento de Internet y optimización de la red eléctrica. Los canales ETL complejos a menudo se representan como redes (por ejemplo, gráficos acíclicos dirigidos) y se pueden utilizar algoritmos de conteo de rutas para identificar dependencias críticas o cuellos de botella en el flujo de trabajo. En el contexto de los motores de recomendación impulsados por gráficos de conocimiento, el análisis de las rutas que fluyen a través del gráfico puede ayudar con la interpretación de las respuestas del recomendador. Dichos recomendadores pueden usar rutas entre entidades para justificar las recomendaciones, haciendo que el sistema sea transparente al mostrar cómo un elemento sugerido está conectado con las preferencias conocidas de un usuario; después de todo, podemos rastrear explícitamente el razonamiento.
La envoltura
En este artículo hemos visto cómo los escenarios lúdicos que forman las narrativas de los acertijos de Advent of Code pueden generar ideas genuinamente poderosas, que van desde la búsqueda y optimización de gráficos hasta la programación lineal, la combinatoria y la resolución de restricciones. Al analizar estos problemas y experimentar con diferentes estrategias de solución, los científicos de datos pueden agudizar sus instintos algorítmicos y crear un conjunto de herramientas versátil que se transfiere directamente al trabajo práctico que abarca ingeniería de características, interpretabilidad de modelos, procesos de optimización y más. A medida que la codificación asistida por IA continúa evolucionando, la capacidad de formular, resolver y razonar críticamente sobre tales problemas probablemente seguirá siendo un diferenciador clave para los científicos de datos. Advent of Code ofrece una forma divertida y de bajo riesgo de mantener esas habilidades en forma: se anima a los lectores a intentar los otros acertijos de la edición de 2025 y experimentar el placer de resolver problemas difíciles utilizando el pensamiento algorítmico.