Programación dinámica - Top-Down
Técnicas utilizadas
Programación dinámica - Top Down: resolución mediante la división en subproblemas, calculando sus resultados y almacenándolos en memoria. El enfoque utilizado es top-down, por lo que se utiliza recursividad y se llama a la misma función con subproblemas más pequeños. También se emplea una estructura de datos auxiliar para guardar los resultados intermedios.
Idea de la solución
Para aplicar programación dinámica (top-down) a este problema, debemos abstraer el movimiento del auto e identificar cómo un problema grande (llegar al target original) se puede descomponer en subproblemas más pequeños. Para ello, decidimos enfocarnos únicamente en resolver la distancia absoluta restante a recorrer: la posición inicial siempre es relativa y poner reversa siempre reinicia la velocidad a 1, creando un subproblema idéntico al original. De este modo, el target se puede ir reduciendo hasta llegar a algún caso que se pueda solucionar.
La aplicación de la técnica se estructura en las siguientes partes:
- Definición de la función recursiva y el caso base: Definimos la función recursiva
resolver(target)que se encarga de obtener la cantidad mínima de instrucciones para recorrer una distancia exactatarget. Sabemos por las reglas del problema que si sólo aceleramos, nuestras posiciones forman la serie de hitos (1, 3, 7, 15, 31…). Nuestro caso base ocurre cuando la distanciatargetes exactamente igual a una de estas posiciones de referencia (target= ). En este caso, la respuesta eskaceleraciones, sin tener que usar reversa. - Transiciones (Descomposición del problema): Si
targetno es una de las posiciones representadas por el caso base, entonces estará entre uno contenido entre dos valores: . Desde estos puntos, se procede a evaluar todas las decisiones óptimas posibles para reducir la distancia. Para ello, se utilizan dos estrategias:- Pasarse y retroceder: Aceleramos
kveces hasta pasarnos del objetivo, ponemos reversa y nos queda una nueva distancia absoluta por recorrer hacia atrás. Se reduce el problema a Costo =k+ 1 +resolver( -target) - Quedarse corto y tomar envión: Aceleramos
k-1veces, frenando antes del objetivo. Esto implica poner reversa, y luego acelerariveces en dirección contraria y volvemos a poner reversa para apuntar al objetivo. Esto genera una nueva distancia restante y se reduce el problema a: Costo = (k- 1) + 1 +i+ 1 +resolver(nuevaDistancia)
- Pasarse y retroceder: Aceleramos
- Memoización y solapamiento de subproblemas: Dado que las estrategias calculan distancias absolutas restantes, el árbol de recursividad pedirá resolver las mismas distancias múltiples veces por diferentes caminos (por ejemplo, si se tiene
target = 10, entoncesresolver(3)puede surgir tanto pasandose y retrocediendo como quedándose corto y tomando envión). Para agilizar estos cálculos, se almacena el costo mínimo calculado para cada distancia en un Diccionario. Antes de evaluar las transiciones para untargetconsultamos si esa distancia ya fue resuelta, retornamos el valor en tiempoO(1), evitando que el árbol de decisiones crezca de forma exponencial.
Código
def resolver(target: int, map: dict | None = None) -> int:
if map is None:
map = {}
return _resolver(target, map)
def _resolver(target: int, map: dict) -> int:
k = abs(target).bit_length()
if target in map:
return map[target]
dist2 = (1 << k) - 1
if dist2 == target:
map[target] = k
return k
# me paso y retrocedo
targetMax = dist2 - target
pasosMin = k + 1 + _resolver(targetMax, map)
# me quedo corto y tomo envión
dist1 = (1 << (k - 1)) - 1
targetMin = target - dist1
i = 0
a = 0
while i < (k - 1):
pasosAct = (k - 1) + 1 + (i + 1) + _resolver(targetMin, map)
i += 1
a = (1 << i) - 1
targetMin = (target - dist1) + a
pasosMin = min(pasosMin, pasosAct)
map[target] = pasosMin
return pasosMinTraza de ejemplo
La imagen ilustra el árbol de recursividad generado al ejecutar el algoritmo para
target = 6.
Explicación del árbol de recursividad:
Iniciamos con _resolver(6, map) y un Diccionario map vacío. Para target = 6, el número de aceleraciones que nos pasa del objetivo es k = 3 (ya que la posición superior es y el inferior es ).
-
Llamada Principal →
resolver(6)- Estrategia 1: Pasarse → Aceleramos
k = 3veces hasta llegar a 7. La distancia restante hacia atrás es 7 - 6 = 1. Se realiza la llamada recursiva_resolver(1). Al ser un caso base, retorna el costo de 1 aceleración y lo guarda en el mapa. Costo total de esta rama: 3 + 1 (reversa) + 1 = 5 - Estrategia 2: Quedarse corto → Aceleramos
k-1 = 2veces hasta llegar a 3. Faltan recorrer 3 unidades, pero debemos evaluar los retrocesos (i):- Si
i = 0(retroceder pero no avanzar en dirección contraria): Ponemos reversa dos veces y la distancia restante es 6 - 3 + 0 = 3. Se realiza la llamada recursiva:_resolver(3). Al ser un caso base, retorna 2 aceleraciones. Costo total de esta rama: 2 + 1 (reversa) + 0 + 1 (reversa) + 2 = 6. - Si
i = 1(retroceder y avanzar en dirección contraria 1 vez): Retrocedemos 1 espacio, quedando a una distancia total de 6 - 3 + 1 = 4. Se realiza la llamada recursiva:_resolver(4)
- Si
(El algoritmo pausa la resolución de 6 para evaluar el subproblema
_resolver(4)). - Estrategia 1: Pasarse → Aceleramos
-
Expansión del subproblema:
_resolver(4)Paratarget = 6, calculamos un nuevok = 3(posiciones entre 3 y 7). Se abren nuevas ramas:- Pasarse → Dado que se está en la posición 7, la distancia restante es 7 - 4 = 3. Llamamos a
_resolver(3). Como el costo de_resolver(3)fue calculado y guardado previamente, se lo busca en el diccionario. - Quedarse corto:
- Si
i = 0: Llegamos a 3 y no retrocedemos. Distancia restante: 4 - 3 + 0 = 1. Llamamos a_resolver(1)y se lo encuentra en el diccionario. - Si
i = 1: Llegamos a 3 y retrocedemos 1. Distancia restante: 4 - 3 + 1 = 2. Llamamos a_resolver(2), el cual a su vez se expandirá en casos base que volverán a llamar a_resolver(1)
- Si
- Pasarse → Dado que se está en la posición 7, la distancia restante es 7 - 4 = 3. Llamamos a
-
Resolución y Retorno Una vez resuelto
_resolver(4)(con costo mínimo 5), la llamada principal_resolver(6)termina de comparar todos los costos calculados:- Costo por pasarse: 5
- Costo por quedarse corto (
i = 0): 6 - Costo por quedarse corto (
i = 1): 2 + 1 + 1 + 1 + 5 = 10
El algoritmo se queda con el valor mínimo, guarda map[6] = 5 y retorna 5, que representa la secuencia de instrucciones más corta para llegar al target = 6 (AAARA).
Complejidad
Temporal
donde T es el valor de target. Gracias a la memoización, cada estado (es decir, cada valor distinto de target que se resuelve) se calcula una única vez. Para resolver un estado, el algoritmo ejecuta un bucle de hasta iteraciones, correspondiente a los distintos casos que surgen de quedarse corto y tomar envión. En el peor caso, pueden generarse hasta T(target) estados distintos, por lo que la complejidad temporal total es
Espacial
está dado por la pila de llamadas recursivas y el tamaño del map para guardar los pasos. Dado que crece hasta almacenar todos los estados únicos visitados, la complejidad espacial dominante es la del map.
Cuando usar esta técnica
Favorable cuando
- Existen múltiples caminos que llevan a la misma distancia restante (solapamiento de subproblemas).
- El valor del
targetes grande. El enfoque top-down sólo calcula las distancias que realmente necesita evaluar (saltando tramos enteros), ahorrando memoria y tiempo.
Limitaciones
- Introduce un poco de overhead por el uso de la pila de recursividad y las búsquedas en el diccionario.
Programación dinámica - Bottom-up
En este caso, se procede a rellenar un Vector de estados. Las ecuaciones para calcular las posibles posiciones de referencia es la misma, pero se cambia la dirección del flujo de la resolución. Aquí se resuelven los subproblemas de menor a mayor.
Idea de la solución
La aplicación de la técnica se estructura en las siguientes partes:
- Definición de la tabla y caso inicial: Creamos un vector
dpde tamañotarget + 1, donde cada casillerodp[t]representa el costo mínimo para recorrer la distanciat. El caso inicial esdp[0] = 0(cero pasos para distancia cero). Además, si al iterar identificamos que la distancia actualtes una posición de referencia (t= ), se asigna directamentedp[t] = k. - Bucle principal: Mediante un bucle que recorre
tdesde 1 hastatarget, evaluamos las dos estrategias posibles para cada casillero:- Pasarse y retroceder: Aceleramos
kveces, ponemos reversa y consultamos el arreglo en la distancia restante:dp[t] = k + 1 + dp[dist2 - t]. - Quedarse corto y tomar envión: Aceleramos
k-1veces, ponemos reversa, retrocedemos acumulandoiaceleraciones y volvemos a poner reversa. El costo se calcula consultando el arreglo en la distancia restante:dp[t] = min(dp[t], (k - 1) + 1 + i + 1 + dp[targetMin]).
- Pasarse y retroceder: Aceleramos
- Como resuelve bottom-up: El bucle garantiza el orden óptimo para resolver cualquier distancia
tya que las distancias restantes consultadas en las estrategias (tanto pasandote como quedandote corto) siempre serán menores quet. Como el bucle avanza de menor a mayor, esas posiciones ya habrán sido resueltas y guardadas en el vectordp, permitiendo acceder a los valores en tiempo .
Código
def resolver(target: int) -> int:
if target <= 0:
return 0
dp = [0] * (target + 1)
for t in range(1, target + 1):
k = t.bit_length()
dist2 = (1 << k) - 1
# posición de referencia encontrada
if t == dist2:
dp[t] = k
else:
# opcion 1: me paso y retrocedo
targetMax = dist2 - t
dp[t] = k + 1 + dp[targetMax]
# opcion 2: me quedo corto y tomo envión
dist1 = (1 << (k - 1)) - 1
for i in range(k - 1):
a = (1 << i) - 1
targetMin = (t - dist1) + a
pasosAct = (k - 1) + 1 + i + 1 + dp[targetMin]
dp[t] = min(dp[t], pasosAct)
return dp[target]Traza de ejemplo
Índice (t) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
Pasos (dp[t]) | 0 | 1 | 4 | 2 | 5 | 7 | 5 |
Explicación del llenado de la tabla
Comenzamos con un arreglo dp de tamaño 7 (del 0 al 6). El estado inicial contiene únicamente el caso base para distancia cero:
Arreglo inicial: [ 0, ?, ?, ?, ?, ?, ? ]
Paso t = 1
Posición de referencia cercana: → k = 1.
Como t = 1 es exactamente la posición de referencia, es un caso directo. Nos toma k = 1 pasos.
Vector actual: [ 0, 1, ?, ?, ?, ?, ? ]
Paso t = 2
Posición de referencia cercana: → k = 2. No es caso directo.
Estrategias:
- Me paso: Nos pasamos del 2 por una distancia de 1 (3 - 2 = 1). Consultamos la tabla para esa vuelta atrás:
Costo = k + 1 + dp[1] = 2 + 1 + 1 = 4 - Me quedo corto (
i = 0): Avanzamos hasta 1 y retrocedemos 0. Nos falta recorrer una distancia de 1.Costo = (k - 1) + 1 + 0 + 1 + dp[1] = 1 + 1 + 0 + 1 + 1 = 4
Se debe elegir el mínimo entre 4 y 4 es 4. Guardamos dp[2] = 4.
Vector actual: [ 0, 1, 4, ?, ?, ?, ? ]
Paso t = 3
Posición de referencia cercana: → k = 2.
Evaluación: Como t = 3 coincide perfectamente con la posición de referencia, es un caso directo. Nos toma k = 2 pasos.
Arreglo actual: [ 0, 1, 4, 2, ?, ?, ? ]
Paso t = 4
Posición de referencia cercana: → k = 3. No es caso directo.
Estrategias:
- Me paso: Nos pasamos del 4 por una distancia de 3 (7 - 4 = 3).
Costo = k + 1 + dp[3] = 3 + 1 + 2 = 6 - Me quedo corto (
i = 0): Avanzamos hasta 3 y retrocedemos 0. Falta una distancia de 1.Costo = 2 + 1 + 0 + 1 + dp[1] = 4 + 1 = 5 - Me quedo corto (
i = 1): Avanzamos hasta 3 y retrocedemos 1 (llegamos al 2). Falta una distancia de 2.Costo = 2 + 1 + 1 + 1 + dp[2] = 5 + 4 = 9
El mínimo de las opciones evaluadas es 5. Guardamos dp[4] = 5.
Vector actual: [ 0, 1, 4, 2, 5, ?, ? ]
Paso t = 5
Posición de referencia cercana: → k = 3. No es caso directo.
Estrategias evaluadas:
- Me paso: Nos pasamos por 2 (7 - 5 = 2).
Costo = 3 + 1 + dp[2] = 4 + 4 = 8 - Me quedo corto (
i = 0): Avanzamos hasta 3 y retrocedemos 0. Falta una distancia de 2.Costo = 2 + 1 + 0 + 1 + dp[2] = 4 + 4 = 8 - Me quedo corto (
i = 1): Avanzamos hasta 3 y retrocedemos 1. Falta una distancia de 3.Costo = 2 + 1 + 1 + 1 + dp[3] = 5 + 2 = 7
El mínimo de las opciones evaluadas es 7. Guardamos dp[5] = 7.
Vector actual: [ 0, 1, 4, 2, 5, 7, ? ]
Paso t = 6 (target original)
Posición de referencia cercana: → k = 3. No es caso directo.
Estrategias:
- Me paso: Nos pasamos por 1 (7 - 6 = 1).
Costo = 3 + 1 + dp[1] = 4 + 1 = 5 - Me quedo corto (
i = 0): Avanzamos hasta 3 y retrocedemos 0. Falta una distancia de 3.Costo = 2 + 1 + 0 + 1 + dp[3] = 4 + 2 = 6 - Me quedo corto (
i = 1): Avanzamos hasta 3 y retrocedemos 1. Falta una distancia de 4.Costo = 2 + 1 + 1 + 1 + dp[4] = 5 + 5 = 10
El mínimo de las opciones evaluadas es 5. Guardamos dp[6] = 5.
Al finalizar todo el bucle, la respuesta para target = 6 se lee directamente en la última posición del vector que se fue creando:
dp = [ 0, 1, 4, 2, 5, 7, 5 ]
Esto quiere decir que se requieren 5 pasos para llegar a target = 6
Complejidad
Temporal
, donde T es el valor de target. El algoritmo recorre de forma iterativa todos los valores desde 1 hasta T para completar el vector. Para cada estado T, ejecuta un bucle de hasta iteraciones, correspondiente a los distintos casos en los que el auto se queda corto y toma envión. Cómo se procesan T estados y cada uno requiere operaciones en el peor caso, la complejidad temporal total es .
Espacial
. El algoritmo utiliza un vector dp de tamaño t + 1, donde almacena la cantidad mínima de instrucciones para llegar a cada posición desde 0 hasta T. No utiliza recursividad, por lo que no existe consumo adicional por pila de llamadas. La complejidad espacial está dominada por el tamaño del vector, resultando en .
Cuando usar esta técnica
Favorable cuando
- Se desea evitar la recursividad y el riesgo de desbordamiento de la pila de llamadas.
- Es necesario obtener la solución para todos los estados desde 1 hasta
target, ya que el vector se completa de manera iterativa. - Se busca una implementación con menor sobrecarga, al utilizar accesos directos a un arreglo en lugar de llamadas recursivas y un diccionario.
Limitaciones
- Calcula todos los estados hasta
target, incluso aquellos que nunca serían necesarios para obtener la respuesta final, lo que puede realizar trabajo adicional cuando existen muchos estados que podrían evitarse con un enfoque top-down.
Diferencia entre los enfoques de programación dinámica
Aunque ambos enfoques de DP son superiores a BFS en promedio, en el peor caso todos son .
Comparación con Búsqueda en Anchura (BFS)
A diferencia de BFS que explora paso a paso el árbol de posibilidades teniendo en cuenta posición y velocidad, la programación dinámica reduce el problema a solo la distancia absoluta. Esto permite que se reemplace la “simulación” con saltos matemáticos directos entre posiciones de referencia. Como resultado, la técnica de programación dinámica es superior en espacio ( frente a de BFS) y en promedio mejor en velocidad. Como dificultad, la lógica matemática que plantea el problema es más difícil de deducir que en BFS.