6. Les séquences

Python propose une grande variété de structures de données. Parmi celles ci, on trouve des séquences, c’est à dire des structures de données ordonnées. Elles peuvent se décomposer par type.

6.1. Container sequences

Ce type de container permet de stocker de façon ordonnée les références des objets (de n’importe quel type) qu’elles contiennent. list, tuple et collections.deque sont des containers de type sequence.

6.1.1. Flat sequences

Une flat séquence stocke directement en mémoire la valeur de chaque item qui la compose. Les flat sequences sont plus compactes mais ne peuvent stocker que des types « primitifs » : caractères alphanumériques, octets, nombres.

str, bytes, bytearray, array.array et memoryview sont des flat sequences.

Les séquences peuvent également s’organiser en deux autres familles, selon qu’elles sont mutables ou pas.

6.1.2. Immutable sequences

Une séquence immutable est une séquence qui ne peut pas être modifiée une fois créée. On connait déjà les classes str et tuple. La classe bytes est également une séquence immutable.

Un exemple de manipulation d’une sequence immutable à partir des méthodes de base de la classe str

>>> s = "abc"
>>> s.__len__()
3
>>> s.__contains__('a')
True

La plupart de ces méthodes sont appelées par les fonctions builtin correspondantes. C’est cette dernière façon de faire que l’on va privilégier:

>>> len(s)
3
>>> 'a' in s
True

On peut construire un itérateur sur une séquence:

>>> i = s.__iter__()
>>> i.__next__()
'a'
>>> i.__next__()
'b'
>>> i.__next__()
'c'
>>> i.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

Généralement, itérer sur une séquence complète se fait à partir de la construction for in:

>>> a = "abc"
>>> for c in a:
...     print(c)
...
a
b
c

Les séquences disposent d’accesseurs bas niveau:

>>> s.__getitem__(0)
'a'

Ceux ci sont mis en oeuvre avec la syntaxe [ ]:

>>> s[0]
'a'

Comme on vient de le voir, les méthodes bas niveau n’ont pas vocation à être utilisées directement lorsque l’on travaille avec les séquences incluses dans la bibliothèque Python. Par contre, ces méthodes doivent être implémentées lorsque l’on développe ses propres classes séquences.

On dispose également d’autres méthodes de séquences permettant d’obtenir l’index ou le nombre d’occurrence d’un élément de la séquence:

>>> s.index('b')
1
>>> s.count('b')
1

6.1.3. Mutable sequences

Dans le cas de séquence mutables, on peut modifier la séquence une fois créee. La séquence mutable la plus courante est la classe list.

bytearray, array.array, collections.deque, memoryview sont également des séquences mutables.

Le diagramme de classes ci dessous permet de structurer les choses. Les noms en italique sont des classes et des méthodes abstraites.

../_images/sequences.png

Comme toutes les séquences mutables, une liste dispose de toutes les méthodes des sequences immutables, mais du fait de sa mutabilité, d’autres sont disponibles. Construisons une simple liste:

>>> l = list(range(5))
>>> l
[0, 1, 2, 3, 4]

Elle dispose bien des méthodes de séquence immutable vues au paragraphe précédent:

>>> l.__contains__(1)
True
>>> l.__contains__(10)
False

Mais elle dispose en plus de méthodes permettant de modifier la structure de la liste.

En ajoutant des éléments:

>>> l.append(5)
>>> l.reverse()
>>> l
[5, 4, 3, 2, 1, 0]
>>> l.extend(list(range(10,13)))
>>> l
[5, 4, 3, 2, 1, 0, 10, 11, 12]

Ou en en retirant:

>>> l.pop()
12
>>> l
[5, 4, 3, 2, 1, 0, 10, 11]
>>> l.remove(3)
>>> l
[5, 4, 2, 1, 0, 10, 11]

6.2. Les list comprehension

La list comprehension est une façon élégante et concise de construire une liste:

>>> l = [ ord(c) for c in 'abcde' ]
>>> l
[97, 98, 99, 100, 101]

On peut inclure un prédicat simple:

>>> l = [ chr(i) for i in range(73,83) if i%2==0 ]
>>> l
['J', 'L', 'N', 'P', 'R']

ou plus complexe avec une clause else:

>>> l = [ chr(i) if chr(i).isalpha() else 'NA' for i in range(89,99) ]
>>> l
['Y', 'Z', 'NA', 'NA', 'NA', 'NA', 'NA', 'NA', 'a', 'b']

On notera que le prédicat se trouve ici avant la boucle for, ce qui n’est pas autorisé lorsque la clause else est absente:

>>> l = [ chr(i) if chr(i).isalpha() for i in range(73,83) ]
  File "<stdin>", line 1
    l = [ chr(i) if chr(i).isalpha() for i in range(73,83) ]

On peut également imbriquer plusieurs boucles:

>>> l = [ "".join((chr(i),str(j))) for i in range(65,73) for j in range(1,9) ]
>>> l
['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G8', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'H7', 'H8']

On peut construire des listes de listes:

>>> l = [ [ "".join((chr(i),str(j))) for i in range(65,73) ] for j in range(1,9) ]
>>> l
[['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1'], ['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2'], ['A3', 'B3', 'C3', 'D3', 'E3', 'F3', 'G3', 'H3'], ['A4', 'B4', 'C4', 'D4', 'E4', 'F4', 'G4', 'H4'], ['A5', 'B5', 'C5', 'D5', 'E5', 'F5', 'G5', 'H5'], ['A6', 'B6', 'C6', 'D6', 'E6', 'F6', 'G6', 'H6'], ['A7', 'B7', 'C7', 'D7', 'E7', 'F7', 'G7', 'H7'], ['A8', 'B8', 'C8', 'D8', 'E8', 'F8', 'G8', 'H8']]

Exercice

Essayer de prédire le résultats des listcomps suivantes:

>>> l = [i*2 for i in [j+1 for j in range(20) if (j%3)==0] if i*i>19]

>>> strings = [ ['par', 'pare', 'pares'], ['se', 'sel', 'self'], ['a', 'an', 'arc'] ]
>>> l = [ (lettre, i) for i, l in enumerate(strings) for mot in l if len(mot)>3 for lettre in mot]

6.2.1. Performances

Une listcomp est une façon très élégante de construire une liste mais c’est également plus rapide qu’une boucle for classique. Une façon simple de mesurer les performances d’un code Python consiste à utiliser la classe timeit.timeit:

>>> import timeit
>>> timeit.timeit('[i for i in range(int(1e6))]', number=10)
1.195560211038753

On observe ici que remplir une liste avec un million d’entiers nécessite environ 1.2 s avec une listcomp (les performances peuvent varier avec la machine).

Avec une boucle for classique:

>>> LOOP = '''
... l = []
... for i in range(int(1e6)):
...     l.append(i)
... '''

>>> timeit.timeit(LOOP, number=10)
2.153525936214919

On constate ici que le temps d’exécution d’une boucle for classique est doublé !

Ce genre d’optimisation est fondamentale lorsque l’on manipule un grand nombre de données.

6.3. Les générateurs

Les générateurs sont une alternative intéressante aux listes, en ce sens qu’ils ne construisent pas la structure de données a priori mais fournissent les items à la demande. Pour effectuer la même tâche que précédemment, on définit une fonction générateur, où le mot clé return est remplacé par yield:

>>> def gen(n):
...     for i in range(n):
...             yield i
...

On fabrique un générateur avec gen() en précisant sa taille en argument:

>>> g = gen(int(1e6))
>>> g
<generator object gen at 0x0000020BAE4EECA8>

On accède à chacun des éléments en itérant sur le générateur

>>> for i in g: print(i)
...
0
1
2
3
...
999999

Tout se passe comme pour les listes à la différence majeure de la place occupée en mémoire. La liste est entièrement construite a priori:

>>> l = [i for i in range(int(1e6)) ]
>>> import sys
>>> sys.getsizeof(l)
8697464

Une liste d’un million d’entiers occupe 8.7 Mb en mémoire.

A contrario, un générateur délivre les éléments un par un et occupe très peu de place en mémoire:

>>> sys.getsizeof(g)
88

Avec le temps d’exécution, la réduction de l’occupation mémoire est un enjeu majeur lorsqu’on manipule un grand nombre de données.

6.3.1. Les gencomps

Comme pour les listes avec les listcomp, Python propose une syntaxe compacte pour fabriquer les générateurs, les gencomp. L’opérateur ( ) remplace l’opérateur [ ]:

>>> g = ( i for i in range(int(1e6)) )
>>> g
<generator object <genexpr> at 0x0000020BAE37D728>
>>> sys.getsizeof(g)
88

Le fonctionnement est transparent:

>>> for i in g: print(i)
...
0
1
2
3
...
999999

6.4. Les arrays

L’objet array.array est une séquence permettant de stocker de façon ordonnée et compacte les valeurs « primitives » : entiers, nombre flottants et caractères. Il diffère de la liste par le fait qu’il ne peut stocker que des valeurs primitives et aucun autre objet. Il en résulte un stockage en mémoire plus efficace que pour la liste. En effet, par construction cette dernière peut stocker des éléments inhomogènes (des objets de différente nature), ce qui a un impact sur l’utilisation de la mémoire. On réservera donc la liste pour le stockage d’une quantité réduite d’objets.

Le constructeur nécessite le type des éléments stockés et la séquence. Ici, les éléments sont stockés sous forme d’entiers. C’est le sens du premier argument I du constructeur. Le second est une séquence contenant les valeurs à stocker dans l”array:

>>> import array
>>> a = array.array('I', [ i for i in range(int(1e6)) ])
>>> type(a)
<class 'array.array'>

Après création, les attributs et les méthodes de la classe array.array permettent d’obtenir des informations élémentaires sur la structure de données:

>>> a.itemsize
4
>>> a.buffer_info()
(2249228529728, 1000000)

L’occupation mémoire est obtenue avec:

>>> a.buffer_info()[1] * a.itemsize
4000000

sys.getsizeof() donne une information légèrement différente.

Avertissement

getsizeof() calls the object’s __sizeof__() method and adds an additional garbage collector overhead if the object is managed by the garbage collector

>>> sys.getsizeof(a)
4000064

6.5. Les slices

Une caractéristique des séquences de Python est qu’elles peuvent être slicées. La syntaxe retenue est seq[i:j:k] où i est l’index de début (compris), j l’index de fin (non compris) et k le step. Si la valeur est manquante, une valeur par défaut est utilisée.

Cette convention présente plusieurs avantages (voir l’article de Dijkstra). Elle permet en particulier:

  • d’écrire seq = seq[:n] + seq[n:]

  • et d’obtenir explicitement le nombre j-i d’éléments de la séquence seq[i:j].

En Python, tout est objet, et les slices n’échappent pas à la règle. On considère les données INSEE du chomage en Ile de france pour l’année 2016:

>>> data ='''
... Période     75         77        78        91        92         93         94         95
... 2016M01     201 160    97 090    91 990    86 190    114 340    165 810    106 260    101 810
... 2016M02     200 660    97 110    92 790    86 040    114 360    165 580    106 120    101 810
... 2016M03     200 520    97 210    93 050    85 960    114 110    165 400    105 820    102 160
... 2016M04     198 750    96 290    92 280    85 240    112 780    163 700    104 890    100 980
... 2016M05     199 590    96 970    93 060    86 030    113 270    164 860    105 440    101 530
... 2016M06     199 500    97 190    93 440    85 870    112 980    164 990    105 580    101 740
... 2016M07     200 160    97 160    93 590    86 340    113 070    165 180    105 790    101 630
... 2016M08     201 740    98 420    94 980    87 660    113 760    166 200    106 910    102 610
... 2016M09     200 730    97 640    93 620    86 440    113 140    165 330    106 190    101 840
... 2016M10     200 130    97 460    93 030    86 600    112 580    164 830    105 930    101 120
... 2016M11     200 270    97 900    93 390    86 870    112 620    165 000    106 070    100 810
... 2016M12     200 150    97 960    93 730    87 140    112 620    164 520    106 020    101 440
... '''

>>> lines = data.split('\n')[2:-1]

>>> lines[0]
'2016M01     201 160    97 090    91 990    86 190    114 340    165 810    106 260    101 810'

On peut accéder aux statistiques par département en slicant chaque ligne. Pour Paris

>>> for l in lines:
...     print(l[12:19])
...
201 160
200 660
200 520
198 750
199 590
199 500
200 160
201 740
200 730
200 130
200 270
200 150

Cette syntaxe est peu lisible. Utiliser les objets slice donne un code plus lisible:

>>> ESSONNE = slice(43,49)
>>> ESSONNE.start
43
>>> ESSONNE.stop
49

>>> for l in lines:
...     print(l[ESSONNE])
...
86 190
86 040
85 960
85 240
86 030
85 870
86 340
87 660
86 440
86 600
86 870
87 140

6.6. Les tris

Python propose deux façons de trier les séquences, en utilisant l’algorithme TimSort, un mix des algorithmes Merge Sort et Insertion Sort. Si vous souhaitez vous rafraîchir la mémoire sur les différents algorithmes de tri, vous pouvez consulter les ressources suivantes:

Pour ce qui concerne la complexité algorithmique, le TimSort a l’avantage d’être très rapide sur les séquences déjà un peu ordonnées. Le pire cas est en \(\Theta(n \log n )\) alors qu’il est en \(\Theta(n^2)\) pour le QuickSort.

6.6.1. list.sort()

La première façon de trier une séquence est la méthode list.sort() qui trie « sur place » une liste. C’est à dire qu’elle modifie l’objet list et ne retourne rien (ou plus exactement elle retourne l’objet None):

>>> l = [5, 2, 3, 1, 4]
>>> x = l.sort()
>>> x is None
True
>>> l
[1, 2, 3, 4, 5]

Important

Le fait que list.sort() retourne None est une convention en Python, utilisée chaque fois qu’une méthode ou une fonction modifie un objet « sur place ».

6.6.2. sorted()

La seconde façon de faire est d’utiliser la fonction sorted() qui accepte en paramètre un iterable au sens large (pas uniquement une liste). Cette fonction retourne une liste (quel que soit le type d’iterable passé en paramètre):

>>> l = [5, 2, 3, 1, 4]
>>> sl = sorted(a)
>>> l
[5, 2, 3, 1, 4]
>>> sl
[1, 2, 3, 4, 5]

>>> t = (5, 2, 3, 1, 4)
>>> type(t)
<class 'tuple'>
>>> st = sorted(b)
>>> type(st)
<class 'list'>
>>> st
[1, 2, 3, 4, 5]

La fonction sorted() accepte tout type d’iterable. Pour un dictionnaire, les éléments triés sont les clés:

>>> d = {1: 'D', 2: 'B', 3: 'B', 4: 'E', 5: 'A'}
>>> type(d)
<class 'dict'>
>>> sorted(d)
[1, 2, 3, 4, 5]

6.6.3. La clé de tri

La méthode list.sort() et la fonction sorted() ont toutes les deux un paramètre key optionnel permettant d’appliquer une fonction à l’item à trier avant d’effectuer le tri proprement dit. Si le paramètre key n’est pas fourni, la fonction identité est utilisée:

>>> pc = "paresse orgueil gourmandise luxure avarice colère envie".split()
>>> sorted(pc)
['avarice', 'colère', 'envie', 'gourmandise', 'luxure', 'orgueil', 'paresse']
>>> sorted(pc, key=None)
['avarice', 'colère', 'envie', 'gourmandise', 'luxure', 'orgueil', 'paresse']

Le tri utilisé dépend de la nature des items dans l’itérable. Lorsque ce sont des str, l’ordre lexicographique est utilisé:

>>> sorted(['Port', 'portée', 'Porté', 'porte'])
['Port', 'Porté', 'porte', 'portée']

Utiliser une clé de tri, permet de passer l’item à trier en paramètre d’une fonction et d’utiliser sa valeur de retour plutôt que l’item lui même:

>>> sorted(pc, key=len)
['envie', 'luxure', 'colère', 'paresse', 'orgueil', 'avarice', 'gourmandise']

On peut également écrire sa propre fonction et l’utiliser comme clé de tri:

>>> def value(name):
...     return sum([ord(letter) - 96 for letter in name])
...

>>> sorted(pc, key=value)
['envie', 'avarice', 'paresse', 'orgueil', 'luxure', 'gourmandise', 'colère']

>>> value('colère')
189
>>> value('envie')
55

6.6.4. Tris de structures complexes

Le tri peut s’appliquer à des séquences non flat, c’est à dire des séquences contenant des objets non primitifs:

>>> philosophers = []
>>> philosophers.append( ('Baruch', 'Spinoza', 1632, 10) )
>>> philosophers.append( ('Blaise', 'Pascal', 1623, 11) )
>>> philosophers.append( ('Emmanuel', 'Kant', 1724, 13) )
>>> philosophers.append( ('Friederich', 'Nietzsche', 1844, 14) )

Si aucune clé de tri n’est fournie, le premier élément de la structure à trier (ici le prénom) est utilisé par défaut:

>>> sorted(philosophers)
[('Baruch', 'Spinoza', 1632, 10), ('Blaise', 'Pascal', 1623, 11), ('Emmanuel', 'Kant', 1724, 13), ('Friederich', 'Nietzsche', 1844, 14)]

Plutôt que de définir explicitement une fonction à usage unique, on peut utiliser les lambda functions comme clé de tri. Cet équivalent des fonctions anonymes dans d’autres langages permet d’écrire un code compact. On y reviendra plus loin dans le paragraphe Les lambda fonctions.

On peut trier sur l’année de naissance:

>>> sorted(philosophers, key= lambda x:x[2])
[('Blaise', 'Pascal', 1623, 11), ('Baruch', 'Spinoza', 1632, 10), ('Emmanuel', 'Kant', 1724, 13), ('Friederich', 'Nietzsche', 1844, 14)]

ou sur le nom de famille:

>>> sorted(philosophers, key= lambda x:x[1])
[('Emmanuel', 'Kant', 1724, 13), ('Friederich', 'Nietzsche', 1844, 14), ('Blaise', 'Pascal', 1623, 11), ('Baruch', 'Spinoza', 1632, 10)]

ou bien encore sur le nombre d’oeuvres principales (bien qu’en philosophie, ce soit une métrique tout à fait discutable):

>>> sorted(philosophers, key= lambda x:x[3])
[('Baruch', 'Spinoza', 1632, 10), ('Blaise', 'Pascal', 1623, 11), ('Emmanuel', 'Kant', 1724, 13), ('Friederich', 'Nietzsche', 1844, 14)]

Il peut arriver de trouver des valeurs égales, et l’ordre des items de même rang est alors déterminé par une clé de tri secondaire. Lorsqu’elle n’est pas explicitement précisée, le premier élément de l’item est utilisé

>>> philosophers.append( ('Jean-Jacques', 'Rousseau', 1712, 13) )
>>> sorted(philosophers, key= lambda x:x[3])
[... , ('Emmanuel', 'Kant', 1724, 13), ('Jean-Jacques', 'Rousseau', 1712, 13), ... ]

On peut également spécifier explicitement cette clé secondaire, en demandant à la lambda function de retourner un tuple dont le deuxième élément est alors la clé secondaire:

>>> sorted(philosophers, key= lambda x:(x[3], x[2]) )
[... , ('Jean-Jacques', 'Rousseau', 1712, 13), ('Emmanuel', 'Kant', 1724, 13), ...]

Astuce

Le recours au collections.namedtuple() permet d’accéder aux champs du tuple de façon plus lisible et plus explicite. Le tri par année de naissance pourrait ainsi s’écrire:

>>> from operator import attrgetter
>>> sorted(philosophers, key= attrgetter('birthyear') )

6.6.5. L’ordre de tri

Par défaut, l’ordre de tri est ascendant. Le paramètre reverse permet d’inverser cet ordre:

>>> sorted(philosophers, key= lambda x:x[2], reverse=True )
[('Friederich', 'Nietzsche', 1844, 14), ('Emmanuel', 'Kant', 1724, 13), ('Jean-Jacques', 'Rousseau', 1712, 13), ('Baruch', 'Spinoza', 1632, 10), ('Blaise', 'Pascal', 1623, 11)]

Par contre, en cas de tri avec des clés multiples, l’ordre de tri est appliqué de façon globale au tuple retourné par la lambda fonction. On trie ici par nombre d’oeuvres décroissant puis par année décroissante:

>>> sorted(philosophers, key= lambda x:(x[3], x[2]), reverse = True )
[('Friederich', 'Nietzsche', 1844, 14), ('Emmanuel', 'Kant', 1724, 13), ('Jean-Jacques', 'Rousseau', 1712, 13), ('Blaise', 'Pascal', 1623, 11), ('Baruch', 'Spinoza', 1632, 10)]

Pour trier par nombre d’oeuvres décroissant puis par année croissante, il faut modifier le tuple retourné par la lambda function:

>>> sorted(philosophers, key= lambda x:(x[3], -x[2]), reverse = True )
[('Friederich', 'Nietzsche', 1844, 14), ('Jean-Jacques', 'Rousseau', 1712, 13), ('Emmanuel', 'Kant', 1724, 13), ('Blaise', 'Pascal', 1623, 11), ('Baruch', 'Spinoza', 1632, 10)]

Exercice

Comme on vient de le voir, les tris s’effectuent avec l’ordre naturel lorsqu’il s’agit de nombres, et avec l’ordre lexicographique lorsqu’il s’agit de lettres. Il est cependant un cas où l’ordre lexicographique ne conduise pas à une solution pertinente. Considérons un répertoire contenant une liste de fichiers:

>>> files = [ 'f1.txt', 'f2.txt', 'f10.txt']

Dans ce cas, le tri lexicographique n’apporte pas une réponse adaptée:

>>> sorted(files)
['f1.txt', 'f10.txt', 'f2.txt']

La raison en est que les nombres utilisés dans les noms de fichier sont triés avec l’ordre lexicographique, pour lequel:

>>> '2' < '10'
False

Imaginer une fonction f() à passer au paramètre key permettant de résoudre ce problème, tel que:

>>> sorted(files, key=f)
['f1.txt', 'f2.txt', 'f10.txt']

6.7. Recherche dans une séquence

La recherche dans les séquences est un sujet majeur, avec un fort impact sur les temps de calcul. Les performances dépendent de la structure de donnée, du fait qu’elle soit triée ou pas.

On considère une liste de taille variable, constituée d’élément tirés au sort aléatoirement avec la fonction random.randrange(). Dans la liste, on effectue les opérations de recherche suivantes:

  • le premier élément

  • l’élément au milieu

  • le dernier élément

  • un élément qui n’est pas dans la liste

Le code ci dessous réalise cette opération et formate les résultats avec la méthode de chaine str.format().

 1import random, timeit
 2
 3SETUP = '''
 4import random
 5from __main__ import l, x
 6'''
 7
 8result = []
 9
10for SIZE in [10000, 100000, 1000000, 10000000]:
11    res = [SIZE]
12    l = [ random.randrange(SIZE*2) for i in range(SIZE) ]
13    for x in [ l[0], l[SIZE//2], l[-1], -1 ]:
14        res.append(timeit.timeit('x in l', number=10, setup=SETUP))
15    result.append(res)
16
17print("{:>10} - {:^12} - {:^12} - {:^12} - {:^12}".format("SIZE", "1st elt", "middle elt", "last elt", "not in list") )
18
19for r in result:
20    print("{:>10} - {:10.8f} s - {:10.8f} s - {:10.8f} s - {:10.8f} s".format(*r) )

L’utilisation du module random fait que les résultats ci dessous ne sont pas reproductible, mais la tendance observée l’est:

    SIZE -   1st elt    -  middle elt  -   last elt   - not in list
   10000 - 0.00000064 s - 0.00084703 s - 0.00141085 s - 0.00119405 s
  100000 - 0.00000096 s - 0.00779258 s - 0.01686130 s - 0.01206940 s
 1000000 - 0.00000096 s - 0.08067754 s - 0.15480338 s - 0.12854622 s
10000000 - 0.00000160 s - 0.81813380 s - 1.44595326 s - 1.29124449 s

On constate que le temps de recherche du premier élément est indépendant de la taille de la liste. Ce qui est logique puisqu’il s’agit du premier élément rencontré lors du parcours.

Le temps de recherche du dernier élément augmente de façon linéaire avec la taille de la liste. Logique également puisque la liste est parcourue en entier.

Le temps de recherche d’un élément n’appartenant pas à la liste est sensiblement le même que celui du dernier élément car il faut avoir parcouru la totalité de la liste pour statuer sur le fait que l’élément est présent ou non.

6.7.1. Recherche dans une séquence triée

Rechercher un élément dans une liste, triée ou non, peut se faire avec la méthode list.index() dont la complexité algorithmique est en \(\Theta(n)\):

>>> SETUP = '''
... from __main__ import l, SIZE
... '''

>>> SIZE = 1000000
>>> l = [ random.randrange(SIZE*2) for i in range(SIZE) ]
>>> l = sorted(l)

>>> timeit.timeit('l.index(l[SIZE//2])', number=10, setup=SETUP)
0.5671283348642646

Cependant lorsque la séquence est ordonnée, ce qui coûte \(\Theta(n \log n\)), la recherche avec la méthode list.index() n’est pas du tout efficace. On lui préférera l’algorithme binary search dont la complexité algorithmique est en \(\Theta(\log n)\):

>>> SETUP = '''
... import bisect
... from __main__ import l, SIZE
... '''

>>> timeit.timeit('bisect.bisect_left(l, l[SIZE//2])', number=10, setup=SETUP)
4.746681167944189e-05

Le module bisect propose également la fonction bisect.insort() qui permet d’insérer un élément dans la liste, tout en conservant son ordonnancement.

6.8. Piles et queues

La liste pourrait être un bon candidat pour implémenter une stack ou une queue en utilisant les méthodes list.append(), list.pop() et list.insert().

En utilisant list.append(), les performances algorithmiques sont correctes pour l’insertion « à droite »:

>>> import random, timeit
>>> SIZE = 1000000
>>> l = [ random.randrange(SIZE*2) for i in range(SIZE) ]

>>> SETUP = 'from __main__ import l'

>>> timeit.timeit('l.append(-1)', number=10, setup=SETUP) # insertion à droite
8.659485921214127e-06

mais ne sont pas du tout optimales pour ce qui concerne l’insertion « à gauche » qui nécessite list.insert():

>>> timeit.timeit('l.insert(0, -1)', number=10, setup=SETUP)
0.015554361045398798

Il existe une structure de données bien plus adaptée (car optimisée pour les opérations précédentes), la deque (double ended queue):

>>> from collections import deque
>>> SIZE = 1000000
>>> l = [ random.randrange(SIZE*2) for i in range(SIZE) ]
>>> d = deque(l)
>>> len(d)
1000000

L’insertion à droite est toujours aussi efficace:

>>> SETUP = 'from __main__ import d'

>>> timeit.timeit('d.append(-1)', number=10, setup=SETUP) # insertion à droite
1.3149589676686446e-05

mais maintenant l’insertion à gauche l’est tout autant:

>>> timeit.timeit('d.appendleft(-1)', number=10, setup=SETUP) # insertion à gauche
8.659485956741264e-06

Le choix de la structure de données est fondamental pour l’optimisation des performances.