Imprima el siguiente más pequeño de 2 ^ i * 5 ^ j donde i, j> = 0

10

Me hicieron esta pregunta durante una evaluación técnica telefónica recientemente y no me fue bien. La pregunta se incluye textualmente a continuación.

Generar {2^i * 5^j | i,j >= 0}colección ordenada. Imprima continuamente el siguiente valor más pequeño.

Ejemplo: { 1, 2, 4, 5, 8, 10...}

El "siguiente más pequeño" me hace pensar que hay un montón mínimo involucrado, pero realmente no sabía a dónde ir desde allí y el entrevistador no me brindó asistencia.

¿Alguien tiene consejos sobre cómo resolver tal problema?

Justin Skiles
fuente
Creo que la entrevista quiere pedirte que lo hagas en memoria constante. El uso de memoria O (n) hace que esto sea bastante trivial. O al menos usando memoria O (logn) porque el tamaño de codificación para la entrada n sería logn. Una O (n) para solución de memoria es una solución de memoria exponencial.
InformadoA

Respuestas:

14

Reformulemos el problema: envíe cada número del 1 al infinito de modo que el número no tenga factores, excepto 2 y 5.

A continuación se muestra un fragmento de C # simple:

for (int i = 1;;++i)
{
    int num = i;
    while(num%2 == 0) num/=2;
    while(num%5 == 0) num/=5;
    if(num == 1) Console.WriteLine(i);
}

El enfoque de Kilian / QuestionC es mucho más eficaz. Fragmento de C # con este enfoque:

var itms = new SortedSet<int>();
itms.Add(1);
while(true)
{
    int cur = itms.Min;
    itms.Remove(itms.Min);
    itms.Add(cur*2);
    itms.Add(cur*5);
    Console.WriteLine(cur);
}

SortedSet evita inserciones duplicadas.

Básicamente, funciona asegurándose de que esté el siguiente número en la secuencia itms.

Prueba de que este enfoque es válido:
el algoritmo descrito asegura que después de cualquier salida numérica en el formulario 2^i*5^j, el conjunto ahora contiene 2^(i+1)*5^jy 2^i*5^(j+1). Supongamos que el siguiente número en la secuencia es 2^p*5^q. Debe existir un número de salida anterior de la forma 2^(p-1)*5^(q)o 2^p*5^(q-1)(o ambos, si ni p ni q son iguales a 0). Si no, entonces 2^p*5^qno es el siguiente número, ya que 2^(p-1)*5^(q)y 2^p*5^(q-1)son a la vez más pequeño.

El segundo fragmento utiliza O(n)memoria (donde n es el número de números que se han emitido), dado que O(i+j) = O(n)(porque i y j son menos que n), y encontrará n números a O(n log n)tiempo. El primer fragmento encuentra números en tiempo exponencial.

Brian
fuente
1
Hola, puedes ver por qué estaba confundido durante la entrevista, espero. De hecho, el ejemplo proporcionado son salidas del conjunto descrito en la pregunta. 1 = 2^0*5^0, 2 = 2^1*5^0, 4 = 2^2*5^0, 5 = 2^0*5^1, 8 = 2^3*5^0, 10 = 2^1*5^1.
Justin Skiles
¿Se repiten .Remove()y .Add()van a desencadenar un mal comportamiento del recolector de basura, o resolverá las cosas?
Snowbody
1
@Snowbody: la pregunta del operador es una pregunta de algoritmos, por lo que es algo irrelevante. Ignorando eso, su primera preocupación debería ser tratar con enteros muy grandes, ya que esto se convierte en un problema mucho antes que la sobrecarga del recolector de basura.
Brian
8

Esta es una pregunta de entrevista bastante común que es útil saber la respuesta. Aquí está la entrada relevante en mi hoja de cuna personal:

  • para generar los números de la forma 3 a 5 b 7 c en orden , comience con 1, coloque los tres sucesores posibles (3, 5, 7) en una estructura auxiliar, luego agregue el número más pequeño a su lista.

En otras palabras, necesita un enfoque de dos pasos con un búfer ordenado adicional para resolver esto de manera eficiente. (Una buena descripción más larga se encuentra en Cracking the Coding Interview por Gayle McDowell.

Kilian Foth
fuente
3

Aquí hay una respuesta que se ejecuta con memoria constante, a expensas de la CPU. Esta no es una buena respuesta en el contexto de la pregunta original (es decir, la respuesta durante una entrevista). Pero si la entrevista dura 24 horas, entonces no está tan mal. ;)

La idea es que si tengo n, que es una respuesta válida, entonces la siguiente en la secuencia será n veces alguna potencia de dos, dividida por alguna potencia de 5. O bien n veces una potencia de 5, dividida por un poder de dos. Siempre que se divida de manera uniforme. (... o el divisor puede ser 1;) en cuyo caso solo está multiplicando por 2 o 5)

Por ejemplo, para pasar de 625 a 640, multiplique por 5 ** 4/2 ** 7. O, más generalmente, multiplique por algún valor de 2 ** m * 5 ** npara algunos m, n donde uno es positivo y uno es negativo o cero, y el multiplicador divide el número de manera uniforme.

Ahora, la parte difícil es encontrar el multiplicador. Pero sabemos que a) el divisor debe dividir el número de manera uniforme, b) el multiplicador debe ser mayor que uno (los números siguen aumentando) yc) si elegimos el multiplicador más bajo mayor que 1 (es decir, 1 <f <todos los demás f) ), entonces ese es nuestro próximo paso. El siguiente paso será el más bajo.

La parte desagradable es encontrar el valor de m, n. Solo hay posibilidades de log (n), porque solo hay que renunciar a 2 o 5, pero tuve que agregar un factor de -1 a +1 como una forma descuidada de lidiar con el redondeo. Entonces solo tenemos que iterar a través de O (log (n)) en cada paso. Entonces es O (n log (n)) en general.

La buena noticia es que, dado que toma un valor y encuentra el siguiente valor, puede comenzar en cualquier parte de la secuencia. Entonces, si desea el siguiente después de mil millones, puede encontrarlo iterando a través de los 2/5 o 5/2 y seleccionando el multiplicador más pequeño mayor que 1.

(pitón)

MAX = 30
F = - math.log(2) / math.log(5)

def val(i, j):
    return 2 ** i * 5 ** j

def best(i, j):
    f = 100
    m = 0
    n = 0
    max_i = (int)(math.log(val(i, j)) / math.log(2) + 1) if i + j else 1
    #print((val(i, j), max_i, x))
    for mm in range(-i, max_i + 1):
        for rr in {-1, 0, 1}:
            nn = (int)(mm * F + rr)
            if nn < -j: continue
            ff = val(mm, nn)
            #print('  ' + str((ff, mm, nn, rr)))
            if ff > 1 and ff < f:
                f = ff
                m = mm
                n = nn
    return m, n

def detSeq():

    i = 0
    j = 0
    got = [val(i, j)]

    while len(got) < MAX:
        m, n = best(i, j)

        i += m
        j += n
        got.append(val(i, j))

        #print('* ' + str((val(i, j), m, n)))
        #print('- ' + str((v, i, j)))

    return got

Validé los primeros 10,000 números que esto genera contra los primeros 10,000 generados por la solución de lista ordenada, y funciona al menos hasta ahora.

Por cierto, el siguiente después de un billón parece ser 1,024,000,000,000.

...

Hm. Puedo obtener el rendimiento de O (n) - O (1) por valor (!) - y el uso de memoria O (log n) al tratar best()como una tabla de búsqueda que extiendo de forma incremental. En este momento ahorra memoria al iterar cada vez, pero está haciendo muchos cálculos redundantes. Al mantener esos valores intermedios, y una lista de valores mínimos, puedo evitar el trabajo duplicado y acelerarlo mucho. Sin embargo, la lista de valores intermedios crecerá con n, de ahí la memoria O (log n).

Robar
fuente
Gran respuesta. Tengo una idea similar que no he codificado. En esta idea, mantengo un rastreador para 2 y 5. Esto rastreará el máximo ny mha sido utilizado a través de los números en la secuencia hasta ahora. En cada iteración, no mpodría o no subir. Creamos un nuevo número a medida que 2^(max_n+1)*5^(max_m+1)reducimos este número de manera exhaustiva y recursiva cada llamada reduciendo el exponente en 1 hasta que obtengamos el mínimo que sea mayor que el número actual. Actualizamos max_n, max_msegún sea necesario. Esto es constante mem. Puede ser O(log^2(n))mem si se usa caché DP en llamada de reducción
InformadoA
Interesante. La optimización aquí es que no necesita considerar todos los pares de m & n, porque sabemos que m, n producirá el multiplicador más cercano a 1. Por lo tanto, solo necesito evaluar m = -i a max_i, y yo solo puedo calcular n, arrojando algo de basura para el redondeo (era descuidado y solo repetí -1 a 1, pero vale la pena pensar más;)).
Rob
Sin embargo, estoy pensando como tú ... la secuencia va a ser determinista ... es realmente como un gran triángulo de Pascal i + 1 en una dirección y j + 1 en la otra. Entonces la secuencia debe ser matemáticamente determinista. Para cualquier nodo en el triángulo, siempre habrá un siguiente nodo determinado matemáticamente.
Rob
1
Puede haber una fórmula para la siguiente, es posible que no necesitemos hacer una búsqueda. No estoy seguro.
InformadoA
Cuando lo pienso, la forma algebraica del siguiente podría no existir (no todos los problemas deterministas tienen forma algebraica para soluciones) además cuando hay más números primos que solo 2 y 5, la fórmula podría ser bastante difícil de encontrar si uno Realmente quiere resolver esta fórmula. Si alguien conoce la fórmula, probablemente leería un poco sobre esto, esto suena interesante.
InformadoA
2

Brian tenía toda la razón: mi otra respuesta fue demasiado complicada. Aquí hay una manera más simple y rápida de hacerlo.

Imagine el cuadrante I del plano euclidiano, restringido a los enteros. Llame a un eje el eje i y al otro eje el eje j.

Obviamente, los puntos cercanos al origen se seleccionarán antes que los puntos alejados del origen. También tenga en cuenta que el área activa se alejará del eje i antes de alejarse del eje j.

Una vez que se ha usado un punto, nunca más se volverá a usar. Y un punto solo se puede usar si el punto directamente debajo de él o a la izquierda del mismo ya se ha usado.

Al unirlos, puede imaginar una "frontera" o "borde de ataque" que comienza alrededor del origen y se extiende hacia arriba y hacia la derecha, extendiéndose a lo largo del eje i más allá del eje j.

De hecho, podemos descubrir algo más: habrá como máximo un punto en la frontera / borde para cualquier valor i dado. (Debe incrementar i más de 2 veces para igualar un incremento de j.) Entonces, podemos representar la frontera como una lista que contiene un elemento para cada coordenada i, que solo varía con la coordenada j y el valor de la función.

Cada pasada, elegimos el elemento mínimo en el borde de ataque y luego lo movemos en la dirección j una vez. Si estamos aumentando el último elemento, agregamos un nuevo último elemento más con un valor i incrementado y un valor j de 0.

using System;
using System.Collections.Generic;
using System.Text;

namespace TwosFives
{
    class LatticePoint : IComparable<LatticePoint>
    {
      public int i;
      public int j;
      public double value;
      public LatticePoint(int ii, int jj, double vvalue)
      {
          i = ii;
          j = jj;
          value = vvalue;
      }
      public int CompareTo(LatticePoint rhs)
      {
          return value.CompareTo(rhs.value);
      }
    }


    class Program
    {
        static void Main(string[] args)
        {
            LatticePoint startPoint = new LatticePoint(0, 0, 1);

            var leadingEdge = new List<LatticePoint> { startPoint } ;

            while (true)
            {
                LatticePoint min = leadingEdge.Min();
                Console.WriteLine(min.value);
                if (min.j + 1 == leadingEdge.Count)
                {
                    leadingEdge.Add(new LatticePoint(0, min.j + 1, min.value * 2));
                }
                min.i++;
                min.value *= 5;
            }
        }
    }
}

Espacio: O (n) en número de elementos impresos hasta ahora.

Velocidad: O (1) inserta, pero eso no se hace todo el tiempo. (Ocasionalmente más tiempo cuando List<>tiene que crecer, pero aún O (1) amortizado). El gran sumidero de tiempo es la búsqueda del mínimo, O (n) en el número de elementos impresos hasta el momento.

Snowbody
fuente
1
¿Qué algoritmo usa esto? Por que funciona La parte clave de la pregunta que se hace es Does anyone have advice on how to solve such a problem?un intento de comprender el problema subyacente. Un volcado de código no responde bien a esa pregunta.
Buen punto, le expliqué mi pensamiento.
Snowbody
+1 Si bien esto es más o menos equivalente a mi segundo fragmento, el uso de bordes inmutables hace que sea más claro cómo crece el conteo de bordes.
Brian
Esto es definitivamente más lento que el fragmento revisado de Brian, pero su comportamiento de uso de la memoria debería ser mucho mejor, ya que no elimina y agrega elementos constantemente. (A menos que CLR o SortedSet <> tengan algún método para reutilizar elementos que no conozco)
Snowbody
1

Probablemente, la solución basada en conjuntos era lo que buscaba su entrevistador, sin embargo, tiene la desafortunada consecuencia de tener O(n)memoria y O(n lg n)tiempo total para la secuencia de nelementos.

Un poco de matemática nos ayuda a encontrar una solución de O(1)espacio y O(n sqrt(n))tiempo. Tenga en cuenta que 2^i * 5^j = 2^(i + j lg 5). La búsqueda de los primeros nelementos de la {i,j > 0 | 2^(i + j lg 5)}reduce a la búsqueda de los primeros nelementos de {i,j > 0 | i + j lg 5}porque la función (x -> 2^x)es estrictamente monótona en aumento, por lo que la única manera de que alguna a,bde que 2^a < 2^bes si a < b.

Ahora, solo necesitamos un algoritmo para encontrar la secuencia de i + j lg 5dónde i,jestán los números naturales. En otras palabras, dado nuestro valor actual de i, j, lo que minimiza el siguiente movimiento (es decir, nos da el siguiente número en la secuencia), es un aumento en uno de los valores (digamos j += 1) junto con una disminución en el otro ( i -= 2). Lo único que nos limita es eso i,j > 0.

Solo hay dos casos a considerar: iaumentos o jaumentos. Uno de ellos debe aumentar ya que nuestra secuencia está aumentando, y ambos no aumentan porque de lo contrario estamos omitiendo el término en el que solo tenemos uno de i,jaumento. Así, uno aumenta y el otro permanece igual o disminuye. Expresado en C ++ 11, el algoritmo completo y su comparación con la solución establecida están disponibles aquí .

Esto logra una memoria constante ya que solo hay una cantidad constante de objetos asignados en el método, aparte de la matriz de salida (ver enlace). El método logra el tiempo logarítmico en cada iteración, ya que, para cualquier caso (i,j), atraviesa el mejor par de (a, b)manera que (i + a, j + b)sea ​​el menor incremento en el valor de i + j lg 5. Este recorrido es O(i + j):

Attempt to increase i:
++i
current difference in value CD = 1
while (j > 0)
  --j
  mark difference in value for
     current (i,j) as CD -= lg 5
  while (CD < 0) // Have to increase the sequence
    ++i          // This while will end in three loops at most.
    CD += 1
find minimum among each marked difference ((i,j) -> CD)

Attempt to increase j:
++j
current difference in value CD = lg 5
while (j > 0)
  --i
  mark difference in value for
     current (i,j) as CD -= 1
  while (CD < 0) // have to increase the sequence
    ++j          // This while will end in one loop at most.
    CD += lg 5
find minimum among each marked difference ((i,j) -> CD)

Cada iteración intenta actualizarse i, entonces j, y va con la actualización más pequeña de las dos.

Desde iy jcomo mucho O(sqrt(n)), tenemos O(n sqrt(n))tiempo total . iy jcrecer a la velocidad del cuadrado de ndesde para cualquier valor máximo imaxy jmaxexisten O(i j)pares únicos a partir de los cuales hacer nuestra secuencia si nuestra secuencia es ntérminos, iy jcrecer dentro de un factor constante entre sí (porque el exponente está compuesto por un lineal combinación de los dos), lo sabemos iy jsomos O(sqrt(n)).

No hay mucho de qué preocuparse con respecto al error de coma flotante, ya que los términos crecen exponencialmente, tendríamos que lidiar con el desbordamiento antes de que el error de flop nos alcance, en varias magnitudes. Agregaré más discusión a esto si tengo tiempo.

VF1
fuente
gran respuesta, creo que también hay un patrón en el aumento de la secuencia para cualquier número primos
InformadoA
@randomA Gracias. Después de pensarlo un poco más, llegué a la conclusión de que, tal como está actualmente, mi algoritmo no es tan rápido como pensaba. Si hay una forma más rápida de evaluar "Intento de aumentar i / j", creo que esa es la clave para obtener el tiempo logarítmico.
VF1
Estaba pensando en eso: sabemos que para aumentar el número, tenemos que aumentar el número de uno de los números primos. Por ejemplo, una forma de aumentar es mul con 8 y dividir entre 5. Entonces obtenemos el conjunto de todas las formas de aumentar y disminuir el número. Esto solo contendrá formas básicas como mul 8 div 5 y no mul 16 div 5. También hay otro conjunto de formas básicas para disminuir. Ordene estos dos conjuntos por su factor de aumento o disminución. Dado un número, el siguiente se puede encontrar mediante la búsqueda de una forma de aumento aplicable con el factor más pequeño del incremento conjunto ..
InformedA
.. aplicable significa que hay suficientes números primos para realizar mul y div. Luego, encontramos una forma decreciente hacia el nuevo número, comenzando con el que disminuye más. Siga usando nuevas formas de disminuir y nos detendremos cuando el nuevo número sea más pequeño que el número original dado. Como el conjunto de primos es constante, esto significa un tamaño constante para dos conjuntos. Esto también necesita un poco de prueba, pero me parece un tiempo constante, memoria constante en cada número. Memoria constante y tiempo lineal para imprimir n números.
InformadoA
@randomA ¿de dónde sacaste la división? ¿Te importa hacer una respuesta completa? No entiendo bien tus comentarios.
VF1