Enquanto começava a aprender cocô, me deparei com o termo recursivo da cauda . O que isso significa exatamente?
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:
function recsum(x) {
if (x === 1) {
return x;
} else {
return x + recsum(x - 1);
}
}
Se você ligou recsum(5)
, é isso que o interpretador JavaScript avaliaria:
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
15
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:
function tailrecsum(x, running_total = 0) {
if (x === 0) {
return running_total;
} else {
return tailrecsum(x - 1, running_total + x);
}
}
Aqui está a sequência de eventos que ocorreriam se você chamasse tailrecsum(5)
(o que seria efetivamente tailrecsum(5, 0)
devido ao segundo argumento padrão).
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
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 .
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.
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
while(E) { S }; return Q
onde E
e Q
são expressões e S
é uma sequência de instruções e a transforma em uma função recursiva de cauda
f() = if E then { S; return f() } else { return Q }
Claro, E
, S
, e Q
tem que ser definida para calcular algum valor interessante sobre algumas variáveis. Por exemplo, a função de loop
sum(n) {
int i = 1, k = 0;
while( i <= n ) {
k += i;
++i;
}
return k;
}
é equivalente às funções recursivas da cauda
sum_aux(n,i,k) {
if( i <= n ) {
return sum_aux(n,i+1,k+i);
} else {
return k;
}
}
sum(n) {
return sum_aux(n,1,0);
}
(Esse "empacotamento" da função recursiva da cauda com uma função com menos parâmetros é um idioma funcional comum.)
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.
Uma chamada de cauda [recursão de cauda] é uma espécie de ir vestido como uma chamada. Uma chamada final ocorre quando uma função chama outra como sua última ação, portanto, não há mais nada a fazer. Por exemplo, no código a seguir, a chamada para
g
é uma chamada final:function f (x) return g(x) end
Após as
f
chamadasg
, não há mais o que fazer. Em tais situações, o programa não precisa retornar à função de chamada quando a função chamada termina. Portanto, após a chamada final, o programa não precisa manter nenhuma informação sobre a função de chamada na pilha. ...Como uma chamada final adequada não usa espaço na pilha, não há limite para o número de chamadas finais "aninhadas" que um programa pode fazer. Por exemplo, podemos chamar a seguinte função com qualquer número como argumento; nunca sobrecarregará a pilha:
function foo (n) if n > 0 then return foo(n - 1) end end
... Como eu disse anteriormente, uma chamada de cauda é uma espécie de goto. Como tal, uma aplicação bastante útil de chamadas de cauda apropriadas em Lua é para programar máquinas de estado. Tais aplicativos podem representar cada estado por uma função; mudar de estado é ir para (ou chamar) uma função específica. Como exemplo, vamos considerar um jogo de labirinto simples. O labirinto tem vários quartos, cada um com até quatro portas: norte, sul, leste e oeste. A cada passo, o usuário insere uma direção de movimento. Se houver uma porta nessa direção, o usuário vai para a sala correspondente; caso contrário, o programa imprimirá um aviso. O objetivo é ir de uma sala inicial para uma sala final.
Este jogo é uma máquina de estado típica, onde a sala atual é o estado. Podemos implementar esse labirinto com uma função para cada sala. Usamos chamadas de cauda para mover de uma sala para outra. Um pequeno labirinto com quatro quartos poderia ser assim:
function room1 () local move = io.read() if move == "south" then return room3() elseif move == "east" then return room2() else print("invalid move") return room1() -- stay in the same room end end function room2 () local move = io.read() if move == "south" then return room4() elseif move == "west" then return room1() else print("invalid move") return room2() end end function room3 () local move = io.read() if move == "north" then return room1() elseif move == "east" then return room4() else print("invalid move") return room3() end end function room4 () print("congratulations!") end
Veja bem, quando você faz uma chamada recursiva como:
function x(n)
if n==0 then return 0
n= n-2
return x(n) + 1
end
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.
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.
Em vez de explicar com palavras, aqui está um exemplo. Esta é uma versão do esquema da função fatorial:
(define (factorial x)
(if (= x 0) 1
(* x (factorial (- x 1)))))
Aqui está uma versão do fatorial que é recursiva da cauda:
(define factorial
(letrec ((fact (lambda (x accum)
(if (= x 0) accum
(fact (- x 1) (* accum x))))))
(lambda (x)
(fact x 1))))
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.
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.
factorial(x, fac=1) {
if (x == 1)
return fac;
else
return factorial(x-1, x*fac);
}
A chamada inicial para o fatorial seria factorial(n)
onde fac=1
(valor padrão) e n é o número para o qual o fatorial deve ser calculado.
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.
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.
public static int factorial(int mynumber) {
if (mynumber == 1) {
return 1;
} else {
return mynumber * factorial(--mynumber);
}
}
public static int tail_factorial(int mynumber, int sofar) {
if (mynumber == 1) {
return sofar;
} else {
return tail_factorial(--mynumber, sofar * mynumber);
}
}
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:
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
^ RECURSÃO
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
^ 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 o recsum
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).
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.
Em Java, aqui está uma possível implementação recursiva da função Fibonacci:
public int tailRecursive(final int n) {
if (n <= 2)
return 1;
return tailRecursiveAux(n, 1, 1);
}
private int tailRecursiveAux(int n, int iter, int acc) {
if (iter == n)
return acc;
return tailRecursiveAux(n, ++iter, acc + iter);
}
Compare isso com a implementação recursiva padrão:
public int recursive(final int n) {
if (n <= 2)
return 1;
return recursive(n - 1) + recursive(n - 2);
}
iter
para acc
quando iter < (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.
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 ...
(defun ! (n &optional (product 1))
(if (zerop n) product
(! (1- n) (* product n))))
E então, por diversão, você pode tentar (format nil "~R" (! 25))
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.
function N(x, p) {
return x == 1 ? p : N(x - 1, p * x);
}
Essa é a maneira não recursiva de escrever a função fatorial acima (embora alguns compiladores C ++ possam otimizá-la de qualquer maneira).
function N(x) {
return x == 1 ? 1 : x * N(x - 1);
}
mas isso não é:
function F(x) {
if (x == 1) return 0;
if (x == 2) return 1;
return F(x - 1) + F(x - 2);
}
Eu escrevi um longo post intitulado " Entendendo a recursão da cauda - Visual Studio C ++ - Exibição de montagem "
aqui está uma versão do Perl 5 da tailrecsum
função mencionada anteriormente.
sub tail_rec_sum($;$){
my( $x,$running_total ) = (@_,0);
return $running_total unless $x;
@_ = ($x-1,$running_total+$x);
goto &tail_rec_sum; # throw away current stack frame
}
Este é um trecho da Estrutura e Interpretação de Programas de Computador sobre recursão da cauda.
Ao contrastar iteração e recursão, devemos ter cuidado para não confundir a noção de processo recursivo com a noção de procedimento recursivo. Quando descrevemos um procedimento como recursivo, estamos nos referindo ao fato sintático de que a definição de procedimento se refere (direta ou indiretamente) ao próprio procedimento. Mas quando descrevemos um processo como seguindo um padrão linear, digamos, recursivo, estamos falando sobre como o processo evolui, não sobre a sintaxe de como um procedimento é escrito. Pode parecer perturbador que nos referimos a um procedimento recursivo, como o fato-iter, como gerador de um processo iterativo. No entanto, o processo é realmente iterativo: seu estado é capturado completamente por suas três variáveis de estado, e um intérprete precisa acompanhar apenas três variáveis para executar o processo.
Uma razão pela qual a distinção entre processo e procedimento pode ser confusa é que a maioria das implementações de linguagens comuns (incluindo Ada, Pascal e C) é projetada de tal maneira que a interpretação de qualquer procedimento recursivo consome uma quantidade de memória que cresce com o número de chamadas de procedimento, mesmo quando o processo descrito é, em princípio, iterativo. Como conseqüência, essas linguagens podem descrever processos iterativos apenas recorrendo a “construções em loop” para fins especiais, como repetir, até, por e enquanto. A implementação do esquema não compartilha esse defeito. Ele executará um processo iterativo no espaço constante, mesmo se o processo iterativo for descrito por um procedimento recursivo. Uma implementação com essa propriedade é chamada de recursiva de cauda. Com uma implementação recursiva de cauda, a iteração pode ser expressa usando o mecanismo de chamada de procedimento comum, para que construções especiais de iteração sejam úteis apenas como açúcar sintático.
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:
public static int fact(int n){
if(n <=1)
return 1;
else
return n * fact(n-1);
}
Do exemplo acima
if(n <=1)
return 1;
É o fator decisivo quando sair do loop
else
return n * fact(n-1);
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)
public static int fact(4){
if(4 <=1)
return 1;
else
return 4 * fact(4-1);
}
If
loop falhar, então ele vai para else
loop, então ele retorna4 * fact(3)
Na memória da pilha, temos 4 * fact(3)
Substituindo n = 3
public static int fact(3){
if(3 <=1)
return 1;
else
return 3 * fact(3-1);
}
If
loop falha, então ele vai para else
loop
entã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
public static int fact(2){
if(2 <=1)
return 1;
else
return 2 * fact(2-1);
}
If
loop falha, então ele vai para else
loop
entã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
public static int fact(1){
if(1 <=1)
return 1;
else
return 1 * fact(1-1);
}
If
loop é verdadeiro
entã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
public static int fact(x, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(x-1, running_total*x);
}
}
public static int fact(4, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(4-1, running_total*4);
}
}
If
loop falhar, então ele vai para else
loop, então ele retornafact(3, 4)
Na memória da pilha, temos fact(3, 4)
Substituindo n = 3
public static int fact(3, running_total=4) {
if (x==1) {
return running_total;
} else {
return fact(3-1, 4*3);
}
}
If
loop falha, então ele vai para else
loop
então retorna fact(2, 12)
Na memória da pilha, temos fact(2, 12)
Substituindo n = 2
public static int fact(2, running_total=12) {
if (x==1) {
return running_total;
} else {
return fact(2-1, 12*2);
}
}
If
loop falha, então ele vai para else
loop
então retorna fact(1, 24)
Na memória da pilha, temos fact(1, 24)
Substituindo n = 1
public static int fact(1, running_total=24) {
if (x==1) {
return running_total;
} else {
return fact(1-1, 24*1);
}
}
If
loop é verdadeiro
então retorna running_total
A saída para running_total = 24
Finalmente, o resultado de fato (4,1) = 24
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.
Uma recursão de cauda é uma função recursiva em que a função se chama no final ("cauda") da função na qual nenhum cálculo é feito após o retorno da chamada recursiva. Muitos compiladores otimizam para alterar uma chamada recursiva para uma chamada recursiva ou iterativa final.
Considere o problema de calcular fatorial de um número.
Uma abordagem direta seria:
factorial(n):
if n==0 then 1
else n*factorial(n-1)
Suponha que você chame fatorial (4). A árvore de recursão seria:
factorial(4)
/ \
4 factorial(3)
/ \
3 factorial(2)
/ \
2 factorial(1)
/ \
1 factorial(0)
\
1
A profundidade máxima de recursão no caso acima é O (n).
No entanto, considere o seguinte exemplo:
factAux(m,n):
if n==0 then m;
else factAux(m*n,n-1);
factTail(n):
return factAux(1,n);
A árvore de recursão para factTail (4) seria:
factTail(4)
|
factAux(1,4)
|
factAux(4,3)
|
factAux(12,2)
|
factAux(24,1)
|
factAux(24,0)
|
24
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.
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.
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:
def recursiveFunction(some_params):
# some code here
return recursiveFunction(some_args)
# no code after the return statement
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:
def factorial(number):
if number == 1:
# BASE CASE
return 1
else:
# RECURSIVE CASE
# Note that `number *` happens *after* the recursive call.
# This means that this is *not* tail call recursion.
return number * factorial(number - 1)
E esta é uma versão recursiva da função fatorial chamada de cauda:
def factorial(number, accumulator=1):
if number == 0:
# BASE CASE
return accumulator
else:
# RECURSIVE CASE
# There's no code after the recursive call.
# This is tail call recursion:
return factorial(number - 1, number * accumulator)
print(factorial(5))
(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.
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 .
Existem dois tipos básicos de recursões: recursão da cabeça e recursão da cauda.
Na recursão da cabeça , uma função faz sua chamada recursiva e, em seguida, executa mais alguns cálculos, talvez usando o resultado da chamada recursiva, por exemplo.
Em uma função recursiva da cauda , todos os cálculos ocorrem primeiro e a chamada recursiva é a última coisa que acontece.
Retirado deste post super impressionante. Por favor, considere a leitura.
Recursão significa uma função que se chama. Por exemplo:
(define (un-ended name)
(un-ended 'me)
(print "How can I get here?"))
Recursão de cauda significa a recursão que conclui a função:
(define (un-ended name)
(print "hello")
(un-ended 'me))
Veja, a última coisa que a função sem fim (procedimento, no jargão do esquema) faz é se chamar. Outro exemplo (mais útil) é:
(define (map lst op)
(define (helper done left)
(if (nil? left)
done
(helper (cons (op (car left))
done)
(cdr left))))
(reverse (helper '() lst)))
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).
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:
Definição 1 As expressões finais de um programa escrito no Core Scheme são definidas indutivamente da seguinte forma.
- O corpo de uma expressão lambda é uma expressão de cauda
- Se
(if E0 E1 E2)
é uma expressão de cauda, então ambosE1
eE2
são expressões de cauda.- Nada mais é uma expressão de cauda.
Definição 2 Uma chamada de cauda é uma expressão de cauda que é uma chamada de procedimento.
(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.)
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:
“A recursão funcional é a maneira natural de iterar no FP porque evita a mutação de estado. Durante cada iteração, um novo valor é passado no construtor de loop para ser atualizado (mutado). Além disso, uma função recursiva pode ser composta, tornando seu programa mais modular, além de apresentar oportunidades para explorar a paralelização ".
Aqui também estão algumas notas interessantes do mesmo livro sobre recursão da cauda:
A recursão de chamada de cauda é uma técnica que converte uma função recursiva regular em uma versão otimizada que pode lidar com entradas grandes sem riscos e efeitos colaterais.
NOTA O principal motivo para uma chamada final como otimização é melhorar a localidade dos dados, o uso da memória e o uso do cache. Ao fazer uma chamada final, o receptor usa o mesmo espaço de pilha que o responsável pela chamada. Isso reduz a pressão da memória. Ele melhora marginalmente o cache porque a mesma memória é reutilizada para os chamadores subseqüentes e pode permanecer no cache, em vez de remover uma linha de cache mais antiga para abrir espaço para uma nova linha de cache.