Haskell maneras al problema 3n + 1

12

Aquí hay un problema de programación simple de SPOJ: http://www.spoj.com/problems/PROBTRES/ .

Básicamente, se le pide que genere el mayor ciclo de Collatz para números entre i y j. (El ciclo de Collatz de un número $ n $ es el número de pasos para llegar eventualmente de $ n $ a 1.)

He estado buscando una forma de Haskell para resolver el problema con un rendimiento comparativo que el de Java o C ++ (para que se ajuste al límite de tiempo de ejecución permitido). Aunque una solución Java simple que memoriza la duración del ciclo de cualquier ciclo ya calculado funcionará, no he tenido éxito en aplicar la idea para obtener una solución Haskell.

He probado Data.Function.Memoize, así como la técnica de memorización de tiempo de registro elaborada en casa utilizando la idea de esta publicación: /programming/3208258/memoization-in-haskell . Desafortunadamente, la memorización en realidad hace que el cálculo del ciclo (n) sea aún más lento. Creo que la desaceleración proviene de la parte superior del camino Haskell. (Intenté ejecutar con el código binario compilado, en lugar de interpretar).

También sospecho que simplemente iterar números de i a j puede ser costoso ($ i, j \ le10 ^ 6 $). Así que incluso intenté calcular previamente todo para la consulta de rango, usando la idea de http://blog.openendings.net/2013/10/range-trees-and-profiling-in-haskell.html . Sin embargo, esto todavía da el error "Límite de tiempo excedido".

¿Puedes ayudar a informar un programa Haskell competitivo y ordenado para esto?

Haskell se ve muy bien
fuente
10
Esta publicación me parece bien. Es un problema algorítmico que necesita un diseño adecuado para lograr un rendimiento adecuado. Lo que realmente no queremos aquí es preguntas de "¿Cómo soluciono mi código roto?".
Robert Harvey

Respuestas:

7

Contestaré en Scala, porque mi Haskell no es tan nuevo, por lo que la gente creerá que esta es una pregunta general de algoritmo de programación funcional. Me atendré a las estructuras de datos y conceptos que son fácilmente transferibles.

Podemos comenzar con una función que genera una secuencia de collatz, que es relativamente sencilla, excepto por la necesidad de pasar el resultado como un argumento para hacerlo recursivo:

def collatz(n: Int, result: List[Int] = List()): List[Int] = {
   if (n == 1) {
     1 :: result
   } else if ((n & 1) == 1) {
     collatz(3 * n + 1, n :: result)
   } else {
     collatz(n / 2, n :: result)
   }
 }

Esto realmente pone la secuencia en orden inverso, pero eso es perfecto para nuestro próximo paso, que es almacenar las longitudes en un mapa:

def calculateLengths(sequence: List[Int], length: Int,
  lengths: Map[Int, Int]): Map[Int, Int] = sequence match {
    case Nil     => lengths
    case x :: xs => calculateLengths(xs, length + 1, lengths + ((x, length)))
}

Llamaría esto con la respuesta del primer paso, la longitud inicial y un mapa vacío, como calculateLengths(collatz(22), 1, Map.empty)). Así es como se memoriza el resultado. Ahora necesitamos modificar collatzpara poder usar esto:

def collatz(n: Int, lengths: Map[Int, Int], result: List[Int] = List()): (List[Int], Int) = {
  if (lengths contains n) {
     (result, lengths(n))
  } else if ((n & 1) == 1) {
    collatz(3 * n + 1, lengths, n :: result)
  } else {
    collatz(n / 2, lengths, n :: result)
  }
}

Eliminamos el n == 1cheque porque solo podemos inicializar el mapa 1 -> 1, pero necesitamos agregar 1a las longitudes que ponemos en el mapa dentro calculateLengths. Ahora también devuelve la longitud memorizada donde dejó de repetirse, que podemos usar para inicializar calculateLengths, como:

val initialMap = Map(1 -> 1)
val (result, length) = collatz(22, initialMap)
val newMap = calculateLengths(result, lengths, initialMap)

Ahora que tenemos implementaciones relativamente eficientes de las piezas, necesitamos encontrar una manera de alimentar los resultados del cálculo anterior en la entrada del siguiente cálculo. Esto se llama a fold, y se ve así:

def iteration(lengths: Map[Int, Int], n: Int): Map[Int, Int] = {
  val (result, length) = collatz(n, lengths)
  calculateLengths(result, length, lengths)
}

val lengths = (1 to 10).foldLeft(Map(1 -> 1))(iteration)

Ahora para encontrar la respuesta real, solo necesitamos filtrar las claves en el mapa entre el rango dado, y encontrar el valor máximo, dando un resultado final de:

def answer(start: Int, finish: Int): Int = {
  val lengths = (start to finish).foldLeft(Map(1 -> 1))(iteration)
  lengths.filterKeys(x => x >= start && x <= finish).values.max
}

En mi REPL para rangos de tamaño 1000 más o menos, como la entrada de ejemplo, la respuesta regresa casi instantáneamente.

Karl Bielefeldt
fuente
3

Karl Bielefeld ya ha respondido bien a la pregunta, solo agregaré una versión de Haskell.

Primero, una versión simple y no memorable del algoritmo básico para mostrar la recurrencia eficiente:

simpleCollatz :: Int -> Int -> Int
simpleCollatz count 1 = count + 1
simpleCollatz count n | odd n     = simpleCollatz (count + 1) (3 * n + 1)
                      | otherwise = simpleCollatz (count + 1) (n `div` 2)

Eso debería ser casi autoexplicativo.

Yo también usaré un simple Mappara almacenar los resultados.

-- double imports to make the namespace pretty
import           Data.Map  ( Map )
import qualified Data.Map as Map

-- a new name for the memoizer
type Store = Map Int Int

Siempre podemos buscar nuestros resultados finales en la tienda, por lo que para un solo valor la firma es

memoCollatz :: Int -> Store -> Store

Comencemos con el caso final

memoCollatz 1 store = Map.insert 1 1 store

Sí, podríamos agregar eso de antemano, pero no me importa. Siguiente caso simple por favor.

memoCollatz n store | Just _ <- Map.lookup n store = store

Si el valor está ahí, entonces está. Sigo sin hacer nada.

                    | odd n     = processNext store (3 * n + 1)
                    | otherwise = processNext store (n `div` 2)

Si el valor no está ahí, tenemos que hacer algo . Pongamos el en una función local. Observe cómo esta parte se parece mucho a la solución "simple", solo que la recursión es un poco más compleja.

  where processNext store'' next | Just count <- Map.lookup next store''
                                 = Map.insert n (count + 1) store''

Ahora finalmente hacemos algo. Si encontramos el valor calculado en la store''(nota al margen: hay dos resaltadores de sintaxis de haskell, pero uno es feo, el otro se confunde con el símbolo primo. Esa es la única razón para el doble cebado), simplemente agregamos el nuevo valor. Pero ahora se pone interesante. Si no encontramos el valor, tenemos que calcularlo y hacer la actualización. ¡Pero ya tenemos funciones para ambos! Entonces

                                | otherwise
                                = processNext (memoCollatz next store'') next

Y ahora podemos calcular un solo valor de manera eficiente. Si queremos calcular varios, simplemente pasamos a la tienda a través de un pliegue.

collatzRange :: Int -> Int -> Store
collatzRange lower higher = foldr memoCollatz Map.empty [lower..higher]

(Es aquí donde puedes inicializar el caso 1/1).

Ahora todo lo que tenemos que hacer es extraer el máximo. Por ahora no puede haber un valor en la tienda que sea superior a uno en el rango, por lo que es suficiente decir

collatzRangeMax :: Int -> Int -> Int
collatzRangeMax lower higher = maximum $ collatzRange lower higher

Por supuesto, si desea calcular varios rangos y compartir la tienda entre esos cálculos también (los pliegues son su amigo), necesitaría un filtro, pero ese no es el enfoque principal aquí.

Marlin
fuente
1
Para mayor velocidad, se Data.IntMap.Strictdebe utilizar.
Olathe