Como exatamente a recursão da cauda funciona?


121

Eu quase entendo como a recursão da cauda funciona e a diferença entre ela e uma recursão normal. I única não entendo por que ele não requer pilha para lembrar o seu endereço de retorno.

// tail recursion
int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

int factorial (int n) {
    return fac_times (n, 1);
}

// normal recursion
int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

Não há nada a fazer depois de chamar uma função em uma função de recursão de cauda, ​​mas isso não faz sentido para mim.


16
A recursão da cauda é recursão "normal". Isso significa apenas que a recursão ocorre no final da função.
— Pete Becker

7
... Mas pode ser implementado de maneira diferente no nível de IL que a recursão normal, reduzindo a profundidade da pilha.
— Keiths

2
BTW, o gcc pode executar a eliminação da recursão da cauda no exemplo "normal" aqui.
— dmckee --- ex-moderador gatinho

1
@ Geek - Eu sou um dev C #, então minha "linguagem assembly" é MSIL ou apenas IL. Para C / C ++, substitua IL por ASM.
— Keiths

1
@ ShannonSeverance Eu descobri que o gcc está fazendo isso com o simples expediente de examinar o código de montagem emitido com sem -O3. O link é para uma discussão anterior que aborda assuntos muito semelhantes e discute o que é necessário para implementar essa otimização.
— dmckee --- ex-moderador gatinho

Respostas:


169

O compilador é simplesmente capaz de transformar isso

int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

em algo como isto:

int fac_times (int n, int acc) {
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;
}

2
@ Mr.32 Não entendi sua pergunta. Eu converti a função em uma equivalente, mas sem recursão explícita (ou seja, sem chamadas de função explícitas). Se você mudar a lógica para algo não equivalente, poderá de fato fazer a função repetir para sempre em alguns ou em todos os casos.
— Alexey Frunze

18
Então a recursão do Tails é efetiva apenas por causa do otimizador do compilador ? E, caso contrário, seria o mesmo que uma recursão normal em termos de memória de pilha?
— Alan Coromano 20/03/2013

34
Sim. Se o compilador não puder reduzir a recursão a um loop, você estará preso à recursão. Tudo ou nada.
— Alexey Frunze

3
@ AlanDert: correto. Você também pode considerar a recursão de cauda como um caso especial da "otimização de chamada de cauda", especial porque a chamada de cauda passa a ter a mesma função. Em geral, qualquer chamada final (com os mesmos requisitos em "não há trabalho a ser feito" que se aplica à recursão final e onde o valor de retorno da chamada final é retornado diretamente) pode ser otimizada se o compilador puder fazer a chamada em um maneira que configura o endereço de retorno da função chamada para ser o endereço de retorno da função que está fazendo a chamada final, em vez do endereço a partir do qual a chamada final foi feita.
— precisa

1
O @AlanDert em C é apenas uma otimização não imposta por nenhum padrão; portanto, o código portátil não deve depender disso. Mas existem idiomas (Scheme é um exemplo), em que a otimização da recursão da cauda é imposta pelo padrão, portanto, você não precisa se preocupar com o excesso de empilhamento em alguns ambientes.
— Jan Wrobel

57

Você pergunta por que "ele não requer pilha para lembrar seu endereço de retorno".

Eu gostaria de mudar isso. Ele faz usar a pilha de lembrar o endereço de retorno. O truque é que a função na qual a recursão da cauda ocorre tem seu próprio endereço de retorno na pilha e, quando salta para a função chamada, o trata como seu próprio endereço de retorno.

Concretamente, sem otimização de chamada de cauda:

f: ...
   CALL g
   RET
g:
   ...
   RET

Nesse caso, quando gé chamado, a pilha terá a seguinte aparência:

   SP ->  Return address of "g"
          Return address of "f"

Por outro lado, com a otimização da chamada de cauda:

f: ...
   JUMP g
g:
   ...
   RET

Nesse caso, quando gé chamado, a pilha terá a seguinte aparência:

   SP ->  Return address of "f"

Claramente, quando gretornar, ele retornará ao local de onde ffoi chamado.

EDIT : O exemplo acima usa o caso em que uma função chama outra função. O mecanismo é idêntico quando a função se chama.


8
Esta é uma resposta muito melhor do que as outras respostas. O compilador provavelmente não possui um caso especial mágico para a conversão de código recursivo de cauda. Apenas realiza uma otimização normal da última chamada que passa para a mesma função.
— Art

12

A recursão da cauda geralmente pode ser transformada em loop pelo compilador, especialmente quando acumuladores são usados.

// tail recursion
int fac_times (int n, int acc = 1) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

compilaria para algo como

// accumulator
int fac_times (int n) {
    int acc = 1;
    while (n > 0) {
        acc *= n;
        n -= 1;
    }
    return acc;
}

3
Não é tão inteligente quanto a implementação de Alexey ... e sim, isso é um elogio.
— precisa

1
Na verdade, o resultado parece mais simples, mas acho que o código para implementar essa transformação seria MUITO mais "inteligente" do que o label / goto ou apenas a eliminação da chamada de cauda (consulte a resposta de Lindydancer).
— Phob

Se tudo isso é recursão, então por que as pessoas ficam tão empolgadas com isso? Não vejo ninguém se empolgando enquanto faz loops.
— Buh Buh

@BuhBuh: Isso não possui fluxo de pilha e evita o empilhamento / estalo de parâmetros da pilha. Para um ciclo apertado como este, pode fazer um mundo de diferença. Fora isso, as pessoas não deveriam estar empolgadas.
— Mooing Duck

11

Existem dois elementos que devem estar presentes em uma função recursiva:

  1. A chamada recursiva
  2. Um local para manter a contagem dos valores de retorno.

Uma função recursiva "regular" mantém (2) no quadro da pilha.

Os valores retornados na função recursiva regular são compostos por dois tipos de valores:

  • Outros valores de retorno
  • Resultado do cálculo da função proprietária

Vejamos o seu exemplo:

int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

O quadro f (5) "armazena" o resultado de sua própria computação (5) e o valor de f (4), por exemplo. Se eu chamar fatorial (5), logo antes das chamadas da pilha começarem a entrar em colapso, eu tenho:

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]

Observe que cada pilha armazena, além dos valores mencionados, todo o escopo da função. Portanto, o uso de memória para uma função recursiva f é O (x), onde x é o número de chamadas recursivas que tenho que fazer. Portanto, se eu precisar de 1kb de RAM para calcular o fatorial (1) ou fatorial (2), preciso de ~ 100k para calcular o fatorial (100), e assim por diante.

Uma função Tail recursiva coloca (2) seus argumentos.

Em uma recursão de cauda, ​​passo o resultado dos cálculos parciais em cada quadro recursivo para o próximo usando parâmetros. Vamos ver o nosso exemplo fatorial, Tail Recursive:

int fatorial (int n) {int helper (int num, int acumulado) {se num == 0 retorno acumulado senão return helper (num - 1, acumulado * num)} return helper (n, 1)
}

Vejamos seus quadros em fatorial (4):

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]

Vê as diferenças? Nas chamadas recursivas "regulares", as funções de retorno compõem recursivamente o valor final. Na recursão da cauda, ​​eles referenciam apenas o caso base (o último avaliado) . Chamamos acumulador de argumento que acompanha os valores mais antigos.

Modelos de recursão

A função recursiva regular é a seguinte:

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))

Para transformá-lo em uma recursão de cauda, ​​nós:

  • Introduzir uma função auxiliar que carrega o acumulador
  • execute a função auxiliar dentro da função principal, com o acumulador definido na caixa base.

Veja:

type tail(n):
    type helper(n, accumulator):
        if n == base case
            return accumulator
        computation
        accumulator = computation combined with accumulator
        return helper(n towards base case, accumulator)
    helper(n, base case)

Veja a diferença?

Otimização de chamada de cauda

Como nenhum estado está sendo armazenado nas pilhas Não-Fronteiras das Chamadas de Chamada, elas não são tão importantes. Alguns idiomas / intérpretes substituem a pilha antiga pela nova. Portanto, sem quadros de pilha restringindo o número de chamadas, as Chamadas Tail se comportam como um loop for nesses casos.

Cabe ao seu compilador otimizar, ou não.


6

Aqui está um exemplo simples que mostra como as funções recursivas funcionam:

long f (long n)
{

    if (n == 0) // have we reached the bottom of the ocean ?
        return 0;

    // code executed in the descendence

    return f(n-1) + 1; // recurrence

    // code executed in the ascendence

}

A recursão de cauda é uma função recursiva simples, onde a recorrência é feita no final da função, portanto, nenhum código é feito em ascensão, o que ajuda a maioria dos compiladores de linguagens de programação de alto nível a fazer o que é conhecido como Otimização de recursão de cauda , também possui um otimização mais complexa, conhecida como módulo de recursão da cauda


1

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 falhar, 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 falhar, 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 falhar, 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 falhar, 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


0

Minha resposta é mais um palpite, porque recursão é algo relacionado à implementação interna.

Na recursão da cauda, ​​a função recursiva é chamada no final da mesma função. Provavelmente o compilador pode otimizar da seguinte maneira:

  1. Deixe a função em andamento terminar (ou seja, a pilha usada é recuperada)
  2. Armazene as variáveis ​​que serão usadas como argumentos para a função em um armazenamento temporário
  3. Depois disso, chame a função novamente com o argumento armazenado temporariamente

Como você pode ver, estamos encerrando a função original antes da próxima iteração da mesma função, portanto, na verdade, não estamos "usando" a pilha.

Mas acredito que, se houver destruidores a serem chamados dentro da função, essa otimização pode não se aplicar.


0

O compilador é inteligente o suficiente para entender a recursão da cauda.No caso, ao retornar de uma chamada recursiva, não há operação pendente e a chamada recursiva é a última instrução, se enquadra na categoria de recursão da cauda. O compilador basicamente executa a otimização da recursão da cauda, ​​removendo a implementação da pilha.

void tail(int i) {
    if(i<=0) return;
    else {
     system.out.print(i+"");
     tail(i-1);
    }
   }

Após executar a otimização, o código acima é convertido em abaixo de um.

void tail(int i) {
    blockToJump:{
    if(i<=0) return;
    else {
     system.out.print(i+"");
     i=i-1;
     continue blockToJump;  //jump to the bolckToJump
    }
    }
   }

É assim que o compilador faz a Otimização de recursão de cauda.

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.