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?
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.
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:
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.
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).
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.
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é:
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 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.
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:
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):
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:
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.
Oui. Il existe plusieurs tâches courantes qui sont faciles à accomplir à l'aide de la récursivité mais impossibles avec seulement des boucles:
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.
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.