Introdução às funções recursivas
Na introdução às funções você viu que, em computação, a definição de uma função pega emprestada a definição matemática. Também viu que, assim como na matemática, uma função pode chamar outra função. Como você deve ter imaginado, uma função pode chamar a si mesma, é quando tratamos da chamada recursividade. A questão do desempenho de algoritmos recursivos v.s. algoritmos não recursivos não é estudada na introdução, isso é visto em disciplinas mais avançadas. Por enquanto vamos nos concentrar apenas em o que é recursão e como utilizá-la.
A lógica de um algoritmo recursivo é muito similar à lógica da indução matemática. Dependendo da turma e do professor, exercícios de recursividade não são explorados ou são mas muito brevemente. A recomendação é que se resolvam muitos exercícios que possam ser resolvidos tanto de maneira iterativa quanto recursiva para aprender bem o conceito. Opcionalmente você pode resolver alguns exercícios de indução matemática para ver a relação da indução com a recursividade.
Na apostila do IME-USP os exercícios de recorrência tratam de soluções recursivas.
A lógica básica é: se um termo de uma sequência pode ser expresso em função de outro termo, por exemplo uma progressão aritmética ou geométrica, então podemos escrever um algoritmo recursivo. Em outras palavras: se um passo seguinte pode ser expresso em função do passo anterior ou de um dos passos anteriores, então podemos transformar um algoritmo iterativo num algoritmo recursivo.
Erros de lógica:
- Confundir iteração com recursão;
- Confundir uma função que chama a si mesma com chamar a função com si própria como argumento;
- Recursão infinita ou excessiva.
- Soma dos n primeiros números naturais, agora com função recursiva:
int soma_n(int n) { if (n == 0) return n; return n + soma_n(n - 1); }
Considere a seguinte lógica:
- A soma de n números é igual a somar n com a soma de todos os anteriores a n;
- Matematicamente, a soma é n + S(n - 1);
- Mas a soma S(n - 1) por sua vez, recorrendo a ela própria, é S(S(n - 1) - 1);
- Até onde isso continua? Até S(1) ou S(0), não há mais para onde ir, a soma é igual a ela própria nesses casos.
Veja como escrever isso em notação de função matemática:
[math]\displaystyle{ f(n) = \begin{cases} n & \text{se} & n = 0 \\ n + f(n - 1) & \text{se} & n \gt 0 \end{cases} }[/math]
Perceba que isso é totalmente diferente de usar um laço. Há repetição? Não, o que há é uma sucessão de chamadas da função de si própria até que um limite é atingido, o caso base. Num laço há uma operação de soma que é repetida até que uma variável acumule a soma desejada. Na função recursiva o que ocorre é uma série de chamadas em que o resultado de uma depende do resultado da seguinte, até que no caso base a chamada pára em si própria. Em outras palavras, o que era um laço com um contador de iterações, passa a ser uma função recursiva com um contador de níveis da função dentro de si mesma.
Veja como isso ocorre no computador:
- n = 10. É o caso base? Não. Então 10 + f(9);
- 9 é o caso base? Não. Então 9 + f(8);
- Continue na sucessão até que atinja 1 + f(0);
- Ao final, o teremos: soma = 10 + 9 + ... + 1 + f(0);
- No papel, o resultado seria este: f(10) = 10 + f(9) = 9 + f(8) = ... = 1 + f(0) = 0.
Uma analogia: coloque um espelho em frente ao outro, os reflexos tornam-se progressivamente menores, convergindo para um pequeno ponto no infinito.
- Calcule o enésimo termo da sequência de Fibonacci
int fibonacci(int n) { if (n == 0) return 0; if (n == 1) return 1; return fibonacci(n - 1) + fibonacci(n - 2); }
Em notação de função matemática:
[math]\displaystyle{ f(n) = \begin{cases} n & \text{se} & n = 1 \\ n = 0 & \text{se} & n = 0 \\ n + f(n - 1) + f(n - 2) & \text{se} & n \gt 2 \end{cases} }[/math]
Atenção! o n se refere à posição do número na sequência, não ao próprio número da sequência.
A simulação segue a mesma lógica do caso da soma dos n primeiros inteiros. Como exercício, tente fazer para o cálculo do fatorial de n. Outro exercício: escreva uma função recursiva que calcula xn.
O caso da sequência de Fibonacci ilustra um caso onde a implementação da recursão é mais prática, mas em troca a execução é muito pior do que a versão não recursiva. Isto não é uma regra. Dependendo do problema e da implementação um algoritmo recursivo pode ser melhor, pior ou mesmo indiferente em relação à versão iterativa. Pior e melhor aqui referem-se à custo de memória e tempo de execução.
Versão com função iterativa:
int fibonacci(int n) { int i, auxiliar, atual = 1, anterior = 0; if (n == 0) return 0; if (n == 1) return 1; for (i = 2; i <= n; i++) { auxiliar = atual; atual = atual + anterior; anterior = auxiliar; } return atual; }
A versão iterativa é muito mais eficiente por uma simples razão: uma variável armazena a soma parcial da série para o próximo termo. A versão recursiva não faz isso, recorre a si mesma para recalcular a série inteira desde o começo a cada novo termo.
- Imprima um vetor com função recursiva
/* recebe o vetor v e o índice do último elemento */ int imprima (int vetor[], int indice) { printf("\n%d", vetor[indice]); if (n == 0) return 0; return imprima (vetor, indice - 1); }
A função imprime em ordem decrescente, do último para o primeiro índice. Cuidado! Os índices começam do n - 1
. Portanto, se o vetor tiver 10 elementos e a chamada da função for feita com indice = 10
, o primeiro elemento será v[10]
, enquanto o último elemento, v[0]
, não será impresso.
Como exercício, tente modificar a função de tal forma que n seja o número de elementos a serem impressos e não o índice do último elemento.
Mesma impressão, mas em ordem crescente:
/* recebe um vetor e o índice do último elemento para imprimir nivel é um contador de recorrências da função */ int imprima (int v[], int indice, int nivel) { printf("\n%d", v[indice]); if (nivel == 0) return 0; return imprima (v, indice + 1, nivel - 1); }
Para imprimir em ordem crescente a função precisa de um parâmetro extra, um contador de níveis da função dentro de si mesma. Para imprimir os 10 primeiros elementos do vetor, 'n' precisa começar do zero e nível começar do nove. Perceba que a chamada da função acabará se traduzindo literalmente em "imprima v do 0 (índice) até o 9 (níveis da função dentro de si mesma)".
Repita o exercício anterior, mas agora tente fazer a função ser lida como "imprima v do primeiro elemento até o último".
Uma analogia: pense num prédio. Na impressão em ordem decrescente, começamos no nono (nível mais alto) andar e fomos descendo até o nível zero (caso base), o nível do solo. Cada nível correspondia ao índice do vetor. Com a impressão em ordem crescente, ainda começamos do nível mais alto e descemos até o zero, mas a cada nível, ao invés de contarmos "nono andar, oitavo andar, ..." estamos contando "avançando um degrau, dois degraus, ...".
- Leia um vetor e devolva um se todos os valores forem iguais ou zero se não forem
/* Recebe dois valores, se forem iguais devolve 1. Se não forem, devolve 0. */ int compare (int a, int b) { if (a == b) return 1; return 0; } /* Recebe um vetor e o índice do último elemento. Se dois elementos consecutivos forem diferentes, devolve 0. Se o índice chegar a 1, significa que todos os elementos são iguais. Caso contrário, função entra em mais um nível da recursão */ int procure (int v[], int i) { if (compare(v[i], v[i - 1]) == 0) return 0; if (i == 1) return 1; return procure(v, i - 1); }
São duas funções, uma comum e outra recursiva. Isso é devido à própria definição de uma função: a função não pode devolver dois valores ao mesmo tempo, ela própria e mais um ou zero dependendo de se todos os elementos do vetor são iguais ou não. Daí a necessidade de se dividir o problema entre duas funções, uma que faz a comparação e outra que faz a busca recursiva. Perceba que existem duas formas da recursão ser interrompida: dois elementos consecutivos são diferentes ou chegou ao fim do vetor (caso base); as duas condicionais nesta ordem! Resolvendo o problema de modo iterativo teremos as mesmas duas formas de interromper a iteração do laço.