Estrutura de Dados I

Recursão

Funções que chamam a si mesmas. Caso base, passo recursivo, o caminho de ida e de volta na pilha de chamadas e exemplos com números e vetores.

"To understand recursion, we must first understand recursion." (Anônimo)

Recursão é um método de resolver um problema dividindo-o em instâncias menores do mesmo problema. As soluções dos problemas menores são então combinadas para obter a solução do problema original. Em programação, isso acontece quando uma função chama a si mesma.

Pense em uma matriosca, a boneca russa que guarda outra boneca dentro de si, que guarda outra, e assim por diante. Para encontrar a menor boneca, abrimos a maior; se dentro dela houver outra, abrimos essa também, e repetimos até chegar a uma boneca que não abre. A recursão funciona da mesma forma: cada chamada "abre" um problema um pouco menor, até chegar a um problema tão simples que se resolve diretamente.

Uma função pode chamar a si mesma?

Antes de escrever código, pense nas perguntas abaixo. Tente responder antes de abrir cada resposta.

Uma função pode chamar qualquer outra função já definida no programa?

Sim, é o que fazemos o tempo todo: o main chama printf, uma função chama outra auxiliar. Chamar uma função é pedir que outro bloco de código seja executado e, quando ele terminar, voltar para onde parou.

E uma função pode chamar a si própria? Alguma regra do C proíbe isso?

Pode, e nenhuma regra proíbe. Para o compilador, uma função que chama a si mesma não é diferente de uma função que chama outra qualquer. Essa é a definição de recursão: uma função que se chama, direta ou indiretamente.

Se a função chama a si mesma, e essa chamada chama de novo, e de novo... o que impede isso de continuar para sempre?

Nada, a menos que a própria função pare de se chamar em algum momento. É preciso uma condição que interrompa as novas chamadas. Sem ela, as chamadas se acumulam até a memória reservada para elas acabar (estouro de pilha). Essa condição de parada é o caso base.

A chamada "nova" enxerga e pode alterar as variáveis locais da chamada "antiga"?

Não. Cada chamada, recursiva ou não, recebe seus próprios parâmetros e variáveis locais, guardados na pilha de execução. As variáveis de chamadas diferentes são independentes, como se fossem funções diferentes (a não ser que sejam globais ou passadas por ponteiro).

Caso base e passo recursivo

Toda função recursiva tem dois ingredientes:

  • Caso base: a versão mais simples do problema, resolvida diretamente, sem nova chamada recursiva. É o que garante que a recursão termina.
  • Passo recursivo (ou etapa de redução): a função resolve uma versão menor do mesmo problema, chamando a si mesma, e usa esse resultado para resolver o problema original.
Estrutura típica

Se o problema atual é simples, resolva-o diretamente (caso base). Caso contrário, reduza-o a um problema menor do mesmo tipo e aplique a mesma estratégia (passo recursivo).

Sem caso base, ou com um caso base que o passo recursivo nunca alcança, temos uma recursão infinita, o equivalente recursivo de um laço infinito.

Tirinha do xkcd: alguém tentando consertar os problemas criados ao tentar consertar os problemas criados ao tentar consertar...
Recursão sem caso base. xkcd 1739, Fixing Problems (Randall Munroe, CC BY-NC 2.5).

Fatorial

O fatorial de um inteiro é o produto dos inteiros de a , com . Por exemplo, . Repare que e, em geral,

A definição já é recursiva: o fatorial de é definido a partir do fatorial de um número menor. Para escrever a função, começamos pelo passo recursivo:

int fatorial(int n) {
    return n * fatorial(n - 1);   /* passo recursivo */
}

Essa versão nunca para: fatorial(0) chamaria fatorial(-1), que chamaria fatorial(-2)... Falta o caso base, o problema mais simples, que sabemos resolver diretamente: .

fatorial.cC
A função ainda tem problemas que não estão sendo tratados. Consegue identificá-los? (Teste com as entradas -3 e 13.)
  • Números negativos: com n = -3, o passo recursivo chama fatorial(-4), fatorial(-5), ..., e o caso base n == 0 nunca é alcançado. As chamadas se acumulam até a pilha estourar (stack overflow). Uma solução é tratar n <= 0 como caso base, ou rejeitar valores negativos antes de chamar.
  • Overflow: não cabe em um int (o maior int é ). Com long long, dá para ir até .

Ida e volta

Este é o ponto central para entender recursão. Uma função recursiva tem um caminho de ida, em que as chamadas se empilham, uma dentro da outra, e um caminho de volta, em que as chamadas terminam na ordem inversa, à medida que a pilha é desempilhada.

imprime.cC
  1. 1

    Código antes da chamada#

    Execute como está e observe a saída: 5 4 3 2 1 0. O printf vem antes da chamada recursiva, então executa na ida.

  2. 2

    Código depois da chamada#

    Comente o primeiro printf e descomente o segundo. Antes de executar, preveja a saída. Agora ela é 0 1 2 3 4 5: o printf vem depois da chamada, então executa na volta.

  3. 3

    Antes e depois#

    Deixe os dois printf ativos. Antes de executar, escreva a saída que você espera e depois confira.

A ideia central

Cada chamada de imprime(n) fica pausada, esperando imprime(n - 1) terminar, antes de continuar o que vem depois dela. O código antes da chamada recursiva executa na ida (5, 4, 3, ...); o código depois executa na volta, na ordem inversa (0, 1, 2, ...).

No fatorial, a multiplicação n * fatorial(n - 1) só pode ser feita depois que fatorial(n - 1) devolve seu valor. Por isso, na ida, as chamadas apenas se empilham; as multiplicações acontecem todas na volta:

Pilha de chamadas de fatorial(4): as chamadas descem até fatorial(0) e os valores 1, 1, 2 e 6 sobem até o resultado 24
Pilha de chamadas de fatorial(4): na ida, cada chamada empilha um novo quadro; na volta, cada quadro recebe o valor da chamada seguinte e calcula o seu.

O programa abaixo mostra o mesmo processo, com um recuo por nível de recursão:

rastreio.cC
Cada chamada tem suas próprias variáveis

Os parâmetros e as variáveis locais de cada chamada ficam num quadro próprio na pilha de execução. No rastreio acima, os cinco n e os cinco resultado existem ao mesmo tempo, cada um na sua chamada, sem que um interfira no outro.

Exemplos

Antes de olhar cada solução, tente responder:

  1. O que a função precisa receber para funcionar (quais parâmetros)?
  2. O que ela deve devolver?
  3. Qual é o caso mais simples (caso base)? Como reduzir o problema a um menor (passo recursivo)?

Soma de 1 a n

É quase o mesmo problema do fatorial, mas somando em vez de multiplicar:

Com a definição matemática em mãos, o código é direto:

int soma(int n) {
    if (n == 0)                  /* caso base */
        return 0;
    else                         /* passo recursivo */
        return n + soma(n - 1);
}

São feitas chamadas, e a pilha chega a ter quadros ao mesmo tempo: a função é em tempo e em memória.

Soma dos pares de 2 até n

Para :

éíé
int f(int n) {
    if (n == 2)                  /* caso base */
        return 2;
    else if (n % 2 == 1)         /* ímpar: não soma */
        return f(n - 1);
    else                         /* par: soma */
        return n + f(n - 1);
}
Quando n é par, precisamos chamar f(n - 1)? Há uma forma mais eficiente?

Se é par, é ímpar e a chamada f(n - 1) só serve para chamar f(n - 2). Podemos ir direto: return n + f(n - 2);. A função continua correta para qualquer (se a chamada inicial for ímpar, o segundo caso a leva para um par, e de par em par chegamos a 2) e faz cerca de metade das chamadas.

Quantidade de dígitos

O caso base é um número com um único dígito (), que tem 1 dígito. Caso contrário, retiramos um dígito e contamos mais um. Para retirar o último dígito, basta a divisão inteira por 10:

int contaDigitos(int n) {
    if (n < 10)                          /* caso base */
        return 1;
    else                                 /* passo recursivo */
        return 1 + contaDigitos(n / 10);
}

Cada chamada divide por 10: o número de chamadas é o número de dígitos, cerca de . A função é .

Para pensar
  • A função supõe . Como adaptá-la para aceitar negativos?
  • Como somar os dígitos em vez de contá-los? Por exemplo, somaDigitos(123) deveria devolver . (Esse é um dos exercícios no fim da página.)

Encontre o erro

A função somaDigitos abaixo deveria devolver 10 para 1234. Antes de executar, tente prever o que ela devolve.

digitos.cC
Resposta

O passo recursivo chama contaDigitos(n / 10) em vez de somaDigitos(n / 10): a função soma o último dígito (4) com a quantidade de dígitos do resto (3), e devolve 7. O compilador não acusa nada, porque as duas funções têm a mesma assinatura. Corrija para return n % 10 + somaDigitos(n / 10);.

Qual é o mistério?

O que a função abaixo calcula?

int misterio(int n) {
    if (n == 1)
        return 0;
    else
        return 1 + misterio(n / 2);
}
Resposta

A melhor estratégia é o teste de mesa: executar a função à mão para alguns valores.

  • misterio(4) = 1 + misterio(2) = 1 + 1 + misterio(1) = 2
  • misterio(6) = 1 + misterio(3) = 1 + 1 + misterio(1) = 2
  • misterio(8) = 1 + misterio(4) = 3
  • misterio(10) = 1 + misterio(5) = 1 + 1 + misterio(2) = 3
  • misterio(16) = 1 + misterio(8) = 4
  • misterio(20) = 1 + misterio(10) = 4

A função conta quantas vezes dá para dividir por 2 até chegar a 1, ou seja, calcula .

Recursão com vetores

Procurar um valor y no vetor x de n elementos, devolvendo seu índice ou :

int procura(int x[], int y, int n) {
    if (n == 0)                  /* vetor vazio: não está */
        return -1;
    if (y == x[n - 1])           /* está na última posição */
        return n - 1;
    return procura(x, y, n - 1); /* procura nos n - 1 primeiros */
}

Imprimir os elementos do vetor:

void escreve(int x[], int n) {
    if (n == 0)
        return;
    printf("%d ", x[0]);
    escreve(x + 1, n - 1);
}
Por que a chamada recursiva é escreve(x + 1, n - 1), e não escreve(x, n - 1)?

x + 1 é o endereço do segundo elemento (aritmética de ponteiros): a chamada recursiva recebe o "resto" do vetor, que começa uma posição à frente e tem um elemento a menos. Com escreve(x, n - 1), todas as chamadas imprimiriam x[0].

Somar os elementos do vetor, em duas versões:

int soma(int x[], int n) {
    if (n == 0)
        return 0;
    else
        return x[0] + soma(x + 1, n - 1);   /* primeiro + soma do resto */
}

A segunda versão tem um erro. Execute e leia a mensagem:

soma_vetor.cC
Resposta

Um vetor com n elementos tem índices de 0 a n - 1: x[n] está fora dos limites já na primeira chamada (e x[0] nunca é somado). O correto é return x[n - 1] + soma(x, n - 1);. Corrigida, ela soma do último elemento para o primeiro; a primeira versão soma do primeiro para o último.

Fibonacci: duas chamadas recursivas

A sequência de Fibonacci começa com 0 e 1, e cada termo seguinte é a soma dos dois anteriores:

int fib(int n) {
    if (n == 0 || n == 1)         /* caso base */
        return n;
    return fib(n - 1) + fib(n - 2);
}

Diferente dos exemplos anteriores, o passo recursivo faz duas chamadas. As chamadas deixam de formar uma linha e passam a formar uma árvore, e os mesmos valores são calculados várias vezes, de forma independente:

Árvore de chamadas de fib(5), com fib(3) aparecendo duas vezes e fib(2) três vezes
Árvore de chamadas de fib(5): 15 chamadas para calcular o quinto termo.

O programa conta as chamadas para valores crescentes de :

fib.cC

A cada 5 unidades de , o número de chamadas fica cerca de 11 vezes maior: o crescimento é exponencial (aproximadamente ). Uma versão com um laço, que guarda os dois últimos termos, calcula o mesmo em .

Recursão não é sempre a melhor escolha

Toda função recursiva pode ser reescrita com laços, e vice-versa. A recursão deixa muitos algoritmos mais simples de escrever e de entender, mas cada chamada ocupa espaço na pilha e, como no Fibonacci, uma recursão ingênua pode repetir muito trabalho.

Recursão e a pilha

Cada chamada ainda não terminada ocupa um quadro na pilha de execução. Uma recursão muito profunda (ou infinita) esgota a pilha e o programa termina com um estouro de pilha, o mesmo problema de um vetor local grande demais (veja Alocação de Memória). Por isso, além do tempo, vale perguntar quantas chamadas ficam empilhadas ao mesmo tempo.

FunçãoChamadasProfundidade máxima da pilhaTempo
fatorial(n), soma(n)
contaDigitos(n)número de dígitosnúmero de dígitos
misterio(n)
procura(x, y, n)até até
fib(n)cerca de exponencial

Exercícios

Encontre e corrija o erro

Cada programa tem um erro. Execute, leia a mensagem (inclusive os avisos do compilador), corrija e execute de novo.

Programa 1

potencia.cC
Resposta

O passo recursivo reduz o expoente de 2 em 2. Com expoente ímpar, ele passa de 1 para e nunca chega ao caso base exp == 0: recursão infinita e estouro de pilha. Uma correção é tratar o ímpar à parte:

if (exp % 2 == 1)
    return base * potencia(base, exp - 1);

Programa 2

maior.cC
Resposta

Quando v[n - 1] não é maior que m, a função termina sem return. O valor devolvido é indefinido (o compilador avisa: control reaches end of non-void function). Falta return m; no fim. Leve sempre a sério os avisos do compilador.

Programa 3

Volte ao programa da soma dos dígitos e ao da soma do vetor, se ainda não os corrigiu.

Rastreie

O que a função abaixo imprime para f(13)? E para f(8)?

void f(int n) {
    if (n == 0)
        return;
    f(n / 2);
    printf("%d", n % 2);
}
Resposta

As chamadas vão de f(13) a f(6), f(3), f(1) e f(0). Como o printf vem depois da chamada, os restos são impressos na volta, do último para o primeiro: 1, 1, 0, 1, ou seja, 1101. Para f(8), imprime 1000. A função imprime em binário.

Implemente

Soma dos dígitos. Implemente somaDigitos de forma recursiva.

Soma dos dígitosC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Palíndromo. Uma palavra é um palíndromo se for igual lida da esquerda para a direita e da direita para a esquerda (como arara e osso). Implemente palindromo(s, ini, fim), que verifica o trecho s[ini..fim]: compare as pontas e, se forem iguais, verifique recursivamente o trecho sem elas.

Palíndromo recursivoC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Potência rápida. Calcular multiplicando por ele mesmo vezes custa . Com recursão, dá para fazer muito melhor:

ééí

Como o expoente cai pela metade a cada chamada (como na busca binária), são cerca de chamadas. Os testes usam expoentes da ordem de : reduzir o expoente de um em um estoura a pilha, e calcular duas vezes (com duas chamadas) estoura o tempo. As contas são feitas módulo , para os valores caberem em long long.

Potência rápidaC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Referências

Nesta página