O décimo primeiro dia do Advent of Code foi ontem 11/12/2024! Se você caiu aqui hoje pela primeira vez, não deixe de ler os posts sobre o Dia 1, Dia 2, Dia 3, Dia 4, Dia 5, Dia 6, Dia 7, Dia 8, Dia 9 e Dia 10.

Classifiquei o décimo primeiro problema como fácil (parte 1) e médio (parte 2).

Dicas#

  • Tanto a parte 1 quanto a parte 2 usam as mesmas regras para gerar as pedras.
  • Uma pedra 0 vira uma pedra 1
  • Uma pedra com número par de dígitos vira duas pedras
  • Se não entrar em nenhuma dessas condições, o valor da pedra é multiplicado por 2024.
  • A geração das pedras na parte 1 é bem interessante e fácil de implementar.
  • O problema traz vários valores de testes que você deve utilizar para validar suas funções
  • Você pode usar qualquer uma das respostas para a parte 1.
  • A parte 2 é complexa porque não há espaço suficiente para guardar tantas pedras. São centenas de trilhões de pedras, guardar cada uma em um elemento da lista não é interessante.
  • Na parte 2, lembre que o objetivo é calcular o número de pedras, não o valor das pedras em si.
  • Quando a pedra tem um número par de dígitos, ela gera duas pedras. Todos os outros valores geram apenas uma pedra. Se você considerar esta propriedade, você pode incrementar o contador de pedras sem guardar todas as pedras.

Quanto de Python você precisa saber?#

Os capítulos se referem ao meu livro Introdução à Programação com Python.

  • Listas, dicionários - Capítulo 6
  • Strings - Capítulo 7
  • Funções recursivas - Capítulo 8
  • A parte 2 é mais complexa, exige uma noção custo computacional para otimizar a solução.

Resposta#

O problema de hoje tem várias soluções possíveis, veja a diferença entre elas, principalmente o tempo de execução nos comentários abaixo. Elas também possuem requisitos de memória bem diferentes.

  • A solução com dicionário é a mais eficiente (92ms).
  • A solução recursiva com cache é a segunda mais rápida (270ms).
  • A função some_pedras consegue calcular o resultado final, mas demora 27 minutos.
  • A solução ingênua é a menos eficiente e não roda na parte 2.

A solução com dicionários só é possível porque o padrão de geração das pedras é repetitivo, gerando um máximo de 3881 chaves após 81 ciclos. Desta forma, o dicionário não cresce indefinidamente e pode ser utilizado para armazenar o número de pedras.

from functools import cache
from collections import defaultdict


ENTRADA_TESTE = "125 17"
ENTRADA_1 = "773 79858 0 71 213357 2937 1 3998391"

# Valores para testes de geração das pedras
TESTES_ENTRADA = [
    [253000, 1, 7],
    [253, 0, 2024, 14168],
    [512072, 1, 20, 24, 28676032],
    [512, 72, 2024, 2, 0, 2, 4, 2867, 6032],
    [1036288, 7, 2, 20, 24, 4048, 1, 4048, 8096, 28, 67, 60, 32],
    [2097446912, 14168, 4048, 2, 0, 2, 4, 40, 48, 2024, 40, 48, 80, 96, 2, 8, 6, 7, 6, 0, 3, 2],
]


def string_para_lista_de_int(entrada):
    return [int(x) for x in entrada.split()]


# Versão simples ou ingênua
# Usa listas para simular o processo de geração de pedras
# Mais fica muito lenta depois de 30 ou 40 lances (não pode ser usada na parte 2)
def simule(entrada: list[int]) -> list[int]:
    saída = []
    for i in entrada:
        if i == 0:
            i = 1
        elif (ls := len(s := str(i))) % 2 == 0:
            ls //= 2
            saída += [int(s[0:ls]), int(s[ls:])]
            continue
        else:
            i *= 2024
        saída.append(i)

    return saída


# Versão ingênua, mas sem usar append.
# Usa uma lista fixa com o dobro do tamanho da entrada
# Mais rápida que a versão ingênua, mas ainda muito lenta para 75 lances
def simule_sem_append(entrada: list[int]) -> list[int]:
    saída = [0] * len(entrada) * 2
    posição = 0
    for i in entrada:
        if i == 0:
            i = 1
        elif (ls := len(s := str(i))) % 2 == 0:
            ls //= 2
            saída[posição] = int(s[0:ls])
            saída[posição + 1] = int(s[ls:])
            posição += 2
            continue
        else:
            i *= 2024
        saída[posição] = i
        posição += 1
    return saída[:posição]


# Soluções para parte 2


# Verifica a pedra e calcula as pedras resultantes
@cache
def multiplique_pedra(pedra: int) -> list[int]:
    if pedra == 0:
        return [1]
    elif (ls := len(s := str(pedra))) % 2 == 0:
        ls //= 2
        return [int(s[0:ls]), int(s[ls:])]
    else:
        return [pedra * 2024]


# Simula o processo de geração de pedras, usando o cache para gerar as pedras
# Não resolve o problema, pois ainda usa listas para simular o processo
def simule_com_cache(entrada: list[int]) -> list[int]:
    saída = [0] * len(entrada) * 2
    posição = 0
    for i in entrada:
        for pedra in multiplique_pedra(i):
            saída[posição] = pedra
            posição += 1
    return saída[:posição]


# Cria um cache com as pedras.
@cache
def multiplique_pedra_lances(pedra: int, lances: int) -> list[int]:
    pedras = [pedra]
    for _ in range(lances):
        pedras = simule_com_cache(pedras)
    return pedras


# Soma as pedras, usando o cache. Faz uma pesquisa em profundidade
# Roda em aproximadamente 27 minutos com 75 lances!
# Funciona, mas longe de ser eficiente
def some_pedras(pedras: list[int], lances: int) -> int:
    PASSO = 25  # Quantidade de lances a simular por chamada
    if lances == 0:
        return len(pedras)
    soma = 0
    for pedra in pedras:
        novas_pedras = multiplique_pedra_lances(pedra, PASSO)
        if lances - PASSO > 0:
            soma += some_pedras(novas_pedras, lances - PASSO)
        else:
            soma += len(novas_pedras)
    return soma


# Melhor solução para parte 2
# Usa um dicionário para armazenar as pedras e suas quantidades
# Roda em aproximadamente 92ms com 75 lances
def soma_pedras_com_dicionario(entrada: list[int], lances: int) -> int:
    d = defaultdict(int)
    for v in entrada:
        d[v] += 1
    for _ in range(lances):
        novo_d = defaultdict(int)
        for pedra in d.keys():
            if pedra == 0:
                novo_d[1] += d[0]
            elif (ls := len(s := str(pedra))) % 2 == 0:
                ls //= 2
                novo_d[int(s[0:ls])] += d[pedra]
                novo_d[int(s[ls:])] += d[pedra]
            else:
                novo_d[pedra * 2024] += d[pedra]
        d = novo_d
    return sum(d.values())


# Segunda melhor solução para parte 2
# Usa uma solução recursiva com cache para calcular as pedras
# Roda em aproximadamente 270ms com 75 lances
@cache
def soluciona_recursivo(pedra, lances) -> int:
    if lances == 0:
        return 1

    if pedra == 0:
        return soluciona_recursivo(1, lances - 1)

    if len(str(pedra)) % 2 == 0:
        sst = str(pedra)
        a, b = sst[: len(sst) // 2], sst[len(sst) // 2 :]
        return soluciona_recursivo(int(a), lances - 1) + soluciona_recursivo(
            int(b), lances - 1
        )

    return soluciona_recursivo(pedra * 2024, lances - 1)


def simule_piscadas(entrada, piscadas) -> int:
    for x in range(piscadas):
        entrada = simule(entrada)
    return len(entrada)


# Parte 1 - Teste
entrada = string_para_lista_de_int(ENTRADA_TESTE)
print("Testes [pedras] número de pedras")
for blink in range(6):
    print(blink + 1, entrada := simule(entrada), len(entrada))
    assert TESTES_ENTRADA[blink] == entrada

entrada = string_para_lista_de_int(ENTRADA_TESTE)
assert simule_piscadas(entrada, 6) == 22

entrada = string_para_lista_de_int(ENTRADA_TESTE)
assert simule_piscadas(entrada, 25) == 55312
print()

# Parte 1 - Entrada 1
entrada_parte_1 = string_para_lista_de_int(ENTRADA_1)
print("Parte 1: ", soma := simule_piscadas(entrada_parte_1, 25))
assert soma == 199982

# Teste da solução recursiva com some_pedras
assert some_pedras(string_para_lista_de_int(ENTRADA_1), 25) == 199982

# A solução com some_pedras funciona para 75 lances, mas demora mais de 27 minutos para rodar!

# Parte 2 - Solução com dicionário +/-92ms
print(
    "Parte 2 com dicionário:",
    soma := soma_pedras_com_dicionario(string_para_lista_de_int(ENTRADA_1), 75),
)
assert soma == 237149922829154
# Parte 2 - Solução recursiva +/- 270ms
print(
    "Parte 2 - recursiva:",
    soma := sum(
        [soluciona_recursivo(s, 75) for s in string_para_lista_de_int(ENTRADA_1)]
    ),
)
assert soma == 237149922829154

# Saída
# Testes [pedras] número de pedras
# 1 [253000, 1, 7] 3
# 2 [253, 0, 2024, 14168] 4
# 3 [512072, 1, 20, 24, 28676032] 5
# 4 [512, 72, 2024, 2, 0, 2, 4, 2867, 6032] 9
# 5 [1036288, 7, 2, 20, 24, 4048, 1, 4048, 8096, 28, 67, 60, 32] 13
# 6 [2097446912, 14168, 4048, 2, 0, 2, 4, 40, 48, 2024, 40, 48, 80, 96, 2, 8, 6, 7, 6, 0, 3, 2] 22

# Parte 1:  199982
# Parte 2 com dicionário: 237149922829154
# Parte 2 - recursiva: 237149922829154

Agradecimentos#

Obrigado ao Tyrone Damasceno que compartilhou a solução com dicionários e a função soluciona_recursivo para a parte 2. O github dele pode ser acessado aqui.