O que é recursão da cauda?


1695

Enquanto começava a aprender cocô, me deparei com o termo recursivo da cauda . O que isso significa exatamente?


155
Para curiosos: enquanto e enquanto estão no idioma há muito tempo. Enquanto estava em uso no inglês antigo; while é um desenvolvimento em inglês do meio do while. Como conjunções, eles têm significado intercambiável, mas ainda não sobreviveram no inglês americano padrão.
Filip Bartuzi

14
Talvez seja tarde, mas este é um bom artigo sobre recursiva cauda: programmerinterview.com/index.php/recursion/tail-recursion
Sam003

5
Um dos grandes benefícios de identificar uma função recursiva da cauda é que ela pode ser convertida em uma forma iterativa e, assim, reviver o algoritmo do método sobrecarga da pilha de métodos. Gostaria de visitar resposta do @Kyle Cronin e alguns outros abaixo
KGhatak

Este link de @yesudeep é a melhor descrição, mais detalhado que eu encontrei - lua.org/pil/6.3.html
Jeff Fischer

1
Alguém poderia me dizer, a classificação de mesclagem e a classificação rápida usam recursão de cauda (TRO)?
majurageerthan

Respostas:


1721

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 .


32
Posso dizer que, com recursão de cauda, ​​a resposta final é calculada apenas pela última invocação do método? Se NÃO for recursão final, você precisará de todos os resultados para todo o método para calcular a resposta.
Chrisapotek

2
Aqui está um adendo que apresenta alguns exemplos em Lua: lua.org/pil/6.3.html Pode ser útil fazer isso também! :)
yesudeep 28/02

2
Alguém pode responder à pergunta de chrisapotek? Estou confuso sobre como isso tail recursionpode ser alcançado em um idioma que não otimiza as chamadas de cauda.
22813 Kevin Meredith

3
@KevinMeredith "recursão de cauda" significa que a última declaração de uma função é uma chamada recursiva para a mesma função. Você está certo de que não faz sentido fazer isso em um idioma que não otimize essa recursão. No entanto, esta resposta mostra o conceito (quase) corretamente. Teria sido mais claramente uma chamada final, se o "else:" fosse omitido. Não mudaria o comportamento, mas colocaria a chamada final como uma declaração independente. Vou enviar isso como uma edição.
Página

2
Portanto, em python não há vantagem, pois a cada chamada para a função tailrecsum, um novo quadro de pilha é criado - certo?
Quazi Irfan

707

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.


17
"Tenho certeza que o Lisp faz isso" - Scheme faz, mas o Common Lisp nem sempre.
Aaron

2
@ Daniel "Basicamente, o valor de retorno de qualquer etapa recursiva é o mesmo que o valor de retorno da próxima chamada recursiva." - Não consigo ver esse argumento válido para o trecho de código publicado por Lorin Hochstein. Você pode por favor elaborar?
21713 Geek

8
@ Geek Esta é uma resposta muito tardia, mas isso é realmente verdade no exemplo de Lorin Hochstein. O cálculo para cada etapa é feito antes da chamada recursiva, e não depois dela. Como resultado, cada parada apenas retorna o valor diretamente da etapa anterior. A última chamada recursiva termina o cálculo e, em seguida, retorna o resultado final sem modificação até o final da pilha de chamadas.
Reirab 23/04

3
Scala sim, mas você precisa do @tailrec especificado para aplicá-lo.
SilentDirge

2
"Dessa maneira, você não obtém o resultado do seu cálculo até retornar de cada chamada recursiva". - talvez eu tenha entendido errado isso, mas isso não é particularmente verdade para idiomas preguiçosos, onde a recursão tradicional é a única maneira de obter um resultado sem chamar todas as recursões (por exemplo, dobrar uma lista infinita de Bools com &&).
hasufell

206

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 Ee Qsã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 Qtem 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.)


Na resposta de @LorinHochstein, entendi, com base em sua explicação, que a recursão da cauda ocorre quando a parte recursiva segue "Return", no entanto, na sua, a recursiva da cauda não é. Tem certeza de que seu exemplo é considerado corretamente recursão de cauda?
CodyBugstein

1
@Imray A parte recursiva da cauda é a instrução "return sum_aux" dentro de sum_aux.
Chris Conway

1
@lmray: o código de Chris é essencialmente equivalente. A ordem do if / then e o estilo do teste de limitação ... se x == 0 versus if (i <= n) ... não é algo para se pendurar. O ponto é que cada iteração passa seu resultado para o próximo.
Taylor

else { return k; }pode ser alterado parareturn k;
c0der 13/06

144

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 fchamadas g, 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.


9
Essa é uma ótima resposta, pois explica as implicações das chamadas finais no tamanho da pilha.
Andrew Swan

@AndrewSwan De fato, embora eu acredite que o solicitante original e o leitor ocasional que possam se deparar com essa pergunta possam ser mais bem atendidos com a resposta aceita (já que ele pode não saber o que realmente é a pilha). ventilador.
Hoffmann

1
Minha resposta favorita também devido à inclusão das implicações no tamanho da pilha.
njk2015

80

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.


1
a "Uma consulta recursiva grande pode realmente causar um estouro de pilha". deve estar no primeiro parágrafo, não no segundo (recursão da cauda)? A grande vantagem da recursão final é que ela pode ser (ex: esquema) otimizada de forma a não "acumular" chamadas na pilha, evitando assim o excesso de pilha!
Olivier Dulac

70

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.


68

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.


4
+1 por mencionar o aspecto mais importante das recursões de cauda que podem ser convertidas em uma forma iterativa e, assim, transformá-la em uma forma de complexidade de memória O (1).
KGhatak

1
@KGhatak não exatamente; a resposta fala corretamente sobre "espaço de pilha constante", não memória em geral. para não falar mal, apenas para garantir que não haja mal-entendidos. por exemplo, o list-reverseprocedimento 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.
Will Ness

45

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.


Achei a sua explicação mais fácil de entender, mas, se houver algo a seguir, a recursão da cauda será útil apenas para funções com casos básicos de uma instrução. Considere um método como este postimg.cc/5Yg3Cdjn . Nota: o externo 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?
Eu quero respostas

2
@IWantAnswers - Não, o corpo da função pode ser arbitrariamente grande. Tudo o que é necessário para uma chamada final é que o ramo em que está chama a função como a última coisa que faz e retorna o resultado da chamada da função. O factorialexemplo é apenas o exemplo simples clássico, só isso.
TJ Crowder

28

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.


21

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);
    }
}

3
0! é 1. Portanto, "meu número == 1" deve ser "meu número == 0".
polerto

19

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 recsummé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.


12

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);
}

1
Isso está retornando resultados errados para mim, para a entrada 8 eu obtenho 36, tem que ser 21. Estou perdendo alguma coisa? Eu estou usando java e copiar colado.
Alberto Zaccagni 28/11

1
Isso retorna SUM (i) para i em [1, n]. Nada a ver com Fibbonacci. Para um Fibbo, você precisa de um teste que suporte iterpara accquando iter < (n-1).
27513 Askolein

10

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.


10

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))


9

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 "

insira a descrição da imagem aqui


1
Como a sua função N é recursiva na cauda?
Fabian Pijcke

N (x-1) é a última instrução na função em que o compilador é inteligente para descobrir que podem ser optimizados para uma for-loop (fatorial)
doctorlai

Minha preocupação é que sua função N seja exatamente o retorno da função da resposta aceita deste tópico (exceto que é uma soma e não um produto), e esse retorno é considerado não recursivo da cauda?
Fabian Pijcke

8

aqui está uma versão do Perl 5 da tailrecsumfunçã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
}

8

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.


1
Li todas as respostas aqui e, no entanto, esta é a explicação mais clara que toca o núcleo realmente profundo desse conceito. Ele explica de maneira tão direta que faz com que tudo pareça tão simples e claro. Perdoe minha grosseria, por favor. De alguma forma, me faz sentir que as outras respostas simplesmente não atingem a unha na cabeça. Eu acho que é por isso que o SICP é importante.
englealuze

8

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

  1. O primeiro ponto a considerar é quando você deve decidir sair do loop, que é o loop if
  2. O segundo é qual processo fazer se formos nossa própria função

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)

  1. Substituindo n = 4
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifloop falhar, então ele vai para elseloop, então ele retorna4 * fact(3)

  1. 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);
}

Ifloop falha, então ele vai para elseloop

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)

  1. 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);
}

Ifloop falha, então ele vai para elseloop

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)

  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

insira a descrição da imagem aqui

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);
    }
}

  1. Substituindo n = 4
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifloop falhar, então ele vai para elseloop, então ele retornafact(3, 4)

  1. 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);
    }
}

Ifloop falha, então ele vai para elseloop

então retorna fact(2, 12)

  1. 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);
    }
}

Ifloop falha, então ele vai para elseloop

então retorna fact(1, 24)

  1. 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

insira a descrição da imagem aqui


7

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.


1
não quebra sob a interpretação de transtorno de personalidade dividida . :) Uma sociedade da mente; uma mente como sociedade. :)
Will Ness

Uau! Agora isso é outra maneira de pensar sobre isso
sutanu dalui 20/01

7

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.


7

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.


6

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 accumulatorparâ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.


"Recursão de cauda (também chamada otimização de chamada de cauda ou eliminação de chamada de cauda)". Não; eliminação de chamada de cauda ou otimização de chamada de cauda é algo que você pode aplicar a uma função recursiva de cauda, ​​mas elas não são a mesma coisa. Você pode escrever funções recursivas de cauda no Python (como você mostra), mas elas não são mais eficientes do que uma função recursiva não de cauda, ​​porque o Python não executa otimização de chamada de cauda.
chepner

Isso significa que, se alguém conseguir otimizar o site e renderizar a chamada recursiva tail-recursive, não teríamos mais o site StackOverflow ?! Isso é horrível.
Nadjib Mami

5

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 .


4

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.


4

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).


3

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.

    1. O corpo de uma expressão lambda é uma expressão de cauda
    2. Se (if E0 E1 E2)é uma expressão de cauda, ​​então ambos E1e E2são expressões de cauda.
    3. 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.)


1

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.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.