Qual é o tempo de execução desse algoritmo recursivo?

8

Fiz o seguinte programa Haskell (não-destruído) para o desafio do código-golfe de calcular o primeironvalores de A229037 .

Esta é a minha solução proposta para calcular o º valor:n

a n | n<1        = 0 
    | n<3        = 1
    | otherwise  = head (goods n)

goods n = [x | x <- [1..], isGood x n]

isGood x n = and [ x - a(n-k) /= a(n-k) - a(n-k-k) || a(n-k-k) == 0 | k <- [1..n] ]

Observe que o Haskell não armazena em cache ou memoriza esses valores automaticamente.

A página OEIS para a sequência fornece o fato de que , portanto, pode ser substituído por , pois o algoritmo nunca alcançará um maior que .uma(n)(n+1)/2[1..][1..(n+1)/2]xn+12

Tentando contar chamadas de função, deduzi o seguinte limite superior , o número de chamadas de função que o algoritmo leva para uma entrada :T(n)n

T(n)=x=1(n+1)/2k=1n2 T(nk)+2 T(n2k)x=1(n+1)/2k=1n T(n-k)x=1(n+1)/2k=1n4 T(n-1)x=1(n+1)/24 n T(n-1)4 n T(n-1) n+122 n (n+1) T(n-1))

Liguei a fórmula final no Mathematica:

RSolve[{T[n] == 2*T[n - 1]*n*(n + 1), T[1] == 1}, T[n], n]

E consegui, depois de um pouco de simplificação:T(n) 2n n! (n+1)!

A proporção média entre este e o tempo de execução do programa Haskell, para é de e o desvio padrão das proporções é de cerca de . (Curiosamente, o gráfico de log dos índices parece ser uma linha reta).n[12,20]2.01039.6.01039.

As relações com a primeira linha, definindo , têm uma média e desvio padrão de e , respectivamente, mas seu gráfico salta bastante.T(n)4.81061.8106

Como posso obter uma ligação melhor à complexidade do tempo desse algoritmo?

Aqui está o algoritmo em C válido (menos as declarações avançadas), que eu acredito que é aproximadamente equivalente ao código Haskell:

int a(int n){
    if (n < 1) {
        return 0;
    } else if (n < 3) {
        return 1;
    } else {
        return lowestValid(n);
    }
}

int lowestValid(int n){
    int possible = 1; // Without checking, we know that this will not exceed (n+1)/2

    while (notGood(possible, n)) {
        possible++;
    }
    return possible;
}

int notGood(int possible, int n){
    int k = 1;

    while (k <= n) {
        if ( ((possible - a(n-k)) == (a(n-k) - a(n-2*k))) && (a(n-2*k) != 0) ) {
            return 1;
        } else {
            k++;
        }
    }
    return 0;
}

A versão C leva cerca de 5 minutos para calcular e a versão Haskell leva aproximadamente o mesmo para .uma(17)uma(19)

As primeiras vezes das versões:

Haskell: [0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1.0e-2,3.0e-2,9.0e-2,0.34,1.42,11.77,71.68,184.37,1815.91]
C:       [2.0e-6, 1.0e-6, 1.0e-6, 2.0e-6, 1.0e-6, 6.0e-6, 0.00003,0.00027, 0.002209, 0.005127, 0.016665, 0.080549, 0.243611, 0.821537, 4.56265, 24.2044, 272.212]
Michael Klein
fonte
Alterei tags e título para deixar claro que se trata de uma análise de algoritmo, não de uma questão de teoria da complexidade. "Supondo que multiplicação e adição são insignificantes" - você pode ? Sério ? Ainda é melhor dizer o que você está contando, porque é provável que você não esteja contando a maioria das coisas. Veja também nossa pergunta de referência .
Raphael
Você já tentou plotar seu resultado (com algum fator constante) em relação aos tempos de execução das medidas reais? (Geralmente, é mais informativo plotar a razão e adivinhar se ela converge para algo em .) Dito isso, acho difícil ajudar aqui, pois a ansatz para depende dos detalhes de Haskell, que nem todos aqui falam. . Especificamente, como é avaliada essa compreensão do conjunto? Está sendo memorizado? Você pode obter respostas melhores (ou qualquer, realmente!), Se você incluiu uma versão em pseudo-código que expõe tanto do que realmente acontece quanto necessário para uma análise rigorosa. O(1)Ta
Raphael
Finalmente, o uso de métodos adequados para derivar limites do Landau provavelmente é inútil. Qualquer função desse tipo pode caber apenas em um conjunto fixo de funções; Eu acho que o Mathematica usou os piores modelos exponenciais lá, e assim fez um mau trabalho ao capturar um crescimento superexponencial.
Raphael
@Raphael Seus comentários foram muito úteis. Vou investigar mais quando tiver algum tempo. Além disso, o veio do ajuste dos logaritmos dos valores a uma linha, que era mais um tiro no escuro do que qualquer outra coisa. O(n22)
Michael Klein

Respostas:

2

Você pode escrever sua recorrência como

T(n)=(n+1)(T(n-1)+2T(n-2)+T(n-3)+2T(n-4)+).
Em particular, T(n)(n+1)T(n-1). Isso significa que a sequênciaT(n) cresce muito rapidamente, e em particular
T(n-1)+2T(n-2)+T(n-1)[1+2n+1n(n-1)+2n(n-1)(n-2)+]=(1+O(1/n))T(n-1).
Portanto
T(n)(n+O(1))T(n-1).
Isso significa que
T(n)=O((n+O(1))!),
e entao
T(n)=O(nO(1)(n/e)n).
Isso melhora o seu limite por uma raiz quadrada.
Yuval Filmus
fonte