José Hisse

Sequência de Collatz

Problema

A seguinte questão foi proposta no problema 14 do Project Euler.

A sequência de Collatz é gerada pela seguinte regra:

\[ n = \begin{cases} & n/2 \text{, se } n \text{ é par } \\ & 3n+1 \text{, se } n \text{ é ímpar } \end{cases} \]

Conforme a regra acima e começando pelo número 13, temos a seguinte sequência:

\[ 13 \rightarrow 40 \rightarrow 20 \rightarrow 10 \rightarrow 5 \rightarrow 16 \rightarrow 8 \rightarrow 4 \rightarrow 2 \rightarrow 1 \]

A sequência acima possui 10 termos até que se chegue ao número 1.

Não foi provado que começando de qualquer número a sequência acabará em 1.

Qual número abaixo de 1 milhão gera a maior sequência?

Solução Naïve

Primeiro devemos ter uma função que represente o cálculo da sequência de Collatz, depois precisamos calcular a quantidade total de termos que partindo de um determinado número chegaremos ao 1.

Como o problema propõe acharmos a maior sequência partindo de um número abaixo de n, criaremos a função calcular_maior_sequencia que receberá um número máximo onde iremos iterar de 1 até o limite máximo para acharmos o número que tem a maior quantidade de termos.

import timeit

def collatz(x):
    if x % 2:   # Se ímpar
        return 3*x+1
    return x/2  # Se par


def calcular_qtd_em_sequencia(n):
    qtd_termos = 1  # Incluindo já o primeiro termo
    while n != 1:
        n = collatz(n)
        qtd_termos += 1
    return qtd_termos


def calcular_maior_sequencia(n_max):
    n_com_maior_sequencia = 0
    qtd_maior_sequencia = 0
    for termo_inicial in range(1, n_max+1):
        qtd_candidata = calcular_qtd_em_sequencia(termo_inicial)
        if qtd_candidata >= qtd_maior_sequencia:
            n_com_maior_sequencia = termo_inicial
            qtd_maior_sequencia = qtd_candidata
    return n_com_maior_sequencia, qtd_maior_sequencia


start = timeit.default_timer()

print("Número {} com {} termos".format(*calcular_maior_sequencia(1000000)))

stop = timeit.default_timer()
print('Tempo decorrido: ', stop - start)

Aqui podemos perceber que a saída para n_max = 1000000 foi:

Número 837799 com 525 termos
Tempo decorrido:  39.81831162200251

Otimizando com cache

Vamos observar a seguinte situação.

Calculando a sequência do número 5:

\[ 5 \rightarrow 16 \rightarrow 8 \rightarrow 4 \rightarrow 2 \rightarrow 1 \]

Depois calculando a sequência do número 6:

\[ 6 \rightarrow 3 \rightarrow 10 \rightarrow 5 \rightarrow 16 \rightarrow 8 \rightarrow 4 \rightarrow 2 \rightarrow 1 \]

Como podemos observar a sequência do número 5 acaba sendo um subconjunto da sequência do número 6, ou seja, gastamos recursos computacionais com cálculos redundantes.

Podemos reescrever a função calcular_qtd_em_sequencia para utilizarmos a técnica de memoização. Como isso evitamos recálculos.

memo = {}
def calcular_qtd_em_sequencia(n):
    qtd_termos = 1  # Incluindo já o primeiro termo
    n_original = n
    while n != 1:
        n = collatz(n)
        qtd_memo = memo.get(n)
        if qtd_memo:
            qtd_termos += qtd_memo
            break
        qtd_termos += 1
    memo[n_original] = qtd_termos
    return qtd_termos

Ganhamos muito em desempenho, como podemos oberservar abaixo.

Número 837799 com 525 termos
Tempo decorrido:  3.9822970859968336

O cache poderia ser otimizado de algumas outras formas, como, por exemplo, a utilização do cache LRU.

Escovando bit

Podemos otimizar a função collatz realizando operações de bits.

Por exemplo, sabemos que o número 5 em binário é representado por 101 e o número 6, 110, ou seja, o último bit nos indica a paridade. Então, para não termos que calcular o resto de uma divisão basta compararmos o último bit do número.

Outra melhoria é na operação de divisão por 2. Como a operação de divisão neste caso só é realizada sobre números pares, então podemos fazer uma operação de shift a direita.

Então a função collatz poderia ser reescrita da seguinte forma:

def collatz(x):
    if x & 1:   # Se ímpar
        return 3*x+1
    return x >> 1  # Se par
Número 837799 com 525 termos
Tempo decorrido:  2.9432366770051885

Código final

import timeit


def collatz(x):
    if x & 1:  # Se ímpar
        return 3 * x + 1
    return x >> 1  # Se par


memo = {}
def calcular_qtd_em_sequencia(n):
    qtd_termos = 1  # Incluindo já o primeiro termo
    n_original = n
    while n != 1:
        n = collatz(n)
        qtd_memo = memo.get(n)
        if qtd_memo:
            qtd_termos += qtd_memo
            break
        qtd_termos += 1
    memo[n_original] = qtd_termos
    return qtd_termos


def calcular_maior_sequencia(n_max):
    n_com_maior_sequencia = 0
    qtd_maior_sequencia = 0
    for termo_inicial in range(1, n_max+1):
        qtd_candidata = calcular_qtd_em_sequencia(termo_inicial)
        if qtd_candidata >= qtd_maior_sequencia:
            n_com_maior_sequencia = termo_inicial
            qtd_maior_sequencia = qtd_candidata
    return n_com_maior_sequencia, qtd_maior_sequencia


start = timeit.default_timer()

print("Número {} com {} termos".format(*calcular_maior_sequencia(1000000)))

stop = timeit.default_timer()
print('Tempo decorrido: ', stop - start)