NOTAS DE AULA – ALGORITMOS E PROGRAMAÇÃO DE COMPUTADORES Prof. Rodrigo de Oliveira 59 Recursão • • • • A maioria dos programas possui frações de código que se repetem várias vezes. Isso é necessário principalmente para cálculos matemáticos. o Exemplo: 2×3 = 2 + (2 × 2) = 2 + 2 + (2 × 1) = 6 A linguagem de programação C fornece três estruturas de repetição para realizar cálculos iterativos: for, while e do-while. No entanto, para casos mais complexos que o exemplo anterior, a leitura desses códigos nem sempre é muito simples para a identificação de erros. Recursão é um conceito fundamental em matemática e ciência da computação. Uma função recursiva é toda aquela que chama a si mesma dentro do corpo de sua função Exemplo: Tente enxergar a recursão transformando uma potência de expoente alto em outra de expoente menor usando a própria teoria de potenciação: 34 = 3 × 33 = 3 × 3 × 32 = 3 × 3 × 3 × 31 = 81 Pode-se dizer que, para todo inteiro positivo x e y: x y = x × x y −1 . Logo, para uma função recursiva: int potencia(int base, int expoente) que calcula o resultado de se elevar a base ao expoente, é correto afirmar que: resultado = base * potencia( base, (expoente – 1) ); Pelo código anterior, a cada nova chamada recursiva da função potencia(), o expoente será menor em uma unidade. Esse processo continua até que o expoente seja igual a 1, quando já se tem a resposta, visto que todo número elevado a 1 é igual a ele mesmo. A função recursiva fica como a seguir: usando recursão usando iteração for 1: int pot(int base, int exp) 2: { 3: if (exp == 1) 4: return base; 5: else 6: return base * pot(base, exp – 1); 7: } 1: int pot (int base, int exp) 2: { 3: int i, resultado = 1; 4: 5: for (i = 0; i < exp; i++) 6: resultado *= base; 7: return resultado; 8: } iguais Note que esta é uma função válida apenas para potências com expoente maior que zero (como todo número elevado a zero é igual a um, a função também pode receber expoente zero se a condição da linha 3 for alterada para checar se o expoente é zero e retornar o valor 1 ao invés da base). NOTAS DE AULA – ALGORITMOS E PROGRAMAÇÃO DE COMPUTADORES Prof. Rodrigo de Oliveira • 60 As abordagens da solução de problemas com recursão têm diversos elementos em comum. Genericamente, um módulo recursivo segue o algoritmo abaixo: if (<condição de parada>) { // Resolva } else { // Divida o problema num caso mais simples utilizando recursão } Note que, sem a condição de parada, o problema entraria em “loop”, dividindo o problema infinitas vezes sem nunca parar. Seguindo a idéia do algoritmo proposto, a função recursiva só sabe como resolver o(s) caso(s) mais simples (caso base identificado na condição de parada). Na função que calcula potência, o caso base é elevar um número qualquer ao expoente 1 e retornar o próprio número (linhas 3 e 4). Se a função é chamada com um problema mais complexo, a função divide o problema em dois pedaços conceituais: um pedaço que a função sabe como resolver (caso base) e um pedaço que a função não sabe como resolver (linhas 5 e 6). Como esse novo problema se parece com o problema original, a função faz uma chamada a si mesma para resolver o problema menor (etapa de recursão). A etapa de recursão é executada enquanto a chamada original para a função ainda está ativa, podendo resultar ainda em muitas outras chamadas recursivas, sempre dividindo cada sub-problema novo em dois pedaços conceituais. Para que a recursão termine em algum momento, a seqüência de problemas cada vez menores deve convergir para o caso base. Nesse ponto, a função reconhece o caso base, devolve um resultado para a cópia da função anterior e uma seqüência de devoluções se segue até a chamada original da função devolver o resultado final. • Outro problema comumente usado para exemplificar recursão é a série de Fibonacci: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ... Ela inicia com 0 e 1 e tem a propriedade de que cada número de Fibonacci subseqüente é a soma dos dois números que o precedem. A série é definida recursivamente como: fibonacci(0) = 0 fibonacci(1) = 1 fibonacci(n) = fibonacci(n – 1) + fibonacci(n – 2) Note que neste caso temos dois casos base: fibonacci(0) e fibonacci(1). A função recursiva que retorna o iésimo elemento da série de Fibonacci é dada a seguir: 1: int fibonacci(int num) 2: { 3: if (num == 0) 4: return 0; 5: else if (num == 1) 6: return 1; 7: else 8: return fibonacci(num - 1) + fibonacci(num - 2); 9: } NOTAS DE AULA – ALGORITMOS E PROGRAMAÇÃO DE COMPUTADORES Prof. Rodrigo de Oliveira 61 Esquematização de fibonacci(4): fib(4) = fib(3) + fib(2) fib(3) = fib(2) + fib(1) fib(2) = fib(1) + fib(0) fib(1) = 1 fib (2) = fib (1) + fib (0) fib(1) = 1 fib(0) = 0 fib(1) = 1 fib(0) = 0 fib(2) = 1 + 0 = 1 fib(2) = 1 + 0 = 1 fib(3) = 1 + 1 = 2 fib(4) = 2 + 1 = 3 • Cada invocação à função fibonacci que não corresponde a um dos casos base, resulta em mais duas chamadas recursivas. Esse número de chamadas cresce rapidamente mesmo para números bem pequenos da série. Por exemplo, o fibonacci de 31 exige 4.356.617 chamadas à função enquanto que o fibonacci de 32 exige 7.049.155, ou seja, 2.692.538 chamadas adicionais à função. Logo, é interessante evitar programas recursivos no estilo de Fibonacci que resultam em uma “explosão” exponencial de chamadas. Isso porque, a cada chamada recursiva da função, ela é adicionada a uma pilha; da mesma forma, a cada término da função, ela é desempilhada. Esse empilhamento e desempilhamento repetitivo consome tempo, fazendo com que o mesmo programa implementado iterativamente venha a ser mais rápido que o recursivo (embora, por outro lado, provavelmente não seja de compreensão e manutenção tão simples). Além disso, a pilha de funções apresenta um tamanho máximo, limitando assim o uso de recursões que mantenham muitas funções na pilha (como o fibonacci de um número grande). Outro problema recursivo é o fatorial de um inteiro n não-negativo, escrito n! = n × (n − 1) × (n − 2) × K × 1 . int fatorial(int num) { if (num <= 1) return num; else return num * fatorial(num - 1); } Recursão versus Iteração • Tem-se a seguir uma comparação entre as duas abordagens de implementação para que o programador possa escolher qual a ideal de acordo com uma situação em particular: Recursão Estruturas de seleção if, if-else ou switch. Repetição por chamadas de funções repetidas. Termina quando um caso base é reconhecido. Pode ocorrer infinitamente. Lento. Soluções simples e de fácil manutenção. Iteração Estruturas de repetição for, while ou do-while. Repetição explícita. Termina quando teste do laço falha. Pode ocorrer infinitamente. Rápido. Soluções complicadas e de difícil manutenção. NOTAS DE AULA – ALGORITMOS E PROGRAMAÇÃO DE COMPUTADORES Prof. Rodrigo de Oliveira 62 Exercícios: Faça funções que usem recursão comum para resolver os seguintes problemas. a) Multiplicação – Recebe dois números positivos não nulos a e b e retorna o resultado de a × b . int multiplicacao(int a, int b) { if (b == 1) return a; else return a + multiplicacao( a, (b – 1) ); } b) Produtório – Recebe dois números m e n tais que m <= n e retorna o produtório de todos os números no intervalo [m,n]. c) Maior – Recebe um ponteiro para vetor de inteiros e seu tamanho e retorna o maior da lista. d) Somatório de vetor – Recebe um ponteiro para um vetor de inteiros e seu tamanho e retorna o somatório de todos os elementos do vetor. e) Somatório de intervalo de vetor – Recebe um ponteiro para um vetor de inteiros, seu tamanho, um índice de início e um de fim e retorna o somatório de todos os elementos do intervalo [início, fim] do vetor. Observação: sempre serão passados valores tais que 0 <= início <= fim < tamanho. f) Pertence - Recebe um número, um ponteiro para vetor de inteiros e seu tamanho e retorna 1 se o número pertence ao vetor e 0 caso contrário. g) Ocorrências – Recebe um número, um ponteiro para vetor de inteiros e seu tamanho e retorna o número de ocorrências desse número no vetor. h) Torre de Hanói (desafio) – Em um templo no Extremo Oriente, os sacerdotes estavam tentando mover uma pilha de discos de um pino para outro. A pilha inicial tinha 64 discos sobrepostos em um pino e ordenados de baixo para cima por tamanho decrescente. Os sacerdotes tentavam mover a pilha desse pino A para um terceiro pino C usando um segundo pino B para conter os discos temporariamente. Apenas um disco podia ser movido por vez e em nenhum momento um disco maior podia ser colocado sobre um disco menor. Dica: Pense no mais simples possível: mover 1 disco apenas. Basta tirá-lo do pino A e colocá-lo no C. Agora pense em mover 2 discos. Primeiro move-se o disco 2 de A para B e caímos no caso anterior (pois o pino A agora só tem o disco 1). Então move-se o disco 1 de A para C e o disco 2 de B para C. Veja que mover n discos pode ser visualizado em termos de mover n – 1 discos como a seguir: ● Mover n – 1 discos do pino A para o pino B, utilizando o pino C como armazenamento temporário; ● Mover o último disco (o maior) do pino A para o pino C; ● Mover os n – 1 discos do pino B para o pino C, utilizando o pino A como armazenamento temporário; Implemente a função com a seguinte declaração: void hanoi (char a, char b, char c, int discos) A B C