Quali metodi ci sono per evitare un overflow dello stack in un algoritmo ricorsivo?


44

Domanda

Quali sono i modi possibili per risolvere un overflow dello stack causato da un algoritmo ricorsivo?

Esempio

Sto cercando di risolvere il problema 14 di Project Euler e ho deciso di provarlo con un algoritmo ricorsivo. Tuttavia, il programma si interrompe con un java.lang.StackOverflowError. Comprensibilmente. L'algoritmo infatti ha traboccato lo stack perché ho cercato di generare una sequenza Collatz per un numero molto grande.

soluzioni

Quindi mi chiedevo: quali modi standard ci sono per risolvere un overflow dello stack supponendo che il tuo algoritmo ricorsivo sia stato scritto correttamente e finirebbe sempre per traboccare lo stack? Due concetti che mi sono venuti in mente erano:

  1. ricorsione della coda
  2. iterazione

Le idee (1) e (2) sono corrette? Ci sono altre opzioni?

modificare

Aiuterebbe a vedere un po 'di codice, preferibilmente in Java, C #, Groovy o Scala.

Forse non utilizzare il problema di Project Euler sopra menzionato in modo da non rovinarlo per altri, ma prendere qualche altro algoritmo. Forse fattoriale o qualcosa di simile.


3
Iterazione. Memoisation
James

2
Ovviamente, Memoization funziona solo quando in realtà viene ripetuto il calcolo.
Jörg W Mittag,

2
vale anche la pena notare che non tutte le implementazioni linguistiche possono comunque eseguire ottimizzazioni di ricorsione della coda
jk.

2
Questo sarebbe probabilmente meglio risolto con la corecursione che la ricorsione.
Jörg W Mittag,

3
Se stai lavorando da un numero inferiore a 1.000.000 e stai andando a 1, la risposta a questa domanda comporta circa 500 passaggi per raggiungere 1. Questo non dovrebbe tassare la ricorsione dato un frame di stack ridotto. --- Se stai tentando di risolvere a partire da 1, quindi seguendolo a 2, 4, 8, 16, {5,32} e salendo da lì, stai sbagliando.

Risposte:


35

L'ottimizzazione delle chiamate di coda è presente in molte lingue e compilatori. In questa situazione, il compilatore riconosce una funzione del modulo:

int foo(n) {
  ...
  return bar(n);
}

Qui, la lingua è in grado di riconoscere che il risultato che viene restituito è il risultato di un'altra funzione e cambiare una chiamata di funzione con un nuovo frame dello stack in un salto.

Realizza che il classico metodo fattoriale:

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

non è ottimizzabile per le chiamate di coda a causa dell'ispezione necessaria al ritorno. ( Esempio di codice sorgente e output compilato )

Per rendere ottimizzabile questa chiamata in coda,

int _fact(int n, int acc) {
    if(n == 1) return acc;
    return _fact(n - 1, acc * n);
}

int factorial(int n) {
    if(n == 0) return 1;
    return _fact(n, 1);
}

Compilando questo codice con gcc -O2 -S fact.c(il -O2 è necessario per abilitare l'ottimizzazione nel compilatore, ma con più ottimizzazioni del -O3 diventa difficile da leggere per un essere umano ...)

_fact(int, int):
    cmpl    $1, %edi
    movl    %esi, %eax
    je  .L2
.L3:
    imull   %edi, %eax
    subl    $1, %edi
    cmpl    $1, %edi
    jne .L3
.L2:
    rep ret

( Esempio di codice sorgente e output compilato )

Si può vedere nel segmento .L3, jnepiuttosto che un call(che fa una chiamata di subroutine con un nuovo frame dello stack).

Si noti che questo è stato fatto con C. L'ottimizzazione delle chiamate di coda in Java è difficile e dipende dall'implementazione di JVM (detto ciò, non ne ho visto nessuno che lo fa, perché è difficile e le implicazioni del modello di sicurezza Java richiesto che richiede stack frame - che è ciò che evita il TCO) - coda-ricorsione + java e coda-ricorsione + ottimizzazione sono buoni set di tag da sfogliare. Si possono trovare altre lingue JVM sono in grado di ottimizzare la ricorsione in coda migliore (prova clojure (che richiede il ripresentarsi alla chiamata di coda ottimizzare), o di scala).

Detto ciò,

C'è una certa gioia nel sapere che hai scritto qualcosa di giusto - nel modo ideale che può essere fatto.
E ora, vado a prendere un po 'di scotch e mettere un po' di elettronica tedesca ...


Alla domanda generale di "metodi per evitare un overflow dello stack in un algoritmo ricorsivo" ...

Un altro approccio è quello di includere un contatore di ricorsione. Questo è più per rilevare infiniti loop causati da situazioni al di fuori del proprio controllo (e scarsa codifica).

Il contatore di ricorsione assume la forma di

int foo(arg, counter) {
  if(counter > RECURSION_MAX) { return -1; }
  ...
  return foo(arg, counter + 1);
}

Ogni volta che si effettua una chiamata, si incrementa il contatore. Se il contatore diventa troppo grande, ti sbagli (qui, solo un ritorno di -1, anche se in altre lingue potresti preferire lanciare un'eccezione). L'idea è di impedire che accadano cose peggiori (errori di memoria insufficiente) quando si esegue una ricorsione che è molto più profonda del previsto e probabilmente un ciclo infinito.

In teoria, non dovresti averne bisogno. In pratica, ho visto codice scritto male che ha colpito questo a causa di una miriade di piccoli errori e cattive pratiche di codifica (problemi di concorrenza multithread in cui qualcosa cambia qualcosa al di fuori del metodo che fa passare un altro thread in un ciclo infinito di chiamate ricorsive).


Usa l'algoritmo giusto e risolvi il problema giusto. In particolare per la congettura di Collatz, sembra che tu stia cercando di risolverlo nel modo xkcd :

XKCD # 710

Stai iniziando da un numero e stai attraversando un albero. Ciò porta rapidamente a uno spazio di ricerca molto ampio. Una corsa veloce per calcolare il numero di iterazioni per la risposta corretta risulta in circa 500 passaggi. Questo non dovrebbe essere un problema per la ricorsione con un frame stack piccolo.

Pur conoscendo la soluzione ricorsiva non è una brutta cosa, si dovrebbe anche rendersi conto che molte volte la soluzione iterativa è migliore . Un certo numero di modi per avvicinarsi alla conversione di un algoritmo ricorsivo in uno iterativo può essere visto su Stack Overflow at Way per passare dalla ricorsione all'iterazione .


1
Mi sono imbattuto in quel cartone xkcd oggi mentre navigavo sul web. :-) I cartoni di Randall Munroe sono una delizia.
Lernkurve,

@Lernkurve Ho notato l'aggiunta della modifica del codice dopo aver iniziato a scrivere questo (e pubblicato). Hai bisogno di altri esempi di codice per questo?

No, per niente. È perfetto. Grazie mille per avermelo chiesto!
Lernkurve,

Posso suggerire di aggiungere anche questo fumetto: imgs.xkcd.com/comics/functional.png
Ellen Spertus,

@espertus grazie. L'ho aggiunto (ripulito un po 'di generazione di sorgente e aggiunto un po' di più)

17

Tieni presente che l'implementazione del linguaggio deve supportare l'ottimizzazione della ricorsione della coda. Non penso che lo facciano i principali compilatori Java.

Memoization significa che ricordi il risultato di un calcolo anziché ricalcolarlo ogni volta, come:

collatz(i):
    if i in memoized:
        return memoized[i]

    if i == 1:
        memoized[i] = 1
    else if odd(i):
        memoized[i] = 1 + collatz(3*i + 1)
    else
        memoized[i] = 1 + collatz(i / 2)

    return memoized[i]

Quando stai calcolando ogni sequenza meno di un milione, ci sarà molta ripetizione alla fine delle sequenze. La memoizzazione rende una rapida ricerca nella tabella di hash per i valori precedenti invece di dover rendere lo stack sempre più profondo.


1
Spiegazione molto comprensibile della memoizzazione. Soprattutto, grazie per averlo illustrato con uno snippet di codice. Inoltre, "ci sarà molta ripetizione alla fine delle sequenze" mi ha chiarito le cose. Grazie.
Lernkurve,

10

Sono sorpreso che nessuno abbia ancora menzionato il trampolino . Un trampolino (in questo senso) è un loop che invoca in modo iterativo funzioni di ritorno thunk (stile di passaggio di continuazione) e può essere utilizzato per implementare chiamate di funzione ricorsive di coda in una lingua di programmazione orientata allo stack.

Questa domanda StackOverflow va in un bel po 'più in dettaglio su diverse implementazioni di trampolino in Java: Manipolazione StackOverflow in Java per Trampoline


Ci ho pensato anche subito. I trampolini sono un metodo per eseguire l'ottimizzazione delle chiamate in coda, quindi le persone (quasi-forse-forse) lo dicono. +1 Per il riferimento specifico.
Steven Evers,

6

Se si utilizza un linguaggio e compilatore che riconoscono funzioni ricorsive di coda e li gestisce correttamente (vale a dire "sostituisce il chiamante in posizione con il chiamato"), allora sì, la pila non dovrebbe crescere fuori controllo. Questa ottimizzazione essenzialmente riduce un metodo ricorsivo a uno iterativo. Non penso che Java lo faccia, ma so che lo fa Racket.

Se segui un approccio iterativo, piuttosto che un approccio ricorsivo, stai eliminando gran parte della necessità di ricordare da dove provengono le chiamate e praticamente eliminando la possibilità di un overflow dello stack (dalle chiamate ricorsive comunque).

La memorizzazione è ottima e può ridurre il numero complessivo di chiamate di metodo cercando in una cache i risultati calcolati in precedenza, dato che il calcolo complessivo comporterà molti calcoli più piccoli e ripetuti. Questa idea è fantastica - è anche indipendente dal fatto che tu stia usando o meno un approccio iterativo o ricorsivo.


1
Il +1 per indicare la memoizzazione è utile anche negli approcci iterativi.
Karl Bielefeldt,

Tutti i linguaggi di programmazione funzionale hanno l'ottimizzazione delle chiamate di coda.

3

potresti creare un'enumerazione che sostituirà la ricorsione ... ecco un esempio per calcolare la facoltà facendolo ... (non funzionerò per grandi numeri come ho usato solo a lungo nell'esempio :-))

public class Faculty
{

    public static IEnumerable<long> Faculties(long n)
    {
        long stopat = n;

        long x = 1;
        long result = 1;

        while (x <= n)
        {
            result = result * x;
            yield return result;
            x++;
        }
    }
}

anche se non si tratta di memoization, in questo modo si annullerà un overflow dello stack


MODIFICARE


Mi dispiace se ho fatto arrabbiare alcuni di voi. La mia unica intenzione era quella di mostrare un modo per evitare un overflow dello stack. Probabilmente avrei dovuto scrivere un esempio di codice completo anziché solo una piccola parte di un estratto di codice scritto in modo rapido e approssimativo.

Il seguente codice

  • evita la ricorsione poiché uso calcolare i valori richiesti in modo iterativo.
  • include la memoizzazione poiché i valori già calcolati vengono archiviati e recuperati se già calcolati
  • include anche un cronometro, quindi puoi vedere che la memoization funziona correttamente

... umm ... se lo esegui assicurati di impostare la finestra della shell dei comandi in modo che abbia un buffer di 9999 righe ... i soliti 300 non saranno sufficienti per scorrere i risultati del programma qui sotto ...

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Text;
using System.Threading.Tasks;
using System.Timers;

namespace ConsoleApplication1
{
    class Program
    {
        static Stopwatch w = new Stopwatch();
        static Faculty f = Faculty.GetInstance();

        static void Main(string[] args)
        {
            Out(5);
            Out(10);
            Out(-5);
            Out(0);
            Out(1);
            Out(4);
            Out(29);
            Out(30);
            Out(20);
            Out(10000);
            Out(20000);
            Out(19999);
            Console.ReadKey();
        }

        static void Out(BigInteger n)
        {
             try
            {
                w.Reset();
                w.Start();
                var x = f.Calculate(n);
                w.Stop();
                var time = w.ElapsedMilliseconds;
                Console.WriteLine(String.Format("{0} ({2}ms): {1}", n, x, time));
            }
            catch (ArgumentException e)
            {
                Console.WriteLine(e.Message);
            }

            Console.WriteLine("\n\n");
       }
    }

Dichiaro * 1 variabile statica "istanza" nella classe Faculty in un negozio un singleton. In questo modo finché il programma è in esecuzione, ogni volta che "GetInstance ()" della classe si ottiene l'istanza che ha archiviato tutti i valori già calcolati. * 1 SortedList statico che conterrà tutti i valori già calcolati

Nel costruttore aggiungo anche 2 valori speciali dell'elenco 1 per gli ingressi 0 e 1.

    public class Faculty
    {
        private static SortedList<BigInteger, BigInteger> _values; 
        private static Faculty _faculty {get; set;}

        private Faculty ()
        {
            _values = new SortedList<BigInteger, BigInteger>();
            _values.Add(0, 1);
            _values.Add(1, 1);
        }

        public static Faculty GetInstance() {
            _faculty = _faculty ?? new Faculty();
            return _faculty;
        }

        public BigInteger Calculate(BigInteger n) 
        {
            // check if input is smaller 0
            if (n < 0)
                throw new ArgumentException(" !!! Faculty is not defined for values < 0 !!!");

            // if value is not already calculated => do so
            if(!_values.ContainsKey(n))
                Faculties(n);

            // retrieve n! from Sorted List
            return _values[n];
        }

        private static void Faculties(BigInteger n)
        {
            // get the last calculated values and continue calculating if the calculation for a bigger n is required
            BigInteger i = _values.Max(x => x.Key),
                           result = _values[i];

            while (++i <= n)
            {
                CalculateNext(ref result, i);
                // add value to the SortedList if not already done
                if (!_values.ContainsKey(i))
                    _values.Add(i, result);
            }
        }

        private static void CalculateNext(ref BigInteger lastresult, BigInteger i) {

            // put in whatever iterative calculation step you want to do
            lastresult = lastresult * i;

        }
    }
}

5
tecnicamente questa è iterazione quando hai rimosso completamente qualsiasi ricorsione
maniaco del cricchetto,

che è :-) e memorizza i risultati all'interno delle variabili dei metodi tra ogni passaggio di calcolo
Ingo

2
Penso che fraintenda la memoisation, cioè quando la facoltà (100) viene chiamata la prima volta che calcola il risultato e lo memorizza in un hash e lo restituisce, quindi quando viene richiamato viene restituito il risultato memorizzato
maniaco del cricco

@jk. A suo merito, in realtà non dice mai che questo è ricorsivo.
Neil,

anche se non si tratta di memoization, in questo modo si annullerà uno stack overflow
Ingo

2

Per quanto riguarda Scala, è possibile aggiungere l' @tailrecannotazione a un metodo ricorsivo. In questo modo il compilatore garantisce che l'ottimizzazione delle chiamate in coda abbia effettivamente avuto luogo:

Quindi questo non verrà compilato (fattoriale):

@tailrec
def fak1(n: Int): Int = {
  n match {
    case 0 => 1
    case _ => n * fak1(n - 1)
  }
}

il messaggio di errore è:

scala: impossibile ottimizzare il metodo annotato @tailrec fak1: contiene una chiamata ricorsiva non in posizione di coda

D'altro canto:

def fak3(n: Int): Int = {
  @tailrec
  def fak3(n: Int, result: Int): Int = {
    n match {
      case 0 => result
      case _ => fak3(n - 1, n * result)
    }
  }

  fak3(n, 1)
}

compilazioni e ottimizzazione delle chiamate in coda.


1

Una possibilità che non è stata ancora menzionata è quella di ricorrere alla ricorsione, ma senza utilizzare uno stack di sistema. Ovviamente puoi anche sovraccaricare il tuo heap, ma se il tuo algoritmo ha davvero bisogno di tornare indietro in un modo o nell'altro (perché altrimenti usare la ricorsione a tutti?), Non hai scelta.

Esistono implementazioni stackless di alcuni linguaggi, ad esempio Stackless Python .


0

Un'altra soluzione sarebbe quella di simulare il proprio stack e non fare affidamento sull'implementazione del compilatore + runtime. Questa non è una soluzione semplice né veloce ma teoricamente otterrai StackOverflow solo quando hai esaurito la memoria.

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.