2010-07-14 37 views
27

Hay algunas bibliotecas de memorización automática disponibles en Internet para varios idiomas diferentes; pero sin saber para qué sirven, dónde usarlos y cómo funcionan, puede ser difícil ver su valor. ¿Cuáles son algunos argumentos convincentes para usar la memorización y en qué dominio de problemas brilla especialmente la memoria? La información para los desinformados sería especialmente apreciada aquí.¿Para qué sirve la memorización y realmente es tan útil?

+1

no es en absoluto difícil de ver su valor. – hop

+1

posible duplicado de [¿Qué es la memorización?] (Http://stackoverflow.com/questions/1988804/what-is-memoization) –

Respuesta

18

El popular factorial respuesta aquí es algo así como una respuesta juguete. Sí, la memorización es útil para las invocaciones repetidas de esa función, pero la relación es trivial: en el caso de "impresión factorial (N) para 0..M", simplemente está reutilizando el último valor.

Muchos de los otros ejemplos aquí son simplemente 'caching'. Lo cual es útil, pero ignora las asombrosas implicaciones algorítmicas que la palabra memoracion tiene para mí.

Mucho más interesantes son los casos en que las diferentes ramas de invocación única de una función recursiva golpean idénticos sub-problemas pero en un patrón no trivial tal que realmente es útil indexar en alguna memoria caché.

Por ejemplo, considere n matrices dimensionales de enteros cuyos valores absolutos suman k. P.ej. para n = 3, k = 5 [1, -4,0], [3, -1,1], [5,0,0], [0,5,0] serían algunos ejemplos. Sea V (n, k) la cantidad de matrices únicas posibles para un n, k dado. Su definición es:

V(n,0)=1; V(0,k)=0; V(n,k) = V(n-1,k) + V(n,k-1) + V(n-1,k-1);

Esta función da 102 para n = 3, k = 5.

Sin memoria, esto se vuelve muy lento para calcular incluso para números bastante modestos. Si visualizas el procesamiento como un árbol, cada nodo una invocación de V() expandiendo a tres hijos tendrías 186,268,135,991,213,676,920,832 V (n, 0) = 1 hojas en el cálculo de V (32,32) ... Implementado ingenuamente esta función se vuelve rápidamente incomputable en el hardware disponible.

Pero muchas de las ramas secundarias del árbol son duplicados exactos entre sí, aunque no de una manera trivial que podría eliminarse fácilmente como la función factorial. Con la memorización podemos fusionar todas esas ramas duplicadas. De hecho, con la memorización V (32,32) solo se ejecuta V() 1024 (n * m) veces, que es una aceleración de un factor de 10^21 (que aumenta a medida que n, k crece, obviamente) más o menos a cambio por una cantidad bastante pequeña de memoria. :) Encuentro este tipo de cambio fundamental en la complejidad de un algoritmo mucho más emocionante que el simple almacenamiento en caché. Puede hacer que los problemas intratables sean fáciles.

Dado que los números python son naturalmente bignums, puede implementar esta fórmula en python con memoialización utilizando un diccionario y las teclas de tupla en solo 9 líneas. Pruébalo y pruébalo sin la memorización.

+2

Usted dice "Muchos de los otros algoritmos aquí son simplemente el almacenamiento en caché", luego "Mucho más interesante es ..." y continúa describiendo ... ¿almacenamiento en caché? Luego, describe una secuencia de ejemplo similar a fibonacci (o mi problema de cuadrícula), después de llamar a las otras respuestas 'juguetes' (incluidos los que usan fibonacci). Me alegra que hayas aceptado tu respuesta, pero personalmente no creo que hayas traído nada nuevo a la mesa. :) – Stephen

+3

Eh, lo siento por no hacer la distinción más clara. La respuesta de Fibonacci no estaba activa cuando comencé a escribir y en realidad no explicaste el ejemplo de tu cuadrícula de forma que alguien pudiera intentarlo. De lo contrario, probablemente no habría respondido. :) Aunque la respuesta número 1 votado es, en mi opinión, todavía no es la más interesante o precisa. El factorial realmente es un juguete y creo que justifiqué esa afirmación. Mi punto de vista sobre la distinción entre "almacenamiento en caché" y "memorización" es el cambio de complejidad, la misma distinción que se dibuja con "ridículo" a "manejable". Aclamaciones. – Gmaxwell

+0

Esta respuesta fue aceptada porque se relaciona directamente (recordando "respuestas de ramificación" pasadas en recursividad) al algoritmo al que se estaba aplicando y demostrando aquí: http: // stackoverflow.com/questions/3242597/what-is-memoization-good-for-and-is-it-really-all-that-helpful/3276775 # 3276775 –

11

Memoization brilla en problemas donde las soluciones a subproblemas se pueden reutilizar. Hablando simplemente, es una forma de almacenamiento en caché. Veamos la función factorial como un ejemplo.

3! es un problema por sí mismo, pero también es un subproblema para n! donde n> 3 como 4! = 4 * 3! ¡Una función que calcula factoriales puede funcionar mucho mejor con la memorización porque solo calculará 3! una vez y almacenar el resultado internamente en una tabla hash. ¡Cuando se trata de 3! nuevamente verá el valor en la tabla en lugar de volver a calcularlo.

Cualquier problema donde las soluciones de subproblemas se pueden reutilizar (cuanto más frecuentemente, mejor) es un candidato para el uso de la memorización.

+1

Dudoso. ¡Sospecho que su búsqueda de tabla hash será mucho más cara que volver a calcular 3 !. –

+7

@Jonathan: ¡¿Qué tal 10 !? 50 !? –

+7

@Jonathan: ¡Claro, 3! es más rápido para recalcular Es '20!'? Es '100!'? Es '200!'? Tomaré 'O (1)' en cualquier momento. – Stephen

1

Uso la memorización todo el tiempo cuando migro datos de un sistema a otro (ETL). El concepto es que si una función siempre devuelve el mismo resultado para el mismo conjunto de entradas, puede tener sentido almacenar en caché el resultado, especialmente si lleva un tiempo calcular ese resultado. Al hacer ETL, a menudo repites las mismas acciones muchas veces en muchos datos, y el rendimiento a menudo es crítico. Cuando el rendimiento no es una preocupación o es insignificante, probablemente no tenga sentido memorizar sus métodos. Como cualquier cosa, use la herramienta adecuada para el trabajo.

+1

¿Cómo se puede lidiar con el subproceso de seguridad e infinito crecimiento en su tabla hash? –

+1

La seguridad del subproceso no es un problema, e incluso si tiene millones y millones de filas de datos, solo puede tener unos mil combos distintos para memorizar en esos millones de filas. En un entorno ETL configurado correctamente, debe tener suficiente RAM de todos modos. – mattmc3

2

La memorización es esencialmente el almacenamiento en caché del valor de retorno de una función para una entrada determinada. Esto es útil si va a repetir una llamada de función muchas veces con la misma entrada, y especialmente si la función tarda un poco en ejecutarse. Por supuesto, dado que los datos deben almacenarse en algún lugar, la memorización va a usar más memoria. Es una compensación entre usar CPU y usar RAM.

13

Memoization es una técnica para almacenar las respuestas a los subproblemas, por lo que un programa no necesita volver a resolver los mismos sub-problemas más adelante.

Es a menudo una técnica importante para resolver problemas usando Dynamic Programming.

Imagine enumerar todas las rutas desde la esquina superior izquierda de una cuadrícula hasta la esquina inferior derecha de una cuadrícula. Muchos de los caminos se superponen entre sí. Puede memorizar las soluciones calculadas para cada punto en la cuadrícula, construyendo desde la esquina inferior derecha hasta la parte superior izquierda. Esto reduce el tiempo de cálculo de "ridículo" a "manejable".

Otro uso es: enumere los factoriales del número 0 a 100. ¡No desea calcular 100! usando 100 * 99 * ... * 1. Usted ya calculó 99!, entonces reutilice esa respuesta y simplemente multiplique 100 veces la respuesta a 99!. Puede memorizar la respuesta en cada uno de estos pasos (trabajando desde 1 hasta 100) para ahorrar cantidades significativas de computación.

Para un punto de datos, para mi red de resolución de problemas más arriba (el problema es un desafío de programación):

memoized:

real 0m3.128s 
user 0m1.120s 
sys 0m0.064s 

-no memoized (que he matado, porque estaba cansado de esperar ... entonces esto está incompleto)

real 24m6.513s 
user 23m52.478s 
sys 0m6.040s 
+1

Si tiene una cuadrícula m-by-n, suponiendo que su ruta solo puede ir hacia abajo y hacia la derecha, hay (m + n-2)!/((M-1)! (N-1)!). Incluso si m y n son solo alrededor de 10, hay alrededor de 50K caminos. ¡Esto es mucho rendimiento! (Si permite las izquierdas y las altas, hay un número infinito de rutas, a menos que no permita ciclos). –

+1

@Paul Hanbury: Sí. No daré la respuesta al problema ... pero es> 100 mil millones. Con la memorización, se resuelve en 1s :) (Esto solo se mueve hacia abajo y hacia la derecha). – Stephen

+1

Lo siento, si la respuesta es un número, creo que ya lo di. Pensé que querías decir que estabas imprimiendo todos los caminos. –

-7

Memoización es sólo af palabra antigua para el almacenamiento en caché. Si los cálculos son más costosos que extraer la información del caché, entonces es algo bueno. El problema es que las CPU son rápidas y la memoria lenta. Así que he descubierto que el uso de la memorización suele ser mucho más lento que simplemente rehacer el cálculo.

Por supuesto, hay otras técnicas disponibles que realmente le dan una mejora significativa. Si sé que necesito f (10) para cada iteración de un ciclo, lo almacenaré en una variable. Como no hay búsqueda de caché, esto generalmente es una ganancia.

EDITAR

Adelante, downvote todo lo que quiera. Eso no cambiará el hecho de que necesitas hacer un benchmarking real y no simplemente comenzar a lanzar ciegamente todo en tablas hash.

Si conoce su rango de valores en tiempo de compilación, digamos porque está usando n! y n es un int de 32 bits, entonces será mejor que use una matriz estática.

Si su rango de valores es grande, digamos doble, su tabla hash puede crecer tanto que se convierte en un problema grave.

Si el mismo resultado se usa una y otra vez junto con un objeto dado, entonces puede tener sentido almacenar ese valor con el objeto.

En mi caso descubrí que más del 90% del tiempo las entradas para cualquier iteración dada eran las mismas que la última iteración. Eso significa que solo necesitaba mantener la última entrada y el último resultado y solo recalc si la entrada cambió. Este fue un orden de magnitud más rápido que el uso de la memoria para ese algoritmo.

+2

Creo que la frase "las CPU son rápidas y la memoria es lenta" se desvanecería en insignificancia cuando se habla del factorial de 100. ¿Cómo se clasifican entonces las comparaciones de velocidad? – paxdiablo

+1

Si simplemente reemplaza ese bit con algo como "es más útil para cálculos intensos, menos para cálculos rápidos", invertiré el -1 que alguien le dio. – paxdiablo

+1

Además, no todo lo que se memoriza pasará automáticamente a la memoria, simplemente puede quedarse en la CPU como un registro, dependiendo de cómo lo haya codificado. –

4

La memorización puede acelerar radicalmente los algoritmos. El ejemplo clásico es la serie Fibonocci, donde el algoritmo recursivo es increíblemente lento, pero la memorización automáticamente lo hace tan rápido como la versión iterativa.

+1

La compilación estática en una matriz es aún más rápida. –

+2

@Jonathan: Te gusta trolling, ¿verdad? –

5

La memoración intercambia el tiempo por el espacio.

La memorización puede convertir el tiempo exponencial (o peor) en tiempo lineal (o mejor) cuando se aplica a problemas que son de naturaleza recursiva múltiple. El costo es generalmente O (n) espacio.

El ejemplo clásico es calcular la secuencia de Fibonacci.La definición libro de texto es la relación de recurrencia:

F (n) = F (n-1) + F (n-2)

Implementado ingenuamente, que se ve así:

int fib(int n) { 
    if (n == 0) { 
    return 0; 
    } 
    else if (n == 1) { 
    return 1; 
    } 
    else { 
    return fib(n-1) + fib(n-2); 
    } 
} 

Puede ver que el tiempo de ejecución crece exponencialmente con n porque cada una de las sumas parciales se calcula varias veces.

Implementado con memoization, parece que esto (torpe, pero funcional):

int fib(int n) { 
    static bool initialized = false; 
    static std::vector<int> memo; 

    if (!initialized) { 
    memo.push_back(0); 
    memo.push_back(1); 
    initialized = true; 
    } 

    if (memo.size() > n) { 
    return memo[n]; 
    } 
    else { 
    const int val = fib(n-1) + fib(n-2); 
    memo.push_back(val); 
    return val; 
    } 
} 

Timing estas dos implementaciones en mi portátil, para n = 42, la versión ingenua toma 6,5 ​​segundos. La versión memoial tarda 0.005 segundos (todo el tiempo del sistema, es decir, está vinculado a E/S). Para n = 50, la versión memoial todavía tarda 0.005 segundos, y la versión ingenua finalmente finalizó después de 5 minutos & 7 segundos (no importa que ambos hayan desbordado un entero de 32 bits).

+2

@Jonathan: Es un ejemplo simple. Para el código de producción, debería ir a BigInts y agregar un contenedor con una verificación única para n> 0, pero aparte de eso, no veo ningún problema. Obviamente, no utilizarías la versión ingenua por mucho tiempo (supongo que descubrirías la memoria muy rápido). Aligerar ... –

22

En mi opinión, los cálculos Fibonacci y factoriales no son realmente los mejores ejemplos. Memoisation realmente entra en en su propio cuando se tiene:

  1. una amplia gama de entradas potenciales para el cálculo de que se trate, pero el rango sigue siendo claramente limitado y conocido
  2. Se sabe de antemano que cualquier uso real del programa solo usará un pequeño subconjunto de posibles entradas para su cálculo (Fibonacci y factorial fallan esto)
  3. No sabe qué entradas particulares se usarán en tiempo de ejecución, y por lo tanto qué resultados particulares necesitarán ser memoised (Fibonacci y factorial fallan esto también, hasta cierto punto)

Obviamente, si conoce sabe todas las entradas posibles, y el espacio lo permite, puede reemplazar la función con una búsqueda (que es lo que haré, por ejemplo, una implementación CRC32 incrustada con un generador conocido).

... incluso mejor que # 2 si para cualquier ejecución particular del programa, puede decir inmediatamente que "el rango de posibles entradas estará restringido a un subconjunto que satisfaga estas condiciones ...".

Tenga en cuenta que gran parte de esto puede ser probabilístico (o intuitivo) - seguro, alguien podría probar todas las 10^13 entradas posibles para su cálculo mágico, pero usted sabe que no lo harán. Si lo hacen, la sobrecarga de la memoria en realidad no les beneficiará. Pero puede decidir que esto es aceptable, o permitir eludir la memorización en tales circunstancias.


He aquí un ejemplo, y yo espero que no sea demasiado enrevesado (o generalizada) para ser informativos.

En algunos firmware que he escrito, una parte del programa toma una lectura de un ADC, que podría ser cualquier número desde 0x000 hasta 0xFFF y calcula una salida para alguna otra parte del programa. Este cálculo también toma un conjunto de números ajustables por el usuario, pero estos solo se leen al inicio del programa. Este cálculo es bastante exitoso la primera vez que se ejecuta.

Crear una tabla de búsqueda antes de tiempo es ridículo. El dominio de entrada es el producto cartesiano de [0x000, ..., 0xFFF] y (una amplia gama de valores de coma flotante) y (otro rango grande ...) y ... No, gracias.

Pero ningún usuario necesita o espera que el dispositivo funcione bien cuando las condiciones cambian rápidamente, y tendrían mucho, pero funciona mejor cuando las cosas son constantes. Por lo tanto, comparto el comportamiento computacional que refleja estos requisitos: quiero que este cálculo sea agradable y rápido cuando las cosas se mantienen estables, y no me importa cuándo no lo sean.

Teniendo en cuenta la definición de "poco a poco cambio de las condiciones" que el usuario típico de espera, que el valor ADC va conformarse a un valor particular y permanecen dentro de aproximadamente 0x010 de su valor establecido. Qué valor depende de las condiciones. por lo tanto

El resultado del cálculo se puede memoised para estos 16 entradas potenciales. Si las condiciones ambientales hacen cambio rápido de lo esperado, el ADC "más lejos" lee desde el más reciente se descarta (por ejemplo. Si he almacenado en caché a 0x210 0x21F, y luego leí 0x222, se me cae el resultado 0x210).

El inconveniente aquí es que si las condiciones ambientales cambian mucho, ya que el cálculo lento corre un poco más lento. Ya hemos establecido que este es un caso de uso inusual, pero si alguien más tarde revela que, en realidad, do desea operarlo en condiciones inusualmente inestables, puedo implementar una forma de eludir la memorización.

+5

+1 para la guía del mundo real. –

+2

También he agregado un ejemplo ahora. – detly

+1

+1 Buen ejemplo. –

4

Uno de los usos para una forma de memorización es en el análisis de árbol de juegos. En el análisis de árboles de juego no triviales (piense ajedrez, ir, puente), calcular el valor de una posición es una tarea no trivial y puede tomar un tiempo significativo. Una implementación ingenua simplemente usará este resultado y luego lo descartará, pero todos los jugadores fuertes lo almacenarán y usarán en caso de que surja nuevamente la situación. Puedes imaginar que en el ajedrez hay innumerables formas de alcanzar la misma posición.

Para lograr esto en la práctica requiere la experimentación sin fin y puesta a punto, pero es seguro decir que los programas de ordenador de ajedrez no serían lo que son hoy en día sin esta técnica.

En AI el uso de tales memoization que normalmente se conoce como una 'tabla de transposición'.

1

creo que la mayoría de todo el mundo ha cubierto las bases de memoization, pero yo voy a dar algunos ejemplos prácticos donde moization se puede utilizar para hacer algunas bonitas sorprendentes cosas (mi humilde opinión):

  1. en C# que puede reflejar una función y crear un delegado para ella, luego puede invocar dinámicamente al delegado ... ¡pero esto es REALMENTE lento! Es aproximadamente 30 veces más lento que invocar el método directamente. Si memoriza la invocación del método, puede hacer que la invocación sea tan rápida como invocar el método directamente.
  2. En Programación genética, puede reducir la sobrecarga de llamar repetidamente a la misma función con los parámetros de entrada similares para cientos y miles de especímenes en la población.
  3. En la ejecución de los árboles de expresión: usted no tiene que mantener la reevaluación del árbol de expresión si ya se memoized ...

Por supuesto que hay muchos más ejemplos prácticos donde la memorización se puede usar, pero estos son solo algunos.

En my blog discutir memoization y reflection separado, pero voy a publicar otro artículo sobre el uso de métodos de memoization reflejadas ...

1

Como ejemplo de cómo utilizar la memoria para aumentar el rendimiento de un algoritmo, lo siguiente se ejecuta aproximadamente veces más rápido para este caso de prueba en particular. Antes, tomaba ~ 200 segundos; 2/3 memoized.


class Slice: 

    __slots__ = 'prefix', 'root', 'suffix' 

    def __init__(self, prefix, root, suffix): 
     self.prefix = prefix 
     self.root = root 
     self.suffix = suffix 

################################################################################ 

class Match: 

    __slots__ = 'a', 'b', 'prefix', 'suffix', 'value' 

    def __init__(self, a, b, prefix, suffix, value): 
     self.a = a 
     self.b = b 
     self.prefix = prefix 
     self.suffix = suffix 
     self.value = value 

################################################################################ 

class Tree: 

    __slots__ = 'nodes', 'index', 'value' 

    def __init__(self, nodes, index, value): 
     self.nodes = nodes 
     self.index = index 
     self.value = value 

################################################################################ 

def old_search(a, b): 
    # Initialize startup variables. 
    nodes, index = [], [] 
    a_size, b_size = len(a), len(b) 
    # Begin to slice the sequences. 
    for size in range(min(a_size, b_size), 0, -1): 
     for a_addr in range(a_size - size + 1): 
      # Slice "a" at address and end. 
      a_term = a_addr + size 
      a_root = a[a_addr:a_term] 
      for b_addr in range(b_size - size + 1): 
       # Slice "b" at address and end. 
       b_term = b_addr + size 
       b_root = b[b_addr:b_term] 
       # Find out if slices are equal. 
       if a_root == b_root: 
        # Create prefix tree to search. 
        a_pref, b_pref = a[:a_addr], b[:b_addr] 
        p_tree = old_search(a_pref, b_pref) 
        # Create suffix tree to search. 
        a_suff, b_suff = a[a_term:], b[b_term:] 
        s_tree = old_search(a_suff, b_suff) 
        # Make completed slice objects. 
        a_slic = Slice(a_pref, a_root, a_suff) 
        b_slic = Slice(b_pref, b_root, b_suff) 
        # Finish the match calculation. 
        value = size + p_tree.value + s_tree.value 
        match = Match(a_slic, b_slic, p_tree, s_tree, value) 
        # Append results to tree lists. 
        nodes.append(match) 
        index.append(value) 
     # Return largest matches found. 
     if nodes: 
      return Tree(nodes, index, max(index)) 
    # Give caller null tree object. 
    return Tree(nodes, index, 0) 

################################################################################ 

def search(memo, a, b): 
    # Initialize startup variables. 
    nodes, index = [], [] 
    a_size, b_size = len(a), len(b) 
    # Begin to slice the sequences. 
    for size in range(min(a_size, b_size), 0, -1): 
     for a_addr in range(a_size - size + 1): 
      # Slice "a" at address and end. 
      a_term = a_addr + size 
      a_root = a[a_addr:a_term] 
      for b_addr in range(b_size - size + 1): 
       # Slice "b" at address and end. 
       b_term = b_addr + size 
       b_root = b[b_addr:b_term] 
       # Find out if slices are equal. 
       if a_root == b_root: 
        # Create prefix tree to search. 
        key = a_pref, b_pref = a[:a_addr], b[:b_addr] 
        if key not in memo: 
         memo[key] = search(memo, a_pref, b_pref) 
        p_tree = memo[key] 
        # Create suffix tree to search. 
        key = a_suff, b_suff = a[a_term:], b[b_term:] 
        if key not in memo: 
         memo[key] = search(memo, a_suff, b_suff) 
        s_tree = memo[key] 
        # Make completed slice objects. 
        a_slic = Slice(a_pref, a_root, a_suff) 
        b_slic = Slice(b_pref, b_root, b_suff) 
        # Finish the match calculation. 
        value = size + p_tree.value + s_tree.value 
        match = Match(a_slic, b_slic, p_tree, s_tree, value) 
        # Append results to tree lists. 
        nodes.append(match) 
        index.append(value) 
     # Return largest matches found. 
     if nodes: 
      return Tree(nodes, index, max(index)) 
    # Give caller null tree object. 
    return Tree(nodes, index, 0) 

################################################################################ 

import time 
a = tuple(range(50)) 
b = (48, 11, 5, 22, 28, 31, 14, 18, 7, 29, 49, 44, 47, 36, 25, 27, 
    34, 10, 38, 15, 21, 16, 35, 20, 45, 2, 37, 33, 6, 30, 0, 8, 13, 
    43, 32, 1, 40, 26, 24, 42, 39, 9, 12, 17, 46, 4, 23, 3, 19, 41) 

start = time.clock() 
old_search(a, b) 
stop = time.clock() 

print('old_search() =', stop - start) 

start = time.clock() 
search({}, a, b) 
stop = time.clock() 

print('search() =', stop - start) 

Referencia:How can memoization be applied to this algorithm?

Cuestiones relacionadas