Solución a “Contraseña Binaria”

Concurso: Preselectivo para la IOI 2015, Etapa 1, Problemset 7
Autor: Orlando Isay Mendoza Garcia
Fuente: Christian Adan Hernández Sánchez

Podemos ayudarnos de la imagen para comprender mejor esta explicación:

En ella aparecen de forma descendente a la izquierda los números pares comenzando desde el dos, y su representación binaria a la derecha. En la parte superior aparece el valor de cada cifra en decimal.

Tomando en cuenta el límite del problema, sabemos que si sumamos B(i) para cada par menor o igual a N, en el peor de los casos tendríamos que realizar 500,000,000,000,000 veces la función. Aún si lograramos calcularla en una operación nuestro programa excedería el tiempo límite.

En cambio, haciendo cálculos notamos que:
2^{50} \approx 1,000,000,000,000,000. Lo cual significa que a lo más habrán 50 columnas en la tabla (ya que en la forma binaria cada cifra representa una potencia de 2).

Dado que sabemos que en una suma el orden de las cantidades a sumar no importa, podemos determinar que es lo mismo sumar los valores de forma horizontal, tanto como de forma vertical. Sumando los valores de las columnas solo tomaría 50 operaciones. La columna 1 podemos ignorarla ya que al ser pares los números de la lista ninguno contendrá un 1 en la última cifra.

Observando la siguiente imagen, vemos que la columna C se forman grupos de tamaño C (por ejemplo, en la columna 4 se forman grupos de cuatro elementos),que contienen una la mitad de 1s y la otra de 0s. También podemos ver que en la columna 2 no hay 0s antes del primer grupo, en columna 4 hay un 0, en la que sigue hay 2, luego 4,etc (área en color gris). Podemos notar que ese espacio aumenta en base a potencias del dos.

Teniendo el número N habrán N / 2 números en la lista. Para calcular la cantidad de grupos completos que se forman en cada columna dividimos N menos el espacio lleno de ceros en esa columna, entre el número de la columna en el que estemos; a su vez, esta cantidad la multiplicamos por, el número de la columna entre dos. Sin embargo, puede que nos falten de contabilizar los 1s que pudieran estar en un grupo que no se completo. Esto se arregla sumando a lo anterior, el mínimo entre el resto de la división anterior y, el número de la columna entre dos.

Código:

Solución a “Poema Equino”

Concurso: Preselectivo para la IOI 2015, Etapa 1, Problemset 5
Autor: Freddy Román Cepeda
Fuente: Edgar Augusto Santiago Nieves, Freddy Román Cepeda

Los límites de este problema permitían hacer una búsqueda sobre todos los estados posibles de los caballos sobre el teclado, ya que si el estado es (\text{poema},\text{fila caballo}_1,\text{columna caballo}_1,\text{fila caballo}_2,\text{columna caballo}_2), solamente hay 100 \times (4 \times 10)^2 = 160,000 estados distintos.

Además, como el problema no pide la cantidad mínima de movimientos no hace falta hacer una BFS (búsqueda en amplitud), sino que una DFS (búsqueda en profundidad) utilizando el mismo stack del lenguaje es suficiente. Para simplificar la implementación, se podían utilizar varias observaciones. Particularmente, no importa qué caballo es el 1 o el 2, por lo que en vez de escribir código para mover a ambos basta con añadir una transición que cambie los roles de los caballos en cada estado. Esto además de simplificar la implementación sirve como una poda ya que ¡reduce la cantidad de estados a la mitad! (¿por qué?). También, se puede aprovechar que los operadores booleanos en C/C++ evalúan a 1 cuando son verdaderos y a 0 cuando son falsos, lo cual es bastante útil para indexar arreglos.

Varios competidores fallaron en su primer intento por no revisar que los caballos no podían ocupar la misma tecla al mismo tiempo. ¡Cuidado!

La siguiente solución implementa las simplificaciones descritas anteriormente.

Solución a “Carretera”

Concurso: Preselectivo para la IOI 2015, Etapa 1, Examen 1
Autor: Freddy Román Cepeda
Fuente: Edgar Augusto Santiago Nieves, Freddy Román Cepeda

Para obtener los puntos de la primer subtarea bastaba notar que las condiciones especificadas significan que hay dos bloques de coches yendo en diferentes sentidos que inicialmente no se intersectan y eventualmente lo harán, por lo que la respuesta simplemente es el máximo de los anchos de estos bloques.

Este código obtiene los primeros 30 puntos:

Para el resto de los puntos: Sea f(t) el ancho necesario para la fotografía en el segundo t. La observación crucial es que f es una función unimodal: es decir, existe un punto t_0 tal que f es decreciente a la izquierda de t_0 y es creciente a la derecha.

Computar f(t) para t fijo es trivial: basta con obtener el coche más a la izquierda y más a la derecha en el segundo t, lo cual toma tiempo O(N). Como f es unimodal, podemos utilizar búsqueda ternaria o búsqueda binaria para encontrar el mínimo de la función en tiempo O(\lg T), donde T es el tamaño del rango a evaluar. Con eso obtenemos un algoritmo con complejidad O(N \lg T), suficiente para obtener todos los puntos del problema.

El siguiente código implementa la solución anterior con búsqueda binaria.

Solución a “Suma Manhattan”

Concurso: Preselectivo para la IOI 2015, Etapa 1, Problemset 1
Autor: Freddy Román Cepeda
Fuente: Freddy Román Cepeda

Este problema requería manipular con cuidado la expresión que había que computar.
Recordemos que nos piden computar

\sum_{0 \leq i < j < N} manhattan(S_i,S_j).

Para resolver la primer subtarea bastaba con iterar sobre todas las parejas de puntos y calcular su distancia. Esto corre en tiempo cuadrático y no es suficiente para obtener todos los puntos.

La siguiente subtarea era una pista: se puede computar la distancia Manhattan de dos puntos considerando por separado sus coordenadas en x y y. Ahora nos preocuparemos por calcular la siguiente expresión:

\sum_{0 \leq i < j < N} |a_i - a_j|.

Donde a son las coordenadas en x o y. El problema está en el valor absoluto. La manera más sencilla de deshacernos de él es ordenar la secuencia a, de tal manera que a_i \leq a_j. Entonces tenemos:

\sum_{0 \leq i < j < N} |a_i - a_j| = \sum_{0 \leq i < j < N} |a_j - a_i| = \sum_{0 \leq i < j < N} a_j - a_i.

La primer igualdad es verdadera porque |x| = |-x| para cualquier x. La segunda es porque como ahora a está ordenado, como a_j \geq a_i \implies a_j - a_i \geq 0, el valor absoluto no hace nada.

Podemos entonces separar la suma en dos términos:

\sum_{0 \leq i < j < N} a_j - \sum_{0 \leq i < j < N} a_i.

Analicemos el primer término. Estamos sumando sobre todas las j tantas veces haya una i menor que ella. Eso quiere decir que cada a_j la vamos a sumar j veces (nota que a_0 la sumamos 0 veces).

El segundo término nos dice que sumaremos todas las a_i tantas veces haya una j mayor a ella. Eso quiere decir que cada a_i la vamos a sumar N-i-1 veces (nota que a_{N-1} la sumamos 0 veces).

Juntando esas ideas, entonces tenemos:

\sum_{j = 0}^{N-1} j \cdot a_j - \sum_{i = 0}^{N-1} (N - i - 1) \cdot a_i

= \sum_{i = 0}^{N-1} i \cdot a_i - \sum_{i = 0}^{N-1} (N - i - 1) \cdot a_i.

= \sum_{i = 0}^{N-1} (i - (N - i - 1)) \cdot a_i.

= \sum_{i = 0}^{N-1} (2i - N + 1) \cdot a_i.

Y con eso terminamos: ahora tenemos una expresión que podemos computar fácilmente en tiempo lineal. Hay que tener cuidado al computar esto: La primera observación es que hay que estar tomando módulo después de cada operación porque en cualquier momento puede haber un overflow. Algunos competidores obtuvieron 60 puntos en este problema por no tomar esto en cuenta. La segunda observación es que el término (2i - N + 1) \cdot a_i no necesariamente cabe en un entero signado de 32 bits — hacía falta utilizar enteros de 64 bits para realizar este cálculo.

Aquí está mi código que implementa la solución anterior.

Solución a “Super Nieves Bros”

Concurso: Preselectivo para la IOI 2014, Etapa 1, Problemset 6
Fuente: Topcoder

Este problema es una adaptación del problema ArcadeManao que apareció en el SRM 576 (Abril 2013) de Topcoder. Los detalles de la solución explicada la pueden en el respectivo Match Summary.

La idea general de este problema es muy sencilla: En base a los límites, la primera observación es que el mapa no es muy grande y en el peor caso, la escalera más alta es de tamaño 50. Hay que notar que no nos piden la ruta más corta para capturar la moneda, más bien cuál es el menor tamaño con el que podemos llegar.

La idea principal es ir probando los tamaños de escalera, empezando desde 0 hasta 50, y para cada tamaño probar si es posible llegar a la moneda o no. Si empezamos probando los tamaños de menor a mayor, la primer escalera que lo logré será el resultado.

Para probar que se puede llegar a la moneda, se puede usar búsqueda en amplitud o búsqueda en profundidad. Dado que el tamaño del mapa es relativamente pequeño, es posible usar búsqueda en profundidad y pararla en cuanto “pisemos” la moneda (de nuevo, no nos interesa saber si la ruta que la búsqueda siguió para dar con la moneda fue la menor de todas las posibles, con llegar a ella basta).

Este par de observaciones son suficiente para resolver el problema. Si el lector quiere más reto, hay otra observación que permitiría reducir el número de búsquedas que hay que hacer de 50 a 6…

Esta es la solución de charlyhlms usando búsqueda en profundidad.

Esta es la solución de spleensarethebest usando búsqueda en amplitud y optimizando la cantidad de pruebas de tamaños de escaleras que hay que hacer.

Solución de “El Concierto de Dr. Lira”

Concurso: Preselectivo para la IOI 2014, Etapa 1, Problemset 13
Fuente: Topcoder

El Concierto de Dr. Lira es una adaptación al problema Changing Sounds que apareció en el SRM 366 (2007, ya llovió) en TopCoder. La solución explicada la pueden encontrar en el Match Summary (necesitan registrarse en omegaUp para verlo).

Les dejo la implementación de spleensarethebest como un muy bien ejemplo de solución a este problema. Cualquier duda, déjenos un comentario.

Solución a “DP Genérica”

Concurso: Preselectivo para la IOI 2014, Etapa 1, Problemset 13
Autor: Freddy Román Cepeda
Fuente: Project Euler

Podemos tratar este problema de varias maneras distintas, 3 de las cuales discutiré en esta solución.

Análisis 1

Primero, una idea que hubiera obtenido 50 puntos.

Podemos observar que el problema es equivalente a encontrar de cuántas maneras se le puede asignar un número n_i del conjunto \{0,1,2\} a cada potencia de 2 tal que \sum_{i=0}^{\infty} n_i 2^i = x. Esto también es equivalente a encontrar cuántos números a y b hay tales que a + b = x y no haya ningún bit encendido en b que no esté encendido en a.

Consideremos la expansión binaria de a = \sum_{i=0}^{\infty} a_i 2^i y b = \sum_{i=0}^{\infty} b_i 2^i , donde cada a_i y b_i es 1 o 0. Al sumar a + b = \sum_{i=0}^{\infty} (a_i + b_i) 2^i tenemos que 0 \le n_i = a_i + b_i \le 2, como se necesita. Para contar solamente una vez cada configuración distinta de la secuencia n, añadimos la restricción de que cualquier b_i puede ser 1 sólo si a_i también lo es.

Subtarea 1

Para esta subtarea es suficiente probar todas las a y b posibles, revisando con un loop para cada bit si la condición sobre b se cumple. Este algoritmo corre en tiempo O(N^2 \log N).

Subtarea 2

Para esta subtarea podemos hacer una observación sencilla: a cada a sólo le puede corresponder una b, igual a x - a, lo que reduce la complejidad en tiempo del algoritmo a O(N \log N).

Subtarea 3

Podemos comprobar si b cumple la condición en tiempo constante utilizando operaciones de bits. Si ~a & b es igual a 0, b no tiene ningún bit encendido que a no tenga encendido. Ahora tenemos un algoritmo lineal. Desafortunadamente, ya no podemos mejorar nuestra solución fácilmente continuando con esta idea.

El siguiente código implementa esta solución:

Análisis 2

Podemos hacer programación dinámica de forma top-down. En ésta, contamos la cantidad de maneras de escribir x como pide el problema incluyendo o no cada una de las potencias distintas.

Consideremos la función f(n,p), que cuenta de cuántas maneras podemos escribir n utilizando potencias de 2 menores o iguales a p no más de 2 veces cada una. Es evidente que la respuesta se encontraría evaluando f(x,63).

Sabemos que f(n,p) = 0 si n es negativo o si p es negativo. Del mismo modo, f(n,p) = 1 si n = 0. De lo contrario, es igual a la suma de f(n,p-1), f(n-2^p,p-1) y f(n-2^{p+1},p-1), que corresponden a poner 0, 1, o 2 veces la potencia 2^p.

Subtarea 1

Aplicando directamente el análisis anterior, la subtarea 1 queda resuelta.

Subtarea 2

Varios de estos estados se repiten, así que convendría memorizarlos. Utilizando un contenedor como std::map, la solución se vuelve lo suficientemente rápida para resolver esta subtarea.

Subtarea 3

Podemos determinar en algunos casos rápidamente si la función se evaluará a 0. Sabemos que \sum_{i=0}^{k} 2^i = 2^{k+1} - 1. Entonces, el número más grande que podemos escribir sólo usando potencias de 2 menores o iguales a p a lo más dos veces es 2\sum_{i=0}^{p} 2^p = 2 (2^{p+1} - 1) = 2^{p+2} - 2. Por lo tanto, f(n,p) = 0 si n > 2^{p+2} - 2.

Esa optimización por sí misma (sin memorización), resuelve la subtarea 3.

Subtarea 4

Combinando las ideas de las dos subtareas anteriores, el algoritmo es lo suficientemente rápido para resolver todos los casos. Específicamente, la cantidad de estados que no podemos determinar como no viables instantáneamente es proporcional a \log x, y cada estado lo podemos evaluar en tiempo O(\log \log x) por nuestro std::map, dándonos una complejidad total de O(\log x \log \log x). Esta cota puede quedar más clara después de describir la tercera solución.

El siguiente código implementa esta solución:

Análisis 3

Esta solución es equivalente a la anterior, pero no precisa de un std::map.

Consideremos la función n(k), que definimos como el número que obtenemos tomando los bits 0..k de x. En otras palabras, si x = \sum_{i=0}^{\infty} x_i 2^i donde x_i es el i-ésimo bit de x, n(k) = \sum_{i=0}^k x_i 2^i. Ahora, definimos la función g(i,r), que cuenta de cuántas maneras se puede escribir el número n(i) + r2^i, utilizando potencias de 2 menores o iguales a i a lo más 2 veces. La respuesta, por lo tanto, se obtendría evaluando g(63,0).

Ahora, sabemos que g(i,r) = 1 si i < 0 y r = 0, porque podemos escribir sólo de una manera 0. Recordando que x_i es el i-ésimo bit de x, podemos decir que g(i,r) cuenta la cantidad de formas que se puede escribir el número (r+x_i)2^i + n(i-1). De ahora en adelante, por conveniencia, t = r + x_i.

Usando esto, podemos definir g(i,r) recursivamente:

g(i,r) = \begin{cases}  1 & \text{si } i < 0 \text{ y } r = 0 \\  \sum_{k=0}^{min(t,2)}g(i-1,2(t-k)) & \text{de lo contrario}  \end{cases}

En otras palabras, si tenemos que poner t veces la potencia i, podemos elegir ponerla hasta min(t,2) veces, y contar las maneras de escribir el resto usando potencias de 2 menores a p. Pero como dejamos t-k veces la potencia i sin poner, es igual a poner 2(t-k) veces la potencia i-1.

La siguiente observación es que si t es mayor a 3, g(i,r) = 0 porque 2\sum_{k=0}^{i} 2^k < 4 \times 2^{i}. Entonces, sólo nos interesan los casos en los que 0 \le t \le 3. En total, sólo hay 4 valores posibles para t en los que g(i,r) no es 0: 0, 1, 2, y 3. Enumerémoslos:

g(i,r) = \begin{cases}  g(i-1,0) & \text{si } t = 0 \\  g(i-1,2) + g(i-1,0) & \text{si } t = 1 \\  g(i-1,4) + g(i-1,2) + g(i-1,0) & \text{si } t = 2 \\  g(i-1,6) + g(i-1,4) + g(i-1,2) & \text{si } t = 3   \end{cases}

Pero g(i,r) = 0 si t > 3, y como t \ge r, nos quedamos con:

g(i,r) = \begin{cases}  g(i-1,0) & \text{si } t = 0 \\  g(i-1,2) + g(i-1,0) & \text{si } t = 1 \\  g(i-1,2) + g(i-1,0) & \text{si } t = 2 \\  g(i-1,2) & \text{si } t = 3   \end{cases}

Tomando en cuenta que r sólo puede ser 0 o 2, y x_i sólo 0 o 1:

(g(i,0),g(i,2)) = \begin{cases}  (g(i-1,0),g(i-1,2)+g(i-1,0)) & \text{si } x_i = 0 \\  (g(i-1,2)+g(i-1,0),g(i-1,2)) & \text{si } x_i = 1   \end{cases}

El siguiente código implementa esta solución, que corre en tiempo O(\log n) y espacio constante:

Como podemos observar, esta solución considera los mismos estados que la anterior, sólo que aquí evitamos computarlos, mientras que la otra los descarta inmediatamente.

Consideraciones

Varios competidores no consideraron que x no cabe en un entero signado de 64 bits. Si bien la respuesta cabe en uno, en los límites del problema se especifica que x < 2^{64}.

Análisis adicional:

Diego Roque escribió una solución distinta, la cual detallaré a continuación.

Consideremos la función f(x) como la define el problema: la cantidad de maneras de escribir x como una suma de potencias no negativas de 2 sin usar cada una más de 2 veces.

Enfoquémonos en la paridad de x (es decir, el último bit de x). Si x es impar, necesariamente tenemos que poner una vez la potencia 2^0, porque las otras dos opciones: ponerla 0 veces o ponerla 2 veces cambiarían la paridad de x. Por lo tanto, f(x) = f(\frac{x-1}{2}) si x es impar. En cambio, si es par, podemos elegir poner la potencia 2^0 0 o 2 veces, lo que nos deja con f(x) = f(\frac{x}{2}) + f(\frac{x}{2}-1). Sólo falta definir los casos base: f(0) = f(1) = 1.

Aquí está su código que implementa esta solución, que corre en tiempo O(\log^2 x \log \log^2 x):

Solución a “Comesolo”

Concurso: Preselectivo para la IOI 2014, Etapa 1, Problemset 8
Autor: lhchavez
Fuente: Félix

Este problema es especial porque es el primero en omegaUp de solo salida! Usualmente lo que debes esperar cuando te enfrentes con uno de esos problemas es que sea un problema NP que no tiene una solución rápida, y usualmente te pedirán que te aproximes lo más posible a la solución óptima. Esto significa que te vas a tener que valer de técnicas ad-hoc y heurísticas para sacar puntos.

La solución del problema es bastante sencilla de explicar: haz una búsqueda en profundidad intentando todos los posibles movimientos por fuerza bruta hasta que te salga una solución aceptable e imprímela. El problema es que esta estrategia es O(n!), y como n puede valer hasta 30×30, puedes esperar que el programa corra varios milenios antes de encontrar la solución óptima. Hay tres trucos (en orden de importancia) para obtener una solución decente en un tiempo razonable:

  • No repetir estados.
  • No “clavarse” con soluciones que parece que son muy buenas, pero en realidad llevan a callejones sin salida.
  • Encontrar una manera de darle prioridad a los estados que tengan más probabilidad de llegar a una solución buena.

La estrategia que yo personalmente seguí fue que cada que encontraba un nuevo estado, obtenía su hash (que resultaba en un entero de 64 bits) y verificaba si no lo había visitado usando una tabla de hash*. Si no la había visitado, encontraba todos los estados vecinos (todos los tableros que resultaban de hacer un movimiento válido) y los guardaba en una fila de acuerdo a la cantidad de puntos (entre más puntos, más adelante en la fila). Luego, elegía aleatoriamente un estado de la fila dándole prioridad a los que estaban más adelante (pues son los que tienen mayor probabilidad de llegar a una buena solución), lo cual también me evitaba seguir un único camino donde me podría atorar. Repetí eso hasta que se me terminó la memoria de la computadora e imprimí la mejor solución.

A continuación, el pseudocódigo de la solución:

class Estado:
  int puntos = 0
  bool[N][N] tablero
  Estado padre = null

  def __init__(Estado p):
    puntos = p.puntos
    tablero = p.tablero
    padre = p

  def hash():
    # Puedes usar cualquier algoritmo que genere un entero de 64 bits
    # a partir de tablero y puntos. Este es el más sencillo.
    hash = puntos
    for i in range(0, N):
      hash = ((hash << 7) | (hash >> 53)) ^ tablero[i]
    return hash

  def siguientes(queue[300] colas):
    # Para todas las celdas (i, j) del tablero...
    for i in range(0, N):
        for j in range(0, N):
          # Si la celda tiene una pieza...
          if tablero[i][j]:
            # Para todos los vecinos contiguos (i+k, j+l)...
            for k in range(-1, 2):
              for l in range(-1, 2):
                # Asegúrate que se haya movido _algo_.
                if k == 0 && l == 0: continue
                # Y que pueda brincar dentro del tablero.
                if j + 2 * l < 0 or j + 2 * l >= N: continue
                # Y que haya brincado una pieza.
                if not tablero[i + k][j + l]: continue
                # Y que el lugar a donde brinca esté desocupado.
                if tablero[i + 2 * k][j + 2 * l]: continue
                
                hijo = new Estado(this)
                # Aumenta la puntuación del hijo
                hijo.puntos++
                # Borra la ficha original y la "comida".
                hijo.tablero[i][j] = hijo.tablero[i + k][j + l] = \
                  False
                # Agrega la ficha en su posición final.
                hijo.tablero[i + 2 * k][j + 2 * l] = True
                # Agrégala a la cola correspondiente.
                colas[hijo.puntos].push(hijo)

def elige_estado():
  # Número aleatorio entre 0 y 1.
  r = (random() / (float)RAND_MAX)
  # El índice de la última cola que estuvo llena.
  ultimolleno = -1
  # La cola que se está considerando.
  x = 0
  # Elige la cola con mayores puntos que no esté vacía como
  # primera opción.
  for i in range(0, N):
    if not colas[i].vacio():
      x = i
  # La primer cola tiene probabilidad de 31% de ser elegida.
  # La segunda cola tiene probabilidad de 21%, la tercera 14%,
  # la cuarta 10% y así sucesivamente.
  while x >= 0:
    if not colas[x].vacio():
      ultimolleno = x
    x--
    r *= 1.45
    if r >= 1 and ultimolleno != -1 break
  if ultimolleno == -1: return Null
  return colas[lastfull].pop()

queue[300] colas
hashtable estados_visitados

# lee el estado original
colas[0].push(estado_original)
Estado mejor = estado_original

while no_se_haya_terminado_la_memoria():
  Estado s = elige_estado()
  # Si ya no hay más estados por visitar,
  # encontramos la respuesta óptima en algún punto.
  if s == Null: break
  # Actualiza |mejor| si hay una respuesta mejor.
  if mejor.puntos < s.puntos: mejor = s
  # Repetir estados es malo.
  if s.hash() in estados_visitados: continue
  estados_visitados.add(s.hash())
  # Agrega todos los vecinos.
  s.siguiente(colas)

# A partir de este punto, |mejor| contiene la mejor solución. Podemos
# saber qué movimiento se hizo para llegar a él comparando las
# diferencias entre el tablero de |mejor.padre| y |mejor|. Ya solo es
# cuestión de imprimir el resultado y listo.

* Aquí mucha gente se va a quejar porque solo guardar el hash abre la puerta a que haya dos estados que puede tener hasta 900 bits que tengan el mismo hash de 64 bits (por el principio del palomar) y esté considerando que ya se visitó un estado que en realidad es nuevo. Si haces las cuentas, la probabilidad de colisión es negligible: la cantidad de estados que podía visitar en mi computadora (27 millones) es significativamente más pequeña que el número de estados necesarios para que la probabilidad de colisión sea de 1% (\approx 10^{135}, por la paradoja del cumpleaños).

Solución a “Colección”

Concurso: Preselectivo para la IOI 2014, Etapa 1, Problemset 5
Autor: Alexis Cervantes / César Cepeda
Fuente: Alexis Cervantes / César Cepeda

Estructura de la solución: ¿Qué nos están pidiendo? El problema nos esta pidiendo que encontremos un subconjunto de las tarjetas tal que la suma de todos los puntajes de las tarjetas de ese subconjunto sea la maxima posible, y la suma de sus precios sea menor o igual al dinero con el que cuentas.  En otras palabras lo que se esta buscando es que:

la suma sea maxima siempre y cuando 

donde Xi = {1 si se compró la tarjeta iy 0 si no se compró}

Modelo del espacio de búsqueda como árbol: Al final de cuentas, incluso el nombre lo indica, este problema se puede reducir a asignarle a cada tarjeta un 0 ó un 1 dependiendo de si la vamos a comprar o no, por lo que una forma de modelar el espacio de búsqueda sería formar un número binario de N dígitos y crear todos los valores posibles para dicho número.

Para formar todos los números, podemos pensar que en el nivel j del árbol vamos a decidir si compramos la tarjeta j, por lo tanto todos los nodos del nivel j tendrán dos hijos, uno de ellos indicando que si compramos la tarjeta y el otro indicando que no la compramos.  El árbol de búsqueda queda como se muestra a continuación.

Técnica de búsquda a utilizar: Dado que tenemos que entregar como resultado el camino con el mayor puntaje de todos , es preciso que revisemos el 100% de los caminos.  

Una forma de resolver el problema es utilizar búsqued en profundidad, sin embargo el espacio de búsqueda es un árbol binario con N niveles por tanto de 2estados.  Para nuestro problema N=500 por lo que el espacio de búsqueda es indescriptiblemente grande, aunque claro que se pueden podar las ramas en las que el precio ya superó a la cantidad de dinero que tenemos, el aplicar la técnica de búsqueda en profundidad en este problema dificilmente alcanzaría para una mayor que 24 ó 25.

Necesitamos por tanto encontrar una poda mucho más agresiva.

Anteriormente, en el ejemplo de los Tanques, utilizamos la búsqueda en profundidad para encontrar un camino mínimo, por lo tanto, debido a que cada nivel que descendemos el coste aumenta, se podía aplicar la poda de que si obteniamos algún mínimo se podían cortar todas las ramas cuyo coste fuera mayor o igual que el mínimo actual.  Sin embargo al querer encontrar un máximo, esto no es posible, ya que el coste por rama siempre aumenta y lo que queremos es es encontrar el máximo, así que no sabemos si el camino nos va a llevar a algo mejor a menos que lo recorramos todo!

Pero que pasaría si tuvieramos una función tal que nos permitiera saber cual es el máximo posible que podemos obtener por una cierta rama?  En ese caso, podríamos cortar cualquier rama si supieramos que por ese camino es imposible lograr algo mejor que lo que ya tenemos.

Esta técnica se conoce como de “acotamiento y poda”.  La idea es buscar una función que para cada estado del espacio de búsqueda nos de cotas del máximo posible al que se puede llegar por dicho camino y de mínimo seguro que podemos obtener también por ese camino.

Esta técnica es increíblemente poderosa y conviene que mediten un momento sobre la misma.  Vale la pena hacer notar que no siempre es sencillo encontrar la función de acotamiento correcta.  Ya que una función que de una cota muy alta no nos sirve de mucho, ya que las podas serian pocas, pero una cota incorrecta nos puede hacer que entreguemos resultados incorrectos.  Por lo tanto al utilizar esta técnica, siempre debemos buscar la función que acote lo más posible pero estando siempre 100% seguros de que el resultado que obtuvimos es efectivamente mayor o igual al máximo posible.

Para este problema voy a definir las dos funciones de acotamiento, llamemos a(c) a la función que nos da el máximo posible que se puede obtener estando en el nodo cb(c) a la función que nos da el mínimo asegurado que tenemos también al estar en el nodo c.  El demostrar que ambas funciones son válidas queda como tarea para el alumno.

Lo primero que tenemos que hacer es ordenar las tarjetas de acuerdo a la relación U/P, es decir, cuantos puntos nos dan por cada peso gastado.  Como queremos obtener el máximo puntaje por nuestro dinero obviamente son mejores las tarjetas que nos dan muchos puntos por peso comparadas con las tarjetas que nos dan pocos puntos por cada peso gastado. OJO: esto no implica que la solución correcta deba tomar siempre las mejores tarjetas, únicamente quiere decir que comparadas individualmente, para nuestro objetivo son mejores las tarjetas que dan más puntos por peso.

Una vez que tengamos todas las tarjetas ordenadas en base a este criterio, iremos decidiendo si las tomamos o no, en ese respectivo orden.  Para cada nodo, nuestras funciones de acotamiento estarán definidas de la siguiente manera:

b(c):  Para calcular la cota mínima asegurada del nodo c vamos tomando las tarjetas que aún no hemos utilizado según el ordenamiento mientras aún tengamos dinero, en el momento en el que no tengamos más dinero para comprar ahi nos detenemos.  Ese es el mínimo que seguro podemos obtener.

a(c):  Para calcular la cota alta, hacemos el mismo procedimiento que en b (o mejor tomamos el resultado de b para no recalcular) y con la primera tarjeta que no pudimos tomamos su relacion U/P y la multiplicamos por el dinero que aún tenemos disponible y lo sumamos a b.  Así obtenemos el máximo posible que se puede lograr en el subárbol del nodo c.  La operación que efectuamos al final fue la de utilizar el dinero que aún tenemos disponible para comprar un “pedazo” de la mejor tarjeta aún queda, obviamente esto no es posible ya que no podemos comprar pedazos de tarjeta, sin embargo nos sirve para calcular el máximo posible.  

Demostrar que b es válida es trivial, sin embargo queda para el lector demostrar que a es una cota que siempre dará un número mayor o igual al máximo posible que se puede obtener por ese camino.

Obviamente una vez que tengamos las funciones de acotamiento, podemos hacer nuestra búsqueda almacenando cual es el mejor mínimo asegurado que hemos obtenido hasta el momento y eliminando todas las ramas cuyo máximo asegurado es menor o igual que éste.

Detalles de implementación: Para la implementación queda un último detalle que resolver, y este es como vamos a buscar?  Como casi siempre tenemos dos opciones, la primera es la de la búsqueda en profundidad, para la cual se implementa una rutina recursiva y no se requiere de mantener arreglos de memoria externos.  Y la segunda es una búsqueda por amplitud, para la cual requieres de una cola que te permita almacenar los estados proximos a ser evaluados.

Si optamos por la búsqueda en profundidad, hay un detalle de implementación muy sútil que puede ser de gran ayuda.  Supongan que modelamos el árbol de búsqueda como el que se muestra en la figura de arriba.  Y supongan que nuestro algoritmo de búsqueda revisa primero la rama izquierda, de ser asi, la cota mínima asegurada y la cota máxima del hijo de la izquierda es exactamente igual a la de su padre, por lo que bajariamos un nivel en la búsqueda sin obtener ninguna información nueva, si, en cambio, revisamos primero el hijo de la derecha, entonces estaríamos obteniendo nuevas cotas con información probablemente últil.

Si se opta por la búsqueda en amplitud, se tiene una ventaja, y esta es que la cola se puede sustituir por un monticulo de modo que se priorice la búsqueda según el nodo que tiene el mejor mínimo asegurado.  Sin embargo aunque esto podría efectivamente reducir la búsqueda bastante no estamos seguros del tamaño que puede llegar a tener la cola y requeririamos que al llenarse la cola el programa pudiera cambiar a una técnica de búsqueda en profundidad, lo cual haría el código más enredado.  Sin embargo si se desea llegar a límites aún mas grandes, esta sería la opción a seguir.  

Implementación:  

Solución a “Ubongo 3D”

Concurso: Preselectivo para la IOI 2014, Etapa 1, Problemset 8
Autor: Miguel Covarrubias
Fuente: Miguel Covarrubias

La solución pone piezas de manera recursiva mientras quepan en el tablero y no se empalmen.

resuelve(pieza)
    si pieza > P
        regresa “Si”
    para cada rotación de la pieza
        para cada casilla g del tablero
            para cada cubo c de la pieza
                si al poner c sobre g, la pieza queda dentro de los primeros 2 niveles del tablero y no se empalma con otra pieza ya puesta entonces
                    marca las posiciones de los cubos de la pieza como ocupados
                    resuelve(pieza + 1)
                    desmarca los cubos de la pieza

Para rotar una pieza se puede rotar por 0^o, 90^o, 180^o o 270^o alrededor de cada eje. El número de operaciones es aproximadamente (número de rotaciones * número de casillas del tablero * número de cubos de una pieza)^3 \le (24 * 7 * 5)^3 < 600,000,000. En los casos de prueba y en el juego todas las soluciones tocan la base del tablero, si no fuera así, solo hay que duplicar el 7 a 14. Para checar si una pieza se puede poner en cierta posición se pueden usar mascaras de bits para los niveles del tablero y para las posiciones ocupadas. Para poner la última pieza se puede comparar todas las rotaciones de los cubos no ocupados contra la última pieza y la complejidad cubica de la solución se reduce a cuadrática.