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.

Solución a “Bloqueo”

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

La primera observación que hay que hacer es que si todas las carreteras son bidireccionales y entre cada par de ciudades existe exactamente un camino que las conecta (usando una o mas carreteras) entonces la representación gráfica del problema es un árbol: los nodos representan las ciudades y las aristas representan las carreteras. La siguiente figura, muestra el árbol que representa el caso de prueba dado como ejemplo. Los nodos rojos representan las ciudades ocupadas, el esfuerzo necesario para destruir cada carretera se muestra junto a la arista correspondiente. Entonces queremos eliminar un subconjunto de aristas de peso total mínimo de tal forma que los nodos rojos queden separados.

Caso de ejemplo Solución

En problemas relacionados con árboles, es muy natural tratar de dividir el problema en problemas más pequeños que están dados por los sub-árboles del árbol original. Esto además sugiere usar recursión: “para resolver un árbol, primero resolvemos recursivamente sus sub-árboles y luego combinamos las sub-soluciones”.

Comencemos con los casos sencillos. Si hay solamente un nodo (el árbol tiene altura 0), entonces no habrá aristas y el esfuerzo total necesario es cero.

Consideremos ahora un árbol de altura 1 como el de la siguiente figura. Como la raiz no es roja, basta con eliminar una de las dos aristas:
elegimos la que requiera menos esfuerzo.

Caso sencillo: altura 1 Solución (suponemos que la arista izquierda requiere menos esfuerzo)

Si la raiz fuera roja, entonces tendríamos que eliminar ambas aristas. Con lo anterior nos damos cuenta de que hay dos casos que debemos considerar:

  1. Si la raiz es roja, entonces debemos eliminar todas las aristas que la conectan con nodos rojos
  2. Si la raiz no es roja, entonces no es necesario desconectar la raíz de todos los nodos rojos: la solución óptima es dejar conectado
    el nodo rojo asociado a la arista mas costosa.

Lo anterior se ilustra en la siguiente figura (aquí suponemos que la arista de la derecha es la mas costosa de todas):

Si la raiz es roja, entonces debemos eliminar todas las aristas que la conectan con nodos rojos Si la raiz no es roja, entonces no es necesario desconectar la raíz de todos los nodos rojos

Ahora que tenemos la solución para los casos pequeños, veamos si podemos usar estas soluciones para construir la solución del problema general,
como en la siguiente figura.

Si la raiz es roja y el nodo blanco está conectado
a algun descendiente rojo, la solución ya no es correcta
Aún si la raiz es blanca, no podemos dejar conectado el nodo blanco
ya que si está conectado con un descendiente rojo, la solución sería incorrecta

Supongamos que ya tenemos la solución para todos los hijos directos de la raíz, es decir, que ya cortamos de manera óptima las aristas de todos los subárboles, de modo que ningún par de nodos rojos se conectan en el sub-árbol. Usando sólo esta información, ¿podemos construir la solución del problema general?. Desafortunadamente, esto no es suficiente: nos gustaría dejar conectados a los hijos blancos, pero si existe un nodo rojo debajo de ellos, entonces tendríamos que desconectarlo también. Lo que necesitamos saber es precisamente si un hijo blanco está conectado con uno de sus descendientes rojos, de ser así diremos que el nodo blanco es “peligroso”. Si el nodo blanco está desconectado de todos sus descendientes rojos, entonces diremos que es “seguro”. Entonces tenemos tres tipos de nodos: ocupados, peligrosos y seguros, que representamos como nodos rojos, amarillos y verdes, respectivamente.Con este nuevo concepto, vemos que tenemos dos tipos de soluciones distintas para una raíz blanca: tenemos soluciones peligrosas y soluciones seguras. Es fácil ver que no existen “hojas peligrosas”, ya que las hojas están ocupadas (rojas) o son seguras (verdes).

Reformulemos nuestra solución con este concepto. Si la raíz es roja, entonces debemos desconectarla de todos sus hijos rojos y de todos sus hijos peligrosos. Esto significa que para cada hijo blanco tenemos dos opciones:

  • Hacer que el hijo sea seguro (verde) y no cortar la arista que lo une con la raíz (puede ser costoso hacerlo seguro, pero con eso nos ahorramos el costo de separarlo de la raíz)
  • Hacer que el hijo sea peligroso (amarillo) y cortar la arista que lo une con la raíz (puede ser barato dejarlo inseguro, pero pagamos al separarlo de la raíz)

Lo anterior resuelve el caso en que la raíz es roja.

Ahora, si la raíz no es roja, debemos calcular dos soluciones: la solución segura (dejar la raíz verde) y la solución peligrosa (dejar la raíz amarilla). Notemos que la solución segura es exactamente igual al caso anterior. Por otro lado, para la solución peligrosa, debemos dejar la raíz conectada a exactamente un hijo que sea rojo o peligroso. Para elegir cuál de todos los hijos rojos o peligrosos dejaremos conectado, basta iterar sobre todos los hijos y elegir la mejor opción. El código queda como sigue: