CHAPITRE 2 : COMPTER LES PAS, PAS LES SECONDES

Puisque les secondes mentent, comptons les pas. Reste à savoir ce qu'est un pas, et comment les additionner.

Ce qui vaut un pas

On appelle opération élémentaire une action dont le coût ne dépend pas de la taille des données. Peu importe qu'elle prenne 2 ou 7 nanosecondes : ce qui compte, c'est que ce soit toujours la même chose.

En valent une, à peu de chose près :

  • une affectation : x = 5 ;
  • une opération arithmétique : a + b, n % 2 ;
  • une comparaison : i < n ;
  • l'accès à une case de tableau : tab[i] ;
  • un return.

N'en valent pas une, et c'est le piège le plus courant :

  • copier un tableau : le coût suit sa taille ;
  • concaténer deux chaînes : idem ;
  • appeler une fonction : il faut compter ce qu'elle fait à l'intérieur ;
  • un in sur une liste en Python, c'est un parcours déguisé.

Le piège du langage. En Python, if x in liste: tient sur une ligne mais parcourt toute la liste. Une ligne courte n'est pas forcément une opération courte. Comptez ce que la machine fait, pas ce que vous écrivez.

Des instructions qui se suivent

Quand des instructions s'enchaînent, leurs coûts s'additionnent.

def ecart(a, b):
    grand = a          # 1
    petit = b          # 1
    if b > a:          # 1
        grand = b      # 1
        petit = a      # 1
    return grand - petit   # 2 (soustraction + retour)

Au total : sept opérations au plus, quelles que soient les valeurs de a et b. Donnez-lui deux nombres à trois chiffres ou à trente chiffres, c'est pareil.

On écrit T(n) = 7, et comme 7 ne dépend pas de n, on dira que ce code a un coût constant.

Une boucle

Une boucle multiplie le coût de son corps par le nombre de tours.

def somme(tab, n):
    total = 0              # 1 opération
    for i in range(n):     # n tours
        total += tab[i]    # 3 opérations par tour
    return total           # 1 opération

Détaillons. Avant la boucle : 1. Dans la boucle : un accès au tableau, une addition, une affectation, soit 3, et cela n fois. Après : 1.

T(n) = 1 + 3n + 1 = 3n + 2

Le compte dépend maintenant de n. Doublez n, le compte double (à deux unités près). Voilà un algorithme dont le travail suit la taille des données.

Deux boucles imbriquées

def paires_nulles(tab, n):
    compte = 0                       # 1
    for i in range(n):               # n tours
        for j in range(n):           #   n tours chacun
            if tab[i] + tab[j] == 0: #   3 opérations
                compte += 1          #   2, parfois
    return compte                    # 1

La boucle intérieure tourne n fois. La boucle extérieure la relance n fois. Le corps s'exécute donc n × n fois.

T(n) ≈ 1 + n × n × 4 + 1 = 4n² + 2

Ici, doubler n ne double pas le travail : il le quadruple. Pour n = 1 000, on est déjà à quatre millions d'opérations.

Une boucle qui ne va pas jusqu'au bout

Attention aux bornes. Les deux codes suivants n'ont pas le même compte.

# version A : toutes les paires, deux fois chacune
for i in range(n):
    for j in range(n):
        ...                 # n² tours

# version B : chaque paire une seule fois
for i in range(n):
    for j in range(i + 1, n):
        ...                 # n(n-1)/2 tours

La version B fait à peu près deux fois moins de tours. C'est un vrai gain, qu'on sent à l'exécution. Mais dans les deux cas, doubler n quadruple le travail : les deux appartiennent à la même famille. Nous verrons au chapitre 3 pourquoi ce facteur deux finit par ne plus compter.

La méthode, en trois gestes

  1. Repérer ce qui dépend de n et ce qui n'en dépend pas.
  2. Compter les tours de chaque boucle, en partant de la plus intérieure.
  3. Multiplier les boucles imbriquées, additionner les blocs qui se suivent.

À retenir

  • Une opération élémentaire a un coût qui ne dépend pas de n.
  • Instructions qui se suivent : on additionne. Boucles imbriquées : on multiplie.
  • Une ligne courte peut cacher un parcours complet. Méfiez-vous des raccourcis du langage.
  • T(n) est un compte d'opérations, pas une durée.

Exercices d'application

Exercice 1
Donnez T(n) pour ce code.
s = 0
for i in range(n):
    s += i
for j in range(n):
    s += j * 2
Voir le corrigeMasquer le corrige

Les deux boucles se suivent, elles ne sont pas imbriquées : leurs coûts s'additionnent.

avant la 1re boucle : 1
1re boucle : n tours x 2 operations = 2n
2e boucle  : n tours x 3 operations = 3n

T(n) = 1 + 2n + 3n = 5n + 1

Le travail suit la taille des données. L'erreur à ne pas faire serait d'écrire n² : deux boucles l'une après l'autre ne se multiplient pas.

Exercice 2
Combien de fois la ligne marquée s'exécute-t-elle ?
for i in range(n):
    for j in range(i):
        print(i, j) # ici
Voir le corrigeMasquer le corrige

La boucle intérieure ne tourne pas n fois, mais i fois, et i change à chaque tour de la boucle extérieure.

Pour i = 0 : 0 tour. Pour i = 1 : 1 tour. Pour i = 2 : 2 tours. Et ainsi de suite jusqu'à i = n−1.

0 + 1 + 2 + … + (n−1) = n(n−1) / 2

Soit environ n²/2. C'est la somme des premiers entiers, qu'on retrouve sans arrêt en complexité : apprenez-la une bonne fois.

Exercice 3
Pourquoi ce code n'est-il pas en O(1), malgré ses deux lignes ?
def doublons(liste):
    return len(liste) != len(set(liste))
Voir le corrigeMasquer le corrige

Parce que les deux appels travaillent sur toute la liste.

set(liste) construit un ensemble en parcourant les n éléments : coût proportionnel à n. len(), lui, est bien constant : Python garde la taille en mémoire.

T(n) ≈ n + 2 constantes

Le code est donc linéaire. La leçon vaut d'être retenue : la longueur du code source n'a aucun rapport avec sa complexité.

Cherchez avant d'ouvrir le corrige : c'est en butant sur les difficultes que l'on progresse.

Questions / Réponses

Aucune question. Soyez le premier à poser une question.
★ ★ ★ ★ ★
2 votes. Moyenne 5 sur 5.

Ajouter un commentaire

Anti-spam