0765 Techniques Algorithmiques Et Programmation
0765 Techniques Algorithmiques Et Programmation
Programmation
Cyril Gavoille
LaBRI
Laboratoire Bordelais de Recherche
en Informatique, Université de Bordeaux
[email protected]
16 août 2020
– 175 pages –
ii
Ce document est publié sous Licence Creative Commons « Attribution - Pas d’Utilisation
cbna Commerciale - Partage dans les Mêmes Conditions 4.0 International (CC BY-NC-SA 4.0) ».
Cette licence vous autorise une utilisation libre de ce document pour un usage non com-
mercial et à condition d’en conserver la paternité. Toute version modifiée de ce document doit être placée sous la
même licence pour pouvoir être diffusée. https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
iii
Programmation efficace
Christoph Dürr et Jill-Jênn Vie
Ellipses 2016
Algorithm Design
Robert Kleinberg et Éva Tardos
Pearson Education 2006
Algorithms
Jeff Erickson
Creative Commons 2019
Table des matières
1 Introduction 1
1.1 Tchisla . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2 Des problèmes indécidables . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.3 Approche exhaustive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.4 Rappels sur la complexité . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
1.4.1 Compter exactement? . . . . . . . . . . . . . . . . . . . . . . . . . . 20
1.4.2 Pour résumer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
1.5 Notations asymptotiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
1.5.1 Exemples et pièges à éviter . . . . . . . . . . . . . . . . . . . . . . . 25
1.5.2 Complexité d’un problème . . . . . . . . . . . . . . . . . . . . . . . 27
1.5.3 Sur l’intérêt des problèmes de décision . . . . . . . . . . . . . . . . 28
1.6 Algorithme et logarithme . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
1.6.1 Propriétés importantes . . . . . . . . . . . . . . . . . . . . . . . . . 32
1.6.2 Et la fonction ln n? . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
1.6.3 Tchisla et logarithme . . . . . . . . . . . . . . . . . . . . . . . . . . 37
1.7 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
2.7 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
3 Voyageur de commerce 65
3.1 Le problème . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
3.2 Recherche exhaustive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
3.3 Programmation dynamique . . . . . . . . . . . . . . . . . . . . . . . . . . 70
3.4 Approximation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
3.4.1 Algorithme glouton: un principe général . . . . . . . . . . . . . . . 80
3.4.2 Problème d’optimisation . . . . . . . . . . . . . . . . . . . . . . . . 81
3.4.3 Autres heuristiques . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
3.4.4 Inapproximabilité . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
3.4.5 Cas euclidien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
3.4.6 Une 2-approximation . . . . . . . . . . . . . . . . . . . . . . . . . . 91
3.4.7 Union-Find . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95
3.4.8 Algorithme de Christofides . . . . . . . . . . . . . . . . . . . . . . 103
3.5 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
4 Navigation 109
4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
4.1.1 Pathfinding . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
4.1.2 Navigation mesh . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
4.1.3 Rappels . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
4.2 L’algorithme de Dijkstra . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
4.2.1 Propriétés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
4.2.2 Implémentation et complexité. . . . . . . . . . . . . . . . . . . . . 118
4.3 L’algorithme A* . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
4.3.1 Propriétés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 126
4.3.2 Implémentation et complexité . . . . . . . . . . . . . . . . . . . . . 130
4.3.3 Plus sur A* . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
4.4 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
vii
Sommaire
1.1 Tchisla . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2 Des problèmes indécidables . . . . . . . . . . . . . . . . . . . . . . . . 5
1.3 Approche exhaustive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.4 Rappels sur la complexité . . . . . . . . . . . . . . . . . . . . . . . . . . 18
1.5 Notations asymptotiques . . . . . . . . . . . . . . . . . . . . . . . . . . 24
1.6 Algorithme et logarithme . . . . . . . . . . . . . . . . . . . . . . . . . . 30
1.7 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
• formule close
• indécidabilité
• instance, problème
• recherche exhaustive
• notation asymptotique, complexité
• fonction logarithme, série géométrique
1. Voir https://www.cs.princeton.edu/~chazelle/pubs/algorithm.html.
2 CHAPITRE 1. INTRODUCTION
1.1 Tchisla
Pour illustrer les notions du cours nous allons considérer un problème réel, volon-
tairement complexe.
Tchisla (du russe « Числа » qui veut dire « nombre ») est une application (voir la
figure 1.1) que l’on peut trouver sur smartphone et tablette. La première version est
sortie en 2017. Le but du jeu est de trouver une expression arithmétique égale à un
entier n > 0 mais utilisant uniquement un chiffre c ∈ {1, . . . , 9} donné. L’expression ne
peut comporter que des symboles parmi les dix suivants :
√
c + - * / ^ ! ( )
Formule close ? Ce qui nous intéresse c’est donc de calculer fc (n) pour tout c et n, et
bien sûr de trouver une expression correspondante avec le nombre optimal de chiffres c.
Il semble que les premières valeurs de n ne laissent pas apparaître de formule évidente.
La première colonne de la figure 1.1 de droite donne les dix premières valeurs pour c = 1
qui sont :
2. On peut trouver sur Internet les solutions optimales pour tous les entiers jusqu’à quelques milliers.
Dans
√ un article scientifique [Tan15] donne les solutions optimales jusqu’à 1 000 mais sans les symboles
et !. On y apprend par exemple que 37 = ccc/(c+c+c) quel que soit le chiffre c.
1.1. TCHISLA 3
n 1 2 3 4 5 6 7 8 9 10
f1 (n) 1 2 3 4 4 3 4 5 4 3
Et la table ci-après donne les dix premières valeurs de n produisant des valeurs crois-
santes pour f1 (n). Encore une fois elle ne laisse apparaître aucun paterne particulier.
f1 (n) 1 2 3 4 5 6 7 8 9 10
n 1 2 3 4 8 15 28 41 95 173
En fait, comme le montre le graphique de la figure 1.2, les 200 premières valeurs
de f1 (n) sont visiblement difficiles à prévoir. Même si les valeurs ont l’air « globalement
croissantes » avec n, on remarque qu’à cause des expressions comme
1
0 20 40 60 80 100 120 140 160 180 200
La fameuse encyclopédie en ligne 3 des suites d’entiers ne répertorie pas cette suite-
là. Certaine valeur semble plus difficile à trouver que d’autre. Pour s’en convaincre,
essayez de déterminer par exemple f4 (64) ? On a facilement 64 = 4*4*4 = 4^4/4, mais
pouvez-vous trouver une expression avec seulement deux 4 ? Ou encore une expression
correspondant à f6 (27) = 4 ?
Pour d’autres problèmes, parfois (et même très souvent) il n’y a pas non plus de
formule directe, ou plus précisément de formule close.
Par exemple, les racines des équations polynomiales de degré 5 ne possèdent pas
de formules closes dans le cas général. C’est un résultat issu de la théorie de Galois.
3. https://oeis.org/
4 CHAPITRE 1. INTRODUCTION
Un algorithme ? S’il n’y a pas de formule close pour le calcul de fc (n), on peut alors
rechercher un algorithme.
Notons qu’une formule close n’est qu’un algorithme (particulièrement simple) parmi
d’autres.
Mais qu’entendons nous par problème ?
C’est tout simplement la description des instances 5 et des sorties attendues, c’est-
à-dire la relation entre les entrées et la sortie.
Cette notion est partiellement capturée par le prototype d’une fonction C comme
int f(int c,int n)
√
Le problème présenté ici pourrait être formalisé ainsi, où Σ = {c,+,-,*,/,^, ,!(,)} est
l’alphabet des 10 symboles évoqués plus haut :
Tchisla
Instance: Un entier n > 0 et un chiffre c ∈ {1, . . . , 9}.
Question: Déterminer une expression arithmétique de valeur n composée des
symboles de Σ comportant le moins de fois le chiffres c.
4. Dans C, elles sont en fait toutes de la forme 21/6 · (cos(2kπ/6) + i sin(2kπ/6)) où k ∈ N. On parle
de racines de l’unité et elles peuvent être représentées par six points du cercle de rayon 21/6 centré à
l’origine et régulièrement espacés. On observe alors que deux des six racines tombent sur la droite réelle :
21/6 (k = 0) et −21/6 (pour k = 3).
5. On réserve le terme d’instances pour un problème. Pour un algorithme on parle plutôt d’entrées.
1.2. DES PROBLÈMES INDÉCIDABLES 5
(x − 1)! + 1 = xy (1.1)
Elles ont été étudiées depuis l’antiquité du nom de Diophante d’Alexandrie, mathéma-
ticien grec du IIIe siècle. Par exemple, le théorème de Wilson établit que l’équation (1.1)
possède une solution si et seulement si x > 1 est premier. Dit autrement l’ensemble des
x > 1 qui font parties des solutions décrit exactement l’ensemble des nombres premiers.
Par exemple, si x = 5, alors l’équation implique (5 − 1)! + 1 = 25 = 5y, soit y = 5. Mais si
x = 6, alors l’équation implique (6 − 1)! + 1 = 121 = 6y qui n’a pas de solution entière.
Ces équations, souvent très simples, sont parfois parfois extrêmement difficiles à
résoudre. Le dernier théorème de Fermat en est un exemple (cf. figure 1.3). Il aura fal-
lut 357 ans d’efforts pour démontrer en 1994, grâce à Andrew Wiles, que l’équation
xp + y p = zp (1.2)
n’a pas de solution entière strictement positive dès que p > 2. Pour p = 2, les solutions
(x, y, z) sont appelées « triplets pythagoriciens », comme (3, 4, 5). Ce n’est pas plus facile
6 CHAPITRE 1. INTRODUCTION
Il faut bien distinguer les problèmes indécidables avec les problèmes sans solution.
On mélange souvent les notions d’instance et de problème. Une équation diophantienne
est ici une instance. Elle possède ou ne possède pas de solution entière. Et pour cer-
taines d’entre elles, c’est facile de le décider : il suffit d’appliquer un théorème. Mais le
problème est de trouver une procédure systématique qui, pour toute équation diophan-
tienne (soit toute instance), détermine s’il existe ou pas de solution entière. Personne ne
peut prétendre avoir trouvé un tel algorithme, car cet algorithme n’existe pas ! Et on sait
prouver qu’il n’existe pas.
On va expliquer comment prouver qu’un algorithme n’existe pas avec le problème
suivant, indécidable donc, et qui est cher aux informaticiens et informaticiennes.
Halte
Instance: Un programme f avec une entrée x.
Question: Est-ce que f (x) s’arrête ou boucle indéfiniment ?
Encore une fois, il est clair que chaque programme sur une entrée donnée s’arrête au
bout d’un moment ou alors boucle indéfiniment 8 . Il n’y a pas d’intermédiaire. Seule-
ment, il n’existe pas d’algorithme qui à coup sûr peut déterminer pour tout programme
f et entrée x, si l’évaluation de f (x) s’arrête.
6. C’est-à-dire dont les variables ont des exposants qui sont des naturels.
7. Cette brique parfaite d’Euler, si elle existe, doit avoir ses arêtes de longueur au moins 5 × 1011 . La
plus petite solution, trouvée en 1719, satisfaisant les trois premières équations est (x, y, z) = (44, 117, 240)
et (a, b, c) = (125, 244, 267).
8. Il s’agit d’une positition de principe : il est clair qu’un programme, lorsqu’il est exécuté sur un
vrai ordinateur, s’arrêtera toujours au bout d’un moment, ne serait-ce qu’à cause du vieillissement de ses
composants et de la finitude de la quantité d’énergie électrique consommable.
1.2. DES PROBLÈMES INDÉCIDABLES 7
Parenthèse.
• Le détournement de l’instruction for en for(;;); permet de boucler indéfiniment.
On aurait aussi pu mettre while(true); ou while(1); qui ont le même effet, mais
qui sont plus long à écrire.
• En C le nom des fonctions est vu comme un pointeur représentant l’adresse mémoire
où elles sont codées en machine. On peut donc passer en paramètre une fonction sim-
plement en spécifiant son nom sans le préfixer avec &, la marque de référencement qui
permet d’avoir l’adresse où est stocké un objet. Mais ce n’est pas faux de le mettre !
Ainsi on peut écrire indifféremment halte(loop,x) ou halte(&loop,x).
La question est de savoir se qui se passe lorsqu’on fait un appel à loop(0) par
exemple ? D’après son code, loop(x) terminera si et seulement si halte(loop,x) est faux,
c’est-à-dire si et seulement si loop(x) boucle ! C’est clairement une contradiction, mon-
trant que la fonction halte(f,x) ne peut pas être correcte pour toute fonction f() et
paramètre x. Le problème de la Halte n’a pas d’algorithme.
Parenthèse. Un autre exemple bien connu est la complexité de Kolmogorov. Notée K(n), elle
est définie sur les entiers naturels comme ceci : K(n) est le nombre de caractères du plus
8 CHAPITRE 1. INTRODUCTION
court programme, disons écrit 9 en C, qui affiche l’entier n et qui s’arrête. La fonction K(n)
n’est pas calculable par un algorithme.
Pourquoi ? Supposons qu’il existe un tel algorithme capable de calculer la fonction K(n).
Cet algorithme est une suite finie d’instructions, et donc peut-être codé par une fonction K()
écrites en C dont le code comprend un total de disons k caractères. Ce programme est ainsi
capable de renvoyer la valeur K(i) pour tout entier i.
Considérons le programme P() ci-dessous faisant appel à la fonction K() et qui affiche
le plus petit entier de complexité de Kolmogorov au moins n :
Même si cela semble assez clair, montrons que ce programme s’arrête toujours. Il s’agit
d’un argument de comptage. Soit f (n) le nombre de programmes C ayant moins de n ca-
ractères 10 . Par définition de K(i), chaque entier i possède un programme de K(i) caractères
qui affiche i et s’arrête. Ces programmes sont tous différents 11 . Lorsque i va atteindre f (n),
les f (n) + 1 entiers de l’intervalle [0, f (n)] auront été examinées. Et tous ne peuvent pas
prétendre avoir un programme qui affiche un résultat différent et qui fasse moins de n ca-
ractères. Donc K(i) > n pour au moins un certain entier i ∈ [0, f (n)] ce qui montre bien que
P(n) s’arrête toujours (à cause du test while(K(i)<n) qui va devenir faux). L’entier affiché
par P(n), disons in , est le plus petit d’entre eux. Et bien évidemment K(in ) > n à cause du
while.
La fonction P() à proprement parlée fait 56 caractères, sans compter les retours à la
ligne qui ne sont pas nécessaires en C. Il faut ajouter à cela le code de la fonction K() qui
par hypothèse est de k caractères. Notons que la fonction P() dépend de n, mais la taille du
code de P() ne dépend pas de n. Idem pour K(). Pour s’en convaincre, il faut imaginer que
chaque int aurait pu être représenté par une chaîne de caractères char* donnant la liste de
ses chiffres. Donc le paramètre n et la variable i ne sont que des « pointeurs » dont la taille
ne dépendent pas de ce qu’ils pointent. Dans notre calcul de la taille du programme, ils ne
9. On peut montrer que la complexité K(n) ne dépend qu’à une constante additive près (la taille d’un
compilateur par exemple) du langage considéré.
10. Bien que la valeur exacte de f (n) n’a ici aucune espèce d’importance, on peut quand même en
donner une valeur approximative. Si on se concentre sur les programmes écrits en caractères ASCII, sur
7-bits donc, alors il y a au plus 27t programmes d’exactement t caractères. En fait beaucoup d’entre-eux
ne compilent même pas, et très peu affichent un entier et s’arrêtent. Il y a des programmes de t = 0, 1, 2, . . .
jusqu’à n − 1 caractères, d’où f (n) 6 n−1 7t = (27n − 1)/(27 − 1) < 128n .
P
t=0 2
11. On utilise ici « l’arrêt » dans la définition de K(n). Sinon, le même programme pourrait potentielle-
ment afficher plusieurs entiers s’il ne s’arrêtait pas. Par exemple, le code suivant de 32 caractères affiche
n’importe quel entier i, n’est-ce pas ? for(int i=0;;) printf("%d",i++);
1.3. APPROCHE EXHAUSTIVE 9
font qu’un seul caractère, ce qui ne dépend en rien de la valeur qu’ils représentent. Donc au
total, le code qui permet de calculer P(n) fait 56 + k caractères, une constante qui ne dépend
pas de n.
On a donc construit un programme P(n) de 56+k caractères qui a la propriété d’afficher
un entier in tel que K(in ) > n et de s’arrêter. En fixant n’importe quel entier n > 56 + k on
obtient une contradiction, puisque :
(1) P(n) s’arrête et affiche l’entier in tel que K(in ) > n ;
(2) P(n) fait 56 + k < n caractères ce qui implique K(in ) < n ;
L’hypothèse qui avait été faite, et qui se révèle fausse, est qu’il existe un algorithme (un
programme d’une certaine taille k) qui calcule la fonction K(n).
n = 3!!!!
n = 3!!!!!/33!!!!
L’instance est bien la même. La différence avec Tchisla est donc qu’on s’intéresse
maintenant non plus au nombre de symboles c mais au nombre total de symboles
10 CHAPITRE 1. INTRODUCTION
de l’expression arithmétique. Il n’y a aucune raison que les solutions optimales pour
Tchisla2 le soit aussi pour Tchisla. Par exemple,
Attention ! L’exhaustivité porte sur l’espace des solutions, sur ce qu’il faut trouver et
donc, en général 12 , sur les sorties. Pas sur les entrées ! Ce qu’on recherche exhaustive-
ment, c’est la solution. Un algorithme balayant l’entrée sera lui plutôt qualifié de simple
parcours ou d’algorithme à balayage.
Par exemple, si le problème est de trouver trois indices i, j, k d’éléments d’un tableau
T tels que T [i] + T [j] = T [k], alors la recherche exhaustive ne consiste pas à parcourir
tous les éléments de T (=l’entrée) mais à lister tous les triplets (i, j, k) (=les solutions)
et à vérifier si T [i] + T [j] = T [k]. [Exercice. Trouver pour ce problème un algorithme
en O(n3 ), puis en O(n2 log n) en utilisant une recherche dichotomique dans un tableau
de paires triées. En utilisant un seul tableau auxiliaire de 2M booléens, proposez un
algorithme de complexité O(n2 + M) si les éléments sont des entiers naturels 6 M. Fi-
nalement, proposez une autre approche menant à un algorithme de complexité O(n2 ),
indépendant de M.]
Parenthèse. En toute généralité, la structure qui représente une solution et qui permet de
vérifier si c’est une solution s’appelle un certificat positif. Dans l’exemple du tableau ci-
dessus, le certificat est un triplet d’indices (i, j, k) vérifiant T [i]+T [j] = T [k]. Pour Tchisla2
12. Mais pas toujours ! comme les problèmes de décisions, dont la sortie est vrai ou faux. Par exemple,
savoir si un graphe possède un chemin hamiltonien, problème discuté au paragraphe 1.5.3. La sortie est
booléenne alors que les solutions possibles sont plutôt des chemins du graphe.
1.3. APPROCHE EXHAUSTIVE 11
c’est une expression sur un alphabet de dix lettres. La méthode exhaustive se résume alors à
lister tous les certificats positifs possibles. Généralement on impose qu’il puisse être vérifié en
temps raisonnable, typiquement en temps polynomial en la taille des entrées, ce qui impose
aussi que leurs tailles sont polynomiales. Les cours de Master reviendront sur ces notions.
À noter que le problème discuté ci-dessus est une variante 13 du problème bien connu
sous le nom de 3SUM pour lequel on conjecture qu’il n’existe pas de complexité O(n2−ε ),
quelle que soit la constante ε > 0. En 2018, un algorithme de complexité 14 entre n2−ε et n2
a été trouvé par Chan [Cha18]. Un algorithme efficace pour 3SUM peut servir par exemple
à détecter s’il existe trois points alignés (s’il existe une droite passant par au moins trois
points) dans un ensemble de n du plan.
Pour être sûr de ne rater aucune expression, on peut lister tous les mots d’une lon-
gueur donnée et vérifier si le mot formé est une expression valide. C’est plus simple que
de générer directement les expressions valides. En quelques sortes on fait une première
recherche exhaustive des expressions valides parmi les mots d’une longueur donnée,
puis parmi ces expressions on en fait une deuxième pour révéler celles qui s’évaluent à
n (cf. le schéma de la figure 1.4).
Ainsi l’expression (c+c)/c sera codé par le tableau T[] = {8,0,1,0,9,4,0}. Chaque
expression se trouve ainsi numérotée. Bien sûr certains tableaux ne représentent aucune
expression valide, comme {8,0,1,0,9,4,1} censé représenter (c+c)/+.
13. À l’origine, il faut trouver un triplet d’indices vérifiant T [i] + T [j] + T [k] = 0.
14. La complexité exacte est de (n2 / log2 n) · log logO(1) n.
12 CHAPITRE 1. INTRODUCTION
Il est a priori inutile de programmer un tel algorithme (quoique ?), car pour n = 9, le
nombre d’opérations est déjà de l’ordre de 1018 , et ce même en oubliant le terme O(n).
Comme on le verra page 69, dès que le nombre d’opérations élémentaires dépasse 109 ×
109 il faut compter 30 ans de calcul sur un processeur 1 GHz... Certes, un ordinateur
plus puissant 17 (cadence plus élevée et multi-cœurs) pourrait sans doute venir à bout
du cas n = 9 en un temps raisonnable, plus rapidement que 30 ans de calcul. Mais si
on passe à n = 10, on multiplie par 100 le temps puisque 102(n+1) = 100 · 102n . Notez
16. Le calcul de 10k peut-être majorer sans formule en remarquant que cette somme représente un
P
nombre s’écrivant avec 2n + 3 « 1 » et terminé par un « 0 ». C’est le nombre 102n+3 + · · · + 1 000 + 100 + 10 =
111 · · · 1 110. Elle est donc < 102n+4 = 10 000 · 102n = O(1) · 102n .
17. En 2018, les smartphones les plus puissants (processeurs A12 d’Apple) affichaient une cadence de
2.4 GHz environ avec 5 000 millards d’opérations/secondes, ce qui ramène le temps de calcul à moins
de 3 jours.
14 CHAPITRE 1. INTRODUCTION
bien qu’en pratique, il n’y a pas de différence entre un programme de complexité trop
grande et un programme qui boucle (et donc incorrect).
Si l’on pense que pour n = 9, l’instance du problème n’est jamais que 2 chiffres (un
pour n et un pour c), la complexité exprimée en fonction de la taille de l’entrée est
vraiment mauvaise. Mais c’est toujours ça, car pour Tchisla on ne dispose d’aucun al-
gorithme !
Parenthèse. On pourrait se demander si notre recherche exhaustive n’est pas trop « exhaus-
tive », c’est-à-dire si on ne cherche pas la solution dans un ensemble démesurément trop
grand. Par exemple, on pourrait optimiser la fonction Next(T,k) en remarquant que le
symbole le plus à droite d’une expression valide ne peut être que c, ! ou ). Dit autrement,
3
T[0] (l’unité) ne peut être que 0, 7 ou 9, permettant de ne garder que 10 = 30% des va-
leurs. Le terme exponentiel dans la complexité en temps passe donc au mieux de 102n à 18
0.3 · 102n = 102n−0.52... , une accélération somme toute assez modeste, surtout que c’est au
prix d’une modification de Next() qui pourrait se trouver plus lente que la version d’ori-
gine.
Pour réduire cette recherche, on pourrait tenter de se passer des parenthèses, en utilisant
la notation Polonaise inversée : pour les opérateurs binaire, on note les opérandes avant
l’opérateur, comme factoriel dans le cas unaire. Par exemple : (c+c*c)^c devient ccc*+c^.
On gagne deux symboles : ( et ). Le terme exponentiel passe donc de 102n à 82n . Mais bon,
même avec 82n , pour n = 10 on dépasse déjà 1018 . Et ce n’est pas non plus exactement le
même problème puisque la sortie n’est plus vraiment une expression arithmétique standard.
Et rien ne dit qu’en rajoutant les parenthèses cela corresponde à la plus petite. Cependant
l’astuce pourrait être utilisée pour la version originale Tchisla puisqu’on ne se soucie que
du symbole c dont le nombre reste identique dans les deux cas.
En fait il est possible de ne générer que les expressions arithmétiques valides (la partie
grisée de la figure 1.4) au lieu d’utiliser un compteur. Pour cela il faut décrire les expres-
sions par une grammaire et utiliser des outils de génération automatique qui peuvent alors
produire en temps raisonnable chaque expression valide. Une sorte de fonction Next() amé-
liorée donc.
La description d’une grammaire ressemblerait à quelque chose comme ceci 19 :
λ → c | λc
o → +|-|*|/|^
√
E → λ | (A) o (B) | (A)! | (A)
A, B → E
Le problème est que, même si la fonction Next() ne générait que des expressions va-
lides, le nombre d’expressions resterait très grand. Pour le voir, considérons l’expression
(c+...+c)/c de valeur n. Elle possède 2n + 3 symboles dont n opérateurs : n − 1 additions
n k(n) e(n)
20 3 c/c
21 7 (c+c)/c
2i 12 + k(i) ((c+c)/c)^(e(i))
√
20. On peut remplacer par exemple c+c par + c ou +c!.
21. Voir page 45 pour la notion d’équivalence asymptotique.
16 CHAPITRE 1. INTRODUCTION
5 = 22 + 20 = ((c+c)/c)^(e(2))+c/c
= ((c+c)/c)^((c+c)/c)+c/c
Bon, c’est pas terrible car on a vu que k(5) 6 2 · 5 + 3 = 13. Mais cela donne une formule
de récurrence sur k(n) qui va se révéler intéressante quand n est assez grand.
Voici un programme récursif calculant k(n) en fonction des k(2i ) et donc des k(i)
avec i > 0. En C, pour tester si n possède le terme 2i dans sa décomposition binaire, il
suffit de faire un ET-bit-à-bit entre n et 2i . Ce programme ne sert strictement à rien pour
tchisla2(). Il peut servir en revanche à son analyse.
Parenthèse. En fait, une analyse plus fine de la récurrence, en fait de l’arbre des d’appels de
la fonction k(n) (cf. page 51), découlant de la décomposition en log2 n puissances de deux
permet de montrer que
∗
log
Yn
k(n) 6 O log(i) n = o log n · log2 (log n)
i=1
où log∗ n et log(i) n sont des fonctions abordées page 159. En pratique log∗ n 6 5 pour toute
valeur de n aussi grande que le nombre de particules dans l’Univers.
On pourrait se demander si d’autres décompositions ne mèneraient pas à des expressions
plus courtes encore, et donc à un meilleur majorant pour k(n). On pourrait ainsi penser aux
décompositions en carrées. Plutôt que de décomposer n en une somme de log n termes 2e(i)
= ((c+c)/c)^(e(i)), on pourrait décomposer en somme de e(i)2 = (e(j))^((c+c)/c).
Le théorème de Lagrange (1770) affirme que tout entier naturel est la somme d’au plus
quatre carrés. (C’est même trois carrés sauf si n est de la forme 4a · (8b − 7).) On tombe alors
sur une récurrence du type : √
k(n) 6 4 · k( n ) + O(1)
18 CHAPITRE 1. INTRODUCTION
√
car chacune des quatre valeurs qui est mise au carré est bien évidemment au plus n. La
solution est alors k(n) = O(4log log n ) car en déroulant i fois l’équation on obtient k(n) 6
i
4i · k(n1/2 ) + O(4i ). En posant i = log log n, il vient k(n) = O(log2 n). C’est donc moins bien
que pour les puissances de deux, ce qui n’est pas si surprenant.
En fait, il a été démontré [GRS14, Théorème 1.6] que la plus courte expression de va-
leur n, en notation polonaise inversée (donc les parenthèses ne comptent pas) et utilisant
seulement les symboles 1 + * ^, était de taille au plus 6 log2 n. Ceci est démontré en consi-
Q α
dérant la décomposition de n = i pi i en facteurs premiers pi , plus exactement en écrivant
n = i (1 + (pi − 1))αi , puis en décomposant ainsi récursivement les αi et les pi − 1. Plus
Q
récemment il a été montré dans [CEH+ 19][page 11] que le plus petit nombre f (n) de 1 dans
une expression de valeur n utilisant seulement les symboles 1 + * ( ) (donc sans ^) véri-
fiait f (n) 6 6 + 2.5 log2 n. Après l’ajout des f (n) − 1 opérateurs binaires, on en déduit une
expression en polonaise inversée de longueur 2f (n) − 1 6 11 + 5 log2 n, soit un peu mieux
que dans [GRS14]. En ajoutant les parenthèses (soit 4 caractères de plus par opérateur)
et en remplaçant le chiffre 1 par c/c (soit 2 caractères de plus par chiffre) on obtient une
expression valide de valeur n et de taille au plus 2f (n) − 1 + 4(f (n) − 1) + 2f (n) = 8f (n) − 5
démontrant que
k(n) 6 8f (n) − 5 6 20 log2 n + 43 .
10k(n) 6 1020 log2 n+43 = 1043 · n20 log2 (10) ≈ 1043 · n66 .
On a progressé, mais pas tellement car pour n = 10 cela donne 1043+66 = 10109−18 · 1018
soit 10 avec 91 zéros fois 30 ans... La question sur l’efficacité de tchisla2() reste entière.
Historiquement, la fonction f (n) a été introduite dans [MP53]. Parmi les nombreux
résultats sur f (n), [Ste14] a établit que f (n) 6 3.6523 log3 n ≈ 2.31 log2 n pour des « entiers
génériques » et même conjecturé que cette borne était vraie pour presque tous les entiers n.
Un des derniers résultats en date est un algorithme pour calculer f (n). Il a une complexité
en O(n1.223 ) [CEH+ 19].
• La complexité.
• Les notations asymptotiques.
Les notations telles que O, Ω, Θ sont de simples écritures mathématiques très utili-
sées en analyse qui servent à simplifier les grandeurs asymptotiques, comme les com-
plexités justement mais pas que. Elles n’ont a priori strictement rien à voir avec la com-
plexité, et d’ailleurs ont peut faire de la complexité sans ces notations. Par exemple, la
1.4. RAPPELS SUR LA COMPLEXITÉ 19
Comme la taille 22 est très souvent notée par un entier n ∈ N, la complexité est la plu-
part du temps une fonction de n positive (et bien souvent croissante mais pas toujours).
En général on s’intéresse à mesurer une certaine ressource consommée par l’algorithme
lorsqu’il s’exécute, comme le nombre d’instructions, l’espace mémoire, le nombre de
comparaisons, etc. L’idée est de classer les algorithmes par rapport à cette complexité.
Il y a donc plusieurs complexités. Les plus utilisées sont quand même la complexité en
temps et en espace.
Le terme temps peut être trompeur. On ne parle évidemment pas ici de seconde ou de
minute, une complexité n’a pas d’unité. C’est un nombre... de quelques choses. On parle
de complexité en temps (et pas de complexité d’instructions) car on admet que chaque
opération élémentaire s’exécute en temps unitaire, si bien que le temps d’exécution est
effectivement donné par le nombre d’opérations élémentaires exécutées 23 .
des indices de S pourront être stockés entièrement sur un mot mémoire, ce qui est bien
pratique. Dans ce cas la taille des mots est au moins de log2 n bits. [Question. Pour-
quoi ?] Voir le paragraphe 1.6.
La taille d’une entrée (comme ici la chaîne binaire S) est exprimée en nombre de
bits ou en nombre de mots (comme par exemple un tableau de n entiers de [0, n[). Pour
résumer, une entrée de taille n doit pouvoir être stocker sur un espace mémoire de taille
n, et donc comporter au plus n mots mémoires.
Les opérations élémentaires sont les opérations de lecture/écriture et de calcul
simple sur les mots mémoires, parmi lesquelles les opérations arithmétiques sur les
entiers de 24 [0, n[. On considère aussi comme opération élémentaire l’accès à un mot
mémoire dont l’adresse est stockée dans un autre mot mémoire. Dans notre exemple,
S[i] pourra être accédé (lu ou écrit) en temps unitaire. On parle de modèle RAM (Ran-
dom Access Memory).
En gros, on décompose et on compte les opérations que la machine peut faire en
temps unitaire (langage machine), et c’est tout.
En passant, si dans Syracuse(n) on remplace 3*n+1 par a*n+b, alors il est indéci-
dable de savoir si, étant donnés les paramètres (a,b), la fonction ainsi généralisée ter-
mine toujours. Encore une fois, pour certaines valeurs comme (a=2,b=0), (a=3,b=-1) ou
(a=1,b=-1) on sait répondre 28 . Mais aucun algorithme n’arrivera jamais à donner la ré-
ponse pour tout (a,b). On est en quelque sorte condamné à produire une solution ou
27. https://fr.wikipedia.org/wiki/Conjecture_de_Syracuse
28. Cela boucle trivialement pour (a=2,b=0) et n=2, et plus généralement pour n=2 et tout (a=i +
1,b=2j ) pour tout i, j ∈ N. Pour (a=3,b=-1) et n=7 la suite devient 7, 20, 10, 5, 14, 7, ... ce qui boucle donc
aussi. Pour (a=1,b=-1) la suite ne fait que décroître, donc la fonction s’arrête toujours.
22 CHAPITRE 1. INTRODUCTION
« Tout nombre entier pair supérieur à trois est la somme de deux nombres pre-
miers. ».
Dans la pratique, on n’aura pas à traiter des cas si complexes, en tout cas cette année.
Cependant, compter exactement le nombre d’opérations élémentaires ou de mots mé-
moires est souvent difficile en pratique même pour des algorithmes/codes relativement
simples, comme tchisla2() ou la fonction f() définie page 30.
La première difficulté qui s’oppose au comptage exact est qu’on ne sait pas toujours
quelles sont les opérations élémentaires qui se cachent derrière le langage, qu’il soit
compilé (comme le C) ou interprété (comme le Python). Le compilateur peut cacher cer-
taines opérations/optimisations, et l’interpréteur peut réaliser des tas d’opérations sans
le dire (gestion de la mémoire 30 par exemple) ! Ensuite, le nombre d’opérations peut
varier non seulement avec la taille de l’entrée, mais aussi avec l’entrée elle-même. Si
l’on cherche un élément pair dans un tableau de taille n, le nombre d’opérations sera
très probablement dépendant des valeurs du tableau. Certains langages aussi proposent
de nombreuses instructions qui sont non élémentaires, comme certaines opérations de
listes en Python.
Essayons de calculer la complexité dans l’exemple suivant :
int T[n];
for(i=0;i<n;i++)
T[i++] = 2*i-1;
29. En juin 2018 une « preuve » non confirmée a été encore annoncée [Sch18].
30. On peut considérer que malloc() prend un temps constant, mais que calloc() et realloc()
prennent un temps proportionnel à la taille mémoire demandée.
1.4. RAPPELS SUR LA COMPLEXITÉ 23
Parenthèse. Il n’est pas vraiment pas conseillé d’utiliser une instruction comme
T[i++] = 2*i-1; La raison est qu’il n’est pas clair si la seconde occurrence de i (à droite
du =) à la même valeur que la première. Sur certains compilateurs, comme gcc on aura
T[0]=-1 car l’incrémentation de i à lieu après la fin de l’instruction (définie par le ; fi-
nal). Pour d’autres 31 , on aura T[0]=1. Ce qui est sûr est que l’option de compilation -Wall
de gcc produit un warning 32 . Mais est-ce bien sûr qu’on n’écrit pas en dehors des indices
[0, n[ ?
En effet, quand n est petit, de toutes façons, peu importe l’algorithme, cela ira vite.
Ce qui compte c’est lorsque les données ont des tailles importantes. C’est surtout là qu’il
faut réfléchir à l’algorithme, car tous ne se valent pas. Différents algorithmes résolvant
le même problème sont alors comparés selon les valeurs asymptotiques de leur com-
plexité. Ce n’est évidemment qu’un critère. Un autre critère, plus pratique, est celui de
la facilité de l’implémenter correctement. Mais c’est une autre histoire.
31. Comme celui en ligne https://www.tutorialspoint.com/compile_c_online.php
32. Message qui est : warning: unsequenced modification and access to ’i’
33. On calcule peut-être aussi l’adresse de T+i sauf si le compilateur s’aperçoit que T est une adresse
constante. Dans ce cas il saura gérer un pointeur p = T qu’il incrémentera au fur et à mesure avec p++.
24 CHAPITRE 1. INTRODUCTION
Car en toute logique la réponse devrait être : « cela dépend de n ». Si n < 18, alors
n2 − 7n < 10n + 5. Sinon c’est le contraire. Le comportement de l’algorithme autour de
n = 18 n’est finalement pas ce qui nous intéresse. C’est pour cela qu’on ne retient que le
comportement asymptotique. Lorsque n devient grand,
10n + 5
−−−−−−→ 0 .
n2 − 7n n→+∞
Cela revient à dire qu’asymptotiquement et à une constante multiplicative près f (n) est
« au plus » g(n).
c · g(n)
f (n)
n0 n
Il est très important de se souvenir que f (n) = O(g(n)) est une notation dont le but
est de simplifier les énoncés. C’est juste pour éviter d’avoir à dire « lorsque n est suf-
fisamment grand » et « à une constante multiplicative près ». Les notations servent à
peu près à rien 35 lorsqu’il s’agit de démontrer des formules précises. La définition
(∃n0 ∈ N, ∃c > 0, ∀n > n0 , ...) doit rester la priorité lorsqu’il s’agit de manipuler des
asymptotiques.
De manière générale, lorsqu’on écrit f (n) = h(O(g(n)) pour une certaine fonction h,
c’est une façon d’écrire un asymptotique sur la composition h−1 ◦ f , puisque h−1 (f (n)) =
O(g(n)). Pour l’exemple précédant, si f (n) = 1/O(n), alors 1/f (n) = O(n) ce qui signifie
que 1/f (n) 6 cn et donc que f (n) > 1/(cn) pour n assez grand et une certaine constante
c > 0.
Il y a quelques pièges ou maladresses à éviter avec les notations asymptotiques.
• Il faut éviter d’utiliser la notation asymptotique les deux cotés d’une égalité, dans
le membre de droite et le membre de gauche, comme par exemple O(f (n)) = Ω(n).
Car il y a alors confusion entre le « = » de l’équation et le « = » de la notation
asymptotique, même si on s’autorise à écrire dans une chaîne de calcul : f (n) =
O(n) = O(n2 ). D’ailleurs dans certains ouvrages, surtout francophiles, on lit parfois
f (n) ∈ O(g(n)). Si cela évite ce problème, cela n’évite pas les autres.
Pour illustrer un des derniers pièges de la notations grand-O, montrons tout d’abord
la propriété suivante :
Propriété 1.1 Si a(n) = O(1) et b(n) = O(1), alors a(n) + b(n) = O(1).
En effet, a(n) = O(1) implique qu’il existe ca et na tels que ∀n > na , a(n) 6 ca . De
même, b(n) = O(1) implique qu’il existe cb et nb tels que ∀n > nb , b(n) 6 cb . Donc si
a(n) = O(1) et b(n) = O(1), alors ∀n > max {na , nb }, a(n)+b(n) 6 ca +ca . On a donc montrer
qu’il existe ns et cs tels que ∀n > ns , a(n) + b(n) 6 cs . Il suffit pour cela de prendre
ns = max {na , nb } et cs = ca + cb . Donc a(n) + b(n) = O(1).
Considérons maintenant f (n) la fonction définie par le programme suivant :
1.5. NOTATIONS ASYMPTOTIQUES 27
En appliquant la propriété 1.1 précédente, montrons par récurrence que f (n) = O(1).
Ça paraît clair : (1) lorsque n < 2, alors f (n) < 2 = O(1) ; (2) si la propriété est vraie
jusqu’à n−1, alors on en déduit que a = f (n−1) = O(1) et b = f (n−2) = O(1) en appliquant
l’hypothèse. On en déduit donc que a + b = f (n) = O(1) d’après la propriété 1.1.
Visiblement, il y a un problème, car f (n) est le n-ième nombre de Fibonacci et donc
j √ m
f (n) = Φ / 5 ≈ 1.61n (cf. (2.1)) n’est certainement pas bornée par une constante. En
n
fait, le même problème survient déjà avec un exemple plus simple encore : montrer par
récurrence que la fonction f définie par f (0) = 0 et f (n) = f (n−1)+1 vérifie f (n) = O(1).
[Question. D’où vient l’erreur ? De la propriété 1.1 ? du point (1) ? du point (2) ?]
Parenthèse. Revenons sur la complexité des algorithmes de tri. Les algorithmes de tri par
comparaisons nécessitent Ω(n log n) comparaisons (dans le pire des cas). Ils ont donc une
complexité en temps en Ω(n log n).
En effet, le problème du tri d’un tableau non trié T à n éléments revient à déterminer
la 36 permutation σ des indices de T de sorte que T [σ (1)] < · · · < T [σ (n)]. L’ensemble des
permutations possibles est N = n!. Trouver l’unique permutation avec des choix binaires
– les comparaisons – ne peut être plus rapide que celui de la recherche binaire dans un
ensemble de taille N . Ce n’est donc rien d’autre que la hauteur 37 minimum d’un arbre
binaire à N feuilles. Comme un arbre binaire de hauteur h contient au plus 2h feuilles, il
est clair qu’on doit avoir 2h > N , soit h > log2 N . Sous peine de ne pas pouvoir trouver
la bonne permutation dans tous les cas, le nombre de comparaisons est donc au moins (cf.
l’équation(1.3)) :
log2 N = log2 (n!) = n log2 n − O(n) .
Comme on l’a dit précédemment, il est possible d’aller plus vite en faisant des calculs sur
les éléments plutôt que d’utiliser de simples comparaisons binaires. Il faut alors supposer
que de tels calculs sur les éléments soient possibles en temps constant. Généralement, on
fait la simple hypothèse que les éléments sont des clefs binaires qui tiennent chacune dans
un mot mémoire. Trier n entiers pris dans l’ensemble {1, . . . , n4 } entre dans cette catégorie.
[Question. Pourquoi ?] Les opérations typiquement autorisées sur ces clefs binaires sont les
additions et les opérations logiques entre mots binaires (∨, ∧, ¬, etc.), les décalages binaires
et parfois même la multiplication.
Le meilleur algorithme de cette catégorie, l’algorithme de Han [Han04], prend un temps
de O(n log log n) pour un espace en O(n). Un précédant algorithme probabiliste, celui de
Thorup [Tho97][Tho02] conçu sept ans plus tôt et qui n’utilise pas de multiplication, don-
nait les mêmes performances mais seulement en moyenne. Cela veut dire que algorithme trie
correctement dans tous les cas mais en temps moyen O(n log log n), cette moyenne dépen-
dant des choix aléatoires de l’algorithme, pas de l’entrée. Les deux mêmes auteurs [HT02]
ont ensuite
p produit un algorithme également probabiliste avec un temps et espace moyen en
O(n log log n ) et O(n) respectivement. C’est la meilleure complexité connue pour le tri de
clefs binaires. On ne sait toujours pas s’il est possible ou non de trier en temps linéaire.
graphe. Au mieux on aimerait construire ce chemin plutôt que de savoir seulement qu’il
existe. Alors, à quoi peut bien servir ce type de « problème d’école » ?
On pratique on s’intéresse plutôt, par exemple, au score maximum qu’on peut faire
dans un jeu de plates-formes 38 (cf. figure 1.7) ou à un problème d’optimisation du gain
que l’on peut obtenir avec certaines contraintes 39 . N’oublions pas que le jeu n’est jamais
qu’une simulation simplifiée de problèmes bien réels, comme les problèmes d’optimi-
sation en logistique (cf. figure 1.8).
Figure 1.7 – Dans un jeu vidéo de type Super Mario Brothers il s’agit sou-
vent de choisir le bon chemin pour maximiser son score ou minimiser son
temps de parcours. (Source [DVW16].)
Un autre exemple est le nombre maximum de cartes de Uno que l’on peut poser à
la suite 40 dans une poignée ... Ces problèmes reviennent à trouver le plus long chemin
possible dans un graphe. Chaque sommet est l’une des positions possibles, et chaque
arête représente un mouvement possible entre deux positions.
38. Il existe des travaux théorique très sérieux et récent sur le jeu Super Mario Brothers
comme [DVW16].
39. Les records du monde pour Super Mario Brothers consistent à minimiser le temps ou le nombre
de frames, voir les vidéos How is this speedrun possible? Super Mario Bros. World Record Explained et
The History of Super Mario Bros Warpless World Records.
40. On rappelle qu’au Uno on peut poser des cartes à la suite si elles sont de la même couleur ou de
numéro consécutif. Les cartes que l’on peut jouer à la suite définissent alors les adjacences d’un graphe.
30 CHAPITRE 1. INTRODUCTION
Il est alors vain de chercher un algorithme efficace général (ou une IA) pour ces
problèmes, car si on en trouvait un alors on pourrait déterminer si un graphe possède
ou pas un chemin hamiltonien simplement en répondant à la question : peut-on faire
un score de n ? 41
Cela nous indique qu’il faut raffiner ou reformuler le problème, s’intéresser non pas
au problème général du chemin le plus long, mais de trouver par exemple le chemin le
plus long dans des graphes particuliers (comme des grilles) ou alors une approximation
du plus long chemin.
Bien souvent, il arrive que le problème réel qu’on s’est posé contienne comme
cas particulier un problème d’école. L’intérêt des problèmes de décisions qui sont
réputés difficiles est alors de nous alarmer sur le fait qu’on est probablement parti
dans une mauvaise voie pour espérer trouver un algorithme efficace.
Il faut alors envisager de modifier le problème en changeant les objectifs (la ques-
tion posée) ou en s’intéressant à les instances (entrées) particulières, c’est-à-dire moins
générales.
Peu importe ce que calcule précisément f(n). Le fait est que ce genre de boucles
41. Pour Uno, ce n’est pas aussi immédiat car le graphe d’une poignée de Uno n’est pas absolument
quelconque comme il le pourrait pour un jeu de plates-formes. Il s’agit cependant du line-graph d’un
graphe cubique. Or le problème reste NP-complet même pour ces graphes là (cf. [DDU+ 10]).
42. Le niveau en math exigé dans ce cours est celui de la terminale ou presque.
43. L’instruction n /= ++p signifie qu’on incrémente p avant d’effectuer n = n/p (division entière).
1.6. ALGORITHME ET LOGARITHME 31
Trois « log » pour une boucle while contenant une seule instruction et les deux opé-
rateurs, +1 et /. Il ne s’agit pas de retenir ce résultat, dont le sketch de preuve hors
programme se trouve en notes de bas de page 44 , mais d’être conscient que la fonction
logarithmique est plus souvent présente qu’il n’y paraît à partir du moment où l’on
s’intéresse aux comportement des algorithmes.
En fait, en mathématique on a souvent à faire à la fonction ln x (ou sa fonction réci-
proque exp(x) = ex ), alors qu’en algorithmique c’est le logarithme en base deux qui nous
intéresse surtout.
Définition 1.1 Le logarithme en base b > 1 d’un nombre n > 0, noté logb n, est la
puissance à laquelle il faut élever la base b pour obtenir n. Autrement dit n = blogb n .
Par exemple, le logarithme de mille en base dix est 3, car 1 000 = 103 , et donc
log10 (1 000) = 3. Plus généralement, et on va voir que cela n’est pas un hasard, le lo-
garithme en base dix d’une puissance de dix est le nombre de zéros dans son écriture
décimale. Bien évidemment 10log10 n fait... n. C’est la définition.
La figure 1.9 montre l’allure générale des fonctions logarithmiques.
Il découle immédiatement de la définition 1.1 :
44. Il n’est pas très difficile de voir que l’entier p, après l’exécution du while, est le plus petit entier
tel que p! > n. Il suit de cette remarque que n > (p − 1)!. En utilisant le fait que ln(p!) = p ln p − Θ(p)
(cf. l’équation (1.3)), on en déduit que ln n ∼ p ln p. Il suit que ln ln n ∼ ln p + Θ(ln ln p) et donc que p ∼
ln n/ ln p ∼ ln n/ ln ln n.
45. On dit aussi « fonction inverse » même si c’est potentiellement ambigu.
32 CHAPITRE 1. INTRODUCTION
√
n
log2 n
ln n
√
Figure 1.9 – Courbes des fonctions ln n, log2 n, et n. Bien que toutes crois-
santes, les√fonctions logarithmiques
√ ont un taux de croissance bien plus
faible que n : 1/n vs. 1/ 2n.
Sauf mention contraire, on supposera toujours que b > 1 (cf. la définition 1.1), car
l’équation n = bx n’a évidemment pas en général de solution pour x lorsque b = 1. Et
donc log1 n n’est pas défini.
while(n>1) n /= b;
n’est pas répétée k = logb n fois avant de sortir avec n 6 1. La raison est que cette
boucle n’effectue pas l’opération n 7→ n/bk , mais plutôt
n 7→ b. . . bbn/bc /bc · · · /bc .
| {z }
k fois
Évidemment, quand n est une puissance entière de b, c’est bien la même chose. On peut
montrer 46 que, malgré les nombreuses parties entières, le résultat n’est pas très loin de
n/bk . C’est en fait égal à 1 près.
Pour démontrer la proposition 1.1, on va se servir de la propriété qui est elle aussi
très importante à connaître car elle revient (très) souvent : la somme des n + 1 premiers
termes d’une suite géométrique (qi )i∈N de raison 47 q , 1 :
n
2 n
X qn+1 − 1
1 + q + q + ··· + q = qi = . (1.4)
q−1
i=0
bk − 1 = (b − 1) · (1 + b + b2 + · · · + bk−1 )
= b − 1 bk−1 + b − 1 bk−2 + · · · + b − 1 b2 + b − 1 b + b − 1
Cette dernière somme comprend k termes de la forme (b − 1) · bi qui représentent pré-
cisément les k chiffres de l’entier bk − 1 écrit en base b. Chacun de ces chiffres vaut
d’ailleurs b − 1 ce qui montre que bk − 1 est le plus grand nombre qui s’écrit en base b
avec k chiffres. Il suit que bk s’écrit avec k + 1 chiffres.
Soit k l’entier tel que bk−1 < n 6 bk . On vient de voir que bk − 1 s’écrit avec k chiffres.
Donc n − 1 6 bk − 1 s’écrit avec au plus k chiffres. Mais on a vu aussi que bk s’écrit
avec k + 1 chiffres. Donc n − 1 > bk−1 s’écrit avec au moins (k − 1) + 1 = k chiffres.
Il suit que n − 1 s’écrit avec exactement k chiffres. Par la croissance du logarithme,
bk−1 < n 6 bk ⇔ k − 1 < logb n 6 k, ce qui revient à dire que k = logb n . Cela termine la
preuve de la proposition 1.1. 2
Notons que la proposition 1.1 est encore une autre façon de se convaincre que la
fonction logb n croît très lentement. En effet, log10 (100) = 2 et log10 (1 000 000 000) = 9
seulement.
La fonction logarithme possède d’autres propriétés importantes découlant de la dé-
finition 1.1, comme celles-ci :
48. C’est lié au fait que [(qn+1 − 1)/(q − 1)] + qn+1 = (qn+1 − 1 + q · qn+1 − qn+1 )/(q − 1) = (qn+2 − 1)/(q − 1).
49. Un exemple qui ne démontre rien : 103 − 1 = 999 s’écrit sur 3 chiffres décimaux.
1.6. ALGORITHME ET LOGARITHME 35
Proposition 1.2
• logb n = (loga n)/(loga b) pour toute base a > 1.
• logb (x · y) = logb x + logb y pour tout x, y > 0.
• logb (nα ) = α logb n pour tout α > 0.
• logb n nα pour toute constante α > 0.
On a donc à la fois bloga n/ loga b = n et n = blogb n , c’est donc que loga n/ loga b = logb n.
Le premier point a pour conséquence que lorsque b est une constante devant n (c’est-
à-dire b = O(1)), les fonctions logb n, log2 n, log10 n ou même comme on va le voir ln n,
sont toutes équivalentes à une constante multiplicative près. On a par exemple log2 n =
log10 n/ log10 2 ≈ 3.32 log10 n et ln n = log2 n/ log2 e ≈ 0.69 log2 n.
De la même manière, on n’écrit jamais quelque chose du type O(2n+1) ou O(3 log n),
l’objectif de la notation asymptotique étant de simplifier les expressions. La remarque
s’applique aussi aux notations Ω et Θ.
Pour le deuxième point. D’une part x·y = blogb (x·y) par définition de logb (x · y). D’autre
part
blogb x+logb y = blogb x · blogb y = x · y = blogb (x·y)
et donc logb x + logb y = logb (x · y). C’est la propriété fondamentale des fonctions loga-
rithmes de transformer les produits en sommes.
Pour le quatrième point. On note « f (n) g(n) » pour dire que que f (n)/g(n) → 0
lorsque n → +∞. C’est pour dire que f (n) est significativement plus petite que g(n). En
math, on le note aussi f (n) = o(g(n)). En utilisant la croissance de la fonction logarithme
et le changement de variable N = logb logb n :
logb n nα ⇔
logb logb n logb (nα ) = α logb n ⇔
logb logb logb n logb (α logb n) ⇔
logb logb logb n logb α + logb logb n ⇔
logb N N + logb α
Comme b et α sont des constantes, logb α = O(1) est aussi une constante (éventuellement
négative si α < 1). Lorsque n devient grand, N devient grand aussi. Clairement logb N
N − O(1), la « longueur » de N (cf. la proposition 1.1) étant significativement plus petite
que N quand√N est grand. D’où logb n nα . Notez que ce point implique par exemple
que log2 n n (α = 0.5).
1.6.2 Et la fonction ln n ?
Une base b particulière procure à la fonction logarithme quelques propriétés remar-
quables. Il s’agit de la base e, la constante due à Euler :
1 1 1 1 X1
e = 1+ + + + ··· = = 2.718 281 828 459...
1 1×2 1×2×3 1×2×3×4 i!
i>0
On a aussi
1 n
1+ −−−−−−−→ e .
n n→+∞
50. Car naturellement plus simple à approximer au 16e siècle que les autres fonctions logarithmes.
51. En hommage à l’écossais John Napier, prononcé Neper, = 1617.
1.6. ALGORITHME ET LOGARITHME 37
ce qui s’interprète comme la surface sous la courbe 1/x pour x ∈ [1, n]. Voir la figure 1.10
ci-après.
Une des propriétés intéressantes est que la somme des inverses des n premiers en-
tiers, notée Hn et appelée série Harmonique, est presque égale à ln n (ce qui se comprend
aisément d’après la figure 1.10) :
n
1 1 1 X 1
Hn = 1 + + + ... + = = ln n + O(1) .
2 3 n i
i=1
On remarque que les opérations + et * ont leurs réciproques * et /. Mais que se passe-
t’il si l’on ajoute l’opération inverse de l’exponentielle (^) ? L’opération réciproque de bx
est logb x. Pour simplifier et éviter d’ajouter un opérateur binaire, ajoutons l’opérateur
unaire ln, soit la fonction x 7→ ln x, qui suffit puisqu’on a vu que logb x = ln x/ ln b. On a
alors 53 :
Proposition√1.3 Tout entier n ∈ N peut être représenté par une expression utilisant les sym-
boles {c, +, /, , ln, (, )} et au plus 7 occurrences de c qui est un chiffre parmi {1, . . . , 9}.
52. On connaît très peu de chose sur cette constante. On ne sait pas par exemple si c’est un nombre
rationnel ou pas. D’ailleurs montrer qu’un réel n’est pas rationnel peut être très compliqué. Il est par
exemple encore ouvert de savoir si π + e ou π/e est rationnel ou pas, cf. Wolfram.
53. Voir aussi la vidéo The Four 4s - Numberphile.
38 CHAPITRE 1. INTRODUCTION
Il y a seulement 3 fois le chiffre 2 et bien évidemment, cela reste vrai pour n’importe
quel n.
Considérons maintenant le cas général avec l’expression
√√√ √
ln(ln( ... (c+c))/ln(c+c))/ln(c/(c+c)) (1.5)
| {z }
n fois
n
répété n fois a pour valeur (2c)(1/2) . L’expression (1.5) peut donc se réécrire en
n!
ln (2c)(1/2)
ln ln (2c)
ln (2c) ln (1/2)n · ln (2c) ln (1/2)
= = n· = n.
ln (1/2) ln (1/2) ln (1/2)
2
1.7 Morale
• En informatique les problèmes sont définis par la relation entre les entrées (on
parle aussi d’instances) et les sorties (décrites généralement sous la forme d’une
question).
• Les algorithmes résolvent des problèmes, tandis que les programmes en donnent
une implémentation. Une instance particulière peut être résolue sans qu’un al-
gorithme existe pour le problème. Par exemple, le problème de la Halte n’a pas
d’algorithme. Pourtant, on connaît la réponse pour beaucoup de ses instances.
• Quand on programme en C un algorithme qui résout un problème, les en-
trées et sorties du problème (en fait leurs types) sont partiellement cap-
turés par le prototype d’une fonction qui implémente l’algorithme, comme
double pow(double,double).
• Pour certains problèmes on peut essayer de trouver une formule close liant les
paramètres d’entrées aux sorties, comme par exemple la formule liant les coeffi-
cients a, b, c d’un polynôme de degré deux à ses deux racines. On peut aussi tenter
la recherche exhaustive (ou brute-force), une technique qui consiste à essayer tous
les résultats possibles.
• Mais pour la plupart des problèmes intéressants il n’existe pas de telles formules.
Et pour certains on ne peut envisager non plus de recherche exhaustive, car, par
exemple, l’ensemble de tous les résultats envisageables n’est pas de taille bornée
(par une fonction de la taille de l’entrée 54 ). Pour certains problèmes il n’existe
54. Par exemple, le problème de savoir si l’on peut dessiner un graphe sur le plan sans croisement
40 CHAPITRE 1. INTRODUCTION
carrément pas d’algorithmes de résolution. Et ce n’est pas parce qu’on ne les a pas
trouvés. C’est parce qu’on peut démontrer qu’il n’existe pas de tels algorithmes.
Inutile alors d’essayer de trouver une fonction C, un programme ou une IA les
résolvant. Ces problèmes là sont indécidables, comme par exemple le problème
de la Halte.
• Les problèmes de décisions très simples ne servent pas forcément à résoudre des
problèmes pratiques (les problèmes pratiques sont souvent bien plus complexes).
Ils servent en revanche à montrer que le problème réel qui nous intéresse est
bien trop difficile, car il contient un problème d’école (de décision) réputé difficile
comme cas particulier.
• La complexité est une mesure qui sert à comparer les algorithmes entres eux. Elle
permet d’écarter rapidement un algorithme qui serait, quelle que soit son implé-
mentation, une perte de temps car de complexité (en temps ou en espace) trop
importante. Ainsi, un programme qui serait amené à exécuter 1018 opérations
élémentaires sur processeur 1 GHz n’a aucun intérêt, car il prendrait plus de 30
ans. En pratique, un programme de complexité en temps trop importante aura le
même comportement qu’un programme qui boucle (et donc erroné).
• L’analyse de complexité, aussi précise soit-elle, ne change en rien l’efficacité d’un
programme. Cette analyse, si elle est fine, sert à comprendre où et comment
est utilisée la ressource mesurée (temps ou espace). Il y des programmes qui
« marchent » sans que l’on sache pourquoi, faute de savoir les analyser.
• La complexité s’exprime toujours en fonction de la taille des entrées (générale-
ment n). C’est un nombre qui n’a pas d’unité. Il s’agit d’un nombre d’opérations
élémentaires (=temps) ou de mots mémoires (=espace). Certaines instructions de
certains langages, comme printf() ou memcpy() de la librairie standard C, ne sont
pas élémentaires.
• Il n’y a pas de notation dédiée à la complexité en temps ou en espace. Les no-
tations O, Ω, Θ n’ont pas de rapport direct avec la complexité. Ce sont des nota-
tions pour alléger les expressions mathématiques portant sur des valeurs asymp-
totiques. Elles évitent d’écrire ∃n0 ∈ N, ∀c > 0, ∀n > n0 , ...
• Il est difficile de calculer la complexité de manière exacte. On utilise plutôt des
ordres de grandeurs et on l’évalue lorsque la taille n est « suffisamment grande »
(n → +∞). On utilise alors souvent les notations asymptotiques pour simplifier
l’écriture, notamment O, Ω, Θ. Ces notations sont parfois sources de pièges qu’il
est bon de connaître.
• Le logarithme en base b de n (=logb n) est une fonction à connaître, surtout lorsque
b = 2, car elle est omniprésente en algorithmique. Et ce n’est pas le président du
Conseil Scientifique de la SIF 55 2014-2020 qui me contredira ! (voir l’affiche ci-
d’arête ne se prête pas a priori à une recherche exhaustive car le nombre de dessins possibles n’est pas de
taille bornée : R2 c’est grand ! même pour un graphe à n sommets. Il n’empêche, il existe des algorithmes
linéaires pour le résoudre.
55. Société informatique de France.
BIBLIOGRAPHIE 41
après et la vidéo).
Bibliographie
[CEH+ 19] K. Cordwell, A. Epstein, A. Hemmady, S. J. Miller, E. Palsson, A. Sharma,
S. Steinerberger, and Y. N. Truong Vu, On algorithms to calculate ingeter
complexity, Integers, 19 (2019), pp. 1–13.
[Cha18] T. M. Chan, More logarithmic-factor speedups for 3SUM, (median,+)-
convolution, and some geometric 3SUM-hard problems, in 29th Symposium
on Discrete Algorithms (SODA), ACM-SIAM, 2018, pp. 881–897. doi :
10.1137/1.9781611975031.57.
[DDU+ 10] E. D. Demaine, M. L. Demaine, R. Uehara, T. Uno, and Y. Uno, UNO is hard,
even for a single player, in 5th International Conference Fun with Algorithms
(FUN), vol. 6099 of Lecture Notes in Computer Science, Springer, June 2010,
pp. 133–144. doi : 10.1007/978-3-642-13122-6_15.
[DVW16] E. D. Demaine, G. Viglietta, and A. Williams, Super mario bros. is harder/ea-
sier than we thought, in 8th International Conference Fun with Algorithms
(FUN), vol. 49 of LIPcs, June 2016, pp. 13 :1–13–14. doi : 10.4230/LI-
PIcs.FUN.2016.13.
[GRS14] E. K. Gnang, M. Radziwiłł, and C. Sanna, Counting arithmetic formu-
las, European Journal of Combinatorics, 47 (2014), pp. 40–53. doi :
10.1016/j.ejc.2015.01.007.
42 BIBLIOGRAPHIE
[Han04] Y. Han, Deterministic sorting in O(n log log n) time and linear space, Journal of
Algorithms, 50 (2004), pp. 96–10. doi : 10.1016/j.jalgor.2003.09.001. Also
appears in STOC ’02.
p
[HT02] Y. Han and M. Thorup, Integer sorting in O(n log log n ) expected time and
linear space, in 43rd Annual IEEE Symposium on Foundations of Computer
Science (FOCS), IEEE Computer Society Press, November 2002, pp. 135–
144. doi : 10.1109/SFCS.2002.1181890.
[MP53] K. Mahler and J. Popken, On a maximum problem in arithmetic (dutch),
Nieuw Archief voor Wiskunde, 3 (1953), pp. 1–15.
[San15] C. Sanna, On the number of arithmetic formulas, International
Journal of Number Theory, 11 (2015), pp. 1099–1106. doi :
10.1142/S1793042115500591.
[Sch18] P. Schorer, A solution to the 3x + 1 problem, June 2018.
[Ste14] S. Steinerberger, A short note on integer complexity, Contributions to Dis-
crete Mathematics, 9 (2014), pp. 63–69. doi : 10.11575/cdm.v9i1.62145.
[Tan15] I. J. Taneja, Single digit representations of natural numbers, RGMIA Research
Report Collection, 18 (2015), pp. 1–55.
[Tho97] M. Thorup, Randomized sorting in O(n log log n) time and linear space using
addition, shift, and bit-wise boolean operations, in 8th Symposium on Discrete
Algorithms (SODA), ACM-SIAM, January 1997, pp. 352–359.
[Tho02] M. Thorup, Randomized sorting in O(n log log n) time and linear space using
addition, shift, and bit-wise boolean operations, Journal of Algorithms, 42
(2002), pp. 205–230. doi : 10.1006/jagm.2002.1211.
2
CHAPITRE
Partition d’un entier
Sommaire
2.1 Le problème . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
2.2 Formule asymptotique . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
2.3 Approche exhaustive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
2.4 Récurrence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
2.5 Programmation dynamique . . . . . . . . . . . . . . . . . . . . . . . . . 54
2.6 Mémorisation paresseuse . . . . . . . . . . . . . . . . . . . . . . . . . . 57
2.7 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
2.1 Le problème
On s’intéresse à toutes les façons de partitionner un ensemble d’éléments indistin-
guables. Par exemple, il y a cinq façons de partager un ensemble de 4 billes :
• : 1 paquet de 4 billes ;
• : 1 paquet de 3 billes et 1 paquet d’1 bille ;
• : 2 paquets de 2 billes ;
44 CHAPITRE 2. PARTITION D’UN ENTIER
On représente plutôt les partitions d’un ensemble de n éléments comme les diffé-
rentes façons d’écrire l’entier n (la cardinalité de l’ensemble) comme somme d’entiers
non nuls (la cardinalité des parts). On parle de Partition d’un entier.
Pour notre exemple précédent, les cinq partages possibles d’un ensemble de 4 billes
reviennent à écrire :
4 = 4
= 3+1
= 2+2
= 2+1+1
= 1+1+1+1
n 1 2 3 4 5 6 7 8 9 10 ... 100
p(n) 1 2 3 5 7 11 15 22 30 42 ... 190 569 292
n 1 2 3 4 5 6 7 8 9 10 ...
F(n) 1 1 2 3 5 8 13 21 34 55 ...
2.2. FORMULE ASYMPTOTIQUE 45
possède une formule close : la formule de Binet (1834). Il est bien connu que :
√
Φ n − (1 − Φ)n
$ n'
Φ 1+ 5
F(n) = √ = √ avec Φ = ≈ 1.6180 (2.1)
5 5 2
ce qui n’est malheureusement pas une formule close, à cause des sommes et produits
infinis.
P+∞ De plus il faut choisir correctement x. Et puis c’est pas vraiment la somme infinie
n=0 p(n) qui nous intéresse...
Il n’y a pas de formule close connue pour p(n). Donc, contrairement à F(n), l’espoir
de pouvoir calculer p(n) à l’aide d’un nombre constant d’opérations arithmétiques est
relativement faible. Il existe seulement des formules asymptotiques.
Hardy et Ramanujan [HR18] ont donné en 1918 l’asymptotique suivant :
1 √ √
p(n) ∼ √ · exp π 2n/3 ≈ 23.7 n .
4n 3
On dit que « f (n) est asymptotiquement équivalent à g(n) », et on note f (n) ∼ g(n),
si
f (n)
lim = 1
n→+∞ g(n)
Parenthèse. L’erreur n’est que de 1.4% pour n = 1 000, les trois premiers chiffres de p(n)
étant correctes à quelques unités près. Bien sûr, par définition de l’asymptotique, cette er-
reur diminue plus n augmente. Siegel a donné une autre formule asymptotique, publiée par
√
1. En fait, (1 − Φ)n / 5 vaut approximativement +0.44, −0.27, +0.17, −0.10, ... pour n = 0, 1, 2, 3, ... ce qui
tend assez rapidement vers 0.
46 CHAPITRE 2. PARTITION D’UN ENTIER
Knopp [Kno81], plus complexe mais qui converge plus efficacement vers la vraie valeur que
celle de Hardy-Ramanujan :
√ ! √
2 3 6 π
p(n) ∼ · 1− √ · exp 24n − 1
24n − 1 π 24n − 1 6
Intuitivement
√ on retrouve
√ la formule de Hardy-Ramanujan car le terme dans l’exponentielle
π
6 24n − 1 tends vers π 2n/3 quand n augmente. De même le coefficient du premier terme
√ √ √ √ √
en 1/n tends vers 2 3/24 = 2 3 · 3/(4 · 6 · 3) = 1/(4 3).
Lorsque f (n) ∼ g(n) cela ne signifie pas que l’écart absolu entre f (n) et g(n) est de
plus en plus petit quand n devient grand et donc que la courbe de g(n) devient une
asymptote de celle de f (n), mais que l’écart relatif tend vers 0. L’écart absolu est la
quantité |f (n) − g(n)| alors que l’écart relative est (f (n) − g(n))/g(n) = f (n)/g(n) − 1.
Une fonction peut avoir plusieurs asymptotiques. Par exemple n2 + n + 1 ∼ n2 + n,
mais on a aussi n2 + n + 1 ∼ n2 . On pourrait dire ici que le premier asymptotique n2 + n
converge plus efficacement vers n2 + n + 1 que le deuxième en n2 . En effet, en comparant
les écarts absolus on a
Notez qu’ici on compare des écarts qui sont des fonctions de n. En toute généralité, il
n’y a pas de raison, comme dans cet exemple, d’en avoir un qui est toujours mieux que
l’autre. Il pourrait se passer qu’un est meilleur jusqu’à un n0 et qu’ensuite cela s’inverse,
voir que cela oscile.
L’idée de la notion d’asymptotique est de ne retenir que le terme principal, le
plus grand lorsque n → +∞, afin de simplifier l’expression. On peut montrer que si
f (n) ∼ g(n) alors f (n) = Θ(g(n)). Le contraire est faux, si l’on considère par exemples les
fonctions n2 et 2n2 .
Une notion que l’on va croiser, et qui est reliée à celle d’asymptotique, est celle-ci.
On dit que qui énonce que « f (n) est en petit-o de g(n) », et on le note f (n) = o(g(n)),
si
f (n)
lim = 0
n→+∞ g(n)
√
En combinant l’asymptotique p(n) ∼ a · nb ec n de la formule d’Hardy-Ramanujan, avec les
√ √ Θ(
√
n )
constances a = 1/(4 3), b = −1, et c = π 4/3, on déduit que p(n) ∼ 2 .
2 + 2 +1+1
- | - | |
0 1 0 1 1
Lister tous les découpages possibles pour un n donné revient donc à énumérer tous
les mots binaires de n−1 bits, ce qu’on peut facilement réaliser grâce à l’algorithme d’in-
crémentation vu page 12. Chaque mot binaire doit cependant être testé afin de détermi-
ner s’il représente une nouvelle partition. En effet, plusieurs découpages peuvent cor-
respondent à la même partition. Par exemple, 01011 (= 2+2+1+1) et 11010 (= 1+1+2+2)
représentent la même partition pour n = 6.
On peut extraire les parts correspondant d’un découpage codé par un mot binaire B
en découpant le mot B1 (B suivi d’un dernier 1 fictif) au niveau de ses 1, ce qui revient
48 CHAPITRE 2. PARTITION D’UN ENTIER
aussi à compter dans B1 le nombre (+1) de 0 précédant chaque 1. Par exemple, pour
n = 10 et B1 = 001, 01, 0001, 1 ce qui représente donc les parts 3, 2, 4, 1, soit la partition
10 = 4 + 3 + 2 + 1. Trier les parts par ordre décroissant revient à considérer le plus grand
code d’une partition. Cela donne un moyen assez simple de détecter si le code d’une
partition est le plus grand (en triant ses parts !) et de le comptabiliser le cas échéant.
Peu importe les détails de l’implémentation et la complexité exacte de cette ap-
proche : elle est clairement inefficace. En effet, on va examiner 2n−1 découpages, ce qui
à partir de n = 61 dépassera la limite fatidique des 1018 opérations élémentaires
√
(> 30
ans de calculs). Rappelons aussi qu’il n’y a qu’asymptotiquement 2 Θ( n ) partitions, ce
qui est considérablement moins puisque
√ √n √ √
n n n
2 = 2 = 2n .
On a vu page 44 que, par exemple, p(100) ≈ 190 · 106 . On est donc très loin des 1018
qu’on atteindrait pour n = 61. On doit pouvoir faire beaucoup mieux !
2.4 Récurrence
Une manière graphique de représenter une partition de n est d’utiliser une sorte de
tableau où l’on entasse, à partir du coin inférieur gauche, n petits carrés en colonnes de
hauteur décroissante. On appelle un tel tableau un diagramme de Ferrers.
Par exemple, sur la figure 2.1 la partition 12 = 5 + 3 + 2 + 1 + 1 peut être représentée
par le diagramme (a) et l’autre partition 12 = 3 + 3 + 2 + 2 + 2 par le diagramme (b).
(a) (b)
$
5 3 2 1 1 3 3 2 2 2
Figure 2.1 – Deux partitions de 12, chacune de 5 parts, représentées par des
diagrammes de Ferrers. La partition (a) est de type 1, (b) de type 2. Il existe
p(12, 5) = 13 partitions de 12 en 5 parts, et p(12) = 77.
Parmi les 5 partitions de n = 4, on a déjà vu qu’il n’y en a exactement deux avec deux
parts : 4 = 2 + 2 = 3 + 1. D’où p(4, 2) = 2. On peut vérifier qu’il y a 13 diagrammes de
Ferrers avec 12 carrés et 5 colonnes, d’où p(12, 5) = 13.
Parenthèse. Rajouter des paramètres afin de trouver une récurrence peut paraître surprenant
de prime abord, car cela tend à contraindre et donc compliquer le problème. Mais c’est une
50 CHAPITRE 2. PARTITION D’UN ENTIER
stratégie générale bien connue en Mathématique : on peut espérer trouver une preuve par
récurrences en enrichissant l’induction de propriétés supplémentaires. En Informatique, il
devient trivial d’écrire une fonction récursive pour le tri fusion d’un tableau T lorsqu’on in-
troduit deux indices supplémentaires : merge_sort(T,i,j) qui trie une partie du tableau,
T[i..j[ (cf. le code page 138). Plus fondamentalement, trouver une récurrence pour ré-
soudre un problème n’est possible que si le problème est très « structuré ». Il devient alors
moins surprenant d’ajouter des contraintes qui vont augmenter la structure du problème.
On peut classifier les partitions de n en k parts, qu’on nommera diagrammes (n, k), en
deux types : celles dont la plus petite part est 1 (type 1), et celles dont la plus petite part
est au moins 2 (type 2). Le diagramme (a) est de type 1, et (b) de type 2. Évidemment,
ces catégories sont disjointes : un diagramme est soit de type 1 soit de type 2. On peut
donc compter séparément les diagrammes de chaque type et faire la somme :
C’est une technique classique pour compter des objets : on les décompose en plus petit
morceaux et/ou on les classifie en catégories plus simples à compter ou à décomposer.
Supposons (par récurrence !) qu’on a réussi à construire tous les diagrammes « plus
petits » que (n, k), c’est à dire tous les diagrammes (n0 , k 0 ) avec n0 6 n et k 0 6 k. Et bien
sûr (n0 , k 0 ) , (n, k), un des deux paramètres doit être strictement plus petit.
Construire tous les diagrammes de type 1, à partir des diagrammes plus petits, est
facile car on peut toujours les couper juste avant la dernière colonne. On obtient alors
un diagramme (n − 1, k − 1) avec un carré et une colonne de moins. Inversement, si à un
diagramme (n − 1, k − 1) on ajoute une colonne de hauteur un, on obtient un diagramme
(n, k) de type 1. Par conséquent, il y a autant de diagrammes (n, k) de type 1 que de
diagrammes (n − 1, k − 1). Dit autrement, p1 (n, k) = p(n − 1, k − 1).
On peut construire les diagrammes de type 2 à l’aide de diagrammes plus petits en
les coupant juste au dessus de la première ligne. On obtient alors un diagramme avec k
carrés de moins mais encore k colonnes puisque toutes les colonnes étaient initialement
de hauteur au moins deux. On obtient donc un diagramme (n − k, k). L’inverse est aussi
vrai : à partir d’un diagramme (n−k, k) on peut construire un diagramme (n, k) de type 2
en le surélevant d’une ligne de k carrés. Par conséquent, il y a autant de diagrammes
(n, k) de type 2 que de diagrammes (n − k, k). Dit autrement, p2 (n, k) = p(n − k, k).
En sommant les diagrammes (n, k) de type 1 et de type 2, on a donc montrer la
relation de récurrence :
valable pour tous les entiers tels que 1 < k < n (à cause de « k − 1 » et de « n − k » dans
le terme de droite qui doivent être > 0). Si k = 1 ou k = n, alors p(n, k) = 1 [Question.
Pourquoi ?], et bien sûr p(n, k) = 0 si k > n [Question. Pourquoi ?]. On aura
Pn pas à gérer
d’autres cas pour les paramètres (n, k) pour le calcul de la somme p(n) = k=1 p(n, k).
2.4. RÉCURRENCE 51
Parenthèse. Le programme termine bien car, même si chacun des paramètres ne diminuent
pas toujours strictement, la somme des paramètres elle, diminue strictement. De manière
générale, pour montrer qu’un programme récursif termine bien, il suffit d’exhiber une fonc-
tion de potentielle dépendant des paramètres d’appels, qui est bornée inférieurement et qui
décroit strictement au cours des appels. On parle de bel ordre.
Arbre des appels. C’est un outil permettant de représenter l’exécution d’une fonction
et qui est très pratique pour calculer sa complexité, notamment quand la fonction est
récursive. Cela permet aussi de repérer les calculs inutiles et donc d’améliorer éventuel-
lement l’algorithme.
L’arbre des appels d’une fonction est un arbre dont les nœuds représentent les pa-
ramètres d’appels et les fils les différents appels (éventuellement récursifs et/ou
composés 2 ) lancés par la fonction. L’exécution de la fonction correspond à un par-
cours en profondeur de l’arbre depuis sa racine qui représente les paramètres du
premier appel.
Voici un exemple (cf. figure 2.2) de l’arbre des appels pour p(6,3). Par rapport à la
définition ci-dessus, on s’est permit d’ajouter aux nœuds l’opération (ici +) ainsi que les
valeurs terminales aux feuilles (ici 0 ou 1), c’est-à-dire les valeurs renvoyées lorsqu’il n’y
2. Un appel composé est un appel correspondant à la composition de fonctions, comme dans l’expres-
sion f(g(n)) ou encore f(f(n/2)*f(n/3)) (dans ce dernier cas c’est un appel composé et récursif avec
trois fils). Il peut arriver que l’arbre des appels ne puisse pas être construit à l’avance, mais seulement
lors de l’exécution lorsque les paramètres sont fixés.
52 CHAPITRE 2. PARTITION D’UN ENTIER
a plus d’appels récursifs. Les valeurs terminales ne font pas partie des nœuds de l’arbre
des appels.
Les valeurs terminales permettent, à l’aide de l’opération attachés aux nœuds, de
calculer progressivement à partir des feuilles la valeur de retour de chaque nœuds et
donc de la valeur finale à la racine. L’évaluation de p(6,3) produit en fait un parcours
de l’arbre des appels. Lorsque qu’un nœud interne a été complètement évalué, sa valeur
(de retour) est renvoyée à son parent qui poursuit le calcul. In fine la racine renvoie la
valeur finale au programme appelant.
(6, 3)
+
(5, 2) (3, 3)
+
1
(4, 1) (3, 2)
+
1
(2, 1) (1, 2)
1 0
Figure 2.2 – Arbre des appels pour p(6,3). Il comporte 7 nœuds dont 4
feuilles, chacune ayant une valeur terminale (0 ou 1 en gris) qui ne font pas
partie de l’arbre des appels. Le nombre de valeurs terminales à 1 est bien sûr
de 3=p(6,3).
L’arbre des appels pour partition(6) est composé d’une racine avec (6) connectée
aux fils (6, 1), (6, 2), . . . , (6, 6) étant eux-mêmes racines d’arbres d’appels (cf. figure 2.3).
6
+
Figure 2.3 – Arbre des appels pour partition(6). Pour obtenir l’arbre com-
plet il faudrait développer les quatre sous-arbres comme sur la figure 2.2.
d’appels à la fonction p(n,k). Ce nombre est aussi le nombre de nœuds dans l’arbre des
appels de partition(n), qui vaut un plus la somme des nœuds des arbres pour p(n,1),
p(n,2), ..., p(n,n).
Le nombre d’appels exacts n’est pas facile à calculer, mais cela n’est pas grave car
c’est la complexité qui nous intéresse. Donc une valeur asymptotique ou a une constante
multiplicative près fera très bien l’affaire. Intuitivement ce nombre d’appels n’est pas
loin de p(n, k), puisque c’est ce que renvoie la fonction en calculant des sommes de
valeurs qui sont uniquement 0 ou 1, les deux cas terminaux de p(n,k). En fait p(n, k) est
précisément le nombre de feuilles de valeur 1.
L’arbre étant binaire 3 , le nombre de nœuds recherchés est 2 fois le nombre de
feuilles moins un 4 . Or chaque feuille renvoie 0 ou 1. De plus il y a jamais de feuilles
sœurs renvoyant toutes les deux 0. C’est du au fait qu’une feuille gauche ne peut jamais
renvoyer 0. En effet, il faudrait avoir n − 1 < k − 1 (fils gauche (n − 1, k − 1) renvoyant 0)
ce qui ne peut arriver car son père (n, k) aurait été une feuille ! (car si n − 1 < k − 1 c’est
que n < k). Donc le nombre de feuilles est au plus deux fois le nombre de valeurs 1. Le
nombre de feuille est aussi au moins le nombre de valeurs 1. Il est donc compris entre
p(n, k) et 2p(n, k). Ainsi, le nombre de nœuds de l’arbre des appels est entre 2p(n, k) − 1
et 4p(n, k) − 2, soit Θ(p(n, k)).
En utilisant la formule asymptotique sur p(n), on déduit que la complexité en temps
de partition(n) est donc proportionnelle à
n
X √
n)
Θ(p(n, k)) = Θ(p(n)) = 2Θ( .
k=1
Calculs inutiles. En fait, on passe son temps à calculer des termes déjà calculés. Pour
le voir, il faut repérer des appels (ou nœuds) identiques dans l’arbre des appels. Cepen-
dant, dans l’arbre des appels de p(6,3) il n’y a aucune répétition !
Pour voir qu’il y a quand même des calculs inutiles, il faut prendre un exemple
plus grand que précédemment. En fait, avec un peu de recul, il est clair qu’il doit y
avoir des appels identiques pour partition(n) et même pour p(n,k). La raison est que
le nombre de nœuds différents n’est jamais que n2 , car il s’agit de couples (n0 , k 0 ) avec
n0 , k 0 ∈ {1, . . . , n}. Or on a vu que l’arbre possédait Θ(p(n)) nœuds ce qui est asymptotique-
ment bien plus grand que n2 . Donc les mêmes nœuds apparaissent plusieurs √
fois, né-
2 )−log 2
cessairement. Il y a même un nœud qui doit apparaître Ω(p(n)/n ) = Ω(2 Θ( n 2 (n ) ) =
√
2Θ( n ) fois !
La figure 2.4 montre qu’à partir de n’importe quel nœud (n, k) on aboutit à la répé-
tition du nœud (n − 2k, k − 2), à condition toutefois que n − 2k et k − 2 soient > 0. (Ce qui
n’était pas le cas pour (6, 3).) Évidemment ce motif se répète à chaque nœud si bien que
le nombre de calculs dupliqués devient rapidement très important.
(n, k)
(n − 1, k − 1) (n − k, k)
(n − k, k − 1) (n − k − 1, k − 1)
(n − 2k + 1, k − 1) (n − k − 2, k − 2)
(n − 2k, k − 2) (n − 2k, k − 2)
Figure 2.4 – Arbre d’appels pour le calcul de p(n,k). En notant G/D les
branches gauche/droite issues d’un nœud, on remarque que les branches
GDDG et DGGD, si elles existent, mènent toujours aux mêmes appels.
p(n, k) k total
n 1 2 3 4 5 6 7 8 p(n)
1 1 1
2 1 1 2
3 1 1 1 3
4 1 2 1 1 5
5 1 2 2 1 1 7
6 1 3 3 2 1 1 11
7 1 3 4 3 2 1 1 15
8 1 4 5 5 3 2 1 1 22
Table 2.1 – Le calcul de la ligne p(n, ·) se fait à partir des lignes précédentes.
Ici p(8, 3) = p(7, 2) + p(5, 3), et en bleu toutes les valeurs utilisées dans son
calcul.
Parenthèse. Le code ci-dessus utilise la déclaration int P[n+1][n+1] sans allocation mé-
moire (malloc()). Mais quelle est la différence entre
• int A[n]; et
• int *B=malloc(n*sizeof(int)) ;
qui dans les deux cas déclarent un tableau de n entiers ? Certes dans les deux cas, les décla-
rations sont locales à la fonction qui les déclarent. Mais les différences sont :
• A[] est stocké sur la pile, comme toutes les autres variables locales. Cette zone mé-
moire est allouée dynamiquement à l’entrée de la fonction (à l’aide d’une simple ma-
nipulation du pointeur de pile). Puis elle est libérée à la sortie de la fonction (avec la
manipulation inverse de la pile). Il n’y a pas de free(A) à faire, il ne faut surtout pas
le faire d’ailleurs.
• B[] est stocké sur le tas, une zone de mémoire permanente, différente de la pile, où sont
stockées aussi les variables globales. Elle n’est pas automatiquement libérée à la sortie
de la fonction. Cependant les valeurs stockées dans B[] sont préservées à la sortie de la
fonction. Il faut explicitement faire un free(B) si ont veut libérer cette zone mémoire.
Dans les deux cas, même après libération et sortie de la fonction, les valeurs stockées dans
les tableaux ne sont pas spécialement effacées. Mais la zone mémoire (de la pile ou du tas)
est libre d’être réallouée par l’exécution du programme, et donc perdu pour l’utilisateur.
À première vue, la déclaration de A[] peut paraître plus simple pour un tableau local
puisque aucune libération explicite avec free(A) n’est nécessaire. Elle est aussi plus efficace
qu’un malloc() qui fait généralement fait appel au système d’exploitation, le gestionnaire
de mémoire. Cependant, la pile est une zone mémoire beaucoup plus limitée que le tas (typi-
quement 64 Ko vs. 4 Go voir beaucoup plus). Si n est trop grand, on arrive vite au fameux
stack overflow.
Plus rapide encore. Il existe d’autres formules de récurrence donnant des calculs en-
core plus performants. Par exemple,
p(n) = (p(n − 1) + p(n − 2)) −
(p(n − 5) + p(n − 7)) +
(p(n − 12) + p(n − 15)) −
(p(n − 22) + p(n − 26)) +
···
C’est le degré maximum de l’arbre des appels. [Exercice. Quelle serait la complexité de
la fonction récursive résultant de cette formule ? En utilisant la programmation dyna-
mique et donc une table, quelle serait alors sa complexité ?]
[Exercice. Considérons la fonction k(n) décrite page 8. Montrez qu’il y a des calculs
inutiles. Proposez une solution de programmation dynamique.]
Dans une fonction récursive, on peut toujours éviter les calculs redondant en utili-
sant de la mémoire supplémentaire. C’est le principe de la programmation dynamique
à l’aide d’une table auxiliaire comme vu précédemment. Mais ce principe impose de
parcourir judicieusement la table. Il faut donc réfléchir un peu plus, et le programme
résultant est souvent assez différent de la fonction originale. (Pour s’en convaincre com-
parer la fonction p(n) récursive page 51 et partition(n) itérative page 54.) Modifier
abondamment un code qui marche est évidemment une source non négligeable d’er-
reurs.
Dans cette partie on va donc envisager de faire de la programmation dynamique
mais sans trop réfléchir à comment remplir la table.
Ainsi, en laissant la fonction gérer ses appels dans l’ordre d’origine, on modifiera au
minimum le code d’origine tout en espérant un gain en temps.
L’idée est donc de modifier le moins possible la fonction d’origine en utilisant une
mémorisation avec le moins d’efforts possibles. Si l’arbre des appels est « suffisamment »
redondant (de nombreux appels sont identiques), alors cette méthode de mémorisation
« paresseuse » aboutira à un gain en temps certain. On appelle parfois cette technique
la mémoïsation d’une fonction.
Attention ! Pour que cette méthode fonctionne il est important que la valeur de la
fonction ne dépende que des paramètres de l’appel. Il ne doit pas y avoir d’effets de
bords via une variable extérieure (globale) à la fonction par exemple. Généralement,
on ne peut pas appliquer la technique de mémoïsation à une fonction déjà mémoïsée
[Question. Pourquoi ?]
58 CHAPITRE 2. PARTITION D’UN ENTIER
Exemple avec une simple table (1D). Pour commencer, voici une illustration de ce
principe pour le calcul des nombres de Fibonacci 5 . La version d’origine est fibo(). Pour
construire la version avec mémoïsation, fibo_mem(), on utilise une table F[] qui restera
dans la mémoire static à travers les différents appels récursifs. Avant le calcul récursif,
on teste simplement si la valeur souhaitée est déjà dans la table F[] ou non.
Parenthèse. Dans cette implémentation on a utilisé une variable locale static long F[]
qui est allouée et initialisée à -1 dans la mémoire statique (et donc pas sur la pile) au mo-
ment de la compilation. Ce tableau n’est accessible que localement par la fonction qui l’a
déclarée mais le contenu est préservé entre les différents appels comme une variable glo-
bale. On parle parfois de variable locale globale. Dans cet exemple, on aurait très bien pu
déclarer F[] en dehors de fibo_mem() comme variable globale.
La différence de code entre les deux fonctions est minime alors que l’amélioration
de la complexité est exponentielle ! En déclarant F[] en dehors du corps de la fonction,
le code de fibo_mem() se trouve alors presque identique à celui de fibo(). D’ailleurs
certains langages comme Python permettent de faire automatiquement cette transfor-
mation. C’est le principe de décoration disponible à partir de la version 3.2.
@lru_cache(maxsize=None)
def fibo(n):
if n<2: return n
return fibo(n-1)+fibo(n-2)
La complexité de fibo(n) est 2Θ(n) . En effet l’arbre des appels a : (1) moins de nœ-
uds que l’arbre des appels de la fonction avec un appel récursif légèrement modifié en
fibo(n-1) + fibo(n-1), soit un arbre binaire complet de hauteur n avec 2n nœuds ; et (2)
plus de nœuds que l’arbre des appels de la fonction avec un appel récursif légèrement
5. Le 100e nombre de Fibonacci tient sur pas moins de 70 bits, soit plus grand que ce que peut contenir
le type long (64 bits) ce qui explique la taille maximale pour F[] dans sa déclaration.
2.6. MÉMORISATION PARESSEUSE 59
modifié en fibo(n-2) + fibo(n-2), soit un arbre binaire complet de hauteur n/2 avec
2n/2 nœuds. Un autre argument montrant que le nombre de nœuds est exponentiel
en n est que l’arbre doit avoir 6 Θ(Φ n ) feuilles, puisque la fonction calcule F(n) avec
seulement des additions et les constantes positives entières < 2 (cas terminal), soit 0
et 1.
La complexité de fibo_mem(n) est cependant seulement de O(n). Le gain est donc
important. Pour le voir il faut construire l’arbre des appels pour constater qu’il ne com-
porte que 2n − 1 nœuds (cf. figure 2.5).
n
n−1 n−2
n−2 n−3
n−4
3
2 1
1 0
1 0
Figure 2.5 – Arbre des appels de fibo_mem(n) avec ses 2n − 1 nœuds, les par-
ties grisées ne faisant pas partie de l’arbre. L’exécution, comme pour fibo(n),
consiste à parcourir l’arbre selon un parcours en profondeur, sauf que les
sous-arbres grisés ne se développent pas. Ils correspondent à une simple lec-
ture dans la table F[].
Parenthèse.! On peut aussi faire en O(log n) avec une technique différente. On pose F~n =
F(n)
le vecteur composé des n-èmes et (n−1)-èmes nombres de Fibonacci. On remarque
F(n − 1)
alors que
! ! ! ! !
1 1 ~ 1 1 F(n) F(n) + F(n − 1) F(n + 1) ~n+1 .
· Fn = · = = = F
1 0 1 0 F(n − 1) F(n) F(n)
Et donc
! !n !n ! !
~n+1 = 1
F
1 ~
· Fn =
1 1
· F1 =
1 1
·
1
=
F(n + 1)
.
1 0 1 0 1 0 0 F(n)
!n !
1 1 a b
En posant = , on en déduit que
1 0 c d
! !n ! ! ! !
F(n + 1) 1 1 1 a b 1 a+b
= · = · =
F(n) 1 0 0 c d 0 c
√
6. On a vu dans l’équation (2.1) que F(n) ∼ Φ n / 5 ≈ 1.6n .
60 CHAPITRE 2. PARTITION D’UN ENTIER
Bien sûr, on sait tous comment calculer en temps O(n) les nombres de Fibonacci sans
table auxiliaire. Il suffit de faire une simple boucle du type
Mais le code est relativement différent de fibo(). Il est fortement basé sur le fait
qu’il suffit de mémoriser les deux valeurs précédentes pour calculer la prochaine. Le
fait que le code avec une boucle for() soit assez différent de l’original tend à montrer
que la transformation n’est peut être pas si générique que cela. On peut légitimement se
demander s’il est possible de faire de même pour toute fonction similaire, c’est-à-dire
un code équivalent sans table auxiliaire utilisant une boucle à la place d’appels récursifs
et pour toute fonction ayant disons un paramètre entier et deux appels récursifs ?
Autant la technique de mémorisation paresseuse, on va le voir, est assez générale,
autant la simplification par une simple boucle sans table auxiliaire n’est pas toujours
possible. Pour s’en convaincre considérons la fonction :
Peut-on transformer la fonction f() ci-dessus avec une simple boucle et sans table
auxiliaire ? [Exercice. Est-t-il bien sûr que cette fonction termine toujours ?] Comme le
suggère l’arbre des appels (cf. figure 2.6 à droite), le deuxième appel (fils droit) est
difficile à prévoir puisqu’il dépend de la valeur de l’appel gauche. De plus ils peuvent
être éloignés l’un de l’autre comme pour f(28) = f(27) + f(2) = 1124.
En fait, la fonction Ackermann que l’on rencontrera page 93 est un exemple bien
connu de fonction comportant deux appels récursifs et deux paramètres entiers qu’il
n’est pas possible de rendre itérative (sans table auxiliaire). La raison fondamentale à
ceci est qu’elle croît beaucoup plus rapidement que tout ce qu’il est possible de faire
avec une (ou plusieurs) boucle(s) et un nombre constant de variables (sans table donc).
2.6. MÉMORISATION PARESSEUSE 61
10 10
9 8 9 4
8 7 8 6
7 6 7 6
6 5 6 3
5 4 5 2
4 3 4 0
3 2 3 0
2 1 2 2 0
1 0 1 1
1 0 1
Figure 2.6 – Arbre des appels pour fibo(10)=55 (à gauche) et pour f(10)=38
(à droite). Pour fibo() on remarque que les appels qui se répètent sont à
distance bornée dans l’arbre (relation oncle-neveu), ce qui montre qu’un
nombre constant de variables dans une simple boucle suffit pour se passer
de la récurrence. En revanche, pour f(), la distance entre nœuds identiques
est variable et plus importante, comme pour f(10) = f(9) + f(4) = 24 + 14,
ce qui nécessite a priori un stockage bien plus important de variables (table)
ou bien la répétition de calculs (récursifs).
Avec une liste chaînée. L’exemple précédant, avec les nombres de Fibonacci, est plu-
tôt simpliste. Chaque appel ne comporte qu’un seul paramètre (ici un entier n), il n’y a
que deux appels par nœuds (l’arbre des appels est binaire), et on sait que la table a une
taille maximum définie à l’avance (ici 100).
Considérons un exemple générique plus complexe du calcul hypothétique d’une cer-
taine fonction récursive f() ayant plusieurs paramètres (pas forcément entiers), disons
deux pour fixer les idées, mais le principe s’applique dès qu’on a un nombre fixé de pa-
ramètres. Autrement, dit les nœuds dans l’arbre des appels sont des couples, comme sur
la figure 2.4. Supposons également que chaque nœuds interne comprend d fils, comme
dans l’exemple 7 for(i=s=0; i<d; i++) s += f(n-i,i);
On mémoïse la fonction f() en f_mem() en modifiant son code de la façon suivante (cf.
le code ci-après). À chaque fois qu’on fait un appel récursif, comme dans l’instruction
7. On verra au chapitre suivant un exemple ayant deux paramètres, dont l’un est un ensemble... et
avec un nombre de fils d non bornés.
62 CHAPITRE 2. PARTITION D’UN ENTIER
v=f(x,y), on cherche d’abord si le nœud (x,y) est déjà en mémoire, disons stocké dans
une liste L. Si oui, on renvoie dans v la valeur correspondant à ce nœud. Si non, on la
calcule comme initialement avec v=f(x,y), ajoute le nœud (x,y) et v à la liste L.
Soit T l’arbre des appels pour f(x,y). La complexité en temps de f(x,y) dépend du
nombre de nœuds de T . Pour en déduire la complexité en temps de f_mem(x,y), il faut
savoir combien de nœuds de T sont visités lors de l’appel à f_mem(x,y).
Comme on l’a déjà dit, le parcours de l’arbre lors des appels suit un parcours en
profondeur. L’effet de la mémorisation, dans f_mem(), est le suivant : si q est un nœuds
que l’on visite pour la première fois, alors tous ces fils sont visités (comme pour f()
donc). Mais si q a déjà été visité, alors tous ses nœuds descendant ne seront pas visités.
En quelque sorte, on parcoure T en supprimant les sous-arbres en dessous des nœuds
déjà visités.
On peut donc être amené à visiter plusieurs fois (et jusqu’à d fois) le même nœ-
ud, mais aucun de ses descendants si c’est la deuxième fois (ou plus). Par exemple, la
figure 2.5 montre que les nœuds de l’arbre pour fibo_mem(n) sont chacun visité deux
fois (sauf la feuille la plus en bas).
recherche ajout
liste (chaînée) O(k) O(1)
arbre (équilibré) O(log k) O(log k)
table (de hachage) O(1) O(1)
2.7 Morale
• La récursivité à l’aide de formules de récurrence permettent d’obtenir des pro-
grammes concis, rapide à développer et dont la validité est facile à vérifier.
• La complexité peut être catastrophique si l’arbre des appels contient des par-
ties communes. On passe alors son temps à recalculer des parties portant sur les
mêmes paramètres (c’est-à-dire les mêmes appels). C’est le cas lorsque la taille
de l’arbre (son nombre total de nœuds) est beaucoup plus grand que le nombre
d’appels différents
√
(le nombre de nœuds qui sont différents). Pour la partition de
n, il y a 2Θ( n) nœuds dans l’arbre, alors qu’il y a seulement n2 appels différents
possibles.
• La mémorisation permet d’éviter le calcul redondant des sous-arbres communs.
Plus généralement, la programmation dynamique utilise des récurrences à travers
une table globale indexée par les divers paramètres des appels.
• La programmation dynamique permet alors d’économiser du temps par rapport à
l’approche récursive naïve. L’inconvénient est l’usage de mémoire supplémentaire
(tables) qui, en cas de pénurie, peut être problématique. Car concrètement, en
cas de pénurie, il faut soit repenser l’algorithme soit modifier la machine en lui
ajoutant de la mémoire. Le manque de temps est peut être plus simple à gérer en
pratique puisqu’il suffit d’attendre.
• Une difficulté dans la programmation dynamique est qu’il faut souvent réfléchir
un peu plus, par rapport à la version récursive, quant au parcours de la table pour
être certain de remplir une case en fonction des cases déjà remplies. La difficulté
va apparaître au chapitre suivant au paragraphe 3.3. On peut y remédier grâce
à la mémorisation paresseuse, qui combine l’approche récursive et la mémorisa-
tion : on fait le calcul et les appels récursifs seulement si l’appel n’est pas déjà en
mémoire.
• Un exemple de programmation dynamique déjà vu, autre le calcul du nombre de
partitions d’un entier, est le calcul de plus courts chemins à partir d’un sommet
dans un graphe. Tester tous les chemins possibles et prendre le plus courts est
beaucoup trop couteux. Par exemple, entre deux sommets diagonaux d’une grille
(n + 1) × (n + 1), il existe 2n 2n−o(n) chemins de longueur 2n (cf. figure 2.7).
n ∼ 2
Ce nombre dépasse la limite fatidique des 1018 opérations élémentaires dès que
n = 32. À la place on utilise l’algorithme de Dijkstra qui mémorise dans un ta-
bleau la distance (D) entre la source et tous les sommets à distance 6 L (au début
L = 0 et D ne contient que la source). Les distances D[v] des sommets v situés à
64 BIBLIOGRAPHIE
(5, 5)
(0, 0)
Figure 2.7 – Un plus court chemin dans une grille carrée entre som-
mets diagonaux. Ce chemin de longueur 10 peut être codé par le mot
« ↑↑→↑→→↑→↑→ » contenant 5 pas « montant » (↑) et 5 pas « à droit » (→). Il
y a autant de pas « ↑ » que de pas « → » pour une grille carrée. Pour n = 5, cela
fait 10
5 = 252 chemins possibles. En effet, construire un chemin de longueur
2n entre les coins (0, 0) et (n, n) revient à choisir n « pas montant » parmi les
2n pas au total, ce qui donne 2n
n possibilités.
Bibliographie
[HR18] G. H. Hardy and S. A. Râmânujan, Asymptotic formulæ in combinatory analysis,
in Proceedings of the London Mathematical Society, vol. 17, 2, 1918, pp. 75–
115. doi : 10.1112/plms/s2-17.1.75.
[Kno81] M. I. Knopp, Analytic Number Theory – Proceedings of a Conference Held at
Temple University, Philadelphia, USA, May 12-15, 1980, vol. 899 of Lecture
Notes in Mathematics, Springer-Verlag, 1981. doi : 10.1007/BFb0096450.
3
CHAPITRE
Voyageur de commerce
Sommaire
3.1 Le problème . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
3.2 Recherche exhaustive . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
3.3 Programmation dynamique . . . . . . . . . . . . . . . . . . . . . . . . . 70
3.4 Approximation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
3.5 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
3.1 Le problème
Un robot doit ramasser un ensemble d’objets en un minimum de temps et revenir
au point de départ. L’ordre de ramassage n’a pas d’importance, seul le temps (ou la
distance parcouru) doit être optimisé.
Une autre instance du même problème est celui où un hélicoptère doit inspecter un
ensemble de plateformes offshore et revenir à son point de départ sur la côte. Il veut
parcourir les plateformes en utilisant le moins de carburant possible. Une autre formu-
66 CHAPITRE 3. VOYAGEUR DE COMMERCE
lation est que l’hélicoptère possède une quantité de carburant C et il veut savoir s’il va
pouvoir visiter toutes les plateformes avant de revenir.
La première formulation est un problème d’optimisation (la réponse est une valeur),
alors que la seconde (avec un budget maximum C donné) est un problème de décision
(la réponse est « oui » ou « non »).
Dans la littérature et historiquement 1 , on parle plutôt du problème du Voyageur de
commerce, TSP en Anglais pour Traveler Salesman Problem. Un commercial doit effec-
tuer une tournée comprenant n villes et il faut déterminer l’ordre de visite qui minimise
la longueur de la tournée (cf. la figure 3.1).
1. D’après William J. Cook [Coo11], c’est l’Irlandais Sir William Rowan Hamilton qui aurait introduit
le problème au 19e siècle.
3.1. LE PROBLÈME 67
Voyageur de commerce
Instance: Un ensemble V de points et une distance d sur V .
Question: Trouver une tournée de longueur minimum passant par tous les
points
Pn−1 de V , c’est-à-dire un ordre v0 , . . . , vn−1 des points de V tel que
i=0 d(vi , vi+1 mod n ) est minimum.
En fait, il existe plusieurs variantes du problème. Pour celle que l’on considèrera,
la plus classique, d est une distance. En particulier, c’est une fonction qui doit vérifier
inégalité triangulaire dont on rappelle la définition.
Une fonction d(·, ·) vérifie l’inégalité triangulaire si d(A, B) 6 d(A, C) + d(C, B) pour
tout triplet d’éléments A, B, C.
Cette inégalité tire son nom du fait que dans un triangle la longueur d’un coté est
toujours plus petite (ou égale) que la somme des deux autres (voir figure 3.3). La dis-
tance euclidienne 2 vérifie l’inégalité triangulaire. Le trajet Agen-Cognac par exemple
est plus court que le trajet Agen-Bordeaux-Cognac.
p
2. En dimension deux, la distance euclidienne entre les points (x, y) et (x0 , y 0 ) vaut (x0 − x)2 + (y 0 − y)2 ,
formule que l’on peut démontrer grâce au théorème de Pythagore. De manière générale, en utilisant les
multiples triangles rectangles liés aux projections sur chacune des dimensions,qon montre facilement que
Pδ
la distance euclidienne en dimension δ > 1 entre (x1 , . . . , xδ ) et (x10 , . . . , xδ0 ) vaut 0 2
i=1 (xi − xi ) .
68 CHAPITRE 3. VOYAGEUR DE COMMERCE
A
Figure 3.3 – Inégalité triangulaire entre Agen, Bordeaux et Cognac pour la
distance à pieds.
(1) Quelle est la sortie attendue d’un algorithme qui résoudrait le problème ?
(2) Comment faire pour savoir si la sortie est celle que l’on veut ?
3.2. RECHERCHE EXHAUSTIVE 69
Pour la question (1), c’est un ordre sur les n points que l’on cherche. Pour la question
(2), c’est l’ordre qui minimise la longueur de la tournée. Visiblement, on peut calculer
tout cela. On a donc un algorithme !
Notez bien qu’il n’y a pas de notation standard pour la complexité en temps d’un
algorithme.
Combien de temps cela prendra-t-il en pratique ? La formule de Stirling donne
l’asymptotique suivant : n √
n
n! ∼ 2πn .
e
En fait, pour tout n > 0, on a n! > (n/e)n . [Question. Peut-on le déduire de la formule
de Stirling ? La formule de Stirling permet-elle de déduire l’équation 1.3 ? ] Rappelons
que e = exp(1) = 2.718281828.... Pour n = 20, cela nous donne un temps approximatif
d’au moins n · n! > 20 · (20/2.72)20 = 1018.63... > 109 × 109 . C’est donc 30 ans (1 millard de
secondes) sur notre processeur 1 GHz.
Bien sûr, on peut raffiner cette complexité en argumentant qu’à cause de la symétrie
de d et qu’en fixant un point de départ, seules (n − 1)!/2 tournées doivent être consi-
dérées. Certes on va gagner un facteur 2n (=40 pour n = 20), mais le temps redevient
presque identique dès qu’on ajoute un point.
Figure 3.4 – Œuvre d’art créée à partir de la solution optimale d’une ins-
tance du Voyageur de commerce de n = 726 points. (Comment être sûr de
l’optimalité ?) © Robert Bosch.
v1 v2 v3 v4 v5
S1 S2 S3 S4
3. Pour des raisons d’implémentation, on verra que c’est plus malin de choisir vn−1 que v0 par exemple.
72 CHAPITRE 3. VOYAGEUR DE COMMERCE
x
vn−1
En effet, la tournée optimale part de vn−1 , visite tous les points de V ∗ pour se ter-
miner en un certain point t ∗ ∈ S ∗ avant de revenir en vn−1 (cf. la figure 3.7). Donc
opt(V , d) = D(t ∗ , V ∗ ) + d(t ∗ , vn−1 ). Or D(t ∗ , V ∗ ) + d(t ∗ , vn−1 ) > mint∈V ∗ {D(t, V ∗ ) + d(t, vn−1 )}
par définition du minimum. Et comme D(t, V ∗ ) + d(t, vn−1 ) représente, pour chaque
t ∈ V ∗ , la longueur d’une tournée, c’est que opt(V , t) = mint∈V ∗ {D(t, V ∗ ) + d(t, vn−1 )}.
V∗
t∗
vn−1
{s1 , . . . , sk , x, t}. Sa longueur est précisément D(t, S) par définition de la variable D(t, S).
L’observation élémentaire, mais cruciale, est que le sous-chemin vn−1 − s1 − · · · − sk − x
est aussi un chemin de longueur minimum de vn−1 à x visitant tous les points de S \ {t}
(cf. figure 3.6). Il est donc de longueur D(x, S \ {t}). En effet, s’il y en avait un autre
plus court, alors en rajoutant le segment x − t on déduirait une longueur de chemin de
vn−1 − s1 − · · · − sk − x − t plus courte que D(t, S). Pour calculer D(t, S) il suffit donc de
trouver le x ∈ S \ {t} qui minimise la longueur D(x, S \ {t}) + d(x, t) (cf. figure 3.8). Notez
qu’on a pas utilisé l’inégalité triangulaire ni la symétrie pour démontrer cette propriété.
x?
S \ {t}
vn−1
Figure 3.8 – Calcul de D(t, S) à partir du point x qui minimise D(x, S \ {t}) +
d(x, t).
(
d(vn−1 , t) si |S| = 1
D(t, S) = (3.2)
minx∈S\{t} {D(x, S \ {t}) + d(x, t)} si |S| > 1
Parenthèse. La constante DBL_MAX (définie dans float.h) correspond au plus grand double
représentable en machine, et la fonction fmin() (définie dans math.h) calcule le minimum
entre ses arguments lorsqu’ils sont de type double.
double tsp_tour(){
double w=DBL_MAX; // w=+∞
set S=set_create(n-1); // crée S = {0, ..., n − 2} = V ∗
for(int t=0;t<n-1;t++) // mint (D(t, V ∗ ) + d(t, vn−1 ))
w=fmin(w,D_rec(t,S)+d(V[t],V[n-1]));
set_free(S);
return w;
}
(t, S)
|S| − 1
(x, S \ {t})
|S| − 2 |S| − 1
Mémorisation. On va donc utiliser une table D[t][S] à deux dimensions pour stocker
les valeurs D(t, S) et éviter de les recalculer sans cesse. Pour simplifier l’implémenta-
tion on représentera un sous-ensemble S ⊂ {v0 , . . . , vn−1 } directement par un entier de n
bits, aussi noté S, chaque bit indiquant si vi ∈ S ou pas. Plus précisément, vi ∈ S si et
seulement si le bit en position i de S est à 1. Les positions commencent à 0 de sorte que
l’entier 2i représente tout simplement le singleton {vi }.
Par exemple, si S = {v3 , v2 , v0 } et n = 5, alors on aura :
v4 v3 v2 v1 v0
S = 0 1 1 0 1 = {v3 , v2 , v0 } = 13dix
On peut ainsi coder très efficacement les opérations sur les ensembles de taille n = 32,
76 CHAPITRE 3. VOYAGEUR DE COMMERCE
valeur expression C
∅ 0
{i} 1<<i
{0, . . . , n − 1} (1<<n)-1
X X^((1<<n)-1))
X ∪Y X|Y
X ∩Y X&Y
X∆Y X^Y
X \Y X&(~Y)
X⊆Y ? (X&Y)==X
|X| > 1 ? X&(X-1)
min X X&(-X)
max X (1<<fls(X))>>1
Les lignes de la table D[t][S] représentent les points (t) et les colonnes les sous-
ensembles (S). Voir la figure 3.10 pour un exemple avec n = 5 points. Comme S ne
3.3. PROGRAMMATION DYNAMIQUE 77
contient jamais vn−1 = v4 , il sera représenté en fait par un entier de n − 1 = 4 bits obtenu
en supprimant le bit le plus à gauche qui vaut toujours 0.
1 v0 }
0}
2 v0 }
2 v1 }
1 v
1 1 0}
1 2 0}
{v2 , v }
1 3 0}
0 3 1}
{v3 , v }
011 {v2 , v ,
101 {v3 , v ,
110 {v3 , v ,
111 {v3 , v ,
1
2
001 {v , v
010 {v , v
100 {v , v
101 { v , v
1 {v0 }
0 1}
0 2}
0 3}
V∗
001 {v
010 {v
100 {v
0
1
S
000
011
110
111
t 1 2 3 4 5 6 7 8 9 10 11 12 12 14 15
v0
v1
v2
v3
1 2 5 3 6 7 11 4 8 9 12 10 13 14 15
En pratique. Pour n = 20, nous avons vu que l’approche exhaustive prenais 30 ans sur
un ordinateur 1 GHz. Et en pratique, c’est plutôt des valeurs de n = 10, 11 ou 12 qu’il
est possible de résoudre en une poignée de secondes par l’approche exhaustive. Dans
notre cas, la complexité en temps devient n2 · 2n ≈ 202 · 220 < 29 · 220 < 109 ce qui fait
1s sur le même ordinateur. En TP on va voir qu’effectivement n = 20, voir un peu plus,
est largement faisable en pratique. Si on avait 30 ans devant nous, alors on pourrait
résoudre une instance de taille... n = 49. Ceci justifie amplement l’usage des entiers
pour le codage des sous-ensembles de {0, . . . , n − 1}.
Il s’agit du meilleur algorithme connu pour résoudre de manière exacte le Voyageur
de commerce. Notons en passant que c’est un problème ouvert de savoir s’il existe un
algorithme de complexité en temps cn+o(n) avec c < 2 une constante (et n = |V |). La
3.4. APPROXIMATION 79
meilleure borne inférieure connue pour la complexité en temps est Ω(n2 ), ce qui laisse
une marge de progression énorme pour les chercheurs en informatique.
3.4 Approximation
Le meilleur algorithme qui résout le Voyageur de commerce prend un temps expo-
nentielle en le nombre de points. Si on a besoin d’aller plus vite pour traiter de plus
grandes instances, disons de n 100 points, alors on doit abandonner la minimalité de
la longueur de la tournée.
Une façon de calculer rapidement une tournée est par exemple d’utiliser l’algo-
rithme dit du « point le plus proche » : on part d’un point quelconque et à chaque étape
on ajoute au chemin courant le point libre le plus proche du dernier point atteint. Une
fois le dernier point atteint on revient au point initial. La figure 3.11 illustre l’exécution
d’une telle construction.
ajouté nécessite de comparer O(n) distances, soit en tout une complexité en temps de
O(n2 ). On pourrait construire encore plus rapidement une tournée. Par exemple en
construisant aléatoirement la tournée, ce qui prend un temps optimal de Θ(n). [Ques-
tion. Pourquoi est-ce optimal en temps ?] Mais la longueur pourrait être n/2 fois plus
longue, puisque la distance entre deux points est 6 opt(V , d)/2 [Question. Pourquoi ?]
et cette distance pourrait se produire sur les n segments. La figure 3.12 propose un
exemple où les n segments sont en moyenne de longueur Θ(opt(V , d)).
L’algorithme du « point le plus proche » est aussi appelé l’algorithme glouton qui est
en fait une méthode assez générale qui peut s’appliquer à d’autres problèmes.
6. La longueur d’une corde formant un angle θ vaut 2r sin (θ/2), ce qui se voit facilement en coupant
un cône d’angle θ en deux triangles rectangles. Pour calculer la moyenne entre deux points d’un cercle, on
peut en fixer un et positionner l’autre selon un angle θ variant dans [0, π]. Puis, il faut Rcalculer la somme
π
sur toutes ces positions et diviser par la longueur de l’intervalle, soit π. Cela donne π1 0 2r sin (θ/2)dθ =
4r/π pour un cercle unité.
3.4. APPROXIMATION 81
Ce n’est pas une définition très précise, d’ailleurs il n’y en a pas. C’est une sorte de
méta-heuristique qui peut se décliner en heuristiques le plus souvent très simples pour
de nombreux problèmes.
Par exemple, pour les problèmes de type bin packing (cf. figure 3.13), qui consiste à
ranger des objets pour remplir le mieux possible une boîte de capacité donnée, l’algo-
rithme glouton se traduit par l’application de la simple règle : « essayer de ranger en
priorité les objets les plus gros ».
Figure 3.13 – Les problèmes du type bin packing sont très étudiés notamment
dans leurs versions 3D. Motivées par l’intérêt croissant de la livraison de
paquets, des sociétés, comme Silfra Technologies, proposent des solutions
algorithmiques et logicielles. À droite, un entrepôt d’Amazon.
graphe).
Pour une instance I d’un problème d’optimisation Π, on notera
• optΠ (I) la valeur de la solution optimale pour l’instance I ; ou
• A(I) la valeur de la solution produite par l’algorithme A sur l’instance I.
Parfois on notera opt(I) ou même simplement opt lorsque Π et I sont clairs d’après
le contexte. Pour simplifier, on supposera toujours que le problème Π est à valeurs
positives 7 , c’est-à-dire que optΠ (I) > 0 pour toute instance I.
Un algorithme d’approximation a donc pour vocation de produire une solution de
valeur « relativement proche » de l’optimal, notion que l’on définit maintenant 8 .
Parenthèse. Dans la définition précédente, on a écrit « algorithme polynomial » au lieu d’« al-
gorithme de complexité en temps polynomiale ». C’est un raccourci pour dire les deux : les
complexités en temps et en espace sont polynomiales. Si on se permet de ne pas préciser, c’est
parce que la complexité en temps est toujours plus grande que la complexité en espace. En
effet, en temps t on ne peut jamais écrire que t mots mémoires. Donc imposer une complexité
en temps polynomiale revient à imposer aussi une complexité en espace polynomiale.
Dans le cas d’une minimisation α > 1 car opt(I) 6 A(I) 6 α · opt(I), et pour une
maximisation α 6 1 car α · opt(I) 6 A(I) 6 opt(I). Remarquons qu’une 1-approximation
est un algorithme exact polynomial.
L’algorithme glouton, c’est-à-dire l’algorithme du « point le plus proche », est-il une
α-approximation pour une certaine constante α ? À cause du contre exemple présenté
sur la figure 3.11, on sait qu’il faut α > 1.5. Mais quid de α = 1.6 soit une garantie de
60% au-delà de l’optimal ? Et bien non ! Et pour le prouver on va montrer que ce n’est
pas une α-approximation pour tout facteur α > 1 donné. Considérons l’ensemble des
points suivants V = {A, B, C, D} ainsi que les distances données par la table :
7. Sinon on peut toujours s’intéresser au problème similaire renvoyant la valeur opposée, la valeur
absolue ou une translation de la valeur, quitte à transformer une maximisation en minimisation (ou le
contraire).
8. La définition peut varier suivant le sens précis que l’on veut donner à « relativement proche ».
Parfois on souhaite des approximations à un facteur additif près plutôt que multiplicatif. Parfois, on
impose le facteur d’approximation seulement pour les instances suffisamment grandes, puisque pour les
très petites, un algorithme exponentiel peut en venir à bout en un temps raisonnable (en fait en temps
constant si la taille était constante).
3.4. APPROXIMATION 83
A
d A B C D
1 A 0 1 2 3 + 6α
3 + 6α 2 B 1 0 1 2
B C 2 1 0 1
D 3 + 6α 2 1 0
2 1
D 1 C
A→B→C→D →A
qui a pour longueur Greedy(V , d) = 6 + 6α. Il est facile de vérifier que toute tournée
optimale, comme par exemple A → B → D → C → A, a pour longueur opt(V , d) = 6. En
effet, c’est les tournées qui n’utilisent pas l’arête A − D sont de longueur 6, et celles qui
l’utilisent de longueur au moins (3 + 6α) + 3 × 1 = 6 + 6α.
Le facteur d’approximation de l’algorithme glouton est donc
Greedy(V , d) 6 + 6α
= = 1 + α > α.
opt(V , d) 6
Donc l’algorithme glouton n’est pas une α-approximation. On parle alors plutôt d’heu-
ristique.
Parfois une heuristique peut être un algorithme d’approximation « qui s’ignore » : l’al-
gorithme peut réellement avoir un facteur d’approximation constant, seulement on ne
sait pas le démontrer... Il peut aussi arriver qu’une heuristique ne soit même pas de
complexité polynomiale (dans le pire des cas), mais très rapide en pratique.
Même sans garantie, une heuristique peut se révéler très efficace en pratique. C’est
d’ailleurs pourquoi elles sont utiles et développées. Pour résumer, une heuristique est
inutile en théorie mais bien utile en pratique, enfin si elle est « bonne ». Mais en l’ab-
sence de facteur d’approximation, on est bien évidemment un peu embêté pour donner
un critère objectif pour comparer les heuristiques entres-elles...
9. La tournée B → A → C → D → A, de longueur optimale 6, aurait pu être produit par l’algorithme
glouton (en partant de B et en choisissant l’arête A − B). Cependant, il s’agit de déterminer la longueur de
la tournée produite par l’algorithme quel que soit l’exécution, donc dans la pire des situations.
84 CHAPITRE 3. VOYAGEUR DE COMMERCE
Parenthèse. En fait, il existe bien une mesure pour comparer les heuristiques : le nombre de
domination. C’est le nombre de solutions dominées par celles produites par l’heuristique
dans le pire des cas, une solution dominant une autre si elle est meilleure ou égale. Un
algorithme exact a un nombre de domination maximum, soit 10 (n − 1)!/2 pour le TSP,
puisqu’il domine alors toutes les solutions. Il a été montré dans [GYZ02], que l’algorithme
glouton a un nombre de domination de 1, pour chaque n > 1. Il arrive donc que l’heuristique
produise la pire des tournées, puisqu’elle domine aucune autre solution.
Dans l’exemple à n = 4 points qui peut faire échouer l’algorithme glouton, on re-
marquera que la fonction d est symétrique mais qu’une des distances (= arêtes du K4 )
ne vérifie pas l’inégalité triangulaire. Plus précisément d(A, D) > d(A, B) + d(B, D) dès
que α > 0. On peut se poser la question si l’algorithme glouton n’aurait pas un facteur
d’approximation constant dans le cas métrique (avec inégalité triangulaire donc) ? Mal-
heureusement, il a été montré en 2015 dans [HW15] que l’algorithme glouton, même
dans le cas euclidien, a un facteur d’approximation de Ω(log n), et que plus générale-
ment pour le cas métrique ce facteur était toujours en O(log n) [RSLI77].
Parenthèse. Notez bien l’usage de Ω(log n) et de O(log n) de la dernière phrase. Elle signifie
qu’il existe des ensembles de n points du plan pour lesquels le facteur d’approximation de
l’algorithme glouton est au moins c log2 n, pour une certaine constante c > 0 et pour n assez
grand. Mais qu’aussi, pour toute instance du TSP métrique (qui inclut le cas euclidien), le
facteur d’approximation du même algorithme ne dépasse jamais c0 log2 n pour une certaine
constante c0 > c et pour n assez grand. L’usage des notations asymptotiques permet ainsi
de résumer fortement les énoncés lorsqu’elles sont correctement utilisées. Notez au passage
qu’il est inutile de préciser la base du logarithme dans la notation O(log n) car logb n =
log n/ log b. Donc log2 n, log10 n ou ln n sont identiques à une constante multiplicative près.
Traditionnellement on utilise O(log n) plutôt que O(ln n). Voir aussi le paragraphe 1.6.
10. Il y a n! permutations possibles. Mais pour chaque permutation, il y n points de départs possibles
et deux sens de parcours, chacun de ses choix produisant une tournée équivalente.
11. Il s’agit ici de voisinage dans l’espace ou le graphe des tournées : chaque point est une tournée et
deux points sont connectés si, par exemple, l’on peut passer d’une tournée à l’autre en échangeant deux
arêtes.
3.4. APPROXIMATION 85
Parenthèse. De manière générale, il a été démontré que trouver une tournée localement op-
timale (pour le TSP métrique), selon toute méthode, ne peut pas prendre un nombre po-
lynomial d’étapes, sauf si tous les problèmes de la classe PLS (Polynomial Local Search)
peuvent être résolus en temps polynomial. Pour les problèmes de cette classe, il est supposé
qu’on dispose d’une méthode permettant en temps polynomial : (1) de déterminer une solu-
tion arbitraire ; (2) d’évaluer le coût d’une solution ; (3) et de parcourir le voisinage d’une
solution.
C’est cependant un résultat général qui ne s’applique qu’à des instances et tournées
initiales très particulières. Par exemple, pour des points choisis uniformément aléatoires
dans le carré unité [0, 1]2 , on observe un nombre moyen de flips effectués de l’ordre de
O(n log n) et une tournée calculée de longueur entre 4% et 7% plus longue que la tournée
optimale. En fait il a été prouvé que cet excès moyen est borné par une constante, ce qui n’est
pas vrai pour des ensembles de points quelconques. On peut aussi prouver que le nombre
moyen de flips est polynomial 14 . Il peut arriver que l’heuristique 2-Opt soit plus longue
d’un facteur Ω(log n/ √ log log n) sur des instances Euclidiennes particulières [CKT99] et
même d’un facteur Θ( n ) pour des instances vérifiant seulement l’inégalités triangulaires
12. Initialement introduite par Georges A. Croes en 1958.
13. Plus précisément Ω(2n/8 ).
14. Plus précisément, c’est au plus O(n4+1/3 log n) dans le cas Euclidien, mais on est pas encore capable
de prouver si c’est moins de n2 par exemple.
86 CHAPITRE 3. VOYAGEUR DE COMMERCE
(voir [CKT99]). Ceci est valable uniquement si l’adversaire peut choisir la (mauvaise) tour-
née de départ. Évidemment, si la tournée de départ est déjà une 2-approximation comme vue
précédemment, la tournée résultante par 2-Opt ne pourra être que plus courte. L’heuristique
2-Opt calculée à partir d’une tournée issue de l’algorithme glouton donne en pratique de
très bon résultat.
Une autre heuristique est celle des « économies » (ou savings) de Clarke et Wright (cf.
figure 3.15). On construit n−1 tournées qui partent de v0 et qui sont v0 −vi −v0 pour tout
vi ∈ V ∗ . Puis, n−2 fois on fusionne deux tournées v0 −va1 −· · ·−vap −v0 et v0 −vb1 −· · ·−vbq −v0
en une plus grande qui évite un passage par v0 , soit v0 − va1 − · · · − vap − vb1 − · · · − vbq −
v0 . Par rapport au total des longueurs des tournées en cours, on économise d(vap , v0 ) +
d(v0 , vb1 ) − d(vap , vb1 ). On fusionne en priorité la paire de tournées qui économisent le
plus de distance. In a été montré aussi dans [BH15] que cette heuristique a un facteur
d’approximation de Θ(log n) dans le cas du TSP métrique.
Une heuristique proche, dite de l’« insertion aléatoire » (ou random insertion, cf. fi-
gure 3.15) donne aussi de très bon résultats, et est relativement rapide à calculer. Au
départ on considère une tournée avec un seul point choisi aléatoirement. Puis n − 1
fois on étend la tournée courante en insérant un point w choisi aléatoirement hors
de la tournée à la place de l’arête u − v qui minimise l’accroissement de distance
d(u, w) + d(w, v) − d(u, v). Une variante consiste à choisir w non pas aléatoirement mais
comme celui qui minimise l’accroissement. (Ce n’est donc plus vraiment l’heuristique
de l’insertion aléatoire). Il s’agit alors une 2-approximation — on se ramène au calcul de
l’arbre de poids minimum par l’algorithme de Prim suivi d’un parcours en profondeur
—, mais elle est O(n) fois plus lente à calculer.
u
w
v0
v
L’heuristique de Lin et Kernighan, plus complexe, est basée sur une généralisation
des flips ou k-Opt. C’est elle qui permet d’obtenir les meilleurs résultats en pratique.
Une très bonne heuristique (aussi rapide que 2-Opt) consiste à flipper trois arêtes dont
deux sont consécutives (on parle de 2.5-Opt, voir figure 3.16).
Une heuristique bien connue lorsque V ⊂ R2 consiste à calculer la tournée bito-
nique optimale, qui apparaît la première fois dans [CLRS01][Édition 1990, page 354].
Une tournée est bitonique si elle part du point le plus à gauche (celui avec l’abscisse la
3.4. APPROXIMATION 87
Figure 3.16 – Heuristique 2.5-Opt qui flippe trois arêtes (en bleu) dont deux
consécutives. (NB : il n’y a qu’une façon de le faire.)
plus petite), parcourt les points par abscisses croissantes jusqu’au point le plus à droite
(celui avec l’abscisse la plus grande) et revient vers le point de départ en parcourant
les points restant par abscisses décroissantes. Une autre définition équivalente est que
toute droite verticale ne coupe la tournée en au plus deux points. La figure 3.17 présente
un exemple. On peut montrer [Exercice. Pourquoi ?] que la tournée bitonique optimale
est sans croisement. De plus, c’est la tournée qui minimise la somme des déplacements
verticaux. L’intérêt de cette notion est qu’il est possible de calculer la tournée bitonique
optimale en temps O(n2 ), et ce par programmation dynamique.
Bien que très efficace, aucune de ces heuristiques ne permet en toute généralité de
garantir un facteur d’approximation constant. On pourra se référer à l’étude complète
sur le Voyageur de commerce par [JM97].
3.4.4 Inapproximabilité
Non seulement le problème du Voyageur de commerce est difficile à résoudre, mais
en plus il est difficile à approximer. On va voir en effet qu’aucun algorithme polyno-
mial 15 (et donc pas seulement l’algorithme glouton !) ne peut approximer à un facteur
15. C’est-à-dire de complexité en temps et/ou en espace polynomial.
88 CHAPITRE 3. VOYAGEUR DE COMMERCE
Parenthèse. Cycle Hamiltonien peut servir à gagner au jeu « Snake » : un serpent doit
manger un maximum de pommes dans une grille donné et il grandit à chaque pomme man-
gée. Le serpent ne doit pas se manger lui-même, les pommes apparaissant une par une au
hasard à chaque pomme mangée (il y a des variantes). En suivant un cycle hamiltonien
prédéterminé de la grille il est possible d’obtenir le score maximum. Voir figure 3.19.
16. P est l’ensemble des problèmes possédant un algorithme déterministe de complexité en temps po-
lynomial, alors que NP est celui des problèmes possédant un algorithme non-déterministe de complexité
en temps polynomial.
17. Du nom de celui même qui a introduit le problème du Voyageur de commerce.
3.4. APPROXIMATION 89
1 n2 = 81
Algorithme CycleHamiltonien(H)
1. Transformer H en une instance (VH , dH ) du Voyageur de commerce.
2. Renvoyer le booléen (A(VH , dH ) < n2 ), où n = |V (H)|.
18. On rappelle que A(I) est la valeur de la solution pour l’instance I renvoyée par l’algorithme A, ici
une longueur de tournée, cf. le paragraphe 3.4.2.
90 CHAPITRE 3. VOYAGEUR DE COMMERCE
polynomial. Pour la première, cela prend un temps O(n2 ), et pour la deuxième c’est po-
lynomial par définition de A. Or Cycle Hamiltonien est réputé difficile : il ne possède
pas d’algorithme polynomial, sauf si P=NP. Il suit, sous l’hypothèse que P,NP, que l’al-
gorithme A n’est pas une α-approximation : soit A n’est pas polynomial, soit le facteur
d’approximation est > α. Le problème du Voyageur de commerce est inapproximable,
sauf si P=NP.
Parenthèse. Le problème Cycle Hamiltonien est difficile même si le graphe est le sous-
graphe induit d’une grille, c’est-à-dire obtenu par la suppression de sommets d’une grille (cf.
figure 3.21). Cependant il est polynomial si la sous-grille n’a pas de trous, c’est-à-dire que
les sommets qui ne sont pas sur le bord de la face extérieure sont tous de degré 4 (cf. [UL97]).
Figure 3.21 – Sous-graphe induit d’une grille 15 × 25. Possède-t-elle un cycle Hamilto-
nien ?
Une réduction qui fait un seul appel à l’algorithme cible (comme l’algorithme A ci-
dessus) est appellée Karp-reduction, alors qu’une réduction qui en ferait plusieurs est appe-
lée Turing-reduction.
Les problèmes Cycle Hamiltonien et Chemin Hamiltonien sont aussi difficiles l’un
que l’autre, à un temps polynomial près. On peut le voir grâce à un autre type de réduction
(Turing-reduction), comme celles-ci :
• Si on sait résoudre Cycle Hamiltonien, alors on sait résoudre Chemin Hamilto-
nien, à un temps polynomial près. En effet, pour chaque paire de sommets (x, y) de H,
notons Hx,y le graphe H plus l’arête x − y. Alors H possède un chemin hamiltonien
si et seulement si Hx,y possède un cycle hamiltonien pour une certaine paire (x, y).
[Question. Pourquoi ?] Il suffit donc de tester |V (H)|
2 fois une procédure de détection
de cycle hamiltonien pour résoudre Chemin Hamiltonien.
• Si on sait résoudre Chemin Hamiltonien, alors on sait résoudre Cycle Hamilto-
nien, à un temps polynomial près. En effet, pour chaque arête x − y de H, notons Hx,y 0
arête x − y. [Question. Pourquoi ?] Il suffit donc de tester |E(H)| fois une procédure de
détection de chemin hamiltonien pour résoudre Cycle Hamiltonien.
3.4. APPROXIMATION 91
Théorème 3.1 ([Aro98][Mit99]) Pour tout ε > 0, il existe une (1 + ε)-approximation pour
le problème du Voyageur de commerce qui a pour complexité en temps
1
√ δ−1
n · (log n)O( ε δ)
.
Algorithme ApproxMST(V , d)
Entrée: Une instance (V , d) du Voyageur de commerce.
Sortie: Une tournée, c’est-à-dire un ordre sur les points de V .
Le graphe complet est un graphe où il existe une arête entre chaque paire de sommets.
Le graphe complet à n sommets possède donc n2 = n(n − 1)/2 = Θ(n2 ) arêtes.
0 4 7 0 4 7
1 1
2 6 2 6
5 5
3 3
Figure 3.22 – (a) Ensemble de points sur lequel est appliqué ApproxMST ; (b)
L’arbre couvrant de poids minimum et un parcours en profondeur ; (c) La
tournée correspondante.
Il est clair que l’algorithme ApproxMST renvoie une tournée puisque dans l’ordre de
première visite les points sont précisément visités une et une seule fois. Pour montrer
que l’algorithme est une 2-approximation, il nous faut démontrer deux points :
1. sa complexité est polynomiale ; et
2. son facteur d’approximation est au plus 2.
Parenthèse. Il existe des algorithmes plus efficaces que Prim et Kruskal pour calculer un
arbre couvrant de poids minimum pour un graphe à n sommets et m arêtes. Les plus ra-
pides, dus à [Cha00] et [Pet99], ont une complexité en O(n + m · α(m, n)) où α(m, n) est la
fonction inverse d’Ackermann 19 . Cette fonction croît extrêmement lentement. Pour toutes
valeurs raisonnables de m et n (disons inférieures au nombre de particules de l’Univers),
α(m, n) 6 4. Plus précisément 20 , α(m, n) = min i : A(i, dm/ne) > log2 n où A(i, j) est la
fonction d’Ackermann définie par :
• A(1, j) = 2j , pour tout j > 1 ;
• A(i, 1) = A(i − 1, 2), pour tout i > 1 ;
• A(i, j) = A(i − 1, A(i, j − 1)), pour tout i, j > 1.
L’algorithme résultant est réputé pour être terriblement compliqué. Il existe aussi des algo-
rithmes probabilistes dont le temps moyen est O(m + n).
Classiquement, la fonction d’Ackermann est plutôt définie ainsi :
• A(0, j) = j + 1, pour tout j > 0 ;
• A(i, 0) = A(i − 1, 1), pour tout i > 0 ;
• A(i, j) = A(i − 1, A(i, j − 1)), pour tout i, j > 0.
On a alors :
• A(1, j) = 2 + (j + 3) − 3
• A(2, j) = 2 × (j + 3) − 3
• A(3, j) = 2 ∧ (j + 3) − 3
• A(4, j) = 2 ∧ · · · ∧ 2 − 3 avec j + 3 deux empilés
• ...
La variante sur la fonction présentée au-dessus évite un terme additif −3 de la version
classique de A(i, j).
P Rappelons que le poids d’un graphe arête-valué (G, ω) est la valeur notée ω(G) =
e∈E(G) ω(e), c’est-à-dire la somme des poids de ses arêtes.
Proposition 3.2 La longueur de la tournée optimale pour l’instance (V , d) est plus grande
que le poids le l’arbre de poids minimum couvrant V . Dit autrement, opt(V , d) > d(T ).
19. Voir aussi ici pour une définition alternative plus simple.
20. Il y a parfois des variantes dans les définitions de α(m, n) : bm/nc au lieu de dm/ne, ou encore n au
lieu de log2 n. Ces variantes simplifient souvent les démonstrations, c’est-à-dire les calculs, mais au final
toutes les définitions restent équivalentes à un terme additif près.
94 CHAPITRE 3. VOYAGEUR DE COMMERCE
Proposition 3.3 La tournée obtenue par le parcours de T est de longueur au plus 2 · d(T ).
Dit autrement, ApproxMST(V , d) 6 2 · d(T ).
8 9
0 4 7
1
2 6
5
3
Figure 3.23 – Parcours de la face extérieure de l’arbre T , chaque arête étant
parcourue exactement deux fois. L’arête 0 − 4 appartient aux chemins P3 et
P7 .
3.4.7 Union-Find
Bien que Prim soit plus rapide dans le cas où m = Θ(n2 ), les détails de son implémen-
tation sont plus nombreux que ceux de Kruskal. Nous allons détailler l’implémentation
de Kruskal, en particulier la partie permettant de savoir si une arête forme un cycle ou
pas, et donc si elle doit être ajoutée à la forêt courante. Rappelons que dans cet algo-
rithme (qui est glouton), on ajoute les arêtes par poids croissant, sans créer de cycles,
jusqu’à former un arbre couvrant.
21. De manière générale pour un graphe G, c’est le plus petit cycle visitant chacune des arêtes de G,
une arête pouvant être traversée plusieurs fois en présence de sommets de degré impair.
96 CHAPITRE 3. VOYAGEUR DE COMMERCE
d’échanges entre A et B, améliorer la solution de B, ce qui conduit bien sûr à une contradic-
tion. Pour simplifier l’argument, on supposera dans un premier temps que toutes les arêtes
du graphe que l’on veut couvrir ont des poids différents.
On considère la première décision de Kruskal où l’arête e est prise pour A mais pas pour
B. L’arête e forme un cycle C dans B [Question. Pourquoi ?]. De plus C contient au moins
une arête e0 de poids ω(e0 ) > ω(e) [Question. Pourquoi ?]. On peut alors échanger les arêtes e
et e0 dans B conduisant à un arbre couvrant B0 de poids strictement inférieur : contradiction.
Donc A = B, montrant que l’arbre A calculé par Kruskal est un arbre de poids minimum.
[Cyril. À finir le cas où le poids des arêtes peuvent être égaux. On peut transformer les
poids en tenant compte des identifiants des sommets pour les rendre uniques et permettant
de retrouver le poids initial de l’arbre. Par exemple, pour des poids entiers et non nuls, on
peut ajouter n’importe quel nombre entre 1/n2 et 1/n sans changer le choix de l’arbre. Pour
obtenir son poids il suffira de ne garder que la partie entière du poids total.]
C v
C0
u
Pour cela on va résoudre un problème assez général de structure de données qui est
le suivant. Dans ce problème il y a deux types d’objects : des éléments et des ensembles.
L’objectif est de pouvoir réaliser le plus efficacement possibles les deux opérations sui-
vantes (voir la figure 3.25 pour un exemple) :
a b c d e f a b c d e f
Par rapport à notre problème de composantes connexes, les éléments sont les som-
mets et les ensembles les composantes connexes. Muni d’une telle structure de données,
l’algorithme de Kruskal peut se résumer ainsi :
Algorithme Kruskal(G, ω)
Entrée: Un graphe arête-valué (G, ω).
Sortie: Un arbre couvrant de poids minimum.
// Union-Find (v1)
void Union(int x, int y){
parent[y]=x;
}
int Find(int u){
while(u!=parent[u]) u=parent[u];
return u;
}
x y z
u v
Figure 3.26 – Fusion des ensembles x=Find(u) et y=Find(v), tous deux repré-
sentés par des arbres enracinés, avec Union(x,y).
Union(Find(u),Find(v));
Cela aboutit à un arbre à un chemin contenant tous les éléments, l’élément 0 étant
une feuille dont la profondeur ne cesse d’augmenter :
n − 1 ← n − 2 ← ··· ← 2 ← 1 ← 0
Plus embêtant, le temps cumulé de ces fusions est de l’ordre de n2 puisque Find(0)
à l’étape u prend un temps proportionnel à u. Ce qui n’est pas très efficace, même
si dans cet exemple on aurait pu faire mieux avec Union(Find(0),Find(u)). [Question.
Pourquoi ?]. On va faire beaucoup mieux grâce aux deux optimisations suivantes.
// Union (v2)
void Union(int x, int y){
if(rank[x]>rank[y]) parent[y]=x; // y → x
else{ parent[x]=y; // x → y
if(rank[x]==rank[y]) rank[y]++;
}
}
x y x
Union(x,y)
y
Notez que ce qui nous intéresse ce n’est pas la hauteur de chacun des sommets, mais
seulement des arbres (ici les racines) ce qui permet une mise à jour plus rapide. La com-
plexité de l’opération Union(), bien que légèrement plus élevée, est toujours constante.
Le gain pour Find() est cependant substantiel.
Preuve. Par induction sur r. Pour r = 0, c’est évident, chaque arbre contenant au moins
1 = 20 élément. Supposons vraie la propriété pour tous les arbres de rang r. D’après le
code, on obtient un arbre de rang r + 1 seulement dans le cas où l’arbre est obtenu par
la fusion de deux arbres de rang r. Le nouvel arbre contient, par hypothèse, au moins
2r + 2r = 2r+1 éléments. 2
Il est clair qu’un arbre possède au plus n éléments. Donc si un arbre de rang r pos-
sède k éléments, alors on aura évidemment 2r 6 k 6 n ce qui implique r 6 log2 n. Donc
chaque arbre a un rang O(log n). Il est facile de voir, par une simple induction, que
le rang de l’arbre est bien sa hauteur. Cela implique que la complexité de Find() est
O(log n). C’est un gain exponentielle par rapport à la version précédente !
x x
Find(u)
u
// Find (v2)
int Find(int u){
if(u!=parent[u]) parent[u]=Find(parent[u]);
return parent[u];
}
Proposition 3.6 Lorsque les deux optimisations « rang » et « compression de chemin » sont
réalisées, la complexité de m opérations de fusion et/ou de recherche sur n éléments est de
O(m · α(m, n)) où α(m, n) est la fonction inverse d’Ackermann 22 , une fois l’initialisation de
la structure de données effectuée en O(n).
22. Ce résultat, ainsi que la fonction inverse Ackermann aussi définie page 93, est expliqué ici.
102 CHAPITRE 3. VOYAGEUR DE COMMERCE
On ne démontrera pas ce résultat qui est difficile à établir. On dit aussi parfois que
la complexité amortie des opérations de fusion et de recherche est de O(α(m, n)) dans la
mesure où la somme de m opérations est O(m · α(m, n)).
On peut montrer que α(m, n) 6 4 pour toutes valeurs réalistes de m et de n, jusqu’à
22048 10500 soit beaucoup plus que le nombre de particules de l’univers estimé à 1080 .
Cela n’a évidemment pas de sens de vouloir allouer un tableau de taille n aussi grande.
Il a été démontré dans [Tar79][FS89] que le terme α(m, n) est en fait nécessaire.
Quelle que soit la structure de données utilisée, il n’est nécessaire d’accéder à Ω(m ·
α(m, n)) mots mémoire pour effectuer m opérations de fusion et/ou de recherche dans
le pire des cas.
1 3
2
4 5
1 2 1 3 1 3 2 4 5 4 5 2 3
Algorithme ApproxChristofides(V , d)
Entrée: Une instance (V , d) du Voyageur de commerce.
Sortie: Une tournée, c’est-à-dire un ordre sur les points de V .
On rappelle qu’un circuit eulérien d’un multi-graphe, c’est-à-dire d’un graphe possé-
dant éventuellement plusieurs arêtes entre deux sommets, est un circuit permettant de
visiter une et une fois chacune des arêtes d’un graphe. Cela est possible si et seulement
si tous les sommets du graphes sont de degrés 25 pairs.
23. Un couplage (donc pas forcément parfait) est une forêt couvrante où les composante sont réduites
à un sommet ou une arête
24. Même si G a un nombre pair de sommet, il pourrait ne pas avoir de couplage parfait, comme une
étoile à trois feuilles par exemple.
25. Le degré d’un sommet est le nombre d’arêtes incidentes à ce sommet, ce qui peut donc être inférieur
au nombre de voisins en présence d’arêtes multiples.
104 CHAPITRE 3. VOYAGEUR DE COMMERCE
0 4 7
1
2 6
5
3
Figure 3.30 – (a) Arbre couvrant T de poids minimum ; (b) Couplage parfait
F de poids minimum pour les points I (en bleu) correspondant aux sommets
de degrés impairs de T ; (c) Multi-graphe T ∪ F et la tournée résultante : les
chemins de T en pointillé vert (3 → 1 → 0 → 4 et 6 → 4 → 7) sont court-
circuitées par les arêtes roses. Elle est un peu plus courte que la tournée de
la figure 3.22(c).
• Le couplage parfait existe bien car |I| est pair (rappelons que dans tout graphe, il
existe un nombre pair de sommet de degré impair) et que le graphe induit par I
est une clique.
1
ApproxChristofides(V , d) 6 d(T ) + d(F) < d(C ∗ ) + · d(CI )
2
1 3
= d(C ∗ ) + d(C ∗ ) = · d(C ∗ )
2 2
3
= · opt(V , d)
2
s t
L’arbre de poids minimum T est le chemin de s à t parcourant tous les points, et donc
d(T ) = n − 1. Il n’y a alors que s et t qui sont de degré impair dans T . Donc le couplage
parfait F est réduit au segment s − t dont le coût est le nombre de triangles (chaque base
valant 1), soit bn/2c. La tournée produite par ApproxChristofides a un coût de d(T ) +
d(C) = (n − 1) + bn/2c = n − 1 + n/2 − 1/2 = 1.5n − 1.5. Or la tournée optimale, obtenue
en formant l’enveloppe convexe des n points, est de coût n. (On ne peut pas faire moins, la
distance minimal entre deux points quelconques étant 1.) Le facteur d’approximation sur
cette instance, 1.5 − O(1/n), approche aussi près que l’on veut 1.5.
106 BIBLIOGRAPHIE
3.5 Morale
• Le problème du Voyageur de commerce (TSP) est un problème difficile, c’est-
à-dire qu’on ne sait pas le résoudre en temps polynomial. Il est aussi difficile à
approximer dans sa version générale, mais pas lorsque la fonction de distance d
vérifie l’inégalité triangulaire.
• Il peut-être résolu de manière exacte par programmation dynamique, mais cela
requière un temps exponentiel en le nombre de points.
• Lorsque la méthode exacte ne suffit pas (car par exemple n est trop grand) on
cherche des heuristiques ou des algorithmes d’approximation censés être bien plus
rapides, au moins en pratique.
• Il existe de nombreuses heuristiques qui tentent de résoudre le TSP. L’algorithme
du « point le plus proche » (qui est un algorithme glouton) et l’algorithme 2-Opt
(qui est un algorithme d’optimisation locale) en sont deux exemples. Il en existe
beaucoup d’autres.
• Un algorithme glouton n’est pas un algorithme qui consome plus de ressources
que nécessaire. Cette stratégie algorithmique consiste plutôt à progresser tant que
possible sans remettre en question ses choix. En quelque sorte un algorithme glou-
ton avance sans trop réfléchir.
• Les algorithmes d’approximation sont de complexité polynomiale et donnent des
garanties sur la qualité de la solution grâce au facteur d’approximation, contrai-
rement aux heuristiques qui ne garantissent ni la complexité polynomiale ni un
facteur d’approximation constant. Le meilleur connu pour le TSP métrique, c’est-
à-dire lorsque d vérifie l’inégalité triangulaire, a un facteur d’approximation de
1.5, à l’aide une variante astucieuse de l’algorithme basé sur le DFS d’un arbre
couvrant de poids minimum (MST).
• Pour être efficace, les algorithmes doivent parfois mettre en œuvre des structures
de données efficaces, comme union-and-find qui permet de maintenir les compo-
santes connexes d’un graphe en temps linéaire en pratique.
• On peut parfois optimiser les structures de données, et donc les algorithmes, en
augmentant l’espace de travail, en utilisant des tables auxiliaires pour permettre,
par exemple, l’optimisation du rang dans union-and-find. Le prix à payer est le
coût du maintient de ces structures auxiliaires. De manière générale, il y a un
compromis entre la taille, le temps de mise à jour de la structure de données et
le temps de requête. Augmenter l’espace implique des mises à jour de cet espace,
mais permet de réduire le temps de requêtes.
Bibliographie
[ABCC06] D. L. Applegate, R. E. Bixby, V. Chvátal, and W. J. Cook, The Traveling Sa-
BIBLIOGRAPHIE 107
Sommaire
4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
4.2 L’algorithme de Dijkstra . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
4.3 L’algorithme A* . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
4.4 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
4.1 Introduction
4.1.1 Pathfinding
La recherche de chemin (pathfinding en Anglais) est l’art de trouver un chemin entre
deux points : un point de départ s (pour start ou source) et une cible t (pour target). C’est
un domaine 1 à part entière de l’IA en Informatique
Il existe de nombreux algorithmes de pathfinding, et on ne va pas tous les étudier :
algorithme en faisceau (on explore qu’un nombre limité de voisins), algorithme best-
first (on explore en premier le « meilleur » voisin déterminé par une heuristique), etc.
On peut aussi se servir de ces algorithmes pour chercher une solution optimale dans
un espace abstrait de solutions. On les utilise principalement en robotique, pour les
systèmes de navigation GPS et les jeux vidéos.
1. Ce la rentre en fait dans le sous-domaine de la planification de l’IA.
110 CHAPITRE 4. NAVIGATION
Pour les jeux vidéos, les algorithmes de pathfinding sont utilisés le plus souvent pour
diriger les personnages non-jouables, c’est-à-dire les bots ou les IA, qui in-fine sont ani-
mées par des algorithmes exécutés par une machine (cf. figure 4.1). On utilise des al-
gorithmes pour calculer les trajets car, pour des raisons évidente de stockage, il n’est
pas possible de coder in extenso (c’est-à-dire en « dur » dans une table ou un fichier)
chaque déplacement s → t possibles 2 . Parce que ces algorithmes sont particulièrement
efficaces, ils sont aussi utilisés pour des jeux temps-réels 3 ou encore des jeux en-lignes
multi-joueurs massifs où chaque joueur peut en cliquant sur l’écran déplacer automati-
quement son personnage vers le point visé.
On ne parlera pas vraiment des algorithmes qui, à partir d’une scène ou d’un décor,
permettent de construire le navigation mesh (figure 4.2). Ce graphe est la plupart du
temps déterminé (au moins partiellement) à la conception du jeu et non pas lors d’une
partie, car cela peut être couteux en temps de calcul. En terme de stockage, ce par contre
relativement négligeable surtout en comparaison avec les textures par exemple.
Les algorithmes de pathfinding s’exécutent sur ce graphe, une structure qui reste
cachée aux l’utilisateurs.
Figure 4.3 – Navigation meshes dans le jeu vidéo Killzone. Ce graphe (en vert
clair) normalement invisible aux joueurs est proche d’une grille avec ses dia-
gonales.
L’algorithme qui va nous intéresser est celui qui se chargent de trouver les chemins
entres deux points d’intérêts s → t du navigation mesh. Dans la grande majorité des
jeux, il s’agit d’A*, un algorithme de pathfinding particulièrement efficace. Il s’agit d’une
extension de l’algorithme de Dijkstra.
112 CHAPITRE 4. NAVIGATION
Bien sûr il y a de nombreux algorithmes qui gèrent la navigation des bots dans un
jeu vidéo. Ceux, par exemple, chargés de la planification des paires si → ti en fonc-
tion de l’environnement et des événements (objets mouvant ou autres bots), mais aussi
pour rendre plus réaliste certaines trajectoires (un bot qui suivrait un plus court chemin
trop tortueux peut paraître trop artificiel et nécessiter un re-découpage, par exemple en
fonction de la visibilité du personnage). Il y a encore les algorithmes chargés de rendre
réaliste le déplacement du personnage le long du chemin déterminé par A* : adoucir les
angles entre deux arêtes successives du chemin (cf. figure 4.4), aller en ligne droite au
lieu de suivre les zig-zags d’une triangulation (comme sur la figure 4.2), etc.
ti
si
4.1.3 Rappels
Il est important de bien distinguer les termes « poids des arêtes », « coût d’un che-
min », « plus court chemin » et « distance », qui sont des notions proches mais diffé-
rentes.
Soit G un graphe, pas forcément symétrique, arête-valué par une fonction de poids
ω. Par exemple, dans le cas d’un graphe géométrique, où les sommets sont des points
du plan, ω(e) peut correspondre à la longueur de l’arête e, c’est-à-dire la distance eucli-
dienne séparant ses extrémités. Mais dans un graphe général on parle plutôt de poids
pour éviter la confusion avec la notion de longueur propre aux graphes géométriques.
4.1. INTRODUCTION 113
Le coût d’un chemin C allant de u à v dans G est tout simplement la somme des
poids de ses arêtes : X
coût(C) = ω(e) .
e∈E(C)
On dit que C est un chemin de coût minimum si son coût est le plus petit parmi
tous les chemins allant de u à v dans G. Dans ce cas on dit aussi que C est un plus
court chemin. La distance entre u et v dans G, notée distG (u, v), est le coût d’un
plus court chemin allant de u à v (cf. la figure 4.5).
2 3
2
s t
1 1 1 1
Figure 4.5 – Deux chemins B (en bleu) et R (en rouge) entre les sommets s et
t d’un graphe G arête-valué. On a distG (s, t) = coût(R) = 4 et coût(B) = 5.
On peut utiliser aussi le terme de « coût » pour une arête e, à la place de « poids »,
car on peut très bien considérer e comme un chemin particulier joignant ses extrémités
dont le coût est précisément ω(e) d’après la définition précédente.
Dans le chapitre 3 concernant le voyageur de commerce, nous avions utilisé le terme
de longueur minimum plutôt que de coût minimum d’un chemin. C’était parce que le
poids des arêtes correspondait à une longueur, la distance euclidienne entre les points
extrémités de l’arête du graphe complet.
Attention ! Une arête e ne définie pas forcément un plus court chemin. Par définition
de la distance, si x, y sont voisins, alors distG (x, y) 6 ω(x, y). Cependant, l’arête x−y peut
représenter un trajet assez tortueux par exemple, si bien qu’un autre chemin alternatif,
évitant x − y, pourrait avoir un coût strictement inférieur. Techniquement parlant, on a
pas forcément l’inégalité triangulaire pour (G, ω).
Évidemment, si l’objectif est de calculer des plus courts chemins, de telles arêtes ne
sont pas très utiles et peuvent être supprimer du graphe en pré-traitement. Après cela
l’inégalité triangulaire sera respectée. [Exercice. Pourquoi ?] Par contre, s’il faut trouver
un chemin de coût maximum il faut les garder.
Si S est un sous-ensemble de sommets
S de G, on notera N (S) l’ensemble des voisins
de S dans G. Dit autrement, N (S) = u∈S N (u) où N (u) est l’ensemble des voisins du
sommet u dans G.
114 CHAPITRE 4. NAVIGATION
P Q
4.2.1 Propriétés
Il faut bien distinguer coût[u], qui est la valeur d’une table pour le sommet u cal-
culée par l’algorithme, et le coût d’un chemin C, notion mathématique notée coût(C)
correspondant à la somme des poids de ses arêtes. Bien évidemment, il va se trouver
que coût[u] = coût(C) où C est un plus court chemin de s à u, soit distG (s, u) d’après les
rappels de la section 4.1.3. Mais il va falloir le démontrer ! car c’est a priori deux choses
différentes. D’ailleurs on verra plus tard que pour A* coût[u] n’est pas forcément le coût
d’un plus court chemin.
Les deux propriétés suivantes sont immédiates d’après l’algorithme. En fait, elle ne
dépendent pas du choix de u dans l’instruction 2a et seront donc communes avec l’al-
gorithme A*. On remarque que les tables coût[u] et parent[u] ne sont définies que pour
les sommets de P ∪ Q. De plus, à l’étape 2a, Q = ({s} ∪ N (P )) \ P .
En effet, on peut vérifier facilement que si le chemin n’est pas trouvé, alors tous les
sommets accessibles depuis s ont été ajoutés à Q. Dit autrement, si t est accessible de-
puis s, l’algorithme finira par ajouter t à Q et trouvera le chemin. Il réalise en fait un
parcours de la composante connexe de s dans le cas symétrique. Dans le cas asymé-
trique, t peut ne pas être accessible depuis s et pourtant être dans la même composante
connexe comme dans l’exemple 4.7). La propriété 4.1 ne dépend en rien de la valuation
ω des arêtes.
t
s
C’est lié au fait que coût[v] est construit en 2(d)iv par l’ajout à coût[u] du poids
ω(u, v) entre v et son père u ∈ P , ce qui de proche en proche constitue la somme des
poids des arêtes du chemin de s à v.
La propriété suivante, que l’on va démontrer, dépend du choix de u dans l’étape 2a.
Proposition 4.1 Soit u le sommet sélectionné à l’étape 2a. Alors coût[u] = distG (s, u).
On déduit de cette proposition que coût[u] = distG (s, u) pour tout u ∈ P ∪ {t} puisque
tous les sommets de P proviennent de l’ajout des sommets issus de l’étape 2a, de même
si u = t. En la combinant avec la propriété 4.2, on en déduit que le chemin défini par
la table parent[ ] pour tout sommet de P ∪ {t} est un plus court chemin. Dit autrement
coût[u] représente effectivement le coût d’un plus court chemin entre s et u.
Preuve. Pour démontrer par contradiction la proposition 4.1, on va suppose qu’il existe
un sommet u sélectionné à l’étape 2a ne vérifiant pas l’énoncé, donc avec coût[u] ,
distG (s, u). Comme coût[u] est le coût d’un chemin de s à u (propriété 4.2), c’est que
coût[u] > distG (s, u).
Sans perte de généralité, on supposera que u est le premier sommet pour lequel
coût[u] > distG (s, u). Dans la suite, les ensembles P et Q correspondent aux ensembles
définis par l’algorithme lorsque le sommet u est sélectionné en 2a.
Tous les sommets sélectionnés en 2a avant u se trouvent dans P . Donc coût[x] =
distG (s, x) pour tout x ∈ P . Notons que s ∈ P (et donc P , ∅), car coût[s] = 0 = distG (s, s)
et donc u , s.
Soit C un plus court chemin de s à u, et soit u 0 le premier sommet en parcourant
C de s à u qui ne soit pas dans P (cf. figure 4.8). Ce sommet existe car s ∈ P et u < P .
Comme Q ⊂ {s} ∪ N (P ), c’est que u 0 ∈ Q. À ce point de la preuve u 0 = u est parfaitement
possible.
Comme étape intermédiaire, nous allons montrer que coût[u 0 ] 6 distG (s, u 0 ).
Lorsque u est choisi, tous les arcs du type w → u 0 avec w ∈ P ont été visité à cause de
l’instruction 2d. À cause de l’instruction 2(d)iii on a :
coût[u 0 ] = coût[parent[u 0 ]] + ω(parent[u 0 ], u 0 ) = min coût[w] + ω(w, u 0 ) .
(4.1)
w∈P
u
s
C
v0
u0
P Q
(NB : ici coût( ) est la valeur mathématique, pas coût[ ].) Comme v 0 ∈ P , coût[v 0 ] =
distG (s, v 0 ) = coût(C[s, v 0 ]) puisque s, v 0 ∈ C qui est un plus court chemin. Or l’arc (v 0 , u 0 )
appartient à C. On a donc :
(v, c0 )
(v, c)
(v, c00 )
Figure 4.9 – Mise à jour paresseuse des clés d’une file de priorité Q, ici im-
plémentée par un tas minimum. C’est la copie de v avec la plus petite clé (ici
c0 ) qui sera extraite en premier.
Pour mettre en œuvre cette mise à jour paresseuse, il faut réorganiser les instructions
en 2d correspondant à la mise à jour des coûts des voisins v de u :
Notez qu’au passage le code se simplifie (vive la paresse !) et surtout évite le test
« v ∈ Q » qui n’est pas adapté aux files.
Cependant on crée, par cet ajout inconditionnel, le problème que les copies de v (le
couple initial (v, c) puis (v, c00 )) vont plus tard être extraites de la file. Cela n’était pas
possible auparavant, mais c’est inexorable maintenant à cause du Tant que Q , ∅. Dans
Dijkstra on peut résoudre ce problème grâce à l’ensemble P , puisqu’une fois extrait, un
sommet se retrouve dans P et n’a plus à être traité de nouveau.
7. En fait, ici (v, coût[u], parent[u]) qu’on ajoute à Q, c’est-à-dire v et toutes ses informations associées.
En pratique c’est une struct reprenant toutes ces informations qui est ajoutée à Q.
4.2. L’ALGORITHME DE DIJKSTRA 121
qui remplace l’ajout simple de u à P en 2c. Dans continuer la boucle il faut comprendre
revenir au début de l’instruction 2 du Tant que Q , ∅, ce qui en C se traduit par un
simple continue.
L’autre inconvénient de cet ajout systématique est qu’on peut être amené à ajouter
plus de n éléments à la file. Mais cela est au plus O(m) car le nombre total de modifica-
tions, on l’a vu, est au plus le nombre d’arcs. L’espace peut donc grimper à O(m). Mais,
on va le voir, cela n’affecte pas vraiment la complexité en temps 8 qui vaut donc :
Implémentation par tas. Une façon simple d’implémenter une file de priorité est
d’utiliser un tas (heap en Anglais). Avec un tas classique implémenté par un arbre
binaire quasi-complet (qui est lui-même un simple tableau), on obtient 9 tmin (m) =
O(log m) = O(log n) et tadd (m) = O(log m) = O(log n) [Question. Pourquoi O(log m) =
O(log n) ?]. Ce qui donne finalement, pour Dijkstra avec implémentation par tas et mise
à jour paresseuse, une complexité de :
O(m · log n) .
Cependant, il existe des structures de données pour les tas qui sont plus sophisti-
quées (voir la parenthèse de la page 119), notamment le tas de Fibonacci. Il permet un
temps moyen par opérations – on parle aussi de complexité amortie – plus faible que le
tas binaire. Il existe même une version, appelée tas de Fibonacci strict [SBLT12], avec
tdec (n) = tadd (n) = O(1) et tmin (n) = O(log n) dans le pire des cas et pas seulement en
moyenne. La complexité finale tombent alors à O(m + n log n). On peut montrer que
c’est la meilleure complexité que l’on puisse espérer pour Dijkstra. Mais ce n’est pas
forcément le meilleur algorithme pour le calcul des distances dans un graphe !
Parenthèse. Le principe consistant à prendre à chaque fois le sommet le plus proche implique
que dans Dijkstra les sommets sont parcourus dans l’ordre croissant de leur distance depuis
la source s. Si, comme dans la figure 4.10, la source s possède n − 1 voisins, le parcours de
ses voisins selon l’algorithme donnera l’ordre croissant des poids de ses arêtes incidentes. En
effet, l’unique plus court chemin entre s et vi est précisément l’arête s − vi . Ceci implique
une complexité d’au moins Ω(n log n) pour Dijkstra, car il faut se souvenir que trier n0 =
n−1 nombres nécessitent au moins log2 (n0 !) = n0 log2 n0 −Θ(n0 ) = Ω(n log n) comparaisons.
8. En plus les navigations meshes à base de triangulations du plan possèdent m < 3n arêtes [Question.
Pourquoi ?]. Et puis il faut partir gagnant (surtout vrai avec A*) : on espère bien évidemment trouver la
cible t avant d’avoir parcouru les m arcs du graphe !
9. C’est la suppression du minimum qui coute O(log m). Le trouver à proprement parler est en O(1).
122 CHAPITRE 4. NAVIGATION
ω1 ω2 ω3 ω4 ω5
v1 v2 v3 v4 v5
W
D’un autre coté la complexité est au moins le nombre total d’arêtes, m. Car, si toutes les
arêtes et leurs poids ne sont pas examinés, l’algorithme pourrait se tromper. Il suit que la
complexité de Dijkstra est au moins 10
1
max {m, n log n} > (m + n log n) = Ω(m + n log n) .
2
4.3 L’algorithme A*
Dijkstra n’est pas vraiment adapté pour chercher une seule cible donnée. C’est un
peu comme si on partait d’une île perdue en radeau pour rejoindre le continent et qu’on
décrivait une spirale grandissante autour de l’île jusqu’à toucher un point quelconque
de la terre ferme. Avec A* on estime le cap, puis on le suit avec plus ou moins de pré-
cision, en le ré-évaluant au fur et à mesure. Bien sûr il faut pouvoir estimer ce cap. En
absence de cap, A* tout comme Dijkstra nous laisseront dans la brume !
10. Attention ! Il y a ici deux arguments menant à deux bornes inférieures sur la complexité en temps.
Schématiquement, l’un dit qu’il faut au moins 1h, tant que l’autre dit qu’il faut au moins 2h. On ne peut
pas conclure directement que l’algorithme doit pendre 3h, mais seulement au moins le maximum des
deux bornes inférieures. Rien ne dit, par exemple, qu’on ne peut pas commencer à trier les poids pendant
qu’on examine les arêtes. Cependant, max {x, y} = Ω(x + y) [Question. Pourquoi ?].
4.3. L’ALGORITHME A* 123
On pourrait (naïvement) se dire qu’avec l’aide d’un cap, le problème devient trivial.
Malheureusement suivre le cap, et rien d’autre, ne suffit pas pour arriver à destination.
Pour aller du Port de Marseille au Port d’Amsterdam, on voit qu’il va falloir partir vers
le sud-ouest (Gibraltar) et pas vers le nord ! (la méditerranée formant un cul-de-sac, cf.
figure 4.13). Il faut donc combiner de manière astucieuse la notion de cap avec l’ap-
proche classique de Dijkstra.
124 CHAPITRE 4. NAVIGATION
Figure 4.12 – Rejoindre le continent depuis une île perdue, avec ou sans cap.
Figure 4.13 – Rejoindre le continent depuis les Îles Baléares avec un cap
devient trivial. Aller du Port de Marseille au Port d’Amsterdam, même avec
un cap, est plus complexe.
L’algorithme A* a été mis au point en 1968 par des chercheurs en intelligence ar-
tificielle, soit presque 10 ans après l’article de Dijkstra présentant son célèbre algo-
rithme [Dij59]. C’est une extension de l’algorithme de Dijkstra. Plusieurs versions ont
été présentées : A1, puis A2 et au final A*.
L’algorithme est donc paramétré par cette « estimation » de distance qui va guider
la recherche du meilleur chemin. Plus précisément, il s’agit d’une fonction notée h(x, t)
4.3. L’ALGORITHME A* 125
qui est une heuristique sur la distance entre un sommet quelconque x et la cible t. Rap-
pelons qu’une heuristique ne donne aucune garantie sur ce qu’elle est censé calculer :
h(x, t) peut être proche de distG (x, t)... ou pas. Pour qu’A* calcule un plus court chemin,
il faut que l’heuristique vérifie une condition supplémentaire qui sera détaillée plus
tard.
C’est donc essentiellement l’ordre dans lequel les sommets de Q sont sélectionnés
qui différentie l’algorithme A* de celui de Dijkstra. L’idée est qu’en visitant d’abord
certains sommets plutôt que d’autres, grâce à l’heuristique h, on va tomber plus ra-
pidement sur la cible que ne le ferait Dijkstra. L’heuristique h donne donc le cap. Dans
l’absolu, c’est-à-dire dans le pire des cas, A* n’est pas meilleur que Dijkstra, les com-
plexités sont les mêmes. C’est en pratique, sur des graphes particuliers, qu’A* se révèle
supérieur.
Algorithme A*
Entrée: Un graphe G, potentiellement asymétrique, arête-valué par une fonction
de poids ω positive ou nulle, s, t ∈ V (G), et une heuristique h(x, t) estimant la
distance entre les sommets x et t dans G.
Sortie: Un chemin entre s et t dans G, une erreur s’il n’a pas été trouvé.
Sont encadrées les différences avec Dijkstra. Ainsi dans A* le choix du sommet u est
déterminé non pas par son coût[u] mais par son score[u] = coût[u] + h(u, t). Comme on
l’a déjà indiqué, les propriétés 4.1 et 4.2 ne reposent pas sur le choix du sommet u en 2a.
Elles sont donc communes avec celles de Dijkstra, et donc :
tout u ∈ P ∪ Q.
4.3.1 Propriétés
Les principales propriétés spécifiques à l’algorithme A* sont les suivantes :
Propriété 4.3 Si h(x, t) = 0, alors A* est équivalent à l’algorithme Dijkstra, et donc calcule
un plus court chemin entre s et t.
C’est évident puisqu’on remarque que si h(x, t) = 0, alors score[u] = coût[u] tout au
long de l’algorithme A*, rendant les deux algorithmes absolument identiques.
Ce qui fait la force de l’algorithme A*, c’est la propriété suivante qu’on ne démon-
trera pas (le terme « monotone » est expliqué juste après) :
Propriété 4.4 ([DP85]) Tout algorithme qui calcule un chemin de s à t, sur la base de la
même heuristique monotone h, visite au moins autant de sommets que A*.
4.3. L’ALGORITHME A* 127
En fait, le nombre de sommets visités peut dépendre de l’ordre des sommets dans
le tas si plusieurs sommets de Q sont de score minimum. Un algorithme gérant diffé-
remment les cas d’égalités pourrait visiter moins de sommets. Cependant, il existe un
ordre des sommets du tas qui fait qu’A* ne visite pas plus de sommets que le meilleur
algorithme possible.
L’heuristique h est monotone si h(x, t) 6 ω(x, y)+h(y, t) pour tout sommet x et voisin
y de x. Elle sous-estime la distance si h(x, y) 6 distG (x, y) pour toutes les paires de
sommets x, y où h est définie.
La monotonie est une sorte de version « faible » d’inégalité triangulaire pour h (cf.
figure 4.15). La différence est que la monotonie s’applique spécifiquement pour t et un
voisin y de x, au lieu de s’appliquer sur tout triplet (x, y, z) quelconque de sommets.
Cependant on retombe sur l’inégalité triangulaire h(x, t) 6 h(x, y) + h(y, t) si l’on impose
que h(x, y) > ω(x, y) pour chaque arête x − y, ce qui n’est pas une grosse contrainte. C’est
en effet la distance entre sommets « distant » qui est difficile d’estimer, et non pas la
distance de ceux directement connectés. La meilleure estimation qu’on puisse espérer
est h(x, t) = distG (x, t). Mais évidemment on dispose rarement d’une telle heuristique
puisque distG (·, t) est ce qu’on cherche à calculer.
Si x et y sont connectés par un chemin C, et plus forcément une arête, alors la mo-
notonie de h, appliquée sur chaque arête de C, implique la formule plus générale :
h(x, t) 6 coût(C) + h(y, t) . (4.2)
Une heuristique peut sous-estimer la distance sans être monotone. Par contre une
fonction monotone sous-estime nécessairement la distance si h(t, t) 6 0. [Exercice. Pour-
quoi ?]
y
y C
h(y, t)
ω h(y, t)
x t x t
h(x, t) h(x, t)
Figure 4.15 – Monotonie pour une arête x − y : h(x, t) 6 ω(x, y) + h(y, t). Géné-
ralisation à un chemin C de x à y : h(x, t) 6 coût(C) + h(y, t).
L’heuristique définie par h(x, t) = 0, ∀x ∈ V (G), est monotone, de même que h(x, t) =
K où K est n’importe quelle constante indépendente de x. [Question. pourquoi ?] Mais,
comme on va le voir, c’est aussi le cas de toute fonction de distance (et donc vérifiant
l’inégalité triangulaire) qui sous-estime la distance dans le graphe. Typiquement, la dis-
tance « vol d’oiseau » vérifie l’inégalité triangulaire et bien sûr sous-estime la distance
128 CHAPITRE 4. NAVIGATION
dans les graphes à base de grilles qui ne peut être que plus longue (cf. la figure 4.16).
Elle est donc monotone. En effet, si h vérifie l’inégalité triangulaire et sous-estime la
distance, alors h(x, z) 6 h(x, y) + h(y, z) et h(x, y) 6 distG (x, y) pour tout x, y, z. En particu-
lier, si x − y est une arête, distG (x, y) 6 ω(x, y), et on retrouve que h(x, t) 6 ω(x, y) + h(y, t)
en posant z = t, ce qui montre que h est monotone.
s
t
Parenthèse. La distance vol d’oiseau correspond à la distance dans un terrain sans aucun
obstacle. Dans la grille avec un 8-voisinage elle est identique à la norme `∞ . C’est aussi la
distance du roi sur l’échiquier, appelée parfois distance de Tchebychev. La distance dans la
grille avec un 4-voisinage, appelée aussi distance de Manhattan, est identique à la norme
`1 .
On rappelle que la norme est une distance qu’on associe aux vecteurs. C’est une géné-
ralisation de la valeur absolue qu’on peut définir quelle que soit la dimension. Dans R2 , la
norme `p vaut
(x, y)
= p |x|p + |y|p = (|x|p + |y|p )1/p
p
p
où p > 1 est un paramètre généralement entier. La norme p `1 vaut donc ||(x, y)||1 = |x| + |y|
(distance de Manhattan) et la norme `2 vaut ||(x, y)||2 = |x|2 + |y|2 (distance euclidienne).
Le disque 11 de rayon unité selon la norme `p est l’ensemble des points (x, y) ∈ R2 tels
que ||(x, y)||p 6 1. Comme le montre la figure 4.17, les disques de normes `p en fonction p,
sont inclus les uns dans les autres.
La norme `∞ est définie par ||(x, y)||∞ = max {|x|, |y|} et correspond à la limite de ||(x, y)||p
lorsque p → +∞. L’intuition est que plus p est grand, plus la norme `p amplifie la coordon-
11. On parle intervalle en dimension un, de disque en dimension deux et de « boule » dans le cas
général.
4.3. L’ALGORITHME A* 129
Figure 4.17 – Inclusion des disques de rayon unité selon la norme `p . La forme
de la région des sommets visités par Dijkstra dans la figure 4.14 (disque carré
à gauche) s’explique par le fait que la distance dans la grille avec 8-voisinage
correspondant à la norme `∞ . Pour chaque p > 1, ||(x, y)||∞ 6 ||(x, y)||p 6 21/p ·
||(x, y)||∞ . Source Wikipédia.
née la plus grande (en valeur absolue). Plus précisément, si |x| > |y|, alors
!p !
p p p |y|
|x| + |y| = |x| · 1 + −−−−−−→ |x|p
|x| p→+∞
car (|y|/|x|)p → 0 si |y|/|x| < 1. Dit autrement, lorsque |x| > |y|,
(x, y)
= (|x|p + |y|p )1/p −−−−−−→ (|x|p )1/p = |x| = max {|x|, |y|} .
p p→+∞
Propriété 4.5 Si h est monotone, alors le chemin trouvé par A* est un plus court chemin. Plus
précisément, le sommet u sélectionné à l’instruction 2a d’A* vérifie coût[u] = distG (s, u).
u h(u, t)
s t
C
v0
u0 h(u 0 , t)
P Q
Dans la preuve de Dijkstra, l’équation (4.3) conduisait à coût[u 0 ] < coût[u] puisque
distG (s, u 0 ) 6 distG (s, u) < coût[u] par hypothèse sur u. C’était une contradiction car u
était supposé être le sommet de coût minimum. Or ici pour A*, u est le sommet de score
minimum où h intervient. Il faut conclure différemment.
Appliquons la propriété de monotonie de h sur chaque arête du chemin C[u 0 , u].
D’après l’équation (4.2) (voir aussi la figure 4.15) :
Du coup,
L’inégalité score[u 0 ] < score[u] contredit le fait que u a été choisi comme le sommet de
Q de score minimum. Donc coût[u] = distG (s, u) ce qui termine la preuve. 2
L’effet est que des voisins déjà dans P peuvent être re-visités, modifiant potentiel-
lement leurs coûts et leurs scores. En les remettant dans Q on peut espérer trouver
un chemin plus court au prix d’un temps d’exploration plus long. Cela complexifie
l’analyse 12 , mais surtout cela n’est pas nécessaire si l’heuristique h est monotone,
puisque dans ce cas A* (version du cours) calcule le chemin le plus court possible.
Donc l’algorithme A* présenté dans le cours est une version simplifiée et optimi-
sée pour le cas des heuristiques monotones. La version originale d’A* est donc
intéressante que lorsque h n’est pas monotone. En fait on peut montrer que la ver-
sion originale calcule un plus court chemin dès que h sous-estime la distance, une
propriété plus faible que la monotonie. [Exercice. Trouver un exemple où l’algo-
rithme du cours échoue à trouver un plus court chemin alors que h sous-estime la
distance ?]
• On peut parfois accélérer le traitement, dans le cas des graphes symétriques, en
lançant deux exécutions d’A* en parallèle : une de s → t et une de t → s. Il y a
alors deux arbres qui croissent : Ps de racine s pour la recherche s → t, et Pt de
racine t pour la recherche t → s. Cela forme un ensemble P = Ps ∪Pt formé de deux
composantes connexes. En pratique, pour gérer les sommets de P , on rajoute une
marque, plaçant un sommet dans trois états possibles : il est soit dans Ps , soit dans
Pt , soit ni dans Ps ni dans Pt (c’est-à-dire pas dans P ). Un seul ensemble Q suffit
pour gérer le voisinage de P . Le score d’un sommet est calculé vis-à-vis de l’arbre
où l’on souhaite le raccrocher : c’est son coût dans cet arbre plus l’heuristique
pour aller vers la racine de l’autre arbre. En extrayant le sommet de score mini-
mum, il se rattache ainsi arbitrairement à l’un ou l’autre des arbres, simulant une
exécution parallèle. Le chemin est construit dès que les deux arbres se touchent.
Voir la figure 4.19. En terme de sommets visités, le gain n’est pas systématique.
[Exercice. Est-ce que le chemin découvert par tel double parcours est toujours un
plus court chemin ? en supposant un graphe symétrique et que h(x, t) et h(x, s) sont
monotones.]
• On peut implémenter le parcours en profondeur (ou DFS pour Depth-First Search)
à l’aide de A*. Pour cela, le coût des arêtes du graphe est fixé à 1. Puis, on remplace
le terme h(v, t) dans l’instruction 2(d)iv par un compteur (initialisée à 2m au dé-
part) qui est décrémenté à chaque utilisation, si bien que c’est le premier sommet
découvert qui est prioritaire. Cela revient aussi à dire que l’heuristique h décroît
12. Notamment cela rend complexe l’analyse de la taille du tas avec une gestion paresseuse de la mise
à jour du score.
132 CHAPITRE 4. NAVIGATION
α coût visités
t 0 11 514
1 11 89
2 11 80
3 14 67
11 4 14 62
5 14 58
14 6 14 57
s 7 14 55
8+ 14 54
4.4 Morale
• Les navigation meshes sont des graphes issus de maillage de terrains 2D ou 3D
pour simplifier les déplacements possibles des personnages artificiels (et autres
bots) animés par des IA qui sont infine pilotée par des algorithmes exécutés par
une machine. La requête principale est celle de recherche du meilleur chemin ou
d’un chemin court entre deux points du navigation mesh.
• Les notions de « chemin court » ou de « meilleur chemin » sont relatives à la valua-
tion des arêtes du graphe, ou plus généralement des arcs si le graphe est orienté.
On parle plutôt de « longueur » dans le cas de graphe géométrique (lié à une dis-
tance entre les extrémités de l’arête), de « poids » si la valeur est positive ou nulle,
ou de « coût » pour une valeur générale (positive ou négative donc). Pour les algo-
rithmes Dijkstra ou A*, le terme approprié est celui de poids.
• On peut faire mieux que Dijkstra en pratique en tenant compte de la cible, car
les choix qu’il prend sont indépendants de la destination. Au contraire, A* profite
d’informations sur la destination encodée par une heuristique qui peut être plus
ou moins précise. C’est évidemment général : toute information supplémentaire
peut être exploitée par l’algorithme pour être plus performant.
134 CHAPITRE 4. NAVIGATION
• On peut se servir de A* pour approximer la distance avec une garantie sur le fac-
teur d’approximation avec un choix judicieux de l’heuristique h.
• L’algorithme A* ne sert pas qu’à gérer le déplacement de bots. Il peut servir aussi
à trouver des solutions dans un espace des « possibles », espace décrit implicite-
ment par une fonction d’adjacence plutôt qu’explicitement par un graphe avec
son ensemble de sommets et d’arêtes. Il est souvent utilisé comme brique de base
en Intelligence Artificielle pour la résolution de problèmes d’optimisation, tout
comme la méthode de descente en gradient (cf. figure 3.14).
Pour la petite histoire, ce n’est qu’en 2014 qu’il a été possible de calculer, grâce à
des super-calculateurs et des programmes très optimisés, le diamètre du graphe
du Rubik’s Cube, soit la plus grande distance possible entre une source et une des-
tination. Il est de 26. Le diamètre est de 20 dans la variante du graphe où les
demi-tours sont possibles (et pas seulement des quarts de tour comme pour 26).
Voir la figure 4.21.
BIBLIOGRAPHIE 135
Bibliographie
[BDPED19] A. Boussard, J. Delescluse, A. Pérez-Escudero, and A. Dussutour, Me-
mory inception and preservation in slime moulds : the quest for a common
mechanism, Philisophical Transactions of The Royal Society B, 374 (2019).
doi : 10.1098/rstb.2018.0368.
[Dij59] E. W. Dijkstra, A note on two problems in connexion with graphs, Numerische
Mathematik, 1 (1959), pp. 269–271. doi : 10.1007/BF01386390.
[DP85] R. Dechter and J. Pearl, Generalized best-first search strategies and the
optimality of A*, Journal of the ACM, 32 (1985), pp. 505–536. doi :
10.1145/3828.3830.
[DRS07] H. Dinh, A. Russell, and Y. Su, On the value of good advice : the complexity
of A* search with accurate heuristics, in 22nd National Conference on Arti-
ficial Intelligence (AAAI), vol. 2, AAAI Press, July 2007, pp. 1140–1145.
https://www.aaai.org/Papers/AAAI/2007/AAAI07-181.pdf.
[NYT00] T. Nakagaki, H. Yamada, and Á. Tóth, Maze-solving by an amoeboid orga-
nism, Nature, 407 (2000), p. 470. doi : 10.1038/35035159.
[SBLT12] G. Stølting Bordal, G. Lagogiannis, and R. E. Tarjan, Strict Fibonacci
heaps, in 44th Annual ACM Symposium on Theory of Computing (STOC),
ACM Press, May 2012, pp. 1177–1184. doi : 10.1145/2213977.2214082.
136 BIBLIOGRAPHIE
Sommaire
5.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
5.2 Trouver la paire de points les plus proches . . . . . . . . . . . . . . . . 140
5.3 Multiplication rapide . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151
5.4 Master Theorem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160
5.5 Calcul du médian . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
5.6 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
5.1 Introduction
Diviser pour régner (divide-and-conquer en Anglais) est une technique permettant de
construire des algorithmiques récursifs. La stratégie consiste à découper le problème en
sous-problèmes similaires (d’où l’algorithme récursif résultant) dans l’espoir d’affaiblir
ou de casser la difficulté du problème initial.
L’expression provient du latin « divide ut regnes » ou « divide et impera », et tire ses
origines de l’antiquité. Une stratégie militaire (ou politique) bien connue consiste, afin
d’affaiblir un groupe d’individus adversaires, à le diviser en plus petit espérant ainsi les
rendre impuissant.
138 CHAPITRE 5. DIVISER POUR RÉGNER
Les algorithmes produits ne sont pas forcément les plus efficaces possibles. Ils
peuvent même parfois se révéler n’être pas plus efficaces que l’approche naïve. On a
déjà vu de tels exemples, les algorithmes récursifs étant parfois franchement inefficaces
(cf. section 2.4). Cependant la technique gagne à être connue puisqu’elle peut mener à
des algorithmes non triviaux auxquels on n’aurait peut-être pas pensé sinon.
L’archétype d’un algorithme résultant de cette approche est sans doute le tri-fusion.
On découpe le tableau en deux sous-tableaux que l’on tri chacun récursivement. Ils sont
ensuite recombinés (d’où le terme de fusion) pour obtenir un tableau entièrement trié.
Voici un rappel du code :
Il faut noter que c’est en fait la fusion qui trie le tableau. Les appels récursifs n’ont
pas pour effet d’ordonner les éléments. Au mieux ils modifient les indices i et j ce qui
virtuellement découpe le tableau en sous-tableaux de plus en plus petits. La fonction
de comparaison de deux éléments (l’instruction « T[p]<T[q]? » ci-après 1 ) n’est présente
que dans la fonction fusion() dont le code est rappelé ci-dessous 2 :
1. C’est cette instruction qu’il faudrait changer pour effectuer un tri selon une fonction de comparai-
son fcmp() quelconque à la qsort(). En fait, pour une fonction de comparaison absolument quelconque
il faudrait utiliser des void* et déclarer le tableau comme void* T[].
2. Traditionnellement on sort les trois conditions de la boucle while, pour obtenir trois boucles while
sans condition, rallongeant d’autant le code. On a préféré ici une présentation succincte du code.
5.1. INTRODUCTION 139
// appel à merge_sort()
void sort(double T[],int n){
A=malloc(n*sizeof(*A));
merge_sort(T,0,n); // trie T[0..n[
free(A);
}
L’approche du tri-fusion est efficace car il est effectivement plus rapide de trier un
tableau à partir de deux tableaux déjà triés. Cela ne prend qu’un temps linéaire. Lorsque
les sous-tableaux ne contiennent plus qu’un élément, alors les fusions opèrent. Pour
analyser le temps consommé par toutes les fusions de l’algorithme, il est plus simple
de grouper les fusions selon leur niveaux. Au plus bas niveau (=0) sont fusionnés les
tableaux à un élément pour former des tableaux de niveau supérieur (=1). Puis sont
fusionnés les tableaux de niveaux i (ou inférieur) pour former des tableaux de niveau
i + 1. Comme la somme totale des longueurs des tableaux d’un niveau donné ne peut
pas dépasser n, le temps de fusion de tous les tableaux de niveau i prend O(n) pour
chaque i. Si l’on découpe en deux parties égales 4 à chaque fois, le niveau d’un tableau
sera au plus O(log n) puisque sa taille doublera à chaque fusion. Au total la complexité
est O(n log n).
Il est intéressant de remarquer que l’approche du tri-par-sélection, une approche
naïve qui consiste à chercher le plus petit élément, de le mettre en tête et de recom-
mencer sur le reste, est bien moins efficace : O(n2 ) comparaisons vs. O(n log n) pour le
tri-fusion. On pourra se reporter au paragraphe 5.2.5 pour la comparaison des com-
plexités n2 et n log n en pratique.
3. Bien sûr, l’utilisation de la variable globale A n’est pas conseillé si sort() a vocation à être exécutée
en parallèle.
4. Si n n’est pas une puissance de deux, il faut alors remarquer que la complexité en temps de l’al-
gorithme ne sera pas plus grande que la complexité de trier n0 ∈]n, 2n] éléments où cette fois n0 est une
puissance de deux, et n0 log n0 6 2n log 2n = O(n log n). En effet, on peut toujours, avant le tri, ajouter n0 −n
éléments fictifs arbitrairement grand en fin de tableau. Après le tri, les n premiers éléments seront les
éléments d’origine et triés.
140 CHAPITRE 5. DIVISER POUR RÉGNER
Parenthèse. Construire un algorithme de tri de complexité O(n log n) itératif, donc non basé
sur une approche récursive, n’est pas si simple que cela. Le tri-par-sélection, le tri-par-
insertion 5 , et le tri-par-bulles 6 sont des algorithmes itératifs de complexité O(n2 ). Même
le tri-rapide 7 est récursif et de complexité O(n2 ), même si en moyenne la complexité est
meilleure. Cf. le tableau comparatif des tris.
Cependant, le tri-par-tas échappe à la règle. Il n’est pas récursif, permet un tri en place,
c’est-à-dire qu’il n’utilise pas de mémoire supplémentaire comme dans le tri-fusion, et a
une complexité O(n log n). Le tableau T des n éléments à trier va servir de support à un tas
maximum. La remarque est que les éléments d’un tas de taille k sont rangés dans k premières
cases de T . Les n − k cases suivantes sont libres pour le stockage des éléments de T qui ne
sont pas dans le tas.
Dans une première phase le tableau est transformé en tas maximum. Pour cela on peut
ajouter les éléments un à un au tas jusqu’à le remplir. Cela prend un temps de O(n log n)
pour les n insertions. On peut cependant faire plus rapidement en parcourant les éléments
par niveau décroissant, à partir du niveau i = h − 1 des parents des feuilles. (Pour ces
dernières il n’y a rien à faire.) Puis pour chacun d’eux on corrige son sous-tas en descendant
ce père au bon endroit comme lors d’une suppression, en temps h − i donc. Cela prend un
Ph−1
temps total de i=0 (h − i) · 2i sachant qu’il y a 2i éléments au niveau i. Il se trouve que
h−1
X
(h − i) · 2i = 2h+1 − h − 2 < 2n
i=0
sachant que la hauteur h = log2 n .
Dans une seconde phase, on extrait successivement le maximum en le supprimant du
tas et en remplissant le tableau par la fin. Cela prend un temps de O(n log n) pour les n sup-
pressions. Le tableau se trouve alors trié par ordre croissant en un temps total de O(n log n),
et ceci sans avoir utilisé d’espace mémoire supplémentaire autre que le tableau lui-même.
5.2.1 Motivation
Il s’agit de déterminer la paire de points les plus proches pris dans un ensemble
donné de n points du plan. C’est un problème de géométrie discrète (computational
geometry) qui s’est posée dans les années 1970 lorsqu’on a commencé à implémenter les
routines de bases des premières cartes graphiques.
On a pensé pendant longtemps 8 qu’aucun algorithme ne pouvait faire mieux qu’exa-
5. On insère chaque élément à sa place dans le début du tableau comme le tri d’un jeu de cartes.
6. On échange les éléments qui ne sont pas dans le bon ordre.
7. On range les éléments par rapport à un pivot, et on recommence dans chaque partie.
8. Des propos mêmes de Jon Louis Bentley [Ben80][page 226] en 1980, co-inventeur de l’algorithme
qu’on va présenter et qui lui date de 1976 [BS76].
5.2. TROUVER LA PAIRE DE POINTS LES PLUS PROCHES 141
miner chacune des paires de points, ce qui correspond à l’approche exhaustive. En effet,
si par exemple toutes les paires de points sont à distance à peu près d sauf une seule
qui est beaucoup plus courte, on risque de devoir examiner toutes les paires avant de la
trouver. Il y a n2 = n(n − 1)/2 paires, ce qui donne une complexité en temps d’au moins
Ω(n2 ) pour l’approche exhaustive.
Et pourtant. On va voir que la technique « diviser pour régner » va produire un
algorithme non trivial bien plus performant.
Plus en détails. Dans la suite on notera δ = min {dA , dB } la plus petite des distances
entres les paires de A2 et B2 . Pour tout sous-ensemble Q ⊆ P , on notera Qx (resp. Qy )
la liste des points de Q ordonnée par abscisses x (resp. ordonnées y) croissant. On se
9. On rappelle que |A| représente la cardinalité de l’ensemble A, soit son nombre d’éléments.
142 CHAPITRE 5. DIVISER POUR RÉGNER
servira du fait qu’une fois la liste Px calculée, on peut calculer la liste Qx par un simple
parcours de Px en temps O(|Px |) et du test d’appartenance à Q. Idem pour Qy à partir de
Py .
On supposera que P ne contient pas deux points avec la même abscisse. On peut s’en
passer, mais cela complique la présentation et l’analyse de l’algorithme. Si jamais c’est
le cas, on peut toujours effectuer une légère 10 rotation des points pour se ramener à ce
cas. La rotation ne change pas, en principe, la distance recherchée. Mais cela reste « en
principe ». Le mieux est d’adapter correctement l’algorithme.
Soit p∗ le point de rang dn/2e dans Px . C’est l’élément médian de la liste ordonnée Px :
il y a autant d’éléments avant qu’après (à un près). On définit alors A comme l’ensemble
des points de rang inférieur ou égale à celui de p∗ dans Px , et B l’ensemble des points
de rang strictement supérieur à celui de p∗ . Enfin, on pose L la ligne verticale passant 11
par p∗ et S l’ensemble des points de P qui sont à distance moins de δ de la ligne médiane
L. Voir la figure 5.1.
Dit autrement :
A = {(x, y) ∈ P : x 6 x∗ }
B = {(x, y) ∈ P : x > x∗ }
L = {(x∗ , y) : y ∈ R}
S = {(x, y) ∈ P : |x − x∗ | < δ} .
Propriété 5.1 S’il existe (a, b) ∈ A × B tels que dist(a, b) < δ, alors a, b ∈ S.
Preuve. Si a < S, alors dist(a, b) > dist(a, B) > dist(a, L) > δ : contradiction. De même, si
b < S, alors dist(a, b) > dist(b, A) > dist(b, L) > δ : contradiction. Conclusion : a et b sont
tous des deux dans S. 2
Cette propriété seule n’aide pas beaucoup. Certes, pour calculer dAB on peut se res-
treindre aux seules paires de S 2 . Malheureusement, il est parfaitement possible que
S = P (S contient tous les points), si bien que le calcul de dAB peut se révéler aussi dif-
ficile que le problème initial. La propriété suivante va nous aider à calculer la paire de
points les plus proches de S en temps O(|S|) au lieu de O(|S|2 ).
Propriété 5.2 S’il existe (s, s0 ) ∈ S 2 tel que dist(s, s0 ) < δ, alors s et s0 sont éloignés d’au plus 7
positions dans Sy .
10. La rotation « légère » exacte dépend en fait de la distance minimum entre deux points... On peut
alors s’en sortir avec une rotation aléatoire, et recommencer tant que cela échoue.
11. C’est ici qu’on a besoin qu’une ligne verticale donnée ne passe par qu’au plus un point de P .
5.2. TROUVER LA PAIRE DE POINTS LES PLUS PROCHES 143
y δ L δ
P
S
b
b0
a0
p∗
a
x
A x∗ B
for(i=0;i<k;i++)
for(j=i+1;(j<=i+7)&&(j<k);j++){
d=dist(S_y[i],S_y[j])
if(d<d_min){ d_min=d; i_min=i; j_min=j; }
}
Notez bien que les points d’indice après si dans Sy ne sont pas rangés suivant leur
distance à si . Il est tout a fait possible d’avoir dist(si , si+7 ) < dist(si , si+1 ) < δ. Par contre il
est certain que dist(si , si+8 ) > δ de même que pour chaque sj dès que j > i + 7. [Question.
Que vaut d_min si Sy ne contient pas au moins deux points ?]
Preuve. Comme dist(si , sj ) = dist(sj , si ), on va supposer sans perte de généralité que i < j
et donc si est en dessous de sj . Pour tout k ∈ {0, 1, 2}, on pose Hk la ligne horizontale
d’ordonnée y(si ) + k · δ/2 où y(si ) est l’ordonnée de si . Donc H0 est la ligne horizontale
passant par si (voir la figure 5.2).
On va quadriller la partie du plan contenant S et au dessus de H0 en boîtes carrées
144 CHAPITRE 5. DIVISER POUR RÉGNER
de coté δ/2 de sorte que H0 et L coïncident avec des bords de boîtes. Il ne faut pas que L
coupe l’intérieur d’une boîte (voir la figure 5.2).
y δ L δ
P
S
b
b0
si+8
H2
H1
H0 si
a0
p∗
a
x
A x∗ B
Donc si deux points p, p0 sont dans une même boîte, ils sont à distance dist(p, p0 ) < δ. Or,
cette boîte est incluse dans A ou dans B, L ne coupant l’intérieur d’aucune boîte. Ceci
implique que leur distance doit être au moins min {dA , dB } = δ : contradiction.
D’après cette propriété, la zone du plan comprise entre H0 et H2 dans S contient au
plus 8 points (incluant si ) puisqu’elle ne comprend que 8 boîtes. En particulier si+8 ne
peut pas être compris entre H0 et H2 car {si , si+1 , . . . , si+8 } contient 9 points. Il suit que
dist(si , si+8 ) > dist(H0 , H2 ) > δ. Donc si dist(si , sj ) < δ, on doit avoir j < i + 8, soit j − i 6 7.
2
En fait, il est possible de placer 4 points dans un carré de coté δ avec la contrainte
que les points soient à distance > δ les uns des autres, placés à l’intérieur du carré, et
sans être sur la même ligne (Hi ) ou même colonne (L), cf la la figure 5.3. Ainsi, on peut
5.2. TROUVER LA PAIRE DE POINTS LES PLUS PROCHES 145
placer si+7 juste en dessous de H2 de sorte qu’il soit au choix à distance < δ ou bien à
distance > δ de si ∈ H0 montrant l’optimalité de la proposition 5.2.
H2 si+7
H0 si
5.2.3 L’algorithme
Algorithme PPPP(P )
Entrée: Un ensemble P de points du plan avec au moins deux points.
Sortie: La paire de points les plus proches.
5.2.4 Complexité
Soit T (n) la complexité en temps de l’algorithme PPPPrec (Px , Py ) lorsqu’il est appliqué
à des tableaux Px , Py de n points chacun. La complexité en temps de l’algorithme PPPP
appliqué à un ensemble P de n points est alors O(n log n)+T (n). En effet, O(n log n) est le
temps nécessaire pour trier les n points (selon x et selon y) avec un algorithme de tri de
cette complexité, comme le tri-fusion (cf. le section 5.1), auquel il faut ajouter le temps
T (n) de calcul pour PPPPrec (Px , Py ).
Il n’est pas difficile de voir que chaque étape, sauf peut-être l’étape 4 qui est récur-
sive, peut être effectuée en temps O(n) [Question. Pourquoi ?] On observe aussi que les
tableaux Ax , Ay , Bx , By sont de taille au plus dn/2e. Donc en incluant l’étape 4, on obtient
que la complexité en temps de PPPPrec vérifie l’équation :
Afin d’éviter les pièges pointés dans la section 1.5.1, il est fortement conseillé de ne
pas mettre de notation grand-O lors de la résolution d’une équation de récurrence. Cela
12. Elle n’existe pas si |Sy | = 1. [Question. Est-ce possible ?].
5.2. TROUVER LA PAIRE DE POINTS LES PLUS PROCHES 147
tient au fait que si on « déplie » la récurrence, on aura un nombre non borné de termes
en O(...) ce qui généralement mène à des erreurs (cf. la preuve fausse 26). Il se trouve que
le facteur 2 devant T (dn/2e), de même que celui à l’intérieur de T (), est particulièrement
important pour la complexité finale. Alors que celui dans le terme O(n) l’est beaucoup
moins. Mais tout ceci, on ne le saura qu’à la fin du calcul...
Donc, pour une constante c > 0 assez grande, on a :
(
2 · T (dn/2e) + cn si n > 3
T (n) 6
c si n 6 3
L’inéquation T (n) 6 c pour n 6 3 est tirée de l’algorithme qui termine en un temps
constant dès que n 6 3. En fait, on aurait du écrire T (3) 6 c0 pour une certaine constante
c0 > 0, mais comme c est choisie « suffisamment grande » il n’est pas faux de supposer
que T (3) 6 c0 6 c. En fait, pour le cas terminal, on peut écrire un peu ce qu’on veut car
il est clair que lorsque n est une constante fixé (par exemple n = 2, 3 ou 100), le temps
de l’algorithme devient aussi borné par une constante (T (2), T (3) ou T (100)).
On cherche donc à résoudre l’équation précédente. On verra dans la section 5.4 que
la complexité T (n) n’est pas influencée par les parties entières 13 .
En dépliant 14 la formule de récurrence, il vient :
T (n) 6 2 · T (n/2) + cn
6 2 · [ 2 · T ((n/2)/2) + c · (n/2) ] + cn
6 22 · T (n/22 ) + cn + cn
6 22 · [ 2 · T ((n/22 )/2) + c · (n/22 ) ] + 2 · cn (5.2)
3 3
6 2 · T (n/2 ) + cn + 2 · cn
...
6 2i · T (n/2i ) + i · cn ∀i > 0
La dernière équation est valable pour tout i > 0 (et même pour i = 0 en fait). La récur-
rence s’arrête dès que n/2i 6 3. Lorsque i = log2 (n/3) , on a 2i > 2log2 (n/3) = n/3 et donc
n/2i 6 3, ce qui permet d’écrire :
T (n) 6 2dlog2 (n/3)e · T (3) + log (n/3) · cn
2
(log2 (n/3))+1
6 2 · c + O(n log n)
6 2c · n/3 + O(n log n)
6 O(n) + O(n log n) = O(n log n) .
13. Une façon de le voir est qu’en temps O(n) on peut ajouter des points sans modifier la solution
[Question. Pourquoi ?] et de sorte que le nouveau nombre de points soit une puissance de deux (et donc les
parties entières peuvent être supprimées). Le nombre de points est au plus doublé [Question. Pourquoi ?],
ce qui n’a pas d’impact pour une complexité polynomiale [Question. Pourquoi ?].
14. Déplier la récurrence permet de trouver la formule en fonction de i et de n. Pour être rigoureux,
il faudrait le démontrer. Mais une fois qu’on a la formule, c’est très facile de le faire... par récurrence
justement ! Appliquer l’hypothèse de récurrence revient à déplier la formule une fois de plus.
148 CHAPITRE 5. DIVISER POUR RÉGNER
Remarquons que la constante c ne joue effectivement aucun rôle (on aurait pu prendre
c = 1), ainsi que la constante « 3 » sur n dans le cas terminal.
Au final on a donc montré que la complexité en temps (dans le pire des cas) de l’algo-
rithme PPPP est O(n log n)+T (n) = O(n log n), soit bien mieux que l’approche exhaustive.
complexité temps
n 1 seconde
n log2 n 30 secondes
n2 30 années
Si dans les années 70, alors qu’on pensait que n2 était la meilleure complexité et que
les ordinateurs étaient bien moins efficaces (les horloges étaient cadencées au mieux 16
à 1 MHz, c’est 1 000 fois moins qu’aujourd’hui), on avait demandé aux chercheurs en
informatique si un jour on pourrait traiter un problème d’1 milliard de points, ils au-
raient sans doute dit « non ». On parle ici de 30 000 ans à supposer que le problème
puisse tenir en mémoire centrale, ce qui n’était pas possible à l’époque.
C’est donc les avancées algorithmiques qui permettent les plus grandes progres-
sions, puisqu’à puissance de calcul égale on passe de 30 ans 17 à 30 secondes simplement
en concevant un meilleur algorithme.
15. Pensez qu’un cube de données volumiques de simplement 1 000 points de cotés fait déjà un milliard
de points (ou voxels).
16. La fréquence des microprocesseurs était de 740 KHz pour l’Intel 4004 en 1971. Il faudra attendre
1999 pour atteindre 1 GHz avec l’Athlon, voir https://fr.wikipedia.org/wiki/Chronologie_des_
microprocesseurs.
17. En fait c’est même plus de 31 ans.
5.2. TROUVER LA PAIRE DE POINTS LES PLUS PROCHES 149
Parenthèse. Voici quelques détails supplémentaires. On utilise une grille virtuelle Gδ du plan
où chaque case correspond à un carré de coté δ, chacune des cases d’indices (i, j) pouvant
contenir une liste de points. Il s’agit d’une table de hachage 18 où en temps constant il est
possible d’avoir accès à la liste associée à la case (i, j), afin de la lire ou d’y ajouter un point.
Donc Gδ [(i, j)] est une simple liste de points appartenant au carré [iδ, iδ + δ[×[jδ, jδ + δ[.
L’idée est de remplir cette grille successivement avec les points p1 , . . . , pn .
Initialement δ = dist(p1 , p2 ). Puis, on prend les points dans l’ordre p1 , p2 , . . . . À chaque
point pt = (xt , yt ) on calcule l’indice (i, j) de la case de Gδ où tombe pt . Il s’agit de la case
(i, j) = (bxt /δc , yt /δ ). Ensuite on calcule la distance minimum dt entre pt et tous les points
des listes de la case (i, j) et de ses 8 voisines (i ± 1, j ± 1). On pose dt = +∞ si ces 9 cases
sont vides. Si dt > δ, on ajoute simplement pt à la liste Gδ [(i, j)] et on continue avec le
point suivant pt+1 . Si on réussit à ajouter le dernier point pn à une liste de Gδ , la distance
cherchée est δ. Si dt < δ, alors on efface la grille Gδ et on recommence le remplissage des
points p1 , p2 , . . . dans une nouvelle grille Gδ0 de paramètre δ0 = dt .
Pour montrer que la complexité est O(n) en moyenne, en supposant que les points sont
ordonnés aléatoirement, il faut remarquer :
(1) Si pt est à distance < δ d’un des points précédents cela ne peut être qu’un point des 9
cases centrées en (i, j). En effet la distance entre pt et tout point de toute autre case est
> δ.
(2) Comme observé précédemment page 144, chacun des 4 sous-carrés de coté δ/2 d’une
case de Gδ ne peut contenir qu’un seul point. Par conséquent, le calcul de dt prend un
temps constant après avoir extrait les 9 listes d’au plus 4 points chacune.
(3) La diminution de δ, qui entraine un redémarrage du remplissage à partir de p1 , se
produit sur des points d’indices tous différents (en fait strictement croissants).
Ainsi, si le redémarrage se produit pour pt , alors le coût sera proportionnel à t. Plus géné-
ralement, si cela se produit pour chaque pt avec une certaine probabilité, disons ρ(t), alors
le coût total de l’algorithme sera proportionnel à nt=1 t · ρ(t).
P
Pour montrer que ce coût est en O(n), il suffit donc de montrer que ρ(t) = O(1/t). La
probabilité ρ(t) recherchée est celle de l’évènement où pt se trouve être l’une des extrémités de
la paire de points la plus proche parmi les t premiers points p1 , . . . , pt . Ces t points forment
18. C’est l’implémentation d’une telle table de hachage en temps constant qui est complexe. On ne peut
pas utiliser un simple tableau à deux dimensions car le nombre de cases (i, j) peut atteindre n2 suivant la
densité des points.
150 CHAPITRE 5. DIVISER POUR RÉGNER
t
2 = t · (t − 1)/2 paires de points. L’une des extrémités de la paire la plus proche étant
pt , cela laisse t − 1 possibilités pour l’autre extrémité, disons ps avec s ∈ [1, t[. À cause de
la permutation aléatoire initiale des n points, chacune des possibilités pour ps se produit
uniformément. Ainsi la probabilité qu’il existe ps avec s ∈ [1, t[ telle que (ps , pt ) soit la paire
de points la plus proche parmi p1 , . . . , pt est donc (t − 1)/ 2t = 2/t = ρ(t), ce qui termine
l’analyse de la complexité.
Avec une approche légèrement différente, le problème de la paire de points les plus
proches peut être résolu assez simplement en temps moyen O(n log n) si (P , dist) est un
espace métrique de dimension doublante bornée. La dimension doublante est le plus petit
réel λ tel que toute boule de rayon r dans P peut être couverte par, c’est-à-dire contenue
dans l’union de, au plus 2λ boules de rayon r/2. Il est facile de voir que λ 6 log2 |P |. Cette
notion généralise la notion de dimension classique de l’espace euclidien de dimension d qui
a dimension doublante λ = O(d) (voir la figure 5.4 pour d = 2). L’idée est trouver un petit
Figure 5.4 – Tout disque peut être entièrement recouvert par 7 disques de
rayon deux fois moindre. La dimension doublante du plan euclidien est donc
λ = log2 7 ≈ 2.807.
anneau séparateur S, c’est-à-dire un ensemble de o(n) points qui est la différence entre deux
boules de même centre. Il délimite un intérieur A et un extérieur B chacun avec au plus n/2
points. Cette étape coûte O(n) en moyenne. Puis récursivement on calcule la distance dans
A ∪ S et B ∪ S en on prend la valeur minimum trouvée. Voir [MMS20][p. 14] pour plus de
détails.
Parenthèse. Deux points a et b sont antipodaux s’il y a deux droites d’appui parallèles du
polygone qui passent respectivement par a et b, cf. la figure 5.5. Le nombre total de paires
de sommets antipodaux ne peut pas excéder 3n0 /2, où n0 est le nombre de sommets de l’en-
veloppe convexe. On peut décrire de manière imagée comment énumérer toutes ces paires :
on trouve une première paire de points antipodaux en « posant le polygone sur une droite
horizontale » et en cherchant le sommet le plus haut. Ensuite on trouve les autres paires
(en tournant toujours dans le même sens) en « faisant rouler le polygone » sur la droite ho-
rizontale. On peut obtenir de la sorte un algorithme déterminant le diamètre du polygone
convexe en O(n0 ).
Des algorithmes efficaces en pratiques pour le calcul du diamètre en dimension supé-
rieure peuvent être trouvés dans [MB02].
meilleur possible. Kolmogorov a voulu rendre homage ainsi à Karatsuba pour avoir
résolu le problème du célèbre chercheur.
1100
12
× 1101
× 13
1100
36
1100 . .
+ 12 .
+ 1100 . . .
156
10011100
L’algorithme est évidemment le même quelle que soit la base, un entier qu’on notera
B et qu’on supposera > 2. Pour B = 2 l’algorithme se résume en fait à recopier ou décaler
le premier opérande suivante que l’on a à multiplication par chiffre 1 ou 0. Lorsqu’on
5.3. MULTIPLICATION RAPIDE 153
Complexité. Supposons que les nombres x, y ont n chiffres chacun. L’algorithme ef-
fectue n fois (pour chaque chiffre yi de y), une multiplication d’un nombre de taille n
par un seul chiffre, puis, après un décalage, d’une addition à la somme courante qui
comporte au plus 2n chiffres. La multiplication par un chiffre, le décalage et l’addition
prennent en tout un temps O(n) par chiffre yi . Au total l’algorithme est donc en O(n2 ).
On a l’impression, tout comme Kolmogorov, que chaque chiffre de x doit « rencon-
trer » chaque chiffre de y, ce qui nécessite Ω(n2 ) opérations. Et pourtant...
plus significatifs. Puis on fait une multiplication par blocs. Par exemple, si x = 1 234 et
y = 9 876 alors on découpe x = 12 34 et y = 98 76 . Puis on fait une multiplication
standard sur 2 chiffres en base B = 100.
12 34
× 98 76
09 37 84 ← 1234 × 76
+ 12 09 32 .. ← 1234 × 98
12 18 69 84
x = x+ x− = x+ · Bm + x−
x×y = x+ x− × y + y − = (x+ · Bm + x− ) × (y + · Bm + y − )
= (x+ × y + ) · B2m + (x+ × y − + x− × y + ) · Bm + x− × y − .
1. Si n = 1, renvoyer 19 x0 · y0
2. Soit m = dn/2e. Poser :
x+ = (xn−1 , . . . , xm ) et x− = (xm−1 , . . . , x0 )
y + = (yn−1 , . . . , ym ) et y − = (ym−1 , . . . , y0 )
3. Calculer :
p1 = Mulrec (x+ , y + )
p2 = Mulrec (x+ , y − )
p3 = Mulrec (x− , y + )
p4 = Mulrec (x− , y − )
a = p2 + p3
4. Renvoyer p1 · B2m + a · Bm + p4
avec T (1) = O(1). Cela ressemble beaucoup à l’équation (5.1) déjà rencontrée qui était
T (n) = 2 · T (dn/2e) + O(n). Malheureusement, on ne peut pas se resservir de la solution,
19. Si l’on devait exécuter l’algorithme à la main, le cas terminal consisterait simplement à lire le résul-
tat dans une table de multiplication, qu’il faut malheureusement apprendre par cœur.
156 CHAPITRE 5. DIVISER POUR RÉGNER
qui était T (n) = O(n log n), à cause du « 4 » qui change tout. Il faut donc recommencer
l’analyse. On verra plus tard, qu’il y a un truc pour éviter de faire les calculs ...
Comme expliqué précédemment, il ne faut pas utiliser la notation asymptotique
pour résoudre une récurrence. On a donc, pour une constante c > 0 suffisamment
grande, les inéquations suivantes :
(
4 · T (dn/2e) + cn si n > 1
T (n) 6
c si n 6 1
En négligeant la partie entière (cf. la section 5.4) et en dépliant la formule de récurrence,
il vient :
T (n) 6 4 · T (n/2) + cn
6 4 · [ 4 · T ((n/2)/2) + c · (n/2) ] + cn
6 42 · T (n/22 ) + (4/2) · cn + cn
6 42 · [4 · T ((n/22 )/2) + c · (n/22 )] + ((4/2) + 1) · cn
6 43 · T (n/23 ) + ((4/2)2 + (4/2) + 1) · cn
...
i−1
X
i i
6 4 · T (n/2 ) + (4/2)j · cn ∀i > 0
j=0
Ce qui est identique aux l’inéquations (5.2)Pi−1en remplaçant le facteur « 2 » par le facteur
j
« 4 » devant T (). Notons que le terme j=0 (2/2) = 1 + · · · + 1 = i. En fait, de manière
générale, si T (n) 6 a · T (n/b) + cn pour certains a, b > 0, alors :
i−1
X
i i
T (n) 6 a · T (n/b ) + (a/b)j · cn ∀i > 0 (5.4)
j=0
Le résultat est décevant, car on ne fait pas mieux que la méthode standard. Et en
plus c’est compliqué. Mais seulement en apparence, les appels récursifs ayant tendance
à compacter le code.
5.3.3 Karatsuba
Pour que la méthode diviser pour régner donne de bons résultats, il faut souvent
ruser. Couper naïvement en deux ne fait pas avancer la compréhension du problème,
sauf si l’étape de « fusion » permet un gain significatif.
Pour le tri-fusion par exemple, c’est la fusion en temps O(n) qui est maline. Pour la
paire de points les plus proches c’est le calcul dans la bande S en temps O(n) qui est
rusé. Pour l’algorithme de Karatsuba, l’idée est de faire moins d’appels récursifs quitte
à perdre un temps O(n) avant chaque appel.
Dans l’algorithme Mulrec (x, y) on utilise les quatre produits : p1 = x+ ×y + , p2 = x+ ×y − ,
p3 = x− × y + et p4 = x− × y − . En y regardant de plus près pour l’étape 4 de Mulrec , on a en
fait besoin de p1 , p4 et de a = p2 + p3 .
L’idée est de remarquer que le produit
p = (x+ + x− ) × (y + + y − ) = x+ × y + + x+ × y − + x− × y + + x− × y −
= p1 + p2 + p3 + p4
contient les quatre produits souhaités. C’est en fait la somme. Donc si l’on calcule
d’abord p1 et p4 , il suffit de calculer p pour avoir p2 + p3 = p − (p1 + p4 ). Plus formel-
lement, l’algorithme s’écrit :
158 CHAPITRE 5. DIVISER POUR RÉGNER
Algorithme Karatsuba(x, y)
Entrée: x, y deux entiers naturels de n chiffres écrits en base B.
Sortie: x × y sur 2n chiffres.
1. Si n = 1, renvoyer x0 · y0
2. Soit m = dn/2e. Poser :
x+ = (xn−1 , . . . , xm ) et x− = (xm−1 , . . . , x0 )
y + = (yn−1 , . . . , ym ) et y − = (ym−1 , . . . , y0 )
3. Calculer :
p1 = Karatsuba(x+ , y + )
p4 = Karatsuba(x− , y − )
a1 = x+ + x−
a2 = y − + y +
p = Karatsuba(a1 , a2 )
a = p − (p1 + p4 )
4. Renvoyer p1 · B2m + a · Bm + p4
Complexité. L’effet le plus notable, en comparant les deux algorithmes, est qu’on a
remplacé un appel récursif par deux additions et une soustraction. C’est un petit dé-
tail qui va profondément changer la résolution de la récurrence dans l’analyse de la
complexité.
Comme précédemment, on note T (n) la complexité en temps de l’algorithme ana-
lysé, ici Karatsuba appliqué à des nombres de n chiffres. Encore une fois, toutes les
opérations, sauf les appels récursifs, prennent un temps O(n). Deux appels utilisent des
nombres d’au plus m = dn/2e chiffres (pour p1 et p4 ), cependant le calcul de p = a1 × a2
utilise des nombres de m + 1 = dn/2e + 1 chiffres. En effet, l’addition d’un nombre de
n1 chiffres avec un nombre de n2 chiffres fait a priori max {n1 , n2 } + 1 chiffres [Question.
Pourquoi ?]. Il suit que T (n) vérifie l’équation de récurrence suivante :
pour une constante c > 0 suffisamment grande. En négligeant les constantes additives
dans le paramètre de T () (cf. la section 5.4) et en dépliant la formule de récurrence
comme vue dans (5.4) avec a = 3 et b = 2, il vient directement :
i−1
X
i i
T (n) 6 3 · T (n/2) + cn 6 3 · T (n/2 ) + (3/2)j · cn ∀i > 0 .
j=0
Parenthèse. Il existe des algorithmes encore plus rapides. Ils sont basés sur la transformée
de Fourrier rapide (FFT pour Fast Fourrier Transform), donnant l’algorithme de Schön-
hage–Strassen de complexité O(n log n log log n) [SS71]. On ne le détaillera pas. En fait, on
ne sait toujours pas s’il est possible de multiplier des entiers en temps linéaires. On pense
que cela n’est pas possible, mais le passé montre qu’on s’est parfois trompé. Il est conjecturé
que le meilleur algorithme possible doit avoir une complexité de Ω(n log n). Mais cela n’est
pas prouvé. Il existe une borne inférieure en Ω(n log n) pour la version on-line de la multi-
plication, pour laquelle on impose que le k-ième chiffre du produit soit écrit avant la lecture
20. En effet, pour toute base b > 1, x = blogb x . Donc xlogb y = b(logb x)(logb y) = b(logb y)(logb x) = y logb x .
160 CHAPITRE 5. DIVISER POUR RÉGNER
du (k + 1)-ième chiffre des opérandes [PFM74][vdH14]. Bien sûr, il n’y a aucune raison que
le meilleur des algorithmes procède ainsi.
L’algorithme le plus rapide, due à [HvdH19] en 2019, a une complexité de O(n log n). Le
∗
précédent record était celui de [Für09] avec une complexité de (n log n)·2O(log n) où log∗ n =
min{i > 0 : log(i) n} est une fonction qui croît extrêmement lentement. Plus formellement,
log∗ n = min{i > 0 : log(i) n} avec log(i) n = log(log(i−1) n) et log(0) n = n est l’itéré de la
fonction log. En pratique log∗ n 6 5 pour tout n inférieur au nombre de particules dans
l’Univers.
où a > 1, b > 1, c > 0 sont des constantes et T (n) et f (n) sont des fonctions croissantes.
La constante a correspond aux nombres d’appels (ou branchements) récursifs, b est le
nombre par lequel on divise le problème initial, et f (n) le temps de fusion des solutions
partielles. Enfin, c permet de gérer les parties entières supérieures ou inférieures. En
fait il existe un théorème qui donne la forme générale de la solution, plus exactement
l’asymptotique.
Théorème 5.1 (Master Theorem) Pour toute fonction entière T (n) vérifiant l’équa-
tion (5.6) avec λ = logb a, alors :
1. Si f (n) = O(nλ−ε ) pour une constante ε > 0, alors T (n) = Θ(nλ ).
2. Si f (n) = Θ(nλ ), alors T (n) = Θ(nλ log n).
3. Si f (n) = Ω(nλ+ε ) pour une constante ε > 0 et si a · f (n/b + c) 6 q · f (n) pour une
constante q < 1, alors T (n) = Θ(f (n)).
Cela a l’air compliqué, mais on peut décrypter simplement le résultat comme suit.
Comme on va le voir, l’exposant λ = logb a est une valeur critique dans l’asymptotique
de T (n). Il y a trois cas, et dans chacun d’eux on compare f (n) à nλ .
Cas 1 : Si f (n) est plus petite que nλ , alors c’est nλ qui « gagne », c’est-à-dire qui contri-
bue le plus à l’asymptotique de T (n).
Cas 3 : Si f (n) est plus grande que nλ , alors c’est f (n) qui contribue le plus à l’asympto-
tique de T (n), moyennant une certaine condition sur la croissance de f .
5.4. MASTER THEOREM 161
Cas 2 : Si f (n) est de l’ordre de nλ , alors f (n) (ou bien nλ ) contribue Θ(log n) fois à T (n),
d’où le facteur supplémentaire en log n.
f (n) nλ ≈ nλ nλ
T (n) nλ nλ log n f (n)
Une autre récurrence qu’on rencontre souvent, typiquement lors d’une recherche
dichotomique, est la suivante :
5.4.2 Explications
L’intuition derrière l’exposant λ = logb a est la suivante. Lorsqu’on « déroule » i fois
la récurrence de T (n), il vient un terme en ai · T (n/bi ), en négligeant la constante c dans
T (n/b + c). Les appels récursifs de l’algorithme, et donc la récurrence, s’arrêtent lorsque
n/bi devient constant, c’est-à-dire lorsque cette valeur est suffisamment petite. Et dans
ce cas T (n/bi ) est aussi constant, car alors l’algorithme travaille sur un problème de taille
constante. Dans un premier temps, et pour simplifier, disons que T (1) 6 1. Calculons
162 CHAPITRE 5. DIVISER POUR RÉGNER
le nombre minimum d’étapes i0 pour avoir n/bi0 6 1. D’après la proposition 1.1 (voir le
paragraphe 1.6), on sait que i0 = logb n . En effet,
On retrouve le dernier terme en ai0 · f (1) = O(ai0 ) car f (1) = O(1), qui on l’a vu vaut
Θ(nλ ). Le nombre de termes de la somme est i0 + 1 = logb n + 1 = Θ(log n). Suivant la
croissance de f (n), la somme peut valoir Θ(nλ ), Θ(nλ log n) ou encore Θ(f (n)).
Si f (n) est assez petit, alors on aura Θ(nλ ) à cause du dernier terme qui vaut Θ(nλ ).
Si f (n) est juste autour de nλ alors on va avoir près le i0 = Θ(log n) termes de l’ordre de
nλ .
Dans le cas 3, en itérant la condition a · f (n/b) 6 q · f (n) avec q < 1, et en supposant
toujours que c = 0, on peut alors majorer :
a · f (n/b) 6 q · f (n)
⇒ a · f ((n/b)/b) 6 q · f (n/b)
⇒ a2 · f (n/b2 ) 6 q · a · f (n/b) 6 q2 · f (n)
⇒ ai · f (n/bi ) 6 qi · f (n) .
Donc sous cette condition, la somme (5.7) se majore par (rappelons que q < 1 est une
constante 21 ) :
i0
+∞
X X 1
ai · f (n/bi ) < qi · f (n) = · f (n) = O(f (n)) .
1−q
i=0 i=0
P+∞
21. La formule qu’on utilise pour i=0 qi est la limite pour n → +∞ de la formule (1.4) déjà vue :
Pn i n+1 − 1)/(q − 1). Comme q < 1, qn+1 → 0, et on retrouve la limite 1/(q − 1).
i=0 q = (q
5.4. MASTER THEOREM 163
La condition a · f (n/b) 6 q · f (n) est technique mais pas restrictive en pratique. Par
exemple, si f (n) = np pour un certain exposant p > λ assez grand, disons p = 2λ =
2 logb a. Alors la condition devient (en observant que blogb a = a) :
p
n a a 1
a · f (n/b) = a · = p · np = 2 log a · np = · f (n)
b b b b a
ce qui donne bien q < 1 dès que a > 1.
La remarque importante est que les termes supplémentaires cb/(b − 1) et c k 1/bk sont
P
constants car b est une constante > 1. Intuitivement, la différence avec le cas c = 0 sera
donc a priori minime.
En prenant, comme précédemment, i0 = logb n , on a n/bi0 6 1 et mais aussi que
n/bi0 + cb/(b − 1) 6 1 + cb/(b − 1) = O(1) car c > 0 et b > 1 sont des constantes. Du coup la
première partie devient
lOn
√ m
peut évidemment imaginer des tas d’autres récurrences, comme T (n) =
T ( 2 n ) + f (n) ou encore T (n) = a1 · T (n/b1 ) + a2 · T (n/b2 ) + f (n). Il n’y a alors plus forcé-
ment de formule asymptotique simple. On rencontre même parfois des récurrences du
type T (n) = T (T (n/2)) + 1.
Bien évidemment il y a autant de récurrences que de programmes récursifs pos-
sibles.
5.5.1 Motivation
On s’en sert pour implémenter efficacement certains algorithmes de tri. Pour com-
mencer, voici quelques algorithmes naïfs pour trier un tableau de n éléments. On va
supposer qu’on trie par ordre croissant et par comparaisons – à l’aide d’une fonction
donnée de comparaison, un peu la fonction f() passée en paramètre à qsort(), et on
compte le nombre d’appels à cette fonction f(). Par exemple on souhaite trier des
nombres réels, des chaînes de caractères, des entrées d’une base de données (combi-
naisons d’attributs comme sexe-age-nom). On exclue donc les tris par comptages, très
efficaces selon le contexte, comme par exemple le tri de copies selon une notes entière
de [0, 20].
Parenthèse. C’est quoi un algorithme naïf ? En général, c’est un algorithme dont le principe
est élémentaire, et pour lequel il est très simple de se convaincre qu’il marche. On donne
donc priorité à la simplicité (simple à coder ou à exécuter à la main ou simple à montrer
qu’il marche) plutôt qu’à l’efficacité. Il peut arriver qu’un algorithme naïf soit également
efficace. Malheureusement, la règle générale est que pour être efficace il vaut mieux utiliser
la « ruse ».
(1) « Tant qu’il existe deux éléments mal rangés on les échange. »
Il est clair qu’à la fin le tableau est trié, mais cela n’est pas très efficace. Il faut
trouver une telle paire mal ordonnée et faire beaucoup d’échanges.
Trouver une paire peut nécessiter n2 = Θ(n2 ) opérations si l’on passe en revue
toutes les paires. Bien sûr on peut être un peu plus malin, en raffinant algorithme
naïf, en observant que s’il existe une paire d’éléments mal rangés, alors il en existe
une où les éléments sont consécutifs dans le tableau ce qui prend un temps O(n)
et pas O(n2 ) par échanges.
5.5. CALCUL DU MÉDIAN 165
Ces algorithmes naïf (ou naturels) ont la propriété de trier « en place ». Les éléments
sont triés en effectuant des déplacements dans le tableau lui-même, sans l’aide d’un
tableau auxiliaire. C’est une propriété clairement souhaitable si l’on pense que les al-
gorithmes de tri sont utilisés pour trier des bases de données (très grand fichier Excel)
selon certains attributs (colonnes). Pour des raisons évidente de place mémoire, on ne
souhaite pas (et souvent on ne peut pas), faire une copie de la base de données juste
pour réordonner les entrées, même si on supprime après la copie. Notons que les tris
par comptage utilisent un espace mémoire auxiliaire (pour le comptage justement) qui
dépend de l’intervalles des valeurs possibles (potentiellement grand donc).
En ce qui concerne les algorithmes efficaces, on pense à celui issu de la méthode
« diviser pour régner », le tri-fusion évoqué en début de chapitre. On coupe en deux
tableaux de même taille que l’on trie récursivement, puis on les fusionne. La récurrence
sur la complexité est T (n) = 2 · T (n/2) + O(n) ce qui donne O(n log n). Mais l’algorithme
nécessite un tableau auxiliaire.
Il y aussi le tri-rapide (qsort) : on choisit un élément particulier, le pivot, et on dé-
place les éléments avant ou après le pivot selon qu’ils sont plus petits ou plus grands
que le pivot. Ce déplacement peut se faire « en place » en temps O(n). Puis on récurse
sur les deux tableaux de part et autre du pivot. En pratique il est efficace (avec un choix
du pivot aléatoire) mais sa complexité dans le pire des cas est en O(n2 ) car le pivot
pourrait ne pas couper en deux tableaux de taille proche, mais en un tableau de taille
1 et en un tableau de taille n − 2 par exemple. La récurrence sur la complexité est alors
T (n) = T (n − 2) + O(n) ce qui fait malheureusement du O(n2 ). L’idéal serait de prendre
comme pivot le médian car il a la propriété de couper précisément en deux tableaux
de même taille. La récurrence devient alors celle du tri-fusion, soit une complexité de
O(n log n)... à condition de trouver rapidement le médian. L’équation de récurrence nous
informe qu’on peut, sans changer la complexité finale, se permettre de dépenser un
temps O(n) pour le trouver. D’où l’intérêt du problème.
166 BIBLIOGRAPHIE
Parenthèse. Il existe d’autres algorithmes de tri en place et qui ont une complexité en
temps O(n log n). Ils sont généralement plus complexes à implémenter et ne donnent pas
de meilleures performances en pratique que le tri-rapide. Citons par exemple, l’implémen-
tation rusée du tri-par-tas qui construit le tas dans le tableau lui-même. (L’étape de rem-
plissage peut même être faite en temps linéaire !) On peut trouver quelques détails sur ce tri
page 139.
5.5.3 Médian
[Cyril. À finir.]
5.6 Morale
• La technique « diviser pour régner » permet de construire des algorithmes aux-
quels on ne pense pas forcément de prime abord.
• Par rapport à une approche naïve (souvent itérative), ces algorithmes ne sont pas
forcément meilleurs. Leur complexité peut être aussi mauvaise.
• Pour obtenir un gain, il est nécessaire d’avoir recours à une « astuce » de calcul
permettant de combiner efficacement les solutions partielles (fusion). Idéalement
la fusion devrait être de complexité inférieure à la complexité globale recherchée.
• La complexité T (n) suit, de manière inhérente, une équation récursive qu’il faut
résoudre (asymptotiquement). Dans de nombreux cas elle est de la forme T (n) =
a·T (n/b)+f (n) pour un algorithme qui ferait a appels récursifs (ou branchements)
sur des sous-problèmes de tailles n/b, avec un temps de fusion f (n) des a sous-
problèmes.
• Des résultats généraux permettent d’éviter de résoudre les équations de récur-
rences en se passant aussi des problèmes de partie entière. Il s’agit du Master
Theorem.
Bibliographie
[Ben80] J. L. Bentley, Multidimensional divide-and-conquer, Communications of the
ACM, 23 (1980), pp. 214–229. doi : 10.1145/358841.358850.
BIBLIOGRAPHIE 167