Recursão
Casos base, redução do problema, pilha de chamadas e estruturas aninhadas.
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, . Para um inteiro não negativo, e quando . Esta relação dá o caso base e o passo recursivo.
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))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:
| Chamada | Resultado que espera | Valor devolvido |
|---|---|---|
fatorial(0) | Nenhum, é o caso base | 1 |
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.
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([]))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 e a exterior vale .
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.
| Parte | Valor |
|---|---|
Operando esquerdo (8, '-', 3) | 5 |
Operando direito (2, '+', 1) | 3 |
| Multiplicação exterior | 15 |
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é
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
// 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.
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.
def soma_aninhada(xs):
return 0Verifica o código para comparar o resultado com os testes.
Testes
Vários níveis e lista vazia interior
Código do testeprint(soma_aninhada([1, [2, [3]], [], 4]))Esperado:10
Lista vazia
Código do testeprint(soma_aninhada([]))Esperado:0
Só listas vazias
Código do testeprint(soma_aninhada([[], [[], []]]))Esperado:0
Negativos e decimais
Código do testeprint(soma_aninhada([-2, [1.5, [0.5]], 4]))Esperado:4.0
Entrada preservada
Código do testexs = [1, [2, [3]], 4] print(soma_aninhada(xs)) print(xs)Esperado:10 [1, [2, [3]], 4]
print(soma_aninhada([1, [2, [3]], [], 4]))10
print(soma_aninhada([]))0
print(soma_aninhada([[], [[], []]]))0
print(soma_aninhada([-2, [1.5, [0.5]], 4]))4.0
xs = [1, [2, [3]], 4]
print(soma_aninhada(xs))
print(xs)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 totalErros frequentes
Tratar apenas um nível, devolver antes de acabar o percurso ou retirar elementos da entrada.
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.
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.
def contar_folhas(xs):
return {}Verifica o código para comparar o resultado com os testes.
Testes
Mesma chave em vários ramos
Código do testeprint(sorted(contar_folhas(["a", ["b", "a"], [], [["b", "a"]]]).items()))Esperado:[('a', 3), ('b', 2)]Só listas vazias
Código do testeprint(contar_folhas([[], [[], []]]))Esperado:{}Texto vazio também é folha
Código do testeprint(sorted(contar_folhas(["", ["", "FP"], "fp"]).items()))Esperado:[('', 2), ('FP', 1), ('fp', 1)]Entrada intacta
Código do testexs = ["a", ["a", "b"]] print(sorted(contar_folhas(xs).items())) print(xs)Esperado:[('a', 2), ('b', 1)] ['a', ['a', 'b']]
print(sorted(contar_folhas(["a", ["b", "a"], [], [["b", "a"]]]).items()))[('a', 3), ('b', 2)]print(contar_folhas([[], [[], []]])){}print(sorted(contar_folhas(["", ["", "FP"], "fp"]).items()))[('', 2), ('FP', 1), ('fp', 1)]xs = ["a", ["a", "b"]]
print(sorted(contar_folhas(xs).items()))
print(xs)[('a', 2), ('b', 1)]
['a', ['a', 'b']]Pista
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 resultadoNa 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.
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)).
def avaliar(expr):
return 0Verifica o código para comparar o resultado com os testes.
Testes
Folha negativa
Código do testeprint(avaliar(-4))Esperado:-4
Duas subárvores
Código do testeprint(avaliar(((8, "-", 3), "*", (2, "+", 1))))Esperado:15
Subtração na ordem recebida
Código do testeprint(avaliar((1, "-", (3, "-", 8))))Esperado:6
Resultado zero
Código do testeprint(avaliar(((2, "+", 3), "*", 0)))Esperado:0
Expressão intacta
Código do testee = ((1, "+", 2), "-", 7) print(avaliar(e)) print(e)Esperado:-4 ((1, '+', 2), '-', 7)
print(avaliar(-4))-4
print(avaliar(((8, "-", 3), "*", (2, "+", 1))))15
print(avaliar((1, "-", (3, "-", 8))))6
print(avaliar(((2, "+", 3), "*", 0)))0
e = ((1, "+", 2), "-", 7)
print(avaliar(e))
print(e)-4 ((1, '+', 2), '-', 7)
Pista
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 * bNa expressão pedida, o operando direito vale . A subtração exterior é , e não . 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.
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.
def soma_exata(precos, alvo):
return FalseVerifica o código para comparar o resultado com os testes.
Testes
Escolher sempre o maior falha
Código do testeprint(soma_exata([6, 4, 3], 7))Esperado:True
Não reutilizar uma posição
Código do testeprint(soma_exata([3], 6))Esperado:False
Posições diferentes com preços iguais
Código do testeprint(soma_exata([3, 3], 6))Esperado:True
Escolha vazia
Código do testeprint(soma_exata([], 0), soma_exata([2, 4], 0), soma_exata([], 1))Esperado:True True False
Sem solução e entrada intacta
Código do testexs = [6, 4, 3] print(soma_exata(xs, 8)) print(xs)Esperado:False [6, 4, 3]
print(soma_exata([6, 4, 3], 7))True
print(soma_exata([3], 6))False
print(soma_exata([3, 3], 6))True
print(soma_exata([], 0), soma_exata([2, 4], 0), soma_exata([], 1))True True False
xs = [6, 4, 3]
print(soma_exata(xs, 8))
print(xs)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.