Académique Documents
Professionnel Documents
Culture Documents
(E. W. Dijkstra)
Lorsqu’un calcul s’arrête en un temps fini et que le résultat final fournit la réponse au problème
on dit alors que ce calcul est un algorithme.
1 Terminaison d’algorithmes.
Turing a prouvé, en 1936, que la terminaison est un problème indécidable. En d’autres termes, il
ne peut pas exister de mécanisme/algorithme capable de toujours prouver la terminaison d’un
programme. On ne pourrait donc jamais créer une méthode universelle qui prend un quelconque
programme en entrée et donne en sortie « se termine » ou « ne se termine pas » en un temps fini.
Autrement dit, dans certains cas il est possible de déterminer que le programme se termine ou qu’il ne
se terminera pas, mais il est impossible de le faire pour tous les programmes. Heureusement, depuis, on
a trouvé différentes techniques qui permettent de prouver la terminaison d’un grand nombre de
programmes.
Analysons un exemple qu’il est impossible de traiter, c’est-à-dire une fonction dont on ne sait pas
déterminer si elle va se terminer pour toutes ses entrées possibles. Cet exemple provient de la suite de
Collatz (ou Syracuse). Pour construire une suite de Collatz, il faut partir d’un nombre entier strictement
positif. Ensuite, s’il est pair il faut le diviser par 2, par contre, dans le cas d’un nombre impair, il faut le
multiplier par 3 et ajouter 1. Cette opération est répétée et produit une suite d’entiers positifs dont
chacun dépend de son prédécesseur. La conjoncture de Collatz déclare que la suite de Collatz de
n’importe quel entier strictement positif, atteint 1.
En 1949, Alan Turing, publie une première preuve de terminaison basée sur une notion d’ordre,
appelée les ordinaux. Il publie cette preuve dans l’article « Checking a Large Routine ». Robert W.
Floyd formalise l’idée de Turing. Il décrit la méthode traditionnelle pour prouver la terminaison d’un
programme. Cette méthode se décompose en deux parties :
1. Rechercher un argument de terminaison : Le but de cette étape est de rechercher un argument de
terminaison sous la forme d’une fonction qui lie chaque état du programme à une valeur dans
une structure mathématique. Cette structure mathématique est appelée un ordre bien fondé.
2. Tester l’argument de terminaison : Si le résultat de la fonction trouvée diminue à chaque
transition de programme, l’argument de terminaison sera valide et le programme se terminera.
Formellement, soit F la fonction trouvée et le programme a une transition de l’état s1 à l’état s2,
alors il faut que F(s1) > F(s2). La fonction F est appelée fonction de rang.
Une relation d'ordre large est une relation réflexive, antisymétrique et transitive.
Soit E un ensemble et R une relation de E vers E :
R est réflexive ssi pour tout x dans E on a R(x,x)
R est réflexive ssi pour tout x dans E on a : not R(x,x)
R est transitive ssi pour tout x,y,z dans E , dès que R(x,y) et R(y,z), on a aussi R(x,z)
R est antisymétrique ssi pour tout x,y dans E , dès que R(x,y) et R(y,x), on a aussi x=y
On note en général les relations d'ordre large ≤ et les relations d'ordre strict <.
Une relation d'ordre est totale si tous les éléments sont deux à deux comparables. Sinon elle est
partielle.
Un ensemble ordonné (E,<) est bien fondé ssi tout sous ensemble non vide de E admet un plus
petit élément.
Cela signifie en particulier que E a au moins un plus petit élément. Si de plus, E est un bon ordre
(total), il en a exactement un.
On peut ordonner les éléments de N par « < ». Il y a un élément 0 qui est plus petit que tout le
monde.
Prouver la terminaison consiste à exhiber une fonction de rang (parfois nommée variant de
boucle).
Une fonction de rang à valeur dans (R,<) pour une boucle est une fonction entière qui dépend des
variables de la boucle et qui décroît strictement à chaque passage dans la boucle.
Pour valider une fonction de rang, on vérifie pour chaque passage dans la boucle les conditions
suivantes :
1. Elle est à valeur dans (R,<) à l’entrée du corps de la boucle juste après la condition d’arrêt
2. Elle est à valeur dans (R,<) juste avant l’instruction fin du corps de la boucle ;
3. Elle décroît strictement entre ces deux points.
Excercice :
Proposer et valider une fonction de rang pour l’algorithme Produit.
.
Fonction Produit(a,b :entiers) :entiers
Var x := a; p :=0 ;
Début
Tantque x> 0 faire
p :=p + b ;
x:=x-1 ;
finTantque
Produit :=p ;
FinFonc
Précondition : C'est une proposition portant sur l'état de la mémoire et que l'on pense vérifié
avant l'exécution d'un fragment de code.
Postcondition : C'est une proposition portant sur l'état de la mémoire et que l'on pense vérifié
après l'exécution d'un fragment de code.
Spécification : Un programme est spécifié par une précondition et une post condition
déterminant les cas dans lesquels le programme va être exécuté et son résultat.
Nous ferons appel à des notations logiques simples et usuelles, dont voici un récapitulatif :
¬P signifie «non» p
Cette liste respecte l’ordre standard de précédence, du symbole le plus fort (¬….) au symbole le plus
faible.
Exemple 2.2 :
Définition 2.1 :
2. Règles de déduction
2.1 Rappel
où chaque règle appliquée est une instance d’une règle. J est la racine de l’arbre, les jugements n’ayant
pas de prémisses sont les feuilles.
Un arbre de déduction complet est un arbre dans laquelle toutes les feuilles sont des instances
d’axiomes.
un jugement J est prouvable si il existe un arbre de déduction complet avec J à la racine. On parle alors
d’arbre de preuve pour J.
Une règle de Hoare est une règle de déduction, c’est-à-dire une fraction de la forme :
Les prémisses et la conséquence sont des triplets de Hoare. Une telle fraction se lit de la manière
suivante : "Si les triplets prémisses sont vrais, alors le triplet conclusion aussi".
Définition (correction partielle) Le triplet de Hoare suivant {Φ}C{Ψ} est vrai si pour tout état initial
vérifiant Φ, si l’exécution de C se termine, alors Ψ est vraie après l’exécution de C. On dit que le
programme C est partiellement correct par rapport à Φ et Ψ.
Définition (correction totale) Le triplet de Hoare suivant {Φ}C{Ψ} est vrai si pour tout état initial
vérifiant Φ, C se termine et Ψ est vraie après l’exécution de C. On dit que le programme C est
partiellement correct par rapport à Φ et Ψ.
La correction totale s’écrit avec des <…> (parfois avec des [...]) .
Théorème (Correction des règles de Hoare). Étant donné un triplet de Hoare donné T, s’il existe un
arbre de déduction de Hoare complet ayant T à sa racine, alors le triplet est vrai.
Nous allons définir et illustrer ce qu’est un arbre de déduction de Hoare dans la suite en détaillant
chaque règle.
Affectation
Soit le triplet, manifestement vrai, {z-y≥0} x :=z-y {x≥0}. La règle de déduction de l’affectation doit
permettre de prouver ce triplet donc la règle de l’affectation doit pouvoir s’appliquer comme suit :
❑
{z − y ≥ 0 }x :=z − y {x ≥0 }
On voit que x≥0 est vrai après l’exécution du programme seulement si la même propriété est vraie pour
z-y au lieu de x. La règle de l’affectation exprime ceci de la manière suivante :
❑ Aff
{ɸ [ x :=av ] }x :=av {ɸ}
où la notation Φ[ x :=av ] signifie « Φ dans laquelle x a été remplacé par av ». Si on lit cette règle de la
manière suivante elle devient claire : Pour que Φ soit vraie pour x après le programme x :=av , il fallait
qu’elle soit vraie pour av avant le programme.
Séquence
{ɸ }C 1 {ɸ ' }{ɸ' }C 2 {Ψ }
Seq
{ɸ}C1 ; C2 {Ψ }
Conséquence
Les propriétés que l’on peut prouver directement à partir des règles vues jusqu’ici sont d’une forme très
contrainte, or il faut pouvoir déduire de ces propriétés celles qui nous intéressent. Ceci se fait par
simple déduction logique à partir des propriétés prouvées par les règles précédentes. Pour cela on
ajoute la règle suivante (notez que les deux implications ne sont pas dans le même sens) :
Ou
Conditionnelle
On ajoute ensuite la règle de la conditionnelle, qui exprime bien que la post-condition doit être vérifiée
dans les deux branches du if chacune sous la condition que le résultat du test est conforme à la branche.
En particulier si une des branches est impossible, alors la prémisse correspondante est trivialement
juste (règle CONSEQ).
{ɸ ∧ B }C 1 {Ψ }{ɸ∧ ¬ B }C 2 {Ψ }
Cond
{ɸ }If B then C1 else C 2 {Ψ }
While
Idée est d’exhiber un invariant de boucle, c’est-à-dire un prédicat P telle que :
1. (<pré-condition> P )
2. p est vrai après chaque itération
3. ( P ∧¬ B <post-condition>)
{P ∧ B }C {P }
Whil
{P }While B then C {P ∧ ¬ B }
3 Méthode diviser-pour-régner
Il existe de nombreuses façons de concevoir un algorithme. Le tri par insertion utilise une
approche incrémentale : après avoir trié le sous-tableau tab[1 . . j − 1], on insère l’élément tab[j] au bon
emplacement pour produire le sous-tableau trié tab[1 . . j].
Rappel : on prend deux éléments qu'on trie dans le bon ordre, puis un 3e qu'on insère à sa place
parmi les 2 autres, puis un 4e qu'on insère à sa place parmi les 3 autres, etc. C'est la méthode la plus
utilisée pour trier des dossiers, des cartes à jouer.
Le principe de l'algorithme est le suivant. On parcourt le tableau du début à la fin (i = 1à n-1), et à
l'étape i, on considère que les éléments de 0 à i -1 du tableau sont déjà triés. On va alors placer le i-ème
élément à sa bonne place parmi les éléments précédents du tableau, en le faisant « redescendre » jusqu'à
atteindre un élément qui lui est inférieur.
On donne ci-dessous l'algorithme de tri par insertion
Cette section va présenter une autre approche de conception, baptisée « diviser pour régner ».
Nous utiliserons cette technique pour créer un algorithme de tri dont le temps d’exécution du cas le plus
défavorable sera très inférieur à celui du tri par insertion.
Nombre d’algorithmes utiles sont d’essence indexrécursivité récursive : pour résoudre le
problème, ils s’appellent eux-mêmes, de manière récursive, une ou plusieurs fois pour traiter des sous-
problèmes très similaires. Ces algorithmes suivent généralement une approche diviser pour régner : ils
séparent le problème en plusieurs sous-problèmes semblables au problème initial mais de taille
moindre, résolvent les sous-problèmes de façon récursive, puis combinent toutes les solutions pour
produire la solution du problème original.
Le paradigme diviser-pour-régner implique trois étapes à chaque niveau de la récursivité :
1. Diviser : le problème en un certain nombre de sous-problèmes.
2. Régner : sur les sous-problèmes en les résolvant de manière récursive. Si la taille d’un
sous-problème est suffisamment réduite, on peut toutefois le résoudre directement.
3. Combiner : les solutions des sous-problèmes pour produire la solution du problème
originel.
L’algorithme du tri par fusion suit fidèlement la méthodologie diviser-pour-régner. Intuitivement,
il agit de la manière suivante :
Diviser : Diviser la suite de n éléments à trier en deux sous-suites de n/2 éléments
chacune.
Régner : Trier les deux sous-suites de manière récursive en utilisant le tri par fusion.
Combiner : Fusionner les deux sous-suites triées pour produire la réponse triée.
24 6 23 14 1 13
triFusion(T,1,6)
24 6 23 14 1 13
triFusion(T,1,3) triFusion(T,4,6)
24 6 23 14 1 13
triFusion(T,1,2) triFusion(T,3,3) triFusion(T,4,5) triFusion(T,6,6)
24 6 23 14 1 13
Fusion(T,1,1,2) Fusion(T,3,4,5)
6 24
1 14
Fusion(T,1,2,3)
Fusion(T,3,4,6)
6 23 24
1 13 14
Fusion(T,1,3,6)
24 6 23 14 1 13
Fig.1.2 : Application du tri par fusion sur la suite d’entiers : 24, 6,23,14,1,13.
4 Programmation dynamique
La programmation dynamique, comme la méthode diviser-pour-régner, résout des problèmes en
combinant des solutions de sous-problèmes. (« Programmation », dans ce contexte, fait référence à une
méthode tabulaire et non à l’écriture de code informatique.).
Comme nous l’avons vu à la section précédente, les algorithmes diviser-pour-régner partitionnent
le problème en sous-problèmes indépendants qu’ils résolvent récursivement, puis combinent leurs
solutions pour résoudre le problème initial. La programmation, quant à elle, peut s’appliquer même
lorsque les sous-problèmes ne sont pas indépendants, c’est-à-dire lorsque des sous-problèmes ont des
sous-sous-problèmes communs. Dans ce cas, un algorithme diviser-pour-régner fait plus de travail que
nécessaire, en résolvant plusieurs fois le sous-sous-problème commun. Un algorithme de
programmation dynamique résout chaque sous-sous-problème une seule fois et mémorise sa réponse
dans un tableau, évitant ainsi le recalcul de la solution chaque fois que le sous-sous-problème est
rencontré.
n
Nous allons nous intéresser au calcul du coefficient binomial C p. Une solution consiste à utiliser
la formule de Pascal, ce qui nous amène à écrire :
Malheureusement, cette fonction s’avère très peu efficace, même pour de relativement faibles
valeurs de n et p (à titre d’illustration, il faut 93 secondes à mon ordinateur pour calculer C 30
15. La raison
5
en est facile à comprendre : lorsqu’on observe par exemple l’arbre de calcul de C 2 on constate que de
nombreux appels récursifs sont identiques et donc superflus :
Nous pouvons par exemple constater que le calcul de C 52 nécessite de calculer deux fois C 31 et
trois fois C 21.
La solution proposée par la programmation dynamique consiste à commencer par résoudre les
plus petits des sous-problèmes, puis de combiner leurs solutions pour résoudre des sous-problèmes de
plus en plus grands.
Concrètement, le calcul de C 52 se réalise en suivant le schéma suivant :
Pour réaliser ce type de solution on utilise souvent un tableau, ici un tableau bi-dimensionnel (n +
1)×(p + 1)(dont seule la partie pour laquelle j≤ i sera utilisée). Ce tableau sera progressivement rempli
par les valeurs des coefficients binomiaux, en commençant par les plus petits :
Il faut faire attention à bien respecter la relation de dépendance (modélisée par les flèches sur le
schéma ci-dessus) pour remplir les cases de ce tableau : la case destinée à recevoir la valeur de C ij. Ne
i− 1 i− 1
peut être remplie qu’après les cases destinées à recevoir C j −1 et C j .
Concrètement, résoudre un problème par la programmation dynamique consiste à suivre les étapes
indiquées ci-dessous :
1. Obtenir une relation de récurrence liant la solution du problème global à celles de problèmes
locaux non indépendants ;
2. Initialiser d’un tableau à l’aide des conditions initiales de la relation obtenue au point
précédent ;
3. Remplir ce tableau en résolvant les problèmes locaux par taille croissante, à l’aide de la relation
obtenue au premier point.
Cette technique est fréquemment employée pour résoudre des problèmes d’optimisation : elle
s’applique dès lors que la solution optimale peut être déduite des solutions optimales des sous-
problèmes (c’est le principe d’optimalité de Bellman, du nom de son concepteur). Cette méthode
garantit d’obtenir la meilleure solution au problème étudié, mais dans un certain nombre de cas sa
complexité temporelle reste trop importante pour pouvoir être utilisée dans la pratique.
Dans ce type de situation, on se résout à utiliser un autre paradigme de programmation, la
programmation gloutonne. Alors que la programmation dynamique se caractérise par la résolution par
taille croissante de tous les problèmes locaux, la stratégie gloutonne consiste à choisir à partir du
problème global un problème local et un seul en suivant une heuristique (c’est à dire une stratégie
permettant de faire un choix rapide mais pas nécessairement optimal). On ne peut en général garantir
que la stratégie gloutonne détermine la solution optimale, mais lorsque l’heuristique est bien choisie on
peut espérer obtenir une solution proche de celle-ci.
Remarque : Un algorithme décrit une séquence d’opérations non ambiguës à réaliser sur des
données pour résoudre un problème. Lorsqu’un algorithme permet la recherche d’une solution à une
problématique, l’arbre du possible à explorer peut-être très grand et croitre de manière exponentielle
avec la taille du problème à traiter. Si cela devient gênant pour converger vers une solution en un temps
raisonnable, il peut être intéressant d’ajouter à l’algorithme certaines règles basées sur l’expérience
ou l’intuition afin d’éliminer très tôt de grands pans de l’arbre de recherche. Cela permet d’écourter
d’autant l’exploration. Ce sont les heuristiques.
Comme une heuristique est toujours une hypothèse raisonnable, mais non garantie (si c’était
garanti, ce ne serait plus une heuristique), cela signifie aussi que la solution optimale peut être éliminée
dans la foulée. Elles sont utilisées quand l’obtention d’une bonne solution rapidement est plus
intéressante que trouver la meilleure solution en un temps extrêmement long.
5 Algorithmes gloutons
Les algorithmes pour problèmes d’optimisation exécutent en général une série d’étapes, chaque
étape proposant un ensemble de choix. Pour de nombreux problèmes d’optimisation, la programmation
dynamique est une approche bien trop lourde pour déterminer les meilleurs choix ; d’autres algorithmes,
plus simples et plus efficaces, peuvent faire l’affaire. Un algorithme glouton fait toujours le choix qui
lui semble le meilleur sur le moment. Autrement dit, il fait un choix localement optimal dans l’espoir
que ce choix mènera à une solution globalement optimale. Cette section étudie les problèmes
d’optimisation qui peuvent se résoudre par des algorithmes gloutons.
Les algorithmes gloutons n’aboutissent pas toujours à des solutions optimales, mais ils y arrivent
dans de nombreux cas. Nous commencerons par examiner à la section suivante un problème simple
mais non trivial, à savoir le problème d’empaquetage, pour lequel un algorithme glouton calcule une
solution efficacement. Nous arriverons à l’algorithme glouton en commençant par étudier une solution
basée sur la programmation dynamique, puis en montrant que l’on peut toujours faire des choix
gloutons pour arriver à une solution optimale.
Exemple : empaquetage.
Soit un ensemble d’objets E = {1, . . . , n} de poids p1, . . . , pn, des boites de capacités C
Problème : placer les objets dans les boites en respectant leurs capacités en utilisant le moins
possible de boites.
Méthode : par choix successifs d’un objet et d’une boite
Choix glouton : placer le premier objet dans la première boîte où c’est possible.
Exemples classiques: Les algorithmes de Prim et de Kruskal de calcul d'un arbre de recouvrement
d'un graphe de poids minimal, l'algorithme des plus courts chemins dans un graphe de Dijkstra, le code
de Huffman, de nombreuses versions de problèmes d'affectations de tâches...