Complexité algorithmique#

Qu'est-ce que la complexité d'un algorithme ?#

Un même problème peut souvent être résolu par plusieurs algorithmes différents. Ils donnent tous le bon résultat, mais certains sont beaucoup plus rapides que d'autres, surtout lorsque la quantité de données devient grande. Chercher un prénom parmi les 25 élèves d'une classe se fait en un clin d'œil, quelle que soit la méthode. Chercher parmi les 9 millions d'habitants de la Suisse est une tout autre histoire...

Mesurer la durée d'un programme en secondes n'est pas une bonne idée : elle dépend de l'ordinateur, de ce qu'il fait en même temps, du langage utilisé... On compte plutôt le nombre d'opérations élémentaires (une comparaison, une addition, une affectation, etc.) que l'algorithme effectue, en fonction de la taille \(n\) des données, par exemple la longueur d'une liste. C'est ce qu'on appelle la complexité de l'algorithme.

La question n'est donc pas "combien de temps prend mon programme ?", mais "comment ce temps augmente-t-il quand les données grandissent ?".

Compter les opérations#

Pour compter les opérations d'un algorithme, on peut simplement ajouter un compteur dans le programme. La fonction ci-dessous calcule la somme des éléments d'une liste, et compte au passage le nombre d'additions effectuées. On l'appelle sur des listes aléatoires de 10, 100 et 1000 éléments.

 1from random import randint
 2
 3def liste_aleatoire(n):
 4    liste = []
 5    for i in range(n):
 6        liste.append(randint(1, 100))
 7    return liste
 8
 9def somme(liste):
10    total = 0
11    nb_operations = 0
12    for valeur in liste:
13        total = total + valeur
14        nb_operations = nb_operations + 1
15    print("Liste de", len(liste), "éléments :", nb_operations, "additions")
16    return total
17
18somme(liste_aleatoire(10))
19somme(liste_aleatoire(100))
20somme(liste_aleatoire(1000))

Sans surprise, il faut une addition par élément : quand la liste est 10 fois plus longue, il y a 10 fois plus d'opérations.

Prenons maintenant un autre problème : savoir si une liste contient deux fois la même valeur (par exemple deux élèves nés le même jour). Une solution consiste à comparer chaque élément avec tous ceux qui le suivent, grâce à deux boucles imbriquées.

 1from random import randint
 2
 3def liste_aleatoire(n):
 4    liste = []
 5    for i in range(n):
 6        liste.append(randint(1, 100))
 7    return liste
 8
 9def contient_doublon(liste):
10    doublon = False
11    nb_comparaisons = 0
12    for i in range(len(liste)):
13        for j in range(i + 1, len(liste)):
14            nb_comparaisons = nb_comparaisons + 1
15            if liste[i] == liste[j]:
16                doublon = True
17    print("Liste de", len(liste), "éléments :", nb_comparaisons, "comparaisons")
18    return doublon
19
20contient_doublon(liste_aleatoire(10))
21contient_doublon(liste_aleatoire(100))
22contient_doublon(liste_aleatoire(1000))

Cette fois, quand la liste est 10 fois plus longue, il y a environ 100 fois plus d'opérations ! Le premier élément est comparé aux \(n - 1\) suivants, le deuxième aux \(n - 2\) suivants, et ainsi de suite, ce qui donne au total :

\[(n - 1) + (n - 2) + \ldots + 2 + 1 = \frac{n \cdot (n - 1)}{2}\]

La notation grand O#

Ce qui nous intéresse, ce n'est pas le nombre exact d'opérations, mais la manière dont il grandit avec \(n\). On le résume avec la notation grand O, en ne gardant que le terme qui grandit le plus vite et en oubliant les constantes qui le multiplient. Par exemple :

  • \(3n + 5\) opérations s'écrit \(O(n)\) ;

  • \(\frac{n \cdot (n - 1)}{2} = \frac{n^2}{2} - \frac{n}{2}\) opérations s'écrit \(O(n^2)\).

Le tableau ci-dessous présente les trois complexités que nous rencontrerons le plus souvent, avec le nombre d'opérations correspondant pour différentes tailles de données.

Notation

Nom

Exemple typique

\(n = 10\)

\(n = 1000\)

\(n = 1\,000\,000\)

\(O(1)\)

constante

accéder à liste[i]

\(1\)

\(1\)

\(1\)

\(O(n)\)

linéaire

parcourir une liste avec un for

\(10\)

\(1000\)

\(10^6\)

\(O(n^2)\)

quadratique

deux boucles imbriquées sur la liste

\(100\)

\(10^6\)

\(10^{12}\)

Pour se rendre compte de ce que cela représente, imaginons un ordinateur capable d'effectuer un milliard (\(10^9\)) d'opérations par seconde. Pour une liste d'un million d'éléments, un algorithme en \(O(n)\) prend environ une milliseconde. Un algorithme en \(O(n^2)\) prend environ \(10^{12} / 10^9 = 1000\) secondes, soit plus d'un quart d'heure !

Important

Pour estimer la complexité d'un programme qui traite une liste de \(n\) éléments :

  • des instructions sans boucle, ou une boucle qui fait toujours le même nombre de tours (par exemple range(10)), donnent du \(O(1)\) ;

  • une boucle qui parcourt la liste donne du \(O(n)\) ;

  • deux boucles imbriquées qui parcourent la liste donnent du \(O(n^2)\) ;

  • deux boucles l'une après l'autre donnent \(O(n) + O(n)\), c'est-à-dire toujours du \(O(n)\).

Meilleur cas et pire cas#

Le nombre d'opérations ne dépend pas toujours seulement de la taille des données, mais aussi de leur contenu. La fonction ci-dessous cherche une valeur dans une liste et s'arrête dès qu'elle la trouve.

 1def contient(liste, valeur):
 2    nb_comparaisons = 0
 3    for element in liste:
 4        nb_comparaisons = nb_comparaisons + 1
 5        if element == valeur:
 6            print(nb_comparaisons, "comparaison(s)")
 7            return True
 8    print(nb_comparaisons, "comparaison(s)")
 9    return False
10
11nombres = [12, 7, 33, 5, 21, 18, 9, 40]
12contient(nombres, 12)   # la valeur est au début
13contient(nombres, 9)    # la valeur est vers la fin
14contient(nombres, 50)   # la valeur n'est pas dans la liste

Dans le meilleur cas, la valeur cherchée est la première de la liste : une seule comparaison suffit. Dans le pire cas, elle est absente, et il faut comparer les \(n\) éléments. Pour être certain qu'un algorithme reste rapide dans toutes les situations, on s'intéresse en général au pire cas : on dit que cette recherche est en \(O(n)\).

Exercices#

Exercice 1#

Donnez la complexité de chacune des fonctions ci-dessous, en fonction de la longueur \(n\) de la liste reçue en paramètre.

  1. def premier_plus_dernier(liste):
        return liste[0] + liste[len(liste) - 1]
    
  2. def maximum(liste):
        maxi = liste[0]
        for valeur in liste:
            if valeur > maxi:
                maxi = valeur
        return maxi
    
  3. def affiche_somme_et_negatifs(liste):
        total = 0
        for valeur in liste:
            total = total + valeur
        nb_negatifs = 0
        for valeur in liste:
            if valeur < 0:
                nb_negatifs = nb_negatifs + 1
        print(total, nb_negatifs)
    
  4. def nb_paires_dix(liste):
        compteur = 0
        for a in liste:
            for b in liste:
                if a + b == 10:
                    compteur = compteur + 1
        return compteur
    
  5. def affiche_triple(liste):
        for valeur in liste:
            for k in range(3):
                print(valeur)
    
  6. def affiche_cent_fois(liste):
        for k in range(100):
            print(liste[0])
    
  7. def triangle(liste):
        for i in range(len(liste)):
            for j in range(i):
                print(liste[i], liste[j])
    

Exercice 2#

Répondez sans exécuter les programmes : combien de fois l'instruction compteur = compteur + 1 est-elle exécutée ?

  1. avec n = 4

    for i in range(n):
        for j in range(n):
            compteur = compteur + 1
    
  2. avec n = 5

    for i in range(n):
        for j in range(i + 1, n):
            compteur = compteur + 1
    
  3. avec n = 6

    for i in range(n):
        compteur = compteur + 1
    for j in range(n):
        compteur = compteur + 1
    
  4. avec n = 1000

    for i in range(n):
        for j in range(i + 1, n):
            compteur = compteur + 1
    
  5. La fonction contient de la théorie est appelée, dans le pire cas, sur une liste de 50 éléments.

Exercice 3#

Dans la théorie, la fonction contient_doublon cherche un doublon en comparant chaque élément à tous les suivants, en \(O(n^2)\). Mais si la liste est triée, deux valeurs égales sont forcément côte à côte !

Écrivez une fonction contient_doublon_triee(liste) qui suppose que la liste reçue est triée, et qui retourne True si elle contient un doublon, False sinon, en ne comparant que les éléments voisins. Comme dans la théorie, la fonction affichera le nombre de comparaisons effectuées. Quelle est sa complexité ?

1def contient_doublon_triee(liste):
2    #Complétez le corps de la fonction
3
4print(contient_doublon_triee([2, 5, 8, 8, 13, 21]))    # True
5print(contient_doublon_triee([2, 5, 8, 13, 21, 34]))   # False