Objectifs du chapitre
À la fin de ce chapitre, vous serez capable de:
- écrire une définition récursive correcte, reconnaître son cas de base et son cas récursif, et justifier sa terminaison et sa correction par récurrence;
- décrire ce qu'est la pile d'appels, ce que contient un cadre, pourquoi la profondeur de récursion est bornée et ce qu'est un débordement de pile;
- compter les appels d'une récursion naïve, expliquer pourquoi Fibonacci récursif est catastrophique, et le corriger par mémoïsation ou par itération;
- appliquer le schéma diviser pour régner à la recherche dichotomique et au tri fusion, et établir leur invariant, leur correction et leur coût;
- poser et résoudre une relation de récurrence par l'arbre de récursion, puis par le théorème maître, en sachant quelles hypothèses celui-ci exige;
- démontrer qu'aucun tri par comparaisons ne peut descendre sous , et situer le tri fusion par rapport à cette borne.
Définitions récursives
Un objet défini à partir de lui-même
Le chapitre 2 a décrit un algorithme comme une suite finie d'instructions non ambiguës, et a mesuré son coût en comptant les opérations élémentaires qu'il exécute. Tous les algorithmes rencontrés jusqu'ici — la recherche séquentielle, le tri par insertion, le tri par sélection — étaient itératifs: une boucle, un invariant, un compteur qui avance. Ce chapitre introduit la seconde manière de construire un algorithme, celle qui donne son nom à la moitié de l'informatique: la récursivité.
L'idée paraît d'abord circulaire. Pour définir une chose, on s'autorise à l'employer. Pour calculer une valeur, on s'autorise à appeler le calcul qu'on est en train de définir. Ce qui empêche la circularité de dégénérer en cercle vicieux est une discipline très simple, et c'est tout le contenu de la notion.
Retenez la forme: un cas de base, un cas récursif, et une taille qui décroît strictement. Une définition récursive à laquelle il manque l'un des trois est fausse, et elle est fausse de trois manières différentes. Sans cas de base, la descente est infinie. Sans cas récursif, la fonction n'est définie qu'en un point. Sans décroissance stricte — par exemple , ou — la définition ne détermine rien du tout, et le programme correspondant ne s'arrête jamais.
La factorielle
L'exemple canonique est la factorielle. La définition habituelle, , cache une récurrence: le produit des premiers entiers est le produit des premiers, multiplié par . On écrit donc
Le cas de base est (avec la convention , qui n'est pas un caprice: c'est le produit vide, et c'est le nombre de façons d'ordonner zéro objet, à savoir une). Le cas récursif fait décroître de : partant de , on atteint le cas de base après exactement appels. La traduction en pseudocode est littérale.
fonction factorielle(n)
si n = 0 alors
retourner 1 # cas de base
sinon
retourner n * factorielle(n - 1) # cas récursif
et en Python, ligne pour ligne:
def factorielle(n):
if n == 0: # cas de base
return 1
return n * factorielle(n - 1) # cas récursif
La suite de Fibonacci
Le second exemple classique a deux cas de base et deux appels récursifs. La suite de Fibonacci est définie par
Ses premiers termes sont Deux cas de base sont ici nécessaires, et non un seul: le cas récursif descend de deux crans, et sans la chaîne partant de passerait à côté de sans jamais s'arrêter proprement. C'est une vérification à faire systématiquement:
Récursivité et récurrence: le même objet
Le lien entre un algorithme récursif et une démonstration par récurrence n'est pas une analogie: c'est une identité de structure. Une démonstration par récurrence d'une propriété comporte une initialisation — est vraie — et une hérédité — si est vraie alors l'est. Un algorithme récursif comporte un cas de base et un cas récursif. La correspondance est terme à terme, et elle fournit la méthode de preuve: pour prouver qu'un algorithme récursif est correct, on prouve son cas de base, puis on suppose l'appel récursif correct et on vérifie que le cas récursif en tire le bon résultat. Cette hypothèse est exactement l'hypothèse de récurrence.
Démonstration. Par récurrence sur .
Terminaison. Le paramètre de l'appel décroît strictement d'une unité à chaque appel récursif et reste entier; la suite des paramètres atteint donc après exactement appels, où le cas de base rend la main sans appeler quoi que ce soit. La chaîne d'appels est de longueur , donc finie.
Initialisation. Pour , le test est vrai et la fonction retourne .
Hérédité. Soit et supposons que factorielle(n - 1) retourne . L'appel factorielle(n) prend la branche récursive et retourne par (3.1).
Par récurrence, la propriété vaut pour tout .
Remarquez le confort de cette preuve: elle ne suit pas l'exécution. Elle ne descend pas la chaîne d'appels, elle ne tient pas de compte des multiplications en attente. Elle se contente de supposer que l'appel interne fait son travail. C'est le bénéfice principal de la récursivité — et, nous allons le voir, aussi son piège, car une preuve de correction ne dit rien du coût.
Laquelle de ces définitions récursives est bien fondée, c'est-à-dire termine pour tout entier ?
La pile d'appels
Ce qu'un appel coûte
Un appel de fonction n'est pas gratuit. Lorsqu'une fonction en appelle une autre, la machine doit se souvenir de l'endroit où elle en était: quelle instruction reprendre au retour, quelles valeurs les variables locales avaient. Cette information est rangée dans une zone de mémoire appelée la pile d'appels (call stack).
Ce fonctionnement «dernier entré, premier sorti» est celui d'une pile au sens des structures de données, que le chapitre 4 étudiera pour elle-même. Il suffit ici de retenir ce qu'il implique: les appels en attente occupent de la mémoire, et cette mémoire n'est libérée qu'au retour.
Suivons factorielle(4). L'appel initial empile un cadre où ; il ne peut pas rendre son résultat avant de connaître factorielle(3), dont le cadre s'empile par-dessus, et ainsi de suite. Quand est atteint, cinq cadres sont présents simultanément. Le cas de base retourne , son cadre est dépilé, le cadre calcule et se dépile, puis , , . Les multiplications s'effectuent donc , dans l'ordre inverse des appels. C'est exactement ce que la preuve du théorème 3.1 nous a dispensés de suivre.
La profondeur est bornée
La pile n'est pas extensible. Le système d'exploitation lui alloue une zone de taille fixée à la création du fil d'exécution, de l'ordre de quelques centaines de kibioctets à quelques mébioctets selon la plateforme; l'ordre de grandeur usuel pour le fil principal d'un programme est de 1 Mio à 8 Mio. Si un cadre occupe une centaine d'octets, cela autorise quelques dizaines de milliers à quelques centaines de milliers d'appels imbriqués — pas davantage.
Python place ce garde-fou très bas, précisément pour transformer un plantage en exception propre. Avec la limite par défaut de 1000, la profondeur maximale mesurée sur la machine utilisée pour ce chapitre est de 998 appels imbriqués; en la portant à 3000 avec sys.setrecursionlimit(3000), elle passe à 2998. La somme récursive d'une liste illustre la conséquence:
def somme(a, i=0):
if i == len(a): # cas de base: plus rien à additionner
return 0
return a[i] + somme(a, i + 1)
Cette fonction renvoie bien pour la liste des entiers de à , et lève RecursionError: maximum recursion depth exceeded while calling a Python object sur une liste de 5000 éléments. Or additionner 5000 nombres n'est pas une prouesse: une boucle for le fait sans le moindre effort, et sans consommer un octet de pile supplémentaire. Voilà le premier avertissement du chapitre: une récursion dont la profondeur croît linéairement avec la taille de l'entrée est une bombe à retardement, alors qu'une récursion de profondeur logarithmique — celle du tri fusion, celle de la dichotomie — ne posera jamais de problème, car .
Un tri fusion traite un tableau de éléments. Chaque niveau de récursion coupe le tableau en deux. Combien de cadres d'appel au maximum sont simultanément empilés, si l'on compte le cadre de l'appel initial? (Réponse: .)
Fibonacci récursif naïf: l'anatomie d'une catastrophe
Le programme
La traduction littérale de (3.2) tient en trois lignes et elle est correcte — la preuve est celle du théorème 3.1, avec deux cas de base et une récurrence forte.
def fib(n):
if n <= 1: # cas de base: F(0) = 0, F(1) = 1
return n
return fib(n - 1) + fib(n - 2)
Elle est aussi inutilisable. Sur la machine utilisée pour ce chapitre, fib(35) demande 1,976 seconde, alors que la même valeur s'obtient par une boucle en 1,14 microseconde, soit un rapport de l'ordre de . Comprendre pourquoi est l'exercice le plus instructif du chapitre.
L'arbre des appels
Chaque appel fib(n) avec en engendre deux, fib(n-1) et fib(n-2), qui en engendrent à leur tour. La structure de l'exécution est donc un arbre d'appels: la racine est l'appel initial, les enfants d'un nœud sont les appels qu'il déclenche, et les feuilles sont les appels qui retournent sans rien appeler, c'est-à-dire les cas de base.
La figure 3.1 rend la catastrophe visible. La branche de gauche descend jusqu'à en passant par ; mais apparaît une seconde fois comme fils droit de la racine, et son sous-arbre entier — cinq appels — est recalculé à l'identique. Rien dans le programme ne conserve la moindre valeur d'un appel à l'autre: chaque rencontré repart de la racine de son propre sous-arbre.
Combien d'appels, exactement
Le décompte n'a pas besoin d'être approché: il est exact et il se démontre par récurrence.
Démonstration. Par récurrence forte sur .
Initialisation. Pour et , l'appel prend le cas de base et n'en déclenche aucun autre: . Or et : la formule est vérifiée.
Hérédité. Soit et supposons (3.3) vraie pour tous les indices strictement inférieurs à . L'appel fib(n) compte pour un appel, puis déclenche fib(n-1) et fib(n-2); les deux sous-arbres sont disjoints, donc
en utilisant (3.2). Pour les feuilles: une feuille est un appel avec , un nœud interne en a exactement deux; dans un arbre binaire où tout nœud interne a deux enfants, le nombre de feuilles dépasse d'une unité le nombre de nœuds internes, donc feuilles et nœuds internes .
Ces valeurs ont été vérifiées en instrumentant la fonction avec un compteur et en comparant, pour de à , le compteur à : les deux coïncident à chaque ligne. Le tableau ci-dessous donne les valeurs mesurées.
| 5 | 10 | 20 | 30 | 35 | 40 | |
|---|---|---|---|---|---|---|
| 5 | 55 | 6 765 | 832 040 | 9 227 465 | 102 334 155 | |
| Appels | 15 | 177 | 21 891 | 2 692 537 | 29 860 703 |
La croissance de est exponentielle: on montre (et le chapitre 2 a donné l'outil, la comparaison asymptotique) que avec , le nombre d'or. Par conséquent
c'est-à-dire une complexité exponentielle. Chaque unité ajoutée à multiplie le travail par ; dix unités le multiplient par . Le tableau le confirme: de à , le nombre d'appels passe de 2,7 millions à 331 millions, soit un facteur 123.
Mémoïsation: se souvenir
Le défaut est identifié — le même sous-problème est résolu de multiples fois — donc le remède aussi: se souvenir. On conserve dans un dictionnaire les valeurs déjà calculées et on les consulte avant de recalculer. C'est la mémoïsation (memoization, sans «r»: le mot vient du latin memorandum).
def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n in memo: # déjà calculé: on le relit
return memo[n]
if n <= 1:
return n
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
Le changement est minime et l'effet massif. Chaque valeur pour de à n'est calculée qu'une fois; les appels supplémentaires se soldent par une lecture du dictionnaire. Le nombre d'appels devient linéaire en et le coût aussi, au prix d'une mémoire pour le dictionnaire — un compromis temps-mémoire tout à fait typique.
Itération: ne rien empiler du tout
Mieux encore: la récurrence (3.2) ne fait intervenir que les deux valeurs précédentes. Il est donc inutile de mémoriser toute la suite. Deux variables suffisent.
def fib_iter(n):
a, b = 0, 1 # invariant: a = F(k), b = F(k+1)
for _ in range(n): # après k tours, a = F(k)
a, b = b, a + b
return a
L'invariant de boucle est écrit dans le commentaire, dans l'esprit du chapitre 2: avant le tour numéro , on a et . Il est vrai au départ (, ) et se conserve, puisque le nouveau couple est . Après tours, . Le coût est opérations et mémoire, et la pile ne grandit jamais.
Les trois coûts, mesurés
Les durées ci-dessous ont été mesurées avec time.perf_counter sous CPython 3.9.6, une exécution unique pour la version naïve et une moyenne sur mille exécutions pour les deux autres. Ce sont des mesures, pas des estimations; elles varient de quelques dizaines de pour cent d'une exécution à l'autre, et les ordres de grandeur seuls sont significatifs.
| Appels (naïf) | Naïf | Mémoïsé | Itératif | |
|---|---|---|---|---|
| 10 | 177 | 0,014 ms | 2,07 µs | 0,41 µs |
| 20 | 21 891 | 1,41 ms | 4,44 µs | 0,69 µs |
| 30 | 2 692 537 | 175 ms | 6,93 µs | 0,98 µs |
| 35 | 29 860 703 | 1 976 ms | 8,14 µs | 1,14 µs |
Lisez ce tableau colonne par colonne. La colonne «naïf» est multipliée par 11 entre et , ce qui est bien . Les colonnes «mémoïsé» et «itératif» croissent lentement et linéairement. À , le rapport entre naïf et itératif atteint : la différence entre les deux programmes n'est pas une affaire de langage, de compilateur ou de machine, mais de , et aucun matériel n'y changera rien. En extrapolant la mesure par le facteur , la version naïve demanderait environ 22 secondes pour , 45 minutes pour , et près de 160 ans pour — alors que la version itérative répond en une microseconde.
Classez ces quatre façons de calculer pour grand, de la plus coûteuse à la moins coûteuse, en départageant les deux méthodes linéaires par la mémoire qu'elles consomment.
Glissez les éléments pour les mettre dans le bon ordre
- Récursion mémoïsée: opérations et mémoire
- Exponentiation rapide de la matrice de Fibonacci: multiplications
- Boucle à deux variables: opérations et mémoire
- Récursion naïve: opérations
La recherche dichotomique
Le problème et l'algorithme
Chercher une valeur dans un tableau trié est le premier exemple sérieux de diviser pour régner, et le plus simple: on divise, on résout une seule des deux moitiés, et il n'y a rien à recombiner.
L'idée est celle du jeu où l'on doit deviner un nombre entre 1 et 100: on propose 50, on apprend si la cible est au-dessus ou en dessous, et l'on a éliminé la moitié des possibilités en une question. Sur un tableau trié , on compare la cible à l'élément central ; si c'est fini, si la cible ne peut être que dans la moitié droite, et si que dans la moitié gauche.
fonction dichotomie(a, x)
bas ← 0 ; haut ← longueur(a) - 1
tant que bas ≤ haut faire
m ← partie_entiere((bas + haut) / 2)
si a[m] = x alors retourner m
sinon si a[m] < x alors bas ← m + 1
sinon haut ← m - 1
retourner «absent»
L'invariant et la correction
Démonstration. Invariant. Nous affirmons qu'au début de chaque tour de boucle, si figure dans , alors son indice appartient à l'intervalle .
Avant le premier tour, l'intervalle est tout entier: l'invariant est vrai. Supposons-le vrai au début d'un tour. Trois cas. Si , on retourne et la conclusion est acquise. Si , alors, le tableau étant trié, tout indice vérifie , donc aucun indice inférieur ou égal à ne porte ; l'indice cherché, s'il existe, est donc dans , qui est le nouvel intervalle. Le cas est symétrique. L'invariant est donc conservé.
Terminaison. Posons , le nombre de candidats. Comme , chacune des deux branches retire au moins l'élément , donc décroît strictement à chaque tour tout en restant entier positif ou nul. La boucle s'arrête donc, soit par un retour, soit quand , c'est-à-dire .
Correction. Si la boucle se termine par , l'intervalle est vide; par l'invariant, ne figure pas dans , et retourner «absent» est correct.
Coût. Précisons la décroissance. Avec , la moitié gauche compte éléments et la moitié droite ; dans les deux cas le nouveau nombre de candidats est au plus . Partant de , après comparaisons il reste au plus candidats. La boucle s'arrête dès que ce nombre atteint , c'est-à-dire dès que , soit .
La formule (3.5) est d'une sobriété remarquable. Pour elle donne ; pour elle donne ; pour elle donne . Chercher dans un annuaire d'un milliard de noms coûte trente comparaisons. C'est ce que le chapitre 2 appelle , et c'est la deuxième meilleure classe de complexité après .
Un tableau trié contient éléments. Combien de comparaisons la recherche dichotomique effectue-t-elle au pire?
Diviser pour régner
Le schéma général
La dichotomie est un cas particulier d'un schéma qui organise une grande partie de l'algorithmique.
Dans la dichotomie, (on ne résout qu'une moitié), , et : une comparaison pour choisir la moitié, rien à combiner. Dans le tri fusion, , et : la division est triviale (couper au milieu) mais la combinaison est le vrai travail.
Le tri fusion
Tout repose sur la fusion. Deux tableaux déjà triés se fusionnent en parcourant les deux de front avec un doigt sur chacun: on compare les deux éléments pointés, on recopie le plus petit, on avance le doigt correspondant, et l'on recommence. Quand un des deux est épuisé, on recopie le reste de l'autre.
fonction fusionner(G, D)
R ← tableau vide ; i ← 0 ; j ← 0
tant que i < longueur(G) et j < longueur(D) faire
si G[i] ≤ D[j] alors ajouter G[i] à R ; i ← i + 1
sinon ajouter D[j] à R ; j ← j + 1
ajouter le reste de G puis le reste de D à R
retourner R
Chaque tour de boucle effectue une comparaison et écrit un élément. Comme il y a éléments à écrire au total et que la boucle s'arrête quand l'un des deux tableaux est épuisé, le nombre de comparaisons d'une fusion est compris entre et : la fusion de deux tableaux totalisant éléments coûte , ce qui est optimal puisqu'il faut de toute façon lire les éléments.
La correction de la fusion tient dans un invariant: à chaque tour, contient, triés, les plus petits éléments de , et tous les éléments non encore recopiés leur sont supérieurs ou égaux. Il est vrai au départ ( vide) et se conserve, parce que le plus petit élément restant est nécessairement ou — les deux tableaux étant triés, aucun élément situé plus loin ne peut être plus petit que celui que son doigt pointe.
Démonstration. Par récurrence forte sur . Si , le tableau est trié par définition et l'algorithme le retourne tel quel. Soit ; supposons le résultat acquis pour toute taille strictement inférieure à . Les deux moitiés ont pour tailles et , toutes deux comprises entre et dès que : l'hypothèse de récurrence s'applique et les deux appels retournent les moitiés triées. La fusion de deux tableaux triés retourne, par son invariant, un tableau trié contenant exactement leurs éléments. Le résultat est donc une permutation triée de . La terminaison suit du même argument: la taille décroît strictement à chaque appel et le cas de base est atteint.
Le tableau du cours, déroulé
Appliquons le tri fusion au tableau fixé du cours, — celui-là même que le chapitre 2 trie par insertion. La figure 3.3 montre la descente, la remontée et le compte de comparaisons de chaque fusion; toutes ces valeurs proviennent de l'exécution effective de l'algorithme, pas d'un comptage à la main.
Suivons la moitié droite, . Elle se coupe en et . Chacune se coupe en deux singletons, déjà triés. Fusionner et demande une comparaison () et donne ; fusionner et en demande une aussi () et donne . Fusionner et demande deux comparaisons: recopie , recopie , et la moitié gauche est épuisée — le reste, , est recopié sans aucune comparaison. On obtient pour quatre comparaisons au total sur cette branche.
La fusion finale, celle de et , en demande six: contre , contre , contre , puis contre (recopie ), contre (recopie ), contre (recopie ); la moitié gauche est épuisée et est recopié gratuitement. Le grand total est .
Combien de comparaisons le tri fusion effectue-t-il au pire sur un tableau de éléments? On utilisera la formule du pire cas .
Relations de récurrence
Poser la récurrence
Le coût d'un algorithme diviser pour régner ne se lit pas dans une boucle: il se lit dans une équation. Si désigne le nombre d'opérations élémentaires sur une entrée de taille , la définition même du schéma donne
avec sous-problèmes de taille () et un coût pour diviser et combiner. Pour le tri fusion, , et — la fusion coûte un passage sur les éléments. Pour la dichotomie, , et .
Nous écrivons sans nous soucier de la divisibilité; en toute rigueur, il faudrait et . On démontre que cela ne change pas l'ordre de grandeur de la solution, et nous l'admettons: la démonstration est un exercice de comptabilité sur les parties entières, sans idée nouvelle.
L'arbre de récursion
La méthode la plus éclairante pour résoudre (3.6) consiste à dessiner l'arbre des appels et à sommer le travail niveau par niveau. Au niveau , il y a sous-problèmes, chacun de taille , chacun coûtant hors appels récursifs. Le travail du niveau vaut donc . Les feuilles sont atteintes quand , soit ; il y a alors feuilles, chacune de coût constant. D'où
Tout se joue alors dans la comparaison entre le coût des feuilles, , et le coût de la racine, . Trois régimes apparaissent, et ce sont exactement les trois cas du théorème maître.
Premier régime: le travail par niveau croît vers le bas. Prenons le tri fusion, , avec . Le niveau compte un problème de taille , coût . Le niveau compte deux problèmes de taille , coût . Le niveau : quatre problèmes de taille , coût . Le niveau : huit problèmes de taille , coût . , et il y a niveaux, d'où . C'est le cas d'équilibre, celui où et coïncident.
Deuxième régime: les feuilles dominent. Prenons . Le niveau coûte : il double à chaque descente. La somme est géométrique de raison et vaut donc, à un facteur près, son dernier terme: . Ici écrase , et . La vérification numérique sur la récurrence exacte , , donne pour , soit un rapport qui tend vers : le est bien la bonne réponse.
Troisième régime: la racine domine. Prenons . Le niveau coûte : il est divisé par deux à chaque descente. La somme géométrique de raison converge et vaut au plus , d'où — le coût est celui du niveau seul. La vérification numérique donne pour , soit un rapport qui tend vers .
Le théorème maître
Ces trois régimes se résument en un énoncé.
Ce théorème est admis. La raison est claire et vaut d'être dite: sa démonstration ne contient aucune idée que l'arbre de récursion ci-dessus n'ait déjà livrée, mais elle exige de traiter proprement les parties entières — les sous-problèmes n'ont pas tous exactement la taille — et de majorer la somme (3.7) par une série géométrique dans chacun des trois cas. C'est un travail technique de deux pages, mené dans le chapitre 4 de Cormen, Leiserson, Rivest et Stein. Ce qu'il faut retenir et savoir justifier, c'est l'argument de l'arbre: comparer le coût des feuilles au coût de la racine.
Trois applications
À gauche, la version linéaire (un appel par niveau). À droite, l'arbre complet où chaque appel en engendre b. Modèle didactique: l'arbre binaire complet majore l'arbre de Fibonacci naïf, qui n'est pas complet.
À quelle classe appartient la solution de ?
La borne inférieure: aucun tri par comparaisons ne bat n log n
Nous savons trier en . La question qui vient naturellement est: peut-on faire mieux? Une réponse négative à cette question n'est pas un aveu d'échec, c'est un théorème — et un théorème d'une nature particulière, car il ne parle pas d'un algorithme mais de tous les algorithmes d'une famille, y compris ceux que personne n'a encore écrits. C'est le sommet intellectuel de ce chapitre.
Le modèle: le tri par comparaisons
Il faut d'abord dire précisément de quoi l'on parle, car une borne inférieure n'a de sens que dans un modèle de calcul.
Le tri par insertion, le tri par sélection, le tri fusion, le tri rapide et le tri par tas sont des tris par comparaisons. Le tri par comptage et le tri par base n'en sont pas: ils utilisent les valeurs elles-mêmes comme indices, ce qui suppose un univers de valeurs restreint. Cette distinction est essentielle: le théorème ci-dessous ne s'applique pas à eux, et c'est pourquoi ils peuvent trier en dans leur domaine de validité.
L'arbre de décision
Fixons un algorithme de tri par comparaisons et une taille , et supposons les éléments deux à deux distincts. Exécutons l'algorithme. Son déroulement est entièrement déterminé par les réponses successives aux comparaisons qu'il pose: à entrée donnée, il pose une première comparaison, et selon la réponse — oui ou non — il pose une deuxième comparaison ou bien il conclut.
Deux observations, et le théorème est démontré.
La première: l'arbre a au moins feuilles. En effet, l'algorithme doit être correct sur chacune des façons d'ordonner éléments distincts. Or deux entrées demandant des permutations différentes pour être triées ne peuvent aboutir à la même feuille: la feuille détermine la permutation appliquée, et une seule permutation trie une entrée donnée. Chacune des permutations doit donc étiqueter au moins une feuille.
La seconde: un arbre binaire de hauteur a au plus feuilles. Récurrence immédiate sur : un arbre de hauteur est une feuille, soit ; un arbre de hauteur a deux sous-arbres de hauteur au plus , donc au plus feuilles.
Démonstration. Soit la hauteur de l'arbre de décision de l'algorithme pour la taille , c'est-à-dire son nombre de comparaisons dans le pire des cas. Les deux observations donnent
d'où et, en passant au logarithme de base 2, . Comme est entier, , ce qui établit (3.8).
Minorons maintenant sans aucun outil sophistiqué. Dans le produit , ne conservons que les derniers facteurs; chacun est supérieur ou égal à . Donc
Pour on a , donc : la borne annoncée vaut avec .
La constante exacte, par Stirling
La minoration élémentaire ci-dessus suffit pour l'ordre de grandeur, mais elle est lâche d'un facteur 4. La formule de Stirling donne la valeur exacte de l'asymptotique:
avec . Le terme dominant est donc bien , et le second terme, , est linéaire: il ne change pas la classe de complexité. La qualité de l'approximation (3.9) a été vérifiée numériquement: pour , et la formule de Stirling donne , soit un écart relatif de ; pour l'écart relatif tombe à environ.
Retenez la conclusion sous la forme la plus parlante: pour trier objets, il faut au moins comparaisons, et le tri fusion en effectue au plus , soit à peu près . Il n'y a pas d'algorithme de tri par comparaisons dix fois plus rapide qui attendrait d'être découvert: la borne est une propriété du problème, pas une limite de notre ingéniosité.
Quel est le nombre minimal de comparaisons, au pire des cas, que doit effectuer un tri par comparaisons sur éléments? On donnera .
Les tours de Hanoï
Le problème
Trois piquets, disques de diamètres tous différents empilés sur le premier par taille décroissante. Il s'agit de transporter la pile entière sur le troisième piquet en respectant deux règles: on ne déplace qu'un disque à la fois, et l'on ne pose jamais un disque sur un disque plus petit.
La solution récursive se lit presque sans y penser. Pour déplacer disques de vers en s'aidant de : déplacer les disques du dessus de vers (en s'aidant de ), déplacer le grand disque de vers , puis déplacer les disques de vers (en s'aidant de ).
procédure hanoi(n, depart, auxiliaire, arrivee)
si n = 0 alors retourner # rien à déplacer
hanoi(n - 1, depart, arrivee, auxiliaire)
déplacer un disque de depart vers arrivee
hanoi(n - 1, auxiliaire, depart, arrivee)
Quatre lignes. Il serait difficile de faire plus court, et une version itérative existe mais demande de raisonner sur la parité de et sur un ordre cyclique des piquets — l'exemple parfait d'un problème où la récursivité est le bon outil, celui annoncé dans l'avertissement plus haut.
Notez que la correction se prouve exactement comme le théorème 3.1: si l'on admet que les deux appels récursifs déplacent correctement disques, alors la séquence complète est légale — pendant le déplacement du grand disque, les autres sont tous sur , et le grand disque est plus grand que tous, donc aucune règle n'est violée.
Le nombre de coups
Démonstration. La formule. La structure de la procédure donne la récurrence
puisqu'elle effectue deux fois le travail pour disques plus un déplacement. Montrons par récurrence. Pour : . Si , alors
L'optimalité. Soit le nombre minimal de déplacements, toutes stratégies confondues. Le plus grand disque doit quitter au moins une fois. À l'instant où il quitte pour aller sur — ce doit être vers , sinon il faudra l'en ressortir plus tard — les autres disques ne peuvent être ni sur (ils seraient au-dessus de lui) ni sur (ils empêcheraient de l'y poser): ils sont donc tous sur . Les amener tous sur coûte au moins déplacements; le grand disque en coûte au moins un; les ramener ensuite de vers en coûte au moins . Donc , avec ; la même récurrence donne . Comme la procédure atteint cette valeur, .
Cette récurrence est du type : elle décrémente au lieu de diviser, et le théorème maître ne s'y applique pas, comme l'avertissement plus haut l'annonçait. Elle produit une complexité , exponentielle, et cette fois ce n'est pas une maladresse d'implémentation: c'est la difficulté intrinsèque du problème, puisque la borne inférieure vaut pour toute stratégie.
Synthèse
- Une définition récursive comporte un cas de base, un cas récursif et une taille qui décroît strictement; les trois sont nécessaires. Prouver qu'un algorithme récursif est correct, c'est faire une démonstration par récurrence: le cas de base est l'initialisation, l'appel récursif est l'hypothèse de récurrence.
- Chaque appel en cours occupe un cadre sur la pile d'appels (paramètres, variables locales, adresse de retour). La pile est de taille fixe: une récursion de profondeur linéaire finit par déborder — en Python, dès 998 appels avec la limite par défaut — alors qu'une récursion qui divise reste à une profondeur d'une vingtaine de cadres pour un million d'éléments.
- La version récursive naïve de Fibonacci effectue exactement appels, soit avec : 15 appels pour , 2 692 537 pour , 331 160 281 pour . La mémoïsation ramène le coût à temps et mémoire, la boucle à deux variables à temps et mémoire. Mesuré: 1,98 s contre 1,14 µs à .
Combien d'appels la version récursive naïve de Fibonacci effectue-t-elle pour calculer ? On compte l'appel initial.
Exercices
Vous pouvez afficher le corrigé directement sous chaque énoncé après avoir cherché la solution.
Pour chacune des fonctions suivantes, identifiez le ou les cas de base, le cas récursif, l'argument de décroissance, et dites si la définition est bien fondée. Si elle ne l'est pas, corrigez-la.
- = somme des entiers de à : et .
Soit fib la version récursive naïve.
- Combien d'appels et combien d'additions
fib(6)effectue-t-il? - Combien de fois
fib(2)est-il évalué au cours defib(6)? - Montrez que le nombre d'additions vaut , et interprétez ce résultat.
- Pour , estimez le temps d'exécution de la version naïve sachant que la version mesurée traite en 1,98 s.
- Sur le tableau trié , déroulez la recherche de et celle de , en donnant à chaque étape l'intervalle et le pivot.
- Quelle est la valeur de trouvée en une seule comparaison? Combien de valeurs du tableau demandent exactement trois comparaisons?
Résolvez, en indiquant le cas du théorème maître utilisé ou en expliquant pourquoi il ne s'applique pas.
- .
- .
- Un ami affirme avoir écrit un tri par comparaisons qui trie éléments en comparaisons au pire. Démontrez qu'il se trompe.
- Combien de comparaisons faut-il au minimum pour trier 5 éléments? Et 12?
- On dispose de 12 pièces d'apparence identique dont une est fausse, plus lourde ou plus légère. On ne dispose que d'une balance à deux plateaux, qui indique «gauche plus lourd», «équilibre» ou «droite plus lourde». Donnez une borne inférieure sur le nombre de pesées nécessaires pour identifier la fausse pièce et dire si elle est plus lourde ou plus légère.
- Le tri fusion effectue au plus comparaisons. Pour , comparez cette majoration à la borne et concluez.
Références
- Cormen, T. H., Leiserson, C. E., Rivest, R. L. & Stein, C., Introduction to Algorithms, 4e édition, MIT Press — chapitres 2 (tri fusion, invariants), 4 (récurrences, théorème maître, Strassen) et 8 (borne des tris par comparaisons).
- Sipser, M., Introduction to the Theory of Computation, Cengage — pour le lien entre récursivité, récurrence et définitions inductives.
- Knuth, D. E., The Art of Computer Programming, volume 3: Sorting and Searching, Addison-Wesley — section 5.3.1 pour le nombre minimal de comparaisons et l'algorithme de tri par fusion-insertion.
- Abelson, H., Ledeen, K. & Lewis, H., Blown to Bits, Addison-Wesley — pour la portée des ordres de grandeur exponentiels hors de l'informatique.
- Polycopiés du cours ICC de l'EPFL, partie «algorithmique et complexité».