Come funziona esattamente la ricorsione della coda?


121

Quasi capisco come funziona la ricorsione della coda e la differenza tra essa e una normale ricorsione. Ho solo non capisco il motivo per cui non necessita di stack a ricordare il suo indirizzo di ritorno.

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

Non c'è niente da fare dopo aver chiamato una funzione stessa in una funzione di ricorsione in coda, ma per me non ha senso.


16
La ricorsione della coda è una ricorsione "normale". Significa solo che la ricorsione si verifica alla fine della funzione.
Pete Becker

7
... Ma può essere implementato in modo diverso a livello IL rispetto alla normale ricorsione, riducendo la profondità dello stack.
KeithS

2
A proposito, gcc può eseguire l'eliminazione della ricorsione della coda nell'esempio "normale" qui.
dmckee --- gattino ex moderatore

1
@ Geek - Sono uno sviluppatore C #, quindi il mio "linguaggio assembly" è MSIL o semplicemente IL. Per C / C ++, sostituire IL con ASM.
KeithS

1
@ ShannonSeverance Ho scoperto che gcc lo fa con il semplice espediente esaminando il codice assembly emesso con senza -O3. Il collegamento è per una discussione precedente che copre un terreno molto simile e discute ciò che è necessario per implementare questa ottimizzazione.
dmckee --- gattino ex moderatore

Risposte:


169

Il compilatore è semplicemente in grado di trasformarlo

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

in qualcosa di simile:

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

2
@ Mr.32 Non capisco la tua domanda. Ho convertito la funzione in una equivalente ma senza ricorsione esplicita (cioè senza chiamate di funzione esplicite). Se modifichi la logica in qualcosa di non equivalente, potresti effettivamente fare in modo che la funzione funzioni per sempre in alcuni o tutti i casi.
Alexey Frunze

18
Quindi la ricorsione delle code è efficace solo perché il compilatore lo ottimizza? E altrimenti sarebbe la stessa di una normale ricorsione in termini di memoria dello stack saggia?
Alan Coromano

34
Sì. Se il compilatore non può ridurre la ricorsione a un ciclo, sei bloccato con la ricorsione. Tutto o niente.
Alexey Frunze

3
@AlanDert: corretto. Puoi anche considerare la ricorsione della coda come un caso speciale di "ottimizzazione della chiamata della coda", speciale perché la chiamata della coda sembra essere la stessa funzione. In generale, qualsiasi chiamata alla coda (con gli stessi requisiti su "nessun lavoro rimasto da fare" che si applicano alla ricorsione della coda e dove viene restituito direttamente il valore di ritorno della chiamata alla coda) può essere ottimizzata se il compilatore può effettuare la chiamata in un modo che imposta l'indirizzo di ritorno della funzione chiamata in modo che sia l'indirizzo di ritorno della funzione che effettua la chiamata tail, invece dell'indirizzo da cui è stata effettuata la chiamata tail.
Steve Jessop

1
@AlanDert in C questa è solo un'ottimizzazione non applicata da nessuno standard, quindi il codice portatile non dovrebbe dipendere da esso. Ma ci sono linguaggi (Scheme è un esempio), in cui l'ottimizzazione della ricorsione della coda è applicata dallo standard, quindi non devi preoccuparti che in alcuni ambienti si sovrapponga.
Jan Wrobel

57

Chiedete perché "non richiede che lo stack ricordi il suo indirizzo di ritorno".

Vorrei cambiare questa situazione. Si fa utilizzare lo stack di ricordare l'indirizzo di ritorno. Il trucco è che la funzione in cui si verifica la ricorsione in coda ha il proprio indirizzo di ritorno nello stack e quando salta alla funzione chiamata, la tratterà come il proprio indirizzo di ritorno.

In concreto, senza ottimizzazione della chiamata di coda:

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

In questo caso, quando gviene chiamato, lo stack avrà il seguente aspetto:

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

D'altra parte, con l'ottimizzazione della chiamata di coda:

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

In questo caso, quando gviene chiamato, lo stack avrà il seguente aspetto:

   SP ->  Return address of "f"

Chiaramente, quando gritorna, tornerà nella posizione da cui è fstato chiamato.

EDIT : L'esempio sopra usa il caso in cui una funzione chiama un'altra funzione. Il meccanismo è identico quando la funzione chiama se stessa.


8
Questa è una risposta molto migliore rispetto alle altre risposte. Il compilatore molto probabilmente non ha qualche caso speciale magico per convertire il codice ricorsivo di coda. Esegue solo una normale ottimizzazione dell'ultima chiamata che accade per andare alla stessa funzione.
Arte

12

La ricorsione in coda può essere solitamente trasformata in un ciclo dal compilatore, specialmente quando vengono utilizzati accumulatori.

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

compilerebbe qualcosa come

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

3
Non così intelligente come l'implementazione di Alexey ... e sì, è un complimento.
Matthieu M.

1
In realtà, il risultato sembra più semplice, ma penso che il codice per implementare questa trasformazione sarebbe MOLTO più "intelligente" rispetto all'eliminazione di label / goto o della chiamata di coda (vedi la risposta di Lindydancer).
Phob

Se questo è tutto ciò che è la ricorsione della coda, perché le persone sono così entusiaste? Non vedo nessuno eccitarsi per il ciclo while.
Buh Buh

@BuhBuh: questo non ha stackoverflow ed evita lo stack push / popping dei parametri. Per un ciclo stretto come questo può fare un mondo di differenza. A parte questo, le persone non dovrebbero essere eccitate.
Mooing Duck

11

Ci sono due elementi che devono essere presenti in una funzione ricorsiva:

  1. La chiamata ricorsiva
  2. Un posto per tenere il conto dei valori restituiti.

Una funzione ricorsiva "normale" mantiene (2) nello stack frame.

I valori restituiti nella normale funzione ricorsiva sono composti da due tipi di valori:

  • Altri valori restituiti
  • Risultato del calcolo della funzione propria

Diamo un'occhiata al tuo esempio:

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

Il frame f (5) "memorizza" il risultato del proprio calcolo (5) e il valore di f (4), per esempio. Se chiamo fattoriale (5), appena prima che le chiamate dello stack inizino a collassare, ho:

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

Si noti che ogni stack memorizza, oltre ai valori che ho menzionato, l'intero ambito della funzione. Quindi, l'utilizzo della memoria per una funzione ricorsiva f è O (x), dove x è il numero di chiamate ricorsive che devo fare. Quindi, se ho bisogno di 1kb di RAM per calcolare fattoriale (1) o fattoriale (2), ho bisogno di ~ 100k per calcolare il fattoriale (100) e così via.

Una funzione ricorsiva di coda inserisce (2) nei suoi argomenti.

In una ricorsione in coda, passo il risultato dei calcoli parziali in ogni frame ricorsivo a quello successivo utilizzando i parametri. Vediamo il nostro esempio fattoriale, Tail Recursive:

int fattoriale (int n) {int helper (int num, int accumulato) {if num == 0 return accumulato altrimenti return helper (num - 1, accumulato * num)} return helper (n, 1)
}

Diamo un'occhiata ai suoi frame nel fattoriale (4):

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

Vedi le differenze? Nelle chiamate ricorsive "regolari" le funzioni di ritorno compongono ricorsivamente il valore finale. In Tail Recursion fanno riferimento solo al caso base (l'ultimo valutato) . Chiamiamo accumulatore l'argomento che tiene traccia dei valori precedenti.

Modelli di ricorsione

La normale funzione ricorsiva va come segue:

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

Per trasformarlo in una ricorsione di coda noi:

  • Introdurre una funzione di supporto che trasporta l'accumulatore
  • eseguire la funzione di supporto all'interno della funzione principale, con l'accumulatore impostato sul case base.

Guarda:

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)

Vedi la differenza?

Ottimizzazione della chiamata di coda

Dal momento che nessuno stato viene memorizzato negli stack Non-Border-Case degli stack Tail Call, non sono così importanti. Alcune lingue / interpreti sostituiscono quindi il vecchio stack con quello nuovo. Quindi, senza stack frame che limitano il numero di chiamate, le Tail Calls si comportano proprio come un ciclo for in questi casi.

Spetta al tuo compilatore ottimizzarlo, o no.


6

Ecco un semplice esempio che mostra come funzionano le funzioni ricorsive:

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

}

La ricorsione in coda è una semplice funzione ricorsiva, in cui la ricorrenza viene eseguita alla fine della funzione, quindi nessun codice viene eseguito in ascesa, il che aiuta la maggior parte dei compilatori di linguaggi di programmazione di alto livello a fare ciò che è noto come Ottimizzazione della ricorsione della coda , ha anche un ottimizzazione più complessa nota come modulo di ricorsione della coda


1

La funzione ricorsiva è una funzione che chiama da sola

Consente ai programmatori di scrivere programmi efficienti utilizzando una quantità minima di codice .

Lo svantaggio è che possono causare loop infiniti e altri risultati imprevisti se non scritti correttamente .

Spiegherò sia la funzione ricorsiva semplice che la funzione ricorsiva di coda

Per scrivere una funzione ricorsiva semplice

  1. Il primo punto da considerare è quando dovresti decidere di uscire dal ciclo che è il ciclo if
  2. Il secondo è quale processo fare se siamo la nostra stessa funzione

Dall'esempio fornito:

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

Dall'esempio sopra

if(n <=1)
     return 1;

È il fattore decisivo quando uscire dal ciclo

else 
     return n * fact(n-1);

È l'effettiva elaborazione da eseguire

Consentitemi di interrompere il compito uno per uno per una facile comprensione.

Vediamo cosa succede internamente se corro fact(4)

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

Ifil ciclo fallisce quindi va in elseciclo così ritorna4 * fact(3)

  1. Nella memoria dello stack, abbiamo 4 * fact(3)

    Sostituendo n = 3

public static int fact(3){
  if(3 <=1)
     return 1;
  else 
     return 3 * fact(3-1);
}

If il ciclo fallisce quindi va a elseciclo

quindi ritorna 3 * fact(2)

Ricorda che abbiamo chiamato `` 4 * fact (3) ``

L'output per fact(3) = 3 * fact(2)

Finora lo stack ha 4 * fact(3) = 4 * 3 * fact(2)

  1. Nella memoria dello stack, abbiamo 4 * 3 * fact(2)

    Sostituendo n = 2

public static int fact(2){
  if(2 <=1)
     return 1;
  else 
     return 2 * fact(2-1);
}

Ifil ciclo fallisce quindi va in elseciclo

quindi ritorna 2 * fact(1)

Ricorda che abbiamo chiamato 4 * 3 * fact(2)

L'output per fact(2) = 2 * fact(1)

Finora lo stack ha 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. Nella memoria dello stack, abbiamo 4 * 3 * 2 * fact(1)

    Sostituendo n = 1

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

If loop è vero

quindi ritorna 1

Ricorda che abbiamo chiamato 4 * 3 * 2 * fact(1)

L'output per fact(1) = 1

Finora lo stack ha 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

Infine, il risultato di fatto (4) = 4 * 3 * 2 * 1 = 24

inserisci qui la descrizione dell'immagine

La ricorsione della coda sarebbe

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

Ifil ciclo fallisce quindi va in elseciclo così ritornafact(3, 4)

  1. Nella memoria dello stack, abbiamo fact(3, 4)

    Sostituendo n = 3

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

Ifil ciclo fallisce quindi va in elseciclo

quindi ritorna fact(2, 12)

  1. Nella memoria dello stack, abbiamo fact(2, 12)

    Sostituendo n = 2

public static int fact(2, running_total=12) {
    if (x==1) {
        return running_total;
    } else {
        return fact(2-1, 12*2);
    }
}

Ifil ciclo fallisce quindi va in elseciclo

quindi ritorna fact(1, 24)

  1. Nella memoria dello stack, abbiamo fact(1, 24)

    Sostituendo 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 è vero

quindi ritorna running_total

L'output per running_total = 24

Infine, il risultato di fatto (4,1) = 24

inserisci qui la descrizione dell'immagine


0

La mia risposta è più un'ipotesi, perché la ricorsione è qualcosa relativo all'implementazione interna.

Nella ricorsione in coda, la funzione ricorsiva viene chiamata alla fine della stessa funzione. Probabilmente il compilatore può ottimizzare nel modo seguente:

  1. Lascia che la funzione in corso si concluda (cioè lo stack usato viene richiamato)
  2. Memorizza le variabili che verranno utilizzate come argomenti della funzione in una memoria temporanea
  3. Dopodiché, chiama di nuovo la funzione con l'argomento memorizzato temporaneamente

Come puoi vedere, stiamo terminando la funzione originale prima della successiva iterazione della stessa funzione, quindi non stiamo effettivamente "usando" lo stack.

Ma credo che se ci sono distruttori da chiamare all'interno della funzione, questa ottimizzazione potrebbe non essere applicabile.


0

Il compilatore è abbastanza intelligente da comprendere la ricorsione in coda. Nel caso in cui, durante il ritorno da una chiamata ricorsiva, non ci siano operazioni in sospeso e la chiamata ricorsiva è l'ultima istruzione, rientra nella categoria della ricorsione in coda. Il compilatore fondamentalmente esegue l'ottimizzazione della ricorsione in coda, rimuovendo l'implementazione dello stack.

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

Dopo aver eseguito l'ottimizzazione, il codice precedente viene convertito in uno inferiore.

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

Questo è il modo in cui il compilatore esegue l'ottimizzazione della ricorsione della coda.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.