Menu

Recursão em C: casos base, fatorial e profundidade da pilha

Como uma função em C chama a si mesma - o caso base que a interrompe, fatorial e Fibonacci passo a passo, por que o Fibonacci ingênuo é catastroficamente lento, o que é de fato um estouro de pilha e quando um laço é a resposta melhor.

Esta página tem editores executáveis - edite, execute e veja a saída na hora.

Uma função que chama a si mesma

Nada impede uma função em C de chamar a si mesma. O próprio nome dela está em escopo dentro do seu corpo, então isto é legal:

void countdown(int n) {
    printf("%d\n", n);
    countdown(n - 1);      /* chama a si mesma - mas nunca para! */
}

Também está quebrada. Ela imprime para sempre, entrando nos negativos, até o programa travar. O que falta é um caso base: uma condição sob a qual a função retorna sem chamar a si mesma.

Toda função recursiva tem exatamente estas duas partes:

  • Um caso base - a menor entrada, respondida diretamente, sem mais nenhuma chamada.
  • Um caso recursivo - resolve o problema em termos de uma versão estritamente menor de si mesmo.

"Estritamente menor" é a parte que as pessoas erram. countdown(n - 1) se move em direção a 0 a cada chamada. countdown(n) não se moveria, e countdown(n / 2) também não, se n pudesse ser 1 para sempre. Todo caminho precisa encolher o problema, ou o caso base nunca é alcançado.

Fatorial

O primeiro exemplo clássico. n! é n × (n-1) × ... × 1, e 0! é definido como 1. Essa definição já é recursiva: n! = n × (n-1)!.

Acompanhe factorial(4) para ver como a resposta é montada. As chamadas descem, e as multiplicações acontecem na volta:

factorial(4)  -> 4 * factorial(3)
                      factorial(3) -> 3 * factorial(2)
                                           factorial(2) -> 2 * factorial(1)
                                                                factorial(1) -> 1   (caso base)
                                           factorial(2) = 2 * 1  = 2
                      factorial(3) = 3 * 2  = 6
factorial(4)  = 4 * 6  = 24

Nada é multiplicado até o caso base retornar. Cada chamada pendente fica esperando, segurando o seu próprio n, que é o ponto que vale internalizar: essas chamadas em espera ocupam memória.

Repare no tipo de retorno. int transborda por volta de 13!, produzindo silenciosamente um número errado - o C não verifica. unsigned long long leva você até 20! e não além, porque 21! passa de 64 bits. A recursão não é o fator limitante aqui; o tipo é.

O caso base usa n <= 1 em vez de n == 1 deliberadamente: factorial(0) deve ser 1, e o <= cuida disso. Com n == 1, chamar factorial(0) recursaria até -1, -2, e nunca terminaria - uma boa ilustração de como um caso base "obviamente correto" pode deixar passar uma entrada.

Fibonacci, e por que a versão ingênua é uma armadilha

Fibonacci é o outro clássico: cada número é a soma dos dois anteriores, começando em 0 e 1. A definição recursiva se escreve sozinha.

Olhe as contagens de chamadas. fib(10) leva 177 chamadas; fib(35) leva quase 30 milhões. Cada passo de 5 multiplica o trabalho por cerca de onze.

A razão fica visível na árvore de chamadas. fib(5) chama fib(4) e fib(3); fib(4) chama fib(3) de novo; e cada um deles recalcula fib(2) do zero. Nada é lembrado, então os mesmos subproblemas são resolvidos repetidas vezes, e o número de chamadas cresce mais ou menos como 1,6ⁿ. fib(50) desse jeito rodaria por dias; fib(100) duraria mais que o universo.

A versão com laço guarda os dois últimos valores e é linear:

fib(90) retorna na hora. A lição não é "recursão é lenta" - é que recursão com subproblemas sobrepostos é lenta a não ser que você guarde as respostas. Armazene os resultados num array conforme os calcula (memoização) e a versão recursiva também fica linear.

A pilha de chamadas e o estouro de pilha

Toda chamada de função precisa de algum lugar para guardar seus parâmetros, suas variáveis locais e o endereço de retorno. Esse armazenamento é um quadro de pilha (stack frame), empilhado quando a chamada começa e desempilhado quando ela retorna. A recursão empilha quadros um em cima do outro - factorial(1000) tem mil quadros vivos ao mesmo tempo, cada um com o seu próprio n.

A pilha não é grande. Um padrão típico é de 1 a 8 MB, então algumas dezenas de milhares de quadros é o limite realista, e bem menos se cada quadro guardar um array local grande. Passe disso e o programa morre:

Segmentation fault (core dumped)

Isso é um estouro de pilha, e há duas formas de conseguir um:

Recursão infinita - um caso base ausente ou inalcançável. Isso é um bug, e o travamento é imediato:

int bad(int n) {
    return bad(n - 1);      /* sem caso base - trava numa fracao de segundo */
}

Correta, mas profunda demais - recursar uma vez por elemento numa lista de um milhão de itens. A lógica está certa; a abordagem não cabe na pilha. Reescreva como um laço, ou reestruture para que a profundidade seja logarítmica (recursar sobre metades, como fazem a busca binária e o merge sort, dá uma profundidade de cerca de 20 para um milhão de itens).

Alguns compiladores conseguem transformar a recursão de cauda - em que a chamada recursiva é a última coisa que a função faz, sem trabalho pendente depois dela - num laço, reutilizando um único quadro. O countdown acima é recursivo de cauda; factorial não é, porque a multiplicação ainda precisa acontecer depois que a chamada retorna. Mas o C não exige essa otimização, então ela pode ou não acontecer dependendo do compilador e das flags. Nunca escreva um C que só funciona porque o otimizador eliminou uma chamada de cauda.

Onde a recursão realmente vence

Toda função recursiva pode ser reescrita como um laço, e para contagens simples o laço é claramente melhor. A recursão se justifica quando os próprios dados são recursivos - quando uma estrutura contém cópias menores de si mesma.

A busca binária é um exemplo limpo: procure numa metade, depois na metade dessa metade.

São dois casos base aqui, o que é normal: um para o sucesso e outro para o esgotamento. A profundidade é de cerca de log₂(n), então até um bilhão de elementos precisa de apenas trinta quadros.

Outros lugares em que a recursão é o encaixe natural: percorrer uma árvore ou uma lista encadeada, varrer diretórios, analisar expressões aninhadas e ordenações de dividir-para-conquistar como quicksort e merge sort. Em todos eles o código recursivo é mais curto e mais claro que o laço com pilha explícita que o substituiria.

Recursão ou laço?

Use um laco quando       o problema e linear - contar, somar, varrer
Use recursao quando      os dados sao aninhados - arvores, estruturas aninhadas, dividir para conquistar
Reescreva a recursao     se a profundidade puder crescer sem limite com o tamanho da entrada
Nunca use recursao       quando os subproblemas se sobrepoem, a menos que voce memoize

Duas notas práticas. Chamadas recursivas custam um pouco mais que uma iteração de laço - um quadro para empilhar e desempilhar a cada vez - então para laços quentes e simples a versão iterativa vence tanto em velocidade quanto em memória. E depurar é diferente: um rastro de pilha de uma recursão profunda são centenas de quadros idênticos, então imprima o parâmetro na entrada (como faz o contador calls acima) quando algo não estiver terminando.

Escrevendo uma função recursiva: uma lista de conferência

  1. Ache o caso base primeiro. Qual é a menor entrada, e qual é a resposta dela? Se você não consegue nomeá-la, a função não pode ser escrita.
  2. Assuma que a chamada recursiva funciona. Não a percorra mentalmente - confie que factorial(n - 1) devolve (n-1)! e escreva o único passo que transforma isso na resposta.
  3. Confira se todo caminho encolhe. Cada chamada recursiva precisa se mover em direção ao caso base para toda entrada possível, incluindo 0 e negativos.
  4. Confira a profundidade. Grosso modo, quantos quadros de profundidade isso vai atingir com dados reais? Milhares tudo bem; milhões não.
  5. Confira a sobreposição. Se o mesmo subproblema é calculado duas vezes, você precisa de memoização ou de um laço.

Perguntas frequentes

O que é recursão em C?

É uma função que chama a si mesma para resolver uma versão menor do mesmo problema. Toda função recursiva precisa de duas coisas: um caso base que retorna sem recursar, e um caso recursivo que se aproxima mensuravelmente dele. Sem o caso base as chamadas nunca param e o programa trava com um estouro de pilha.

Como escrever uma função fatorial em C?

int factorial(int n) { if (n <= 1) return 1; return n * factorial(n - 1); }. O caso base trata 0 e 1, e cada chamada recursiva reduz n em um até chegar lá. Note que int transborda em 13! - use unsigned long long para valores maiores.

Por que o Fibonacci recursivo é tão lento em C?

Porque fib(n) chama fib(n-1) e fib(n-2), que recalculam os mesmos subproblemas repetidamente - o número de chamadas cresce exponencialmente, então fib(50) levaria anos. Reescrevê-lo como um laço que guarda os dois últimos valores o torna linear e instantâneo.

O que causa um estouro de pilha na recursão em C?

Cada chamada ocupa um quadro de memória na pilha para seus parâmetros e variáveis locais, e a pilha tem só alguns megabytes. Um caso base ausente ou inalcançável significa recursão infinita e travamento imediato; mesmo uma recursão correta que desce centenas de milhares de níveis pode esgotar a pilha.

Coddy programming languages illustration

Aprenda a programar com o Coddy

COMEÇAR