cours1 min de lecture

Bases de la complexité

Combien coûte un algorithme ? Premier contact avec la notation grand-O, sans dramatiser les maths.

Introduction

Le parcours séquentiel du cours précédent est en place. Les algorithmes qui suivent sont plus subtils, et la première question d'un informaticien devant un algorithme n'est pas « est-ce qu'il marche ? » mais « combien coûte-t-il ? ». Combien d'opérations effectue-t-il selon la taille des données ? Si l'entrée double, le temps double-t-il, quadruple-t-il, ou augmente-t-il à peine ? Ce cours pose le socle de cette analyse. Pas de théorème lourd : juste une intuition claire et une notation simple, qui servent dans tout le reste du chapitre.

Pourquoi mesurer ?

Deux programmes font la même chose : chercher un nom dans un annuaire de 10 millions d'entrées. Le premier met 3 secondes, le second met 3 minutes. Le premier est évidemment préférable. La complexité, c'est l'outil qui permet de prédire cette différence avant même d'exécuter le code, en analysant la structure de l'algorithme.

On ne mesure pas en secondes (cela dépend de la machine, du langage, de la charge système). On compte le nombre d'opérations élémentaires effectuées en fonction de la taille de l'entrée, notée nn.

Taille de l'entrée

Avant de compter, il faut décider de ce qu'est la taille nn. C'est en général évident :

  • pour un tableau ou une liste : nn = le nombre d'éléments,
  • pour une chaîne de caractères : nn = sa longueur,
  • pour un entier kk : nn = sa valeur (parfois son nombre de chiffres).

Trois ordres de grandeur typiques

Coût constant : O(1)\mathcal{O}(1)

Le coût ne dépend pas de la taille. Que tableau ait 10 ou 10 millions d'éléments, l'opération prend le même temps.

def premier(tableau):
    return tableau[0]   # un seul accès, peu importe la taille

Coût linéaire : O(n)\mathcal{O}(n)

Le coût est proportionnel à nn. Si on double la taille, le temps double. Tout parcours séquentiel du cours précédent est dans ce cas.

def somme(tableau):
    total = 0
    for v in tableau:   # exactement n tours
        total += v
    return total

Coût quadratique : O(n2)\mathcal{O}(n^2)

Le coût est proportionnel à n2n^2. Si on double la taille, le temps est multiplié par 4. On l'obtient typiquement avec deux boucles imbriquées qui parcourent chacune le tableau.

def doublons(tableau):
    """Renvoie True si tableau contient au moins un doublon."""
    n = len(tableau)
    for i in range(n):
        for j in range(i + 1, n):
            if tableau[i] == tableau[j]:
                return True
    return False

Le nombre de comparaisons est de l'ordre de n×n/2n \times n / 2, ce qui reste proportionnel à n2n^2.

Si un algo en O(n²) met 1 seconde pour n=1000, combien de temps environ pour n=10000 ?

La notation grand-O, en douceur

On a écrit O(n)\mathcal{O}(n), O(n2)\mathcal{O}(n^2). Que signifie ce symbole ?

L'idée : on néglige les constantes et les détails de petite taille pour ne garder que l'ordre de grandeur quand nn devient grand. Par exemple, un algorithme qui fait 3n+73n + 7 opérations et un autre qui en fait n−1n - 1 ont le même comportement asymptotique (c'est-à-dire la même allure quand nn devient grand) : tous deux sont en O(n)\mathcal{O}(n). Quand nn atteint 1 million, le « +7 » et le « ×3 » sont sans importance face au grand nn.

Définition intuitive : un algorithme est en O(f(n))\mathcal{O}(f(n)) si, à partir d'une certaine taille, son nombre d'opérations reste borné par un multiple de f(n)f(n). Autrement dit : « il croît au pire comme f(n)f(n) ».

Le cours 4 (dichotomie) ajoute O(log⁡n)\mathcal{O}(\log n) à cette liste, un coût encore plus petit que O(n)\mathcal{O}(n), qui croît extrêmement lentement.

Comparer les ordres de grandeur

nnlog⁡2n\log_2 nnnn2n^2
10≈ 310100
100≈ 710010 000
1 000≈ 101 0001 000 000
1 000 000≈ 201 000 000101210^{12}

Pour un million d'éléments, un algorithme logarithmique finit en 20 étapes ; un algorithme linéaire en un million ; un algorithme quadratique en mille milliards, ce qui est irréalisable en pratique.

Hiérarchie à retenir :O(1)  <  O(log⁡n)  <  O(n)  <  O(n2)\mathcal{O}(1) \;<\; \mathcal{O}(\log n) \;<\; \mathcal{O}(n) \;<\; \mathcal{O}(n^2)Plus on est à gauche, mieux on passe à l'échelle.
⏵ Ctrl+↵ pour exécuter
Aucune exécution pour l'instant.

Cas pire, cas moyen

Tous les algorithmes n'effectuent pas le même nombre d'opérations sur toutes les entrées de même taille. La recherche linéaire peut :

  • trouver dès la 1ère case (cas favorable, 1 comparaison) ;
  • trouver à la dernière case ou ne rien trouver (cas pire, nn comparaisons).

On parle souvent du cas pire car c'est la garantie maximale offerte par l'algorithme. Quand on dit « la recherche linéaire est en O(n)\mathcal{O}(n) », c'est au pire ; au mieux, elle est en O(1)\mathcal{O}(1).

Au mieux, combien d'opérations effectue une recherche linéaire qui trouve sa cible ?

Pièges courants

  • Compter en secondes au lieu de compter des opérations : trompeur, car dépendant de la machine.
  • Garder les constantes dans la notation O\mathcal{O} : on écrit O(n)\mathcal{O}(n), jamais O(3n+5)\mathcal{O}(3n + 5).
  • Confondre cas pire et cas moyen : à l'oral, préciser quand c'est ambigu.

Pour aller plus loin

Ces ordres de grandeur se vérifient dans les cours suivants : les tris du cours 3 sont en O(n2)\mathcal{O}(n^2), la dichotomie du cours 4 est en O(log⁡n)\mathcal{O}(\log n). La différence de performance entre les deux dernières est colossale : un algorithme dichotomique sur un milliard d'éléments tient en 30 étapes seulement.