web-dev-qa-db-fra.com

Maîtriser la programmation récursive

J'ai du mal à penser/résoudre le problème en termes de récursivité. J'apprécie vraiment le concept et je peux les comprendre comme créer le cas de base, le cas de sortie et les appels récursifs, etc. Je peux résoudre des problèmes simples comme l'écriture factorielle ou la sommation d'entiers dans un tableau. C'est là que ma pensée s'arrête. Je ne pouvais pas vraiment appliquer les concepts ou trouver des solutions lorsque le problème se compliquait. Par exemple, la tour de Hanoi, bien que je puisse comprendre le problème et la solution, je ne peux pas, à moi tout seul, trouver une solution. Il s'applique également à d'autres algorithmes comme le tri rapide/la traversée d'arbre binaire. Donc ma question est

  1. Quelle est la meilleure façon de le maîtriser?
  2. Quelqu'un peut-il suggérer une liste de problèmes ou de questions que je peux utiliser comme exercice pour le pratiquer?
  3. L'apprentissage d'un langage fonctionnel m'aidera-t-il à mieux comprendre?

Veuillez conseiller.

39
Hunter

La récursivité n'est qu'une façon de penser, tout comme l'itérative. Quand nous étions enfants à l'école, on ne nous a pas appris à penser récursivement et c'est là que réside le vrai problème. Vous devez intégrer cette façon de penser dans votre arsenal, une fois que vous le faites, il y restera pour toujours.

Meilleure façon de maîtriser:

J'ai trouvé utile de toujours comprendre les cas de base en premier, peut-être qu'au début, ils ne sont pas les plus simples, mais une fois que vous commencerez à construire la récursivité au-dessus de ce cas de base, vous vous rendrez compte que vous pouvez le simplifier. L'importance d'identifier le cas de base est que, premièrement, vous vous concentrez sur ce qui doit être résolu sous sa forme la plus simple (les cas les plus simples) et cela dessine en quelque sorte une feuille de route pour le futur algorithme, deuxièmement, vous vous assurez que l'algorithme s'arrête . Peut-être ne retourne pas le résultat attendu, mais au moins s'arrête, ce qui est toujours encourageant.

En outre, cela aide toujours à déterminer comment une petite instance d'un problème vous aidera à trouver la solution d'une plus grande instance du problème. C'est par exemple, comment pouvez-vous construire la solution pour l'entrée n ayant déjà la solution de l'entrée n-1.

Résolvez tous les problèmes auxquels vous pouvez penser récursivement. Oui, Hanoi Towers n'est pas un très bon exemple, ses solutions récursives sont une solution très intelligente . Essayez des problèmes plus faciles, des problèmes presque élémentaires.

Liste des problèmes

  1. Opérations mathématiques: Exponentiation et toutes les opérations mathématiques auxquelles vous pouvez penser.
  2. Gestion des cordes: Le palindrome est un très bon exercice. Trouver des mots dans une grille est également utile.
  3. En savoir plus sur les structures de données arborescentes: C'est en particulier l'OMI la meilleure formation. Les arbres sont des structures de données récursives. Renseignez-vous sur leurs traversées (en ordre, post-commande, précommande, calculez sa hauteur, son diamètre, etc.). Presque chaque opération sur une structure de données arborescente est un excellent exercice.
  4. Problèmes combinatoires: Très important, combinaisons, permutations, etc.
  5. Recherche de chemin: algorithme de Lee, algorithmes de labyrinthe, etc.

Mais le plus important, commencez par des problèmes simples. Presque tous les problèmes ont une solution récursive. Les problèmes mathématiques sont excellents pour en comprendre. Chaque fois que vous voyez une boucle for ou une boucle while, transformez cet algorithme en récursivité.

Langage de programmation

La programmation fonctionnelle repose fortement sur la récursivité. Je ne pense pas que cela devrait aider beaucoup car ils sont intrinsèquement récursifs et peuvent être encombrants pour les utilisateurs qui ne comprennent pas encore très bien la récursivité.

Utilisez un langage de programmation simple, celui que vous connaissez le mieux, de préférence celui qui ne vous préoccupe pas beaucoup avec les ennuis de mémoire et les pointeurs. Python est un très bon début à mon avis. Est très simple, ne vous dérange pas avec la saisie ou les structures de données compliquées. Tant que le langage vous aide à rester concentré uniquement sur la récursivité, il être meilleur.

Un dernier conseil, si vous ne trouvez pas de solution à un problème, recherchez-le sur Internet ou appelez à l'aide, comprenez ce qu'il fait complètement et passer à l'autre. Ne les laissez pas vous contourner, car ce que vous essayez de faire est incorporez cette façon de penser à votre tête.

Pour récursion principale , vous devez d'abord récursivité principale :)

J'espère que cela t'aides!

39
Paulo Bu

Mon conseil: confiance que la fonction récursive "fait le travail", c'est-à-dire remplit sa spécification. Et sachant cela, vous pouvez créer une fonction qui résout un problème plus important tout en respectant les spécifications.

Comment résolvez-vous le problème des tours de Hanoi? Supposons qu'il existe une fonction Hanoi (N) capable de déplacer une pile de N disques sans enfreindre les règles. En utilisant cette fonction, vous implémentez facilement Hanoi '(N + 1): effectuez Hanoi (N), déplacez le disque suivant et effectuez à nouveau Hanoi (N).

Si Hanoi (N) fonctionne, alors Hanoi '(N + 1) fonctionne également, sans enfreindre les règles. Pour terminer l'argument, vous devez vous assurer que les appels récursifs se terminent. Dans ce cas, si vous pouvez résoudre le Hanoi (1) de manière non récursive (ce qui est trivial), vous avez terminé.

En utilisant cette approche, vous n'avez pas à vous soucier de la façon dont les choses se produiront, vous êtes assuré que cela fonctionne. (À condition de passer à des instances de plus en plus petites du problème.)

Autre exemple: traversée récursive d'un arbre binaire. Supposons que la fonction Visit(root) fait le travail. Ensuite, if left -> Visit(left); if right -> Visit(right); print root fera le travail! Parce que le premier appel imprimera le sous-arbre gauche (ne vous inquiétez pas comment) et le second le sous-arbre droit (ne vous inquiétez pas comment), et la racine sera également imprimée.

Dans ce dernier cas, la terminaison est garantie par le fait que vous traitez des sous-arbres de plus en plus petits, jusqu'aux feuilles.

Autre exemple: Quicksort. Supposons que vous ayez une fonction qui trie un tableau sur place, laissez Quicksort. Vous l'utiliserez comme suit: déplacez les petits éléments avant les grands éléments, en place, en les comparant à une valeur de "pivot" bien choisie (en fait, n'importe quelle valeur du tableau peut faire). Triez ensuite les petits éléments à l'aide de la fonction Quicksort et les grands éléments de la même manière, et vous avez terminé! Pas besoin de se demander la séquence exacte des partitions qui auront lieu. La résiliation est assurée si vous évitez les sous-réseaux vides.

Dernier exemple, le triangle de Pascal. Vous savez qu'un élément est la somme des deux au-dessus, avec des 1 sur les côtés. Donc avec les yeux fermés, C(K, N)= 1 if K=0 or K=N, else C(K, N) = C(K-1, N-1) + C(K, N-1). C'est tout!

9
Yves Daoust

L'étude d'un langage fonctionnel vous aiderait certainement à penser en récursivité. Je recommanderais Haskell ou LISP (ou Clojure). La bonne chose est que vous n'avez pas besoin de vous familiariser avec les "morceaux durs" de ces deux langues avant de passer à la récursivité. Pour en savoir plus sur la récursivité, vous n'avez pas à apprendre suffisamment de l'un ou l'autre de ces langages pour faire une "vraie" programmation.

La syntaxe de correspondance de motifs de Haskell signifie que les cas de base sont faciles à voir. À Haskell, Factorial ressemble à ceci:

factorial 0 = 1
factorial n = n * factorial (n - 1)

... qui est exactement équivalent à un langage procédural:

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

... mais avec moins de syntaxe pour obscurcir le concept.

Pour être complet, voici le même algorithme dans LISP:

(defun factorial (n)
   (if (== n 0)
       1
       (* n (factorial (- n 1)))))

Ce que vous devriez pouvoir voir est équivalent, bien qu'au début toutes les parenthèses tendent à obscurcir la vue des gens sur ce qui se passe. Pourtant, un livre LISP couvrira un grand nombre de techniques récursives.

De plus, tout livre sur un langage fonctionnel vous donnera de nombreux exemples de récursivité. Vous commencerez avec des algorithmes qui fonctionnent avec des listes:

 addone [] = []
 addone (head:tail) = head + 1 : addone tail

.. qui utilise un modèle très courant avec un appel récursif par fonction. (En fait, un modèle si commun que presque toutes les langues l'abrégent dans une fonction de bibliothèque appelée map)

Ensuite, vous passerez aux fonctions qui traversent les arbres, en effectuant un appel récursif pour chaque branche à partir d'un nœud.

Plus généralement, pensez à des problèmes comme celui-ci:

"Puis-je résoudre une petite partie de ce problème et me laisser avec le même problème, mais en plus petit?".

... ou ...

"Ce problème serait-il facile à résoudre si seulement le reste était déjà résolu?".

Ainsi, par exemple, factorial(n) est simple à déterminer si vous connaissez factorial(n-1), ce qui suggère une solution récursive.

Il s'avère que beaucoup de problèmes peuvent être pensés de cette façon:

"Le tri d'une liste de 1000 éléments semble difficile, mais si je choisis un nombre aléatoire, trie tous les nombres plus petits que cela, puis trie tous les nombres plus grands que cela, j'ai terminé." (revient finalement à trier des listes de longueur 1)

...

"Le calcul du chemin le plus court vers un nœud est difficile, mais si je pouvais simplement connaître la distance jusqu'à chacun de mes nœuds adjacents, ce serait facile."

...

"La consultation de chaque fichier dans cette arborescence de répertoires est difficile, mais je peux regarder les fichiers dans le répertoire de base et les sous-répertoires de menaces de la même manière."

De même la tour de Hanoi. La solution est simple si vous l'exprimez ainsi:

 To move a stack from a to c:
  If the stack is of size 1
      just move it.
  otherwise
      Move the stack minus its largest ring, to b (n-1 problem)
      Move the largest ring to c (easy)
      Move the stack on b to c (n-1 problem)

Nous avons simplifié le problème en esquissant deux étapes apparemment difficiles. Mais ces étapes sont à nouveau le même problème, mais "une plus petite".


Vous pouvez trouver utile de parcourir manuellement un algorithme récursif en utilisant des morceaux de papier pour représenter la pile d'appels, comme décrit dans cette réponse: Comprendre le déroulement de la pile en récursivité (traversée d'arbre)


Une fois que vous êtes plus à l'aise avec la récursivité, retournez en arrière et pensez si c'est la bonne solution pour un cas particulier. Bien que factorial() soit un bon moyen de démontrer le concept de récursivité, dans la plupart des langues une solution itérative est plus efficace. Découvrez l'optimisation de la récursivité de la queue , quelles langues le proposent et pourquoi.

3
slim

La récursivité est un moyen pratique d'implémenter le paradigme Divide & Conquer: lorsque vous devez résoudre un problème donné, une approche puissante consiste à le diviser en problèmes de même nature, mais avec un taille plus petite. En répétant ce processus, vous finirez par travailler sur des problèmes si petits qu'ils peuvent être résolus facilement par une autre méthode.

La question que vous devez vous poser est "puis-je résoudre ce problème en en résolvant certaines parties?". Lorsque la réponse est positive, vous appliquez ce schéma bien connu:

  • diviser le problème en sous-problèmes de manière récursive, jusqu'à ce que la taille soit petite,

  • résoudre les sous-problèmes par une méthode directe,

  • fusionner les solutions dans l'ordre inverse.

Notez que le fractionnement peut être effectué en deux parties ou plus, et celles-ci peuvent être équilibrées ou non.

Par exemple: puis-je trier un tableau de nombres en effectuant des tris partiels?

Réponse 1: oui, si je laisse le dernier élément et trie le reste, je peux trier le tableau entier en insérant le dernier élément au bon endroit. Il s'agit d'un tri par insertion.

Réponse 2: oui, si je trouve le plus grand élément et le déplace à la fin, je peux trier l'ensemble du tableau en triant les éléments restants. Il s'agit d'un tri par sélection.

Réponse 3: oui, si je trie deux moitiés du tableau, je peux trier le tableau entier en fusionnant les deux séquences, en utilisant un tableau auxiliaire pour les mouvements. Il s'agit d'un tri par fusion.

Réponse 4: oui, si je partitionne le tableau à l'aide d'un pivot, je peux trier l'ensemble du tableau en triant les deux parties. C'est un tri rapide.

Dans tous ces cas, vous résolvez le problème en résolvant des sous-problèmes de même nature et en ajoutant de la colle.

3
Yves Daoust

la récursivité est difficile parce que c'est une façon de penser différente, à laquelle nous n'avons jamais été initiés lorsque nous étions plus jeunes.

d'après ce que vous dites, vous avez déjà le concept, tout ce dont vous avez vraiment besoin est simplement de le pratiquer davantage. un langage fonctionnel serait certainement utile; vous serez obligé de penser à vos problèmes de manière récursive et avant de vous en rendre compte, la récursion vous semblera très naturelle

il y a des tonnes d'exercices que vous pouvez faire concernant la récursivité, gardez à l'esprit que tout ce qui est fait avec une boucle peut aussi être fait de manière récursive.

voir ceci réponse pour de grands détails sur les références et les problèmes d'exercice

Pour les problèmes complexes, je suggère de résoudre le problème pour des problèmes de petite taille et de voir quels types de modèles vous trouvez. Par exemple, dans les tours de Hanoï, commencez par un problème de taille un, puis deux, puis trois, etc. À un moment donné, vous commencerez probablement à voir un modèle, et vous vous rendrez compte qu'une partie de ce que vous rencontrez faire est tout comme ce que vous aviez à faire sur les problèmes de plus petite taille, ou qu'il est suffisamment similaire pour que vous puissiez utiliser la même technique qu'auparavant, mais avec quelques variations.

Je viens de passer par le problème des tours de Hanoi moi-même et j'ai étudié ma propre pensée. J'ai commencé avec un problème de taille un:

 Nous avons un disque sur la cheville A. 
 *** Déplacez-la sur la cheville C. 
 Terminé! 

Maintenant pour deux.

 Nous avons deux disques sur la cheville A. 
 J'ai besoin d'utiliser la cheville B pour retirer le premier disque. 
 *** Passer de la cheville A à la cheville B 
 Maintenant, je peux faire le reste 
 *** Passer du piquet A au piquet C 
 *** Passer du piquet B au piquet C 
 Terminé! 

Maintenant pour trois.

Les choses commencent à devenir un peu plus intéressantes. La solution n'est pas aussi évidente. Cependant, j'ai compris comment déplacer deux disques d'une cheville à une autre, donc si je pouvais déplacer deux disques de la cheville A vers la cheville B, puis déplacer un disque de la cheville A vers la cheville C, puis deux disques de la cheville B pour arrimer C, j'aurais fini! Ma logique pour le cas de deux disques fonctionnera, sauf que les chevilles sont différentes. Si nous mettons la logique dans une fonction et faisons des paramètres pour les chevilles, alors nous pouvons réutiliser la logique.

def move2(from_peg,to_peg,other_peg):
   # We have two disks on from_peg
   # We need to use other_peg to get the first disk out of the way
   print 'Move from peg '+from_peg+' to peg '+other_peg
   # Now I can do the rest
   print 'Move from peg '+from_peg+' to peg '+to_peg
   print 'Move from peg '+other_peg+' to peg '+to_peg

La logique est alors:

 move2 ('A', 'B', 'C') 
 print 'Passer de la cheville A à la cheville C' 
 move2 ('B', 'C', ' UNE')

Je peux rendre cela plus simple en ayant également une fonction move1:

def move1(from_peg,to_peg):
    print 'Move from '+from_peg+' to '+to_peg

Maintenant, ma fonction move2 peut être

def move2(from_peg,to_peg,other_peg):
   # We have two disks on from_peg
   # We need to use other_peg to get the first disk out of the way
   move1(from_peg,other_peg,to_peg)
   # Now I can do the rest
   move1(from_peg,to_peg)
   move1(other_peg,to_peg)

Ok, qu'en est-il de quatre?

On dirait que je peux appliquer la même logique. J'ai besoin d'obtenir trois disques de la cheville A à la cheville B, puis un de A à C, puis trois de B à C.J'ai déjà résolu de déplacer trois disques, mais avec les mauvais chevilles, je vais donc généraliser:

def move3(from_peg,to_peg,other_peg):
   move2(from_peg,other_peg,to_peg)
   move1(from_peg,to_peg)
   move2(other_peg,to_peg,from_peg)

Cool! Et attendez, move3 et move2 sont assez similaires maintenant, et cela a du sens. Pour tout problème de taille, nous pouvons déplacer tous les disques sauf un vers le piquet B, puis déplacer un disque de A à C, puis déplacer tous les disques du piquet B vers le piquet C.Notre fonction de déplacement peut donc simplement prendre le nombre de disques comme un paramètre:

def move(n,from_peg,to_peg,other_peg):
    move(n-1,from_peg,other_peg,to_peg)
    move1(from_peg,to_peg)
    move(n-1,other_peg,to_peg,from_peg)

Cela semble très proche, mais cela ne fonctionne pas dans le cas où n == 1 car nous finissons par appeler move (0, ...). Nous devons donc gérer cela:

def move(n,from_peg,to_peg,other_peg):
    if n==1:
        move1(from_peg,to_peg)
    else:
        move(n-1,from_peg,other_peg,to_peg)
        move1(from_peg,to_peg)
        move(n-1,other_peg,to_peg,from_peg)

Excellent! Qu'en est-il d'un problème de taille cinq? Nous appelons simplement move (5, 'A', 'C', 'B'). On dirait que toute taille de problème est la même chose, donc notre fonction principale est juste:

def towers(n):
    move(n,'A','C','B')

et nous avons terminé!

1
Vaughn Cato