Imprima o menor menor de 2 ^ i * 5 ^ j onde i, j> = 0

10

Recentemente, foi-me feita essa pergunta durante uma triagem técnica por telefone e não me saí bem. A questão está incluída literalmente abaixo.

Gere {2^i * 5^j | i,j >= 0}coleção classificada. Imprima continuamente o próximo valor menor.

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

"Próximo menor" me faz pensar que está envolvido um min-heap, mas eu realmente não sabia para onde ir a partir daí e nenhuma assistência foi fornecida pelo entrevistador.

Alguém tem conselhos sobre como resolver esse problema?

Justin Skiles
fonte
Eu acho que a entrevista quer pedir que você faça isso em memória constante. Usar memória O (n) torna isso bastante trivial. Ou pelo menos usando a memória O (logn) porque o tamanho da codificação para a entrada n seria logn. Um O (n) para solução de memória é uma solução de memória exponencial.
InformedA

Respostas:

14

Vamos reformular o problema: imprima todos os números de 1 ao infinito, de modo que o número não tenha fatores, exceto 2 e 5.

Abaixo está um trecho simples de C #:

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);
}

A abordagem de Kilian / QuestionC tem muito mais desempenho. Fragmento de C # com esta abordagem:

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 impede inserções duplicadas.

Basicamente, ele funciona garantindo que o próximo número na sequência esteja itms.

Prova de que essa abordagem é válida:
O algoritmo descrito garante que, após qualquer número de saída no formulário 2^i*5^j, o conjunto agora contenha 2^(i+1)*5^je 2^i*5^(j+1). Suponha que o próximo número na sequência seja 2^p*5^q. Deve existir um número de saída anterior do formulário 2^(p-1)*5^(q)ou 2^p*5^(q-1)(ou ambos, se nem p nem q forem iguais a 0). Se não, então 2^p*5^qnão é o próximo número, uma vez 2^(p-1)*5^(q)e 2^p*5^(q-1)são ambos menores.

O segundo trecho usa O(n)memória (onde n é o número de números que foram produzidos), pois O(i+j) = O(n)(porque iej são ambos menores que n) e encontrará n números com o O(n log n)tempo. O primeiro trecho encontra números em tempo exponencial.

Brian
fonte
1
Oi, você pode ver por que fiquei confuso durante a entrevista, espero. De fato, o exemplo fornecido são resultados do conjunto descrito na pergunta. 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.
23613 Justin Skiles
Eles são repetidos .Remove()e .Add()vão desencadear um mau comportamento do coletor de lixo, ou isso vai resolver as coisas?
22614 Snowbody
1
@ Snowbody: A pergunta do op é uma questão de algoritmos, por isso é um tanto irrelevante. Ignorando isso, sua primeira preocupação deve ser lidar com números inteiros muito grandes, pois isso se torna um problema muito mais cedo do que a sobrecarga do coletor de lixo.
Brian
8

Essa é uma pergunta de entrevista bastante comum e útil para saber a resposta. Aqui está a entrada relevante na minha ficha pessoal de berço:

  • para gerar os números do formulário 3 a 5 b 7 c em ordem , comece com 1, encha todos os três sucessores possíveis (3, 5, 7) em uma estrutura auxiliar e adicione o menor número dele à sua lista.

Em outras palavras, você precisa de uma abordagem em duas etapas com um buffer classificado adicional para resolver isso com eficiência. (Uma boa descrição mais longa está em Cracking the Coding Interview, de Gayle McDowell.

Kilian Foth
fonte
3

Aqui está uma resposta que roda com memória constante, às custas da CPU. Esta não é uma boa resposta no contexto da pergunta original (ou seja, resposta durante uma entrevista). Mas se a entrevista durar 24 horas, não será tão ruim. ;)

A idéia é que, se eu tiver n, que é uma resposta válida, o próximo na sequência será n vezes alguma potência de dois, dividido por alguma potência de 5. Ou então n vezes uma potência de 5, dividido por um poder de dois. Desde que divida uniformemente. (... ou o divisor pode ser 1;) nesse caso, você está apenas multiplicando por 2 ou 5)

Por exemplo, para ir de 625 a 640, multiplique por 5 ** 4/2 ** 7. Ou, geralmente, multiplique por algum valor de 2 ** m * 5 ** npara alguns m, n onde um é positivo e outro é negativo ou zero, e o valor multiplicador divide o número uniformemente.

Agora, a parte complicada é encontrar o multiplicador. Mas sabemos que a) o divisor deve dividir o número uniformemente, b) o multiplicador deve ser maior que um (os números continuam aumentando) ec) se escolhermos o multiplicador mais baixo maior que 1 (ie 1 <f <todos os outros f's ), esse é certamente o próximo passo. O passo seguinte será o passo mais baixo.

A parte desagradável é encontrar o valor de m, n. Existem apenas possibilidades de log (n), porque existem apenas 2 ou 5 para desistir, mas eu tive que adicionar um fator de -1 a +1 como uma maneira desleixada de lidar com o arredondamento. Portanto, precisamos iterar através de O (log (n)) a cada passo. Portanto, é O (n log (n)) em geral.

A boa notícia é que, como leva um valor e encontra o próximo valor, você pode começar em qualquer lugar da sequência. Portanto, se você deseja o próximo após 1 bilhão, ele pode ser encontrado através da iteração entre 2/5 ou 5/2 e escolhendo o menor multiplicador maior que 1.

(Pitão)

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

Eu validei os primeiros 10.000 números que isso gera com relação aos primeiros 10.000 gerados pela solução da lista classificada e funciona pelo menos até agora.

Aliás, o próximo, depois de um trilhão, parece ser 1.024.000.000.000.

...

Hum. Posso obter O (n) desempenho - O (1) por valor (!) - e O (log n) uso de memória tratando best()como uma tabela de pesquisa que eu estendo gradualmente. No momento, ele economiza memória repetindo cada vez, mas está fazendo muitos cálculos redundantes. Mantendo esses valores intermediários - e uma lista de valores mínimos - eu posso evitar o trabalho duplicado e acelerar muito. No entanto, a lista de valores intermediários aumentará com n, daí a memória O (log n).

Roubar
fonte
Ótima resposta. Tenho uma ideia semelhante que não codifiquei. Nesta idéia, eu mantenho um rastreador para 2 e 5. Isso rastreará o máximo ne mque foram usados ​​nos números da sequência até agora. A cada iteração, nou mpode ou não subir. Criamos um novo número e, em 2^(max_n+1)*5^(max_m+1)seguida, reduzimos esse número de maneira recursiva exaustiva a cada chamada, reduzindo o expoente em 1 até obtermos o mínimo que é maior que o número atual. Nós atualizamos max_n, max_mconforme necessário. Isso é mem constante. Pode ser O(log^2(n))mem se o cache do DP é usado na chamada redução
InformedA
Interessante. A otimização aqui é que ele não precisa considerar todos os pares de m & n, porque sabemos que m, n correto produzirá o multiplicador mais próximo de 1. Portanto, eu só preciso avaliar m = -i para max_i e I pode apenas calcular n, jogando algum lixo para arredondamento (eu era desleixado e iterava de -1 a 1, mas é preciso pensar mais;)).
19414 Rob
No entanto, estou pensando como você ... a sequência será determinística ... é realmente como um grande triângulo de Pascal i + 1 em uma direção e j + 1 na outra. Portanto, a sequência deve ser matematicamente determinística. Para qualquer nó no triângulo, sempre haverá um próximo nó matematicamente determinado.
19414 Rob
1
Pode haver uma fórmula para a próxima, talvez não precisemos fazer pesquisa. Eu não tenho certeza.
InformedA
Quando penso nisso, a forma algébrica do próximo pode não existir (nem todos os problemas determinísticos têm forma algébrica para soluções), além disso, quando há mais números primos do que apenas 2 e 5, a fórmula pode ser bem difícil de encontrar. realmente quer elaborar essa fórmula. Se alguém conhece a fórmula, eu provavelmente leria um pouco sobre isso, isso parece interessante.
InformedA
2

Brian estava absolutamente certo - minha outra resposta foi muito complicada. Aqui está uma maneira mais simples e rápida de fazer isso.

Imagine o quadrante I do plano euclidiano, restrito aos números inteiros. Chame um eixo como eixo i e o outro eixo como eixo j.

Obviamente, os pontos próximos à origem serão escolhidos antes dos pontos distantes da origem. Observe também que a área ativa se afastará do eixo i antes de se afastar do eixo j.

Depois que um ponto é usado, ele nunca mais será usado. E um ponto só pode ser usado se o ponto diretamente abaixo ou à esquerda dele já tiver sido usado.

Juntando tudo isso, você pode imaginar uma "fronteira" ou "borda de ataque" que começa em torno da origem e se espalha para cima e para a direita, espalhando-se ao longo do eixo i mais do que no eixo j.

De fato, podemos descobrir algo mais: haverá no máximo um ponto na fronteira / borda para qualquer valor i. (Você deve incrementar i mais de 2 vezes para igualar um incremento de j.) Portanto, podemos representar a fronteira como uma lista contendo um elemento para cada coordenada i, variando apenas com a coordenada j e o valor da função.

A cada passagem, escolhemos o elemento mínimo na borda principal e depois o movemos na direção j uma vez. Se por acaso aumentarmos o último elemento, adicionamos um novo último elemento a mais com um valor i incrementado e um 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;
            }
        }
    }
}

Espaço: O (n) em número de elementos impressos até o momento.

Velocidade: O (1) é inserido, mas isso não é feito sempre. (Ocasionalmente mais tempo quando o valor List<>precisa crescer, mas ainda assim O (1) é amortizado). O grande momento gasto é a busca pelo mínimo, O (n) no número de elementos impressos até o momento.

Corpo de neve
fonte
1
Que algoritmo isso usa? Por que isso funciona? A parte principal da pergunta que está sendo feita é Does anyone have advice on how to solve such a problem?uma tentativa de entender o problema subjacente. Um despejo de código não responde bem a essa pergunta.
Bom ponto, expliquei meu pensamento.
Snowbody
+1 Embora isso seja aproximadamente equivalente ao meu segundo trecho, o uso de arestas imutáveis ​​deixa mais claro como a contagem de arestas aumenta.
Brian
É definitivamente mais lento que o trecho revisado de Brian, mas seu comportamento de uso de memória deve ser muito melhor, pois não está constantemente excluindo e adicionando elementos. (A menos que o CLR ou SortedSet <> tem algum método de reutilização de elementos que eu não sei sobre)
Snowbody
1

A solução baseada em conjuntos foi provavelmente o que o entrevistador estava procurando, no entanto, tem a conseqüência infeliz de ter O(n)memória e O(n lg n)tempo total para nelementos de seqüenciamento .

Um pouco de matemática nos ajuda a encontrar uma solução de O(1)espaço e O(n sqrt(n))tempo. Observe isso 2^i * 5^j = 2^(i + j lg 5). Encontrar os primeiros nelementos de {i,j > 0 | 2^(i + j lg 5)}reduz a encontrar os primeiros nelementos de {i,j > 0 | i + j lg 5}porque a função (x -> 2^x)está aumentando estritamente monotonicamente, portanto, a única maneira de alguns a,bdeles 2^a < 2^bé se a < b.

Agora, precisamos apenas de um algoritmo para encontrar a sequência de i + j lg 5, onde i,jestão os números naturais. Em outras palavras, dado o nosso valor atual de i, j, o que minimiza o próximo movimento (ou seja, nos dá o próximo número na sequência), é um aumento em um dos valores (digamos j += 1), juntamente com uma diminuição no outro ( i -= 2). A única coisa que está nos limitando é isso i,j > 0.

Existem apenas dois casos a considerar - iaumentos ou jaumentos. Um deles deve aumentar, pois nossa sequência está aumentando e os dois não aumentam, porque, caso contrário, estamos pulando o termo em que temos apenas um i,jaumento. Assim, um aumenta e o outro permanece o mesmo ou diminui. Expressado em C ++ 11, todo o algoritmo e sua comparação com a solução definida estão disponíveis aqui .

Isso obtém memória constante, pois há apenas uma quantidade constante de objetos alocados no método, além da matriz de saída (consulte o link). O método atinge o tempo logarítmico a cada iteração, pois, para qualquer dado (i,j), ele percorre o melhor par, de (a, b)modo que (i + a, j + b)seja o menor aumento no valor de i + j lg 5. Este percurso é 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 iteração tenta atualizar i, então j, e acompanha a atualização menor dos dois.

Desde ie jno máximo O(sqrt(n)), temos O(n sqrt(n))tempo total . ie jcrescem na taxa do quadrado de ndesde para quaisquer valores máximos imaxe jmaxexistem O(i j)pares únicos a partir dos quais fazer nossa sequência se nossa sequência for ntermos, ie jcrescer dentro de algum fator constante um do outro (porque o expoente é composto de um linear combinação dos dois), sabemos disso ie jsomos O(sqrt(n)).

Não há muito com que se preocupar com o erro de ponto flutuante - como os termos crescem exponencialmente, teríamos que lidar com o estouro antes que o erro do flop nos alcance, por várias magnitudes. Vou acrescentar mais discussão a isso, se tiver tempo.

VF1
fonte
ótima resposta, acho que também existe um padrão para aumentar a sequência de números primos #
InformedA:
@randomA Thanks. Após algumas reflexões, cheguei à conclusão de que, atualmente, meu algoritmo não é tão rápido quanto eu pensava. Se houver uma maneira mais rápida de avaliar "Tentativa de aumentar i / j", acho que essa é a chave para obter tempo logarítmico.
VF1 21/07
Eu estava pensando nisso: sabemos que para aumentar o número, temos que aumentar o número de um dos primos. Por exemplo, uma maneira de aumentar é multiplicar por 8 e dividir por 5. Portanto, temos o conjunto de todas as maneiras para aumentar e diminuir o número. Isso conterá apenas maneiras básicas, como mul 8 div 5 e não mul 16 div 5. Também há outro conjunto de maneiras básicas para diminuir. Classifique esses dois conjuntos pelo fator de aumento ou diminuição. Dado um número, o próximo pode ser encontrado por encontrar um aumento maneira aplicável com menor fator do aumento set ..
InformedA
.. aplicável significa que existem números primos suficientes para executar mul e div. Então, encontramos uma maneira decrescente para o novo número, começando pelo que diminui mais. Continue usando novas formas de diminuir e paramos quando o novo número for menor que o número original fornecido. Como o conjunto de primos é constante, isso significa tamanho constante para dois conjuntos. Isso precisa de um pouco de prova também, mas parece tempo constante, memória constante em cada número para mim. Memória constante e tempo linear para imprimir n números.
InformedA
@randomA de onde você conseguiu a divisão? Você se importa em dar uma resposta completa - não entendo bem seus comentários.
VF1 25/07