web-dev-qa-db-fra.com

La récursivité est-elle toujours plus rapide que la boucle?

Je sais que la récursion est parfois beaucoup plus propre que la boucle, et je ne demande pas quand je devrais utiliser la récursion sur l'itération, je sais qu'il y a déjà beaucoup de questions à ce sujet.

Ce que je demande, c'est si la récursion jamais est plus rapide qu'une boucle? Pour moi, il semble que vous puissiez toujours affiner une boucle et la faire fonctionner plus rapidement qu'une fonction récursive car la boucle est absente en configurant constamment de nouveaux cadres de pile.

Je cherche en particulier à savoir si la récursivité est plus rapide dans les applications où la récursivité est le moyen approprié de gérer les données, telles que certaines fonctions de tri, les arbres binaires, etc.

266
Carson Myers

Cela dépend de la langue utilisée. Vous avez écrit "langue-agnostique", alors je vais donner quelques exemples.

En Java, C et Python, la récursivité est assez coûteuse par rapport à l'itération (en général) car elle nécessite l'allocation d'un nouveau cadre de pile. Dans certains compilateurs C, on peut utiliser un indicateur de compilateur pour éliminer cette surcharge, qui transforme certains types de récursivité (en fait, certains types d'appels de fin) en sauts au lieu d'appels de fonction.

Dans les implémentations de langage de programmation fonctionnel, l'itération peut parfois être très onéreuse et la récursivité peut être très bon marché. Dans de nombreux cas, la récursivité est transformée en un simple saut, mais le changement de la variable de boucle (qui est modifiable) parfois nécessite des opérations relativement lourdes, en particulier sur les implémentations prenant en charge plusieurs threads d'exécution. La mutation est coûteuse dans certains de ces environnements en raison de l'interaction entre le mutateur et le récupérateur de place, si les deux s'exécutent en même temps.

Je sais que dans certaines implémentations de Scheme, la récursivité sera généralement plus rapide que la mise en boucle.

En bref, la réponse dépend du code et de la mise en œuvre. Utilisez le style que vous préférez. Si vous utilisez un langage fonctionnel, la récursivité pourrait ​​être plus rapide. Si vous utilisez un langage impératif, l'itération est probablement ​​plus rapide. Dans certains environnements, les deux méthodes génèrent le même assemblage (placez-le dans votre tuyau et fumez-le).

Addendum: Dans certains environnements, la meilleure alternative n'est ni la récursivité ni l'itération, mais plutôt des fonctions d'ordre supérieur. Celles-ci incluent "carte", "filtre" et "réduction" (également appelé "fold"). Non seulement ces styles constituent le style préféré, non seulement ils sont souvent plus propres, mais dans certains environnements, ces fonctions sont les premières (ou les seules) à tirer parti de la parallélisation automatique. Elles peuvent donc être nettement plus rapides que l'itération ou la récursivité. Data Parallel Haskell est un exemple d'un tel environnement.

La compréhension de liste est une autre alternative, mais il s’agit généralement de sucre syntaxique pour les fonctions d’itération, de récursivité ou d’ordre supérieur.

333
Dietrich Epp

la récursivité est-elle toujours plus rapide qu'une boucle?

[No, L'itération sera toujours plus rapide que la récursivité. (dans une architecture de von Neumann)

Explication:

Si vous créez à partir de rien les opérations minimales d'un ordinateur générique, "L'itération" est d'abord un bloc de construction et nécessite moins de ressources que la "récursivité", ergo est plus rapide.

Construire une pseudo-machine-informatique à partir de zéro:

Question vous-même: De quoi avez-vous besoin pour calculer une valeur, c'est-à-dire suivre un algorithme et atteindre un résultat?

Nous allons établir une hiérarchie de concepts, en partant de zéro et en définissant en premier lieu les concepts de base de base, puis en construisant des concepts de second niveau avec ceux-ci, etc.

  1. Premier concept: cellules de mémoire, stockage, état. Pour faire quelque chose dont vous avez besoin places pour stocker les valeurs de résultat final et intermédiaire. Supposons que nous ayons un tableau infini de cellules "entières", appelé mémoire, M [0..infini].

  2. Instructions: faire quelque chose - transformer une cellule, changer sa valeur. changement d'état. Chaque instruction intéressante effectue une transformation. Les instructions de base sont:

    a) Définir et déplacer des cellules de mémoire

    • enregistrer une valeur en mémoire, par exemple: stocker 5 m [4]
    • copier une valeur dans une autre position: par exemple: magasin m [4] m [8]

    b) logique et arithmétique

    • et, ou, xor, pas
    • ajouter, sous, mul, div. par exemple. ajouter m [7] m [8]
  3. n agent d'exécution: a core dans un processeur moderne. Un "agent" est quelque chose qui peut exécuter des instructions. Un Agent ​​peut également être une personne qui suit l'algorithme sur papier.

  4. Ordre des étapes: une séquence d’instructions: c’est-à-dire: faites ceci en premier, faites ceci après, etc. Une séquence impérative d’instructions. Même une ligne expressions est "une séquence d'instructions impérative". Si vous avez une expression avec un "ordre d'évaluation" spécifique, alors vous avez étapes. Cela signifie qu’une seule expression composée a des "étapes" implicites et a également une variable locale implicite (appelons-la "résultat"). par exemple.:

    4 + 3 * 2 - 5
    (- (+ (* 3 2) 4 ) 5)
    (sub (add (mul 3 2) 4 ) 5)  
    

    L'expression ci-dessus implique 3 étapes avec une variable implicite de "résultat".

    // pseudocode
    
           1. result = (mul 3 2)
           2. result = (add 4 result)
           3. result = (sub result 5)
    

    Ainsi, même les expressions infixes, puisque vous avez un ordre d'évaluation spécifique, sont ne séquence d'instructions impérative. L'expression implique une séquence d'opérations à effectuer dans un ordre spécifique, et puisqu'il y a étapes, il existe également une variable intermédiaire implicite "résultat".

  5. Pointeur d'instruction: Si vous avez une séquence d'étapes, vous avez également un "pointeur d'instruction" implicite. Le pointeur d'instruction marque l'instruction suivante et avance après la lecture de l'instruction mais avant l'exécution de l'instruction.

    Dans cette pseudo-machine à calculer, le pointeur d'instructions fait partie de Memory. (Remarque: Normalement, le Pointeur d’instruction sera un "registre spécial" dans un cœur de CPU, mais nous simplifierons ici les concepts et supposerons que toutes les données (registres inclus) font partie de "Mémoire")

  6. Jump - Une fois que vous avez un nombre ordonné d'étapes et un Pointeur de l'instruction, vous pouvez appliquer l'instruction "store" pour modifier la valeur du paramètre Instruction Pointer lui-même. Nous appellerons cette utilisation spécifique de instruction de stockage sous un nouveau nom: Jump. Nous utilisons un nouveau nom car il est plus facile de le considérer comme un nouveau concept. En modifiant le pointeur d'instruction, nous demandons à l'agent de "passer à l'étape x".

  7. Itération infinie: Par [sautant en arrière,) == vous pouvez maintenant faire en sorte que l'agent "répète" un certain nombre d'étapes. À ce stade, nous avons Itération infinie.

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    
  8. Conditionnel - Exécution conditionnelle des instructions. Avec la clause "conditionnelle", vous pouvez exécuter de manière conditionnelle l'une des instructions en fonction de l'état actuel (pouvant être définie avec une instruction précédente).

  9. Itération correcte: Maintenant, avec la clause conditionnelle, nous pouvons échapper à la boucle infinie de l'instruction revenir en arrière. Nous avons maintenant un boucle conditionnelle puis itération appropriée

    1. mov 1000 m[30]
    2. sub m[30] 1
    3. (if not-zero) jump 2  // jump only if the previous 
                            // sub instruction did not result in 0
    
    // this loop will be repeated 1000 times
    // here we have proper ***iteration***, a conditional loop.
    
  10. Nom: attribuer des noms à un emplacement de mémoire spécifique contenant des données ou contenant une étape. Ceci est juste une "commodité" à avoir. Nous n’ajoutons aucune nouvelle instruction en ayant la capacité de définir des "noms" pour les emplacements de mémoire. "Nommer" n'est pas une instruction pour l'agent, c'est simplement une commodité pour nous. Nommage rend le code (à ce stade) plus facile à lire et plus facile à modifier.

       #define counter m[30]   // name a memory location
       mov 1000 counter
    loop:                      // name a instruction pointer location
        sub counter 1
        (if not-zero) jmp-to loop  
    
  11. sous-programme à un nivea: Supposons qu’il existe une série d’étapes à exécuter fréquemment. Vous pouvez stocker les étapes dans une position nommée en mémoire, puis passer à cette position lorsque vous devez les exécuter (appel). À la fin de la séquence, vous aurez besoin de return au point call pour continuer l'exécution. Avec ce mécanisme, vous êtes en créant de nouvelles instructions (sous-routines) en composant des instructions de base.

    Implémentation: (aucun nouveau concept requis)

    • Stocker le pointeur d’instruction actuel dans une position de mémoire prédéfinie
    • saut au sous-programme
    • à la fin du sous-programme, vous récupérez le pointeur d'instruction à partir de l'emplacement de mémoire prédéfini, ce qui vous permet de revenir à l'instruction suivante de l'original call

    Problème avec l'implémentation n nivea: Vous ne pouvez pas appeler un autre sous-programme à partir d'un sous-programme. Si vous le faites, vous écraserez l'adresse de renvoi (variable globale) afin que vous ne puissiez pas imbriquer des appels.

    Pour avoir un meilleure implémentation pour les sous-programmes: vous avez besoin d'un STACK

  12. Stack: Vous définissez un espace mémoire pour qu'il fonctionne comme une "pile", vous pouvez "Transférer" des valeurs sur la pile et "extraire" la dernière valeur "transmise". Pour implémenter une pile, vous aurez besoin d'un --- [Pointeur d'empilement (similaire au pointeur d'instructions) qui pointe vers la "tête" réelle de la pile. Lorsque vous "appuyez" sur une valeur, le pointeur de la pile décrémente et vous stockez la valeur. Lorsque vous "pop", vous obtenez la valeur au pointeur de pile réel, puis le pointeur de pile est incrémenté.

  13. Sous-routines Maintenant que nous avons un [pile) == nous pouvons mettre en œuvre les sous-routines appropriées permettant les appels imbriqués. L'implémentation est similaire, mais au lieu de stocker le pointeur d'instruction dans une position de mémoire prédéfinie, nous "poussons" la valeur de l'IP dans la pile =]. À la fin du sous-programme, nous "extrayons" simplement la valeur de la pile et retournons à l’instruction après l’originale call. Cette implémentation, ayant une "pile", permet d’appeler un sous-programme depuis un autre sous-programme. Avec cette implémentation, nous pouvons créer plusieurs niveaux d'abstraction lors de la définition de nouvelles instructions en tant que sous-routines, en utilisant des instructions de base ou d'autres sous-routines comme blocs de construction.

  14. Récursion: Que se passe-t-il lorsqu'un sous-programme s'appelle tout seul?. Ceci s'appelle "récursion".

    Problème: En écrasant les résultats intermédiaires locaux, un sous-programme peut être stocké en mémoire. Puisque vous appelez/réutilisez les mêmes étapes, if le résultat intermédiaire est stocké dans des emplacements de mémoire prédéfinis (variables globales) et sera écrasé lors des appels imbriqués.

    Solution: Pour permettre la récursion, les sous-routines doivent stocker les résultats intermédiaires locaux dans la pile, donc, sur chaque appel récursif (directs ou indirects) les résultats intermédiaires sont stockés dans différents emplacements de mémoire.

...

ayant atteint [récursion nous nous arrêtons ici.

Conclusion:

Dans une architecture de Von Neumann, clairement "Itération") == est un concept plus simple/fondamental que "Récursion". Nous avoir une forme de "Itération" au niveau 7, tandis que "Récursion" est au niveau 14 de la hiérarchie des concepts.

[Itération sera toujours plus rapide dans le code machine car cela implique moins d'instructions, donc moins de cycles de la CPU.

Quel est le meilleur"?

  • Vous devriez utiliser "itération" lorsque vous traitez des structures de données séquentielles simples, et partout une "simple boucle" fera l'affaire.

  • Vous devez utiliser "récursivité" lorsque vous devez traiter une structure de données récursive (j'aime les appeler "structures de données fractales") ou lorsque la solution récursive est nettement plus "élégante".

Conseils: utilisez le meilleur outil pour le travail, mais comprenez le fonctionnement interne de chaque outil afin de choisir judicieusement.

Enfin, notez que vous avez beaucoup d’occasions d’utiliser la récursivité. Vous avez Structures de données récursives partout, vous en voyez une maintenant: les parties du DOM supportant ce que vous lisez sont un RDS, une expression JSON est un RDS, le système de fichiers hiérarchique de votre ordinateur est un RDS, c’est-à-dire que vous avez un répertoire racine contenant des fichiers et des répertoires, chaque répertoire contenant des fichiers et des répertoires, chacun de ces répertoires contenant des fichiers et des répertoires ...

47
Lucio M. Tato

La récursivité peut être plus rapide lorsque l’alternative consiste à gérer explicitement une pile, comme dans les algorithmes de tri ou d’arborescence binaire que vous mentionnez.

J'ai eu un cas où la réécriture d'un algorithme récursif dans Java le ralentissait.

La bonne approche consiste donc à l'écrire d'abord de la manière la plus naturelle, à n'optimiser que si le profilage montre qu'il est essentiel, puis à mesurer l'amélioration supposée.

34
starblue

queue récursive est aussi rapide que la boucle. De nombreux langages fonctionnels ont une récursion de queue implémentée.

12
mkorpela

Considérez ce qui doit absolument être fait pour chaque itération et récurrence.

  • itération: un saut au début de la boucle
  • récursivité: un saut au début de la fonction appelée

Vous voyez qu'il n'y a pas beaucoup de place pour les différences ici.

(Je suppose que la récursivité est un appel final et que le compilateur est conscient de cette optimisation).

12
Pasi Savolainen

La plupart des réponses ici oublient le coupable évident pour lequel la récursivité est souvent plus lente que les solutions itératives. Cela est lié à la constitution et au démantèlement des cadres de pile, mais ce n’est pas exactement cela. C'est généralement une grande différence dans le stockage de la variable automatique pour chaque récursion. Dans un algorithme itératif avec une boucle, les variables sont souvent conservées dans des registres et même si elles se renversent, elles résident dans le cache de niveau 1. Dans un algorithme récursif, tous les états intermédiaires de la variable sont stockés dans la pile, ce qui signifie qu'ils engendreront beaucoup plus de débordements en mémoire. Cela signifie que même si le nombre d'opérations effectuées est le même, il y aura beaucoup d'accès mémoire dans la boucle dynamique et, ce qui ne fait qu'aggraver les choses, ces opérations de mémoire ont un taux de réutilisation médiocre rendant les caches moins efficaces.

Les algorithmes récursifs TL; DR ont généralement un comportement en cache pire que les algorithmes itératifs.

8
Patrick Schlüter

La plupart des réponses ici sont faux. La bonne réponse est ça dépend. Par exemple, voici deux fonctions C qui se promènent dans un arbre. D'abord le récursif:

static
void mm_scan_black(mm_rc *m, ptr p) {
    SET_COL(p, COL_BLACK);
    P_FOR_EACH_CHILD(p, {
        INC_RC(p_child);
        if (GET_COL(p_child) != COL_BLACK) {
            mm_scan_black(m, p_child);
        }
    });
}

Et voici la même fonction implémentée en utilisant l'itération:

static
void mm_scan_black(mm_rc *m, ptr p) {
    stack *st = m->black_stack;
    SET_COL(p, COL_BLACK);
    st_Push(st, p);
    while (st->used != 0) {
        p = st_pop(st);
        P_FOR_EACH_CHILD(p, {
            INC_RC(p_child);
            if (GET_COL(p_child) != COL_BLACK) {
                SET_COL(p_child, COL_BLACK);
                st_Push(st, p_child);
            }
        });
    }
}

Ce n'est pas important de comprendre les détails du code. Juste que p sont des nœuds et que P_FOR_EACH_CHILD fait la marche. Dans la version itérative, nous avons besoin d'une pile explicite st sur laquelle les nœuds sont poussés, puis sautés et manipulés.

La fonction récursive est beaucoup plus rapide que la fonction itérative. La raison en est que dans ce dernier, pour chaque élément, une CALL à la fonction st_Push est nécessaire, puis une autre à st_pop.

Dans le premier cas, vous ne disposez que de la règle CALL récursive pour chaque nœud.

De plus, l'accès aux variables sur la pile d'appels est incroyablement rapide. Cela signifie que vous lisez en mémoire, ce qui est susceptible de toujours se trouver dans le cache le plus interne. En revanche, une pile explicite doit être sauvegardée par la mémoire malloc: ed du tas, beaucoup plus lente à accéder.

Avec une optimisation minutieuse, telle que l’inclusion de st_Push et st_pop, je peux atteindre à peu près la parité avec l’approche récursive. Mais au moins sur mon ordinateur, le coût d'accès à la mémoire de tas est supérieur au coût de l'appel récursif.

Mais cette discussion est généralement sans objet car la marche dans les arbres récursive est incorrect. Si l'arborescence est suffisamment grande, vous manquerez d'espace dans la pile d'appels, raison pour laquelle un algorithme itératif doit être utilisé.

6
Björn Lindqvist

Dans tout système réaliste, non, créer un cadre de pile coûtera toujours plus cher qu'un INC et un JMP. C’est pourquoi de très bons compilateurs transforment automatiquement la récursion finale en appel du même cadre, c’est-à-dire sans surcharge, pour obtenir la version source la plus lisible et la version compilée la plus efficace. Un vraiment, vraiment bon compilateur devrait même être capable de transformer une récursion normale en une récursion finale lorsque cela est possible.

2
Kilian Foth

La programmation fonctionnelle concerne plus "quoi" que "comment".

Les implémenteurs de langage trouveront un moyen d'optimiser le fonctionnement du code en dessous, si nous n'essayons pas de le rendre plus optimisé que nécessaire. La récursivité peut également être optimisée dans les langues prenant en charge l'optimisation des appels en attente.

Ce qui importe davantage du point de vue du programmeur, c’est la lisibilité et la maintenabilité plutôt que l’optimisation. Encore une fois, "l'optimisation prématurée est la racine de tout mal".

1
noego

En règle générale, non, la récursivité ne sera pas plus rapide qu'une boucle dans toute utilisation réaliste disposant d'implémentations viables dans les deux formes. Je veux dire, bien sûr, vous pouvez coder des boucles qui prennent une éternité, mais il y aurait de meilleures façons d'implémenter la même boucle qui pourrait surpasser toute implémentation du même problème via la récursivité.

Vous avez frappé le clou sur la tête en ce qui concerne la raison; créer et détruire des cadres de pile coûte plus cher qu'un simple saut.

Cependant, notez que j'ai dit "a des implémentations viables sous les deux formes". Pour de nombreux algorithmes de tri, par exemple, il n'existe généralement pas de moyen très viable de les implémenter sans configurer correctement sa propre version d'une pile, en raison de la génération de "tâches" enfants faisant partie intégrante du processus. Ainsi, la récursivité peut être aussi rapide que la tentative d'implémentation de l'algorithme via le bouclage.

Éditer: Cette réponse suppose des langages non fonctionnels, où la plupart des types de données de base sont mutables. Cela ne s'applique pas aux langages fonctionnels.

1
Amber

C'est une supposition. Généralement, la récursion ne bat probablement pas souvent ou jamais avec des problèmes de taille décente si les deux utilisent de très bons algorithmes (sans compter la difficulté de mise en œuvre), elle peut être différente si elle est utilisée avec un langage w/ récursion d’appel final = (et un algorithme récursif de queue et avec des boucles faisant également partie du langage) - qui aurait probablement très similaire et préférerait peut-être même une récursion de temps en temps.

0
Roman A. Taycher

Selon la théorie, c'est la même chose. La récursivité et la boucle avec la même complexité O() fonctionnent à la même vitesse théorique, mais la vitesse réelle dépend bien sûr de la langue, du compilateur et du processeur. Exemple avec puissance de nombre peut être codé de manière itérative avec O (ln (n)):

  int power(int t, int k) {
  int res = 1;
  while (k) {
    if (k & 1) res *= t;
    t *= t;
    k >>= 1;
  }
  return res;
  }
0
Hydrophis Spiralis