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.