Implémentation des listes

publicité
Implémentation des listes
Lycée Berthollet, MP/MP* 2016-17
I
Présentation du problème
Ce court texte a pour objet de discuter de l’implémentation des objets Python de type list
et des conséquences qui en résultent du point de vue de la complexité des algorithmes.
Rappelons tout d’abord ce qu’est un objet de type list : un tel objet L est un suite finie
ordonnée d’objets Python de type quelconque (en particulier, ils peuvent eux aussi être de type
list). S’il y a n objets, ils sont indexés par les entiers i ∈ [[0, n − 1]] et on peut accéder directement à l’élément de la liste d’indice i, soit pour le consulter, soit pour le modifier, par la syntaxe
L[i] (notation usuelle en informatique). L’intérêt de ce type list est qu’il permet facilement
de rajouter des éléments à la fin de la liste (la taille de la liste n’est donc pas fixée une fois pour
toute) voire de supprimer ou insérer des éléments ou même des sous-listes. On parle dans ces
cas-là de gestion dynamique de la mémoire.
Il y a classiquement deux manières d’implémenter de telles listes : à l’aide de tableaux dynamiques ou à l’aide de listes chaînées. Il semble que le type d’implémentation ne soit pas
imposé par la spécification du langage Python. Cela a comme conséquence que la complexité
des opérations sur les listes peut dépendre de l’implémentation utilisée. Remarquons que l’implémentation aujourd’hui majoritairement utilisée est CPython (c’est le cas par exemple si vous
travaillez sous pyzo) et que celle-ci utilise les tableaux dynamiques. Il est cependant intéressant
de comprendre aussi l’implémentation par listes chaînées, car selon la manière dont vous voulez utiliser vos “listes”, il pourrait être préférable de créer votre propre type pour des raisons
de complexité. Il est à noter que, mis à part les problèmes de complexité, chacune de ces deux
méthodes permet de comprendre par des représentations à l’aide de pointeurs (représentés par
des flèches) les subtilités des objets Python de type list et en particulier le fait que les listes
sont des objets muables.
Notons enfin qu’on pourrait éventuellement imaginer d’autres implémentations plus sophistiquées de ces objets qui respecteraient la définition du langage Python.
II
Tableaux dynamiques
On appellera ici case un certain nombre (fixé suivant la machine) d’octets consécutifs de la
mémoire permettant de stocker au total une adresse mémoire et elle-même repérée par un entier
qui est son adresse mémoire. Un tableau dynamique de taille n va être représenté en mémoire
par la donnée de l’entier n (nous ne parlerons pas ici de son stockage) et de n cases consécutives,
chacune contenant l’adresse d’un objet Python (i.e. un pointeur vers un objet Python). Pour lire
1
l’élément d’indice i, il suffit donc d’aller chercher son adresse dans la (i + 1)-ème case et pour
cela d’ajouter i à l’adresse de début de tableau. Cela se fait en temps constant Θ(1). Pour
modifier cet élément, il suffit de faire pointer la case correspondante sur un autre objet Python
ce qui se fait aussi en Θ(1).
Pour ajouter un élément à la liste, il faut modifier n (ce dont nous ne parlerons pas) et ajouter
une case au tableau. Si la case qui suit directement les n cases consécutives est “libre”, pas de
problème, on y met le pointeur qui désigne l’élément ajouté, ce qui se fait en Θ(1). Si ce n’est
pas le cas, on doit alors déplacer tout le tableau vers une zone libre plus grande, où on pourra
le prolonger, ce qui nécessite la recopie intégrale des n cases et se fait en Θ(n). Pour que cela
arrive le moins souvent possible, on utilise d’habitude le procédé suivant : au départ, on prévoit
en général plus de place que les n cases initiales, au cas où on voudrait prolonger la liste. Puis
lors de chaque déplacement forcé, on s’arrange pour doubler la place mémoire disponible et la
réserver. Cela réduit le nombre de déplacements et le coût d’ajout de p éléments est alors en
Θ(max(n, p)). Par exemple, si on part d’une liste vide et on ajoute un à un n éléments à la liste,
cela fait un nombre maximal de recopies de cases ' n + 2n + n4 · · · = Θ(n), et la complexité de
l’opération globale a le même ordre de grandeur que s’il n’y avait pas eu de déplacement ! En
pratique, on lisse le coût des déplacements et on fait l’approximation que les ajouts se font en
temps constant, convention à adopter dans vos calculs de complexité un jour d’épreuve.
Cependant, si on connaît au départ la taille de la liste, il est vivement conseillé de réserver
dès le départ la zone mémoire nécessaire au stockage du tableau dynamique, par exemple à
l’aide d’une instruction L = [0]*n. Remarquons au passage que la création d’une liste par
compréhension (du type [0 for i in range(n)]) est implémentée de façon très efficace et
peut remplacer la réservation précédente. Dans le cas d’une liste de liste, la “compréhension”
est même la méthode à utiliser, à cause de l’erreur fréquente obtenue avec le code fautif L =
[[0]*n]*p (la modification d’une ligne impactant alors toutes les lignes !).
Pour enlever un élément à la liste, ou insérer un élément à la liste, il faut décaler d’une case
tous les pointeurs à partir de l’endroit de suppression/insertion, ce qui est d’autant plus coûteux
que l’élément supprimé est proche du début du tableau. Cela se fait au pire en Θ(n).
On peut voir plus de détails sur les complexités des opérations sur les listes en CPython à
l’adresse https://wiki.python.org/moin/TimeComplexity.
III
Listes chaînées
Une liste chaînée est une suite d’entités, stockées éventuellement à des endroits éloignés les
uns des autres en mémoire, dont chacune :
— contient un pointeur vers un objet
— contient un pointeur vers l’entité suivante
La dernière entité admet comme “suivante” un objet spécifique, usuellement noté NIL, signifiant la fin de la liste chaînée. La liste est alors donnée par un pointeur vers la première entité.
On peut au besoin créer facilement une telle classe en Python.
Dans le cas d’une implémentation par liste chaînée, l’accès au i-ème élément se fait en
parcourant la liste, donc au pire en Θ(n), ce qui est nettement moins bon que dans le cas d’un
tableau dynamique. En revanche, l’ajout se fait toujours en temps constant, et l’insertion ou la
suppression sont beaucoup plus rapides quand on est proche du début de la liste.
2
IV
Conclusion
Au niveau des CPGE et des concours qu’elles préparent, il semble raisonnable d’utiliser les
complexités issues de l’implémentation CPython par tableaux dynamiques, en particulier :
— Lecture d’un élément : Θ(1)
— Modification d’un élément : Θ(1)
— Insertion/Suppression d’un élément : Θ(n)
et de faire l’approximation que l’ajout d’un élément en fin de liste se fait en Θ(1).
V
Appendice : remarque sur le type ndarray
Les objets de type numpy.ndarray sont eux des tableaux (éventuellement multidimensionnels) au sens “classique” du terme : il utilisent une gestion statique de la mémoire : lors de la
création d’un tableau de n flottants, par exemple, si on note p le nombre de cases mémoires
nécessitant le stockage d’un flottant (qui peut varier suivant la machine et l’implémentation),
on réserve n × p cases consécutives et on stocke directement chaque flottant dans les p cases
réservées à cet égard. L’accés et la modification se font donc en temps constant Θ(1). L’ajout,
l’insertion et la suppression sur place ne sont pas possibles.
Rappelons par ailleurs une fois encore, même si ce n’est ici pas le sujet, que l’utilisation du
slicing ne crée pas pour ce type de recopie des données, contrairement aux listes. La modification de l’objet résultant du slicing modifie ainsi le tableau initial.
3
Téléchargement