web-dev-qa-db-fra.com

Quelles sont les méthodes pour éviter un débordement de pile dans un algorithme récursif?

Question

Quels sont les moyens possibles de résoudre un débordement de pile provoqué par un algorithme récursif?

Exemple

J'essaie de résoudre problème Project Euler 14 et j'ai décidé de l'essayer avec un algorithme récursif. Cependant, le programme s'arrête avec une Java.lang.StackOverflowError. Naturellement. L'algorithme a en effet débordé la pile car j'ai essayé de générer une séquence Collatz pour un très grand nombre.

Solutions

Je me demandais donc: quelles sont les méthodes standard pour résoudre un débordement de pile en supposant que votre algorithme récursif était écrit correctement et finirait toujours par déborder de la pile? Deux concepts qui me sont venus à l'esprit étaient:

  1. récursivité de la queue
  2. itération

Les idées (1) et (2) sont-elles correctes? Y a-t-il d'autres options?

Modifier

Il serait utile de voir du code, de préférence en Java, C #, Groovy ou Scala.

Peut-être n'utilisez pas le problème Project Euler mentionné ci-dessus afin qu'il ne soit pas gâché pour les autres, mais prenez un autre algorithme. Factorielle peut-être, ou quelque chose de similaire.

44
Lernkurve

Optimisation des appels de queue est présent dans de nombreux langages et compilateurs. Dans cette situation, le compilateur reconnaît une fonction du formulaire:

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

Ici, le langage est capable de reconnaître que le résultat renvoyé est le résultat d'une autre fonction et de changer un appel de fonction avec une nouvelle trame de pile en saut.

Sachez que la méthode factorielle classique:

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

n'est pas pas appel de queue optimisable en raison de l'inspection nécessaire au retour. ( Exemple de code source et sortie compilée )

Pour rendre cet appel de queue optimisable,

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

Compilation de ce code avec gcc -O2 -S fact.c (le -O2 est nécessaire pour activer l'optimisation dans le compilateur, mais avec plus d'optimisations de -O3, il devient difficile pour un humain de lire ...)

_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

( Exemple de code source et sortie compilée )

On peut voir dans le segment .L3, le jne plutôt qu'un call (qui effectue un appel de sous-routine avec un nouveau cadre de pile).

Veuillez noter que cela a été fait avec l'optimisation des appels C. Tail en Java est difficile et dépend de la mise en œuvre de la JVM (cela dit, je n'en ai vu aucun le faire, car il est difficile et ses implications). du modèle de sécurité requis Java nécessitant des trames de pile - ce que TCO évite) - récursion + Java et récursion + optimisation sont de bons ensembles de balises à parcourir. Vous pouvez trouver que d'autres langages JVM sont capables d'optimiser la récursion de queue mieux (essayez clojure (qui nécessite le recur pour optimiser l'appel de queue), ou scala).

Cela dit,

Il y a une certaine joie à savoir que vous avez écrit quelque chose à droite - de la manière idéale que cela peut être fait.
Et maintenant, je vais prendre du scotch et mettre de l'électronique allemande ...


A la question générale des "méthodes pour éviter un débordement de pile dans un algorithme récursif" ...

Une autre approche consiste à inclure un compteur de récursivité. C'est plus pour détecter des boucles infinies causées par des situations indépendantes de sa volonté (et un mauvais codage).

Le compteur de récursivité prend la forme de

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

Chaque fois que vous passez un appel, vous augmentez le compteur. Si le compteur devient trop grand, vous faites une erreur (ici, juste un retour de -1, bien que dans d'autres langues, vous préférerez peut-être lever une exception). L'idée est d'éviter que des choses pires ne se produisent (erreurs de mémoire insuffisantes) lors d'une récursion beaucoup plus profonde que prévu et probablement une boucle infinie.

En théorie, vous ne devriez pas en avoir besoin. En pratique, j'ai vu du code mal écrit qui a frappé cela en raison d'une pléthore de petites erreurs et de mauvaises pratiques de codage (problèmes de concurrence multithread où quelque chose change quelque chose en dehors de la méthode qui fait qu'un autre thread entre dans une boucle infinie d'appels récursifs).


Utilisez le bon algorithme et résolvez le bon problème. Spécifiquement pour la conjecture Collatz, il apparaît que vous essayez de résoudre de la manière xkcd :

XKCD #710

Vous commencez à un nombre et faites une traversée d'arbre. Cela conduit rapidement à un très grand espace de recherche. Une exécution rapide pour calculer le nombre d'itérations pour la réponse correcte se traduit par environ 500 étapes. Cela ne devrait pas être un problème de récursivité avec un petit cadre de pile.

Bien que connaître la solution récursive ne soit pas une mauvaise chose, il faut également se rendre compte que plusieurs fois la solution itérative c'est mieux . Un certain nombre de façons d'aborder la conversion d'un algorithme récursif en un algorithme itératif peut être vu sur Stack Overflow à Chemin à parcourir de la récursivité à l'itération .

35
user40980

Gardez à l'esprit que l'implémentation du langage doit prendre en charge l'optimisation de la récursivité de queue. Je ne pense pas que les principaux compilateurs Java font.

La mémorisation signifie que vous vous souvenez du résultat d'un calcul plutôt que de le recalculer à chaque fois, comme:

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]

Lorsque vous calculez chaque séquence de moins d'un million, il y aura beaucoup de répétitions à la fin des séquences. La mémorisation en fait une recherche rapide de table de hachage pour les valeurs précédentes au lieu d'avoir à rendre la pile de plus en plus profonde.

17
Karl Bielefeldt

Je suis surpris que personne n'ait encore mentionné trampoline . Un trampoline (dans ce sens) est une boucle qui invoque de manière itérative des fonctions de retour de thunk (style de passage de continuation) et peut être utilisé pour implémenter des appels de fonction récursifs à la queue dans une langue de programmation orientée pile.

Cette question sur StackOverflow donne un peu plus de détails sur les différentes implémentations du trampoline en Java: Gestion de StackOverflow dans Java pour Trampoline

10
Rein Henrichs

Si vous utilisez un langage et un compilateur qui reconnaissent les fonctions récursives de queue et les gère correctement (c'est-à-dire "remplace l'appelant en place par l'appelé"), alors oui, la pile ne devrait pas devenir incontrôlable. Cette optimisation réduit essentiellement une méthode récursive à une méthode itérative. Je ne pense pas Java fait cela, mais je sais que Racket le fait.

Si vous optez pour une approche itérative, plutôt qu'une approche récursive, vous supprimez une grande partie du besoin de vous rappeler d'où viennent les appels et éliminez pratiquement le risque de débordement de pile (des appels récursifs de toute façon).

La mémorisation est excellente et peut réduire le nombre total d'appels de méthode en recherchant les résultats précédemment calculés dans un cache, étant donné que votre calcul global entraînera de nombreux calculs plus petits et répétés. Cette idée est excellente - elle est également indépendante du fait que vous utilisiez ou non une approche itérative ou récursive.

6
Benjamin Brumfield

vous pouvez créer une énumération qui remplacera la récursivité ... voici un exemple pour calculer la faculté en faisant cela ... (ne fonctionnera pas pour les grands nombres car je ne l'utilise que depuis longtemps dans l'exemple :-))

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

même si ce n'est pas de la mémorisation, vous annulerez ainsi un débordement de pile


MODIFIER


Je suis désolé si j'ai contrarié certains d'entre vous. Ma seule intention était de montrer comment éviter un débordement de pile. J'aurais probablement dû écrire un exemple de code complet au lieu d'un petit morceau d'un extrait de code rapidement écrit et approximatif.

Le code suivant

  • évite la récursivité car j'utilise calculer les valeurs requises de manière itérative.
  • inclut la mémorisation car les valeurs déjà calculées sont stockées et récupérées si elles sont déjà calculées
  • comprend également un chronomètre, vous pouvez donc voir que la mémorisation fonctionne correctement

... euh ... si vous l'exécutez, assurez-vous de définir votre fenêtre de commande Shell pour avoir un tampon de 9999 lignes ... les 300 habituels ne suffiront pas pour parcourir les résultats du programme ci-dessous ...

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

Je déclare * 1 variable statique "instance" dans la classe Faculty à un magasin un singleton. De cette façon, tant que votre programme est en cours d'exécution, chaque fois que vous "GetInstance ()" de la classe, vous obtenez l'instance qui a stocké toutes les valeurs déjà calculées. * 1 SortedList statique qui contiendra toutes les valeurs déjà calculées

Dans le constructeur, j'ajoute également 2 valeurs spéciales de la liste 1 pour les entrées 0 et 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;

        }
    }
}
3
Ingo

Quant à Scala, vous pouvez ajouter le @tailrec annotation à une méthode récursive. De cette façon, le compilateur assure que l'optimisation des appels de queue a effectivement eu lieu:

Donc, cela ne compilera pas (factoriel):

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

le message d'erreur est:

scala: impossible d'optimiser la méthode annotée @tailrec fak1: elle contient un appel récursif pas en position de queue

D'autre part:

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

compile et l'optimisation des appels de queue a eu lieu.

2
Beryllium

Une possibilité qui n'a pas encore été mentionnée est la récursivité, mais sans utiliser de pile système. Bien sûr, vous pouvez également déborder votre tas, mais si votre algorithme a vraiment besoin de revenir en arrière sous une forme ou une autre (pourquoi utiliser la récursivité sinon?), Vous n'avez pas le choix.

Il existe des implémentations sans pile de certains langages, par ex. Python sans pile .

1
SK-logic

Une autre solution serait de simuler votre propre pile et de ne pas compter sur l'implémentation du compilateur + runtime. Ce n'est pas une solution simple ni rapide, mais théoriquement, vous n'obtiendrez StackOverflow que lorsque vous êtes à court de mémoire.

0
m3th0dman