Terminaison et complexité

information-icon

Les premières épreuves du bac de français édition 2024 sont pour bientôt ! Consulte les dates du bac de français, notre programme de révision accompagné de toutes nos fiches de révisions pour te préparer au mieux à ton examen 💪

Introduction :

Les algorithmes sont aujourd’hui omniprésents dans tous les secteurs et dans notre vie quotidienne. Dans ce chapitre, nous préciserons d’abord la grande ancienneté des algorithmes avant de nous intéresser aux notions de terminaison et de correction, puis de complexité algorithmique, que nous illustrerons ensuite par des études de cas d’algorithmes de recherche.

Ancienneté et modernité des algorithmes

De nos jours le terme algorithme est étroitement associé à l’informatique. Il n’est toutefois pas né avec les sciences numériques, mais avec les mathématiques dont les origines sont bien plus anciennes.

bannière definition

Définition

Algorithme :

Un algorithme est une séquence d’instructions décrivant de manière précise les étapes de la résolution d’un problème mathématique.

C’est une définition très large, qui peut s’appliquer aussi bien aux mathématiques, à l’informatique, et même à des recettes de cuisine ou au montage d’un meuble en kit.

Antériorité des algorithmes à l’informatique

Les algorithmes sont très antérieurs à l’informatique. Ils sont employés depuis l’Antiquité pour résoudre des problèmes. Les mathématiciens babyloniens ont transcrit sur tablettes, des procédures de calcul et de résolution d’équations. Les mathématiciens grecs employaient déjà des algorithmes : on peut citer le crible d’Eratosthène pour trouver des nombres premiers, l’algorithme d’Euclide pour déterminer le plus grand commun diviseur de deux nombres entiers ou encore l’algorithme d’Archimède pour le calcul du nombre $\pi$ (pi).

Les mathématiciens grecs n’employaient cependant pas le terme d’algorithme qui est d’apparition plus récente.

Préhistoire informatique et algorithmes

Le mot algorithme est une déclinaison latinisée du nom du grand mathématicien perse du IXe siècle Al-Khwârizmî, à qui on doit notamment les premiers manuels d’algèbre et de méthodes de résolution d’équation.

Au XIXe siècle, le métier à tisser mécanique a été inventé par le mécanicien français Joseph Marie Jacquard, premier système se programmant avec des cartes perforées. Quelques années plus tard, la mathématicienne britannique Ada Lovelace publie le tout premier algorithme destiné à être exécuté par la machine analytique inventée par Charles Babbage.

L’informatique se développe au XXe siècle, sous l’égide de plusieurs mathématiciens, parmi lesquels le britannique Alan Turing. Il formalise les concepts d’algorithmes et d’ordinateur et participe ensuite au développement des premières machines capables de stocker électroniquement des données.

Algorithmes informatiques

Le développement fulgurant des ordinateurs pendant la seconde partie du XXe siècle, entraîne celui des langages informatiques utilisés pour traduire la logique algorithmique, en instructions compréhensibles par l’ordinateur.

L’algorithme indique une méthode à appliquer pour résoudre un problème. Un programme informatique est une implémentation possible d’un algorithme.

  • Un même algorithme peut donc être appliqué de différentes manières, et dans différents langages informatiques.

Dans le langage courant, algorithme et programme sont souvent interchangeables. Avec les développements récents mais spectaculaires de l’intelligence artificielle, les algorithmes désignent aussi dans le langage courant toutes les décisions prises par des machines sur la base d’analyse de volumes colossaux de données.

Les méthodes de création et d’évaluation des algorithmes ont accompagné les progrès technologiques. En sciences numériques, les problèmes algorithmiques sont abordés de manière formalisée selon un cadre théorique bien défini. Ce cadre établit un certain nombre de critères d’évaluation des algorithmes.

Terminaison et correction algorithmiques

Il est nécessaire de pouvoir s’assurer qu’un algorithme effectue bien ce qui est attendu, qu’il finit bien par s’arrêter et qu’il effectue son travail dans un délai raisonnable. Nous allons dans ce chapitre nous intéresser aux notions de terminaison et de correction des algorithmes, et nous étudierons ensuite leur complexité dans la dernière partie.

Terminaison d’un algorithme

  • Un algorithme est une série d’instructions destinés à résoudre un problème.
bannière à retenir

À retenir

L’algorithme doit donc se terminer à un moment. Les séquences d’instruction des algorithmes se composent notamment de boucles et de branchements conditionnels.

  • Il est donc nécessaire de s’assurer qu’aucune erreur de conception ou d’implémentation n’empêche l’algorithme de se terminer.

Dans le cas des boucles, on distingue les boucles de type for et les boucles de type while.

  • Les boucles de type for

Avec les boucles de type for le nombre de tours de boucle, appelés itérations, est explicitement défini. La sortie de boucle est donc certaine, à l’issue du parcours d’un nombre initialement fixé de valeurs.

nombres = [3, 9, 12, 17, 41]

for nombre in nombres:

print(nombre)

On obtient l’affichage suivant :

3
9
12
17
41

Pour faire émerger plus explicitement la notion de variant de boucle, nous réécrivons le code qui précède avec la notation indicielle, en remplacement de la notation for … in typique de Python.

for index in range(len(nombres)):

print(nombres[index])

On obtient le même affichage que précédemment. La variable index va prendre successivement toutes les valeurs générées par la séquence range à partir de la longueur de la liste.

Pour être encore plus explicite on peut demander d’afficher la valeur de la variable index correspondant à la position de l’élément dans la liste.

for index in range(len(nombres)):

print(index, nombres[index])

On obtient l’affichage suivant :

0 3
1 9
2 12
3 17
4 41

La variable index prend bien les valeurs successives correspondant à chaque élément de la liste considérée. Notre algorithme comporte bien un variant de boucle.

bannière definition

Définition

Variant de boucle :

Un variant de boucle est une expression dont la valeur varie à chaque itération

  • Les boucles de type while

Avec les boucles de type while le nombre d’itérations peut être indéterminé ; il existe par conséquent un risque que l’algorithme ne se termine jamais s’il est mal conçu ou mal implémenté.

Le programme suivant affiche les nombres de 1 à 10 :

i = 0

while i != 11:

print(i)

i += 1

bannière attention

Attention

Si on omet la dernière ligne qui incrémente la valeur de la variable i, elle conservera indéfiniment sa valeur initiale. La condition testée au niveau de l’instruction while restera toujours vraie. Le programme réalise alors une infinité de boucles, souvent désignée boucle infinie, qui nécessite un arrêt forcé pour reprendre la main.

On peut facilement créer une boucle infinie avec un test incorrect par rapport au variant de boucle. Si nous décidons de modifier notre programme pour n’afficher que les nombres pairs en modifiant la valeur d’incrémentation, nous créons aussi une boucle infinie.

i = 0

while i != 11:

print(i)

i += 2

# Attention, ce programme crée une boucle infinie !

# Si vous le lancez, vous devrez utiliser [control]+[C] pour l'interrompre.

En effet, notre programme ne calcule que des nombres pairs, et la condition évalue au niveau de l’instruction while une non-égalité avec le nombre impair 11. Cette condition ne sera donc jamais fausse et le programme ne se terminera pas. On corrigera aisément ce code en indiquant une valeur paire ou en remplaçant la non-égalité par une inégalité d’infériorité.

Quand les expressions évaluées par l’instruction while sont simples, il est assez facile de se prémunir contre une boucle infinie. Mais avec une expression plus complexe, la condition de sortie de boucle peut très bien ne pas se réaliser dans certains cas. Il faut donc prendre soin de toujours définir des conditions de sortie qui doivent finir par se réaliser quelles que soient les données fournies à l’algorithme.

Si la boucle n’a pas d’élément variant comme dans notre premier exemple (omission de l’incrémentation) ou que les valeurs prises par l’élément variant ne correspondent pas à l’évaluation de l’expression conditionnelle à l’origine de la boucle, la terminaison ne peut pas se produire.

bannière à retenir

À retenir

Un algorithme doit se terminer. Mais la terminaison d’un algorithme ne garantit pas la correction du résultat qu’il fournit.

Correction d’un algorithme

La démarche pour prouver la correction d’un algorithme consiste à trouver un invariant de boucle.

bannière definition

Définition

Invariant de boucle :

Un invariant de boucle est une propriété qui est vraie avant l’exécution d’une itération et qui reste vraie après cette exécution.

  • L’invariant de boucle n’est pas altéré par l’itération.

Découvrons plus concrètement cette notion avec un algorithme qui recherche et retourne la valeur minimale parmi une liste de valeurs.

def minimale(liste):

mini = liste[0]

for element in liste:

if element < mini:

mini = element

return mini

Nous testons cet algorithme avec une liste non ordonnée de valeurs.

nombres = [-15, 2, 9, 137, -444, 12, 22]:

print(minimale(nombres))

# affiche -444

Nous constatons qu’il se termine et il semble fonctionner correctement. Recherchons une propriété qui resterait vraie avant et après itération.

L’algorithme procède élément par élément et ajuste la valeur de sa variable locale mini en fonction des valeurs rencontrées. Si une valeur est inférieure, elle devient le nouveau minimum. On peut donc considérer qu’à chaque instant la variable mini est toujours inférieure ou égale aux valeurs déjà rencontrées.

  • Vérifions-le, étape par étape :
  • Avant l’entrée dans la boucle, la variable mini est initialisée avec la première valeur de la liste. Elle est donc bien inférieure ou égale à cette valeur.
  • Pour chaque tour de boucle la condition est vraie à l’entrée dans la boucle. Elle l’est aussi à la sortie de chaque tour de boucle, puisque sa valeur est chaque fois comparée à un nouvel élément de la liste :
  • si l’élément courant est plus grand, la variable mini reste inchangée et est donc déjà inférieure ou égale à toutes les valeurs déjà rencontrées ;
  • si l’élément courant est plus petit, la variable mini prend sa valeur pour devenir inférieure ou égale à toutes les valeurs déjà rencontrées en sortie de boucle.
  • Lorsque l’ensemble des itérations est terminé, la liste a été parcourue en totalité et la variable mini est bien inférieure ou égale à toutes les valeurs de la liste.
  • La propriété choisie est bien un invariant de boucle. Notre algorithme est donc correct.
bannière astuce

Astuce

Le fait que la valeur de la variable mini puisse varier au cours de l’algorithme n’est pas un problème, car notre invariant de boucle n’est pas sa valeur mais une propriété qui lui est rattachée : le fait qu’elle soit à tout moment inférieure ou égale aux valeurs déjà rencontrées.

S’il est relativement aisé de trouver un invariant de boucle pour des algorithmes simples, la tâche peut s’avérer plus difficile avec des algorithmes plus élaborés.

bannière à retenir

À retenir

  • L’existence d’un variant de boucle est nécessaire à la terminaison de l’algorithme.
  • L’existence d’un invariant de boucle est nécessaire à la correction de l’algorithme.

La troisième notion à prendre en compte est celle de complexité des algorithmes, qui fait l’objet de la prochaine partie.

Complexité algorithmique

Tous les algorithmes ne mobilisent pas les ressources de la machine de la même manière. La notion de complexité traduit ici les performances prévisibles d’un algorithme donné.

bannière definition

Définition

Complexité :

La complexité fait référence à la quantité de ressources nécessaire à la résolution d’un problème.

Dimensions temporelle et spatiale de la complexité

Ces ressources peuvent être temporelles et spatiale :

  • la complexité temporelle fait référence au temps mis par un programme informatique pour résoudre le problème ;
  • la complexité spatiale fait référence à l’étendue de l’espace mémoire de l’ordinateur nécessaire pour résoudre le problème.

Avec les ordinateurs modernes dotés de vastes espaces mémoire, la question de la complexité spatiale est devenue un peu moins cruciale. Elle ne doit pas être totalement négligée pour autant.

  • En général la complexité algorithmique désigne d’abord la complexité temporelle.

Scénarios optimistes et pessimistes

La performance d’un algorithme ne dépend pas seulement de la manière dont il a été conçu, mais aussi des données qui lui sont soumises.

Imaginons un algorithme chargé de déterminer si un élément est présent dans une liste non ordonnée d’éléments. Notre algorithme parcourt toute la liste, élément par élément, jusqu’à trouver l’élément, s’il est présent.

En voici une illustration concrète.

def trouve(nombre, liste):

tour_de_boucle = 0

for element in liste:

tour_de_boucle += 1

if element == nombre:

return tour_de_boucle

return tour_de_boucle

nombres = [12, 17, 3, 29, 41, 1, 99, 51, 32, 6]

print(trouve(41, nombres))

# affiche 5

print(trouve(6, nombres))

# affiche 10

print(trouve(12, nombres))

# affiche 1

  • Si l’élément se situe au milieu de la liste, notre algorithme doit parcourir la moitié de celle-ci pour trouver l’élément.
  • Si l’élément se situe au début de la liste, notre algorithme doit effectuer une seule lecture.
  • Si l’élément se situe en fin de liste, notre algorithme doit parcourir toute la liste avant de le trouver.
  • Les trois recherches effectuées en milieu, en fin et en début de liste montrent qu’avec cet algorithme le nombre de tours de boucle dépend de la position du nombre recherché dans la liste. Le pire cas de figure est évidemment lorsque le nombre recherché est le dernier de la liste ou absent de la liste, puisque dans les deux cas l’algorithme devra parcourir la totalité de la liste pour le constater.

En général quand on évalue la complexité d’un algorithme on considère en priorité le pire cas de figure et le cas moyen. Le meilleur cas de figure est plutôt considéré comme une éventuelle bonne surprise.

Ordre de grandeur

Nous pourrions compter les instructions individuelles et le nombre de fois où elles sont répétées pour évaluer notre algorithme. Mais ce qui nous intéresse surtout, c’est la capacité de l’algorithme à traiter de gros volumes de données. Si on désigne par $\text{n}$ la taille du problème considéré, il nous importe de savoir ce qui se passe quand $\text{n}$ devient très grand.

bannière exemple

Exemple

Dans notre exemple, $\text{n} = 10$ et dans le pire cas de figure notre algorithme doit effectuer $10$ tours de boucle. Si $\text{n} = 10,000$ notre algorithme devra effectuer dans le pire cas de figure $10,000$ tours de boucle.

L’ordre de grandeur de notre algorithme est donc directement lié à la taille $n$ de notre liste. Sa complexité est linéaire car directement proportionnelle à $n$.

Il existe une notation spécifique pour l’exprimer : appelée « grand $\text{O}$ », elle s’écrit $\text{O}()$. La lettre $n$ désigne la taille du problème considéré.

Dans le cas présent, l’ordre de grandeur est celui de $\text{n}$. On écrira donc $\text{O}(\text{n})$.

Quand $n$ devient très grand, le nombre d’instructions dans chaque tour de boucle importe de moins en moins, et devient négligeable par rapport à la valeur de $n$. La notation grand $\text{O}$ est une simplification pour pouvoir évaluer un ordre de grandeur et comparer des algorithmes entre eux.

Considérons un programme qui imbrique des boucles.

compteur = 0

for i in une_liste:

for j in une_liste:

compteur += 1

print(compteur)

Vous pourrez facilement constater en fixant différentes longueurs à la liste que le nombre de tours de boucles est $n\times n$, soit $n^2$. Il est de complexité quadratique et l’on exprime en ordre de grandeur par $\text{O}(n^2)$.

  • Cela signifie que si la liste comporte mille éléments, le programme effectuera un million de tours de boucle.

Pour les algorithmes plus complexes, étant composés de plusieurs termes, on retient uniquement celui d’ordre le plus élevé.

bannière exemple

Exemple

Pour $n^2+7\times n+400$ l’ordre de grandeur est $n^2$.

Si $n=1$ ou $n=10$ la valeur de la constante $400$ est bien plus importante, mais à mesure que $n$ grandit, son importance décroit. Il en va de même pour $7\times n$. On ne retient que $n^2$.

Si un coefficient multiplicateur est présent, il est pareillement négligé. Ainsi $3\times n$ et $7\times n$ sont tous deux d’ordre $n$, que l’on écrira $\text{O}(n)$.

Pour $3\times n+\text{log}2\ (n)+5$ l’ordre de grandeur est $n\times\text{log}(n)$.

Pour l’ordre de grandeur on ne précise pas non plus la base logarithmique qui devient négligeable quand $n$ grandit.

Les principaux ordres de grandeur algorithmiques, peuvent être classés selon leur complexité en temps croissante :

Ordre Complexité
$\text{O}(\text{1})$ temps constant, quel que soit le volume des données traitées
$\text{O}(\text{log}\,n)$ logarithmique
$\text{O}(n)$ linéaire
$\text{O}(n\,\text{log}\,n)$ pseudo-linéaire
$\text{O}(n^2)$ quadratique
$\text{O}(2^n)$ exponentielle

Algorithmes de recherche

Nous allons nous intéresser aux algorithmes de recherche pour illustrer les possibilités d’optimisation des algorithmes. Nous disposons d’une liste triée et nous désirons vérifier si un élément est présent dans cette liste.

Notre liste est un lexique de termes informatiques, triés par ordre alphabétique.

lexique = ['clavier', 'clé USB', 'disque dur', 'haut-parleur', 'imprimante', 'micro', 'moniteur', 'scanner', 'souris', 'webcam']

bannière rappel

Rappel

Il existe une méthode très simple pour obtenir notre résultat en une ligne de code. Le mot-clé in du langage Python permet d’évaluer l’appartenance d’un élément à une structure de données.

print('souris' in lexique)

# affiche True

print('stylet' in lexique)

# affiche False

Nous allons étudier différentes manières plus ou moins efficaces d’obtenir le même résultat.

Algorithme de recherche systématique

La première méthode est la plus simple. Nous parcourons toute la liste et nous évaluons pour chaque élément, s’il correspond à celui recherché. À l’issue du parcours nous indiquons par un booléen si l’élément recherché a ou non été trouvé.

def recherche_systematique(terme, liste):

present = False

for element in liste:

if element == terme:

present = True

return present

Nous vérifions que notre algorithme se termine et fonctionne correctement.

print(recherche_systematique('souris', lexique))

# affiche True

print(recherche_systematique('stylet', lexique))

# affiche False

Nous aurions également pu écrire cet algorithme avec la notation indicielle des listes :

def recherche_systematique_indicielle(terme, liste):

present = False

for i in range(len(liste)):

if liste[i] == terme:

present = True

return present

Toutefois n’ayant pas besoin d’accéder à la position de l’élément dans la liste par son indice, on préfèrera la première solution qui permet un code plus lisible.

Notre algorithme actuel s’appuie sur la puissance de calcul de l’ordinateur dans une logique de « force brute » où toutes les possibilités sont systématiquement testées. Chaque élément de la liste est parcouru individuellement, du premier jusqu’au dernier.

Ajoutons un compteur pour mieux visualiser les tours de boucle opérés par notre algorithme, ainsi qu’un message quand une correspondance est trouvée entre l’élément recherché et la valeur courante dans la liste.

def recherche_systematique_avec_comptage(terme, liste):

present = False

tour_de_boucle = 0

for element in liste:

tour_de_boucle += 1

print('Tour de boucle numéro', tour_de_boucle)

if element == terme:

present = True

print('Correspondance trouvée')

return present

Testons à nouveau notre code.

print(recherche_systematique_avec_comptage('souris', lexique))

Produit l’affichage suivant :

Tour de boucle numéro 1
Tour de boucle numéro 2
Tour de boucle numéro 3
Tour de boucle numéro 4
Tour de boucle numéro 5
Tour de boucle numéro 6
Tour de boucle numéro 7
Tour de boucle numéro 8
Tour de boucle numéro 9
Correspondance trouvée
Tour de boucle numéro 10

True

On obtient exactement le même nombre de tours de boucles pour une recherche non fructueuse.

print(recherche_systematique_avec_comptage('stylet', lexique))

Produit l’affichage suivant :

Tour de boucle numéro 1
Tour de boucle numéro 2
Tour de boucle numéro 3
Tour de boucle numéro 4
Tour de boucle numéro 5
Tour de boucle numéro 6
Tour de boucle numéro 7
Tour de boucle numéro 8
Tour de boucle numéro 9
Tour de boucle numéro 10
False

  • Notre algorithme parcourant systématiquement la totalité de la liste, la complexité en temps de cet algorithme est de l’ordre de $n$.

Il est relativement simple de l’améliorer.

Algorithme de recherche par parcours séquentiel

L’objectif de notre algorithme est de déterminer si un élément est présent dans une liste. Notre algorithme évalue si c’est le cas pour chaque élément, mais même après avoir trouvé une correspondance, il continue ses tours de boucle jusqu’à atteindre la fin de la liste.

Nous allons le modifier pour qu’il s’interrompt aussitôt dès qu’il trouve une correspondance. Pour bien visualiser les tours de boucle, nous continuons d’employer un compteur et un afficheur de message.

def recherche_sequentielle(terme, liste):

present = False

tour_de_boucle = 0

for element in liste:

tour_de_boucle += 1

print('Tour de boucle numéro', tour_de_boucle)

if element == terme:

present = True

print('Correspondance trouvée')

break

return present

Nous effectuons la même recherche que précédemment.

print(recherche_sequentielle('souris', lexique))

Produit l’affichage suivant :

Tour de boucle numéro 1
Tour de boucle numéro 2
Tour de boucle numéro 3
Tour de boucle numéro 4
Tour de boucle numéro 5
Tour de boucle numéro 6
Tour de boucle numéro 7
Tour de boucle numéro 8
Tour de boucle numéro 9
Correspondance trouvée
True

On constate que le dernier tour de boucle n’a pas été effectué, l’instruction break ayant interrompu la boucle. Dans le cas présent, on a seulement économisé un tour de boucle, mais si l’élément recherché se situe en début de boucle, l’économie est plus substantielle.

print(recherche_sequentielle('disque dur', lexique))

Produit l’affichage suivant :

Tour de boucle numéro 1
Tour de boucle numéro 2
Tour de boucle numéro 3
Correspondance trouvée
True

  • En revanche s’il n’y a pas de correspondance, notre algorithme devra toujours parcourir l’intégralité de la liste élément par élément.

Cependant si on se place dans le pire cas de figure, notre algorithme ne fait guère mieux que précédemment. Il reste donc de l’ordre de $\text{O}(n)$.

Les deux algorithmes implémentés jusqu’à présent fonctionnent : ils se terminent et fournissent la réponse attendue. Mais ils ne prennent pas en compte une caractéristique importante de notre liste : le fait qu’elle soit triée. Cette particularité peut pourtant nous faire gagner beaucoup de temps.

L’optimisation algorithmique nécessite un examen attentif du problème à résoudre, et parfois un peu de créativité pour simplifier le problème initial en exploitant judicieusement une propriété remarquable de notre problème.

Conclusion :

Les algorithmes, nés dans l’Antiquité avec les mathématiques, ont connu un fort développement spécifique au XXe siècle avec l’apparition des sciences numériques. Les algorithmes informatiques sont évalués sous les angles de leur terminaison, de leur correction et de leur complexité.
Aujourd’hui omniprésents, les algorithmes peuvent avoir un fort impact sociétal. Ceux qui les conçoivent portent donc la responsabilité de produire des algorithmes fiables et dépourvus de défaut.