Le Problème avec QuickSort, partie 3

Où l'on continue d'explorer des problématiques liées aux algorithmes de tris…

TrisIPT Spé

Dans ce dernier article, nous allons nous pencher sur l'algorithme Introsort, et voir comment il est programmé dans la bibliothèque standard du C++. Mais avant cela, nous allons présenter une dernière technique d'optimisation en l'appliquant au tri par insertion.

Règle primordiale de l'optimisation : On ne cherche à optimiser que lorsque l'on a un premier programme qui marche, et jamais avant.

Optimisation du tri par insertion

Rappelons la structure de l'algorithme de tri par insertion. Il est composé de deux boucles, la boucle interne consistant en un parcours du tableau vers la gauche à partir de la position i pour savoir jusqu'où décaler la valeur l[i] en train d'être traitée.

def insertsort(l):
    for i in range(1, len(l)):
        v = l[i]
        j = i - 1
        while j >= 0 and l[j] > v:
            l[j + 1] = l[j]
            j -= 1
        l[j + 1] = v

Le test de la boucle interne est en deux parties :

  • la condition l[j] > v pour déterminer si l'on doit continuer à décaler la valeur en cours de traitement 
  • la condition j >= 0 est une garde pour s'assurer que l'on ne sort pas du tableau et donc que l[j]est bien défini.

Nous allons commencer par modifier le test de la boucle while pour en isoler les deux parties (en utilisant leurs négations, puisque le test pour rester dans la boucle devient deux tests pour sortir de la boucle).

def insertsort1(l):
    for i in range(1, len(l)):
        v = l[i]
        j = i - 1
        while True:
            if j < 0:
                break
            if l[j] <= v:
                break
            l[j + 1] = l[j]
            j -= 1
        l[j + 1] = v

Maintenant, grâce au module line_profiler, nous allons étudier le nombre d'appels et le temps mis par chaque instruction avec par exemple le temps mis pour trier une liste de 10000 éléments.

Total time: 40.2962 s
File: sort.py
Function: insertsort1 at line 10

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    10                                           @profile
    11                                           def insertsort1(l):
    12     10000       5310.0      0.5      0.0      for i in range(1, len(l)):
    13      9999       3531.0      0.4      0.0          v = l[i]
    14      9999       3614.0      0.4      0.0          j = i - 1
    15  24829903    7162741.0      0.3     17.8          while True:
    16  24829903    7501215.0      0.3     18.6              if j < 0:
    17         8          1.0      0.1      0.0                  break
    18  24829895    8922078.0      0.4     22.1              if l[j] <= v:
    19      9991       4361.0      0.4      0.0                  break
    20  24819904    9078290.0      0.4     22.5              l[j + 1] = l[j]
    21  24819904    7611284.0      0.3     18.9              j -= 1
    22      9999       3752.0      0.4      0.0          l[j + 1] = v

Commençons par regarder la colonne Hits qui indique le nombre de fois que chaque ligne est exécutée. Elle est assez simple à interpréter. Concernant la ligne 12, la boucle externe, elle est exécutée 10000 fois, et le corps de la boucle l'est 9999 fois, qui correspond aussi au nombre de fois qu'est exécuté la boucle interne. Au total, sur tout le tri, le corps de la boucle interne sera exécuté 24829903 fois. Il est facile d'interpréter ce nombre. L'espérance de décalage d'un élément dans une liste de taille k&#123;`k`&#125; pour le mettre à la bonne place est k2&#123;`\\frac k 2`&#125;. Le nombre total d'itérations a donc pour espérance

k=1n1k2=n(n1)4&#123;`\\sum_{k = 1&#125;^{n - 1} \\frac k 2 = \\frac {n (n - 1)} 4`}

soit 24997500&#123;`24997500`&#125; si n=10000&#123;`n = 10000`&#125;. Les résultats mesurés sont conformes à cette analyse.

Intéressons-nous maintenant au résultat de la ligne 17, les 8 fois à l'on sort de la boucle à l'issue du test j < 0. Pour que cela arrive, il faut que la valeur en train d'être traitée, v, soit strictement inférieure à toutes les valeurs déjà vues. C'est un exercice classique de probabilités que de déterminer l'espérance du nombre de cas où cela se produit, c'est le nombre de records d'une permutation. Plus précisément, comme le premier record ne compte pas (on ne « trie » pas la première valeur du tableau), l'espérance est donc

k=2n1k.&#123;`\\sum_{k = 2&#125;^{n} \\frac 1 k.`}

Pour n=10000&#123;`n = 10000`&#125;, cela donne 8.78761&#123;`8.78761`&#125;.

Mais ce qu'il faut retenir, c'est que le test de la ligne 16 consomme 18.6% du temps total de tri… alors que l'on sait mathématiquement qu'il va servir une quantité négligeable de fois. Comment améliorer cela ?

On a vu que cela correspond au cas où la valeur en train d'être traitée est strictement inférieure aux valeurs déjà vues. Or, il est très facile de détecter quand cela arrive… puisque les valeurs déjà vues ont été triées par ordre croissante. Il s'agit donc de la première valeur du tableau.

Ainsi,

  • si la valeur en cours est strictement inférieure à la première valeur du tableau, on doit tout décaler vers la droite pour placer la valeur en cours au début du tableau,
  • sinon, on est sûr que l'on va s'arrêter avec de sortir du tableau, et donc la garde est inutile.

On peut donc réécrire le programme ainsi :

def insertsort2(l):
    for i in range(1, len(l)):
        v = l[i]
        if v < l[0]:
            # On décale tout vers la droite…
            j = i
            while j > 0:
                l[j] = l[j - 1]
                j -= 1
            # …avant de ranger v dans la première case.
            l[0] = v
        else:
            # On examine les valeurs une par une en partant du range i - 1…
            j = i - 1
            while l[j] > v:
                # …sans avoir besoin de garde.
                l[j + 1] = l[j]
                j -= 1
            l[j + 1] = v

En réécrivant le programme ainsi, on a maintenant deux boucles intérieurs et, pour chacune d'elles, on teste une unique condition.

Si l'on regarde maintenant le temps passé pour chaque instruction (en triant la même liste que précédemment), on doit que le temps total passe de 40.2962 secondes à 24.1963 secondes. On a bien gagné entre 20% et 25%, comme espéré.

Total time: 24.1963 s
File: sort.py
Function: insertsort2 at line 24

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    24                                           @profile
    25                                           def insertsort2(l):
    26     10000       5033.0      0.5      0.0      for i in range(1, len(l)):
    27      9999       3506.0      0.4      0.0          v = l[i]
    28      9999       4216.0      0.4      0.0          if v < l[0]:
    29         8          1.0      0.1      0.0              j = i
    30      1989        623.0      0.3      0.0              while j > 0:
    31      1981        735.0      0.4      0.0                  l[j] = l[j - 1]
    32      1981        651.0      0.3      0.0                  j -= 1
    33         8          2.0      0.2      0.0              l[0] = v
    34                                                   else:
    35      9991       3742.0      0.4      0.0              j = i - 1
    36  24827914    8127583.0      0.3     33.6              while l[j] > v:
    37  24817923    8601594.0      0.3     35.5                  l[j + 1] = l[j]
    38  24817923    7444660.0      0.3     30.8                  j -= 1
    39      9991       3997.0      0.4      0.0              l[j + 1] = v

Implémentation d'Introsort

La bibliothèque standard de C++, la Standard Template Library propose une implémentation dans les règles de l'art d'Introsort. On peut la trouver à cette page, avec la fonction sort ligne 5461, bien que les fonctions utilisées soient plutôt définies ligne 2327 et avant.

Pour l'étudier, nous allons utiliser une traduction en Python disponible dans le fichier introsort.py. La porte d'entrée est la fonction sort définie ainsi :

def sort(l):
    if len(l) <= 1:
        return
    s = len(l)
    introsort_loop(l, 0, s, 2 * int_log2(s))
    final_insertion_sort(l, 0, s)

Après avoir vérifié que la liste contient au moins 2 éléments, elle appelle la fonction introsort_loop avec comme nombre maximal d'itérations 2 * int_log2(s). Ainsi, pour une liste de 10000 éléments, on peut faire au plus 26 appels récursifs d'introsort_loop.

Cette fonction pourrait être implémentée ainsi :

def introsort_loop(l, first, last, depth):
    if last - first <= threshold:
        # si la liste n'est pas assez longue,
        # on passe au tri par insertion
        # nous y reviendrons
        return
    if depth == 0:
        # si l'on a effectué trop d'appels récursifs,
        # on passe au tri par tas
        heap_sort(l, first, last)
        return
    # sinon, on mets à jour le nombre d'appels récursifs
    depth -= 1
    # on effectue maintenant une étape de quicksort
    cut = unguarded_partition_pivot(l, first, last)
    # et on trie les deux sous-tableaux restant
    introsort_loop(l, cut, last, depth)
    introsort_loop(l, first, cut, depth)

Mais on peut utiliser une astuce qui consiste à remplacer l'un des appels récursifs finaux à introsort_loop par une boucle qui joue le même rôle. On obtient la version suivante, qui est celle retenue dans la STL&nbps;:

def introsort_loop(l, first, last, depth):
    while (last - first > threshold):
        if depth == 0:
            heap_sort(l, first, last)
            return
        depth -= 1
        cut = unguarded_partition_pivot(l, first, last)
        introsort_loop(l, cut, last, depth)
        last = cut

Une étape de quicksort, c'est-à-dire

  1. le choix du pivot
  2. la répartition des éléments de part et d'autre du pivot

s'effectue dans la fonction unguarded_partition_pivot… dont le nom suggère que l'on peut totalement se passer de gardes, de tests pour s'assurer que l'on ne sort pas du tableau. Voici son implémentation :

def unguarded_partition_pivot(l, first, last):
    mid = first + (last - first) // 2
    move_median_first(l, first, mid, last - 1)
    return unguarded_partition(l, first + 1, last, l[first])

Elle utilise une astuce évoquée précédemment, le choix du pivot comme médiane parmi trois valeurs : celle du début, celle du milieu et celle de la fin. Mais une astuce supplémentaire est utilisée ici. La fonction move_median_to_first, dont le code est légèrement fastidieux, effectue des tests pour toutes les permutations possibles des trois valeurs candidates pour le pivot, et utilise celle du milieu.

def move_median_first(l, a, b, c):
    if l[a] < l[b]:
        if l[b] < l[c]:
            # l[a] < l[b] < l[c]
            l[a], l[b] = l[b], l[a]
        elif l[a] < l[c]:
            # l[a] < l[c] <= l[b]
            l[a], l[c] = l[c], l[a]
        else:
            # l[c] <= l[a] < l[b]
            pass
    else:
        if l[a] < l[c]:
            # l[b] <= l[a] < l[c]
            pass
        elif l[b] < l[c]:
            # l[b] < l[c] <= l[a]
            l[a], l[c] = l[c], l[a]
        else:
            # l[c] <= l[b] <= l[a]
            l[a], l[b] = l[b], l[a]

Le code de unguarded_partition est plus intéressant.

def unguarded_partition(l, first, last, pivot):
    while True:
        while l[first] < pivot:
            first += 1
        last -= 1
        while pivot < l[last]:
            last -= 1
        if first >= last:
            return first
        l[first], l[last] = l[last], l[first]
        first += 1

Comme son nom l'indique, elle ne contient pas de garde. Pour expliquer cela, on remarque qu'elle n'utilise que des inégalités strictes vis-à-vis du pivot. Or, comme la valeur du pivot se trouve en début de tableau, on est assuré de ne pas sortir du tableau « à gauche » en décrémentant last. Mais cela est vrai en général, et cette simplification peut s'effectuer pour n'importe quelle implémentation raisonnable de QuickSort.

C'est l'absence de risque de sortie de tableau « à droite » qui est plus intéressante. Cela découle des points suivants :

  1. si l'on a une valeur supérieure (ou égale) au pivot à droite du tableau, on ne peut pas sortir par la droite ;
  2. si l'on fait au moins un inversion, on a une valeur supérieure ou égale au pivot à droite du tableau ;
  3. la seule façon de ne faire aucune inversion et donc de sortir du tableau à droite est d'abord toutes les valeurs (à part le pivot) du tableau strictement inférieures au pivot. Le fait d'avoir choisi le pivot comme médiane interdit cette situation.

Ainsi, il est impossible de sortir à droite, et donc aucune garde n'est nécessaire.

Reste à étudier le tri par insertion final. D'après les fonctions sort et introsort_loop, il y a un unique appel à la fonction final_insertion_sort à la fin du tri, sur l'intégralité du tableau :

def final_insertion_sort(l, first, last):
    if last - first > threshold:
        insertion_sort(l, first, first + threshold)
        unguarded_insertion_sort(l, first + threshold, last)
    else:
        insertion_sort(l, first, last)

La fonction insertion_sort ressemble à la fonction vue au début :

def unguarded_linear_insert(l, last):
    val = l[last]
    prev = last - 1
    while val < l[prev]:
        l[last] = l[prev]
        last = prev
        prev -= 1
    l[last] = val


def insertion_sort(l, first, last):
    if first == last:
        return
    for i in range(first + 1, last):
        if l[i] < l[first]:
            val = l[i]
            for j in range(i, 0, -1):
                l[j] = l[j - 1]
            l[0] = val
        else:
            unguarded_linear_insert(l, i)

Mais on rencontre à nouveau un appel à une fonction unguarded :

def unguarded_insertion_sort(l, first, last):
    for i in range(first, last):
        unguarded_linear_insert(l, i)

Si l'on regarde de plus près, un tri par insertion classique (avec garde) est appliqué au début du tableau jusqu'à la position first + threshold (ou avant si le tableau n'est pas assez grand), et la suite est trié… sans garde. Comme peut-on être sûr de ne pas y trouver de valeurs plus petite que ce qu'il y a avant, et qui doit donc se retrouver un début de tableau ?

Cela vient du fait que le tri par insertion final a lieu, comme son nom l'indique, après la phase d'introsort. Or, dans cette phase, on s'arrête de trier dès que la taille du sous-tableau est inférieure au seuil threshold. Cela signifie que dans les threshold premières valeurs de tableau, à l'issue d'introsort, on est sûr de trouver un ancien pivot, c'est-à-dire une valeur qui est à sa place, et donc inférieure à tout ce qui suite. C'est pourquoi à partir de la position first + threshold, il n'y a pas besoin de test type garde, et on peut se contenter dans le tri par insertion de décaler chaque valeur vers la gauche jusqu'à s'arrêter.

En conclusion, on voit que l'implémentation de la fonction de tri de la STL repose sur l'implémentation rigoureuse d'un certain nombre d'idées importantes :

  • l'utilisation d'introsort et le passage éventuelle en tri par tas pour garantir une complexité temporelle dans le pire des cas en O(nlnn)&#123;`O(n \\ln n)`&#125; ;
  • le choix du pivot comme médiane parmi trois valeurs permet de limiter le choix d'un mauvais pivot et de rendre superflus les tests de dépassement de tableau ;
  • la finition par un tri par insertion, avec une gestion précise des gardes.

Un dernier commentaire pour conclure. Les plus perspicaces parmi ceux qui ont lu jusqu'au bout auront peut-être remarqué la chose suivante. Normalement, dans QuickSort, à l'issue de la partition par rapport au pivot, on insert le pivot à la bonne place en procédant à un échange. Ici, rien de tel, la valeur pivot sera mise en place lors du tri par insertion final.