31 de marzo de 2026

El algoritmo que se moría en CDMX: cómo pasé de O(n⁷) a una consulta en base de datos
Cómo un generador de números telefónicos válidos para campañas en México pasó de horas de loops en Python a minutos usando tablas, JOINs y bulk inserts en la base de datos.
Hace algunos años trabajé en un proyecto de marketing por teléfono. El objetivo era generar listas de números telefónicos válidos para hacer campañas de llamadas en distintas ciudades de México. Si en algún momento recibiste una de esas llamadas... lo siento. No fue personal.
El punto técnico del asunto era este: necesitábamos generar números de teléfono que fueran válidos según la regulación del IFT (Instituto Federal de Telecomunicaciones). Y para eso, construimos un algoritmo. Uno que funcionaba. Pero que a cierta escala, simplemente se moría.
Cómo funcionan los teléfonos en México (lo mínimo necesario)
En México, todos los números telefónicos tienen 10 dígitos. Lo interesante es que los primeros 2, 3 o 4 dígitos están asignados a una localidad y estado específicos según la numeración publicada por el IFT. Esto significa que si sabes en qué ciudad quieres operar, ya conoces una parte del número desde el inicio.
Pero el IFT no solo define los prefijos — también establece restricciones sobre qué combinaciones de dígitos son válidas. No todos los sufijos son permitidos. Hay reglas.
Así que el problema real era: dado un prefijo de ciudad, generar todas las combinaciones válidas de los dígitos restantes (entre 6 y 8 dígitos, dependiendo del prefijo), respetando las restricciones del regulador.
La primera versión: el infierno de los for
La implementación inicial fue lo más natural que se me ocurrió en ese momento: ciclos for anidados. La lógica era directa: iterar sobre cada posición de dígito, generar todas las combinaciones posibles, y dentro de cada iteración aplicar los if con las reglas del IFT para filtrar las combinaciones inválidas.
for d1 in range(10):
for d2 in range(10):
for d3 in range(10):
for d4 in range(10):
for d5 in range(10):
for d6 in range(10):
for d7 in range(10):
if cumple_reglas_ift(d1, d2, d3, d4, d5, d6, d7):
guardar_numero(prefijo, d1, d2, d3, d4, d5, d6, d7)En el peor caso —ciudades con prefijo de 3 dígitos, donde quedan 7 dígitos libres— la complejidad llegaba a O(10⁷), es decir, hasta 10 millones de iteraciones solo para generar las combinaciones, antes siquiera de aplicar los filtros.
Para ciudades pequeñas, funcionaba. Tardaba un rato, pero terminaba. Para ciudades como CDMX o Monterrey, el proceso tardaba horas. Literalmente. El algoritmo era correcto. El problema era el enfoque.
El insight clave: el problema no era el algoritmo, era la herramienta
El cuello de botella no era la lógica de negocio — las reglas del IFT eran las mismas sin importar cómo las implementara. El problema era que estaba usando un proceso iterativo para resolver algo que en realidad era un problema de combinación de conjuntos de datos. Y los motores de bases de datos llevan décadas siendo increíblemente buenos para exactamente eso.
En lugar de generar las combinaciones con código, ¿qué tal si modelo las reglas como datos y dejo que la base de datos haga el cruce?
La solución: tablas temporales + JOINs + bulk insert
1. Las reglas del IFT se convirtieron en tablas
En lugar de tener las restricciones codificadas en if dentro de los ciclos, las traduje a tablas temporales en la base de datos. Cada tabla representaba los valores válidos para una posición de dígito, condicionados al estado y localidad seleccionados.
-- Ejemplo simplificado
CREATE TEMPORARY TABLE digitos_validos_pos1 AS
SELECT valor FROM reglas_ift
WHERE estado = 'CDMX' AND posicion = 1;2. El cruce de combinaciones se hace con JOINs
En lugar de ciclos anidados, un SELECT con JOIN entre las tablas de cada posición genera el producto cartesiano de combinaciones válidas directamente en el motor de base de datos. El motor aprovecha índices, paralelismo y optimizaciones de query que ningún loop en aplicación puede igualar.
SELECT
p.prefijo,
d1.valor, d2.valor, d3.valor,
d4.valor, d5.valor, d6.valor, d7.valor
FROM prefijos p
JOIN digitos_validos_pos1 d1 ON 1=1
JOIN digitos_validos_pos2 d2 ON 1=1
JOIN digitos_validos_pos3 d3 ON 1=1
JOIN digitos_validos_pos4 d4 ON 1=1
JOIN digitos_validos_pos5 d5 ON 1=1
JOIN digitos_validos_pos6 d6 ON 1=1
JOIN digitos_validos_pos7 d7 ON 1=1
WHERE p.ciudad = 'CDMX'
AND NOT (d1.valor = 0 AND d2.valor = 0) -- ejemplo de restricción3. El resultado va directo a tablas particionadas con bulk insert
La tabla resultante del SELECT no se traía a la aplicación registro por registro — se insertaba directamente en las tablas finales mediante un INSERT INTO ... SELECT, con particionamiento por estado/ciudad e índices ya definidos para las consultas posteriores.
INSERT INTO numeros_generados (ciudad, numero_completo)
SELECT
'CDMX',
CONCAT(p.prefijo, d1.valor, d2.valor, d3.valor, d4.valor, d5.valor, d6.valor, d7.valor)
FROM prefijos p
JOIN digitos_validos_pos1 d1 ON 1=1
JOIN digitos_validos_pos2 d2 ON 1=1
JOIN digitos_validos_pos3 d3 ON 1=1
JOIN digitos_validos_pos4 d4 ON 1=1
JOIN digitos_validos_pos5 d5 ON 1=1
JOIN digitos_validos_pos6 d6 ON 1=1
JOIN digitos_validos_pos7 d7 ON 1=1
WHERE p.ciudad = 'CDMX';El resultado
El cambio fue contundente:
Ciudad pequeña: de ~5 min a menos de 10 segundos.
Monterrey: de ~3 horas a 2-3 minutos.
CDMX: de ~5+ horas a 2-3 minutos.
De horas a minutos. Sin cambiar las reglas de negocio, sin comprar servidores, sin paralelismo manual. Solo cambiando dónde y cómo se hacía el trabajo.
La lección que me quedó
Este caso me enseñó algo que sigo aplicando como principio de arquitectura: antes de optimizar el código, pregúntate si estás usando la herramienta correcta para el problema.
Los ciclos for son intuitivos y fáciles de depurar, pero son herramientas de procesamiento secuencial. Los motores de bases de datos son herramientas diseñadas específicamente para cruzar, filtrar y mover grandes volúmenes de datos. Cuando el problema es fundamentalmente de conjuntos, la base de datos casi siempre va a ganar.
No es que los loops estén mal — es que había un desajuste entre el problema y la solución. Y reconocer ese desajuste a tiempo es, en gran medida, el trabajo de un buen arquitecto.
Si tienes un algoritmo que se muere con volumen, vale la pena preguntarse: ¿estoy procesando datos con código cuando debería estar procesando datos con datos?
¿Tienes algún caso similar donde cambiar de herramienta (no de algoritmo) fue la solución? Me encantaría leerlo en los comentarios.