100% ont trouvé ce document utile (1 vote)
238 vues175 pages

0765 Techniques Algorithmiques Et Programmation

Transféré par

Yousri Boughamoura
Copyright
© © All Rights Reserved
Nous prenons très au sérieux les droits relatifs au contenu. Si vous pensez qu’il s’agit de votre contenu, signalez une atteinte au droit d’auteur ici.
Formats disponibles
Téléchargez aux formats PDF, TXT ou lisez en ligne sur Scribd
100% ont trouvé ce document utile (1 vote)
238 vues175 pages

0765 Techniques Algorithmiques Et Programmation

Transféré par

Yousri Boughamoura
Copyright
© © All Rights Reserved
Nous prenons très au sérieux les droits relatifs au contenu. Si vous pensez qu’il s’agit de votre contenu, signalez une atteinte au droit d’auteur ici.
Formats disponibles
Téléchargez aux formats PDF, TXT ou lisez en ligne sur Scribd
Vous êtes sur la page 1/ 175

Techniques Algorithmiques et

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

Licence 3 : Techniques Algorithmiques et Programmation

Objectifs : Introduire, aux travers d’exemple de problèmes simples, diverses ap-


proches algorithmiques, les programmer et les tester sur machines. Les approches abor-
dées sont :
• Formule close ;
• Exhaustive (Brute-Force) ;
• Récursive ;
• Programmation dynamique ;
• Heuristique ;
• Approximation ;
• Gloutonne (Greedy) ;
• Diviser pour régner (Divide-and-Conquer).
Faute de temps, les approches suivantes ne seront pas abordées :
• Probabiliste ;
• Programmation linéaire ;
• Branchement et élagage (Branch-and-Bound) ;
• Solveur SAT.
Nous programmerons en C avec un tout petit peu d’OpenGL/SDL pour plus de gra-
phismes. Les concepts techniques et les objets que l’on croisera seront : les algorithmes,
la complexité, les graphes, les distances, les points du plan, ...

Pré-requis : langage C, notions algorithmiques, notions de graphes

Quelques ouvrages de référence :

Programmation efficace
Christoph Dürr et Jill-Jênn Vie
Ellipses 2016

The Algorithm Design Manual (2nd edition)


Steven S. Skiena
Springer 2008
iv

Algorithm Design
Robert Kleinberg et Éva Tardos
Pearson Education 2006

Introduction à l’algorithmique (2e édition)


Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest et
Clifford Stein
Dunod 2001

Algorithms (4th edition)


Robert Sedgewick et Kevin Wayne
Addison-Wesley 2011

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 Partition d’un entier 43


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
vi

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

5 Diviser pour régner 137


5.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
5.2 Trouver la paire de points les plus proches . . . . . . . . . . . . . . . . . . 140
5.2.1 Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 140
5.2.2 Principe de l’algorithme . . . . . . . . . . . . . . . . . . . . . . . . 141
5.2.3 L’algorithme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
5.2.4 Complexité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 146
5.2.5 Différences entre n, n log n et n2 . . . . . . . . . . . . . . . . . . . . 148
5.2.6 Plus vite en moyenne . . . . . . . . . . . . . . . . . . . . . . . . . . 149
5.2.7 La paire de points les plus éloignés . . . . . . . . . . . . . . . . . . 150
5.3 Multiplication rapide . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151
5.3.1 L’algorithme standard . . . . . . . . . . . . . . . . . . . . . . . . . 152
5.3.2 Approche diviser pour régner . . . . . . . . . . . . . . . . . . . . . 153
5.3.3 Karatsuba . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
5.4 Master Theorem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160
5.4.1 Exemples d’applications . . . . . . . . . . . . . . . . . . . . . . . . 161
5.4.2 Explications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
5.4.3 D’autres récurrences . . . . . . . . . . . . . . . . . . . . . . . . . . 164
5.5 Calcul du médian . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
5.5.1 Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
5.5.2 Tri-rapide avec choix aléatoire du pivot . . . . . . . . . . . . . . . 166
5.5.3 Médian . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
5.6 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
viii
1
CHAPITRE
Introduction

May the Algorithm’s Force be with you.


— Bernard Chazelle 1

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

Mots clés et notions abordées dans ce chapitre :

• 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 + - * / ^ ! ( )

Figure 1.1 – Capture d’écran de l’application Tchisla.

L’objectif est de trouver l’expression comportant √ le moins de fois le chiffre c, et on


note fc (n) cette valeur. Par exemple, 10 = 4 + 4 + 4 ce qui fait que f4 (10) 6 3. En fait
on ne peut pas faire mieux,√ si bien que f4 (10) = 3. On en déduit alors par exemple que
11 = 10 + 1 = 4 + 4 + 4 + 4/4 et donc f4 (11) 6 5. Cependant, 11 = 44/4 ce qui est
optimal 2 , et donc f4 (11) = 3. La figure 1.1 montre que
q
11
2016 = (1 + 1) − (1 + 1)11−1
et on ne peut pas faire mieux si bien que f1 (2016) = 9, mais aussi elle montre les pre-
mières valeurs de fc (n) pour n = 1...10 (lignes) et c = 1...9 (colonnes).

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

11 11! 11!! 11!!! 11!!!! ...

il y a qu’en même une infinité de valeurs de n pour lesquelles f1 (n) = 2.


10

1
0 20 40 60 80 100 120 140 160 180 200

Figure 1.2 – Les 200 premières valeurs pour f1 (n).

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.

Il s’agit d’une formule arithmétique comportant un nombre fini d’opérations


arithmétiques liée aux paramètres (ici c et n). Une somme ou un produit infini
ne constitue pas une 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

Pour les calculer, on a recours à d’autres techniques, comme l’approximation et le calcul


numérique. Dans pas mal de cas on peut obtenir un nombre de chiffres significatifs
aussi grand que l’on veut. Mais le temps de l’algorithme de résolution s’allonge avec
le nombre de chiffres souhaités, c’est-à-dire avec la précision, ce qui n’est pas le cas
lorsqu’on dispose d’une formule directe.
Bien sûr, pour certaines équations polynomiales on peut exprimer les racines de ma-
nière exacte comme x6 = 2. Dans ce cas il y a 21/6 comme solution mais pas seulement 4 .

Un algorithme ? S’il n’y a pas de formule close pour le calcul de fc (n), on peut alors
rechercher un algorithme.

C’est un procédé automatique et systématique de calcul (une recette) donnant une


solution à chaque entrée d’un problème donné.

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.

Malheureusement, trouver un algorithme pour le problème Tchisla, et donc pour


le calcul de fc (n), n’est pas si évident que cela. Et parfois la situation est plus grave que
prévue. Pour certains problèmes, il n’y a ni formule ni algorithme !

On parle de problème indécidable — il serait plus juste de dire incalculable — lors-


qu’il n’y a pas d’algorithme permettant de le résoudre.

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

1.2 Des problèmes indécidables


Une équation diophantienne est une équation à coefficients entiers dont on s’intéresse
aux solutions entières (si elles existent), comme par exemple celle-ci :

(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.

Figure 1.3 – Édition de 1670 de l’ouvrage de Diophante. Le texte reprend la


note de 1621 (traduite ici en latin) de Pierre Fermat concernant le problème
II.VIII à propos de l’équation diophantienne xp + y p = zp : « Au contraire, il est
impossible de partager soit un cube en deux cubes, soit un bicarré en deux bicarrés,
soit en général une puissance quelconque supérieure au carré en deux puissances
de même degré : j’en ai découvert une démonstration véritablement merveilleuse
que cette marge est trop étroite pour contenir. » Source Wikipédia.

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

pour les systèmes d’équations diophantiennes polynomiales 6 comme


 2
c
z


 a = x2 + y 2
2
= y 2 + z2

b

x 

d y b
c2 = z2 + x2






a  d2 = x2 + y 2 + z2

On ne sait pas si ce système, qui exprime les contraintes de longueurs et de diagonales


d’une brique 7 , possède ou non de solutions entières non nulles.
En fait, c’est même pire pour les systèmes. Le problème de savoir si un système
d’équations diophantiennes polynomiales avec au plus 11 variables possède ou non une
solution (entière) est indécidable. Remarquons qu’on peut toujours se ramener à une
seule équation diophantienne polynomiale, qui donnerait pour le système précédent :

(a2 − x2 − y 2 )2 + (b2 − y 2 − z2 )2 + (c2 − z2 − x2 )2 + (d 2 − x2 − y 2 − z2 )2 = 0 .

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

Le point commun de ces problèmes indécidables, et ce qui les rend si difficiles à


résoudre, c’est qu’on arrive pas à dire si tous les cas ont été examinés et donc à dire s’il
finira par s’arrêter sur la bonne décision. On peut parfaitement lister les suites d’entiers
(x, y, z, p) avec p > 2 ou simuler l’exécution du programme f (x). Si l’équation (1.2) est
satisfaite ou si le programme s’arrête, on pourra décider car on va s’en apercevoir. Mais
sinon, comment décider ? Faut-il continuer la recherche ou bien s’arrêter et répondre
qu’il y a pas de solution ou que le programme boucle ? En fait, on a la réponse pour
l’équation (1.2) grâce à un théorème (Andrew Wiles). Mais on a pas de théorème pour
chaque équation diophantienne possible !
Mais comment montrer qu’un problème n’a pas d’algorithme ? Supposons qu’on dis-
pose d’une fonction halte(f,x) permettant de dire si une fonction f écrit en C du type
void f(int x) s’arrête sur l’entier x (=true) ou boucle pour toujours (=false). La fonc-
tion halte(), aussi compliquée qu’elle soit, implémente donc un certain algorithme
censé être correct qui termine toujours sur la bonne réponse. Son prototype serait :
bool halte(void (*f)(int),int).
Considérons le programme loop() ci-dessous faisant appel à la fonction halte() :

bool halte(void (*f)(int),int); // fonction définie quelque part

void loop(int x){


if(halte(loop,x)) for(;;); // ici loop(x) va boucler
return; // ici loop(x) se termine
}

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 :

int K(int); // fonction dont le code est défini quelque part

void P(int n){


int i=0;
while(K(i)<n) i++;
print("%d",i); // ici K(i)> 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).

1.3 Approche exhaustive


On n’a pas formellement montré que le calcul de fc (n) ne possède pas de formule
close, ni que le problème Tchisla est indécidable. Pour être honnête, la question n’est
pas tranchée, même si je pense qu’un algorithme existe. Une des difficultés est que
la taille de l’expression arithmétique qu’il faut trouver pour atteindre fc (n) n’est pas
bornée en fonction de fc (n). Par exemple,

n = 3!!!!

est un nombre gigantesque de 62 chiffres et pourtant f3 (n) = 1. Et on pourrait ajouter


un nombre arbitraire d’opérateurs unaires de la sorte. Et que dire du nombre suivant ?

n = 3!!!!!/33!!!!

Se pose aussi le problème de l’évaluation. On pourrait être amener à produire des


nombres intermédiaires de tailles titanesques, impossibles à calculer alors que n n’est
pas si grand que cela. Il n’est même pas clair que l’arithmétique entière suffise comme
√√√
n= ( 3)^(3!!/3!)) = 14 348 907

où les calculs intermédiaires pourraient ne pas être entiers ni même rationnels...


On va donc considérer une variante du problème plus simple à étudier.
Tchisla2
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 symboles possibles.

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,

24 = (1+1+1+1)! #1=4 longueur=10


= 11+11+1+1 #1=6 longueur=9

Cependant, pour résoudre Tchisla2, on peut maintenant appliquer l’algorithme dont


le principe est le suivant :

Principe. On liste toutes les expressions par taille croissante, et on s’arrête


à la première expression valide dont la valeur est égale à n.

Comme on balaye les expressions de manière exhaustive et par taille croissante, la


première que l’on trouve sera nécessairement la plus petite. Cet algorithme ne marche
évidemment que pour la version Tchisla2. Car comme on l’a déjà remarqué, pour la
version originale, on ne sait pas à partir de quelle taille s’arrêter.

Cette approche, qui s’appelle « recherche exhaustive » ou « algorithme brute-force »


en Anglais, est en fait très générale. Elle consiste à essayer tous les résultats pos-
sibles, c’est-à-dire à lister tous les résultats envisageables et à vérifier à chaque fois
si c’est une solution ou non. En quelques sortes on essaye de deviner la solution et
on vérifie qu’elle est bien valide.

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).

Figure 1.4 – Le rectangle représente l’ensemble des mots de taille 6 2n + 3,


ensemble qui contient forcément la solution. Parmi ces mots il y a les expres-
sions valides (zone grisée). Parmi cette zone, celles qui s’évaluent à n (disques
colorés). Et enfin, parmi ces disques ceux de plus petite taille (en rouge). Il
peut y en avoir plusieurs.

On va coder une expression de taille k par un tableau de k entiers avec le codage


suivant pour chacun des dix symboles :

c + - * / ^ ! ( )
0 1 2 3 4 5 6 7 8 9

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

Générer tous les tableaux de k chiffres revient à maintenir un compteur. Et pour


passer au suivant, il suffit d’incrémenter le compteur.

T[] expression validité


··· ··· ···
8010940 (c+c)/c 3
8010941 (c+c)/+ 7
8010942 (c+c)/- 7
··· ··· ···
8010949 (c+c)/) 7
8010950 (c+c)^c 3
8010951 (c+c)^+ 7
··· ··· ···

Pour rappel, l’algorithme d’incrémentation (c’est-à-dire qui ajoute un à un compteur),


peut être résumé ainsi :
Principe. Au départ on se positionne sur le dernier chiffre, celui des unités.
On essaye d’incrémenter le chiffre sous la position courante. S’il n’y a pas
de retenue on s’arrête. Sinon, le chiffre courant passe à 0 et on recommence
avec le chiffre précédent.
Pour pouvoir donner une implémentation concrète, on supposera déjà programmées
les deux fonctions suivantes qui s’appliquent à un compteur T de k chiffres décimaux :
• bool Next(int T[],int k) qui incrémente le compteur puis renvoie true si tout
c’est bien passé ou bien false lorsque le compteur a dépassé sa capacité maximum,
c’est-à-dire qu’il est revenu à 0...0. Ainsi, si T[]={9,9,9}, alors Next(T,3)==false
avec en retour T[]={0,0,0}. Cette fonction s’implémente facilement, comme expli-
qué précédemment. Il s’agit d’un simple parcours du tableau T, et donc sa com-
plexité est 15 O(k).
• int Eval(int T[],int k,int c) qui renvoie la valeur n de l’expression T de taille k
dans laquelle le code 0 correspond au chiffre c. Si l’expression n’est pas valide ou
si n 6 0 on renvoie 0, en se rappelant que le résultat est censé vérifier n > 0. L’éva-
luation d’une expression valide se fait par un simple parcours de T en utilisant
une pile (cf. cours/td d’algorithmique de 1ère et 2e année). Il est facile de le mo-
difier de sorte que pendant l’évaluation on renvoie 0 dès qu’une erreur se produit
traduisant une expression non valide (comme un mauvais parenthésage lors d’un
dépilement, des opérandes ou opérateurs incorrects lors de l’empilement, etc.). Sa
complexité est O(k).
15. La complexité est en fait constant en moyenne car l’incrémentation du i-ème chiffre de T se produit
seulement toutes les 10i incrémentations. Donc sur le total des 10k incrémentations, 10k nécessitent le
changement du chiffre numéro 0 (le dernier) ; 10k−1 nécessitent le changement du chiffre 1 ; 10k−2 néces-
sitent le changement du chiffre 2 ; ... soit un total de changements de k−1 k−i = Pk 10i < 10k+1 /9. En
P
i=0 10 i=1
moyenne, cela fait donc < (10k+1 /9)/10k = 10/9 < 2 chiffres à modifier.
1.3. APPROCHE EXHAUSTIVE 13

D’où le programme qui résout Tchisla2 :

int tchisla2(int c,int n){


int T[2*n+3]; // n=(c+...+c)/c, soit 2n+3 symboles
for(int k=0;;k++){ // une condition vide est toujours vraie
T[k]=0; // initialisation du dernier chiffre
do if(Eval(T,k,c)==n) return k; // fin si T s’évalue à n
while(Next(T,k)); // passe à l’expression suivante
}
}

La fonction renvoie en fait la longueur k de la plus courte expression. L’expression


elle-même se trouve dans les k premières cases de T. La ligne int T[2*n+3] se justifie
par le fait que n = (c+...+c)/c (la somme ayant n termes) qui est une expression valide
de valeur n de 2n + 3 symboles. On peut donc toujours résoudre le problème par une
expression d’au plus 2n + 3 symboles.
On pourrait se demander si on ne peut pas trouver une expression générale en fonc-
tion de c qui soit plus courte. Cette une question intéressante à part entière abordée
ci-après, et qui n’est pas au programme.

Complexité. La boucle for(;;k++) s’exécutera au plus 2n + 3 fois, puisque comme ex-


pliqué précédemment la plus petite expression valide a au plus 2n + 3 symboles. Et le
nombre de fois qu’on exécute les fonctions Eval() et Next(), qui prenne chacune un
temps O(k), est au plus 10k , soit le nombre d’expressions de taille k = 1, 2, 3, . . . , 2n + 3.
Au final, la complexité est 16 :
2n+3
X  2n+3
X
k · 10 k
< (2n + 3) · 10k = O(n) · 102n .
k=1 k=1

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

18. Notez que 0.3 = 10log10 (0.3) = 10−0.5228... .


19. Cela n’est √pas parfait, car on génère des parenthèses inutilement comme les expressions
(c+c)+(c+c) ou (22).
1.3. APPROCHE EXHAUSTIVE 15

et 1 division. Chacun de ces n opérateurs peut être arbitrairement remplacé par + - * / ^


produisant à chaque fois une expression parfaitement valide. Les n − 1 additions peuvent
être remplacées aussi par le chiffre c, soit six symboles interchangeables. Chacune de ces
expressions valides devra être évaluée a priori car la plus petite peut se trouver parmi elles.
Il y a n − 1 premiers symboles à choisir parmi six et le dernier parmi cinq. Ce qui fait déjà
6n−1 ·5 expressions valides possibles dont au moins une s’évalue à n, sans compter les façons
de mettre des paires de parenthèses, les opérateurs unaires 20 .
On pourrait arguer que beaucoup de ces expressions sont en fait équivalentes à causes
des règles d’associativité et de commutativité. Si l’on pouvait ne générer que celles vrai-
ment différentes, cela en ferait beaucoup moins. Certes, mais en 2015, [San15] a établit que
le nombre d’expressions arithmétiques de valeur n, comprenant les symboles 1 + * ( )
et non équivalentes par application répétée √de l’associativité et de la commutativité, était
asymptotiquement équivalent 21 à 24n/24+O( n ) .
Bref, le nombre d’expressions valides (et différentes) est intrinsèquement exponentiel en
la taille de l’expression. Notez que dès que n > 313, 24n/24 > 1018 ... soit 30 ans de calcul.

Et si tchisla2() était efficace ? Certes le nombre d’expressions valides est inexora-


blement exponentiellement en la taille de expression recherchée, mais rien ne dit que
la recherche exhaustive ne va pas toujours s’arrêter sur une expression de taille très
courte. L’analyse précédente est basée sur le fait que la taille de l’expression la plus
courte ne peut pas dépasser 2n + 3. La complexité est donc au plus exponentielle en
2n + 3. Mais c’est peut-être exponentiel en une longueur beaucoup plus petite ? Si c’est
le cas, l’algorithme exhaustif tchisla2() pourrait finalement se révéler relativement ef-
ficace en pratique. En effet, l’analyse de la complexité ne change pas l’efficacité réelle
du programme.
Essayons de trouver une expression de valeur n de taille la plus courte possible,
indépendamment de c. Notons k(n) cette taille et e(n) une expression de valeur n cor-
respondant à cette taille. La complexité de tchisla2() est donc de l’ordre de 10k(n) .
Bien sûr k(n) 6 2n + 3 avec l’expression e(n)=(c+...+c)/c. Pour tenter d’en trouver
une plus courte, et donc de trouver un meilleur majorant pour k(n), on va décomposer
n en une somme de puissance de deux. L’idée est que dans une telle somme, le nombre
de termes est assez faible ainsi que ses exposants. On va alors pouvoir ré-appliquer
récursivement la construction aux exposants. Voici ce que cela donne pour quelques
puissance de deux :

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

Prenons un exemple d’une telle décomposition. Par exemple n = 5 :

5 = 22 + 20 = ((c+c)/c)^(e(2))+c/c
= ((c+c)/c)^((c+c)/c)+c/c

Pour la taille de l’expression, il vient (il s’agit d’un majorant) :

k(5) 6 k(22 ) + 1 + k(20 )


6 12 + k(2) + 1 + 3
6 16 + k(2) = 16 + 7 = 23

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.

int k(int n){ // il faut n > 0


int i=0,p=1,s=0,t=0; // p=2i , s=taille, t=#termes
for(i=0;p<=n;i++,p*=2)
if(n&p){ // teste le i-ème bit de n
if(i==0) s+=3;
else s+=min(12+k(i),2*p+3); // le meilleur des deux
if(t>0) s++; // ajoute ’+’ s’il y a déjà un terme
t++;
}
return s;
}

La valeur de i lorsque la boucle for(i=...) se termine correspond au nombre de bits


dans l’écriture binaire de n. Notons ce nombre L(n). Dans le paragraphe 1.6, on verra
 
que L(n) = log2 (n + 1) = O(log n).
On peut alors donner un majorant sur la taille k(n), car au pire sont présentes les
1.3. APPROCHE EXHAUSTIVE 17

L(n) puissances de deux, sans oublier les + entre les termes :


L(n)−1
X
k(n) 6 L(n) − 1 + k(2i )
i=0
L(n)−1
X L(n)−1
X
< L(n) + (12 + k(i)) 6 L(n) + (12 + (2i + 3))
i=0 i=0
L(n)−1
X
6 L(n) + 15 · L(n) + 2 i 6 16 · L(n) + (L(n) − 1) · L(n)
i=0
6 L(n)2 + 15 · L(n) = O(log2 n) .
Il s’agit bien sûr d’un majorant grossier, puisqu’on n’utilise ni la récursivité ni le fait
qu’on peut prendre le minimum entre k(2i ) et 2i + 3. Pour n = 63, ce majorant donne
113 alors que 2n + 3 = 129. C’est donc mieux. En utilisant le programme ci-dessus pour
k(63), on obtient 95. On a aussi, en utilisant le programme, que k(n) < 2n + 3 dès que
n > 31.
Nous avons précédemment vu que la complexité de tchisla2() était exponentielle en
la taille k de l’expression recherchée. Bien sûr k 6 k(n). La discussion ci-dessus montre
donc que cette complexité est en fait plus petite, de l’ordre de :
2
10k(n) = 10O(log n)
= nO(log n) .
En fait, l’exposant est plus petit que O(log n). Mais la décomposition en puissances de
deux, et surtout l’analyse ci-dessus, ne permettent pas de conclure que la complexité de
tchisla2() est en fait nO(1) , c’est-à-dire polynomiale.

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 .

Finalement, la fonction tchisla2() a une complexité de l’ordre de

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].

1.4 Rappels sur la complexité


Il est important de bien distinguer deux concepts qui n’ont rien à voir.

• 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

page Wikipédia à propos de la formule de Stirling est remplie de notations asympto-


tiques comme
ln n
ln(n!) = n ln n − n + + O(1) (1.3)
2
alors que ln(n!) n’a a priori rien à voir avec la complexité et les algorithmes. On rappel-
lera les définitions de O, Ω, Θ dans la section 1.5.

La complexité est une mesure qui est appliquée à un algorithme ou un programme


et qui s’exprime en fonction de la taille des entrées ou des paramètres.

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.

La complexité en temps est le nombre d’opérations élémentaires maximum exécutées


par l’algorithme pour toute entrée (donc dans le pire des cas).

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 .

La complexité en espace est le nombre de mots mémoires maximum utilisés durant


l’exécution de l’algorithme pour toute entrée (donc dans le pire des cas).

Il faut en théorie se mettre d’accord sur ce qu’est une opération élémentaire et un


mot mémoire. C’est le modèle de calcul. La plupart du temps un mot mémoire (ou re-
gistre) est une zone consécutive de mémoire comportant un nombre de bits suffisant
pour au moins contenir un pointeur sur les données. Par exemple, si l’entrée d’un pro-
blème est une chaîne binaire S de taille n, alors les entiers i de [0, n[ pouvant représenter
22. La taille est liée au codage de l’entrée (son type), qui est souvent implicite dans la description d’un
problème. Les entiers (int), par exemple, sont toujours supposés être représentés en binaire (et non en
unaire).
23. La réalité est un peu plus compliquée. Par exemple, le temps de lecture d’un mot mémoire peut
dépendre du niveau de cache où il se trouve. Lecture et écriture sont aussi des opérations qui ont un coût
énergétique différent (l’écriture chauffe plus une clé USB que sa lecture), et potentiellement des durées
différentes. Donc on considère que c’est le temps de l’opération élémentaire la plus lente qui s’exécute en
temps borné (disons unitaire). Si bien que le temps est alors majoré par la complexité en temps dans le
pire des cas.
20 CHAPITRE 1. INTRODUCTION

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.

1.4.1 Compter exactement ?


Calculer la complexité d’un algorithme est une tâche souvent jugée difficile. Effecti-
vement, que la complexité de tchisla2() soit polynomiale, par exemple, n’a rien d’évi-
dement. Cela ne vient pas de la définition de la complexité, mais tout simplement de la
nature des objets mesurés : les algorithmes.
Calculer le nombre d’opérations élémentaires exécutées est évidemment très difficile
puisqu’il n’y a déjà pas de méthode systématique pour savoir si ce nombre est fini ou
pas : c’est le problème de la Halte qui est indécidable. En fait, un théorème (celui de
Rice) établit que pour toute propriété non triviale 25 définie sur un programme n’est pas
décidable. Des exemples de propriété sont : « Est-ce que le programme termine par une
erreur ? » ou bien « Peut-on libérer un pointeur précédemment allouée ? »
En fait, il n’est même pas la peine d’aller voir des algorithmes très compliqués
pour percevoir le problème. Considérons le programme suivant 26 renvoyant le nombre
d’étapes nécessaires pour atteindre la valeur 1 par la suite définie par
(
n/2 si n est pair
n 7→
3n + 1 sinon
24. Les opérations arithmétiques sur des entiers plus grands, comme [0, n2 [, ne sont pas vraiment un
problème. Elles prennent aussi un temps constant en simulant l’opération avec des couples d’entiers de
[0, n[.
25. Une propriété triviale d’un programme P serait une propriété qui donnerait toujours la même ré-
ponse quelque soit le programme P , ou alors quelque soit l’entrée x. Par exemple, « Quelle est la longueur
d’un programme ? » est une propriété triviale car elle ne dépend pas de l’entrée.
26. On peut aussi faire récursif en une seule ligne :
int Syracuse(int n){ return (n>1)? 1+Syracuse( (n&1)? 3*n+1 : n/2) : 0; }
1.4. RAPPELS SUR LA COMPLEXITÉ 21

int Syracuse(int n){


int k=0;
while(n>1){
n = (n&1)? 3*n+1 : n/2;
k++;
}
return k;
}

Trouver la complexité en temps de Syracuse(n) fait l’objet de nombreuses re-


cherches 27 , voir aussi l’ouvrage figure 1.5. En fait, on ne sait pas si la boucle while
s’arrête toujours au bout d’un moment, c’est-à-dire si sa complexité est finie ou pas, ou
dit encore autrement, si la suite des valeurs de n finit toujours par atteindre 1.

Figure 1.5 – Ouvrage consacré au problème « 3x + 1 » et lettre de Goldbach à


Euler en 1742.

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

preuve ad hoc pour chaque paire (a,b) ou famille de paires.


Le mathématicien Paul Erdős a dit à propos de ce problème (qu’on appelle aussi
conjecture de Collatz, conjecture d’Ulam ou encore problème « 3x + 1 » qui a été véri-
fiée 29 pour tout x < 5 · 1018 :

« Les mathématiques ne sont pas encore prêtes pour de tels problèmes ».

Beaucoup de problèmes très difficiles peuvent se formuler en simple problème de


complexité et d’analyse d’algorithme, comme la conjecture de Goldbach (cf. figure 1.5)
qui dit :

« Tout nombre entier pair supérieur à trois est la somme de deux nombres pre-
miers. ».

Face à ceci, il y a deux attitudes :

• Pessimiste : la complexité c’est compliquée ! c’est sans espoir.


• Optimiste : on peut espérer produire des algorithmes qui défis les mathématiques !
qui finalement marchent sans qu’on puisse dire et comprendre pourquoi.

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[ ?

Compter exactement le nombre d’opérations élémentaires n’est pas facile. Que se


passe-t-il vraiment avec int T[n] ? Combien y-a-t’il d’opérations dans la seule instruc-
tion T[i++] = 2*i-1 ? Une incrémentation, une multiplication, une soustraction, une
écriture, donc 4 ? 33 On fait aussi à chaque boucle une incrémentation, un saut et une
comparaison (dans cet ordre d’ailleurs). Soit un total de 7 instructions par boucle. Et
combien de fois boucle-t-on ? n/2 ou plutôt bn/2c ? Donc cela fait 7 · bn/2c opérations
plus le nombre d’instructions élémentaires pour int T[n] et i=0 (en espérant que le
nombre d’instructions pour int T[n] ne dépende pas de n). Bref, même sur un exemple
très simple, cela devient vite assez laborieux d’avoir un calcul exact du nombre d’opé-
rations élémentaires.
En fait, peu importe le nombre exact d’opérations élémentaires. Avec un processeur
1 GHz, une opération de plus ou de moins ne fera jamais qu’une différence se mesurant
en milliardième de secondes, soit le temps que met la lumière pour parcourir 30 cm.
Dans cet exemple, on aimerait surtout dire que la complexité de l’algorithme est
linéaire en n. Car ce qui est important c’est que si n double, alors le temps doublera.
Cela reste vrai que la complexité soit 7 bn/2c ou 4n − 1. Et cela restera vrai, très cer-
tainement, quelque soit le compilateur ou le langage utilisé. Si la complexité était en
n4 , peu importe le coefficient devant n4 , lorsque n double, le temps est multiplié par
24 = 16. En plus, que peut-on dire vraiment du temps d’exécution puisque qu’un pro-
cesseur cadencé à 2 GHz exécutera les opérations élémentaires deux fois plus vite qu’un
processeur à 1 GHz.

Enfin, ce qui importe c’est la complexité asymptotique, c’est-à-dire lorsque la taille


n de l’entrée est grande, tend vers l’infini.

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

1.4.2 Pour résumer


La complexité mesure généralement le nombre d’opérations élémentaires exécutées
(complexité en temps) ou le nombre de mots mémoires utilisés (complexité en espace)
par l’algorithme. Elle s’exprime en fonction de la taille de l’entrée. C’est n en général,
mais pas toujours ! Dans la plupart des cas on ne peut pas calculer la complexité exacte-
ment. On s’intéresse donc surtout à sa valeur asymptotique, c’est-à-dire lorsque n tend
vers l’infini, car on souhaite éviter une réponse de Normand. Par exemple, à la question
de savoir 34 :

« Lequel des algorithmes a la meilleure complexité entre 10n + 5 et n2 − 7n ? »

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→+∞

Quand n devient grand, un algorithme de complexité en cn finit par gagner (complexité


inférieure) sur celui de complexité c0 n2 , peu importe les constantes c et c0 , et peu im-
porte les termes de second ordre. Cela se voit aussi sur les graphes des deux fonctions.
Au bout d’un moment, l’une des deux courbes est au-dessus de l’autre, et pour toujours.

1.5 Notations asymptotiques


Les notations O, Ω, Θ servent à exprimer plus simplement les valeurs asymptotiques.
Encore une fois, cela n’a rien à voir a priori avec la complexité. D’ailleurs, dans le cha-
pitre suivant on l’utilisera pour parler de tout autre chose que la complexité. Il se trouve
qu’en algorithmique on est particulièrement intéressé à exprimer des valeurs asympto-
tiques pour les complexités.
Soient f , g deux fonctions définies sur N.

On dit que « f (n) est en grand-O de g(n) », et on le note f (n) = O(g(n)), si

∃ n0 ∈ N, ∃ c > 0, ∀ n > n0 , f (n) 6 c · g(n)

Cela revient à dire qu’asymptotiquement et à une constante multiplicative près f (n) est
« au plus » g(n).

34. Peu importe qu’il s’agisse de temps, d’espace ou autre.


1.5. NOTATIONS ASYMPTOTIQUES 25

On dit que « f (n) est en Ω(g(n)) », et on le note f (n) = Ω(g(n)), si et seulement si


g(n) = O(f (n)). Ce qui revient à dire qu’asymptotiquement et à une constant multipli-
cative près que f (n) est « au moins » g(n). Enfin, on dit que « f (n) est en Θ(g(n)) », et on
le note f (n) = Θ(g(n)), si et seulement si f (n) = O(g(n)) et f (n) = Ω(g(n)).

c · g(n)

f (n)

n0 n

Figure 1.6 – f (n) = O(g(n)) : ∃n0 ∈ N, ∃c > 0, ∀n > n0 , f (n) 6 c · g(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.

1.5.1 Exemples et pièges à éviter


Souvent on étend la notation simple f (n) = O(g(n)) en la composant avec d’autres
fonctions. Par exemple, lorsqu’on écrit f (n) = 2O(n) c’est pour dire qu’on peut rempla-
cer l’exposant O(n) par quelque chose 6 cn pour n assez grand et pour une certaine
constante c > 0. On veut donc exprimer le fait que f (n) 6 2cn pour n assez grand et une
certaine constante c > 0.
Si maintenant on écrit f (n) = 1/O(n) c’est pour dire que le terme O(n) est au plus
cn pour une certaine constant c > 0 et pour n assez grand. Cela revient donc à dire que
f (n) > 1/(cn) ou encore que f (n) est au moins 1/n à une constante multiplicative près.
D’ailleurs cela montre que si f (n) = 1/O(n) alors f (n) = Ω(1/n), notation plus claire qui
est à privilégier dans ce cas.

35. Sauf en mode expert...


26 CHAPITRE 1. INTRODUCTION

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.

• Il faut éviter de composer plusieurs asymptotiques, comme par exemple, f (n) =


1/Ω(2O(n) ). Ou encore de composer l’asymptotique O avec une fonction décrois- √
sante, comme f (n) = O(n)−1/2 . Dans ce cas il vaut mieux écrire f (n) = Ω(1/ n ),
une expression plus facile à décoder. Rappelons que l’objectif de ces notions est
de simplifier les écritures, pas de les compliquer ! Notons en passant que O(n)−1/2
n’est pas pareil que O(n−1/2 ). Même chose pour Ω qu’il faut éviter de composer
avec une fonction décroissante, qui in fine change le sens des inégalités ... 6 cn ou
... > cn dans les définitions de O et Ω.

• Il faut éviter de manipuler les asymptotiques dans des formules de récurrences,


comme on va le montrer dans l’exemple ci-après. En particulier, il faut éviter
d’écrire f (n) = O(1) + · · · + O(1) = O(1) car une somme de termes constants ne
fait pas toujours une constante. Cela dépend du nombre de termes !

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

int f(int n){


if(n<2) return n;
int a=f(n-1), b=f(n-2);
return a + b;
}

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) ?]

1.5.2 Complexité d’un problème


Souvent on étend la notion de complexité d’un algorithme donné à celle d’un pro-
blème donné. On dit qu’un problème Π a une complexité C(n) s’il existe un algorithme
qui résout toutes les instances de Π de taille n avec une complexité O(C(n)) et que tout
autre algorithme qui le résout a une complexité Ω(C(n)). Dit plus simplement, la com-
plexité du meilleur algorithme possible vaut Θ(C(n)).
Par exemple, on dira que la complexité (en temps) du tri par comparaisons est de
n log n ou est en Θ(n log n). Le tri fusion atteint cette complexité et aucun algorithme de
tri par comparaison ne peut faire mieux. Il existe des algorithmes de tri de complexité
inférieure... et qui, bien sûr, ne sont pas basés sur des comparaisons.

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

36. La permutation est unique si les éléments sont distincts.


37. Ici la hauteur est le nombre d’arêtes d’un chemin allant de la racine à une feuille quelconque.
28 CHAPITRE 1. INTRODUCTION

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.

Il y a beaucoup de problèmes dont on ne connaît pas la complexité comme : la multi-


plication de nombres de n bits, de matrices booléennes n×n, savoir si un graphe contient
un triangle, savoir si un ensemble de n points du plan en contient trois alignés (appar-
tiennent à une même droite)... C’est autant de problèmes qui font l’objet de nombreuses
recherches.

1.5.3 Sur l’intérêt des problèmes de décision


Un problème de décision est un problème qui attend une réponse « oui » ou « non ».
Le problème de la Halte qu’on a vu au paragraphe 1.2 est un problème de décision,
contrairement au problème Tchisla dont la réponse n’est pas binaire mais une expres-
sion arithmétique. À première vue, un problème de décision est moins intéressant. Quel
est donc l’intérêt des problèmes de décision tel que le suivant ?
Chemin Hamiltonien
Instance: Un graphe G.
Question: Est-ce que G possède un chemin hamiltonien ? c’est-à-dire un chemin
passant une et une seulement fois par chacun de ses sommets.

Ce problème est réputé difficile, mais en pratique on s’intéresse rarement au pro-


blème précis de savoir s’il existe ou pas un chemin passant par tous les sommets d’un
1.5. NOTATIONS ASYMPTOTIQUES 29

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].)

Figure 1.8 – Optimisation des trajectoires de robots dans les entrepôts de


stockage de l’entreprise de livraison chinoise Shengton.

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.

1.6 Algorithme et logarithme


La fonction logarithme est omniprésente en algorithmique. Certes, algorithme et
logarithme sont intimement liés – se sont des anagrammes – mais ce n’est pas la seule
raison ! Il y a des raisons plus profondes. Pour le comprendre, on va revenir sur les
propriétés principales de cette fonction. Si ces propriétés ont déjà été vues en terminale,
pour la suite du cours, elles doivent être maitrisées 42 .
Voici un petit exemple qui montre pourquoi cette fonction apparaît souvent en al-
gorithmique. Considérons le code suivant 43 :

int f(int n){


int p=1;
while(n>1) n /= ++p;
return n;
}

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

simplissimes, ou ses variantes, apparaissent régulièrement en algorithmique, le bloc


d’instructions à répéter pouvant souvent être plus complexe encore. Si l’on se pose la
question de la complexité de cette fonction (ce qui revient ici à déterminer la valeur
finale de p qui représente, à un près, le nombre de fois qu’est exécutée la boucle while)
alors la réponse, qui ne saute pas aux yeux, est :
!
log n
Θ .
log log n

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 :

• La solution x de l’équation n = bx est x = logb n.


• C’est la réciproque 45 de la fonction puissance de b.
• logb (1) = 0, logb (b) = 1, et logb n > 0 dès que n > 1.
• La fonction logb n croît très lentement lorsque n grandit.

La croissance de la fonction logarithmique provient de la croissance de la fonction


0
puissance de b > 1. On a bx < bx si et seulement si x < x0 . Ensuite, cette croissance est
lente dès que n > b. Pour que x = logb n augmente de 1 par exemple, il faut multiplier n
par b, car bx+1 = b · n. On y reviendra.

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.

1.6.1 Propriétés importantes


La propriété vraiment importante qu’on démontrera page 34 et qu’il faut retenir est :
 
Proposition 1.1 Le nombre logb n est le nombre de chiffres dans l’écriture de n − 1
en base b. C’est aussi le plus petit nombre de fois qu’il est nécessaire de diviser n par b
pour obtenir un ou moins.

Comme expliqué précédemment, il faut que b > 1.


Donc le logarithme de n est un peu la « longueur » de n. Par exemple :

• Pour b = 10 et n = 103 . Alors n − 1 = 999 s’écrit sur 3 chiffres décimaux.


• Pour b = 2 et n = 24 . Alors n − 1 = 15 = 1111deux s’écrit sur 4 chiffres binaires.
1.6. ALGORITHME ET LOGARITHME 33
 
• Pour b = 2 et n = 13. Alors n−1 = 12 = 1100deux s’écrit sur log2 (12) = d3.5849...e =
4 chiffres.
Comme on peut facilement « éplucher » les chiffres d’un nombre n écrit en base b en
répétant l’opération n 7→ bn/bc, grâce à l’instruction n /= b qui en C effectue la division
euclidienne, on déduit de la première partie de la proposition 1.1 le code suivant pour
 
calculer logb n :

int log_b(int n, int b){ // n>0 et b>1


n--; int k=1; // écrit n-1, au moins un chiffre
while(n>=b) n /= b, k++; // tant qu’il y a plus d’un chiffre
return k; // k = #chiffres de n-1 en base b
}

Attention ! Contrairement à ce que semble affirmer la proposition 1.1, une boucle en


langage C comme

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

La forme plus générale


n n−m
m m+1 n
X
i m
X
i qn−m+1 − 1
m
q +q + ··· + q = q = q q = q ·
q−1
i=m i=0

46. Cf. le fait 6.1 du chapitre 6 du cours d’Algorithmique distribuée.


47. La formule ne marche pas si q = 1. Bien évidemment, dans ce cas ni=0 1i = n + 1.
P
34 CHAPITRE 1. INTRODUCTION

s’obtient trivialement depuis d’équation (1.4) en factorisant par le premier terme « qm ».


On retient dans cette dernière formule que « qm » est le premier terme et « n − m + 1 » le
nombre de termes de la somme. Notons d’ailleurs que la formule (1.4) est triviale 48 à
démontrer par récurrence, le problème étant de se soutenir de la formule à démontrer.
Par exemple 1 + 2 + 4 + 8 + · · · + 2h = (2h+1 − 1)/(2 − 1) = 2h+1 − 1 = 111 . . . 1deux donne le
nombre de sommets dans un arbre binaire complet de hauteur h. On a aussi 1+10+100+
· + 10h = (10h+1 − 1)/9 = 999 . . . 9dix /9 = 111 . . . 1dix . Notons que dans ces deux exemples,
le résultat est un nombre qui s’écrit avec h chiffres identiques qui sont des uns.

Preuve de la proposition 1.1. Le nombre k de divisions par b à partir de n nécessaire


pour avoir 1 ou moins est le plus petit entier k tel que n/bk 6 1. Par la croissance de la
fonction logarithme,
n
6 1 ⇔ n 6 bk ⇔ logb n 6 k .
bk
 
Le plus petit entier k vérifiant k > logb n est précisément k = logb n qui est donc l’entier
recherché.
 
Montrons maintenant que logb n est aussi le nombre de chiffres pour écrire n−1 en
base b. On va d’abord montrer que pour tout entier k > 1, le nombre bk − 1 s’écrit avec k
chiffres 49 . D’après la formule (1.4) avec q = b et n = k − 1, on a :

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.

Pour le premier point. Calculons le nombre bloga n/ loga b en remplaçant la première


occurrence de b par b = aloga b , par définition de loga b. Il vient :
 loga n/ loga b
bloga n/ loga b = aloga b = a(loga b)(loga n)/ loga b = aloga n = n .

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.

Dans les notations asymptotiques faisant intervenir des logarithmes, on ne précise


pas la base (si celle-ci est une constante). On note donc simplement O(log n) au lieu
de O(log2 n), O(log10 n) ou encore O(logπ 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 troisième point. Il peut se déduire directement du précédent seulement si


α est un entier. L’argument est cependant similaire aux précédents. D’une part nα =
α
blogb (n ) par définition de logb (nα ). D’autre part
 α
nα = blogb n = b(logb n)α = bα logb n
α
et donc blogb (n ) = bα logb n . On a donc que logb (nα ) = α logb n.
36 CHAPITRE 1. INTRODUCTION

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→+∞

On note loge n = ln n et on l’appelle le logarithme naturel 50 ou encore logarithme népé-


rien 51 de n. La réciproque de ln n est donc en , la fonction exponentielle classique.
Le premier point de la proposition 1.2 permet de montrer, en posant comme
deuxième base a = e, que :
ln n
logb n = .
ln b
Il se trouve que la fonction ln n est la seule fonction définie pour n > 0 qui s’annule
en zéro et dont la dérivée vaut 1/x. C’est la définition classique. Autrement dit, on a :
Zn
1
ln n = dx
1 x

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

En particulier ln n ∼ Hn . On peut être même plus précis avec l’asymptotique Hn = ln n +


γ + O(1/n) où γ = 0.577 215 664... est la constante d’Euler-Mascheroni 52 . On observe
encore une fois que la fonction ln n croît lentement, puisque Hn+1 = Hn + 1/(n + 1) et
donc ln (n + 1) ≈ (ln n) + 1/(n + 1).

1.6.3 Tchisla et logarithme


Les dix symboles utilisés dans la version classique du problème Tchisla sont :

c + - * / ^ ! ( )

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}.

Preuve. Avant d’aborder le cas général, commençons par un exemple : c = 2 et n = 5.


Avec la version classique, et les dix symboles de Σ, il faut 4 = f2 (5) chiffres 2, réalisé
par l’expression n = 5 =2+2+2/2. On va faire mieux en ajoutant l’opérateur ln. Pour
simplifier, supposons qu’on peut utiliser la négation. Remarquons que
v
u
tsr

q
1 1 1 1 1 5 −5
2 = 2 2 · 2 · 2 · 2 · 2 = 21/2 = 22 .

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

Figure 1.10 – Approximation de ln n par Hn (source Wikipédia). On voit par


R5
exemple que H5 − 1 < 1 1x dx < H4 . En fait H4 s’interprète comme la somme
R5
des colonnes colorés 1, 2, 3, 4 (qui majorent l’air 1 1x dx) et H5 − 1 comme
celles de colonnes 2, 3, 4, 5 (qui décalées à gauche d’une colonne minorent
R5
l’air 1 1x dx). Par concavité de 1/x, on peut majorer chaque colonne par le mi-
lieu entre la colonne et sa suivante, ce qui donne un encadrement encore plus
R5
serré H5 − 1 < 1 1x dx < 12 4i=1 ( 1i + i+1 1
) = 12 (H4 + H5 − 1) = 12 (H5 − 15 + H5 − 1) =
P

H5 − 21 ( 15 + 1) = H5 − 1 + 52 . Et donc ( 12 + · · · + 15 ) < ln 5 < ( 12 + · · · + 15 ) + 25 soit l’en-


cadrement 1.283 < ln 5 < 1.683. En fait, ln 5 = 1.609.... De manière générale
on en déduit l’encadrement de taille < 0.5 qui est Hn − 1 < ln n < Hn − 1 + n−1 2n
pour tout entier n > 1.

En se rappelant que ln (xb ) = b ln x, il vient


√√√√√
ln( 2) = 2−5 ln 2
√√√√√
⇒ ln( 2)/ln(2) = 2−5
√√√√√
⇒ -ln(ln( 2)/ln(2))/ln(2) = 5 .

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

Cette expression comprend n+29 symboles dont 7 occurrences du chiffre√√√


c. Remarquons

que ln(c+c)> ln(2) , 0 car c > 1, que ln(c/(c+c))= ln(1/2) , 0 et que ... (c+c)
1.7. MORALE 39

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

On peut faire un peu mieux


√(n) encore dès que c , 1, car
√(n)dans ce cas on peut remplacer
dans l’équation (1.5) ln( (c+c))/ln(c+c) par ln( c)/ln(c) puisque la division
par ln (c) , 0 devient possible. On tombe alors à 5 occurrences
√ de c. On peut même
faire 4 pour c = 4 en remplaçant ln(4/(4+4)) par ln( 4/4).
Si on ajoute l’opérateur !, on √
peut √faire 4 aussi pour c = 3 et c = 9, en remplaçant
ln(c/(c+c)) par ln(3/3!) et ln( 9/( 9)!) respectivement. Si on √√ajoute l’opération -,
on peut faire 4 pour c = 8 en remplaçant ln(8/(8+8)) par (-ln( (8+8))). Enfin, on
peut faire 3 pour c = 2 en remplaçant ln(2/(2+2)) par (-ln(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

Mots clés et notions abordées dans ce chapitre :


• nombre de partitions
• formule asymptotique
• récurrence, arbre des appels
• programmation dynamique
• mémorisation paresseuse (mémoïsation)

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

• : 1 paquet d’2 billes et 2 paquets d’1 bille ;


• : 4 paquets d’1 bille.

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

Les éléments étant indistinguables, la somme 1 + 3 représente la même partition que


3 + 1. Par habitude on écrit les sommes par ordre décroissant des parts.
Pour simplifier un peu le problème, on va se contenter de compter le nombre de
partitions d’un entier n, et on notera p(n) ce nombre. Les premières valeurs sont :

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

Et le problème précis est :


Partition d’un entier
Instance: Un entier n > 0.
Question: Calculer p(n), le nombre de partitions de n, soit le nombre de façons de
partitionner un ensemble de n éléments indistinguables en sous-ensembles
non vides.

2.2 Formule asymptotique


Le n-ième nombre de Fibonacci noté F(n), qui au passage à l’air proche de p(n)
d’après la table ci-dessous,

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

On note bxe = bx + 0.5c l’entier de plus proche de x, √ c’est-à-dire l’arrondi. La formule


avec l’arrondi vient du fait 1 que le terme |(1 − Φ)n / 5| < 0.5. Ces formules ne sont pas
nécessairement très efficaces telles quelles car en pratique les calculs faisant intervenir
les irrationnels (comme le nombre d’Or Φ) sont difficiles à représenter exactement en
machine. Et donc les calculs sont vites entachés d’erreurs, même si dans le cas de la
formule de Binet le premier chiffre après la virgule permet toujours de déterminer F(n)
et que Φ n − (1 − Φ)n est toujours un entier. Il n’empêche, une formule close est un bon
point de départ pour la recherche d’un algorithme efficace.
Le nombre de partitions est très étudié en théorie des nombres. Par exemple, il a
été montré en 2013 que p(120 052 058), qui possède 12 198 chiffres, était premier. Il est
aussi connu que, pour tout x ∈]0, 1[ :
+∞ +∞ 
1
X Y 
n
p(n) · x =
n=0
1 − xk
k=1

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

1 = (n2 + n + 1) − (n2 + n)  (n2 + n + 1) − n2 = n + 1 .

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)

On peut vérifier que f (n) ∼ g(n) si et seulement si f (n) = g(n) + o(g(n)).


Comme le montrent les exemples précédents, une fonction comme p(n) peut avoir
plusieurs équivalents asymptotiques. En fait, pour ce chapitre, peu importe la finesse

des asymptotiques sur p(n). On retiendra surtout que p(n) est exponentielle en n, ce
qui peut s’écrire : √
p(n) = 2Θ( n ) .
2.3. APPROCHE EXHAUSTIVE 47

Parenthèse. Mais pourquoi √peut-on écrire que √
p(n) = 2Θ( n ) ? Tout d’abord (et par défini-
tion), parce que p(n) = 2O( n ) et p(n) = 2Ω( n ) . Ensuite, pour toute constante c
√  c√n √ 0

ec n
= 2log2 e = 2(c log2 e) n = 2c n

avec c0 = c log2 e. (Voir le paragraphe 1.6.) Donc on a


√ √
n) n)
eΘ( = 2Θ( .

On a également pour toutes constantes a, b, c


√ √ √ √
a · nb · ec n
= eln a · eb ln n · ec n
= eln a+b ln n+c n
= eΘ( 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.3 Approche exhaustive


Essayons la recherche exhaustive pour calculer p(n). Le plus simple est de générer
toutes les partitions puis de les compter. Comme pour Tchisla au paragraphe 1.3, il
faut un moyen de représenter une partition via un codage. Cela va permettre de lister
les partitions en générant tous les codes possibles.
Pour partitionner un ensemble de billes, disons supposées alignées, on peut décou-
per cet alignement en intervalles, en insérant (ou pas) une séparation entre deux billes
consécutives. Par exemple, la partition 6 = 2+2+1+1 = pourrait être repré-
sentée par le découpage - | - | | , utilisant les symboles « | » ou « - » pour signaler
une séparation (ou pas) entre deux billes. En oubliant les billes et en ne gardant que les
symboles de séparation, on peut coder le découpage par un mot binaire de n − 1 bits.

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.

Chaque colonne représente une part de la partition. Le nombre de parts est le


nombre de colonnes. Notons que chaque partition est uniquement représentée par un
diagramme (c’est lié au fait que les colonnes sont triées par hauteur). Inversement,
2.4. RÉCURRENCE 49

chaque diagramme comportant n carrés organisés en colonnes décroissantes représente


une seule partition de n. Donc compter le nombre de partitions revient à compter le
nombre de tels diagrammes.

Parenthèse. La représentation en diagramme


√ permet facilement de se convaincre que p(n)
est au moins exponentiellement en n. Pourquoi ? On part d’un diagramme de k colonnes
où pour tout i, la colonne i est de hauteur i. Ce diagramme possède n0 = k(k + 1)/2 ∼
1 2
2 k carrés répartis en k colonnes. Maintenant, en haut de chacune des k colonnes, on peut
décider d’ajouter ou pas un carré. Cela crée à chaque fois un diagramme valide et différent.
On construit ainsi 2k diagrammes tous différents. Parmi eux beaucoup ont été obtenus en
k 
ajoutant exactement bk/2c carrés : bk/2c pour être précis. Ces diagrammes possèdent tous
n0 + bk/2c carrés. Soit n = n0 + bk/2c. Comme n0 ∼ 21 k 2 , on a aussi que n ∼ 12 k 2 , et donc que

k ∼ 2n. Le nombre de diagrammes à n carrés ainsi construits est donc

! √
k
∼ 2k−o(k) = 2 2n−o( n ) .
bk/2c

Autrement dit p(n) = 2Ω( n ).

Une autre représentation



des partitions de n, en fait un codage, permet de montrer qu’il
y en au plus 2 O( n log n) . On code la partition n = v1 + v2 + · · · + vk par une suite de t couples
(vi1 , r1 ), (vi2 , r2 ), . . . , (vit , rt ) où rj est le nombre de répétition de la même valeur vij . Cette
suite peut donc être codée avec 2t entiers de {1, . . . , n}. Cela montre qu’il y a plus au √plus
n2t = 2O(t log n) telles suites, et donc au plus autant de partitions. Montrons que t = O( n ).
En effet, les valeurs vij sont toutes différentes et > 1. Du coup vij > j. La somme des viJ est
évidemment bornée par n. On a donc tj=1 j 6 tj=1 vij 6 n. On en déduit que t(t+1)/2 6 n,
P P

ou encore que t < 2n.

Les diagrammes se décomposent facilement en éléments plus petits ce qui facilite


leur comptage. Par exemple, si l’on coupe un diagramme de Ferrers entre deux colonnes,
on obtient deux diagrammes de Ferrers. De même si on le coupe entre deux lignes (voir
les flèches de la figure 2.1).
Les récurrences sont plus faciles à établir si l’on fixe le nombre de parts des parti-
tions, c’est-à-dire le nombre de colonnes des diagrammes. Dans la suite, on notera p(n, k)
le nombre de partitions de n en k parts. Évidemment, le nombre parts k varie entre 1
et n, d’où
n
X
p(n) = p(n, 1) + · · · + p(n, n) = p(n, k) .
k=1

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 :

p(n, k) = p1 (n, k) + p2 (n, k) .

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 :

p(n, k) = p(n − 1, k − 1) + p(n − k, k)

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

De cette récurrence, on en déduit immédiatement l’algorithme et le programme sui-


vant :

int p(int n,int k){


if(k>n) return 0;
if(k==1 || k==n) return 1;
return p(n-1,k-1) + p(n-k,k);
}

int partition(int n){


int k,s=0;
for(k=1;k<=n;k++) s += p(n,k);
return s;
}

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.

Pour analyser les performances de la fonction partition() on va utiliser l’arbre des


appels.

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
+

(6, 1) (6, 2) (6, 3) (6, 4) (6, 5) (6, 6)


1 1

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.

Complexité en temps. Calculons la complexité en temps de partition(n). La première


chose à dire est que, d’après le code, cette complexité est proportionnelle aux nombres
2.4. RÉCURRENCE 53

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

Opérations arithmétiques sur de grands entiers. En fait on a supposé que l’opé-


rations arithmétiques (ici +) sur les nombres p(n, k) était élémentaires. Ce n’est vrai
que si les nombres sont des entiers pas trop grands, s’ils tiennent sur un mot mé-
moire (int ou long ou long long) comme dans le code de p(n,k) page 51. En fait,
ce n’est plus le cas si au bout d’un moment les nombres sommés deviennent très
grands. En toute rigueur, il faudrait alors effectuer les opérations de somme sur des
tableaux de chiffres, et remplacer l’opération S=A+B par une fonction ressemblant à
Sum(int A[],int B[],int S[],int length).

D’après la formule asymptotique, des tableaux de length = O( n ) chiffres suffisent.
[Question. Pourquoi ?] Il faudrait donc multiplier la complexité en temps√ vue précédem-
ment, qui représentait en fait le nombre d’additions, par ce facteur O( n ) puisque la
somme de deux tableaux de taille length se fait trivialement en√temps O( length). No-
√ Θ( n ) Θ(

n ) [Question.
tons toutefois que cela ne change pas grand chose car O( n ) · 2 =2
Pourquoi ?].

3. C’est-à-dire chaque nœud interne a exactement deux fils


4. Ce qui se démontre facilement par récurrence.
54 CHAPITRE 2. PARTITION D’UN ENTIER

Complexité exponentielle ? Par rapport à la taille de l’entrée du problème du calcul


x
de partition de n, la complexité est en fait doublement exponentielle (22 pour un cer-
tain x). Pourquoi ? Il s’agit de calculer le nombre p(n) en fonction de l’entrée n (voir
 
la formulation du problème page 44). L’entrée est donc√ un entier sur k = log10 n =
Θ(log n) chiffres. Donc, une complexité en temps de 2Θ( n ) , en fonction de k est en fait
Θ(k) √
une complexité en temps de 22 car n = 2(log n)/2 = 2Θ(k) et Θ(2Θ(k) ) = 2Θ(k) . La com-
plexité en temps de la version récursive est donc doublement exponentielle !

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.

2.5 Programmation dynamique

La programmation dynamique est l’implémentation améliorée de la version récur-


sive d’un algorithme. Au lieu de faire des appels récursifs, on utilise la mémorisation
qui économise ainsi des calculs (et du temps) au détriment de l’espace mémoire. On
utilise une table où les valeurs sont ainsi calculées « dynamiquement » en fonction des
précédentes.
On va donc utiliser une table P[n][k] similaire à la table 2.1 que l’on va remplir
progressivement grâce à la formule de récurrence. Pour simplifier, dans la table P on
n’utilisera pas l’indice 0, si bien que P[n][k] va correspondre à p(n, k). Toute la diffi-
culté est de parcourir la table dans un ordre permettant de calculer chaque élément en
fonction de ceux précédemment calculés.
2.5. PROGRAMMATION DYNAMIQUE 55

(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.

int partition(int n){


int P[n+1][n+1],i,k,s=0; // indices 0,1,...,n
for(i=1;i<=n;i++){ // pour chaque ligne
P[i][1]=P[i][i]=1;
for(k=2;k<i;k++) // pour chaque colonne
P[i][k] = P[i-1][k-1] + P[i-k][k];
}
for(k=1;k<=n;k++) s += P[n][k];
return s;
}
56 CHAPITRE 2. PARTITION D’UN ENTIER

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.

La complexité en temps est O(n2 )... si n n’est pas trop grand.

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)) +
···

De manière plus synthétique la formule de récurrence s’exprime comme :


(
1 si n = 1
p(n) = P i−1
i>1 (−1) · (p(n − i · (3i ± 1)/2)) si n > 1
Il faut bien sûr que l’argument n − i · (3i ± 1)/2 > 1 puisque p(n)√n’est défini que pour
n > 1. On en déduit alors que la somme comprend seulement 2 2n/3 termes environ.
2.6. MÉMORISATION PARESSEUSE 57

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.]

2.6 Mémorisation paresseuse

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.

Principe. On stocke au fur et à mesure le résultat de chaque appel (ainsi


que l’appel lui-même) sans se soucier de l’ordre dans lequel ils se pro-
duisent. Et si un appel avec les mêmes paramètres réapparaît, alors on
extrait de la table sa valeur sans refaire de calculs.

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.

long fibo(int n){ // version d’origine


if(n<2) return n; // fibo(0)=0, fibo(1)=1
return fibo(n-1)+fibo(n-2);
}

long fibo_mem(int n){ // version mémoïsée


static long F[]={[0 ... 99]=-1}; // initialisation en gcc
if(n<2) return n;
if(F[n]<0) F[n]=fibo_mem(n-1)+fibo_mem(n-2); // déjà calculée?
return F[n];
}

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

d’où c = F(n). En utilisant l’exponentiation rapide, soit

(x · x)n/2 si n est pair


(
n
x 7→
x · xn−1 sinon
!n
1 1
on peut calculer le coefficient c et de après au plus 2 log n multiplications de ma-
1 0
trices 2 × 2, ce qui fait une complexité en O(log n) pour le calcul de F(n).

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

for(u=0,v=1,i=2; i<n; i++) t=u,u=v,v+=t;

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 :

long f(int n){


if(n<2) return n;
long u=f(n-1);
return u+f(u%n);
}

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).

Si les fonctions récursives, et la fonction Ackermann en particulier, sont parfois plus


« puissantes » c’est qu’elles font un usage intensif de la pile (empilement des appels)
comme le ferait une fonction itérative avec une table auxiliaire.

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.

v=f(x,y); -> p=list_search(L,x,y); // ptr sur la valeur ou NULL


if(p==NULL) { v=f(x,y); L=list_add(L,x,y,v); }
else v=*p;

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).

Par conséquent, le nombre de nœuds de T visités pour f_mem(x,y) est au plus d


fois le nombre de nœuds différents de T , ce qui est en général bien plus petit que
le nombre total de nœuds de T .

Soit k le nombre de nœuds différents de T . Et pour simplifier, supposons que la


complexité en temps de f(), hormis les appels récursifs, est constante (comme fibo()
et toutes les fonctions récursives vues jusqu’à présent). La complexité en temps pour
f_mem() va alors être augmentée du temps de l’ajout (avec list_add()) de chacun des k
nouveaux nœuds, et de la recherche (avec list_search()) des au plus dk nœuds visités
par f_mem().
Bien évidemment, la liste chaînée va contenir au plus k éléments. L’ajout (en tête)
d’un élément prend un temps constant, alors que la recherche prend un temps O(k) par
élément. Au total, cela fait donc O(k + dk 2 ) = O(dk 2 ) pour une liste chaînée. Notons que
si les paramètres (x,y) sont des entiers de [0, n[, alors k = O(n2 ) [Question. Pourquoi ?]
Si T est binaire (soit d = 2), cela fait donc une complexité en O(n4 ) même si T possédait
un nombre total exponentiel de nœuds.
Bien sûr, pour la mémoïsation, des structures de données autres que les listes chaî-
nées sont envisageables. Les temps de recherche et d’ajout dans une structure de taille k
ont alors les complexités suivantes (mais les détails ne font pas l’objet de ce cours).
2.7. MORALE 63

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.

distance immédiatement supérieure, c’est-à-dire à distance L + 1, sont alors cal-


culées à partir des sommets u de la table D par une formule de récurrence du
type :
D[v] = min {D[u] + d(u, v)} .
u∈D,uv∈E

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

TSP Art [KB05] : Tournée non optimale sur 12 000 points.


— The Mathematical Art of Robert Bosch

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

Mots clés et notions abordées dans ce chapitre :


• problème d’optimisation
• inégalité triangulaire
• problème difficile
• algorithme d’approximation
• facteur d’approximation
• heuristique

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).

Figure 3.1 – L’application Vroom propose des solutions au problème du


Voyageur de commerce sur une carte routière réelle.

C’est un problème célèbre où 1 M$ est offert pour sa résolution en temps polyno-


mial. Il présente à la fois un intérêt théorique (pour la compréhension de la limite théo-
rique du calcul informatique) et pratique (où les logiciels professionnels résolvant ce
problème peuvent être fortement monnayables).
Plusieurs ouvrages lui ont été consacré, comme [ABCC06] ou [Coo11] pour les
plus récents, et même depuis 2012 une application éducative sur l’Apple Store ! (cf.
figure 3.2).
Formellement le problème est le suivant :

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

Figure 3.2 – Une application et des ouvrages consacrés au problème du


Voyageur de commerce.

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

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.

On parle ainsi de TSP « métrique » lorsque d vérifie l’inégalité triangulaire. Dans la


version générale du TSP, c’est-à-dire lorsque d n’est plus forcément une distance véri-
fiant l’inégalité triangulaire, on doit ajouter que la tournée passe une et une seule fois
par chacun des points.
Il existe aussi un TSP « asymétrique », lorsque d(A, B) , d(B, A). Notons que dans le
réseaux Internet l’inégalité triangulaire n’est, en général, pas respectée ; de même que
la symétrie (c’est le « A » de l’ADSL). Les temps de trajet entre gares du réseau ferré ne
vérifient pas non plus l’inégalité triangulaire. Le trajet Bordeaux → Lyon par la ligne
traversant le Massif central est plus long (en temps) que le trajet Bordeaux → Paris-
Montparnasse → Paris-Gare-de-Lyon → Lyon.
Il y a aussi la variante où les points sont les sommets d’un graphe avec des arêtes
valuées et la distance est la distance dans le graphe. La tournée, qui doit visiter tous les
sommets, ne peut passer que par des arêtes du graphe (par exemple pour contraindre
la trajectoire d’un véhicule à n’utiliser que des segments de routes comme dans l’appli-
cation présentée figure 3.1). Elle peut être amenée à passer plusieurs fois par le même
sommet. On parle de TSP « graphique ».

3.2 Recherche exhaustive


La question de savoir s’il existe une formule close n’a pas vraiment de sens puisque
le nombre de paramètres n’est pas borné (le nombre de points). On ne risque pas d’avoir
une formule de taille bornée...
Pour la recherche exhaustive, il suffit généralement de commencer par se poser deux
questions :

(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 !

Principe. Générer tous les ordres possibles, calculer la longueur de cha-


cune des tournées et ne garder que la plus petite.

Complexité en temps. Le nombre d’ordres possibles sur n points est le nombre de


permutations, soit n!. Une fois l’ordre des points fixé, le calcul de la tournée prend un
temps O(n) pour calculer la somme des n distances. Mettre à jour et retenir le minimum
prend un temps constant. Au final, la complexité en temps de l’algorithme brute-force
est :
O(n · n!) .

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.

Des ordres de grandeurs importants à connaître. On reparlera des ordres de gran-


deurs plus tard au paragraphe 5.2.5, mais voici deux ordres de grandeurs qu’il faut
avoir en tête :

• En un milliardième de seconde, soit la durée de 10−9 s, d’1 nanoseconde ou en-


core d’1 GHz, la lumière se déplace d’au plus 30 cm (et encore dans le vide, car
dans le cuivre c’est 30% à 40% de moins). Ceci explique que les processeurs ca-
dencés à plus d’1 GHz sont généralement de taille  30 cm puisque sinon la com-
munication est impossible dans le délais imparti. Notons que le processeur A12
d’Apple (2018) cadencé de 2.4 GHz implique que la lumière ne peut parcourir
que 30/2.4 = 12.5cm pendant un cycle horloge, soit moins que la diagonale du
smartphone (≈ 16cm).
70 CHAPITRE 3. VOYAGEUR DE COMMERCE

• Un millard de secondes, soit 109 s, correspond à une durée supérieure à 30 ans.


Ainsi sur un ordinateur 1 GHz pouvant exécuter un millard d’opérations élémen-
taires par seconde, il faudra que la complexité de l’algorithme soit < 109 × 109 =
1018 pour qu’il est un quelconque intérêt en pratique. Notons en passant que le
nombre de nanosecondes depuis le bigbang est de 13· 109 · 365 · 24 · 3600 · 109 ≈ 26!
(à 1% près) et que 26! ≈ 1026 .

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.

3.3 Programmation dynamique


On va présenter l’algorithme de Held – Karp découvert indépendamment par Bell-
man en 1962 qui résout le problème du Voyageur de commerce. C’est l’algorithme qui
a la plus basse complexité en temps connue pour ce problème. En fait, il fonctionne
même si d ne vérifie pas l’inégalité triangulaire et/ou n’est pas symétrique. On a juste
besoin que d(A, B) > 0. D’ailleurs c’est la même chose pour l’algorithme brute-force qui
pour fonctionner n’utilise ni la symétrie, ni l’inégalité triangulaire.
La formulation du problème semble indiquer qu’il n’y a pas vraiment d’alternative à
chercher parmi toutes les tournées possibles celles de longueur minimum. Et pourtant...
Observons d’abord que l’algorithme brute-force teste inutilement de nombreux cas.
Supposons que parmi toutes les tournées possibles, on s’intéresse à toutes celles qui
passent par v1 , S1 , v2 , S2 , v3 , S3 , v4 , S4 , v5 où les Si sont des ensembles de points, comme
représenté sur la figure 3.5. Elles doivent passer par v1 , . . . , v5 mais sont libres de circuler
dans chaque Si par le point du haut ou du bas. Comme chaque Si possède deux points,
3.3. PROGRAMMATION DYNAMIQUE 71

le nombre de chemins possibles est donc 2 × 2 × 2 × 2 = 24 = 16. L’approche brute-force


va donc tester ces 16 chemins.

v1 v2 v3 v4 v5

S1 S2 S3 S4

Figure 3.5 – Trois chemins parmi les 16 visitant l’ensemble de points v1 , S1 ,


v2 , S2 , v3 , S3 , v4 , S4 , v5 dans cet ordre. Le chemin minimum visitant v1 , S1 , v2
est calculé deux fois.

Cependant, si on avait commencé par résoudre (récursivement ?) le problème du


meilleur des deux chemins allant de vi à vi+1 et passant par Si , pour chacun des 4
ensembles, alors on aurait eut à tester seulement 2 + 2 + 2 + 2 = 2 × 4 = 8 chemins
contre 16 pour l’approche brute-force. L’écart n’est pas très impressionnant car les Si
ne contiennent que deux points. S’ils en contenaient 3 par exemple, la différence serait
alors de 3 × 4 = 12 contre 34 = 81 pour le brute-force.
L’algorithme par programmation dynamique est un peu basé sur cette remarque.
Comme pour Partition d’un entier, pour exprimer une formule de récurrence on a
besoin de définir une variable particulière qui dépend de nouveaux paramètres (comme
p(n, k) au lieu de p(n)).

La variable. Dans la suite, on supposera que la tournée recherchée commence, ou


plutôt termine, au point vn−1 . Ce choix est arbitraire 3 . Pour simplifier les notations, on
notera V ∗ = V \ {vn−1 } = {v0 , . . . , vn−2 } qui est donc l’ensemble des points de V sans le
dernier.
Attention ! l’ordre v0 , v1 , . . . , vn−1 n’est pas ici la tournée de longueur minimum
comme dans la formulation encadrée du problème. C’est simplement les indices des
points d’origine. L’indexation des points est donc ici totalement arbitraire sans lien avec
la solution.
L’algorithme de programmation dynamique repose sur la variable D(t, S), définie
pour tout sous-ensemble de points S ⊆ V ∗ et tout point t ∈ S, comme ceci :
(
la longueur minimum d’un chemin allant de vn−1 à t
D(t, S) =
et qui visite tous (et seulement) les points de S.

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

Figure 3.6 – Chemin de longueur minimum allant de vn−1 à t visitant tous


les points de S. Il faut t ∈ S et vn−1 < S. On remarque que le sous-chemin de
vn−1 à x est aussi celui de longueur minimum allant de vn−1 à x et à passer
par tous les points de S \ {t}.

Le « seulement » dans la définition précédente est nécessaire seulement si d ne vérifie


pas l’inégalité triangulaire. Sinon, dans le cas du TSP métrique, le chemin de longueur
minimum visitant tous les sommets de S ne peut emprunter de sommet en dehors de
S (hormis le point de départ vn−1 ), car le chemin direct x − y entre deux points de S est
plus court que (ou égal à) tout chemin x − z − y avec z < S.
Notons opt(V , d) la solution optimale recherchée, c’est-à-dire la longueur minimum
de la tournée pour l’instance (V , d) du Voyageur de commerce. Il est facile de voir que
opt(V , d) = min∗ {D(t, V ∗ ) + d(t, vn−1 )} . (3.1)
t∈V

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

Figure 3.7 – Tournée optimale à l’aide d’un chemin minimum de vn−1 à t ∗


visitant tous les points de V ∗ .

Formule de récurrence. L’idée est de calculer D(t, S) à partir de sous-ensembles stric-


tement inclus dans S. Supposons que vn−1 − s1 − · · · − sk − x − t soit un chemin de lon-
gueur minimum parmi les chemins allant de vn−1 à t et visitant tous les points de S =
3.3. PROGRAMMATION DYNAMIQUE 73

{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).

De cette discussion, on en déduit la formule suivante, définie pour tout S ⊆ V ∗ et


tout t ∈ S :

(
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

On rappelle que |S|, lorsque S est un ensemble, représente la cardinalité de S (son


nombre d’éléments). Notons que la condition « |S| = 1 » est équivalente à poser « S = {t} »
étant donné qu’on doit avoir t ∈ S.

Implémentation récursive. De l’équation (3.2), on déduit immédiatement l’implé-


mentation triviale suivante, en supposant déjà définies quelques opérations de bases
sur les ensembles comme set_card, set_in, set_minus, set_create, set_free. Pour sim-
plifier, V, n et d sont des variables globales et ne sont pas passées comme paramètres.
74 CHAPITRE 3. VOYAGEUR DE COMMERCE

double D_rec(int t,set S){ // calcul récursif de D(t, S)


if(set_card(S)==1) return d(V[n-1],V[t]); // si |S| = 1
double w=DBL_MAX; // w=+∞
set T=set_minus(S,t); // crée T = S \ {t}
for(int x=0;x<n-1;x++) // pour tout x ∈ S:
if(set_in(x,T)) // si x ∈ T
w=fmin(w,D_rec(x,T)+d(V[x],V[t])); // minx (D(x, T ) + d(x, t))
set_free(T);
return w;
}

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;
}

Malheureusement, cette implémentation va se révéler inefficace. Ce n’est pas parce


qu’on a trouvé une formulation par récurrence que l’algorithme résultant est efficace.
L’arbre des appels est composé à la racine de n − 1 branches (à cause du for() dans
tsp_tour()), qui se subdivisent chacune en n − 2 appels lors du premier appel à D_rec(),
qui génère à son tour n − 3 appels, puis n − 4, etc. car le paramètre S (via T ) diminue
d’un point à chaque récursion (cf. figure 3.9 pour un calcul plus précis). Le nombre total
d’évaluations est au moins le nombre de feuilles de cet arbre qui vaut (n−1)!. (Un calcul
plus précis, cf. figure 3.9, montre qu’il y a un total de e · (n − 1)! nœuds.) La complexité
de cette implémentation est donc au moins ce nombre de nœuds (et au plus O(n) fois
plus pour tenir compte du temps de calcul en chaque nœud).
Ce n’est donc pas vraiment mieux que l’approche exhaustive 4 .
On voit aussi que l’algorithme va passer son temps à recalculer les mêmes sous-
problèmes. Chaque branche correspond à un choix du dernier points t ∈ S. C’est le
point t dans tsp_tour() puis le point x dans D_rec(), etc. Il y aura, par exemple, deux
4. En pratique c’est sans doute plus lent car on va faire en plus autant de malloc() et de free() pour
la construction de T dans les appels à D_rec().
3.3. PROGRAMMATION DYNAMIQUE 75

(t, S)
|S| − 1

(x, S \ {t})
|S| − 2 |S| − 1

Figure 3.9 – Arbre d’appels pour le calcul de D_rec(t,S). Il comprend (|S|−1)!


feuilles. Le nombre de nœuds au niveau i est de (|S| − 1) · (|S| − 2) · · · (|S| − i) =
P|S|−1
(|S| − 1)!/(|S| − i − 1)!. Donc le nombre de nœuds de l’arbre est i=0 (|S| −
P|S|−1
1)!/(|S| − i − 1)! = (|S| − 1)! i=0 1/(|S| − i − 1)!. Avec le changement de variable
j ← |S| − i − 1 et la définition de e du paragraphe 1.6.2, le nombre de nœuds
P|S|−1
est (|S| − 1)! j=0 1/j! → e · (|S| − 1)!. Quant au nombre total de nœuds dans
l’arbre des appels pour tsp_tour() il tend vers e · (n − 1)!, puisqu’il y a n − 1
appels à D_rec(t,S) avec |S| = n − 1 (boucle for(int t...)).

embranchements, t − x et x − t, correspondant aux deux façons de terminer la tournée.


Ces deux embranchements vont tout deux faire un appel à D(x0 , S \ {t, x}), et ce pour
chaque x0 ∈ S \{t, x}. Par exemple, D_rec(n-4,{0,...,n-4}) est évalué au moins deux fois,
pour t = vn−2 , x = vn−3 et x0 = vn−4 .
Une autre évidence de la présence de calculs inutiles est que les nœuds de l’arbre des
appels sont des paires (t, S) où t ∈ S et S ⊆ V ∗ . Le nombre d’appels distincts est donc au

plus |V ∗ |·2|V | = (n−1)·2n−1 ce qui est bien plus petit que le nombre nœuds de l’arbre des
appels qui est d’au moins (n − 1)!. Pour ne prendre qu’un exemple, considérons n = 20
et comparons :

(n − 1) · 2n−1 = 19 · 219 = 9 961 472


(n − 1)! = 19! = 121 645 100 408 832 000

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

64 ou 128 (dépendant de l’architecture), ce qui est amplement suffisant. L’opération la


plus utile sera la suppression d’un élément i d’un ensemble S, ce qui en binaire revient
à mettre à 0 le bit numéro i de S. [Question. Pourquoi peut-on se passer d’implémenter
l’appartenance ? ou comment implémenter un test comme « i ∈ S » ?]
Parenthèse. Le codage des sous-ensembles d’entiers de {0, . . . , n − 1} par des mots mémoires
(registres) de n bits (si n est assez petit donc) permet une implémentation très efficace en
C, correspondant à quelques instructions machines, de nombreuses opérations sur les en-
sembles.
Dans la table ci-dessous, on suppose que X, Y ⊆ {0, . . . , n − 1}. Les opérations x<<i et
x>>i correspondent aux décalages de x à gauche (resp. à droite) de i positions, pour i=
0, 1, ..., n − 1. Elles prennent un temps constant. L’opération X∆Y correspond à la différence
symétrique de X et Y , c’est-à-dire X∆Y = (X \Y )∪(Y \X). Notons que X = X∆ {0, . . . , n − 1}.
On rappelle également qu’en C la valeur entière 0 est synonyme du booléen false, et une
valeur non nulle synonyme de true. Pour min X et max X, les expressions donnent 0 si
X = ∅.

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

Certaines expressions se simplifient si n correspond à la taille d’un mot mémoire, par


exemple si n = 4*sizeof(int). On a alors, {0, . . . , n − 1} qui se code en ~0 ou -1, et X en
~X, puisque 1<<n vaut 0.
Une opération qui sert aussi souvent est celle permettant d’extraire d’un mot binaire la
position du premier bit (de poids faible ou least significant bit) ou du dernier bit (de poids
fort ou most significant bit). Par exemple, si n = 8 et X = 001101001 = 26 + 25 + 23 + 20 , la
position du premier bit est 0 (à cause de 20 ) tant dis que celle du dernier bit est 6 (à cause
de 26 ). Notons que le bit de poids fort vaut aussi log2 (X) = 6, pour tout entier X > 0. En C
 
on utilise ffs(X) pour la position du premier bit (first) et fls(X) pour le dernier (last). En
fait, ces fonctions C (de string.h) et qui prennent un temps constant, renvoient la position
plus un et 0 si X=0.

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

Figure 3.10 – Table D[t][S]. La colonne correspondant à l’ensemble vide


(S = 0000) n’est pas représentée. La ligne correspondante à v4 n’a pas besoin
d’être dans la table. Les numéros de la dernière ligne indique dans quel ordre
parcourir les colonnes pour remplir la table par taille croissante des sous-
ensembles. Mais d’autres ordres sont possibles ! Les cellules colorées n’ont
pas à être calculées. [Question. Pourquoi ?]

On remplit chaque case D[t][S] de la table à l’aide de l’équation (3.2). La difficulté


principale est de décider dans qu’elle ordre les remplir. La formule de récurrence pré-
cise que pour calculer D(·, S) il faut D(., T ) pour tous les sous-ensembles T ⊂ S ayant
juste un élément de moins. Il suffirait donc, par exemple, de lister les ensembles par
taille croissante. Par exemple, il faut remplir d’abord les colonnes 1, 2, 4, 8 (ensembles
de taille 1 où le cas de base s’applique), pour pouvoir remplir les colonnes 3, 5, 6, 9,
10, 12 (ensembles de taille 2). De plus, dans chaque colonne il faut faire attention de ne
remplir que les cases correspondant à des sommets de S (de couleur blanche).
La remarque importante qui évite d’avoir à calculer un ordre spécifique de traite-
ment des colonnes est que si T ⊂ S, alors les entiers correspondant vérifient T<S. En
effet, lorsqu’on enlève de S un de ses bits qui est à 1 (pour obtenir T), on obtient un
entier strictement plus petit 5 . Le corollaire est qu’on peut simplement parcourir les co-
lonnes de D[t][S] dans l’ordre croissant des indices S. Il faut cependant vérifier à chaque
colonne S si |S| = 1 ou |S| > 1. En fait, on veut tester si S = {t} ou pas, ce qui est facile
une fois implémentée une fonction comme set_minus(S,t) [Question. Pourquoi ?]. No-
tons également que tester si S possède plus d’un élément revient à tester si l’expression
5. Il faut faire attention aux nombres signés (int) et au cas où n correspond à la taille d’un mot
mémoire, soit 4*sizeof(int). En effet, dans ce cas, le bit de poids fort sert au codage du signe. Et donc
ajouter un bit, celui de poids fort, peut aboutir à un entier plus petit car négatif. Il faut alors utiliser la
version unsigned du type entier.
78 CHAPITRE 3. VOYAGEUR DE COMMERCE

S&(S-1) est non nulle. [Question. Pourquoi ?]

Récupérer la tournée. Pour déterminer la longueur opt(V , d) de la tournée optimale


une fois la table calculée, il faut examiner la dernière colonne, celle correspondant à
l’ensemble le plus grand soit S = V ∗ , et appliquer la formule de l’équation (3.1). Si
l’on souhaite de plus extraire la tournée (l’ordre des points réalisant ce minimum), il
faut stocker plus d’informations dans la table. Plus précisément, il faut mémoriser pour
quel point x la longueur minimum de D(t, S) a été atteinte, c’est-à-dire le sommet pré-
cédant t.

Complexité en espace. Le nombre de mots mémoire utilisés est, a un facteur constant


près, majoré par le nombre de cases de la table qui est (n − 1) · 2n−1 . Donc la complexité
en espace est O(n · 2n ).

Complexité en temps. L’algorithme se résume donc à remplir la table D[t][S] et à


récupérer la longueur de la tournée grâce à la dernière colonne. Déterminer la tournée,
en particulier le calcul de opt(V , d) grâce à l’équation (3.1), se fait en temps O(n) une
fois la table calculée.
On a vu que le nombre de cases de la table est (n−1)·2n−1 . Remplir une case nécessite
le calcul d’un minimum pour x ∈ S \ {t}. Cela prend un temps O(n) car il y n’a pas plus
de n éléments x à tester. Donc le remplissage de toutes les cases prend un temps de
O(n2 · 2n ), même si on remarque que la moitié des cases de la table ne sont pas utilisées.
(En fait chacune des lignes est utilisée à moitié puisqu’un point vi est présent dans
exactement la moitié des sous-ensembles S ⊆ V ∗ .)
Au total, la complexité en temps de l’algorithme est de :

O(n2 · 2n ) + O(n) = O(n2 · 2n ) .

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.

Mémorisation paresseuse. On peut se demander quelle serait la complexité d’une


implémentation de la version récursive vue page 73 avec une mémorisation paresseuse,
disons à l’aide d’une liste chaînée (comme discutée page 61).
Le nombre d’appels différents, on l’a vu page 75, est k = O(n · 2n ). La complexité de
D_rec(), sans les appels récursifs, est O(n). [Question. Pourquoi ?] La recherche dans une
liste chaînée a une complexité linéaire, soit O(k).
Pour résumer, on a donc k appels différents qui vont être cherchés/insérés en un
temps total O(k 2 ) = O(n2 · 22n ). Au final cela fait O(n3 · 22n ). Bien que plus rapide que
l’approche naïve en n!, c’est bien moins efficace que le remplissage direct de la table
D[t][S]. Notez bien que 22n = 2n · 2n .

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.

Figure 3.11 – Tournée produite par l’algorithme du « point le plus proche »


pour un ensemble V de 4k points positionnés sur k carrés disjoints de coté 1
(ici k = 3). La tournée optimale est de longueur 4k, obtenue en parcourant
l’enveloppe convexe de V . La tournée produite par l’algorithme est allongée
précisément des 2k − 2 arêtes bleues, soit un accroissement relatif de (2k −
2)/4k ∼ 1/2 = 50%.

Comme on peut le voir, le résultat ne donne pas nécessairement la tournée de lon-


gueur minimum. En contrepartie l’algorithme est très rapide. En effet, chaque point
80 CHAPITRE 3. VOYAGEUR DE COMMERCE

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)).

(a) (b) (c)


Figure 3.12 – Exemple de tournée sur n = 100 points répartis aléatoirement
uniformément sur un cercle de rayon r = 140. La longueur moyenne `¯ d’une
corde d’un cercle de rayon r vaut 6 4r/π ≈ 178. La tournée aléatoire (b) a pour
longueur L ≈ 17 027 ≈ n`¯ alors que la longueur optimale (c) est opt(V , d) ≈
879 ce qui est très proche du périmètre qui vaut P = 2πr ≈ 880. L’accroisse-
¯ = n·(4r/π)/(2πr) = n·2/π2 =
ment relatif L/opt(V , d) est donc proche de n`/P
Θ(n). [Exercice. Montrer que si les points sont en position convexe (comme
sur un cercle par exemple), alors la tournée de longueur minimum visite les
points dans l’ordre donné par l’enveloppe convexe.]

Souvent on souhaite trouver un compromis entre la qualité de la solution et le temps


de calcul.

3.4.1 Algorithme glouton : un principe général

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

L’algorithme glouton (greedy en Anglais) est une stratégie algorithmique qui


consiste à former une solution en prenant à chaque étape le meilleur choix sans
faire de backtracking, c’est-à-dire sans jamais remettre en cause les choix précé-
dents.

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.

L’algorithme de Kruskal, pour calculer un arbre couvrant de poids minimum, est


issu de la même stratégie : « essayer d’ajouter en priorité les arêtes de plus petit poids ».
Cette stratégie est optimale pour l’arbre de poids minimum, pas pour bin packing.
Pour le Voyageur de commerce la stratégie gloutonne consiste à construire la tour-
née en ajoutant à chaque fois le points qui minimise la longueur de la tournée courante,
ce qui revient à prendre à chaque fois, parmi les points restant, celui le plus proche du
dernier point sélectionné. C’est donc exactement l’algorithme du « point le plus proche »
discuté précédemment.

3.4.2 Problème d’optimisation


Les problèmes d’optimisations sont soit des minimisations (comme le Voyageur
de commerce) soit des maximisations (comme chercher le plus long chemin dans un
82 CHAPITRE 3. VOYAGEUR DE COMMERCE

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 .

Définition 3.1 Une α-approximation, pour un réel α > 0 et un problème d’optimi-


sation Π donnés, est un algorithme polynomial A qui donne une solution pour toute
instance I ∈ Π telle que :
• A(I) 6 α · optΠ (I) dans le cas d’une minimisation ; et
• A(I) > α · optΠ (I) dans le cas d’une maximisation.
La valeur α est le facteur d’approximation de l’algorithme A.

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

La tournée produite par l’algorithme glouton à partir 9 de A est

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.

De manière générale, on nomme heuristique tout algorithme supposé efficace en


pratique qui produit un résultat sans garantie de qualité par rapport à la solution
optimale.

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.

3.4.3 Autres heuristiques


Il existe de nombreuses autres heuristiques, et un ouvrage de 600 pages traite de leur
implémentation [ABCC06]. Une famille parmi elles est appelées optimisations locales.
On part d’une solution (une tournée), et on cherche dans son proche « voisinage 11 » s’il
n’y a pas une meilleure solution (une tournée plus courte donc). Et on recommence tant
qu’il y a un gain. C’est la base des méthodes de descente en gradient pour l’optimisation
de fonction (cf. figure 3.14). On ne traitera pas ici cette technique générale très utilisées
en IA.

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

Figure 3.14 – Méthode de descente en gradient : les points du maillage sont


des solutions et les arêtes permettent une descente vers un optimum local.
© Navid Azizan chercheur au Caltech en « apprentissage automatique » ou
machine learning, une branche de l’IA.

Pour le Voyageur de commerce cela correspond aux heuristiques 2-Opt ou 3-Opt


qui consistent à flipper 12 deux ou trois arêtes (cf. figure 3.15). Aussi étrange que cela
puisse paraître le temps de convergence vers l’optimal local peut prendre un nombre
d’étapes exponentielles 13 même dans le cas de la distance Euclidienne [ERV07].

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

(a) (b) (c)


Figure 3.15 – Heuristique (a) 2-Opt, (b) de l’économie ou (c) de l’insertion
aléatoire.

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.

Figure 3.17 – Tournée bitonique optimale.

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

constant le problème du Voyageur de commerce dans toute sa généralité, sauf si les


classes de complexité P et NP sont égales 16 , problème notablement difficile à 1 M$.
Pour le voir on peut transformer une instance d’un problème réputé difficile en une
instance du Voyageur de commerce de sorte que la tournée optimale (et même une ap-
proximation) donne une solution au problème difficile initial. Cela s’appelle une réduc-
tion. On va utiliser un problème de la classe NP-complet. Ce sont des problèmes de NP
qui sont réputés difficiles : on ne connaît pas d’algorithme de complexité polynomiale
mais on arrive pas à démontrer qu’il n’y en a pas. Donc sauf si P=NP, on ne peut pas
trouver un algorithme efficace pour le Voyageur de commerce car sinon il permettrait
de résoudre un problème réputé difficile (et même tous ceux qui s’y réduisent).
Le problème difficile (NP-complet) que l’on va considérer est celui du Cycle Ha-
miltonien, un problème proche du problème Chemin Hamiltonien rencontré au para-
graphe 1.5.3, consistant à déterminer si le graphe possède un cycle, dit hamiltonien 17 ,
passant une et une seule fois par chacun de ses sommets (cf. figure 3.18).

Figure 3.18 – Un graphe avec et un graphe sans aucun cycle hamiltonien.

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.

Figure 3.19 – Le jeu de genre « Snakes ».

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

On va construire une instance du Voyageur de commerce à partir d’un graphe H


donné à n sommets. L’ensemble des points est VH = V (H) et la distance dH définie par
(voir la figure 3.20) :
(
1 si vi vj ∈ E(H)
dH (vi , vj ) =
n2 sinon
La paire (VH , dH ) est une instance particulière du Voyageur de commerce.

1 n2 = 81

Figure 3.20 – Construction d’une instance (VH , dH ) pour l’approximation du


Voyageur de commerce à partir d’une instance H du problème Cycle Ha-
miltonien qui est NP-complet. Ici H est une grille 3 × 3. Les arêtes de H sont
valuées 1, les autres n2 .

Considérons une α-approximation du Voyageur de commerce, un algorithme noté


A, où α est une constante < n. Alors 18 A(VH , dH ) < n2 si et seulement si H possède un
cycle hamiltonien.
En effet, si H possède un cycle hamiltonien, l’algorithme d’approximation devra ren-
voyer une tournée de longueur au plus αn puisque la longueur optimale est dans ce cas
n. Cette longueur αn < n2 par hypothèse sur α. Et si H ne possède pas de cycle hamilto-
nien, la tournée renvoyée par A contiendra au moins une paire de points visités consécu-
tivement vi , vi+1 ne correspondant pas à une arête de H, donc avec dH (vi , vi+1 ) = n2 > αn.
Dit autrement, étant donnée une α-approximation A pour le Voyageur de com-
merce, on pourrait en déduire l’algorithme suivant pour résoudre Cycle Hamiltonien :

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)|.

On a donc réduit le problème du Cycle Hamiltonien à celui de l’approximation


du Voyageur de commerce, c’est-à-dire qu’on peut résoudre Cycle Hamiltonien à
l’aide d’une α-approximation pour Voyageur de commerce, et ce en temps polynomial
puisque chacune des deux étapes de l’algorithme CycleHamiltonien prend un temps

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

le graphe obtenu à partir de H en supprimant l’arête x − y et en ajoutant deux som-


mets de degré un, x0 et y 0 , connectés à x et y respectivement. Alors H possède un cycle
hamiltonien si et seulement si Hx,y 0 possède un chemin hamiltonien pour une certaine

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

On pourrait arguer que la réduction précédente produit une instance du Voyageur


de commerce qui ne satisfait pas l’inégalité triangulaire. Cela ne prouve en rien, par
exemple, qu’il n’y a pas d’algorithme efficace dans le cas du TSP métrique. En fait, il
a été démontré dans [Kar15] que le TSP métrique ne peut être approché (en temps
polynomial) à un facteur < 1 + 1/122, sauf si les classes P et NP sont confondues, ce
qui est < 1% de l’optimal. À titre de comparaison, le meilleur algorithme d’approxi-
mation connu pour le TSP métrique a un facteur d’approximation de 1.5 (voir le para-
graphe 3.4.8), soit 50% de l’optimal. Cela laisse donc une grande marge d’amélioration.
Des résultats d’inapproximabilité sont aussi donnés dans [Kar15] pour les variantes TSP
asymétrique et TSP graphique mais qui restent en dessous des 2%.

3.4.5 Cas euclidien


Lorsque les points sont pris dans un espace euclidien de dimension δ, c’est-à-dire
V ⊂ Rδ , et que d correspond à la distance euclidienne, alors il existe un algorithme
d’approximation réalisant le compromis temps vs. approximation suivant :

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( ε δ)
.

On parle parfois de schéma d’approximation polynomial, car le facteur d’approxi-


mation 1 + ε peut-être choisit arbitrairement proche de 1 tout en gardant un temps
polynomial, ε et δ étant ici des constantes.
Notons que, par exemple, pour δ = 2 et ε = 0.1 (soit le plan avec au plus 10% de
l’optimal), la complexité en temps est de seulement n · (log n)O(1) ce qui est moins que
le nombre de distances soit Θ(n2 ). [Question. Pourquoi ?] Ce résultat a valu à Arora et
Mitchell le Prix Gödel en 2010. L’algorithme est réputé très difficile à implémenter, et
en pratique on continue à utiliser des heuristiques.

3.4.6 Une 2-approximation


On va montrer que l’algorithme suivant est une 2-approximation. Il est plus général
que l’algorithme d’Arora-Mitchell car il s’applique non seulement au cas de la distance
euclidienne, mais aussi à toute fonction d vérifiant l’inégalité triangulaire. Il est aussi
très simple à implémenter.
L’algorithme ApproxMST est basé sur le calcul d’un « arbre couvrant de poids mi-
nimum » que se dit Minimum Spanning Tree (MST) en Anglais. Rappelons qu’il s’agit
de trouver un arbre couvrant dont la somme des poids de ses arêtes est la plus petite
possible.
92 CHAPITRE 3. VOYAGEUR DE COMMERCE

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 .

1. Calculer un arbre couvrant de poids minimum T sur le graphe complet dé-


fini par V et les arêtes valuées par d.
2. La tournée est définie par l’ordre de visite des sommets selon un parcours en
profondeur d’abord de T .

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.


Exemple. Voir la figure 3.22.

(a) (b) 8 9 (c) 8 9

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.

Complexité. Calculer un arbre couvrant de poids minimum prend un temps de


O(m log n) pour un graphe ayant m arêtes et n sommets, en utilisant l’algorithme de
Kruskal qui est assez simple à programmer. Avec l’algorithme de Prim (et une bonne
structure de données) c’est O(n2 ). Ici, le graphe est complet, donc m = Θ(n2 ). Ainsi, la
première étape prend un temps O(n2 ) avec Prim ou O(n2 log n) avec Kruskal.
Le parcours en profondeur prend un temps linéaire en le nombre de sommets et
d’arêtes du graphe. Ici le graphe est un arbre sur n sommets, et donc n − 1 arêtes. Cette
3.4. APPROXIMATION 93

étape prend donc un temps O(n).


On a donc montré :

Proposition 3.1 L’algorithme ApproxMST a pour complexité O(n2 ).

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).

Facteur d’approximation. C’est le point difficile en général. Il faut relier la longueur


de la tournée optimale à la tournée construite par l’algorithme.

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

Preuve. Soit T l’arbre couvrant de poids minimum calculé à l’étape 1 de l’algorithme.


Le poids de T , vu comme un graphe arête-valué (T , d), vaut d(T ) puisque le poids de
chaque arête de T est la distance donnée par d entre ses extrémités.
À partir de n’importe quelle tournée pour (V , d), on peut former un cycle arête-valué
(C, d) dont le poids d(C) correspond précisément à la longueur de la tournée (la somme
des poids des arêtes qui vaut la distance d ici). Si on supprime une arête e quelconque
de C, alors on obtient un arbre couvrant, C \{e} n’ayant pas de cycle et couvrant toujours
tous les sommets. En particulier, pour le cycle C ∗ correspondant à la tournée optimale,
on a :
d(C ∗ ) > d(C ∗ \ {e}) = d(C ∗ ) − d(e) = opt(V , d) − d(e) > d(T )
puisque T est un arbre de poids minimum et que d(C ∗ ) = opt(V , d). On a donc montré
que opt(V , d) > d(T ) + d(e) > d(T ).
Parenthèse. On peut raffiner un peu plus cette inégalité et donner une meilleure borne in-
férieure sur opt(V , d), ce qui est toujours intéressant pour évaluer les performances des
heuristiques :
opt(V , d) > d(T ) + d(e+ ) (3.3)
où e+ est l’arête juste plus lourde que l’arête la plus lourde de T . Autrement dit, dans l’ordre
croissant des arêtes du graphe (ici une clique), si ei était la dernière arête ajoutée à T , alors
e+ = ei+1 . En effet, lorsqu’on forme un arbre à partir de C ∗ , plutôt que de choisir n’importe
qu’elle arête e, on peut choisir d’enlever l’arête e∗ la plus lourde de C ∗ . L’arête e∗ ne peut être
dans le MST [Question. Pourquoi ?]. Elle est aussi forcément plus lourde que la plus lourde
du MST, c’est-à-dire d(e∗ ) > d(e+ ). [Question. Pourquoi ?] On a vu (en posant e = e∗ ) que
opt(V , d) − d(e∗ ) > d(T ), ce qui implique opt(V , d) > d(T ) + d(e∗ ) > d(T ) + d(e+ ) et prouve
l’équation 3.3. Évidemment d(T ) + d(e∗ ) est une meilleure borne inférieure, mais e∗ est par
essence difficile à calculer (il faudrait connaître C ∗ ), contrairement à e+ .

Il reste à majorer la longueur de la tournée renvoyée par l’algorithme en fonction de


d(T ). Pour cela, on a besoin de l’inégalité triangulaire.

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 ).

Preuve. Soit v0 − v1 − · · · − vn−1 − v0 la tournée renvoyée par l’algorithme. Notons Pi le


chemin dans T entre vi et son suivant vi+1 (modulo n). L’inégalité triangulaire permet
d’affirmer que la longueur du segment vi −vi+1 vaut au plus d(Pi ), le poids
P du chemin Pi .
La longueur de la tournée renvoyée par l’algorithme est donc au plus n−1 i=0 d(Pi ). Pour
montrer que cette somme est au plus 2 · d(T ), il suffit d’observer que chaque arête e de
T appartient à au plus deux chemins parmi P0 , . . . , Pn−1 .
Pour le voir (cf. figure 3.23), on peut d’abord redessiner l’arbre T , éventuellement
en réordonnant les fils autour de certains sommets, de sorte que parcours DFS de T
3.4. APPROXIMATION 95

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 .

corresponde à un parcours de sa face extérieure. Cela ne change évidemment pas la


longueur des chemins Pi dans T . La suite des chemins P0 , . . . , Pn−1 constitue alors un
simple parcours de la face extérieure de T (les anglo-saxons parlent aussi de parcours
eulérien 21 ), chaque arête étant visitée en descendant (par exemple si Pi est une arête
vi − vi+1 de T ) ou en remontant (si Pi part d’une feuille). 2

La combinaison des propositions 3.2 et 3.3 permet de conclure que la longueur de


la tournée produite par l’algorithme est au plus deux fois l’optimale. En effet, on vient
de voir que ApproxMST(V , d) 6 2 · d(T ) et que d(T ) < opt(V , d). On en déduit donc que
ApproxMST(V , d) < 2 · d(T ). Avec la proposition 3.1, on a donc montré que :

Proposition 3.4 L’algorithme ApproxMST est une 2-approximation.

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.

Parenthèse. La validité de l’algorithme de Kruskal se démontre par un argument d’échange,


qui est souvent utilisé pour prouver l’optimalité d’un algorithme glouton.
On considère l’arbre A construit par Kruskal et B un arbre de poids minimum. L’hy-
pothèse est que A , B. L’argument consiste à montrer que dans ce cas on peut, à l’aide

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.]

Le problème général sous-jascent est de maintenir les composantes connexes d’un


graphe (ici une forêt) qui au départ est composé de sommets isolées et qui croît pro-
gressivement par ajout d’arêtes (cf. figure 3.24).

C v
C0
u

Figure 3.24 – Maintient des composantes connexes d’un graphe à l’aide


d’une forêt couvrante : on ajoute une arête seulement si elle ne forme pas
de cycle.

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) :

• Fusionner deux ensembles donnés ;


• Trouver l’ensemble contenant un élément donné.

a b c d e f a b c d e f

Figure 3.25 – Exemple de 4 fusions d’ensembles sur 6 éléments, aboutissant


aux ensembles {a, b, c, d} et {e, f }. Les ensembles sont codés par des arbres
enracinés. Ils sont identifiés par leur racine.
3.4. APPROXIMATION 97

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.

1. Initialiser T := (V (G), ∅).


2. Pour chaque arête uv de G prise dans l’ordre croissant de leur poids ω :
(a) Trouver la composante C de u et la composante C 0 de v ;
(b) si C , C 0 , ajouter uv à T et Fusionner C et C 0 .
3. Renvoyer T .

La structure de données qui supporte ces opérations s’appelle Union-Find. Comme


on va le voir, elle est particulièrement simple à mettre en œuvre et redoutablement
efficace.
La structure de données représente chaque ensemble par un arbre enraciné, les nœ-
uds étant les éléments de l’ensemble. L’ensemble est identifié par la racine de l’arbre.
Donc trouver l’ensemble d’un élément revient en fait à trouver la racine de l’arbre le
contenant. Notez bien que l’arbre enraciné représentant un ensemble (ou une compo-
sante connexe) n’a pas a priori de rapport avec l’arbre T construit par Kruskal.
On code un arbre enraciné par la relation de parenté, un tableau parent[], avec la
convention que parent[u]=u si u est la racine. On suppose qu’on a un total de n éléments
qui sont, pour simplifier, des entiers (int) que l’on peut voir comme les indices des
éléments. Au départ, tout le monde est racine de son propre arbre qui comprend un
seul nœud : on a n éléments et n singletons.

// Initialisation Union-Find (v1)


int parent[n];
for(u=0; u<n; u++) parent[u]=u;

Pour trouver l’ensemble contenant un élément, on cherche la racine de l’arbre auquel


il appartient. Pour la fusion de deux ensembles, identifiés par leur racine (disons x et
y), on fait pointer une des deux racines vers l’autre (ici y pointe vers x). Ce qui donne,
en supposant pour simplifier que parent[] est une variable globale :
98 CHAPITRE 3. VOYAGEUR DE COMMERCE

// 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).

Attention ! Pour le problème du maintient des composantes connexes d’un graphe,


l’arbre enraciné qui représente l’ensemble n’a rien à voir avec la composante connexe
elle-même (qui dans Kruskal se trouvent être aussi un arbre). Les arcs des arbres codant
les ensembles ne correspondent pas forcément à des arêtes de la composante. Dans la
figure 3.26, on va fusionner la composante de u avec celle de v à cause d’une arête u − v.
On obtiendra une nouvelle composante représentée par un arbre ayant un arc entre x
et y, mais sans arc entre u et v.
On fait la fusion d’ensembles et pas d’éléments. Par exemple pour fusionner l’en-
semble contenant u avec l’ensemble contenant v, il faut d’abord chercher leurs racines
respectives, ce qui se traduit par (cf. aussi la figure 3.26) :

Union(Find(u),Find(v));

En terme de complexité, Union() prend un temps constant, et Find(u) prend un


temps proportionnel à la profondeur de u, donc au plus la hauteur de l’arbre. Mal-
heureusement, après seulement n − 1 fusions, la hauteur d’un arbre peut atteindre n − 1
comme dans l’exemple suivant :
3.4. APPROXIMATION 99

for(int u=1; u<n; u++)


Union(Find(u),Find(0));

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.

Première optimisation. Cette optimisation, dite du rang, consiste à fusionner le plus


petit arbre avec le plus grand (en terme de hauteur). L’idée est que si on rattache un
arbre peu profond à la racine du plus profond, alors la hauteur du nouvel arbre ne
changera pas (cf. figure 3.27). Elle ne changera que si les arbres sont de même hauteur.
Pour cela on ajoute donc un simple tableau rank[] permettant de gérer la hauteur de
chacun des arbres qu’il va falloir maintenir. On va mettre à jour le rang seulement s’il
doit augmenter. Pour cette première optimisation, si x est une racine, rank[x] va corres-
pondre à la hauteur de son arbre. Pour la deuxième, il s’agira d’un simple majorant de
cette hauteur.

// Initialisation Union-Find (v2)


int parent[n], rank[n];
for(u=0; u<n; u++) parent[u]=u, rank[u]=0;

Parenthèse. De manière générale en algorithmique, l’augmentation de espace de travail peut


permettre un gain de temps. C’est par exemple le cas en programmation dynamique discutée
au chapitre 2. Cela a cependant un coût : celui de la mise à jour des informations lors de
chaque opération. Et c’est nécessaire ! En effet, s’il n’y avait pas besoin de mise à jour, c’est
que l’information n’était pas vraiment utile. Pour les structures de données, il y a donc
un compromis entre le temps de requête (c’est-à-dire la complexité en temps des opérations
supportées par la structure de données), la taille de la structure de données et le temps de
mise à jour qui inévitablement va s’allonger en fonction de la taille.
100 CHAPITRE 3. VOYAGEUR DE COMMERCE

// 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

Figure 3.27 – Opération Union(x,y) avec optimisation dite du rang : c’est


l’arbre le plus petit qui se raccroche au plus grand.

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.

Proposition 3.5 Tout arbre de rang r possède au moins 2r éléments.

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 !

Deuxième optimisation. La seconde optimisation, appelée compression de chemin, est


basée sur l’observation qu’avant de faire Union() on fait un Find() (en fait deux). Lors du
Find() sur un élément u qui a pour effet de parcourir le chemin de u vers sa racine, on
3.4. APPROXIMATION 101

en profite pour connecter directement à la racine chaque élément du chemin parcouru.


C’est comme si le chemin de u à sa racine avait été compressé en une seul arête ramenant
tous les sous-arbres accrochés à ce chemin comme fils de la racine. Voir la figure 3.28.

x x
Find(u)
u

Figure 3.28 – Opération Find(u) avec compression de chemin.

Cela ne change pas la complexité de l’opération de Find(u), il s’agit d’un parcours


que l’on effectue de toute façon. Mais cela va affecter significativement la complexité des
Find() ultérieurs puisque l’arbre contenant u a été fortement raccourcis. Voici le code
(final) :

// Find (v2)
int Find(int u){
if(u!=parent[u]) parent[u]=Find(parent[u]);
return parent[u];
}

On pourrait éviter la récursivité avec deux parcours : un premier pour trouver la


racine et un second pour changer tous les parents.
On remarque que rank[] n’est pas modifié par Find() alors que la hauteur de l’arbre
est susceptible de diminuer. Le rang devient un simple majorant. Ce n’est pas gênant,
car ce qui compte c’est que les Find() aient un coût faible. Avec cette optimisation, il
devient très difficile d’avoir des arbres de grande profondeur. Car pour qu’un élément
soit profond, il faut l’avoir ajouté, donc avoir fait un Find() sur cet élément, ce qui
raccourcit l’arbre. On peut montrer :

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.

Parenthèse. La fonction inverse d’Ackermann apparaît aussi dans le contexte de la géomé-


trique discrète, où il s’agit de déterminer dans un arrangement de n segments de droite du
plan la complexité de l’enveloppe basse, c’est-à-dire le nombre de sous-segments que verrait
un observateur basé sur l’axe des abscisses (voir la figure 3.29 à gauche).
Cette complexité intervient dans les algorithmes permettant de déterminer cette enve-
loppe basse, comme par exemple les algorithmes de rendu par lancer de rayons (ray-tracing
ou eye-tracing) pour des scènes composés d’objets polygonaux s’intersectant (ou non, comme
sur la figure 3.29 à droite).

1 3
2
4 5

1 2 1 3 1 3 2 4 5 4 5 2 3

Figure 3.29 – À gauche, l’enveloppe basse pour n = 5 segments, représen-


tée approximativement en pointillé noir. Sa complexité, notée EB(n), corres-
pond au nombre d’intervalles des sous-segments projetés sur l’axe des abscisses.
On a EB(5) = 13 et la suite 1, 2, 1, 3, 1, ...5, 2, 1 forme une suite de Davenport-
Schinzel d’ordre trois. De manière générale, EB(n) ∼ 2n · ᾱ(n) avec ᾱ(n) =
min {i : A(i, i) > n}, A(i, j) étant la fonction d’Ackermann rencontrée page 93.
C’est presque linéaire car on peut montrer que ᾱ(n) 6 α(n2 , n) + 1 (cf. page 102),
et on rappelle que α(m, n) 6 4 pour toutes valeurs réalistes de m et n. À droite,
rendu d’objects 3D obtenus par algorithmes de lancer de rayons où cette com-
plexité intervient (mais pas que). Source Wikipédia.
3.4. APPROXIMATION 103

3.4.8 Algorithme de Christofides


Il s’agit d’une variante de l’algorithme précédant, due à Nicos Christofides en
1976 [Chr76], et qui donne une 1.5-approximation. C’est actuellement le meilleur algo-
rithme d’approximation pour le TSP métrique.
L’algorithme utilise la notion de couplage parfait de poids minimum. Il s’agit d’ap-
parier les sommets d’un graphe arête-valuée (G, ω) par des arêtes indépendantes de G
(deux arêtes ne pouvant avoir d’extrémité commune). Bien sûr, pour pouvoir apparier
tous les sommets, il faut que G possède un nombre pair de sommets. Un couplage parfait
est ainsi une forêt couvrante F où chaque composante est composée d’une seule arête 23 .
Parmi tous les couplages F de (G, ω) il s’agit de trouver celui de poids ω(F) minimum.
Un couplage parfait de poids minimum, s’il en existe un 24 , peut être calculé en temps
O(n3 ). Ce dernier algorithme est complexe et ne sera pas abordé dans ce cours.

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 .

1. Calculer un arbre couvrant de poids minimum T sur le graphe complet dé-


fini par V et les arêtes valuées par d.
2. Calculer l’ensemble I des sommets de T de degré impair.
3. Calculer un couplage parfait de poids minimum F pour le graphe induit
par I.
4. La tournée est définie par un circuit eulérien du multi-graphe T ∪ F dans
lequel on ignore les sommets déjà visités.

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.

Exemple. La figure 3.30 représente l’exécution de l’algorithme


ApproxChristofides(V , d), sur la même instance que l’exemple de la figure 3.22.
La tournée n’est pas exactement la même.

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

(a) (b) (c) 9 8

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).

Validité. On peut se convaincre de la validité de l’algorithme en remarquant :

• 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.

• L’ajout du couplage parfait F à T produit un multi-graphe où tous les sommets


sont de degré pairs, puisqu’on ajoute exactement une arête incidente à chaque
sommet de degré impair de T .

• Le circuit eulérien de T ∪ F visite au moins une fois chacun des sommets de V ,


puisque toutes les arêtes sont visitées et que T couvre V .

Facteur d’approximation. La longueur de la tournée renvoyée par l’algorithme,


ApproxChristofides(V , d), est au plus la somme des poids des arêtes du circuit eulé-
rien de T ∪ F. Cela peut être moins car on saute les sommets déjà visités, ce qui grâce à
l’inégalité triangulaire produit un raccourcis.
On a donc ApproxChristofides(V , d) 6 d(T ∪F) = d(T )+d(F). On a déjà vu que d(T ) <
d(C ∗ ) = opt(V , d). Soit CI la tournée optimale pour l’instance (I, d), donc restreinte aux
sommets I. Clairement d(CI ) 6 d(C ∗ ) puisque I ⊆ V .
Remarquons qu’à partir de la tournée CI on peut construire deux couplages parfaits
pour I : l’un obtenu en prenant une arête sur deux, et l’autre en prenant son complé-
mentaire. Le plus léger d’entre eux a un poids 6 12 d(CI ) puisque leur somme fait d(CI ).
Il suit que le couplage parfait F de poids minimum pour I est de poids d(F) 6 12 d(CI ).
3.4. APPROXIMATION 105

En combinant les différentes inégalités on obtient que :

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

ce qui montre que le facteur d’approximation est de 1.5.


Il est clair que l’algorithme ApproxChristofides est de complexité polynomiale.
L’étape la plus coûteuse (qui est aussi la plus complexe) est celle du calcul du couplage
parfait de poids minimum en O(n3 ). La complexité totale de l’algorithme étant ainsi de
O(n3 ). On a donc montrer que :

Proposition 3.7 L’algorithme ApproxChristofides est une 1.5-approximation.

Parenthèse. On peut construire une instance critique pour l’algorithme ApproxChristo-


fides, c’est-à-dire d’une instance où le facteur d’approximation est atteint (ou approché
asymptotiquement). En effet, ce n’est parce qu’on a prouvé que le facteur d’approximation
est un certain α qu’il existe des instances où ce facteur est atteint.
On choisit un nombre n impair de points formant bn/2c triangles équilatéraux de lon-
gueur unité comme le montre la figure 3.31.

s t

Figure 3.31 – Instance critique pour ApproxChristofides composé de bn/2c tri-


angles équilatéraux unités. L’arbre T est en vert et le couplage F en bleu.

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

lesman Problem – A Computational Study, Princeton Series in Applied Mathe-


matics, Wiley-Interscience, 2006. isbn : 978-0-691-12993-8.
[Aro98] S. Arora, Polynomial time approximation schemes for euclidean traveling sales-
man and other geometric problems, Journal of the ACM, 45 (1998), pp. 753–
782. doi : 10.1145/290179.290180.
[BH15] J. Brecklinghaus and S. Hougardy, The approximation ratio of the greedy
algorithm for the metric traveling salesman problem, Operations Research Let-
ters, 43 (2015), pp. 259–261. doi : 10.1016/j.orl.2015.02.009.
[Cha00] B. Chazelle, A minimum spanning tree algorithm with inverse-Ackermann
type complexity, Journal of the ACM, 46 (2000), pp. 1028–1047. doi :
10.1145/355541.355562.
[Chr76] N. Christofides, Worst-case analysis of a new heuristic for the travelling sales-
man problem, Management Science Research Report 388, Graduate School
of Industrial Administration, Carnegie-Mellon University, Pittsburgh, Fe-
bruary 1976.
[CKT99] B. Chandra, H. J. Karloff, and C. A. Tovey, New results on the old k-Opt
algorithm for the traveling salesman problem, SIAM Journal on Computing, 28
(1999), pp. 1998–2029. doi : 10.1137/S0097539793251244.
[CLRS01] T. H. Cormen, C. E. Leiserson, R. L. Rivest, and C. Stein, Introduction à
l’algorithmique (2e édition), DUNOD, 2001.
[Coo11] W. J. Cook, In Pursuit of the Traveling Salesman : Mathematics at the Limits of
Computation, Princeton University Press, 2011. isbn : 978-0-691-15270-7.
[ERV07] M. Englert, H. Röglin, and B. Vöcking, Worst case and probabilistic analysis
of the 2-opt algorithm for the TSP, in 18th Symposium on Discrete Algorithms
(SODA), ACM-SIAM, January 2007, pp. 1295–1304.
[FS89] M. L. Fredman and M. E. Saks, The cell probe complexity of dynamic data struc-
tures, in 21st Annual ACM Symposium on Theory of Computing (STOC),
ACM Press, May 1989, pp. 345–354. doi : 10.1145/73007.73040.
[GYZ02] G. Gutin, A. Yeo, and A. Zverovich, Traveling salesman should not be greedy :
domination analysis of greedy-type heuristics for the TSP, Discrete Applied Ma-
thematics, 117 (2002), pp. 81–86. doi : 10.1016/S0166-218X(01)00195-0.
[HW15] S. Hougardy and M. Wilde, On the nearest neighbor rule for the metric trave-
ling salesman problem, Discrete Mathematics, 195 (2015), pp. 101–103. doi :
10.1016/j.dam.2014.03.012.
[JM97] D. S. Johnson and L. A. McGeoch, The traveling salesman problem : A case
study in local optimization, 1997. Local Search in Combinatorial Optimiza-
tion, E.H.L. Aarts and J.K. Lenstra (eds.), John Wiley andSons, London, 1997,
pp. 215-310.
108 BIBLIOGRAPHIE

[Kar15] M. Karpinski, Towards better inapproximability bounds for TSP : A challenge of


global dependencies, in Electronic Colloquium on Computational Complexity
(ECCC), vol. Report No. 97, June 2015.
[KB05] C. S. Kaplan and R. Bosch, TSP art, in Renaissance Banff : Ma-
thematical Connections in Art, Music and Science, R. Sarhangi and
R. V. Moody, eds., Bridges Conference, July 2005, pp. 301–308.
http://archive.bridgesmathart.org/2005/bridges2005-301.html.
[Mit99] J. S. Mitchell, Guillotine subdivisions approximate polygonal subdivisions : A
simple polynomial-time approximation scheme for geometric TSP, k-MST, and
related problems, SIAM Journal on Computing, 28 (1999), pp. 1298–1309.
doi : 10.1137/S0097539796309764.
[Pet99] S. Pettie, Finding minimum spanning trees in O(mα(m, n)), Tech. Rep. TR-99-
23, University of Texas, October 1999.
[RSLI77] D. J. Rosenkrantz, R. E. Stearns, and P. M. Lewis II, An analysis of several
heuristics for the traveling salesman problem, SIAM Journal on Computing, 6
(1977), pp. 563–581. doi : 10.1137/0206041.
[Tar79] R. E. Tarjan, A class of algorithms which require nonlinear time to maintain
disjoint sets, Computer and System Sciences, 18 (1979), pp. 110–127. doi :
10.1016/0022-0000(79)90042-4.
[UL97] C. Umans and W. Lenhart, Hamiltonian cycles in solid grid graphs, in
38th Annual IEEE Symposium on Foundations of Computer Science
(FOCS), IEEE Computer Society Press, October 1997, pp. 496–505. doi :
10.1109/SFCS.1997.646138.
4
CHAPITRE
Navigation

Physarum polycephalum, organisme unicellulaire (donc


sans système nerveux ni cerveau), est capable de résoudre
des problèmes de calcul de plus courts chemins [NYT00].

Sommaire
4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
4.2 L’algorithme de Dijkstra . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
4.3 L’algorithme A* . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
4.4 Morale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136

Mots clés et notions abordées dans ce chapitre :


• Intelligence Artificielle (IA)
• pathfinding, navigation mesh
• algorithme de Dijkstra
• algorithme A*

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é.

Figure 4.1 – Animation de bots.

4.1.2 Navigation mesh


Dans un jeu vidéo il faut spécifier par une structure abstraite où peuvent se dépla-
cer les personnages. C’est le navigation mesh. Il s’agit d’un graphe. Les sommets sont les
points d’intérêts ou point de cheminement (waypoints en Anglais) avec des coordonnées
2D ou 3D, et les arêtes interconnectent les points d’intérêts, le plus souvent en définis-
sant un tuilage. Ce tuilage est à base de triangulations, de grilles ou d’autres types de
maillage du plan, voir de l’espace, plus ou moins dense (figure 4.2). Bien sûr ce graphe
est invisible au joueur qui doit avoir l’impression de naviguer dans un vrai décor. Il y a
un compromis entre la taille du graphe (densité du maillage qui impacte les temps de
calcul) et le réalisme de la navigation qui va en résulter.
2. C’est parce qu’il y aurait des centaines de millions (n2 ) de trajectoires à stocker pour une map avec
quelques milliers (n) de points d’intérêts.
3. La notion de temps-réel se réfère au fait que le temps de réponse de la machine est limité de manière
absolue et garantie, en pratique à quelques fractions de secondes.
4.1. INTRODUCTION 111

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.2 – Constructions de navigation meshes. De gauche à droite : dé-


cor 2D, graphe de contour, graphe de visibilité (une arête connecte des points
d’intérêts visibles), maillage carré (ici des 4-voisinages), triangulation avec
source et cible.

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

Figure 4.4 – Adoucissement d’un chemin si → ti , ici à l’aide d’une courbe


B-spline cubique. On peut utiliser aussi des trajectoires « répulsives » évitant
la collision avec les obstacles.

Il y a aussi les algorithmes qui déterminent le déplacement du personnage à l’in-


térieur d’une tuile vers le point d’intérêt le plus proche (et à la fin du dernier point
d’intérêt et de la cible). D’ailleurs une façon de faire est de modifier localement et tem-
porairement le navigation mesh en ajoutant dans la tuile de la source (et de la cible) un
point d’intérêt connecté à tous les points d’intérêts du bord de la tuile. Dans la suite
nous supposerons que les déplacements planifiés si → ti concernent des points d’inté-
rêts (=sommets) du navigation mesh (=graphe) qui est fixe. C’est l’entrée du problème
sur laquelle A* s’applique.

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

4.2 L’algorithme de Dijkstra

L’algorithme A* étant une extension de l’algorithme de Dijkstra, il est important de


comprendre les détails de ce dernier. On va le présenter sous une version un peu modi-
fiée. À l’origine, l’algorithme de Dijkstra calcule un plus court chemin entre un sommet
source s et tous les autres accessibles depuis s dans un graphe G. Ici l’algorithme s’ar-
rêtera dès qu’un sommet cible t donné sera atteint. Pour fonctionner, l’algorithme sup-
pose des poids positifs ou nuls, mais pas forcément symétrique. Il est possible d’avoir
ω(u, v) , ω(v, u). Par exemple, la montée d’un escalier est plus coûteuse que sa descente.
Ou encore la vitesse de déplacement en vélo sur une route droite et plate peut être af-
fectée par le sens du vent. Il n’y a pas d’autres hypothèses sur les poids. En particulier
l’algorithme reste correct et calcule les plus courts chemins même si l’inégalité trian-
gulaire n’est pas respectée, c’est-à-dire même s’il existe trois arêtes formant un triangle
x, y, z avec ω(x, z) > ω(x, y) + ω(y, z).

Principe. On fait croître un sous-arbre du graphe depuis la source s en


ajoutant progressivement les feuilles. La prochaine feuille à être ajoutée
est choisie parmi le voisinage de l’arbre 4 de sorte qu’elle minimise le coût
du nouveau chemin ainsi crée dans l’arbre.

L’algorithme de Dijkstra peut être ainsi vu comme un algorithme glouton. On sélec-


tionne le sommet le plus proche, c’est-à-dire celui qui minimise le coût du chemin crée,
et on ne remet jamais en question ce choix. Comme le montre la figure 4.6, le choix de
ce sommet peut être indépendant de certaines arêtes ce qui montre que l’algorithme ne
peut être correct si des poids < 0 sont autorisés 5 .
Dans l’algorithme, P représentera l’ensemble des sommets de l’arbre, et Q représen-
tera la frontière de P , c’est-à-dire l’ensemble des sommets en cours d’exploration (voir la
figure 4.6) qui sont aussi des voisins de P .

4. C’est l’ensemble des voisins des sommets de l’arbre dans G.


5. On peut tout de même arriver à trouver les distances correctes si G possède un seul arc uv de poids
négatif (et sans cycle absorbant). Il faut calculer deux fois Dijkstra dans le graphe G0 = G\{uv} : l’un depuis
s et l’autre depuis v. Ensuite, pour chaque sommet x, on sélectionne le plus court chemin entre celui qui
ne prend pas uv et de coût distG0 (s, x), et celui qui passe par uv de coût distG0 (s, u) + ω(u, v) + distG0 (v, x).
4.2. L’ALGORITHME DE DIJKSTRA 115

P Q

Figure 4.6 – Principe de l’algorithme de Dijkstra pour un graphe arête-valué


(poids non représenté). Le choix du plus proche sommet u ∈ Q est indépen-
dant des arêtes internes à Q (arête verte). Il ne dépend pas non plus de la
cible (non représentée). Les flèches liant les sommets de P représentent la
relation u → parent[u].

Algorithme Dijkstra (modifié)


Entrée: Un graphe G, potentiellement asymétrique, arête-valué par une fonction
de poids ω positive ou nulle, et s, t ∈ V (G).
Sortie: Un plus court chemin entre s et t, une erreur s’il n’existe pas.

1. Poser P := ∅, Q := {s}, coût[s] := 0, parent[s] := ⊥


2. Tant que Q , ∅ :
(a) Choisir u ∈ Q tel que coût[u] est minimum et le supprimer de Q
(b) Si u = t, alors renvoyer le chemin de s à t grâce à la relation parent[u] :
t → parent[t] → parent[parent[t]] → · · · → s
(c) Ajouter u à P
(d) Pour tout voisin v < P de u :
i. Poser c := coût[u] + ω(u, v)
ii. Si v < Q, ajouter v à Q
iii. Sinon, si c > coût[v] continuer la boucle
iv. coût[v] := c, parent[v] := u
3. Renvoyer l’erreur : « le chemin n’a pas été trouvé »

Traditionnellement l’algorithme n’est pas présenté exactement de cette façon.


D’abord, ici on s’arrête dès que la destination t est atteinte. Alors que dans l’algorithme
d’origine on cherche à atteindre tous les sommets accessibles depuis s.
Ensuite, l’ensemble Q n’est pas explicité dans l’algorithme d’origine. Généralement
on pose coût[s] := 0 et coût[u] := +∞ pour tous les autres sommets u, si bien que les
sommets de Q sont les sommets u < P avec coût[u] < +∞. Le début de l’algorithme
116 CHAPITRE 4. NAVIGATION

classique s’écrit plutôt :

1. Poser coût[u] := +∞ pour tout u ∈ V (G), P := ∅, coût[s] := 0, parent[s] := ⊥


2. Tant qu’il existe un sommet u < P :
(a) Choisir u < P tel que coût[u] est minimum

L’avantage d’avoir l’ensemble Q est pour l’implémentation. Les tables coût[ ] et


parent[ ] n’ont besoin d’être calculées que pour les sommets qui sont ajoutés à Q. Si t
est proche de s, très probablement l’algorithme ne visitera pas tous les sommets du
graphe. On consome donc potentiellement beaucoup plus de mémoire et de temps si
l’on initialise coût[u] := +∞ pour tous les sommets, alors que l’initialisation à l’étape 1
prend ici un temps constant.

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 .

Propriété 4.1 S’il existe un chemin de s à t dans G, alors l’algorithme le trouve.

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.

Propriété 4.2 Si u ∈ P ∪Q, le coût du chemin u → parent[u] → parent[parent[u]] → · · · →


s vaut coût[u]. De plus tous les sommets du chemin, sauf peut-être u, sont dans P .
4.2. L’ALGORITHME DE DIJKSTRA 117

t
s

Figure 4.7 – Exemple de graphe asymétrique où t n’est pas accessible de-


puis 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

Soit v 0 le prédécesseur de u 0 sur C, en parcourant C de s à u 0 . Par construction de u 0 ,


v 0 ∈ P . Il est possible d’avoir v 0 , parent[u 0 ] comme illustré par la figure 4.8. À cause de
118 CHAPITRE 4. NAVIGATION

u
s
C
v0
u0
P Q

Figure 4.8 – Illustration de la preuve de la proposition 4.1. NB : Les flèches


représentent la relation de parenté w → parent[w], pas les arcs du graphe. Le
plus court chemin C de s à u peut pénétrer plusieurs fois P et Q, et ne pas
suivre l’arborescence dans P à cause de poids nuls.

l’équation (4.1), et puisque v 0 ∈ P ,

coût[u 0 ] 6 coût[v 0 ] + ω(v 0 , u 0 ) .

Notons C[x, y] la partie du chemin C allant de x à y, pour tout x, y ∈ C. L’observation


est que C[x, y] est un plus court chemin entre x et y, car C est un plus court chemin. Dit
autrement, X
coût(C[x, y]) = ω(e) = distG (x, y) .
e∈E(C[x,y])

(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 :

coût[u 0 ] 6 coût[v 0 ] + ω(v 0 , u 0 ) = coût(C[s, v 0 ]) + ω(v 0 , u 0 ) = coût(C[s, u 0 ]) = distG (s, u 0 ) .

On a donc montré que coût[u 0 ] 6 distG (s, u 0 ).


On a donc coût[u 0 ] 6 distG (s, u 0 ) 6 distG (s, u) mais aussi, par hypothèse,
distG (s, u) < coût[u]. Il suit que coût[u 0 ] < coût[u], ce qui contredit le choix de
u à l’étape 2a comme étant le sommet de Q de coût minimum. Par conséquent
coût[u] = distG (s, u), ce qu’on voulait montrer. 2

On remarquera que la preuve de la proposition 4.1 n’utilise pas l’inégalité triangu-


laire des poids. On utilise seulement le fait que le sous-chemin d’un plus court chemin
est un plus court chemin.

4.2.2 Implémentation et complexité.


File de priorité. Généralement on implémente l’ensemble Q par une file de priorité
(priority queue en Anglais). C’est une structure de données qui permet de gérer certaines
4.2. L’ALGORITHME DE DIJKSTRA 119

opérations sur les ensembles et qui sont les suivantes 6 :


• créer une file vide ;
• d’ajouter à la file un élément et sa priorité ;
• d’extraire de la file l’élément de plus haute priorité ; et
Pour être plus précis, une clé c est associée à chaque élément v permettant de détermi-
ner la priorité de l’élément. Pour notre utilisation, l’élément de plus haute priorité est
celui avec la plus petite clé. C’est donc le couple (v, c) qui est inséré dans la file. Pour
Dijkstra, la clé est c = coût[v] si bien que l’élément de plus haute priorité est celui de
coût minimum.
Des variantes plus sophistiquées de file de priorité permettent en plus d’augmenter
la priorité d’un élément déjà dans la file en diminuant (=décrémenter) sa clé. C’est
malheureusement plus complexe à programmer car il faut gérer, à chaque mise à jour
de la file, la position de chaque élément dans la file.
Dans Dijkstra on remarque que l’on :
• parcourt chaque arc au plus une fois, ce qui coute O(m) ;
• extrait de Q au plus une fois chacun des sommets, ce qui coute O(n · tmin (n)) ;
• ajoute au plus chacun des sommets à Q, ce qui coute O(n · tadd (n)) ; et
• modifie les coûts au plus autant de fois qu’il y a d’arcs, ce qui coute O(m · tdec (n)).
Ici tmin (n), tadd (n), tdec (n) sont respectivement les complexités en temps des opéra-
tions d’extraction du minimum, d’ajout et de décrémentation de la clé d’un élément
d’une file de taille au plus n.
Les sommets de P se gèrent par un simple marquage qui coute au total un temps et
un espace en O(n). Au total la complexité en temps de Dijkstra est donc
O(m + n · tmin (n) + n · tadd (n) + m · tdec (n)) .
Il faudrait ajouter le temps de création (voir de suppression) d’une file vide qui sont des
opérations qui s’implémentent facilement en O(1). Notons que les différentes tables,
y compris la file, ne contiennent que des sommets distincts (avec leurs clés) et donc
occupent un espace O(n).
Parenthèse. On pourra se référer à Wikipédia pour plus de détails et les diverses implémen-
tations possibles, ainsi que et leurs complexités, des files de priorités. On notera qu’il existe
une réduction des files de priorité aux algorithmes de tri. Plus précisément, s’il est possible
de trier n clés en temps Sort(n), alors il existe une file de priorité supportant l’insertion et la
suppression de l’élément de plus haute priorité en temps O(Sort(n)/n). Ce résultat de 2007
est du à Thorup [Tho07], le même chercheur en informatique qui a produit l’algorithme de
tri le plus rapide connu (qui n’est pas par comparaisons) ainsi qu’un algorithme de calcul
des plus courts chemins d’une complexité meilleure que celle de Dijkstra. Cela sera redis-
cuté dans la parenthèse de la page 121. À partir des meilleures complexités connues pour
6. On pourrait rajouter, mais c’est pas essentiel, la suppression d’une file (précédemment créée) et de
tester si une file est vide (qui est implicite dans l’opération d’extraction).
120 CHAPITRE 4. NAVIGATION
p
Sort(n), on en déduit des complexités en O(log log n) (et même O( log log n ) en moyenne)
pour les opérations sur les files de priorité.

Mise à jour paresseuse. En fait on peut se passer d’implémenter la décrémentation


de clé si on est pas trop limité en espace. Au lieu d’essayer de décrémenter la clé c en
c0 < c d’un élément v déjà dans la file, on peut simplement faire une mise à jour de
manière paresseuse : on ajoute à la file un nouveau couple (v, c0 ) (cf. figure 4.9). Cela
n’a pas de conséquences dans la mesure où l’on extrait à chaque fois l’élément de clé
minimum. C’est donc (v, c0 ), et sa dernière mise à jour, que l’on traitera en premier. Il en
va de même en fait pour toute modification de clé, qu’elle soit une incrémentation ou
décrémentation. Si plus tard on souhaite augmenter c0 en c00 > c0 , alors on ajoute (v, c00 )
à la file. Dans tous les cas, c’est la valeur minimum qui sera extraite en premier.

(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 :

ii. Si v < Q, ajouter v à Q


ii. coût[v] := c, parent[v] := u
iii. Sinon, si c > coût[v] continuer la boucle 7→
iii. ajouter 7 v à Q
iv. coût[v] := c, parent[v] := 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

Il suffit donc de modifier l’instruction 2c de Dijkstra ainsi :

(c) Si u ∈ P , continuer la boucle, sinon l’ajouter à P

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 :

O(m + m · (tmin (m) + tadd (m))) .

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

Figure 4.10 – Exemple de graphe avec n = 6 sommets et m = 9 arêtes où l’algo-


rithme Dijkstra depuis s permet de trier les poids ωi = ω(s, vi ) des n − 1 arêtes
incidentes à s, en supposant que le poids des autres arêtes vérifient ω(vi , vj ) = W ,
où W > maxi {ωi }.

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

En utilisant une structure de données adéquate (notamment un tas de Fibonacci), Dijks-


tra peut effectivement être implémenté pour atteindre la complexité de O(m + n log n). Ce-
pendant, on ne peut pas en déduire que Dijkstra est l’algorithme ayant la meilleure com-
plexité permettant de calculer les distances à partir d’une source donnée. Car rien n’indique
que le principe du sommet le plus proche soit le meilleur. En fait, un algorithme de com-
plexité optimale O(n + m) [Question. Pourquoi est-ce optimal ?] a été trouvé par [Tho99].
Bien sûr, cet algorithme ne parcourt pas les sommets par ordre croissant des distances de-
puis s.

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

Figure 4.11 – Capacités étonnantes de physarum polycephalum d’optimisation


de chemins. Ici de la nourriture a été placée dans des points représentant les
gares principales de la région de Tokyo. Après une phase d’exploration (1
à 2 cm/h), l’organisme ne « garde » que les chemins les plus courts entre
les lieux de nourriture. Il se contracte, forme des filaments, et les faces du
graphes forment autant de trous dans la cellule (qui n’est plus équivalent à
une sphère).
En A, le réseau final de l’organisme sans contrainte. En B, pour plus de réa-
lisme, de la lumière (qui repousse l’organisme) placée là où sont érigées des
montagnes reproduisant la topographie des lieux. De même, un éclairement
mimait les lacs et le littoral. On obtient alors le graphe C, que l’on peut com-
parer au réseau réel D des chemins de fers de la région de Tokyo. © A. Tero
et al.
Pour des problèmes de labyrinthe, il a été démontré en 2019 que l’organisme
utilise son mucus comme mémoire externe, soit un véritable marquage des
lieux visités ! [BDPED19].

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*.

Principe. Il est identique à celui de Dijkstra (croissance d’un arbre de ra-


cine s, la source, par ajout de feuilles) sinon que le choix du sommet u se
fait selon score[u], une valeur qui tient compte non seulement de coût[u]
(du coût du chemin dans l’arbre de s à u), mais aussi d’une estimation de
la distance entre u et la cible t.

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é.

1. Poser P := ∅, Q := {s}, coût[s] := 0, parent[s] := ⊥, score[s] := coût[s] + h(s, t)


2. Tant que Q , ∅ :

(a) Choisir u ∈ Q tel que score[u] est minimum et le supprimer de Q


(b) Si u = t, alors renvoyer le chemin de s à t grâce à la relation parent[u] :
t → parent[t] → parent[parent[t]] → · · · → s
(c) Ajouter u à P
(d) Pour tout voisin v < P de u :
i. Poser c := coût[u] + ω(u, v)
ii. Si v < Q, ajouter v à Q
iii. Sinon, si c > coût[v] continuer la boucle
iv. coût[v] := c, parent[v] := u, score[v] := c + h(v, t)

3. Renvoyer l’erreur : « le chemin 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 :

• si un chemin de s à t existe, A* le trouvera ; et


• le coût du chemin u → parent[u] → parent[parent[u]] → · · · → s vaut coût[u] pour
126 CHAPITRE 4. NAVIGATION

tout u ∈ P ∪ Q.

On peut mesurer la différence de performances entre Dijkstra et A* dans le cas de


graphes basés sur des grilles 2D (cf. figure 4.14). Pour une cible séparée d’une distance
r de la source, Dijkstra visitera, à l’issue d’une recherche circulaire, environ r 2 sommets
centrés autour de la source. Alors qu’A*, avec la distance vol d’oiseau comme heuris-
tique h, visitera de l’ordre de r sommets, ce qui est évidemment le mieux que l’on puisse
espérer. La différence est loin d’être négligeable en pratique.

Figure 4.14 – Sur le plan Dijkstra (à gauche) visite un nombre quadratique


de sommets en la distance, alors que A* (à droite) un nombre linéaire. Le
plan est représenté ici par une grille avec 8-voisinage et l’heuristique est la
distance vol d’oiseau dans cette grille, ce qui explique la forme du « disque »
(cf. la parenthèse page 128 sur les normes). Les sommets de Q sont en cyan et
ceux de P dans un dégradé bleu anthracite. [Question. Pourquoi l’ensemble
Q sur la figure de gauche présente des excroissances aléatoires ?]

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

Figure 4.16 – La distance vol d’oiseau sous-estime la distance. Le graphe (à


droite) est un navigation mesh issu d’un maillage carré avec un 8-voisinage
dans lequel on a enlevé les nœuds correspondant aux obstacles. La distance
vol d’oiseau (en pointillé jaune) vaut dans l’exemple max {|xs − xt |, |ys − yt |} = 4
au lieu de 6 pour le plus court chemin dans le graphe (en rouge). [Question.
Est-ce que h est monotone pour ce graphe si on définit h(x, t) comme distance
euclidienne entre x et 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→+∞

On a vu que Dijkstra correspond à A* avec l’heuristique h(x, t) = 0 qui se trouve être


monotone, et donc A* calcule un plus court chemin pour cette heuristique là. C’est en
fait une caractéristique générale d’A*. On va montrer que :

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).

Preuve. La preuve ressemble beaucoup à la preuve de la proposition 4.1, et reprend les


mêmes notations (cf. figure 4.18). Donc u est toujours le premier sommet choisi en 2d
tel que coût[u] > distG (s, u). C’est notre hypothèse. La différence étant que u est choisi
comme le sommet de Q de score minimum, et non pas comme celui de coût minimum.
On a montré dans la preuve de la proposition 4.1, en considérant le sommet u 0 sur
le plus court chemin chemin C de s à u, que :
coût[u 0 ] 6 distG (s, u 0 ) . (4.3)
Ceci reste valable puisque la preuve est basée sur la mise à jour de la table coût[ ] en 2d
pour les sommets de Q (voir aussi équation (4.1)) et la définition de C qui ne dépendent
pas de score[ ].
130 CHAPITRE 4. NAVIGATION

u h(u, t)
s t
C
v0
u0 h(u 0 , t)
P Q

Figure 4.18 – Illustration de la preuve de la proposition 4.5.

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) :

h(u 0 , t) 6 coût(C[u 0 , u]) + h(u, t) .

Du coup,

score[u 0 ] = coût[u 0 ] + h(u 0 , t) 6 distG (s, u 0 ) + h(u 0 , t) (par définition de score[ ])


6 distG (s, u 0 ) + coût(C[u 0 , u]) + h(u, t) (par monotonie de h)
6 coût(C[s, u 0 ]) + coût(C[u 0 , u]) + h(u, t)
6 coût(C[s, u]) + h(u, t) (par définition de C)
6 distG (s, u) + h(u, t) (par hypothèse sur u)
< coût[u] + h(u, t) = score[u] .

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

4.3.2 Implémentation et complexité


La complexité et l’implémentation d’A* sont similaires à celles de Dijkstra, sinon
qu’on implémente Q par un tas minimum pour la valeur score[ ] au lieu de coût[ ] comme
vu au paragraphe 4.2.2. Mettre à jour le coût et le score d’un sommet peut se faire de
manière paresseuse comme dans Dijkstra comme vu au paragraphe 4.2.2, en ajoutant
systématiquement v à Q, même si v était déjà dans Q et même si le nouveau coût n’est
pas meilleur que celui de la dernière version de v dans Q.
4.3. L’ALGORITHME A* 131

4.3.3 Plus sur A*


• La version présentée page 125 n’est pas la version originale d’A*. Dans sa version
originale, l’instruction 2d devrait être :

(d) Pour tout voisin v de u :

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

Figure 4.19 – Double parcours d’A*, de s → t et de t → s, s étant situé en


bas à droite. Avec l’heuristique vol d’oiseau, le double parcours (à gauche)
visite 18 545 sommets contre 27 508 pour le simple parcours (à droite) pour
trouver un plus court chemin. La seconde moitié du chemin (celle inclus dans
la partie rougeâtre), bien que de même longueur, diffère.

avec le temps d’exécution de l’algorithme. On obtient de meilleures performances


en programmant directement un parcours DFS.
• L’algorithme A* peut également être utilisé pour calculer une α-approximation du
plus court chemin entre s et t (cf. la définition 3.1 au chapitre 3). Si l’heuristique
h est telle que h(x, t)/α est monotone pour une certaine constante α > 1, alors A*
trouve un chemin entre s et t (s’il existe) de coût au plus α · distG (s, t). Pour s’en
convaincre, il suffit de réécrire, dans la preuve de la proposition 4.5, les inéqua-
tions page 130 comparant score[u 0 ] à score[u], en utilisant l’hypothèse que u est le
premier sommet tel que coût[u] > α · dist(s, u) et qu’ainsi 13 coût[u 0 ] 6 α ·dist(s, u 0 ),
la monotonie de h/α impliquant que h(u 0 , t) 6 α · coût(C[u 0 , u]) + h(u, t).
Donc ici α > 1 est le facteur d’approximation sur la distance. L’espoir est que,
grâce à une heuristique plus élevée, A* privilégie plus encore les sommets proches
de la cible et visite ainsi moins de sommets. C’est très efficace s’il y a peu d’obs-
tacles entre s et t. Voir la figure 4.20.
• L’algorithme A* n’est pas seulement utilisé pour le déplacement de bots et les jeux
vidéos. Il sert d’heuristique pour l’exploration d’un espace de solutions. Le graphe
représente ici des possibilités ou des choix, et il s’agit de trouver une cible dans cet
espace (cf. [DRS07]). Un exemple est de savoir si un système peut atteindre un état
cible donné à partir d’un état de départ donné, et de trouver un tel chemin. Donc
ici le graphe n’est pas forcément entièrement donné dès le départ. Il est construit
au fur et à mesure de l’exploration, les voisins d’un sommet u n’étant construits
que si u est exploré. Bien sûr, la difficulté est dans la conception de l’heuristique h.
13. Il vaut se servir du fait que le prédécesseur v 0 de u sur C vérifie v 0 ∈ P et donc que coût[v 0 ] 6
α · dist(s, v 0 ). Ensuite, à cause de la mise à jour des coûts, on en déduit que coût[u 0 ] 6 coût[v 0 ] + ω(v 0 , u) 6
α · dist(s, v 0 ) + dist(v 0 , u 0 ) 6 α · dist(s, u 0 ) car v 0 − u 0 appartient à un plus court chemin et que α = 1.
4.4. MORALE 133

α 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

Figure 4.20 – Performances d’A* pour l’heuristique h(x, t) = α · δ(x, t) avec


différentes valeurs entières d’α, où δ(x, t) est la distance vol d’oiseau pour
les grilles avec un 8-voisinage. Seule une partie de la grille est représentée.
Deux chemins sont trouvés : celui de coût 11 (rouge) ou de coût 14 (cert).
On constate que dans le meilleur des cas (α = 2) l’algorithme est capable
de trouver le plus court chemin en visitant seulement 80 sommets, 6 fois
moins qu’avec Dijkstra (α = 0). Il est aussi capable de trouver un chemin plus
long de seulement 3/11 ≈ 28% en ne visitant que 54 sommets, 9 fois moins
qu’avec Dijkstra ! Avec une double exécution (s → t et t → s) et avec α = 4,
on peut trouver le plus court chemin (de coût 11) en visitant 67 sommets.
L’augmentation d’α mène aux mêmes statistiques que l’exécution simple s →
t à partir d’α = 5.

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

• Il faut distinguer le problème que résout un algorithme, et l’implémentation de


l’algorithme. Il y a plusieurs implémentations possibles de Dijkstra, pas toutes
équivalentes en termes de complexité. L’implémentation de Dijkstra présentée
dans le cours, à l’aide d’un tas binaire, a une complexité de O(m log n), et la com-
plexité la plus faible possible atteint Θ(m+n log n). Cette borne est suffisante grâce
aux tas de Fibonacci, et elle est nécessaire à cause du parcours des sommets par
ordre croissant de distance depuis la source. Cependant, ce n’est pas la meilleure
complexité pour le problème ! Il existe un algorithme en O(m + n) qui calcule les
distances de une source vers tous les autres, et c’est bien sûr la meilleure possible.

• La ressource critique pour ce type d’algorithme, comme beaucoup d’autres en fait,


est la mémoire utilisée, ce qui correspond au nombre de sommets visités. Pour
chaque heuristique fixée, A* est l’algorithme de recherche de chemins qui visite le
moins de sommets possibles.

• 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).

Comme exemple de problème on peut citer le problème du Rubik’s Cube (même


si dans ce cas précis A* n’est pas forcément le plus adapté). Il s’agit à partir d’une
configuration arbitraire de trouver un chemin « court » permettant d’atteindre la
configuration gagnante où toutes les faces sont d’une seule couleur. Ici les som-
mets sont les configurations et le voisinage défini par les configurations accessibles
par une rotation des faces du cube (il y en a 12). Il n’est pas envisageable d’explo-
rer, et encore moins de construire, le graphe des n = 8! × 37 × 12! × 210 ≈ 43 × 1018
configurations (voir Wikipédia pour le détail du calcul). Même en explorant un
milliard (109 ) de configurations par secondes il faudrait au moins 43 milliards
de secondes pour parcourir seulement les sommets (pour les arêtes c’est 12 fois
plus...), soit plus de 43 × 30 = 1 290 années de calculs.

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

Figure 4.21 – L’unique configuration connue pour être à distance 26 de l’ori-


gine. Elle peut être obtenue grâce aux 26 mouvements suivants : U U F U U
R- L F F U F- B- R L U U R U D- R L- D R- L- D D. Ces lettres codent les faces de-
vant être tournées d’un quart dans le sens des aiguilles d’une montre (dans
le sens contraire si suivies d’un « - »). En fixant le centre vert, les faces op-
posées sont respectivement Front & Back, Left & Right et Up & Down. Voir
http://cube20.org/qtm pour plus de détails.

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

[Tho99] M. Thorup, Undirected single-source shortest paths with positive integer


weights in linear time, Journal of the ACM, 46 (1999), pp. 362–394. doi :
10.1145/316542.316548.
[Tho07] M. Thorup, Equivalence between priority queues and sorting, Journal of the
ACM, 54 (2007), p. Article No. 28. doi : 10.1145/1314690.1314692.
5
CHAPITRE
Diviser pour régner

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

Mots clés et notions abordées dans ce chapitre :


• la paire de points les plus proches
• algorithme de Karatsuba
• complexité définie par formule de récurrence
• Master Theorem

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 :

// tri récursif de T[i..j[


void merge_sort(double T[],int i,int j){
if(j-i<2) return; // rien à trier si un seul élément
int m=(i+j)/2; // m = milieu de l’intervalle [i..j[
merge_sort(T,i,m); // tri récursif de T[i..m[
merge_sort(T,m,j); // tri récursif de T[m..j[
fusion(T,i,m,j); // fusion T[i..m[ + T[m..j[ -> T[i..j[
}

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 :

// fusion de T[i..m[ et T[m..j[ pour donner T[i..j[


void fusion(double T[],int i,int m,int j){
int k=i,p=i,q=m,*r; // r = pointeur vers p ou q
while(k<j){ // tant qu’il y a des éléments
if(p==m) r=&q; // T[i..m[ a été traité
else if(q==j) r=&p; // T[m..j[ a été traité
else r=(T[p]<T[q])? &p : &q; // comparaison
A[k++]=T[(*r)++]; // copie dans A[k] et incrémente p ou q
}
memcpy(T+i,A+i,(j-i)*sizeof(*A)); // recopie A[i..j[ dans T[i..j[
}

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

Remarque sur l’implémentation. Dans le code précédent de la fonction fusion(), il


est supposé qu’on dispose d’un tableau auxiliaire A de la même taille que T. Bien sûr,
on aurait peut aussi mettre en début de fusion() un int *A = malloc(...) et un free(A)
avant de quitter la fonction. Cependant la fonction fusion() va être appelée O(n) fois,
soit autant de malloc() et de free() ce qui peut représenter un délais non négligeable sur
le temps d’exécution. On peut donc utiliser un seul malloc() et un seul free() comme
ceci 3 :

static double *A;

// 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 Trouver la paire de points les plus proches

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.

5.2.2 Principe de l’algorithme


Formellement, le problème s’énonce ainsi.
La paire de points les plus proches
Instance: Un ensemble P ⊂ R2 de n points du plan, n > 2.
Question: Trouver deux points distincts p, p0 ∈ P telle que dist(p, p0 ) est minimum.

Ici dist(p, p0 ) représente la distance euclidienne entre les points p et p0 du plan. On


étend cette notation aux ensembles, dist(p, Q) = minq∈Q dist(p, q) représentant la dis-
tance entre un point p et un ensemble de points Q.

Diviser. L’idée est de partitionner les points de P et deux sous-ensembles, A et B. Si


(p, p0 ) est la paire recherchée, alors clairement soit :
• (p, p0 ) ∈ A2 ; ou bien
• (p, p0 ) ∈ B2 ; ou bien
• (p, p0 ) ∈ A × B.
On calcule alors récursivement dA = min(a,a0 )∈A2 dist(a, a0 ) et dB = min(b,b0 )∈B2 dist(b, b0 )
les distances minimum entres les paires de points de A2 et B2 . Reste ensuite à calculer
dAB , la distance minimum pour les paires de A × B, afin de combiner le tout et d’obtenir
la distance désirée (en fait la paire de points). Le calcul de dAB est la partie difficile.
De prime abord, il semble que le calcul préliminaire de dA et dB n’aide pas vraiment
pour le calcul de dAB . En effet, le nombre de couples (p, p0 ) ∈ A × B est 9 |A| · |B| = Ω(n2 )
dans le cas équilibré où |A| = |B|. On a donc pas forcément avancé pour le calcul de
dAB , sinon, et c’est le point crucial, qu’on connaît la distance minimale à battre, soit
min {dA , dB }. C’est le petit détail qui va tout changer.

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∗ | < δ} .

Notons que |A|, |B| 6 dn/2e.


L’algorithme repose sur les deux propriétés suivantes.

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

Figure 5.1 – Découpage de P en A et B selon le point médian p∗ . Les paires


(a, a0 ) et (b, b0 ) sont les paires de points les plus proches dans A et dans B
[exemple presque réaliste].

Dit autrement, si dans la liste Sy = (s0 , . . . , si , . . . , sj , . . . , sk−1 ) on a dist(si , sj ) < δ, alors


j − i 6 7. En particulier, si les deux points les plus proches de S sont à distance < δ, on
les trouvera avec deux boucles comme par exemple :

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

Figure 5.2 – Quadrillage de S en 8 boîtes de cotés δ/2 pour si . Certaines


boîtes pourraient être vides.

L’observation importante est que chaque boîte contient au plus un point de P . En


effet, la distance la plus grande réalisable par deux points d’une même boîte a pour
longueur la diagonale d’un carré de coté δ/2, soit
s
 2  2
δ δ δ √ δ δ
+ = · 2 = √ < < δ.
2 2 2 2 1.4

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

Figure 5.3 – Configuration montrant que le « 7 » de la proposition 5.2 peut


être nécessaire.

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.

1. Construire les tableaux Px et Py .


2. Renvoyer PPPPrec (Px , Py ).
146 CHAPITRE 5. DIVISER POUR RÉGNER

Algorithme PPPPrec (Px , Py )


Entrée: Deux tableaux de points (les mêmes) triés selon x et selon y.
Sortie: La paire de points les plus proches.

1. Si |Px | = 2 ou 3, renvoyer la paire de points de Px les plus proches.


2. Extraire le point médian de Px , et soit x∗ son abscisse.
3. Soit A = {(x, y) ∈ Px : x 6 x∗ } et B = {(x, y) ∈ Px : x > x∗ }. Construire les tableaux
Ax , Bx , Ay et By à partir de x∗ , Px et Py .
4. Calculer (a, a0 ) = PPPPrec (Ax , Ay ) et (b, b0 ) = PPPPrec (Bx , By )
n o
5. Calculer δ = min {dist(a, a0 ), dist(b, b0 )}. Soit S = (x, y) ∈ Py : |x∗ − x| < δ .
Construire Sy à partir de Py .
n o
6. Pour chaque point si ∈ Sy , calculer min dist(si , sj ) : j ∈ {i + 1, . . . , i + 7} .
7. Soit (s, s0 ) la paire de points les plus proches calculée à l’étape 6, si elle
existe 12 . Renvoyer la paire de points les plus proches parmi (s, s0 ), (a, a0 ) et
(b, b0 ).

[Exercice. On a supposé que la ligne L du médian p∗ ne contient qu’un seul point.


Mais que se passe-t-il si cela n’est pas le cas ? Comment modifier l’algorithme pour qu’il
marche efficacement dans tous les cas ?]

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 :

T (n) = 2 · T (dn/2e) + O(n) . (5.1)

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.

5.2.5 Différences entre n, n log n et n2


Il est important de réaliser l’énorme différence en pratique entre un algorithme li-
néaire (O(n)), quasi-linéaire (O(n log n)) ou quadratique (O(n2 )). Par exemple, considé-
rons un jeu de données avec n = 109 points (un milliard), ce qui représente un fichier de
2*n*sizeof(double) ≈ 16 Go. Les numérisations digitales au laser de grands objets 3D
(statues, cavernes, etc.) dépassent largement cette taille 15 . Et supposons qu’on dispose
d’une machine capable de traiter un milliard d’instructions élémentaires par secondes
(soit une fréquence de 10−9 = 1 GHz).
Le tableau de comparaison ci-après donne une idée des différents temps d’exécu-
tion en fonction de la complexité. Bien sûr le temps réel d’exécution n’est pas forcé-
ment exactement celui-ci. La complexité linéaire O(n) correspond peut-être à l’exécu-
tion réelle de 10n instructions élémentaires ; Et puis il y a différents niveaux de caches
mémoires qui influencent le temps d’exécution. Mais cela donne toutefois un bon ordre
de grandeur. (Voir aussi le paragraphe page 69).

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

5.2.6 Plus vite en moyenne


En fait, le problème peut être résolu en temps O(n) en utilisant un algorithme proba-
biliste (soit 1 seconde pour n = 109 de points d’après le calcul précédent). L’algorithme
repose sur des tables hachages. Il est basé sur le tirage initial d’un ordre aléatoire des
points. Le temps dépend de ce tirage initial, c’est donc une variable aléatoire, mais la
paire de points les plus proches est correctement renvoyée. La complexité est donc ici
une complexité moyenne calculée sur tous les tirages possibles. On parle d’algorithme
Las Vegas. Les détails sont relativement complexes et on n’en parlera pas plus.

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.

5.2.7 La paire de points les plus éloignés


On peut également en temps O(n) en moyenne, avec un algorithme Las Vegas (cf. le
paragraphe 5.2.6), calculer les deux points les plus éloignés. On appelle aussi ce pro-
blème celui du calcul du diamètre de P . L’implémentation est bien plus simple et ne
nécessite pas la programmation de structures de données complexes comme les tables
de hachage en temps constant.
5.3. MULTIPLICATION RAPIDE 151

Le principe est de tirer un point q ∈ Q uniformément au hasard parmi un ensemble


Q de candidats possibles, avec au départ Q = P . On calcule le point p le plus éloigné de
q. On supprime ensuite de Q tous les points du disque de diamètre dist(p, q). Puis on
recommence jusqu’à ce que Q soit vide. La dernière paire (p, q) forme le diamètre de P .
Notons que cet algorithme est valable quelque soit la dimension. [Exercice. Montrer
que l’algorithme s’arrête et est correct.] Avec de simples tableaux il est possible de réa-
liser chaque itération en temps O(|P |). [Question. Pourquoi ?] L’analyse de la complexité
nécessite des détails supplémentaires.
[Exercice. Proposer une 2-approximation pour le problème du diamètre de P en
temps O(n).] [Question. Si le diamètre de P est r, est-il vrai qu’il existe un point p ∈ P tel
que le disque de centre p et de rayon r/2 contient tous les points de P ? Même question
avec un centre p ∈ R2 , pas forcément dans P .]
Le diamètre de P peut aussi être calculé, sans tirage aléatoire, en temps O(n log n)
en se basant sur l’enveloppe convexe. Il s’agit du polygone ayant le plus petit nombre de
sommets de sorte que tous les points de P se trouvent soit sur le bord soit à l’intérieur
de ce polygone. Par minimalité, ce polygone est nécessairement convexe, d’où le nom.
On peut alors remarquer que le diamètre de P est réalisé par deux points de l’enveloppe
convexe qui sont de plus antipodaux. (Voir la parenthese ci-après pour plus de détails.)
Une fois cette l’enveloppe convexe calculée, on peut en déduire le diamètre de P en
temps linéaire en le nombre de points de l’enveloppe convexe, soit O(n). L’enveloppe
convexe de P se calcule en temps O(n log n) en triant les points selon les coordonnées en
x et en y.

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].

5.3 Multiplication rapide


L’algorithme qu’on va présenter a été développé par Anatolii Alexevich Karatsuba
en 1960 et publié en 1962. L’article d’origine a été écrit par Andreï Kolmogorov et Yuri
Ofman, mais il a été publié sous les noms de Karatsuba et d’Ofman [KO62]. Kolmogorov
vers 1956, ainsi que beaucoup d’autres, pensaient que l’algorithme naïf en n2 était le
152 CHAPITRE 5. DIVISER POUR RÉGNER

Figure 5.5 – Enveloppe convexe et points antipodaux. Illustration empruntée


à [Smi03] qui détaille la preuve et donne aussi un algorithme parallèle.

meilleur possible. Kolmogorov a voulu rendre homage ainsi à Karatsuba pour avoir
résolu le problème du célèbre chercheur.

Parenthèse. Alexander Zvonkine, ancien doctorant de Kolmogorov et enseignant-chercheur


au LaBRI à Bordeaux, m’a rapporté qu’Andreï pensait que la complexité de la multiplication
de deux matrices n × n était en ne = n2.718... , peu de temps après la découverte du premier
algorithmique sous-cubic en nlog2 (7) = n2.807... de Volker Strassen [Str69]. Le meilleur algo-
rithme, celui de Coppersmith and Winograd [CW90], atteint une complexité de n2.372... qui
a été donnée par François Le Gall [LG14]. Beaucoup pensent maintenant que la vraie borne
est n2+o(1) .

5.3.1 L’algorithme standard


Pour comprendre l’algorithme, rappelons l’algorithme standard, celui qu’on ap-
prend à l’école élémentaire. Il est illustré par l’exemple suivant en base 10 et en base 2.

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

multiplie un nombre de n chiffres par un autre de m chiffres, le résultat est a priori un


nombre de n + m chiffres. C’est différent pour B = 1, le codage unaire se comportant
différemment pour la multiplication. On supposera que B est une valeur fixée indépen-
dante de la taille des nombres manipulés. C’est donc une constante.
Soit z un entier naturel de n chiffres en base B. Sa représentation numérique est la
suite (zn−1 , . . . , z0 ) de ces chiffres, des entiers zi ∈ [0, B[, tels que :
n−1
X
z = zn−1 · Bn−1 + · · · + z2 · B2 + z1 · B + z0 = zi · Bi .
i=0

Le problème de la multiplication de grands entiers peut se formaliser ainsi :


Multiplication
Instance: Deux entiers x et y de n chiffres écrits en base B > 2.
Question: Donner les 2n chiffres de z = x × y en base B.

L’algorithme standard pour la multiplication de x par y consiste donc à considérer


chacun des chiffres yi de y, à calculer le produit partiel (x · yi ) · Bi , puis à l’ajouter à la
somme courante qui contiendra à la fin le résultat souhaité.
Les grands nombres sont représentés en machine par un simple tableau d’entiers de
[0, B[. Du coup un produit par une puissance de la base du type p = z · Bi n’est pas une
opération arithmétique mais un simple décalage : il suffit d’écrire z au bon endroit dans
le tableau de chiffres représentant p. D’ailleurs avec la méthode apprise à l’école on met
un ’.’ à chaque nouveau chiffre pour simuler le décalage qui dans les faits ne coute rien.

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...

5.3.2 Approche diviser pour régner


Pour simplifier, on va supposer que x et y comportent tous les deux n chiffres. Si tel
n’était pas le cas, on complète par des zéros à gauche la représentation du plus court
des deux nombres.
L’idée est de découper chaque suite de n chiffres en deux sous-suites : l’une compre-
nant les m = dn/2e chiffres les moins significatifs, et l’autre les n − m = bn/2c chiffres les
154 CHAPITRE 5. DIVISER POUR RÉGNER

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

Plus formellement, si n > 1, on découpe x = x+ x− avec x+ = (xn−1 , . . . , xm ) et x− =


(xm−1 , . . . , x0 ). Le bloc x− possède exactement m chiffres alors que x+ en possède n − m.
En choisissant m = dn/2e, on a m > 1 et n − m = bn/2c > 1. Du coup x− et x+ ont tous les
deux au plus m chiffres. On a alors :

x = x+ x− = x+ · Bm + x−

comme si on représentait x en base Bm et que x avait deux chiffres : x+ et x− . En


découpant de manière similaire y = y + y − = y + · Bm + y − , le produit s’exprime alors :

x×y = x+ x− × y + y − = (x+ · Bm + x− ) × (y + · Bm + y − )
= (x+ × y + ) · B2m + (x+ × y − + x− × y + ) · Bm + x− × y − .

On a donc remplacé un produit de deux nombres de n chiffres, par 4 produits d’au


plus m = dn/2e chiffres, 4 sommes sur des nombres d’au plus 2n chiffres et 2 décalages
(correspondant aux multiplications par B2m et par Bm ). Plus formellement, l’algorithme
s’écrit :
5.3. MULTIPLICATION RAPIDE 155

Algorithme Mulrec (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 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

Dans l’algorithme ci-dessus aux l’étapes 3 et 4, on a noté de manière abusive par « + »


l’addition de grands nombres, c’est-à-dire sur les tableaux. C’est une opération triviale
non détaillée ici qui peut être réalisée par un simple parcours linéaire des tableaux.
Pour être rigoureux et respecter le format de l’entrée, il faudrait que lors des appels
récursifs on soit certain que les nombres aient bien exactement le même nombre de
chiffres. C’est le cas pour p1 qui utilise deux opérandes de bn/2c chiffres et p4 qui utilise
deux opérandes de dn/2e chiffres. Mais ce n’est potentiellement pas le cas pour p2 et
p3 où x− et y − pourraient comprendre un chiffre de plus que x+ et y + . Il faut donc
éventuellement ajouter un zéro à gauche pour x+ et y + si tel n’était pas le cas.

Complexité. Soit T (n) la complexité en temps de l’algorithme Mulrec (x, y) appliqué


à des nombres de n chiffres. Toutes les opérations, sauf les appels récursifs, prennent
un temps au plus O(n). Les appels récursifs s’appliquent à des nombres d’au plus m =
dn/2e chiffres. On peut donc borner le temps de chacun de ces appels récursifs par
T (dn/2e), même si certains appels ne font que T (bn/2c), car clairement T est une fonction
croissante. Donc T (n) vérifie l’équation de récurrence :

T (n) = 4 · T (dn/2e) + O(n) (5.3)

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

Ce qui s’obtient immédiatement en remplaçant 4 → a et 2 → b dans le calcul précédent.


Rappelons (cf. l’équation (1.4)) que :
i−1 i−1
X
j
X 2i − 1
(4/2) = 2j = < 2i .
2−1
j=0 j=0

On a précédemment vu que n/2i 6 1 lorsque i = log2 n , et la récurrence s’arrête sur le


 
cas terminal T (1). Cela donne :
T (n) 6 4dlog2 ne · T (1) + 2dlog2 ne · cn
6 4(log2 n)+1 · c + 2(log2 n)+1 · cn
6 4c · 22 log2 n + 2n · cn
2
6 4c · 2log2 (n ) + O(n2 )
6 4c · n2 + O(n2 ) = O(n2 ) .
5.3. MULTIPLICATION RAPIDE 157

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

Comme précédemment, les « + » et « − » dans les étapes 3 et 4 sont l’addition et la


soustraction entre deux grands nombres qu’on suppose être des opérations connues.
Comme pour l’addition, la soustraction s’effectue par un simple parcours linéaires des
deux tableaux de chiffres.

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 :

T (n) = 3 · T (dn/2e + 1) + O(n) (5.5)

ce qui en enlevant la notation asymptotique donne :


(
3 · T (dn/2e + 1) + cn si n > 1
T (n) 6
c si n 6 1
5.3. MULTIPLICATION RAPIDE 159

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

Rappelons (cf. l’équation (1.4)) que :


i−1
X (3/2)i − 1
(3/2)j = < 2 · (3/2)i .
(3/2) − 1
j=0

On a précédemment vu que n/2i 6 1 lorsque i = log2 n , et la récurrence s’arrête sur le


 
cas terminal T (1). Cela donne :

T (n) 6 3dlog2 ne · T (1) + 2 · (3/2)dlog2 ne · cn


6 3(log2 n)+1 · c + 2 · (3/2)(log2 n)+1 · cn
6 3c · 3log2 n + 4c · (3/2)log2 n · n

On va utiliser le fait que 20 xlogb y = y logb x . Donc 3log2 n = nlog2 3 et (3/2)log2 n · n =


nlog2 (3/2)+1 = nlog2 (3/2)+log2 (2) = nlog2 (2·3/2) = nlog2 3 . D’où :

T (n) 6 3c · nlog2 3 + 4c · nlog2 3 = O(nlog2 3 ) = O(n1.59 )

car log2 3 = 1.5849625....


C’est significativement plus rapide lorsque n est grand. Dans le tableau de compa-
raison du paragraphe 5.2.5 qui compare différentes complexités et temps d’exécution
pour n = 109 , on passerait ainsi de 30 ans pour un algorithme en n2 à 51 heures pour
l’algorithme en nlog2 3 . Bien sûr, il n’est pas dit qu’en pratique on soit amené à multiplier
des nombres d’un milliard de chiffres. Cependant, pour des clés cryptographiques de
l’ordre du Mo, n = 106 est plausible. Dans ce cas on passerait de 16 minutes à 3 secondes.

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.

5.4 Master Theorem


Au travers des exemples de ce chapitre (et même avant), on a vu que la complexité
en temps T (n) d’un algorithme pouvait s’exprimer par une équation (ou inéquation) de
récurrence. Bien souvent l’équation ressemble à ceci :

T (n) 6 a · T (n/b + c) + f (n) (5.6)

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.

La comparison formelle des fonctions f (n) et nλ , en fait des asymptotiques, se fait en


jouant sur l’exposant avec ε. Mais l’idée est bien de dire : soit f (n)  nλ , soit f (n) ≈ nλ ,
soit f (n)  nλ .

f (n)  nλ ≈ nλ  nλ
T (n) nλ nλ log n f (n)

5.4.1 Exemples d’applications


Dans ce cours, on a déjà vu trois exemples :

T (n) 6 2 · T (dn/2e) + O(n)


On peut prendre a = b = 2, c = 1 (car dn/2e 6 n/2 + 1) et f (n) = Θ(n). Alors λ =
logb a = 1, et donc nλ = n. Il vient que T (n) = Θ(n log n).

T (n) 6 4 · T (dn/2e) + O(n)


On peut prendre a = 4, b = 2, c = 1 et f (n) = O(n). Alors λ = logb a = 2, et donc
nλ = n2 . Il vient que T (n) = Θ(n2 ).

T (n) 6 3 · T (dn/2e + 1) + O(n)


On peut prendre a = 3, b = 2, c = 2 et f (n) = O(n). Alors λ = logb a = log2 3, et donc
nλ < n1.59 . Il vient que T (n) = O(n1.59 ).

Une autre récurrence qu’on rencontre souvent, typiquement lors d’une recherche
dichotomique, est la suivante :

T (n) 6 T (dn/2e) + O(1)


On peut prendre a = 1, b = 2, c = 1 et f (n) = O(1). Alors λ = logb a = 0, et donc
nλ = 1. Il vient que T (n) = Θ(log n).

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,

n/bi0 6 1 ⇔ n 6 bi0 ⇔ logb n 6 i0 .


 
Donc après i0 = logb n appels récursifs l’algorithme s’arrête sur le cas terminal. Appa-
raît alors dans T (n) un terme en :

ai0 · T (n/bi0 ) 6 ai0 · T (1) 6 adlogb ne < a(logb n)+1


6 a · alogb n = a · nlogb a = Θ(nλ )
en utilisant la croissance de T , le fait que a > 1 et b > 1 sont des constantes et le fait que
xlogb y = y logb x (cf. la note de bas de page20 ). On se rend compte d’ailleurs que si on avait
pris comme condition terminale T (c1 ) 6 c2 pour des constantes positives c1 , c2 au lieu
de T (1) 6 1, alors on aurait eut le terme c2 · a · (n/c1 )logb a = Θ(nλ ) ce qui ne change pas la
valeur asymptotique.
Bien sûr, il manque la contribution de f (n) dans T (n). En négligeant la constante c,
c’est la somme :
i0
X
2 2 i0
f (n) + a · f (n/b) + a · f (n/b ) + · · · + a · f (1) = ai · f (n/bi ) . (5.7)
i=0

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.

Et si c > 0 ? Examinons le cas où c > 0. Précédemment (avec c = 0), en déroulant i > 0


fois la récurrence, les paramètres en n dans T (n) et f (n) évoluaient ainsi :
n 7→ n/b 7→ (n/b)/b = n/b2 7→ · · · 7→ n/bi .
En tenant compte de c, ils évoluent en fait plutôt comme ceci :
n 7→ n/b + c 7→ (n/b + c)/b + c = n/b2 + c(1/b + 1)
7→ (n/b2 + c(1/b + 1))/b + c = n/b3 + c(1/b2 + 1/b + 1)
...
i−1
X
7→ n/bi + c 1/bj 6 n/bi + cb/(b − 1) .
j=0

car on remarque21 que i−1 j


P
j=0 1/b < b/(b − 1) car b > 1. Donc le terme que l’on obtient en
déroulant i la récurrence est :
 
i−1
X  j−1
X 
T (n) 6 ai · T (n/bi + cb/(b − 1)) + aj · f n/bj + c 1/bk  ∀i > 0 .
 
 
j=0 k=0

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

ai0 · T (n/bi0 + cb/(b − 1)) 6 adlogb ne · T (1 + cb/(b − 1))


= O(alogb n ) · T (O(1)) = O(nlogb a ) = O(nλ ) .
Ce qui n’a rien changé. Il en va de même pour le second terme. On peut vérifier par
exemple · f (n) implique aj · f (n/bj +
que dans le cas 3, la condition a · f (n/b + c) 6 qP
P k j +∞ i
k 1/b ) 6 q · f (n). Et donc que la majoration précédente ( i=0 q ) · f (n) = O(f (n)) reste
valable.
On peut trouver la preuve complète et formelle du Master Theorem dans [CLRS01]
par exemple.
164 CHAPITRE 5. DIVISER POUR RÉGNER

5.4.3 D’autres récurrences


Bien sûr le Master Theorem ne résout par toutes les récurrences. Par exemple T (n) =
a · T (n − b) + f (n) qui se résout en T (n) = Θ(an/b · n/b n/b · n · f (n)). Il faut
P
i=0 f (n − ib)) = O(a
a, b > 0. Le cas a = 1 avec T (n) = Θ(n · f (n)) est fréquent.

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 Calcul du médian

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

Et puis le nombre d’échanges peut-être grand. Combien ? Sans doute beaucoup si


on ne prête pas attention à l’ordre dans lequel on opère les échanges. En effet, un
élément donné peut au cours de l’algorithme se rapprocher puis s’éloigner (et ceci
plusieurs fois) de sa position finale. En raffinant l’algorithme encore un peu on
peut effectuer les échanges à la suite en se dirigeant vers le début du tableau. C’est
un peu le tri-par-bulles qui est en O(n2 ).
(2) « Chercher le plus petit élément et le placer au début, puis recommencer avec le
reste du tableau. »
C’est l’algorithme du tri-par-sélection où l’on construit le tableau final trié pro-
gressivement élément par élément à partir de la gauche. Cette construction li-
néaire permet de se convaincre que le tableau est correctement ordonné à la fin
de l’algorithme. La complexité est en O(n2 ) ce qui est atteint lorsque les éléments
dans rangés dans l’ordre décroissant.

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.2 Tri-rapide avec choix aléatoire du pivot


[Cyril. À finir.]

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

[BS76] J. L. Bentley and M. I. Shamos, Divide-and-conquer in multidimensional space,


in 8th Annual ACM Symposium on Theory of Computing (STOC), ACM
Press, May 1976, pp. 220–230. doi : 10.1145/800113.803652.
[CLRS01] T. H. Cormen, C. E. Leiserson, R. L. Rivest, and C. Stein, Introduction to
Algorithms (second edition), The MIT Press, 2001.
[CW90] D. Coppersmith and S. Winograd, Matrix multiplication via arithmetic pro-
gressions, Journal of Symbolic Computation, 9 (1990), pp. 251–280. doi :
10.1016/S0747-7171(08)80013-2.
[Für09] M. Fürer, Faster multiplication algorithm, SIAM Journal on Computing, 39
(2009), pp. 979–1005. doi : 10.1137/070711761.
[HvdH19] D. Harvey and J. van der Hoeven, Integer multiplication in time
O(n log n), Tech. Rep. hal-02070778, HAL, March 2019. https://hal.archives-
ouvertes.fr/hal-02070778.
[KO62] A. A. Karatsuba and Y. Ofman, Multiplication of many-digital numbers by
automatic computers, Doklady Akad. Nauk SSSR, 145 (1962), pp. 293–294.
http://mi.mathnet.ru/dan26729.
[LG14] F. Le Gall, Powers of tensors and fast matrix multiplication, in 39th Interna-
tional Symposium on Symbolic and Algebraic Computation (ISSAC), ACM
Press, July 2014, pp. 296–303. doi : 10.1145/2608628.2608664.
[MB02] G. Malandain and J.-D. Boissonnat, Computing the diameter of a point set, In-
ternational Journal of Computational Geometry and Applications, 12 (2002),
pp. 489–510. doi : 10.1142/S0218195902001006.
[MMS20] A. Maheshwari, W. Mulzer, and M. Smid, A simple randomized O(n log n)-
time closest-pair algorithm in doubling metrics, April 2020. doi : 2004.05883v1.
[PFM74] M. S. Paterson, M. J. Fischer, and A. R. Meyer, An improved overlap argu-
ment for on-line multiplication, in Complexity of Computation, R. M. Karp,
ed., vol. VII, SIAM-AMS proceedings, American Mathematical Society, 1974,
pp. 97–111.
[Smi03] M. Smid, Computing the diameter of a point set : sequentiel and parallel algo-
rithms, November 2003.
[SS71] A. Schönhage and V. Strassen, Schnelle Multiplikation großer Zahlen, Com-
puting, 7 (1971), pp. 281–292. doi : 10.1007/BF02242355.
[Str69] V. Strassen, Gaussian elimination is not optimal, Numerische Mathematik, 13
(1969), pp. 354–356. doi : 10.1007/BF02165411.
[vdH14] J. van der Hoeven, Faster relaxed multiplication, in 39th International Sym-
posium on Symbolic and Algebraic Computation (ISSAC), ACM Press, July
2014, pp. 405–412. doi : 10.1145/2608628.2608657.

Vous aimerez peut-être aussi