Le cours
Résumé en cinq points
- Une fonction récursive a des cas de base et des cas récursifs sur des arguments plus petits. Les activations s'empilent, et forment un arbre dès qu'il y a plusieurs appels.
- La correction se prouve par récurrence, la terminaison par une mesure strictement décroissante dans un ordre bien fondé, comme les entiers naturels ou l'ordre lexicographique sur les couples.
- Diviser pour régner découpe, résout récursivement et combine. Le tri par partition-fusion en est l'exemple de référence, et Karatsuba montre qu'on gagne en réduisant le nombre de sous-problèmes.
- Le coût d'une fonction récursive se lit sur sa relation de récurrence ou sur l'arbre des appels, par exemple pour le tri fusion et pour Fibonacci naïf. Aucun théorème général n'est au programme.
- La complexité dans le pire cas est la référence. Les complexités en moyenne et amortie existent et se rencontrent sur des exemples, tri rapide et tableau dynamique.
Pourquoi ce chapitre
L'option informatique change de langage et de manière de penser. En Python, on a surtout écrit des boucles qui modifient des variables. En OCaml, la construction de base est la fonction récursive, qui s'appelle elle-même sur un problème plus petit.
- Cette façon de programmer colle aux définitions mathématiques par récurrence.
- Elle rend les preuves de correction presque immédiates.
- Elle est indispensable pour les arbres, les formules logiques et les structures de données de toute l'option.
Le programme demande d'aller au-delà de la présentation expérimentale du tronc commun, en faisant le lien avec le principe de récurrence, en prouvant la terminaison et en calculant des complexités. Aux concours, chaque sujet d'option commence par des fonctions récursives courtes dont on demande la terminaison, la correction et le coût.
1Récursivité
1.1Fonction récursive
Définition 1.1Fonction récursive
Une fonction est récursive si sa définition fait appel à elle-même. Elle comporte :
- un ou plusieurs cas de base, traités sans appel récursif ;
- un ou plusieurs cas récursifs, qui se ramènent à des arguments plus petits.
En OCaml, le mot-clé rec est obligatoire.
let rec factorielle n =
if n = 0 then 1 else n * factorielle (n - 1)
Sur les listes, la récursivité suit la structure du type : cas de la liste vide et cas d'une tête suivie d'une queue.
let rec longueur l = match l with
| [] -> 0
| _ :: q -> 1 + longueur q
1.2Récursivité croisée
Définition 1.2Fonctions mutuellement récursives
Deux fonctions sont mutuellement récursives, ou croisées, quand chacune appelle l'autre. En OCaml, on les définit ensemble avec let rec ... and ....
let rec est_pair n = if n = 0 then true else est_impair (n - 1)
and est_impair n = if n = 0 then false else est_pair (n - 1)
On rencontre la récursivité croisée dans deux cas :
- l'analyse d'expressions : une expression contient des termes, qui contiennent des facteurs, qui peuvent contenir des expressions ;
- les types mutuellement récursifs.
1.3Pile d'exécution et activations
Définition 1.3Activation
Chaque appel crée une activation, ou bloc d'activation, qui mémorise ses paramètres, ses variables locales et l'endroit où reprendre le calcul au retour. Ces blocs sont empilés sur la pile d'exécution.
L'appel factorielle 4 empile les activations pour 4, 3, 2, 1 et 0, puis les dépile en effectuant les multiplications au retour.
Attention
Une récursion trop profonde épuise la pile : erreur Stack_overflow en OCaml.
Définition 1.4Appel terminal
Quand l'appel récursif est la dernière opération effectuée, on parle d'appel terminal. OCaml réutilise alors l'activation courante au lieu d'en empiler une nouvelle, et la récursion ne consomme plus de pile.
On obtient souvent cette forme en ajoutant un accumulateur.
let factorielle n =
let rec aux n acc =
if n = 0 then acc else aux (n - 1) (n * acc) in
aux n 1
1.4Organisation des activations sous forme d'arbre en cas d'appels multiples
Quand une fonction fait plusieurs appels récursifs, les activations ne forment plus une simple suite mais un arbre, l'arbre des appels. Chaque nœud est un appel, ses fils sont les appels qu'il déclenche. L'exemple type est la suite de Fibonacci écrite naïvement.
let rec fib n = if n < 2 then n else fib (n - 1) + fib (n - 2)
L'arbre des appels de fib 5 a pour racine fib 5, qui a pour fils fib 4 et fib 3, et ainsi de suite. Le même sous-calcul, fib 3 par exemple, apparaît plusieurs fois.
Propriété 1.5Nombre d'appels de fib
Le nombre d'appels vérifie
et croît comme , où est le nombre d'or, environ . Le calcul de fib 50 demande ainsi des dizaines de milliards d'appels.
Lire l'arbre des appels, c'est à la fois comprendre l'ordre d'exécution, qui est un parcours en profondeur de cet arbre, et estimer le coût.
Propriété 1.6Hauteur de pile
La pile ne contient à chaque instant que les activations d'un chemin de la racine au nœud courant. Sa hauteur maximale est donc la hauteur de l'arbre. Pour fib n, la hauteur de l'arbre, donc de la pile, est .
2Récursivité et principe de récurrence
2.1Prouver la correction par récurrence
Une fonction récursive se prouve par récurrence sur la taille de son argument. On montre que le résultat est correct dans les cas de base, puis que si les appels récursifs renvoient des résultats corrects, l'appel courant aussi.
Exemple 2.1Correction de factorielle
La propriété « factorielle n renvoie » est vraie pour . Si elle est vraie pour , alors factorielle n renvoie .
Pour les listes, on raisonne par récurrence sur la longueur, ou par induction structurelle, point développé au chapitre sur les types récursifs.
Exemple plus riche : la fusion de deux listes triées, écrite dans la section sur le tri par partition-fusion.
Proposition 2.2Correction de fusion
Hypothèses
l1etl2sont deux listes triées.
Alors fusion l1 l2 est triée et contient exactement les éléments de l1 et de l2.
Démonstration
Récurrence sur la somme des longueurs .
Si l'une des listes est vide, le résultat est l'autre, triée.
Sinon, l1 :: q1 et l2 :: q2. Si , le résultat est suivi de fusion q1 l2. Cette liste est triée par hypothèse de récurrence, puisque la somme des longueurs a diminué de 1. Tous ses éléments sont au moins égaux à : q1 est triée et commence après , et l2 est triée et commence par . Le résultat est donc trié.
Le cas est symétrique.
La preuve de correction suit exactement la structure du programme, c'est l'intérêt principal de la récursivité.
2.2Terminaison
MéthodeProuver la terminaison
Une fonction récursive termine si aucune suite d'appels n'est infinie.
- Exhiber une mesure des arguments.
- Montrer qu'elle décroît strictement à chaque appel récursif.
- Vérifier qu'elle est à valeurs dans un ensemble où une suite strictement décroissante ne peut pas être infinie.
Pour factorielle, l'argument entier naturel décroît de 1.
Attention
Attention au domaine : factorielle (-1) ne termine pas, car la mesure sort des entiers naturels. La spécification doit donc préciser .
2.3Ordre bien fondé
Définition 2.3Ordre bien fondé
Un ensemble ordonné est bien fondé s'il n'existe aucune suite infinie strictement décroissante, ce qui équivaut à ce que toute partie non vide admette un élément minimal.
L'ensemble des entiers naturels muni de l'ordre usuel est bien fondé, celui des entiers relatifs ne l'est pas.
Propriété 2.4Terminaison par ordre bien fondé
Hypothèses
- chaque appel récursif se fait sur un argument strictement plus petit pour un ordre bien fondé.
Alors la fonction termine.
C'est la généralisation de la récurrence à tout ordre bien fondé (récurrence bien fondée). Le programme demande d'insister sur ce lien avec les relations d'ordre.
Définition 2.5Ordre lexicographique
Sur les couples d'entiers naturels, si , ou et . Cet ordre est bien fondé.
Il prouve la terminaison de fonctions où aucune composante seule ne décroît toujours.
Exemple 2.6Fonction d'Ackermann
Elle est définie par Chaque appel se fait sur un couple strictement plus petit pour l'ordre lexicographique, même quand la deuxième composante explose, donc elle termine.
3Stratégie diviser pour régner
3.1Principe
Diviser pour régner résout un problème en trois temps :
- Diviser l'entrée en sous-problèmes du même type et de taille nettement plus petite, typiquement la moitié ;
- Résoudre récursivement chaque sous-problème ;
- Combiner les solutions pour obtenir celle du problème initial.
Le programme demande de ne pas se limiter à des exemples où la décomposition et la recomposition sont évidentes.
3.2Tri par partition-fusion
On coupe la liste en deux moitiés, on trie chacune récursivement, puis on fusionne les deux listes triées en parcourant leurs têtes.
let rec fusion l1 l2 = match l1, l2 with
| [], l | l, [] -> l
| x :: q1, y :: q2 ->
if x <= y then x :: fusion q1 l2
else y :: fusion l1 q2
Le partage se fait en distribuant les éléments un sur deux dans deux listes, puis tri l = fusion (tri l1) (tri l2), avec pour cas de base les listes de longueur 0 ou 1.
let rec partage l = match l with
| [] -> [], []
| [x] -> [x], []
| x :: y :: q -> let a, b = partage q in x :: a, y :: b
let rec tri l = match l with
| [] | [_] -> l
| _ -> let a, b = partage l in fusion (tri a) (tri b)
Attention
Le cas de base [_] est indispensable. Sans lui, une liste à un élément serait partagée en elle-même et la liste vide, et tri s'appellerait indéfiniment sur la même liste : la mesure ne décroîtrait plus. C'est l'exemple type où la preuve de terminaison repère un bug.
Pour une liste de longueur , les deux moitiés ont des longueurs et , toutes deux strictement inférieures à .
- La fusion de deux listes de longueurs et coûte au plus comparaisons.
- Le partage coûte un parcours de la liste.
3.3Exemples moins immédiats
- Exponentiation rapide. si est pair, sinon. Un seul appel récursif sur divisé par 2, à condition de ne calculer qu'une fois.
- Multiplication de Karatsuba. Pour multiplier deux entiers de chiffres, on les coupe en deux moitiés, et . Le produit demande , et . On calcule ce dernier comme , soit trois multiplications de taille au lieu de quatre.
- Points les plus proches dans le plan. La combinaison demande d'examiner une bande autour de la droite de séparation. C'est l'exemple où la recomposition est la vraie difficulté.
4Analyse de la complexité des algorithmes récursifs
4.1Relations de récurrence sur le coût
Le coût d'une fonction récursive vérifie une relation qui reproduit sa structure. On la résout sur chaque exemple, en déroulant la relation ou en raisonnant sur l'arbre des appels, sans théorème général, qui n'est pas au programme. On étudie la complexité dans le pire cas.
- Factorielle. , donc est de l'ordre de .
- Exponentiation rapide. , donc est de l'ordre de .
- Karatsuba. . Le niveau de l'arbre a nœuds de taille , et le dernier niveau domine, d'où un coût de l'ordre de , soit environ , contre pour la méthode scolaire.
- Fibonacci naïf. , coût exponentiel, .
Exemple 4.1Tri fusion
La relation est , où le terme regroupe le partage et la fusion.
Pour , l'arbre des appels a niveaux, et chaque niveau traite au total éléments. Le coût est donc de l'ordre de .
On peut aussi dérouler la relation. En posant et , on obtient soit . Pour quelconque, on encadre entre deux puissances de 2 consécutives, en utilisant que est croissante.
4.2Autres analyses, en moyenne et amortie
Le programme demande seulement de sensibiliser, sur des exemples, à l'existence d'autres analyses que le pire cas.
- La complexité en moyenne fait la moyenne du coût sur toutes les entrées d'une taille donnée, pour une loi de probabilité fixée. Le tri rapide a un coût de l'ordre de dans le pire cas mais de l'ordre de en moyenne sur les permutations équiprobables.
- La complexité amortie mesure le coût moyen d'une opération sur une suite d'opérations. Ajouter un élément à un tableau dynamique qui double sa capacité quand il est plein coûte parfois de l'ordre de , lors d'une recopie, mais le coût total de ajouts reste de l'ordre de , soit un coût amorti constant.
Méthodes
MéthodeÉcrire et analyser une fonction récursive
- Avant d'écrire la fonction, écrire la relation mathématique qu'elle doit suivre, cas de base compris. Le code en est la traduction directe.
- Vérifier que chaque cas récursif se fait sur un argument strictement plus petit, et que tous les cas de base possibles sont traités : liste vide, entier nul.
- Pour la terminaison, nommer la mesure et l'ordre bien fondé. Pour la correction, écrire la propriété prouvée par récurrence.
- Pour le coût, écrire la relation de récurrence, puis dessiner l'arbre des appels : nombre de nœuds par niveau et travail par nœud.
- Repérer les calculs répétés dans l'arbre des appels. Un même sous-appel calculé plusieurs fois signale un coût exponentiel évitable.
- Pour éviter un débordement de pile sur de grandes entrées, chercher une version récursive terminale avec accumulateur.
Ce qu'il faut savoir refaire en khôlle
- Écrire en OCaml une fonction récursive sur les entiers et une sur les listes, avec filtrage, et prouver leur correction par récurrence.
- Définir deux fonctions mutuellement récursives et expliquer l'usage de
let rec ... and .... - Dessiner l'arbre des appels de la fonction de Fibonacci naïve et en déduire le nombre d'appels et la hauteur de pile.
- Définir un ordre bien fondé, montrer que l'ordre lexicographique sur les couples d'entiers naturels l'est, et prouver la terminaison de la fonction d'Ackermann.
- Écrire le tri par partition-fusion sur les listes, établir la relation de coût et justifier le coût de l'ordre de .
- Présenter l'idée de Karatsuba, établir sa relation de coût et comparer à la multiplication naïve.
- Donner un exemple de complexité en moyenne et un exemple de complexité amortie.
Méthodes et exercices
Les résultats à connaître
Le chapitre contient surtout des méthodes de preuve et des coûts de référence, à savoir établir sur chaque exemple.
- Principe de terminaison. Si le corps termine hors appels récursifs, et si chaque appel porte sur un argument strictement plus petit pour un ordre bien fondé, alors tout appel termine. Exigible avec démonstration.
- Un ordre est bien fondé si et seulement si toute partie non vide admet un élément minimal. Exigible, preuve esquissée.
- N muni de l'ordre usuel est bien fondé. Admis, c'est le cours de mathématiques.
- L'ordre lexicographique sur N², et sur Nk, est bien fondé. Exigible avec démonstration.
- L'ordre lexicographique sur les mots d'un alphabet d'au moins deux lettres n'est pas bien fondé. Contre-exemple exigible.
- Correction. Elle se prouve par récurrence simple, forte ou bien fondée sur la mesure de terminaison. Méthode exigible.
- Fusion de deux listes triées de longueurs p, q ≥ 1. Le résultat est trié, avec au plus p + q − 1 comparaisons. Exigible avec démonstration.
- Tri par partition-fusion. Il est correct, avec O(n log n) comparaisons dans le pire cas. Exigible avec démonstration.
- Exponentiation rapide. Elle fait O(log n) multiplications. Exigible avec démonstration.
- Karatsuba. Son coût est O(nlog₂ 3). Exigible sur l'exemple, par l'arbre des appels.
- Fibonacci naïf. Il fait 2F(n + 1) − 1 appels, où F est la suite de Fibonacci, et la pile atteint une hauteur d'ordre n. Exigible avec démonstration.
- Appel terminal. OCaml n'empile pas de nouvelle activation. Admis, c'est un comportement du compilateur.
- Tri rapide. Il est quadratique dans le pire cas et en O(n log n) en moyenne. Sensibilisation, le résultat en moyenne est admis.
- Hors programme : théorème général des relations de récurrence (« théorème maître »), dérécursification générale, classes de complexité.
Clés de démonstration
Principe de terminaison. Si un appel ne termine pas, l'un de ses sous-appels ne termine pas non plus, puisque le corps termine hors appels. Ce sous-appel porte sur un argument strictement plus petit. En itérant, on obtient une suite infinie strictement décroissante, ce qui est impossible. Astuce : suivre la chaîne des appels qui ne terminent pas.
Ordre lexicographique sur N². Soit (ak, bk) une suite strictement décroissante. La suite des ak décroît au sens large dans N, donc elle est stationnaire à partir d'un rang K. Au-delà, ce sont les bk qui décroissent strictement dans N, contradiction. Astuce : une suite décroissante d'entiers naturels est stationnaire.
Correction du tri fusion. On raisonne par récurrence forte sur la longueur n. Pour n ≥ 2, les moitiés ont des longueurs ⌈n/2⌉ et ⌊n/2⌋, strictement inférieures à n, donc elles sont triées par hypothèse. La correction de la fusion conclut. Astuce : la récurrence forte est nécessaire, car on ne passe pas de n − 1 à n.
Coût du tri fusion pour n quelconque. On montre par récurrence forte que le coût maximal C est croissant. Si 2k−1 < n ≤ 2k, alors C(n) ≤ C(2k) = O(k·2k) = O(n log n). Astuce : se ramener aux puissances de 2 par monotonie.
Appels de Fibonacci. On a A(0) = A(1) = 1 et A(n) = 1 + A(n − 1) + A(n − 2). Avec B(n) = A(n) + 1, on obtient B(n) = B(n − 1) + B(n − 2) et B(0) = B(1) = 2, donc B(n) = 2F(n + 1). Astuce : ajouter la constante qui rend la relation homogène.
Karatsuba. Pour n = 2k, le niveau i compte 3i nœuds de travail proportionnel à n/2i. Le total est proportionnel à n·Σ(3/2)i pour i de 0 à k, somme majorée par 3·(3/2)k. D'où C(n) = O(3k) = O(nlog₂ 3). Astuce : dans une somme géométrique de raison supérieure à 1, le dernier terme domine.
Exemples et contre-exemples à connaître
- Suite de Syracuse. On écrit sans peine la fonction qui compte les étapes jusqu'à 1, mais personne ne sait prouver qu'elle termine toujours. La terminaison n'est pas une formalité.
- Fonction 91 de McCarthy : f(n) = n − 10 si n > 100, f(f(n + 11)) sinon. Elle vaut 91 pour n ≤ 100, alors que l'argument croît d'abord. La preuve utilise la mesure 101 − n et établit la valeur en même temps.
- Une décrémentation jusqu'à 0 ne termine pas sur un entier négatif. Z n'est pas bien fondé, la spécification doit fixer le domaine.
- Les mots b, ab, aab, aaab, … décroissent strictement pour l'ordre lexicographique. Cet ordre n'est pas bien fondé sur les mots.
- Sans le cas de base [_], le tri fusion boucle sur une liste à un élément. La preuve de terminaison détecte le bug.
- Renverser une liste avec
@coûte de l'ordre de n², avec un accumulateur de l'ordre de n. L'opérateur@n'est pas en temps constant. - Écrire
puissance x (n / 2) * puissance x (n / 2)redonne un coût linéaire. Deux appels identiques annulent le gain. - Le tri rapide avec la tête comme pivot est quadratique sur une liste déjà triée. Le pire cas peut être l'entrée la plus banale.
- Euclide,
pgcd a b = pgcd b (a mod b), termine avec la mesure b et coûte un temps logarithmique. C'est une récursion sur les entiers qui n'est pas une décrémentation. - A(4, 2) = 265536 − 3 pour Ackermann, soit plus de dix-neuf mille chiffres. Terminer ne veut pas dire terminer vite.
Techniques du chapitre
Technique 1 : écrire la relation avant le code. Nombre de chiffres décimaux de n ≥ 1 : c(n) = 1 si n < 10, 1 + c(n / 10) sinon, avec la division entière. Code : let rec chiffres n = if n < 10 then 1 else 1 + chiffres (n / 10). La mesure n décroît strictement dans N, car n / 10 < n.
Technique 2 : généraliser l'énoncé pour un accumulateur. Soit aux [] acc = acc et aux (x :: q) acc = aux q (acc + x). On prouve « pour tout acc, aux l acc = acc + somme de l » par récurrence sur la longueur. L'hérédité donne acc + x + somme q. L'énoncé doit porter sur tout acc.
Technique 3 : nommer la mesure. Pour pgcd a b avec a, b ≥ 0, la mesure est b ∈ N. Si b > 0, l'appel porte sur (b, a mod b) avec 0 ≤ a mod b < b. La mesure décroît strictement, donc la fonction termine.
Technique 4 : récurrence forte pour les appels sur n/2. Soit f n = if n = 0 then 0 else 1 + f (n / 2). Pour n ≥ 1, n / 2 < n et son écriture binaire est celle de n privée du dernier bit. La récurrence forte montre que f n est le nombre de bits de n.
Technique 5 : lire le coût sur l'arbre. Pour C(1) = 0 et C(n) = 2C(n/2) + 1 avec n = 2k, le niveau i compte 2i nœuds de coût 1, pour i de 0 à k − 1. Donc C(n) = 2k − 1 = n − 1.
Technique 6 : sommer une série géométrique décroissante. Pour C(1) = 1 et C(n) = C(n/2) + n, on déroule : C(n) = n + n/2 + … + 1 < 2n. Le premier niveau domine, le coût est linéaire.
Technique 7 : supprimer les calculs répétés en renvoyant un couple. On calcule (F(n), F(n + 1)) à partir de (F(n − 1), F(n)), avec un seul appel par niveau (exercice 3).
Exercices classiques corrigés
Choisis ton niveau. Difficulté indicative ; les exercices qui se suivent restent dans l'ordre.
Exercice 1. Renverser une liste
Classiques de khôlle et concours · Khôlle
Écrire une version naïve et une version avec accumulateur du renversement d'une liste. Comparer les coûts et prouver la seconde.
Technique accumulateur et généralisation de l'énoncé.
Voir le corrigé
- La version naïve place la tête après le renversé de la queue. L'autre empile les éléments sur un accumulateur.
let rec renverse_naif l = match l with
| [] -> []
| x :: q -> renverse_naif q @ [x]
let renverse l =
let rec aux l acc = match l with
| [] -> acc
| x :: q -> aux q (x :: acc) in
aux l []
l1 @ l2coûte de l'ordre de la longueur de l1. La version naïve coûte 0 + 1 + … + (n − 1) = n(n − 1)/2, la seconde fait n appels de coût constant.- Par récurrence sur la longueur de l : pour tout acc, aux l acc = (renversé de l) @ acc. Si l = x :: q, aux q (x :: acc) = (renversé de q) @ [x] @ acc = (renversé de l) @ acc.
- Avec acc vide, renverse est correcte.
piège : Prouver seulement le cas acc = [] : l'hypothèse ne s'applique plus à x :: acc.
Exercice 2. Exponentiation rapide
Classiques de khôlle et concours · Khôlle
Calculer xn pour n ≥ 0 en O(log n) multiplications, et prouver terminaison, correction et coût.
Technique récurrence forte et comptage des appels.
Voir le corrigé
let rec puissance x n =
if n = 0 then 1
else
let y = puissance x (n / 2) in
if n mod 2 = 0 then y * y else x * y * y
- Terminaison : mesure n ∈ N, et n / 2 < n pour n ≥ 1.
- Correction par récurrence forte. Pour n = 0, on renvoie 1 = x⁰. Pour n ≥ 1, y = xn/2. Si n est pair, y² = xn, sinon n = 2(n/2) + 1 et x·y² = xn.
- Coût : les arguments sont n, n/2, …, 1, 0, soit ⌊log₂ n⌋ + 2 appels pour n ≥ 1. Ceux sur un argument non nul font au plus deux multiplications, d'où au plus 2⌊log₂ n⌋ + 2 multiplications.
piège : Oublier que les entiers OCaml sont bornés : le résultat n'est exact que sans dépassement. Le coût, lui, ne change pas.
Exercice 3. Fibonacci, arbre des appels et version linéaire
Classiques de khôlle et concours · Khôlle
Pour fib naïf, donner le nombre d'appels et la hauteur maximale de pile. Écrire une version linéaire.
Technique relation sur le nombre d'appels et renvoi d'un couple.
Voir le corrigé
- Le nombre d'appels vaut 2F(n + 1) − 1 (section 2), exponentiel en n.
- La pile contient un chemin de la racine au nœud courant. La branche la plus longue suit n, n − 1, …, 1, d'où une hauteur de pile de n.
- Version linéaire, où aux n renvoie (F(n), F(n + 1)) :
let fib n =
let rec aux n =
if n = 0 then (0, 1)
else let a, b = aux (n - 1) in (b, a + b) in
let a, _ = aux n in a
- Correction par récurrence simple, et n + 1 appels.
piège : Confondre le nombre d'appels, exponentiel, et la hauteur de pile, linéaire.
Exercice 4. Tours de Hanoï
Classiques de khôlle et concours · Khôlle
n disques de tailles distinctes sont empilés du plus grand au plus petit sur un piquet, avec deux autres piquets. On déplace un disque à la fois, jamais sur un plus petit. Renvoyer la liste des déplacements et montrer que 2n − 1 déplacements sont nécessaires et suffisants.
Technique relation de récurrence et argument d'optimalité.
Voir le corrigé
let rec hanoi n dep arr inter =
if n = 0 then []
else hanoi (n - 1) dep inter arr @ [(dep, arr)] @ hanoi (n - 1) inter arr dep
- On déplace les n − 1 petits disques sur l'intermédiaire, puis le grand, puis les n − 1 petits par-dessus.
- D(0) = 0 et D(n) = 2D(n − 1) + 1, donc D(n) = 2n − 1.
- Minoration. Au premier déplacement du grand disque, les n − 1 autres sont tous sur le troisième piquet, ce qui a demandé au moins M(n − 1) coups. Après son dernier déplacement, il en faut au moins M(n − 1) pour reposer les autres. Donc M(n) ≥ 2M(n − 1) + 1 et M(n) ≥ 2n − 1.
piège : Confondre déplacements et coût du programme : à cause de @, construire la liste coûte plus que 2n.
Exercice 5. Suites croisées
Classiques de khôlle et concours · Khôlle
On pose u₀ = v₀ = 1, u_(n+1) = un + 2v_n et v_(n+1) = un + vn. Écrire deux fonctions mutuellement récursives, compter leurs appels, puis réduire le coût.
Technique récursivité croisée et suppression des calculs répétés.
Voir le corrigé
let rec u n = if n = 0 then 1 else u (n - 1) + 2 * v (n - 1)
and v n = if n = 0 then 1 else u (n - 1) + v (n - 1)
- u n et v n déclenchent chacun u (n − 1) et v (n − 1). Donc T(0) = 1 et T(n) = 1 + 2T(n − 1), d'où T(n) = 2n+1 − 1 appels.
- Une seule fonction renvoie le couple (un, vn), en n + 1 appels.
let rec uv n =
if n = 0 then (1, 1)
else let a, b = uv (n - 1) in (a + 2 * b, a + b)
piège : Écrire deux let rec séparés : la première fonction ne connaît pas la seconde, il faut let rec … and ….
Exercice 6. Fonction d'Ackermann
Classiques de khôlle et concours · Khôlle
Avec la définition de la fiche, calculer A(1, n), A(2, n) et A(3, n), puis rédiger la terminaison.
Technique récurrence sur n à m fixé et ordre lexicographique.
Voir le corrigé
- A(1, 0) = A(0, 1) = 2 et A(1, n + 1) = A(1, n) + 1, donc A(1, n) = n + 2.
- A(2, 0) = A(1, 1) = 3 et A(2, n + 1) = A(2, n) + 2, donc A(2, n) = 2n + 3.
- A(3, 0) = A(2, 1) = 5 et A(3, n + 1) = 2A(3, n) + 3, donc A(3, n) = 2n+3 − 3 par récurrence.
- Terminaison par récurrence bien fondée sur (m, n) pour l'ordre lexicographique. Dans le troisième cas, l'appel intérieur porte sur (m + 1, n) < (m + 1, n + 1), donc il termine et renvoie un entier naturel k. L'appel extérieur porte sur (m, k) < (m + 1, n + 1), quel que soit k.
piège : Oublier l'appel intérieur, ou supposer k petit. Seule la première composante compte pour l'appel extérieur.
Exercice 7. Borne exacte pour le tri fusion
Classiques de khôlle et concours · D'après un écrit CCINP MP
Soit C(n) le nombre maximal de comparaisons du tri fusion sur une liste de longueur n ≥ 1. Montrer que C(n) ≤ n⌈log₂ n⌉.
Technique récurrence forte avec parties entières.
Voir le corrigé
- C(1) = 0 et, pour n ≥ 2, C(n) ≤ C(⌈n/2⌉) + C(⌊n/2⌋) + n − 1, car la fusion fait au plus p + q − 1 comparaisons.
- Soit n ≥ 2 et k = ⌈log₂ n⌉. Comme n ≤ 2k, on a ⌊n/2⌋ ≤ ⌈n/2⌉ ≤ 2k−1, donc les deux moitiés ont un logarithme arrondi au plus k − 1.
- Par récurrence forte, C(n) ≤ (⌈n/2⌉ + ⌊n/2⌋)(k − 1) + n − 1 = nk − 1 ≤ nk.
piège : Affirmer ⌈n/2⌉ ≤ 2k−1 sans le justifier par n ≤ 2k et la croissance de la partie entière supérieure.
Exercice 8. Compter les inversions
Approfondissement · Type écrit Mines
Une inversion d'une liste est un couple de positions i < j avec li > lj. Compter les inversions en O(n log n).
Technique diviser pour régner avec recomposition non évidente.
Voir le corrigé
- On coupe la liste par position en deux moitiés. Les inversions sont internes à l'une, internes à l'autre, ou croisées. Trier chaque moitié ne change pas les inversions croisées. On les compte pendant la fusion : si y, pris dans la seconde moitié, passe avant x, les éléments restants de la première sont ≥ x > y et forment chacun une inversion avec y.
- Code, où n1 est la longueur restante de l1 :
let rec coupe l k = if k = 0 then [], l else match l with
| [] -> [], []
| x :: q -> let a, b = coupe q (k - 1) in x :: a, b
let rec fusion l1 l2 n1 = match l1, l2 with
| [], l | l, [] -> l, 0
| x :: q1, y :: q2 ->
if x <= y then let r, c = fusion q1 l2 (n1 - 1) in x :: r, c
else let r, c = fusion l1 q2 n1 in y :: r, c + n1
let rec inv l n =
if n <= 1 then l, 0
else
let a, b = coupe l (n / 2) in
let ta, ia = inv a (n / 2) in
let tb, ib = inv b (n - n / 2) in
let t, ic = fusion ta tb (n / 2) in
t, ia + ib + ic
- Chaque inversion croisée est comptée une fois, quand son y est placé. Le coût vérifie C(n) = 2C(n/2) + O(n), soit O(n log n).
piège : Réutiliser le partage un sur deux de la fiche : il mélange les positions, et les couples croisés ne sont plus des inversions.
Exercice 9. Compteur binaire et coût amorti
Approfondissement · Type oral Centrale
Un entier est la liste de ses bits, poids faible en tête. Écrire l'incrémentation, donner son pire cas, puis le coût total de N incrémentations depuis zéro.
Technique analyse amortie par comptage bit par bit.
Voir le corrigé
let rec incr l = match l with
| [] -> [1]
| 0 :: q -> 1 :: q
| _ :: q -> 0 :: incr q
- Si les t bits de poids faible valent 1, l'appel coûte t + 1. Le pire cas est de l'ordre de log N.
- Chaque position visitée change de valeur. En N incrémentations, le bit de poids 2i change au plus ⌊N/2i⌋ fois.
- Le coût total est au plus N(1 + 1/2 + 1/4 + …) < 2N, soit un coût amorti constant.
piège : Multiplier le pire cas par N : la majoration N log N est juste mais pas optimale.
Erreurs fréquentes
- Oublier ou mal choisir un cas de base, comme la liste à un élément du tri fusion ou un entier négatif. La terminaison doit couvrir tout le domaine annoncé.
- Compter
@,List.lengthou une copie de liste comme des opérations de coût constant. Leur coût est proportionnel à la longueur. - Faire une récurrence simple quand les appels portent sur n/2. Il faut une récurrence forte, ou bien fondée sur la mesure.
- Recalculer deux fois le même sous-appel. Le coût passe de logarithmique à linéaire, ou de linéaire à exponentiel.
- Proposer une mesure qui ne décroît pas à chaque appel, ou qui vit dans Z. Pour Ackermann, l'appel imbriqué se traite à part.
Fiche de cours rédigée à partir du programme officiel de l'option informatique de la filière MPSI-MP (2021), programme de première année, partie 1.1 « Programmation récursive ». Elle couvre la récursivité et la récursivité croisée, l'organisation des activations sous forme d'arbre en cas d'appels multiples, le lien avec le principe de récurrence, la terminaison et la notion d'ordre bien fondé, la stratégie diviser pour régner avec le tri par partition-fusion, et l'analyse de la complexité des algorithmes récursifs. Les exemples sont écrits en OCaml, langage de l'option, dont les éléments exigibles sont détaillés au chapitre suivant. Toute théorie générale de la dérécursification, tout théorème général de complexité et la notion de classe de complexité d'un problème sont hors programme.