Enquanto começava a aprender cocô, me deparei com o termo recursivo da cauda . O que isso significa exatamente?
1695
Enquanto começava a aprender cocô, me deparei com o termo recursivo da cauda . O que isso significa exatamente?
Respostas:
Considere uma função simples que adicione os primeiros N números naturais. (por exemplo
sum(5) = 1 + 2 + 3 + 4 + 5 = 15
).Aqui está uma implementação JavaScript simples que usa recursão:
Se você ligou
recsum(5)
, é isso que o interpretador JavaScript avaliaria:Observe como todas as chamadas recursivas devem ser concluídas antes que o intérprete JavaScript comece a realmente executar o trabalho de calcular a soma.
Aqui está uma versão recursiva da cauda da mesma função:
Aqui está a sequência de eventos que ocorreriam se você chamasse
tailrecsum(5)
(o que seria efetivamentetailrecsum(5, 0)
devido ao segundo argumento padrão).No caso recursivo de cauda, com cada avaliação da chamada recursiva, o
running_total
é atualizado.Nota: A resposta original usou exemplos do Python. Eles foram alterados para JavaScript, pois os intérpretes Python não oferecem suporte à otimização de chamada de cauda . No entanto, embora a otimização da chamada de cauda faça parte da especificação do ECMAScript 2015 , a maioria dos intérpretes de JavaScript não a suporta .
fonte
tail recursion
pode ser alcançado em um idioma que não otimiza as chamadas de cauda.Na recursão tradicional , o modelo típico é que você executa suas chamadas recursivas primeiro e depois pega o valor de retorno da chamada recursiva e calcula o resultado. Dessa maneira, você não obtém o resultado do seu cálculo até retornar de cada chamada recursiva.
Na recursão final , você executa seus cálculos primeiro e depois executa a chamada recursiva, passando os resultados da sua etapa atual para a próxima etapa recursiva. Isso resulta na última declaração na forma de
(return (recursive-function params))
. Basicamente, o valor de retorno de qualquer etapa recursiva é o mesmo que o valor de retorno da próxima chamada recursiva .A conseqüência disso é que, quando você estiver pronto para executar sua próxima etapa recursiva, não precisará mais do quadro de pilha atual. Isso permite alguma otimização. De fato, com um compilador adequadamente escrito, você nunca deve ter um snicker de estouro de pilha com uma chamada recursiva final. Simplesmente reutilize o quadro de pilha atual para a próxima etapa recursiva. Tenho certeza que Lisp faz isso.
fonte
Um ponto importante é que a recursão da cauda é essencialmente equivalente a loop. Não é apenas uma questão de otimização do compilador, mas um fato fundamental sobre a expressividade. Isso acontece nos dois sentidos: você pode fazer qualquer loop do formulário
onde
E
eQ
são expressões eS
é uma sequência de instruções e a transforma em uma função recursiva de caudaClaro,
E
,S
, eQ
tem que ser definida para calcular algum valor interessante sobre algumas variáveis. Por exemplo, a função de loopé equivalente às funções recursivas da cauda
(Esse "empacotamento" da função recursiva da cauda com uma função com menos parâmetros é um idioma funcional comum.)
fonte
else { return k; }
pode ser alterado parareturn k;
Este trecho do livro Programação em Lua mostra como fazer uma recursão de cauda adequada (em Lua, mas também deve se aplicar ao Lisp) e por que é melhor.
Veja bem, quando você faz uma chamada recursiva como:
Isso não é recursivo final, porque você ainda tem coisas a fazer (adicione 1) nessa função depois que a chamada recursiva é feita. Se você inserir um número muito alto, provavelmente causará um estouro de pilha.
fonte
Usando recursão regular, cada chamada recursiva envia outra entrada para a pilha de chamadas. Quando a recursão estiver concluída, o aplicativo deverá exibir cada entrada novamente.
Com a recursão final, dependendo do idioma, o compilador pode recolher a pilha em uma entrada, para economizar espaço na pilha ... Uma consulta recursiva grande pode realmente causar um estouro de pilha.
Basicamente, as recursões de cauda podem ser otimizadas na iteração.
fonte
O arquivo do jargão tem a dizer sobre a definição de recursão da cauda:
recursão da cauda / n./
Se você ainda não está cansado disso, consulte a recursão da cauda.
fonte
Em vez de explicar com palavras, aqui está um exemplo. Esta é uma versão do esquema da função fatorial:
Aqui está uma versão do fatorial que é recursiva da cauda:
Você notará na primeira versão que a chamada recursiva ao fato é alimentada na expressão de multiplicação e, portanto, o estado deve ser salvo na pilha ao fazer a chamada recursiva. Na versão recursiva de cauda, não há outra expressão S aguardando o valor da chamada recursiva e, como não há mais trabalho a ser feito, o estado não precisa ser salvo na pilha. Como regra, as funções recursivas de cauda do esquema usam espaço de pilha constante.
fonte
list-reverse
procedimento de mutação de cauda recursiva de lista recursiva será executado no espaço de pilha constante, mas criará e aumentará uma estrutura de dados no heap. Uma travessia de árvore pode usar uma pilha simulada, em um argumento adicional. etc.A recursão de cauda refere-se à última chamada lógica na última instrução lógica do algoritmo recursivo.
Normalmente, na recursão, você tem um caso base que é o que interrompe as chamadas recursivas e começa a abrir a pilha de chamadas. Para usar um exemplo clássico, embora seja mais C-ish que Lisp, a função fatorial ilustra a recursão da cauda. A chamada recursiva ocorre após a verificação da condição do caso base.
A chamada inicial para o fatorial seria
factorial(n)
ondefac=1
(valor padrão) e n é o número para o qual o fatorial deve ser calculado.fonte
else
é a etapa que você pode chamar de "caso base", mas se estende por várias linhas. Estou entendendo mal você ou minha suposição está correta? A recursão da cauda só é boa para um forro?factorial
exemplo é apenas o exemplo simples clássico, só isso.Isso significa que, em vez de precisar pressionar o ponteiro de instrução na pilha, você pode simplesmente pular para o topo de uma função recursiva e continuar a execução. Isso permite que as funções sejam executadas indefinidamente sem sobrecarregar a pilha.
Eu escrevi um blog de post sobre o assunto, que tem exemplos gráfica do que os quadros de pilha parecer.
fonte
Aqui está um trecho de código rápido que compara duas funções. A primeira é a recursão tradicional para encontrar o fatorial de um determinado número. O segundo usa recursão da cauda.
Muito simples e intuitivo de entender.
Uma maneira fácil de saber se uma função recursiva é uma recursiva de cauda é se ela retorna um valor concreto no caso base. Significando que ele não retorna 1 ou verdadeiro ou algo assim. É mais do que provável que retorne alguma variante de um dos parâmetros do método.
Outra maneira de saber é se a chamada recursiva está livre de acréscimos, aritmética, modificação, etc ... Ou seja, não é senão uma chamada recursiva pura.
fonte
A melhor maneira de entender
tail call recursion
é um caso especial de recursão em que a última chamada (ou a chamada final ) é a própria função.Comparando os exemplos fornecidos no Python:
^ RECURSÃO
^ RECURSÃO DA CAUDA
Como você pode ver na versão recursiva geral, a chamada final no bloco de código é
x + recsum(x - 1)
. Então, depois de chamar orecsum
método, há outra operação que éx + ..
.No entanto, na versão recursiva final, a chamada final (ou a chamada final) no bloco de código é o
tailrecsum(x - 1, running_total + x)
que significa que a última chamada é feita para o próprio método e nenhuma operação depois disso.Esse ponto é importante porque a recursão de cauda, como vista aqui, não está aumentando a memória, porque quando a VM subjacente vê uma função se chamando em uma posição de cauda (a última expressão a ser avaliada em uma função), elimina o quadro de pilha atual, que é conhecido como Tail Call Optimization (TCO).
EDITAR
NB Lembre-se de que o exemplo acima está escrito em Python, cujo tempo de execução não suporta o TCO. Este é apenas um exemplo para explicar o ponto. O TCO é suportado em idiomas como Scheme, Haskell etc.
fonte
Em Java, aqui está uma possível implementação recursiva da função Fibonacci:
Compare isso com a implementação recursiva padrão:
fonte
iter
paraacc
quandoiter < (n-1)
.Eu não sou um programador Lisp, mas acho que isso vai ajudar.
Basicamente, é um estilo de programação em que a chamada recursiva é a última coisa que você faz.
fonte
Aqui está um exemplo do Common Lisp que faz fatoriais usando recursão de cauda. Devido à natureza sem empilhamento, era possível realizar cálculos fatoriais insanamente grandes ...
E então, por diversão, você pode tentar
(format nil "~R" (! 25))
fonte
Em resumo, uma recursão de cauda tem a chamada recursiva como a última instrução na função, para que não precise aguardar a chamada recursiva.
Portanto, essa é uma recursão final, ou seja, N (x - 1, p * x) é a última instrução na função em que o compilador é inteligente para descobrir que pode ser otimizado para um loop for (fatorial). O segundo parâmetro p carrega o valor intermediário do produto.
Essa é a maneira não recursiva de escrever a função fatorial acima (embora alguns compiladores C ++ possam otimizá-la de qualquer maneira).
mas isso não é:
Eu escrevi um longo post intitulado " Entendendo a recursão da cauda - Visual Studio C ++ - Exibição de montagem "
fonte
aqui está uma versão do Perl 5 da
tailrecsum
função mencionada anteriormente.fonte
Este é um trecho da Estrutura e Interpretação de Programas de Computador sobre recursão da cauda.
fonte
A função recursiva é uma função que chama por si só
Ele permite que os programadores escrevam programas eficientes usando uma quantidade mínima de código .
A desvantagem é que eles podem causar loops infinitos e outros resultados inesperados, se não forem escritos corretamente .
Explicarei as funções Recursiva Simples e Recursiva de Cauda
Para escrever uma função recursiva simples
Do exemplo dado:
Do exemplo acima
É o fator decisivo quando sair do loop
O processamento real deve ser feito
Deixe-me interromper a tarefa, uma a uma, para facilitar o entendimento.
Vamos ver o que acontece internamente se eu correr
fact(4)
If
loop falhar, então ele vai paraelse
loop, então ele retorna4 * fact(3)
Na memória da pilha, temos
4 * fact(3)
Substituindo n = 3
If
loop falha, então ele vai paraelse
loopentão retorna
3 * fact(2)
Lembre-se de que chamamos `` `` 4 * fact (3) ``
A saída para
fact(3) = 3 * fact(2)
Até agora, a pilha tem
4 * fact(3) = 4 * 3 * fact(2)
Na memória da pilha, temos
4 * 3 * fact(2)
Substituindo n = 2
If
loop falha, então ele vai paraelse
loopentão retorna
2 * fact(1)
Lembre-se de que ligamos
4 * 3 * fact(2)
A saída para
fact(2) = 2 * fact(1)
Até agora, a pilha tem
4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)
Na memória da pilha, temos
4 * 3 * 2 * fact(1)
Substituindo n = 1
If
loop é verdadeiroentão retorna
1
Lembre-se de que ligamos
4 * 3 * 2 * fact(1)
A saída para
fact(1) = 1
Até agora, a pilha tem
4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1
Finalmente, o resultado de fato (4) = 4 * 3 * 2 * 1 = 24
A recursão da cauda seria
If
loop falhar, então ele vai paraelse
loop, então ele retornafact(3, 4)
Na memória da pilha, temos
fact(3, 4)
Substituindo n = 3
If
loop falha, então ele vai paraelse
loopentão retorna
fact(2, 12)
Na memória da pilha, temos
fact(2, 12)
Substituindo n = 2
If
loop falha, então ele vai paraelse
loopentão retorna
fact(1, 24)
Na memória da pilha, temos
fact(1, 24)
Substituindo n = 1
If
loop é verdadeiroentão retorna
running_total
A saída para
running_total = 24
Finalmente, o resultado de fato (4,1) = 24
fonte
A recursão da cauda é a vida que você está vivendo agora. Você recicla constantemente o mesmo quadro de pilha, repetidamente, porque não há motivos ou meios para retornar a um quadro "anterior". O passado acabou e acabou, para que possa ser descartado. Você obtém um quadro, movendo-se para sempre no futuro, até que seu processo inevitavelmente morra.
A analogia é interrompida quando você considera que alguns processos podem utilizar quadros adicionais, mas ainda são considerados recursivos de cauda se a pilha não crescer infinitamente.
fonte
Considere o problema de calcular fatorial de um número.
Uma abordagem direta seria:
Suponha que você chame fatorial (4). A árvore de recursão seria:
A profundidade máxima de recursão no caso acima é O (n).
No entanto, considere o seguinte exemplo:
A árvore de recursão para factTail (4) seria:
Aqui também, a profundidade máxima da recursão é O (n), mas nenhuma das chamadas adiciona qualquer variável extra à pilha. Portanto, o compilador pode acabar com uma pilha.
fonte
A recursão da cauda é bem rápida em comparação com a recursão normal. É rápido porque a saída da chamada dos ancestrais não será gravada na pilha para manter o controle. Mas, na recursão normal, todos os ancestrais chamam a saída escrita em pilha para manter o controle.
fonte
Uma função recursiva da cauda é uma função recursiva em que a última operação realizada antes do retorno é fazer a chamada da função recursiva. Ou seja, o valor de retorno da chamada de função recursiva é retornado imediatamente. Por exemplo, seu código ficaria assim:
Compiladores e intérpretes que implementam otimização de chamada de cauda ou eliminação de chamada de cauda podem otimizar o código recursivo para evitar estouros de pilha. Se o seu compilador ou intérprete não implementar a otimização de chamada de cauda (como o interpretador CPython), não haverá benefício adicional em escrever seu código dessa maneira.
Por exemplo, esta é uma função fatorial recursiva padrão no Python:
E esta é uma versão recursiva da função fatorial chamada de cauda:
(Observe que, embora esse seja o código Python, o interpretador CPython não faz otimização de chamada de cauda, portanto, organizar seu código dessa maneira não confere nenhum benefício em tempo de execução.)
Talvez você precise tornar seu código um pouco mais ilegível para fazer uso da otimização de chamada de cauda, como mostrado no exemplo fatorial. (Por exemplo, o caso base agora é um pouco pouco intuitivo e o
accumulator
parâmetro é efetivamente usado como uma espécie de variável global.)Mas o benefício da otimização da chamada final é que ela evita erros de estouro de pilha. (Observarei que você pode obter esse mesmo benefício usando um algoritmo iterativo em vez de um algoritmo recursivo.)
Estouros de pilha são causados quando a pilha de chamadas possui muitos objetos de quadro pressionados. Um objeto de quadro é empurrado para a pilha de chamadas quando uma função é chamada e sai da pilha de chamadas quando a função retorna. Os objetos de quadro contêm informações como variáveis locais e para qual linha de código retornar quando a função retornar.
Se sua função recursiva fizer muitas chamadas recursivas sem retornar, a pilha de chamadas poderá exceder o limite de objetos do quadro. (O número varia de acordo com a plataforma; no Python, são 1000 objetos de quadro por padrão.) Isso causa um erro de estouro de pilha . (Ei, é daí que o nome deste site vem!)
No entanto, se a última coisa que sua função recursiva fizer é fazer a chamada recursiva e retornar seu valor de retorno, não há motivo para manter o objeto de quadro atual necessário para permanecer na pilha de chamadas. Afinal, se não houver código após a chamada de função recursiva, não há razão para se manter nas variáveis locais do objeto de quadro atual. Portanto, podemos nos livrar do objeto de quadro atual imediatamente, em vez de mantê-lo na pilha de chamadas. O resultado final disso é que sua pilha de chamadas não aumenta de tamanho e, portanto, não pode exceder a pilha.
Um compilador ou intérprete deve ter a otimização de chamada de cauda como um recurso para poder reconhecer quando a otimização de chamada de cauda pode ser aplicada. Mesmo assim, você pode ter reorganizado o código em sua função recursiva para fazer uso da otimização de chamada de cauda, e depende de você se essa diminuição potencial na legibilidade valer a otimização.
fonte
Para entender algumas das principais diferenças entre recursão de chamada final e recursão sem chamada final, podemos explorar as implementações .NET dessas técnicas.
Aqui está um artigo com alguns exemplos em C #, F # e C ++ \ CLI: Adventures in Tail Recursion in C #, F # e C ++ \ CLI .
C # não otimiza para recursão de chamada de cauda, enquanto F # faz.
As diferenças de princípio envolvem loops vs. cálculo Lambda. O C # é projetado com loops em mente, enquanto o F # é construído a partir dos princípios do cálculo Lambda. Para um livro muito bom (e gratuito) sobre os princípios do cálculo Lambda, consulte Estrutura e interpretação de programas de computador, de Abelson, Sussman e Sussman. .
Em relação às chamadas de cauda no F #, para um artigo introdutório muito bom, consulte Introdução detalhada às chamadas de cauda no F # . Finalmente, aqui está um artigo que aborda a diferença entre recursão não-cauda e recursão de chamada de cauda (em F #): recursão de cauda vs. recursão não-cauda em F sharp .
Se você quiser ler sobre algumas das diferenças de design da recursão de chamada de cauda entre C # e F #, consulte Gerando código de opção de chamada de cauda em C # e F # .
Se você deseja saber o que as condições impedem o compilador C # de executar otimizações de chamada de retorno, consulte este artigo: Condições de chamada de retorno do JIT CLR .
fonte
Existem dois tipos básicos de recursões: recursão da cabeça e recursão da cauda.
Retirado deste post super impressionante. Por favor, considere a leitura.
fonte
Recursão significa uma função que se chama. Por exemplo:
Recursão de cauda significa a recursão que conclui a função:
Veja, a última coisa que a função sem fim (procedimento, no jargão do esquema) faz é se chamar. Outro exemplo (mais útil) é:
No procedimento auxiliar, a ÚLTIMA coisa que faz se a esquerda não for nula é chamar a si mesma (DEPOIS de algo e CDR). É basicamente assim que você mapeia uma lista.
A recursão da cauda tem uma grande vantagem de que o intérprete (ou compilador, dependente do idioma e do fornecedor) possa otimizá-lo e transformá-lo em algo equivalente a um loop while. De fato, na tradição Scheme, a maioria dos loop "for" e "while" é feita de maneira recursiva (não existe for and while, tanto quanto eu saiba).
fonte
Esta pergunta tem muitas ótimas respostas ... mas não posso deixar de adotar uma abordagem alternativa sobre como definir "recursão da cauda" ou pelo menos "recursão adequada da cauda". Ou seja: deve-se encará-lo como propriedade de uma expressão específica de um programa? Ou deve-se encará-lo como propriedade de uma implementação de uma linguagem de programação ?
Para mais informações sobre esta última visão, existe um artigo clássico de Will Clinger, "Recursão apropriada da cauda e eficiência espacial" (PLDI 1998), que definiu "recursão apropriada da cauda" como uma propriedade de uma implementação de linguagem de programação. A definição é construída para permitir ignorar os detalhes da implementação (como se a pilha de chamadas é realmente representada pela pilha de tempo de execução ou por uma lista vinculada de quadros alocados por heap).
Para fazer isso, ele usa análise assintótica: não do tempo de execução do programa como normalmente se vê, mas do uso do espaço do programa . Dessa maneira, o uso de espaço de uma lista vinculada alocada ao heap versus uma pilha de chamadas em tempo de execução acaba sendo assintoticamente equivalente; portanto, é possível ignorar os detalhes de implementação da linguagem de programação (um detalhe que certamente importa bastante na prática, mas pode-se confundir bastante as águas quando se tenta determinar se uma determinada implementação está satisfazendo o requisito de ser "recursivo da propriedade") )
O artigo merece um estudo cuidadoso por várias razões:
Ele fornece uma definição indutiva das expressões de cauda e chamadas de cauda de um programa. (Essa definição, e por que essas ligações são importantes, parece ser o assunto da maioria das outras respostas fornecidas aqui.)
Aqui estão essas definições, apenas para fornecer uma amostra do texto:
(uma chamada recursiva final ou, como o jornal diz, "chamada automática" é um caso especial de uma chamada final em que o procedimento é chamado por si próprio.)
Ele fornece definições formais para seis "máquinas" diferentes para avaliar o Core Scheme, em que cada máquina tem o mesmo comportamento observável, exceto a classe de complexidade de espaço assintótico em que cada uma se encontra.
Por exemplo, depois de fornecer definições para máquinas com, respectivamente, 1. gerenciamento de memória baseado em pilha, 2. coleta de lixo, mas sem chamadas finais, 3. coleta de lixo e chamadas finais, o documento continua com estratégias de gerenciamento de armazenamento ainda mais avançadas, como 4. "evlis tail recursion", em que o ambiente não precisa ser preservado durante a avaliação do último argumento de subexpressão em uma chamada de cauda, 5. reduzindo o ambiente de um fechamento apenas às variáveis livres desse fechamento, e 6. chamada semântica de "espaço seguro", conforme definido por Appel e Shao .
Para provar que as máquinas realmente pertencem a seis classes distintas de complexidade espacial, o artigo, para cada par de máquinas em comparação, fornece exemplos concretos de programas que expõem a explosão de espaço assintótico em uma máquina, mas não na outra.
(Lendo minha resposta agora, não tenho certeza se realmente consegui captar os pontos cruciais do artigo de Clinger . Mas, infelizmente, não posso dedicar mais tempo ao desenvolvimento dessa resposta agora.)
fonte
Muitas pessoas já explicaram a recursão aqui. Gostaria de citar algumas reflexões sobre algumas vantagens que a recursão oferece no livro “Concorrência no .NET, padrões modernos de programação simultânea e paralela”, de Riccardo Terrell:
Aqui também estão algumas notas interessantes do mesmo livro sobre recursão da cauda:
fonte