Recursão: Quando uma Função Chama a Si Mesma

Recursão: Quando uma Função Chama a Si Mesma

O fatorial recursivo é bonito; o Fibonacci recursivo é uma armadilha, porque recalcula o mesmo valor três, quatro, dez vezes. Entre os dois está a lição da aula: o caso base não é detalhe, é o que faz a pilha voltar a esvaziar — e a recursão é ferramenta, nunca virtude em si mesma.
Linguagem C

9 min de leitura

Abrimos a Fase 5 com uma das ideias mais elegantes e, à primeira vista, mais desconcertantes da programação: uma função que chama a si mesma. Parece um paradoxo — como algo pode se definir em termos de si próprio sem girar para sempre? Mas a recursão, quando bem compreendida, é uma ferramenta poderosa que expressa certos problemas com uma clareza que a repetição comum não alcança. E há uma razão especial para estudá-la agora, no início desta fase: as estruturas de dados que vamos construir — listas, árvores — têm natureza recursiva, e percorrê-las com recursão será quase natural.

A ideia central: um problema definido em termos de si mesmo

A recursão funciona quando um problema pode ser dividido em uma versão menor de si mesmo. Pense no fatorial de um número: 5! é 5 × 4!, que é 5 × 4 × 3!, e assim por diante. Cada fatorial é definido em termos de um fatorial menor. Essa autossimilaridade é o sinal de que a recursão se encaixa.

Toda função recursiva precisa de duas partes, e a ausência de qualquer uma é fatal. O caso base é a condição de parada — o menor problema, cuja resposta é conhecida diretamente, sem mais recursão. O caso recursivo é onde a função chama a si mesma com uma versão menor do problema, aproximando-se do caso base. Sem caso base, a recursão nunca para (e estoura a pilha); sem progresso rumo a ele, idem.

O exemplo canônico: fatorial

#include <stdio.h>

long fatorial(int n) {
    if (n <= 1) {          // CASO BASE: 0! = 1! = 1
        return 1;
    }
    return n * fatorial(n - 1); // CASO RECURSIVO: n! = n × (n-1)!
}

int main(void) {
    for (int i = 0; i <= 6; i++) {
        printf("%d! = %ld\n", i, fatorial(i));
    }
    return 0;
}

Saída:

0! = 1
1! = 1
2! = 2
3! = 6
4! = 24
5! = 120
6! = 720

Acompanhe o que acontece ao chamar fatorial(3). A função vê que 3 > 1, então retorna 3 * fatorial(2). Para calcular isso, ela chama fatorial(2), que retorna 2 * fatorial(1). E fatorial(1) atinge o caso base, retornando 1 diretamente. Agora as chamadas "desempilham": fatorial(2) completa 2 * 1 = 2, fatorial(3) completa 3 * 2 = 6. O caso base é o que permite essa cadeia terminar e as respostas voltarem.

Recursão e a pilha: o que acontece por baixo

Aqui reconectamos com a aula A Pilha e o Heap: Onde Cada Coisa Vive, sobre a pilha. Cada chamada recursiva cria um novo stack frame na pilha, com sua própria cópia dos parâmetros e variáveis locais. Quando você chama fatorial(3), empilham-se os frames de fatorial(3), fatorial(2) e fatorial(1), um sobre o outro. Só quando o caso base é atingido é que eles começam a desempilhar, cada um devolvendo seu resultado ao anterior.

Essa é a beleza e o perigo da recursão. A beleza: a pilha guarda automaticamente o "estado" de cada nível, sem que você precise gerenciá-lo. O perigo: cada nível consome espaço da pilha, que é limitada. Uma recursão profunda demais (ou infinita, por falta de caso base) causa o stack overflow que estudamos — a pilha se esgota e o programa é encerrado. É por isso que o caso base não é um detalhe: é o que garante que a pilha volte a esvaziar.

Comparando recursão e iteração

Todo problema recursivo pode ser reescrito com um laço (iteração), e vice-versa. O fatorial, por exemplo, é igualmente simples de forma iterativa:

#include <stdio.h>

long fatorial_iterativo(int n) {
    long resultado = 1;
    for (int i = 2; i <= n; i++) {
        resultado *= i;
    }
    return resultado;
}

Qual usar? Depende. A versão iterativa costuma ser mais eficiente (não há custo de chamadas de função nem consumo de pilha) e é preferível quando o problema é naturalmente sequencial, como este. A versão recursiva brilha quando o problema é intrinsecamente recursivo — quando a solução recursiva é muito mais clara e direta que a iterativa. Para o fatorial, a iteração é perfeitamente adequada. Mas para percorrer uma árvore, como veremos, a recursão será tão mais simples que a iteração pareceria contorcida. A regra prática: use recursão quando ela tornar o código mais claro; prefira iteração quando a recursão for apenas uma complicação desnecessária.

Um caso onde a recursão é natural: percorrer estruturas

Para antecipar por que a recursão importa nesta fase, veja como ela expressa naturalmente a soma dos dígitos de um número — um problema que se define em termos menores (o último dígito, mais a soma dos demais):

#include <stdio.h>

int soma_digitos(int n) {
    if (n == 0) {              // caso base
        return 0;
    }
    return (n % 10) + soma_digitos(n / 10); // último dígito + resto
}

int main(void) {
    printf("%d\n", soma_digitos(12345)); // 15 (1+2+3+4+5)
    return 0;
}

A cada passo, n % 10 extrai o último dígito e n / 10 remove-o, encolhendo o problema até n chegar a zero. A estrutura recursiva reflete diretamente a definição do problema. Quando chegarmos às listas encadeadas e às árvores, veremos que "processar o primeiro elemento e depois processar o resto" é exatamente esse mesmo padrão — e é por isso que a recursão e as estruturas encadeadas combinam tão bem.

Um alerta honesto: recursão mal usada

Nem toda recursão é boa recursão. O exemplo clássico de recursão ineficiente é a sequência de Fibonacci implementada de forma ingênua:

long fib(int n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2); // recalcula os mesmos valores muitas vezes!
}

Essa versão é elegante, mas desastrosamente lenta para n grande, porque recalcula os mesmos valores repetidamente — fib(5) calcula fib(3) duas vezes, fib(2) três vezes, e a explosão só piora. É um caso em que a recursão ingênua, embora bonita, é a escolha errada; uma versão iterativa (ou recursão com memoização) resolve em tempo linear. A lição: a recursão é uma ferramenta, não uma virtude em si. Use-a quando ela traz clareza sem penalidade proibitiva, e desconfie quando ela recalcula trabalho ou aprofunda demais a pilha.

Recursão não é técnica de otimização, é forma de descrever problema: quando a estrutura do dado é ela mesma recursiva — uma árvore, uma lista, um diretório dentro de outro — a solução recursiva costuma ser a mais curta e a mais fácil de conferir. O custo aparece na pilha, que cresce a cada chamada e não é infinita; e o caso base, aquele que quase todo mundo escreve por último, é o que separa o algoritmo do travamento.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva uma função recursiva int soma_ate(int n) que calcule a soma dos inteiros de 1 até n (por exemplo, soma_ate(5) = 15). Identifique claramente o caso base e o caso recursivo.

Ver resposta

✓ Resposta:

#include <stdio.h>
int soma_ate(int n) {
    if (n <= 0) return 0;        // caso base
    return n + soma_ate(n - 1);  // caso recursivo
}
int main(void) {
    printf("%d\n", soma_ate(5)); // 15
    return 0;
}

O caso base (n <= 0) para a recursão; o caso recursivo soma n ao resultado do problema menor soma_ate(n-1).

Exercício 2

Escreva uma função recursiva int potencia(int base, int expoente) que calcule base elevado a expoente (com expoente ≥ 0), sem usar pow. O caso base é expoente == 0 (resultado 1).

Ver resposta

✓ Resposta:

#include <stdio.h>
int potencia(int base, int expoente) {
    if (expoente == 0) return 1;              // caso base: base^0 = 1
    return base * potencia(base, expoente - 1); // caso recursivo
}
int main(void) {
    printf("%d\n", potencia(2, 10)); // 1024
    return 0;
}

Exercício 3

Escreva uma função recursiva que imprima uma contagem regressiva de n até 1 e depois imprima "Fim!". Depois, mova a impressão para depois da chamada recursiva e observe: a contagem agora sai crescente. Explique por quê.

Ver resposta

✓ Resposta:

#include <stdio.h>
void regressiva(int n) {
    if (n == 0) { printf("Fim!\n"); return; }
    printf("%d ", n);       // imprime ANTES de recorrer
    regressiva(n - 1);
}

Isso imprime 5 4 3 2 1 Fim!. Movendo o printf para depois da chamada:

void crescente(int n) {
    if (n == 0) { printf("Fim! "); return; }
    crescente(n - 1);
    printf("%d ", n);       // imprime DEPOIS de recorrer
}

Agora sai Fim! 1 2 3 4 5. A razão: quando a impressão vem depois da chamada recursiva, ela só acontece na fase de "desempilhamento" — a função mergulha até o caso base primeiro (sem imprimir), e só então, ao voltar, cada nível imprime seu valor. Como o desempilhamento ocorre na ordem inversa das chamadas (do menor n de volta ao maior), a saída fica crescente. É a pilha diformando a ordem: imprimir antes segue a ida; imprimir depois segue a volta.

Exercício 4

Reescreva a função soma_digitos do artigo de forma iterativa (com um laço while), produzindo o mesmo resultado. Qual das duas versões você acha mais legível para este problema?

Ver resposta

✓ Resposta:

#include <stdio.h>
int soma_digitos(int n) {
    int soma = 0;
    while (n > 0) {
        soma += n % 10; // último dígito
        n /= 10;        // remove o último dígito
    }
    return soma;
}
int main(void) {
    printf("%d\n", soma_digitos(12345)); // 15
    return 0;
}

Para este problema, a legibilidade é comparável: ambas expressam bem a ideia de "extrair o último dígito e prosseguir". A versão iterativa tem a vantagem de não consumir pilha e é ligeiramente mais eficiente. Muitos considerariam a iterativa preferível aqui, reservando a recursão para problemas onde ela seja claramente mais natural (como percorrer árvores).

Exercício 5

Explique por que a versão recursiva ingênua de Fibonacci é tão ineficiente. Se fib(5) chama fib(4) e fib(3), quantas vezes fib(2) acaba sendo calculada no total ao computar fib(5)?

Ver resposta

✓ Resposta: A versão ingênua de Fibonacci é ineficiente porque recalcula os mesmos subproblemas repetidamente, sem reaproveitar resultados. Cada chamada fib(n) gera duas novas chamadas, e essas se sobrepõem: os mesmos valores são computados vez após vez, numa explosão que cresce exponencialmente. Ao computar fib(5): fib(5) chama fib(4) e fib(3); fib(4) chama fib(3) e fib(2); e assim por diante. Rastreando as chamadas, fib(2) acaba sendo calculada 3 vezes no total (uma vinda de fib(4)fib(3), uma de fib(4) direto via fib(3)... contando toda a árvore de chamadas de fib(5), fib(2) aparece 3 vezes, fib(1) aparece 5 vezes, fib(0) 3 vezes). Esse retrabalho é desperdício puro. A correção usa memoização (guardar resultados já calculados) ou uma abordagem iterativa, ambas reduzindo o custo de exponencial para linear.

Comentários

Mais em Linguagem C

Funções, Escopo e Passagem por Valor
Funções, Escopo e Passagem por Valor

Por que dobrar um número dentro de uma função deixa o original intacto? A…

Vetores e o Primeiro Contato com a Memória Contígua
Vetores e o Primeiro Contato com a Memória Contígua

Guardados lado a lado na memória — e é esse detalhe, não a sintaxe dos…

Variáveis, Tipos e a Memória por Trás Deles
Variáveis, Tipos e a Memória por Trás Deles

Um int não tem 4 bytes garantidos: o padrão fixa apenas mínimos e uma ordem, e…