Cos'è la ricorsione della coda?


1696

Mentre iniziavo a imparare lisp, mi sono imbattuto nel termine ricorsivo della coda . Cosa significa esattamente?


155
Per curiosi: sia mentre sia che sono stati nella lingua per molto tempo. Mentre era in uso in inglese antico; while è uno sviluppo dell'inglese centrale di while. Come congiunzioni sono intercambiabili nel significato, ma mentre non è sopravvissuto nell'inglese americano standard.
Filip Bartuzi,

14
Forse è tardi, ma questo è un articolo abbastanza buono sulla ricorsività della coda: programmerinterview.com/index.php/recursion/tail-recursion
Sam003

5
Uno dei grandi vantaggi dell'identificazione di una funzione ricorsiva della coda è che può essere convertita in una forma iterativa e rivivere così l'algoritmo dal metodo-stack-overhead. Potrebbe piacere visitare la risposta di @Kyle Cronin e pochi altri sotto
KGhatak,

Questo link da @yesudeep è la descrizione migliore e più dettagliata che ho trovato - lua.org/pil/6.3.html
Jeff Fischer

1
Qualcuno potrebbe dirmi: Unisci l'ordinamento e l'ordinamento rapido usa la ricorsione della coda (TRO)?
Majurageerthan

Risposte:


1722

Considera una semplice funzione che aggiunge i primi N numeri naturali. (ad es sum(5) = 1 + 2 + 3 + 4 + 5 = 15.).

Ecco una semplice implementazione JavaScript che utilizza la ricorsione:

function recsum(x) {
    if (x === 1) {
        return x;
    } else {
        return x + recsum(x - 1);
    }
}

Se lo chiamassi recsum(5), è ciò che valuterà l'interprete JavaScript:

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

Nota come ogni chiamata ricorsiva deve essere completata prima che l'interprete JavaScript inizi a svolgere effettivamente il lavoro di calcolo della somma.

Ecco una versione ricorsiva della coda della stessa funzione:

function tailrecsum(x, running_total = 0) {
    if (x === 0) {
        return running_total;
    } else {
        return tailrecsum(x - 1, running_total + x);
    }
}

Ecco la sequenza di eventi che si verificherebbero se si chiamasse tailrecsum(5), (che sarebbe effettivamente tailrecsum(5, 0), a causa del secondo argomento predefinito).

tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15

Nel caso ricorsivo della coda, ad ogni valutazione della chiamata ricorsiva, running_totalviene aggiornato.

Nota: la risposta originale utilizzava esempi di Python. Questi sono stati cambiati in JavaScript, poiché gli interpreti Python non supportano l' ottimizzazione delle chiamate di coda . Tuttavia, mentre l'ottimizzazione delle chiamate di coda fa parte delle specifiche ECMAScript 2015 , la maggior parte degli interpreti JavaScript non la supporta .


32
Posso dire che con la ricorsione della coda la risposta finale viene calcolata dall'ULTIMA invocazione del solo metodo? Se NON si tratta di ricorsione della coda, per calcolare la risposta sono necessari tutti i risultati di tutti i metodi.
chrisapotek,

2
Ecco un addendum che presenta alcuni esempi in Lua: lua.org/pil/6.3.html Potrebbe essere utile anche per passare attraverso quello! :)
yesudeep,

2
Qualcuno può rispondere alla domanda di chrisapotek? Sono confuso su come si tail recursionpossa ottenere in una lingua che non ottimizza le chiamate in coda.
Kevin Meredith,

3
@KevinMeredith "ricorsione della coda" significa che l'ultima istruzione in una funzione è una chiamata ricorsiva alla stessa funzione. Hai ragione nel dire che non ha senso farlo in una lingua che non ottimizza tale ricorsione. Tuttavia, questa risposta mostra il concetto (quasi) correttamente. Sarebbe stato più chiaramente un richiamo di coda, se l '"altro:" fosse stato omesso. Non cambierebbe il comportamento, ma inseriva la chiamata di coda come un'istruzione indipendente. Lo invierò come una modifica.
ToolmakerSteve

2
Quindi in Python non c'è alcun vantaggio perché con ogni chiamata alla funzione tailrecsum, viene creato un nuovo frame dello stack - giusto?
Quazi Irfan,

708

Nella ricorsione tradizionale , il modello tipico è quello di eseguire prima le chiamate ricorsive, quindi prendere il valore di ritorno della chiamata ricorsiva e calcolare il risultato. In questo modo, non si ottiene il risultato del calcolo fino a quando non si è tornati da ogni chiamata ricorsiva.

Nella ricorsione della coda , esegui prima i calcoli, quindi esegui la chiamata ricorsiva, passando i risultati del passaggio corrente al passaggio ricorsivo successivo. Ciò si traduce nell'ultima affermazione nella forma di (return (recursive-function params)). Fondamentalmente, il valore di ritorno di ogni dato passaggio ricorsivo è lo stesso del valore di ritorno della chiamata ricorsiva successiva .

La conseguenza di ciò è che una volta che sei pronto per eseguire il tuo prossimo passo ricorsivo, non hai più bisogno dell'attuale stack frame. Ciò consente una certa ottimizzazione. In effetti, con un compilatore scritto in modo appropriato, non si dovrebbe mai avere uno snicker di overflow dello stack con una chiamata ricorsiva di coda. Riutilizzare semplicemente il frame dello stack corrente per il passaggio successivo ricorsivo. Sono abbastanza sicuro che Lisp lo faccia.


17
"Sono abbastanza sicuro che Lisp faccia questo" - Lo schema fa, ma Common Lisp non sempre.
Aaron,

2
@Daniel "Fondamentalmente, il valore di ritorno di ogni dato passaggio ricorsivo è lo stesso del valore di ritorno della prossima chiamata ricorsiva." - Non riesco a vedere questo argomento valido per lo snippet di codice pubblicato da Lorin Hochstein. Puoi per favore elaborare?
Geek,

8
@Geek Questa è una risposta molto tardiva, ma in realtà è vero nell'esempio di Lorin Hochstein. Il calcolo per ogni passaggio viene eseguito prima della chiamata ricorsiva, anziché dopo. Di conseguenza, ogni arresto restituisce semplicemente il valore direttamente dal passaggio precedente. L'ultima chiamata ricorsiva termina il calcolo e quindi restituisce il risultato finale non modificato completamente indietro nello stack di chiamate.
reirabio

3
Scala lo fa ma è necessario il @tailrec specificato per imporlo.
SilentDirge,

2
"In questo modo, non si ottiene il risultato del calcolo fino a quando non si è tornati da ogni chiamata ricorsiva." - forse ho frainteso questo, ma questo non è particolarmente vero per le lingue pigre in cui la ricorsione tradizionale è l'unico modo per ottenere effettivamente un risultato senza chiamare tutte le ricorsioni (ad esempio ripiegando un elenco infinito di bool con &&).
Hasufell,

206

Un punto importante è che la ricorsione della coda equivale essenzialmente al looping. Non è solo una questione di ottimizzazione del compilatore, ma un fatto fondamentale sull'espressività. Questo va in entrambi i modi: puoi prendere qualsiasi ciclo del modulo

while(E) { S }; return Q

dove Ee Qsono espressioni ed Sè una sequenza di affermazioni e la trasformano in una funzione ricorsiva della coda

f() = if E then { S; return f() } else { return Q }

Naturalmente, E, S, e Qdevono essere definiti per calcolare un valore interessante su alcune variabili. Ad esempio, la funzione di loop

sum(n) {
  int i = 1, k = 0;
  while( i <= n ) {
    k += i;
    ++i;
  }
  return k;
}

è equivalente alle funzioni ricorsive della coda

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

(Questo "avvolgimento" della funzione ricorsiva della coda con una funzione con meno parametri è un linguaggio funzionale comune.)


Nella risposta di @LorinHochstein ho capito, in base alla sua spiegazione, che la ricorsione della coda si verifica quando la porzione ricorsiva segue "Ritorno", tuttavia nella tua non ricorsiva la coda. Sei sicuro che il tuo esempio sia correttamente considerato ricorsione della coda?
CodyBugstein,

1
@Imray La parte ricorsiva della coda è l'istruzione "return sum_aux" all'interno di sum_aux.
Chris Conway,

1
@lmray: il codice di Chris è sostanzialmente equivalente. L'ordine del if / then e lo stile del test limitante ... if x == 0 contro if (i <= n) ... non è qualcosa su cui aggrapparsi. Il punto è che ogni iterazione passa il suo risultato al successivo.
Taylor,

else { return k; }può essere modificato inreturn k;
c0der

144

Questo estratto dal libro Programmazione in Lua mostra come effettuare una corretta ricorsione della coda (in Lua, ma dovrebbe applicarsi anche a Lisp) e perché è meglio.

Una chiamata di coda [ricorsione di coda] è una specie di goto vestito come una chiamata. Una chiamata in coda si verifica quando una funzione chiama un'altra come ultima azione, quindi non ha nient'altro da fare. Ad esempio, nel codice seguente, la chiamata a gè una chiamata di coda:

function f (x)
  return g(x)
end

Dopo le fchiamate g, non ha nient'altro da fare. In tali situazioni, il programma non deve tornare alla funzione chiamante quando termina la funzione chiamata. Pertanto, dopo la chiamata di coda, il programma non ha bisogno di conservare tutte le informazioni sulla funzione di chiamata nello stack. ...

Poiché una corretta chiamata di coda non utilizza spazio di stack, non esiste un limite al numero di chiamate di coda "nidificate" che un programma può effettuare. Ad esempio, possiamo chiamare la seguente funzione con qualsiasi numero come argomento; non supererà mai lo stack:

function foo (n)
  if n > 0 then return foo(n - 1) end
end

... Come ho detto prima, una chiamata in coda è una specie di goto. In quanto tale, un'applicazione abbastanza utile di adeguate chiamate di coda in Lua è per la programmazione di macchine a stati. Tali applicazioni possono rappresentare ogni stato mediante una funzione; cambiare stato significa andare a (o chiamare) una funzione specifica. Ad esempio, consideriamo un semplice gioco di labirinto. Il labirinto ha diverse stanze, ognuna con un massimo di quattro porte: nord, sud, est e ovest. Ad ogni passo, l'utente inserisce una direzione di movimento. Se c'è una porta in quella direzione, l'utente va nella stanza corrispondente; in caso contrario, il programma stampa un avviso. L'obiettivo è passare da una stanza iniziale a una stanza finale.

Questo gioco è una tipica macchina a stati, in cui la stanza attuale è lo stato. Possiamo implementare tale labirinto con una funzione per ogni stanza. Usiamo le chiamate di coda per spostarci da una stanza all'altra. Un piccolo labirinto con quattro stanze potrebbe assomigliare a questo:

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

Quindi vedi, quando fai una chiamata ricorsiva come:

function x(n)
  if n==0 then return 0
  n= n-2
  return x(n) + 1
end

Questo non è ricorsivo di coda perché hai ancora cose da fare (aggiungi 1) in quella funzione dopo aver effettuato la chiamata ricorsiva. Se si immette un numero molto alto, probabilmente si verificherà un overflow dello stack.


9
Questa è un'ottima risposta perché spiega le implicazioni delle chiamate di coda sulla dimensione dello stack.
Andrew Swan,

@AndrewSwan In effetti, anche se credo che il richiedente originale e il lettore occasionale che potrebbero inciampare in questa domanda potrebbero essere meglio serviti con la risposta accettata (dal momento che potrebbe non sapere quale sia effettivamente lo stack.) A proposito, io uso Jira, grande fan.
Hoffmann,

1
Anche la mia risposta preferita a causa dell'inclusione delle implicazioni per la dimensione dello stack.
njk2015,

80

Utilizzando la ricorsione regolare, ogni chiamata ricorsiva inserisce un'altra voce nello stack di chiamate. Quando la ricorsione è completata, l'app deve quindi rimuovere ciascuna voce fino in fondo.

Con la ricorsione della coda, a seconda della lingua il compilatore potrebbe essere in grado di comprimere lo stack fino a una voce, in modo da risparmiare spazio nello stack ... Una query ricorsiva di grandi dimensioni può effettivamente causare un overflow dello stack.

Fondamentalmente le ricorsioni di coda possono essere ottimizzate in iterazione.


1
la "Una query ricorsiva di grandi dimensioni può effettivamente causare un overflow dello stack." dovrebbe essere nel 1 ° paragrafo, non nel 2 ° (ricorsione della coda)? Il grande vantaggio della ricorsione della coda è che può essere (es: Schema) ottimizzato in modo da non "accumulare" le chiamate nello stack, quindi eviterà soprattutto gli overflow dello stack!
Olivier Dulac il

70

Il file gergo ha questo da dire sulla definizione di ricorsione della coda:

ricorsione della coda / n./

Se non ne sei già stanco, consulta la ricorsione della coda.


68

Invece di spiegarlo con le parole, ecco un esempio. Questa è una versione dello schema della funzione fattoriale:

(define (factorial x)
  (if (= x 0) 1
      (* x (factorial (- x 1)))))

Ecco una versione di fattoriale ricorsiva alla coda:

(define factorial
  (letrec ((fact (lambda (x accum)
                   (if (= x 0) accum
                       (fact (- x 1) (* accum x))))))
    (lambda (x)
      (fact x 1))))

Noterai nella prima versione che la chiamata ricorsiva ai fatti viene inserita nell'espressione di moltiplicazione e quindi lo stato deve essere salvato nello stack quando si effettua la chiamata ricorsiva. Nella versione ricorsiva della coda non vi è altra espressione S in attesa del valore della chiamata ricorsiva e, poiché non è necessario eseguire ulteriori operazioni, lo stato non deve essere salvato nello stack. Di norma, le funzioni ricorsive della coda dello Schema utilizzano uno spazio di stack costante.


4
+1 per menzionare l'aspetto più importante delle ricorsioni di coda che possono essere convertite in una forma iterativa e quindi trasformarlo in una forma di complessità della memoria O (1).
KGhatak,

1
@KGhatak non esattamente; la risposta parla correttamente di "spazio di stack costante", non di memoria in generale. non essere pignoli, solo per assicurarsi che non ci siano malintesi. ad esempio, la list-reverseprocedura di mutazione della coda ricorsiva della coda verrà eseguita in uno spazio di stack costante ma creerà e svilupperà una struttura di dati sull'heap. Un attraversamento di alberi potrebbe usare una pila simulata, in un argomento aggiuntivo. ecc.
Will Ness,

45

La ricorsione della coda si riferisce alla chiamata ricorsiva che è l'ultima nell'ultima istruzione logica dell'algoritmo ricorsivo.

In genere in ricorsione, hai un caso base che è ciò che ferma le chiamate ricorsive e inizia a far scoppiare lo stack di chiamate. Per usare un esempio classico, sebbene più C-ish di Lisp, la funzione fattoriale illustra la ricorsione della coda. La chiamata ricorsiva si verifica dopo aver verificato le condizioni del caso base.

factorial(x, fac=1) {
  if (x == 1)
     return fac;
   else
     return factorial(x-1, x*fac);
}

La chiamata iniziale a fattoriale sarebbe factorial(n)dove fac=1(valore predefinito) e n è il numero per cui calcolare il fattoriale.


Ho trovato la tua spiegazione più facile da capire, ma se c'è qualcosa da seguire, la ricorsione della coda è utile solo per le funzioni con casi di una sola istruzione. Prendi in considerazione un metodo come questo postimg.cc/5Yg3Cdjn . Nota: l'esterno elseè il passaggio che potresti chiamare un "caso base" ma si estende su più righe. Ti sto fraintendendo o la mia assunzione è corretta? La ricorsione della coda è buona solo per un rivestimento?
Voglio risposte

2
@IWantAnswers - No, il corpo della funzione può essere arbitrariamente grande. Tutto ciò che serve per una chiamata di coda è che il ramo in cui si trova chiama la funzione come l'ultima cosa che fa e restituisce il risultato della chiamata alla funzione. L' factorialesempio è solo il classico esempio semplice, tutto qui.
TJ Crowder,

28

Significa che invece di dover spingere il puntatore dell'istruzione nello stack, puoi semplicemente saltare all'inizio di una funzione ricorsiva e continuare l'esecuzione. Ciò consente alle funzioni di ricorrere indefinitamente senza traboccare lo stack.

Ho scritto un post sul blog sull'argomento, che contiene esempi grafici di come appaiono i frame dello stack.


21

Ecco uno snippet di codice rapido che confronta due funzioni. La prima è la ricorsione tradizionale per trovare il fattoriale di un determinato numero. Il secondo utilizza la ricorsione della coda.

Molto semplice ed intuitivo da capire.

Un modo semplice per capire se una funzione ricorsiva è ricorsiva alla coda è se restituisce un valore concreto nel caso base. Ciò significa che non restituisce 1 o true o qualcosa del genere. Molto probabilmente restituirà alcune varianti di uno dei parametri del metodo.

Un altro modo è dire se la chiamata ricorsiva è libera da qualsiasi aggiunta, aritmetica, modifica, ecc ... Significa che non è altro che una pura chiamata ricorsiva.

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. Quindi "mynumber == 1" dovrebbe essere "mynumber == 0".
polerto

19

Il modo migliore per me di capire tail call recursionè un caso speciale di ricorsione in cui l' ultima chiamata (o la chiamata di coda) è la funzione stessa.

Confrontando gli esempi forniti in Python:

def recsum(x):
 if x == 1:
  return x
 else:
  return x + recsum(x - 1)

^ Ricorsione

def tailrecsum(x, running_total=0):
  if x == 0:
    return running_total
  else:
    return tailrecsum(x - 1, running_total + x)

^ RICURSIONE DI POSTA

Come puoi vedere nella versione ricorsiva generale, la chiamata finale nel blocco di codice è x + recsum(x - 1). Quindi dopo aver chiamato il recsummetodo, c'è un'altra operazione che è x + ...

Tuttavia, nella versione ricorsiva di coda, la chiamata finale (o la chiamata di coda) nel blocco di codice è il tailrecsum(x - 1, running_total + x)che significa che l'ultima chiamata viene effettuata al metodo stesso e nessuna operazione successiva.

Questo punto è importante perché la ricorsione della coda vista qui non sta facendo crescere la memoria perché quando la macchina virtuale sottostante vede una funzione chiamarsi in una posizione di coda (l'ultima espressione da valutare in una funzione), elimina l'attuale frame dello stack, che è noto come Tail Call Optimization (TCO).

MODIFICARE

NB. Tieni presente che l'esempio sopra è scritto in Python il cui runtime non supporta TCO. Questo è solo un esempio per spiegare il punto. TCO è supportato in lingue come Scheme, Haskell ecc


12

In Java, ecco una possibile implementazione ricorsiva della coda della funzione 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);
}

In contrasto con l'implementazione ricorsiva standard:

public int recursive(final int n) {
    if (n <= 2)
        return 1;
    return recursive(n - 1) + recursive(n - 2);
}

1
Questo sta restituendo risultati sbagliati per me, per l'ingresso 8 ottengo 36, deve essere 21. Mi sto perdendo qualcosa? Sto usando Java e copialo incollato.
Alberto Zaccagni,

1
Ciò restituisce SUM (i) per i in [1, n]. Niente a che vedere con Fibbonacci. Per un Fibbo, avete bisogno di un test che sottrae iteral accmomento iter < (n-1).
Askolein,

10

Non sono un programmatore di Lisp, ma penso che questo aiuterà.

Fondamentalmente è uno stile di programmazione tale che la chiamata ricorsiva è l'ultima cosa che fai.


10

Ecco un esempio di Lisp comune che fa fattoriali usando la ricorsione della coda. A causa della natura senza stack, si potevano eseguire calcoli fattoriali follemente grandi ...

(defun ! (n &optional (product 1))
    (if (zerop n) product
        (! (1- n) (* product n))))

E poi per divertimento potresti provare (format nil "~R" (! 25))


9

In breve, una ricorsione di coda ha la chiamata ricorsiva come ultima istruzione nella funzione in modo che non debba attendere la chiamata ricorsiva.

Quindi questa è una ricorsione della coda, cioè N (x - 1, p * x) è l'ultima istruzione nella funzione in cui il compilatore è intelligente per capire che può essere ottimizzato per un ciclo continuo (fattoriale). Il secondo parametro p riporta il valore del prodotto intermedio.

function N(x, p) {
   return x == 1 ? p : N(x - 1, p * x);
}

Questo è il modo non ricorsivo di scrivere la funzione fattoriale sopra descritta (anche se alcuni compilatori C ++ potrebbero essere in grado di ottimizzarla comunque).

function N(x) {
   return x == 1 ? 1 : x * N(x - 1);
}

ma questo non è:

function F(x) {
  if (x == 1) return 0;
  if (x == 2) return 1;
  return F(x - 1) + F(x - 2);
}

Ho scritto un lungo post intitolato " Capire la ricorsione della coda - Visual Studio C ++ - Vista assieme "

inserisci qui la descrizione dell'immagine


1
Come è ricorsiva la tua funzione N?
Fabian Pijcke,

N (x-1) è l'ultima affermazione nella funzione in cui il compilatore è intelligente per capire che può essere ottimizzato per un for-loop (fattoriale)
doctorlai

La mia preoccupazione è che la tua funzione N sia esattamente la sintesi della funzione dalla risposta accettata di questo argomento (tranne che è una somma e non un prodotto), e che si dice che la sintesi non è ricorsiva alla coda?
Fabian Pijcke,

8

ecco una versione Perl 5 della tailrecsumfunzione menzionata in precedenza.

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

Questo è un estratto da Struttura e interpretazione dei programmi per computer sulla ricorsione della coda.

Nel contrastare iterazione e ricorsione, dobbiamo stare attenti a non confondere la nozione di processo ricorsivo con la nozione di procedura ricorsiva. Quando descriviamo una procedura come ricorsiva, ci riferiamo al fatto sintattico che la definizione della procedura si riferisce (direttamente o indirettamente) alla procedura stessa. Ma quando descriviamo un processo come seguendo un modello che è, diciamo, linearmente ricorsivo, stiamo parlando di come il processo si evolve, non della sintassi di come viene scritta una procedura. Può sembrare inquietante il fatto che ci riferiamo a una procedura ricorsiva come fact-iter come generazione di un processo iterativo. Tuttavia, il processo è davvero iterativo: il suo stato viene catturato completamente dalle sue tre variabili di stato e un interprete deve tenere traccia di sole tre variabili per eseguire il processo.

Uno dei motivi per cui la distinzione tra processo e procedura può essere fonte di confusione è che la maggior parte delle implementazioni di linguaggi comuni (tra cui Ada, Pascal e C) sono progettate in modo tale che l'interpretazione di qualsiasi procedura ricorsiva consuma una quantità di memoria che cresce con il numero di chiamate di procedura, anche quando il processo descritto è, in linea di principio, iterativo. Di conseguenza, questi linguaggi possono descrivere i processi iterativi solo ricorrendo a "costrutti ciclici" per scopi speciali come do, ripetizione, fino a, per e mentre. L'implementazione di Scheme non condivide questo difetto. Eseguirà un processo iterativo nello spazio costante, anche se il processo iterativo è descritto da una procedura ricorsiva. Un'implementazione con questa proprietà è definita ricorsiva della coda. Con un'implementazione ricorsiva della coda, l'iterazione può essere espressa usando il normale meccanismo di chiamata di procedura, in modo che costrutti speciali di iterazione siano utili solo come zucchero sintattico.


1
Ho letto tutte le risposte qui, eppure questa è la spiegazione più chiara che tocca il nucleo veramente profondo di questo concetto. Lo spiega in modo così diretto che rende tutto così semplice e chiaro. Perdona la mia maleducazione per favore. In qualche modo mi fa sentire come se le altre risposte non colpissero l'unghia sulla testa. Penso che sia per questo che SICP conta.
englealuze,

8

La funzione ricorsiva è una funzione che chiama da sola

Permette ai programmatori di scrivere programmi efficienti usando una quantità minima di codice .

Il rovescio della medaglia è che possono causare loop infiniti e altri risultati imprevisti se non scritti correttamente .

Spiegherò entrambi funzione ricorsiva semplice che la funzione ricorsiva della 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 funzione

Dall'esempio dato:

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 loop

else 
     return n * fact(n-1);

L'elaborazione effettiva deve essere eseguita

Consentitemi di interrompere l'attività una per una 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 loop fallisce, quindi va in elseloop e torna4 * 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);
}

Ifil loop fallisce, quindi va al elseloop

così 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 loop fallisce, quindi va al elseloop

così 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 il ciclo è vero

così 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 loop fallisce, quindi va in elseloop e tornafact(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 loop fallisce, quindi va al elseloop

così 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 loop fallisce, quindi va al elseloop

così 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 il ciclo è vero

così ritorna running_total

L'output per running_total = 24

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

inserisci qui la descrizione dell'immagine


7

La ricorsione della coda è la vita che stai vivendo in questo momento. Ricicli costantemente lo stesso frame dello stack, ripetutamente, perché non c'è motivo o mezzo per tornare a un frame "precedente". Il passato è finito e quindi può essere scartato. Ottieni un fotogramma, che si sposta per sempre verso il futuro, fino a quando il tuo processo inevitabilmente muore.

L'analogia si interrompe se si considera che alcuni processi potrebbero utilizzare frame aggiuntivi ma vengono comunque considerati ricorsivi di coda se lo stack non cresce all'infinito.


1
non si rompe sotto l' interpretazione del disturbo di personalità divisa . :) Una società della mente; una mente come società. :)
Will Ness,

Wow! Questo è un altro modo di pensarci
sutanu dalui,

7

Una ricorsione della coda è una funzione ricorsiva in cui la funzione si chiama alla fine ("coda") della funzione in cui non viene eseguito alcun calcolo dopo il ritorno della chiamata ricorsiva. Molti compilatori si ottimizzano per cambiare una chiamata ricorsiva in una ricorsiva di coda o in una chiamata iterativa.

Considera il problema dell'informatica fattoriale di un numero.

Un approccio semplice sarebbe:

  factorial(n):

    if n==0 then 1

    else n*factorial(n-1)

Supponiamo di chiamare factorial (4). L'albero di ricorsione sarebbe:

       factorial(4)
       /        \
      4      factorial(3)
     /             \
    3          factorial(2)
   /                  \
  2                factorial(1)
 /                       \
1                       factorial(0)
                            \
                             1    

La profondità massima di ricorsione nel caso precedente è O (n).

Tuttavia, considera il seguente esempio:

factAux(m,n):
if n==0  then m;
else     factAux(m*n,n-1);

factTail(n):
   return factAux(1,n);

L'albero di ricorsione per factTail (4) sarebbe:

factTail(4)
   |
factAux(1,4)
   |
factAux(4,3)
   |
factAux(12,2)
   |
factAux(24,1)
   |
factAux(24,0)
   |
  24

Anche in questo caso, la profondità massima di ricorsione è O (n) ma nessuna delle chiamate aggiunge ulteriori variabili allo stack. Quindi il compilatore può eliminare uno stack.


7

La ricorsione della coda è piuttosto veloce rispetto alla ricorsione normale. È veloce perché l'output della chiamata degli antenati non verrà scritto in pila per mantenere la traccia. Ma nella ricorsione normale tutti gli antenati chiamano l'output scritto in pila per mantenere la traccia.


6

Una funzione ricorsiva di coda è una funzione ricorsiva in cui l'ultima operazione eseguita prima di ritornare è la chiamata di funzione ricorsiva. Cioè, il valore restituito della chiamata di funzione ricorsiva viene immediatamente restituito. Ad esempio, il tuo codice sarebbe simile al seguente:

def recursiveFunction(some_params):
    # some code here
    return recursiveFunction(some_args)
    # no code after the return statement

Compilatori e interpreti che implementano l' ottimizzazione della coda o l' eliminazione della coda possono ottimizzare il codice ricorsivo per evitare overflow dello stack. Se il compilatore o l'interprete non implementa l'ottimizzazione delle chiamate di coda (come l'interprete CPython), non vi è alcun vantaggio aggiuntivo nella scrittura del codice in questo modo.

Ad esempio, questa è una funzione fattoriale ricorsiva standard in 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 questa è una versione ricorsiva del richiamo della funzione fattoriale:

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

(Si noti che anche se si tratta di codice Python, l'interprete CPython non esegue l'ottimizzazione delle chiamate di coda, quindi organizzare il codice in questo modo non conferisce alcun vantaggio di runtime.)

Potrebbe essere necessario rendere il codice un po 'più illeggibile per utilizzare l'ottimizzazione delle chiamate di coda, come mostrato nell'esempio fattoriale. (Ad esempio, il caso base ora non è intuitivo e il accumulatorparametro viene effettivamente utilizzato come una sorta di variabile globale.)

Ma il vantaggio dell'ottimizzazione delle chiamate di coda è che impedisce errori di overflow dello stack. (Noterò che puoi ottenere questo stesso vantaggio utilizzando un algoritmo iterativo anziché uno ricorsivo.)

Gli overflow dello stack sono causati quando lo stack di chiamate ha ricevuto troppi oggetti frame spinti. Un oggetto frame viene inserito nello stack di chiamate quando viene chiamata una funzione e rimosso dallo stack di chiamate quando la funzione ritorna. Gli oggetti frame contengono informazioni come le variabili locali e la riga di codice a cui tornare quando la funzione ritorna.

Se la funzione ricorsiva effettua troppe chiamate ricorsive senza tornare, lo stack di chiamate può superare il limite dell'oggetto frame. (Il numero varia in base alla piattaforma; in Python sono 1000 frame frame per impostazione predefinita.) Ciò provoca un errore di overflow dello stack . (Ehi, ecco da dove viene il nome di questo sito!)

Tuttavia, se l'ultima cosa che fa la tua funzione ricorsiva è effettuare la chiamata ricorsiva e restituire il suo valore di ritorno, non c'è motivo per cui sia necessario che l'oggetto frame corrente rimanga nello stack di chiamate. Dopotutto, se non c'è alcun codice dopo la chiamata di funzione ricorsiva, non c'è motivo di aggrapparsi alle variabili locali dell'oggetto frame corrente. Quindi possiamo eliminare immediatamente l'oggetto frame corrente anziché tenerlo nello stack di chiamate. Il risultato finale di questo è che lo stack di chiamate non aumenta di dimensioni e quindi non può impilare l'overflow.

Un compilatore o un interprete deve avere l'ottimizzazione delle chiamate di coda come caratteristica per poter riconoscere quando è possibile applicare l'ottimizzazione delle chiamate di coda. Anche allora, potresti aver riorganizzato il codice nella tua funzione ricorsiva per utilizzare l'ottimizzazione delle chiamate di coda, e spetta a te se questa potenziale riduzione della leggibilità vale l'ottimizzazione.


"Ricorsione della coda (chiamata anche ottimizzazione della coda o eliminazione della coda)". No; l'eliminazione della coda o l'ottimizzazione della coda è qualcosa che puoi applicare a una funzione ricorsiva della coda, ma non sono la stessa cosa. È possibile scrivere funzioni ricorsive di coda in Python (come si mostra), ma non sono più efficienti di una funzione non ricorsiva di coda, poiché Python non esegue l'ottimizzazione delle chiamate di coda.
Chepner,

Significa che se qualcuno riesce a ottimizzare il sito Web e rendere la chiamata ricorsiva coda ricorsiva non avremmo più il sito StackOverflow ?! È orribile.
Nadjib Mami,

5

Per comprendere alcune delle principali differenze tra la ricorsione di coda e la ricorsione di non coda possiamo esplorare le implementazioni .NET di queste tecniche.

Ecco un articolo con alcuni esempi in C #, F # e C ++ \ CLI: Adventures in Tail Recursion in C #, F # e C ++ \ CLI .

C # non ottimizza per la ricorsione di coda mentre F # lo fa.

Le differenze di principio riguardano i cicli rispetto al calcolo Lambda. C # è progettato pensando ai loop mentre F # è costruito secondo i principi del calcolo Lambda. Per un ottimo libro (e gratuito) sui principi del calcolo Lambda, vedi Struttura e interpretazione dei programmi per computer, di Abelson, Sussman e Sussman .

Per quanto riguarda le chiamate di coda in F #, per un ottimo articolo introduttivo, vedere Introduzione dettagliata alle chiamate di coda in F # . Infine, ecco un articolo che copre la differenza tra ricorsione non di coda e ricorsione di coda (in F #): ricorsione di coda e ricorsione di non coda in F sharp .

Se si desidera leggere alcune delle differenze di progettazione della ricorsione delle chiamate in coda tra C # e F #, vedere Generazione del codice operativo Tail-Call in C # e F # .

Se ti interessa abbastanza sapere quali condizioni impediscono al compilatore C # di eseguire ottimizzazioni di coda, consulta questo articolo: Condizioni di coda JIT CLR .


4

Esistono due tipi base di ricorsione: ricorsione della testa e ricorsione della coda.

Nella ricorsione principale , una funzione effettua la sua chiamata ricorsiva e quindi esegue altri calcoli, ad esempio utilizzando il risultato della chiamata ricorsiva.

In una funzione ricorsiva della coda , tutti i calcoli avvengono per primi e la chiamata ricorsiva è l'ultima cosa che accade.

Tratto da questo post fantastico. Per favore, considera di leggerlo.


4

Ricorsione significa una funzione che si chiama da sola. Per esempio:

(define (un-ended name)
  (un-ended 'me)
  (print "How can I get here?"))

Tail-Recursion indica la ricorsione che conclude la funzione:

(define (un-ended name)
  (print "hello")
  (un-ended 'me))

Vedi, l'ultima cosa che la funzione senza fine (procedura, nel gergo dello Schema) è di chiamare se stessa. Un altro esempio (più utile) è:

(define (map lst op)
  (define (helper done left)
    (if (nil? left)
        done
        (helper (cons (op (car left))
                      done)
                (cdr left))))
  (reverse (helper '() lst)))

Nella procedura di aiuto, l'ultima cosa che fa se la sinistra non è nulla è chiamare se stesso (DOPO contro qualcosa e cdr qualcosa). Questo è fondamentalmente il modo in cui si mappa un elenco.

La ricorsione della coda ha un grande vantaggio che l'interprete (o il compilatore, a seconda della lingua e del fornitore) può ottimizzarlo e trasformarlo in qualcosa di equivalente a un ciclo while. È un dato di fatto, nella tradizione dello Schema, la maggior parte del ciclo "for" e "while" viene eseguito in modo ricorsivo di coda (non esiste per e mentre, per quanto ne so).


3

Questa domanda ha molte grandi risposte ... ma non posso fare a meno di intervenire con una visione alternativa su come definire "ricorsione della coda", o almeno "corretta ricorsione della coda". Vale a dire: si dovrebbe considerarlo come una proprietà di una particolare espressione in un programma? O dovremmo considerarlo come una proprietà di un'implementazione di un linguaggio di programmazione ?

Per ulteriori informazioni su quest'ultima vista, c'è un documento classico di Will Clinger, "Corretta ricorsione della coda ed efficienza spaziale" (PLDI 1998), che definisce "la corretta ricorsione della coda" come proprietà di un'implementazione del linguaggio di programmazione. La definizione è costruita per consentire di ignorare i dettagli dell'implementazione (ad esempio se lo stack di chiamate è effettivamente rappresentato tramite lo stack di runtime o tramite un elenco di frame collegati allocati in heap).

A tale scopo, utilizza l'analisi asintotica: non del tempo di esecuzione del programma come si vede di solito, ma piuttosto dell'utilizzo dello spazio del programma . In questo modo, l'utilizzo dello spazio di un elenco collegato allocato in heap rispetto a uno stack di chiamate di runtime risulta asintoticamente equivalente; quindi si può ignorare quel dettaglio di implementazione del linguaggio di programmazione (un dettaglio che conta sicuramente un po 'nella pratica, ma che può confondere un po' le acque quando si tenta di determinare se una determinata implementazione soddisfa il requisito di essere "ricorsivo della coda della proprietà" )

Vale la pena studiare attentamente l'articolo per una serie di motivi:

  • Fornisce una definizione induttiva delle espressioni di coda e dei richiami di coda di un programma. (Tale definizione, e perché tali chiamate sono importanti, sembra essere l'oggetto della maggior parte delle altre risposte fornite qui.)

    Ecco queste definizioni, solo per fornire un sapore del testo:

    Definizione 1 Le espressioni di coda di un programma scritto nel Core Scheme sono definite induttivamente come segue.

    1. Il corpo di un'espressione lambda è un'espressione di coda
    2. Se (if E0 E1 E2)è un'espressione di coda, allora entrambe E1e E2sono espressioni di coda.
    3. Nient'altro è un'espressione di coda.

    Definizione 2 Una chiamata di coda è un'espressione di coda che è una chiamata di procedura.

(una chiamata ricorsiva di coda, o come dice il documento, "chiamata di coda automatica" è un caso speciale di una chiamata di coda in cui viene invocata la procedura stessa).

  • Fornisce definizioni formali per sei diverse "macchine" per la valutazione dello schema di base, in cui ogni macchina ha lo stesso comportamento osservabile ad eccezione della classe di complessità dello spazio asintotico in cui ciascuna si trova.

    Ad esempio, dopo aver fornito le definizioni per le macchine rispettivamente con 1. gestione della memoria basata su stack, 2. garbage collection ma senza chiamate di coda, 3. garbage collection e chiamate di coda, il documento continua con strategie di gestione dello storage ancora più avanzate, come 4. "evlis tail recursion", in cui non è necessario preservare l'ambiente durante la valutazione dell'ultimo argomento di sottoespressione in un richiamo di coda, 5. ridurre l'ambiente di una chiusura solo alle variabili libere di quella chiusura, e 6. La cosiddetta semantica "sicuro per lo spazio" come definita da Appel e Shao .

  • Al fine di dimostrare che le macchine appartengono effettivamente a sei classi distinte di complessità dello spazio, la carta, per ciascuna coppia di macchine a confronto, fornisce esempi concreti di programmi che esporranno l'esplosione dello spazio asintotico su una macchina ma non sull'altra.


(Leggendo la mia risposta ora, non sono sicuro di essere riuscito a catturare effettivamente i punti cruciali del documento di Clinger . Ma, ahimè, non posso dedicare più tempo allo sviluppo di questa risposta in questo momento.)


1

Molte persone hanno già spiegato la ricorsione qui. Vorrei citare un paio di riflessioni su alcuni vantaggi che la ricorsione dà dal libro "Concurrency in .NET, Modern patterns of simult simult and parallel parallel" di Riccardo Terrell:

“La ricorsione funzionale è il modo naturale di iterare in FP perché evita la mutazione di stato. Durante ogni iterazione, un nuovo valore viene passato nel costruttore del ciclo invece di essere aggiornato (mutato). Inoltre, è possibile comporre una funzione ricorsiva, rendendo il programma più modulare e introducendo opportunità per sfruttare la parallelizzazione. "

Ecco anche alcune note interessanti dello stesso libro sulla ricorsione della coda:

La ricorsione di coda è una tecnica che converte una normale funzione ricorsiva in una versione ottimizzata in grado di gestire input di grandi dimensioni senza rischi ed effetti collaterali.

NOTA Il motivo principale di una chiamata in coda come ottimizzazione è migliorare la localizzazione dei dati, l'utilizzo della memoria e l'uso della cache. Effettuando una chiamata in coda, il chiamante utilizza lo stesso spazio di stack del chiamante. Questo riduce la pressione della memoria. Migliora leggermente la cache perché la stessa memoria viene riutilizzata per i chiamanti successivi e può rimanere nella cache, piuttosto che sfrattare una vecchia riga della cache per fare spazio a una nuova riga della cache.

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.