web-dev-qa-db-fra.com

Y a-t-il quelque chose qui peut être fait avec la récursivité qui ne peut pas être fait avec des boucles?

Il y a des moments où l'utilisation de la récursivité est meilleure que l'utilisation d'une boucle, et des moments où l'utilisation d'une boucle est meilleure que l'utilisation de la récursivité. Choisir le "bon" peut économiser des ressources et/ou réduire le nombre de lignes de code.

Y a-t-il des cas où une tâche ne peut être effectuée qu'en utilisant la récursivité, plutôt qu'une boucle?

130
Pikamander2

Oui et non. En fin de compte, il n'y a rien que la récursion puisse calculer que le bouclage ne peut pas, mais le bouclage prend beaucoup plus de plomberie. Par conséquent, la récursion que les boucles ne peuvent pas faire est de rendre certaines tâches super faciles.

Prenez la marche d'un arbre. Marcher dans un arbre avec récursivité est stupide et facile. C'est la chose la plus naturelle du monde. Marcher sur un arbre avec des boucles est beaucoup moins simple. Vous devez conserver une pile ou une autre structure de données pour suivre ce que vous avez fait.

Souvent, la solution récursive à un problème est plus jolie. C'est un terme technique et c'est important.

165
Scant Roger

Non.

Pour en venir aux très notions de base des minimums nécessaires pour calculer, il suffit de pouvoir boucler (cela seul n'est pas suffisant, mais est plutôt un composant nécessaire). Peu importe comment.

Tout langage de programmation qui peut implémenter une machine de Turing, est appelé Turing complet . Et il y a beaucoup de langues qui sont complètes.

Ma langue préférée dans la voie de "ça marche vraiment?" La complétude de Turing est celle de FRACTRAN , qui est Turing complete . Il a une structure en boucle et vous pouvez y implémenter une machine de Turing. Ainsi, tout ce qui est calculable peut être implémenté dans un langage qui n'a pas de récursivité. Par conséquent, il y a rien que la récursivité peut vous donner en termes de calcul que le simple bouclage ne peut pas.

Cela se résume vraiment à quelques points:

  • Tout ce qui est calculable est calculable sur une machine de Turing
  • Tout langage pouvant implémenter une machine Turing (appelé Turing complete), peut calculer tout ce que n'importe quel autre langage peut
  • Puisqu'il y a des machines Turing dans des langages qui manquent de récursivité (et il y en a d'autres qui seulement ont une récursivité quand vous entrez dans certains des autres esolangs), il est nécessairement vrai qu'il n'y a rien que vous puissiez faire avec la récursion que vous ne pouvez pas faire avec une boucle (et rien que vous pouvez faire avec une boucle que vous ne pouvez pas faire avec la récursivité).

Cela ne veut pas dire qu'il existe certaines classes de problèmes qui peuvent être plus facilement envisagées avec la récursivité plutôt qu'avec le bouclage, ou avec le bouclage plutôt qu'avec la récursivité. Cependant, ces outils sont également tout aussi puissants.

Et bien que j'aie poussé cela à l'extrême 'esolang' (principalement parce que vous pouvez trouver des choses qui sont Turing complètes et implémentées de manière plutôt étrange), cela ne signifie pas que les esolangs sont en aucun cas facultatifs. Il y a un tout liste des choses qui sont accidentellement terminées y compris Magic the Gathering, Sendmail, les modèles MediaWiki et le système de type Scala. Beaucoup d'entre eux sont loin d'être optimal quand il s'agit de faire quelque chose de pratique, c'est juste que vous pouvez calculer tout ce qui est calculable en utilisant ces outils.


Cette équivalence peut devenir particulièrement intéressante lorsque vous entrez dans un type particulier de récursivité appelé appel de queue .

Si vous avez, disons, une méthode factorielle écrite comme:

int fact(int n) {
    return fact(n, 1);
}

int fact(int n, int accum) {
    if(n == 0) { return 1; }
    if(n == 1) { return accum; }
    return fact(n-1, n * accum);
}

Ce type de récursivité sera réécrit en boucle - aucune pile utilisée. De telles approches sont en effet souvent plus élégantes et plus faciles à comprendre que la boucle équivalente en cours d'écriture, mais encore une fois, pour chaque appel récursif, il peut y avoir une boucle équivalente écrite et pour chaque boucle, il peut y avoir un appel récursif écrit.

Il y a aussi des moments où la conversion de la boucle simple en appel récursif d'appel de fin peut être compliquée et plus difficile à comprendre.


Si vous voulez entrer dans le côté théorique de celui-ci, consultez la Thèse de Church Turing . Vous pouvez également trouver la church-turing-thesis sur CS.SE utile.

79
user40980

Y a-t-il des cas où une tâche ne peut être effectuée qu'en utilisant la récursivité, plutôt qu'une boucle?

Vous pouvez toujours transformer un algorithme récursif en une boucle, qui utilise une structure de données Last-In-First-Out (pile AKA) pour stocker l'état temporaire, car l'appel récursif est exactement cela, en stockant l'état actuel dans une pile, en poursuivant avec l'algorithme, puis restaurer plus tard l'état. Donc, la réponse courte est: Non, il n'y a pas de tels cas .

Cependant, un argument peut être avancé pour "oui". Prenons un exemple concret et simple: fusionner le tri. Vous devez diviser les données en deux parties, fusionner, trier les parties, puis les combiner. Même si vous ne faites pas un appel de fonction de langage de programmation réel pour fusionner le tri afin de fusionner le tri sur les pièces, vous devez implémenter des fonctionnalités identiques à celles d'un appel de fonction (état push vers votre propre pile, passez à début de boucle avec différents paramètres de démarrage, puis pop plus tard l'état de votre pile).

S'agit-il d'une récursivité, si vous implémentez vous-même l'appel de récursivité, en tant qu'étapes distinctes "Etat push" et "Aller au début" et "Etat pop"? Et la réponse à cela est: Non, cela ne s'appelle toujours pas récursivité, cela s'appelle itération avec pile explicite ( si vous souhaitez utiliser la terminologie établie).


Notez que cela dépend également de la définition de "tâche". Si la tâche consiste à trier, vous pouvez le faire avec de nombreux algorithmes, dont beaucoup n'ont besoin d'aucune sorte de récursivité. Si la tâche consiste à implémenter un algorithme spécifique, comme le tri par fusion, alors l'ambiguïté ci-dessus s'applique.

Examinons donc la question: existe-t-il des tâches générales pour lesquelles il n'existe que des algorithmes de type récursif. D'après le commentaire de @WizardOfMenlo sous la question, fonction Ackermann en est un exemple simple. Le concept de récursivité est donc autonome, même s'il peut être implémenté avec une construction de programme informatique différente (itération avec pile explicite).

31
hyde

Cela dépend de la façon dont vous définissez strictement la "récursivité".

Si nous l'exigeons strictement pour impliquer la pile d'appels (ou tout autre mécanisme utilisé pour maintenir l'état du programme), nous pouvons toujours le remplacer par quelque chose qui ne le fait pas. En effet, les langages qui conduisent naturellement à une utilisation intensive de la récursivité ont tendance à avoir des compilateurs qui font un usage intensif de l'optimisation des appels de queue, donc ce que vous écrivez est récursif mais ce que vous exécutez est itératif.

Mais considérons un cas où nous effectuons un appel récursif et utilisons le résultat d'un appel récursif pour cet appel récursif.

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
  if (m == 0)
    return  n+1;
  if (n == 0)
    return Ackermann(m - 1, 1);
  else
    return Ackermann(m - 1, Ackermann(m, n - 1));
}

Rendre le premier appel récursif itératif est facile:

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
restart:
  if (m == 0)
    return  n+1;
  if (n == 0)
  {
    m--;
    n = 1;
    goto restart;
  }
  else
    return Ackermann(m - 1, Ackermann(m, n - 1));
}

Nous pouvons ensuite nettoyer le goto pour conjurer velociraptors et la nuance de Dijkstra:

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
  while(m != 0)
  {
    if (n == 0)
    {
      m--;
      n = 1;
    }
    else
      return Ackermann(m - 1, Ackermann(m, n - 1));
  }
  return  n+1;
}

Mais pour supprimer les autres appels récursifs, nous allons devoir stocker les valeurs de certains appels dans une pile:

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
  Stack<BigInteger> stack = new Stack<BigInteger>();
  stack.Push(m);
  while(stack.Count != 0)
  {
    m = stack.Pop();
    if(m == 0)
      n = n + 1;
    else if(n == 0)
    {
      stack.Push(m - 1);
      n = 1;
    }
    else
    {
      stack.Push(m - 1);
      stack.Push(m);
      --n;
    }
  }
  return n;
}

Maintenant, lorsque nous considérons le code source, nous avons certainement transformé notre méthode récursive en une méthode itérative.

Compte tenu de ce à quoi cela a été compilé, nous avons transformé le code qui utilise la pile d'appels pour implémenter la récursivité en code qui ne le fait pas (et ce faisant, il a transformé le code qui lèvera une exception de dépassement de pile pour les valeurs même assez petites en code qui ne fera que prendre un temps atrocement long pour retourner [voir Comment puis-je empêcher ma fonction Ackerman de déborder la pile? pour quelques optimisations supplémentaires qui le font réellement revenir pour beaucoup plus d'entrées possibles]).

Compte tenu de la manière dont la récursivité est généralement implémentée, nous avons transformé le code qui utilise la pile des appels en code qui utilise une pile différente pour contenir les opérations en attente. Nous pourrions donc affirmer qu'elle est encore récursive, lorsqu'elle est considérée à ce niveau bas.

Et à ce niveau, il n'y a en effet pas d'autre solution. Donc, si vous considérez cette méthode comme récursive, alors il y a effectivement des choses que nous ne pouvons pas faire sans. Généralement, nous ne qualifions pas ce code de récursif. Le terme récursivité est utile car il couvre un certain ensemble d'approches et nous donne un moyen d'en parler, et nous n'utilisons plus l'une d'entre elles .

Bien sûr, tout cela suppose que vous avez le choix. Il existe à la fois des langages qui interdisent les appels récursifs et des langages dépourvus des structures de bouclage nécessaires à l'itération.

20
Jon Hanna

La réponse classique est "non", mais permettez-moi d'expliquer pourquoi je pense que "oui" est une meilleure réponse.


Avant de continuer, mettons de côté quelque chose: du point de vue calculabilité et complexité:

  • La réponse est "non" si vous êtes autorisé à avoir une pile auxiliaire lors de la boucle.
  • La réponse est "oui" si aucune donnée supplémentaire n'est autorisée lors du bouclage.

Bon, maintenant, mettons un pied en terrain d'entraînement, gardons l'autre pied en terrain théorique.


La pile d'appels est une structure de contrôle , tandis qu'une pile manuelle est une structure ( structure des données . Le contrôle et les données ne sont pas des concepts égaux, mais ils sont équivalents dans le sens qu'ils peuvent être réduits les uns aux autres (ou "émulés" les uns par rapport aux autres) d'un point de vue calculabilité ou complexité.

Quand cette distinction pourrait-elle être importante? Lorsque vous travaillez avec des outils du monde réel. Voici un exemple:

Supposons que vous implémentez N-way mergesort. Vous pouvez avoir une boucle for qui traverse chacun des segments N, appelle mergesort séparément, puis fusionne les résultats.

Comment pourriez-vous paralléliser cela avec OpenMP?

Dans le domaine récursif, c'est extrêmement simple: il suffit de mettre #pragma omp parallel for autour de votre boucle qui va de 1 à N, et vous avez terminé. Dans le domaine itératif, vous ne pouvez pas faire cela. Vous devez générer des threads manuellement et leur transmettre manuellement les données appropriées afin qu'ils sachent quoi faire.

D'un autre côté, il existe d'autres outils (comme les vectoriseurs automatiques, par exemple #pragma vector) qui fonctionnent avec des boucles mais sont totalement inutiles avec la récursivité.

Ce n'est pas parce que vous pouvez prouver que les deux paradigmes sont mathématiquement équivalents, cela ne signifie pas qu'ils sont égaux dans la pratique. Un problème qui pourrait être trivial à automatiser dans un paradigme (disons, la parallélisation de boucle) pourrait être beaucoup plus difficile à résoudre dans l'autre paradigme.

par exemple: Les outils d'un paradigme ne se traduisent pas automatiquement dans d'autres paradigmes.

Par conséquent, si vous avez besoin d'un outil pour résoudre un problème, il est probable que l'outil ne fonctionnera qu'avec un type d'approche particulier et, par conséquent, vous ne pourrez pas résoudre le problème avec une approche différente, même si vous pouvez prouver mathématiquement que le problème peut être résolu de toute façon.

9
user541686

Mis à part le raisonnement théorique, examinons à quoi ressemblent la récursivité et les boucles du point de vue d'une machine (matérielle ou virtuelle). La récursivité est une combinaison de flux de contrôle qui permet de démarrer l'exécution de certains codes et de revenir à la fin (dans une vue simpliste lorsque les signaux et les exceptions sont ignorés) et de données qui sont transmises à cet autre code (arguments) et qui sont renvoyées depuis il (résultat). Habituellement, aucune gestion de mémoire explicite n'est impliquée, mais il existe une allocation implicite de mémoire de pile pour enregistrer les adresses de retour, les arguments, les résultats et les données locales intermédiaires.

Une boucle est une combinaison de flux de contrôle et de données locales. En comparant cela à la récursivité, nous pouvons voir que la quantité de données dans ce cas est fixe. La seule façon de briser cette limitation est d'utiliser la mémoire dynamique (également connue sous le nom de tas ) qui peut être alloué (et libéré) chaque fois que nécessaire.

Résumer:

  • cas de récursion = flux de contrôle + pile (+ tas)
  • Cas de boucle = Flux de contrôle + Tas

En supposant que la partie du flux de contrôle est raisonnablement puissante, la seule différence réside dans les types de mémoire disponibles. Il nous reste donc 4 cas (le pouvoir d'expressivité est indiqué entre parenthèses):

  1. Pas de pile, pas de tas: la récursivité et les structures dynamiques sont impossibles. (récursion = boucle)
  2. Pile, pas de tas: la récursivité est OK, les structures dynamiques sont impossibles. (récursivité> boucle)
  3. Pas de pile, tas: la récursivité est impossible, les structures dynamiques sont OK. (récursion = boucle)
  4. Pile, tas: les structures récursives et dynamiques sont OK. (récursion = boucle)

Si les règles du jeu sont un peu plus strictes et que l'implémentation récursive n'est pas autorisée à utiliser des boucles, nous obtenons ceci à la place:

  1. Pas de pile, pas de tas: la récursivité et les structures dynamiques sont impossibles. (récursion <boucle)
  2. Pile, pas de tas: la récursivité est OK, les structures dynamiques sont impossibles. (récursivité> boucle)
  3. Pas de pile, tas: la récursivité est impossible, les structures dynamiques sont OK. (récursion <boucle)
  4. Pile, tas: les structures récursives et dynamiques sont OK. (récursion = boucle)

La principale différence avec le scénario précédent est que le manque de mémoire de pile ne permet pas à la récursivité sans boucles d'effectuer plus d'étapes pendant l'exécution qu'il n'y a de lignes de code.

8

Oui. Il existe plusieurs tâches courantes qui sont faciles à accomplir à l'aide de la récursivité mais impossibles avec seulement des boucles:

  • Causer des débordements de pile.
  • Programmeurs débutants totalement déroutants.
  • Création de fonctions d'aspect rapide qui sont en fait O (n ^ n).
2
jpa

Il y a une différence entre les fonctions récursives et les fonctions récursives primitives. Les fonctions récursives primitives sont celles qui sont calculées à l'aide de boucles, où le nombre d'itérations maximal de chaque boucle est calculé avant le début de l'exécution de la boucle. (Et "récursif" ici n'a rien à voir avec l'utilisation de la récursivité).

Les fonctions récursives primitives sont strictement moins puissantes que les fonctions récursives. Vous obtiendriez le même résultat si vous preniez des fonctions qui utilisent la récursivité, où la profondeur maximale de la récursivité doit être calculée au préalable.

1
gnasher729

Si vous programmez en c ++ et utilisez c ++ 11, il y a une chose à faire en utilisant les récursions: les fonctions constexpr. Mais la norme limite cela à 512, comme expliqué dans cette réponse . L'utilisation de boucles dans ce cas n'est pas possible, car dans ce cas, la fonction ne peut pas être constexpr, mais cela est modifié en c ++ 14.

1
BЈовић
  • Si l'appel récursif est la toute première ou toute dernière instruction (à l'exclusion de la vérification de condition) d'une fonction récursive, il est assez facile à traduire en une structure en boucle.
  • Mais si la fonction fait d'autres choses avant et après l'appel récursif, alors il serait fastidieux de la convertir en boucles.
  • Si la fonction a plusieurs appels récursifs, sa conversion en code qui utilise uniquement des boucles sera pratiquement impossible. Une pile sera nécessaire pour suivre les données. En récursivité, la pile d'appels elle-même fonctionnera comme pile de données.
0
Gulshan