Recursão

Casos base, redução do problema, pilha de chamadas e estruturas aninhadas.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

Uma função pode resolver um problema usando a solução de uma versão mais pequena do mesmo problema: reduz a entrada até um caso que resolves diretamente e depois combina os resultados no regresso.

Fatorial e pilha de chamadas

O fatorial de n é o produto dos inteiros de 1 até n. Por exemplo, 4!=4×3×2×1=244!=4\times3\times2\times1=24. Para um inteiro não negativo, 0!=10!=1 e n!=n×(n−1)!n!=n\times(n-1)! quando n>0n>0. Esta relação dá o caso base e o passo recursivo.

Calcular um fatorial
def fatorial(n):
    if n < 0:
        raise ValueError("n deve ser não negativo")
    if n == 0:
        return 1
    return n * fatorial(n - 1)

print(fatorial(0))
print(fatorial(4))
Resultado

Executa o código para ver a saída e os gráficos.

Para calcular fatorial(4), ficam pendentes as multiplicações por 4, 3, 2 e 1. A chamada exterior espera 4 * fatorial(3) e a seguinte espera 3 * fatorial(2), até chegarmos a 4 * (3 * (2 * (1 * fatorial(0)))).

Quando o caso base devolve 1, resolvemos as multiplicações de dentro para fora:

ChamadaResultado que esperaValor devolvido
fatorial(0)Nenhum, é o caso base1
fatorial(1)1 * fatorial(0)1
fatorial(2)2 * fatorial(1)2
fatorial(3)3 * fatorial(2)6
fatorial(4)4 * fatorial(3)24

Cada chamada tem o seu parâmetro n. A pilha de chamadas conserva essas chamadas e o trabalho pendente. return devolve o resultado de cada uma. Esquecer o return do passo recursivo pode deixar a função exterior devolver None.

Justificar que termina

Para n não negativo, cada chamada reduz n em um. Essa quantidade não pode diminuir para sempre sem chegar a zero. Um caso base por si só não chega: return n * fatorial(n) não se aproxima dele. A validação dos negativos evita afastar-se de zero com n - 1.

Python limita a profundidade de chamadas. Mesmo uma função matematicamente terminante pode lançar RecursionError para uma entrada profunda. Python não elimina chamadas em posição terminal. Para uma sequência longa, um ciclo pode evitar o custo da pilha.

Recursão sobre dígitos

Um inteiro positivo divide-se no último dígito n % 10 e no prefixo n // 10. Se o prefixo é zero, já não há mais dígitos a somar.

def soma_digitos(n):
    if n == 0:
        return 0
    return n % 10 + soma_digitos(n // 10)

print(soma_digitos(502))

O resultado é 7; o contrato exige um inteiro não negativo. Segue a substituição dos resultados:

soma_digitos(502) = 2 + soma_digitos(50)
                 = 2 + (0 + soma_digitos(5))
                 = 2 + (0 + (5 + soma_digitos(0)))
                 = 2 + (0 + (5 + 0)) = 7

O prefixo 50 não é o caso base, embora o seu último dígito seja zero. Esta decomposição calcula o mesmo que o ciclo de soma de dígitos, mas deixa a adição pendente nas chamadas.

Estruturas aninhadas

A recursão é útil quando os dados também são recursivos: uma lista pode conter números ou outras listas com a mesma regra, e queremos somar todos os números por mais níveis que existam.

Somar números em listas aninhadas
def soma_aninhada(elementos):
    soma = 0
    for elemento in elementos:
        if isinstance(elemento, list):
            soma += soma_aninhada(elemento)
        else:
            soma += elemento
    return soma

print(soma_aninhada([2, [3, [], [4]], 1]))
print(soma_aninhada([]))
Resultado

Executa o código para ver a saída e os gráficos.

A lista vazia tem soma zero. Um número soma-se diretamente; uma lista exige outra chamada. Para [2, [3, [], [4]], 1], a soma interior vale 3+0+4=73+0+4=7 e a exterior vale 2+7+1=102+7+1=10.

O contrato aceita apenas números e listas finitas sem referências cíclicas. Uma lista que contém a si própria não satisfaz a redução estrutural.

isinstance(elemento, list) devolve True quando o elemento é uma lista. Assim o programa decide se deve tratar um número ou percorrer outra lista.

Combinar resultados recursivos

A chamada interior pode devolver uma coleção em vez de um número. Para contar strings numa lista aninhada, cada folha contribui com uma ocorrência; cada sublista devolve um dicionário de frequências. A chamada exterior soma as contagens das mesmas chaves, em vez de substituir o resultado anterior.

Com ['azul', ['verde', 'azul'], [], 'verde'], a sublista central devolve {'verde': 1, 'azul': 1}. Ao juntá-la à primeira ocorrência de azul, obtemos {'azul': 2, 'verde': 1}; a lista vazia não acrescenta chaves e a última folha leva verde a 2. O exercício juntar contagens de ramos diferentes desenvolve esta combinação. O domínio continua a exigir listas finitas sem referências cíclicas, agora com strings nas folhas.

Expressões como árvores

Uma expressão pode ser um inteiro ou um tuplo (esquerda, operador, direita), onde os dois operandos seguem a mesma regra. Em ((8, '-', 3), '*', (2, '+', 1)), o tuplo exterior representa uma multiplicação, não uma lista de instruções a executar da esquerda para a direita.

ParteValor
Operando esquerdo (8, '-', 3)5
Operando direito (2, '+', 1)3
Multiplicação exterior15

Para avaliar, um inteiro é o caso base. Num tuplo, avalia primeiro os dois operandos recursivamente e aplica o operador exterior aos valores devolvidos. A ordem dos operandos importa na subtração: trocar 8 e 3 produziria -5. A medida que diminui é o tamanho da subárvore tratada, pois cada chamada recebe um operando próprio. O contrato aceita árvores finitas e apenas os operadores +, - e *; não exige analisar uma string como "8 - 3".

Explorar escolhas com recursão

Uma redução pode abrir várias escolhas. Para decidir se alguns preços somam exatamente um alvo, o primeiro preço tem dois destinos: usamos essa posição, reduzindo o alvo, ou ignoramo-la, mantendo o alvo. Nos dois ramos, já não podemos voltar a escolher essa posição. Isto é uma procura por tentativas, ou backtracking.

Com preços [6, 4, 3] e alvo 7, escolher sempre o maior começa com 6 e deixa 1, que não conseguimos formar. Ignorar o 6 permite escolher 4 e 3. Falhar num ramo não demonstra que todos os restantes falhem.

Preços [6, 4, 3], alvo 7
Escolher 6: restam [4, 3], alvo 1. Falha.
Ignorar 6: restam [4, 3], alvo 7.
  Escolher 4: restam [3], alvo 3.
    Escolher 3: alvo 0. Sucesso.

Assumimos preços inteiros estritamente positivos, alvo inteiro não negativo e cada posição disponível uma vez. Alvo zero é sucesso com a escolha vazia; sem preços e com alvo positivo é falha. Um alvo negativo também é falha, porque preços positivos não o podem corrigir. A medida de terminação é o número de posições ainda disponíveis, menor em ambos os ramos.

Uma função combina os dois resultados com or e só precisa do segundo ramo se o primeiro falhar. No pior caso pode explorar muitas escolhas, até 2n2^n subconjuntos para n posições. O problema não passa a ser eficiente só por a medida diminuir; distinguir terminação de custo continua necessário.

Evitar trabalho repetido

Na sequência de Fibonacci, os primeiros valores são 0 e 1; cada valor seguinte é a soma dos dois anteriores. A definição recursiva direta faz duas chamadas por nível e repete muito trabalho.

Para fib(4), a árvore repete chamadas:

fib(4)
├── fib(3)
│   ├── fib(2)
│   │   ├── fib(1)
│   │   └── fib(0)
│   └── fib(1)
└── fib(2)
    ├── fib(1)
    └── fib(0)

fib(2) corre duas vezes e fib(1) corre três vezes, num total de 9 chamadas para devolver 3; um ciclo que conserva os dois valores anteriores evita essa repetição:

def fibonacci(n):
    a, b = 0, 1
    for i in range(n):
        a, b = b, a + b
    return a

Para um inteiro n não negativo, antes da iteração i, a e b são os valores de Fibonacci nas posições i e i + 1. O lado direito usa os dois valores antigos antes de os substituir. Para n = 4, os pares passam por (0, 1), (1, 1), (1, 2), (2, 3) e (3, 5); devolvemos 3. Para n = 0, devolvemos 0 sem entrar no ciclo.

A recursão não torna automaticamente um programa mais eficiente. Escolhe-a pela decomposição do problema, depois examina profundidade, trabalho repetido e criação de cópias. Por exemplo, usar xs[1:] em cada chamada cria sucessivas fatias; passar um índice pode evitar essas cópias.

Exercícios

Analisar a redução de um inteiro

Usa a função soma_digitos da lição para expandir soma_digitos(9008) até ao caso base e obter o resultado. Explica por que os zeros interiores não param a recursão. O que acontece com soma_digitos(-1) se ignorarmos a pré-condição de entrada não negativa?

Pista
Python arredonda // para baixo também com números negativos.
Solução

A expansão é 8 + (0 + (0 + (9 + soma_digitos(0)))). O caso base devolve 0 e o resultado é 17. Os prefixos são 9008, 900, 90, 9 e 0; só o último é zero.

Para -1, -1 // 10 vale -1. A chamada repete a mesma entrada sem chegar a zero e acaba em RecursionError. Para aceitar negativos, podemos converter a entrada com abs antes de aplicar esta redução; para os rejeitar, podemos lançar ValueError numa função de validação.

Confere a tua resposta:

  • Expandi as chamadas 9008, 900, 90, 9 e 0.
  • Obtive 17 e distingui dígito zero de caso base.
  • Expliquei por que -1 // 10 é -1.
Voltar à explicação
Somar por níveis

Define soma_aninhada(xs): devolve a soma de todos os números nas listas, mesmo dentro de outras listas. O domínio contém apenas números e listas finitas sem referências cíclicas. Conserva a entrada. Pratica a decomposição recursiva; os testes verificam o resultado e a preservação dos dados.

Python
def soma_aninhada(xs):
    return 0
Testes e resultado

Verifica o código para comparar o resultado com os testes.

Testes
  1. Vários níveis e lista vazia interior
    Código do teste
    print(soma_aninhada([1, [2, [3]], [], 4]))
    Esperado:
    10
  2. Lista vazia
    Código do teste
    print(soma_aninhada([]))
    Esperado:
    0
  3. Só listas vazias
    Código do teste
    print(soma_aninhada([[], [[], []]]))
    Esperado:
    0
  4. Negativos e decimais
    Código do teste
    print(soma_aninhada([-2, [1.5, [0.5]], 4]))
    Esperado:
    4.0
  5. Entrada preservada
    Código do teste
    xs = [1, [2, [3]], 4]
    print(soma_aninhada(xs))
    print(xs)
    Esperado:
    10
    [1, [2, [3]], 4]

Pista

Um número contribui diretamente. Uma lista coloca o mesmo problema num nível interior.

Mais uma pista

Percorre os elementos. Se isinstance(x, list), soma o resultado de soma_aninhada(x); caso contrário, soma x.

Solução

Cada lista vazia devolve zero. Na primeira entrada, a chamada exterior junta 1, a soma interior 5, a soma vazia 0 e 4, produzindo 10. Cada chamada tem o seu acumulador.

def soma_aninhada(xs):
    total = 0
    for x in xs:
        if isinstance(x, list):
            total += soma_aninhada(x)
        else:
            total += x
    return total

Erros frequentes

Tratar apenas um nível, devolver antes de acabar o percurso ou retirar elementos da entrada.

Voltar à explicação
Identificar base e medida

Para soma_digitos, com entrada inteira não negativa, indica o caso base e uma medida que diminui em cada chamada fora dele. Para a árvore de fib(4) da lição, conta as chamadas a fib(2) e explica que trabalho um ciclo evita.

Pista

Segue n // 10 até zero; conta os ramos repetidos da árvore.

Mais uma pista

Desenha a árvore de fib(4): fib(3) e fib(2) no primeiro nível; fib(2) aparece uma vez de cada lado. Conta quantas vezes cada chamada se repete antes de responder.

Solução

O caso base é n == 0 com soma 0. A medida é o valor inteiro n; a chamada seguinte recebe n // 10, que é menor quando n > 0 e continua não negativo. Em fib(4), fib(2) corre duas vezes e fib(1) três vezes. Um ciclo conserva os dois valores anteriores e calcula cada novo valor uma vez, sem reconstruir as mesmas subárvores.

Confere a tua resposta:

  • Indiquei o caso base de soma_digitos.
  • Indiquei a medida que diminui.
  • Expliquei porque fib repete trabalho.

Erros frequentes

Confundir o dígito extraído com o prefixo restante; contar apenas entradas distintas e esquecer que duas chamadas com a mesma entrada repetem o cálculo.

Voltar à explicação
Juntar contagens de ramos diferentes

Define contar_folhas(xs): recebe uma lista finita, sem referências cíclicas, cujos elementos são strings ou listas com a mesma regra. Devolve um dicionário com a frequência de cada string em todos os níveis. Uma string vazia conta como qualquer outra; maiúsculas distinguem valores. Conserva a entrada. Constrói a solução recursivamente e explica como juntas as contagens de duas sublistas que contêm a mesma string.

Python
def contar_folhas(xs):
    return {}
Testes e resultado

Verifica o código para comparar o resultado com os testes.

Testes
  1. Mesma chave em vários ramos
    Código do teste
    print(sorted(contar_folhas(["a", ["b", "a"], [], [["b", "a"]]]).items()))
    Esperado:
    [('a', 3), ('b', 2)]
  2. Só listas vazias
    Código do teste
    print(contar_folhas([[], [[], []]]))
    Esperado:
    {}
  3. Texto vazio também é folha
    Código do teste
    print(sorted(contar_folhas(["", ["", "FP"], "fp"]).items()))
    Esperado:
    [('', 2), ('FP', 1), ('fp', 1)]
  4. Entrada intacta
    Código do teste
    xs = ["a", ["a", "b"]]
    print(sorted(contar_folhas(xs).items()))
    print(xs)
    Esperado:
    [('a', 2), ('b', 1)]
    ['a', ['a', 'b']]

Pista
Substituir um dicionário por outro não soma as frequências.
Solução

Cada chamada começa com um dicionário próprio. Uma folha acrescenta 1 à sua chave. Uma sublista devolve contagens, que acrescentamos chave a chave ao resultado exterior. A lista vazia devolve {} e não contribui com folhas.

def contar_folhas(xs):
    resultado = {}
    for elemento in xs:
        if isinstance(elemento, list):
            contagens = contar_folhas(elemento)
            for chave, quantidade in contagens.items():
                resultado[chave] = resultado.get(chave, 0) + quantidade
        else:
            resultado[elemento] = resultado.get(elemento, 0) + 1
    return resultado

Na primeira entrada, os ramos interiores contribuem com {'b': 1, 'a': 1} cada um. Somando também a folha exterior "a", obtemos três ocorrências de "a" e duas de "b". update sozinho substituiria valores comuns e perderia as contagens anteriores. A chamada só desce para uma sublista própria, por isso a estrutura finita sem ciclos garante a redução. Strings são hashable, o que permite usá-las como chaves.

Voltar à explicação
Avaliar operadores depois dos operandos

Define avaliar(expr): a expressão é um inteiro ou um tuplo (esquerda, operador, direita), onde cada operando é uma expressão válida e operador é "+", "-" ou "*". A árvore é finita. Devolve o resultado inteiro sem alterar a expressão nem usar eval. Explica a ordem de avaliação de (1, "-", (3, "-", 8)).

Python
def avaliar(expr):
    return 0
Testes e resultado

Verifica o código para comparar o resultado com os testes.

Testes
  1. Folha negativa
    Código do teste
    print(avaliar(-4))
    Esperado:
    -4
  2. Duas subárvores
    Código do teste
    print(avaliar(((8, "-", 3), "*", (2, "+", 1))))
    Esperado:
    15
  3. Subtração na ordem recebida
    Código do teste
    print(avaliar((1, "-", (3, "-", 8))))
    Esperado:
    6
  4. Resultado zero
    Código do teste
    print(avaliar(((2, "+", 3), "*", 0)))
    Esperado:
    0
  5. Expressão intacta
    Código do teste
    e = ((1, "+", 2), "-", 7)
    print(avaliar(e))
    print(e)
    Esperado:
    -4
    ((1, '+', 2), '-', 7)

Pista
O tuplo diz como combinar os resultados dos dois operandos.
Solução

Um inteiro já é um valor. Num tuplo, cada chamada recursiva resolve um operando, e só depois aplicamos a operação exterior.

def avaliar(expr):
    if isinstance(expr, int):
        return expr
    esquerda, operador, direita = expr
    a = avaliar(esquerda)
    b = avaliar(direita)
    if operador == "+":
        return a + b
    if operador == "-":
        return a - b
    return a * b

Na expressão pedida, o operando direito vale 3−8=−53-8=-5. A subtração exterior é 1−(−5)=61-(-5)=6, e não (1−3)−8=−10(1-3)-8=-10. A forma da árvore determina os agrupamentos. O último ramo usa multiplicação porque o contrato já garante que só existem os três operadores permitidos. Cada chamada trata uma subárvore própria; uma folha termina diretamente.

Voltar à explicação
Explorar uma escolha que pode falhar

Define soma_exata(precos, alvo): os preços são uma lista de inteiros estritamente positivos e o alvo é um inteiro não negativo. Devolve True se algumas posições, cada uma usada no máximo uma vez, somam exatamente o alvo. A escolha vazia soma zero. Posições diferentes podem ter o mesmo preço. Conserva a entrada e usa uma decomposição recursiva que considere escolher ou ignorar a primeira posição. Explica por que ordenar e escolher sempre o maior não basta.

Python
def soma_exata(precos, alvo):
    return False
Testes e resultado

Verifica o código para comparar o resultado com os testes.

Testes
  1. Escolher sempre o maior falha
    Código do teste
    print(soma_exata([6, 4, 3], 7))
    Esperado:
    True
  2. Não reutilizar uma posição
    Código do teste
    print(soma_exata([3], 6))
    Esperado:
    False
  3. Posições diferentes com preços iguais
    Código do teste
    print(soma_exata([3, 3], 6))
    Esperado:
    True
  4. Escolha vazia
    Código do teste
    print(soma_exata([], 0), soma_exata([2, 4], 0), soma_exata([], 1))
    Esperado:
    True True False
  5. Sem solução e entrada intacta
    Código do teste
    xs = [6, 4, 3]
    print(soma_exata(xs, 8))
    print(xs)
    Esperado:
    False
    [6, 4, 3]

Solução

Testamos alvo zero antes de rejeitar a lista vazia. Nos restantes casos, retiramos a primeira posição nos dois ramos. O primeiro tenta usá-la e reduz o alvo; o segundo ignora-a. O or aceita se algum ramo tiver sucesso.

def soma_exata(precos, alvo):
    if alvo == 0:
        return True
    if alvo < 0 or not precos:
        return False
    return (soma_exata(precos[1:], alvo - precos[0])
            or soma_exata(precos[1:], alvo))

Com [6, 4, 3] e alvo 7, o ramo que escolhe 6 falha, mas ignorá-lo permite somar 4 e 3. Para alvo 8, as somas possíveis são 0, 3, 4, 6, 7, 9, 10 e 13; nenhuma é 8. Com [3], não podemos usar a mesma posição duas vezes; [3, 3] tem duas posições e permite formar 6.

Cada chamada tem uma lista mais curta e as fatias não alteram a entrada. Essa redução garante terminação, mas cria cópias e pode explorar um número exponencial de subconjuntos. A condição alvo < 0 só é válida porque todos os preços são positivos.

Voltar à explicação

À tua maneira

Aparência

Modo de cor
Tema
Cor de destaque

Leitura

Fonte de leitura

Álgebra, lógica e uma ideia de cada vez.

Código

Fonte do código

Navegação

Cursor de leitura, sem edição. h, j, k, l movem; / pesquisa nesta página. v seleciona caracteres; V seleciona linhas; y copia. Tab permite destacar ou comentar. Espaço ou Enter abre blocos recolhíveis. Nos cartões, h, j, k, l mudam o foco.

CSS personalizado

Ativa uma sugestão ou guarda o teu CSS. Só muda este navegador.

Usa --page, --text e --accent para acompanhar o tema.

Seletores e recuperação

.site-header, .prose, .course-card, [data-reading-history], [data-playground] e .page-actions.

Abre a aparência com a tecla vírgula ou no rodapé. Se o CSS esconder os controlos, abre esta página sem CSS personalizado.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Copiar fórmula

A cópia automática não está disponível. Copia o LaTeX abaixo.

Pesquisar nesta página

Enter procura · n / N repetem · Esc cancela. Ctrl / Cmd K pesquisa todo o site.

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.