Chapitre 1 informatique commune
Structures de données
linéaires
1. Complexité d’un algorithme
Analyser un algorithme revient le plus souvent à évaluer les ressources nécessaires à son exécution (la quantité
de mémoire requise) et le temps de calcul à prévoir. Bien évidemment, ces deux notions dépendent de nombreux
paramètres matériels qui sortent du domaine de l’algorithmique : nous ne pouvons attribuer une valeur absolue
ni à la quantité de mémoire requise ni au temps d’exécution d’un algorithme donné. En revanche, il est souvent
possible d’évaluer l’ordre de grandeur de ces deux quantités de manière à identifier l’algorithme le plus ecace
au sein d’un ensemble d’algorithmes résolvant le même problème.
Pour réaliser cette évaluation, il est nécessaire de préciser un modèle de la technologie employée ; en ce qui nous
concerne, il s’agira d’une machine à processeur unique pour laquelle les instructions seront exécutées l’une
après l’autre, sans opération simultanées. Il faudra aussi préciser les instructions élémentaires disponibles ainsi
que leurs coûts. Ceci est particulièrement important lorsqu’on utilise un langage de programmation tel que
python pour illustrer ce cours car ce langage possède de nombreuses instructions de haut niveau qu’il serait
irréaliste de considérer comme ayant un coût constant : par exemple, la fonction
sort
permet eectivement de
trier un tableau en une instruction, mais il serait illusoire de croire que son temps d’exécution est indépendant
de la taille du tableau. En outre, pour évaluer cette dépendance il n’y a guère d’autre solution que de se plonger
dans le code source de python ou sa documentation ; lorsque nous étudierons les algorithmes de tri il sera plus
sage de ne pas tenir compte de l’existence de cette instruction.
1.1 Instructions élémentaires
Les instructions élémentaires (et qui seront considérées comme ayant un coût constant) sont présentes dans la
plupart des langages de programmation :
opérations arithmétiques (addition, soustraction, multiplication, division, modulo, partie entière, .. .)
comparaisons de données (relation d’égalité, d’infériorité, . . .)
transferts de données (lecture et écriture dans un emplacement mémoire)
instructions de contrôle (branchement conditionnel et inconditionnel, appel à une fonction auxiliaire, . . .)
mais là encore il est parfois nécessaire de préciser la portée de certaines de ces instructions. En arithmétique
par exemple, il est impératif que les données représentant les nombres soient codées sur un nombre fixe de
bits. C’est le cas en général des nombres flottants (la classe
float
) et des entiers relatifs (la classe
int
) représentés
usuellement sur 64 bits
1
, mais dans certains langages existe aussi un type entier long dans lequel les entiers ne
sont pas limités en taille. C’est le cas en python, où coexistaient jusqu’à la version 3.0 du langage une classe
int
et une classe
long
. Ces deux classes ont depuis fusionné, le passage du type
int
au type
long
étant désormais
transparent pour l’utilisateur.
Dans le cas des nombres entiers, l’exponentiation peut aussi être source de discussion : s’agit-t’il d’une opé-
ration de coût constant ? En général on répond à cette question par la négative : le calcul de
nk
nécessite un
nombre d’opérations élémentaires (essentiellement des multiplications) qui dépend de k. Cependant, certains
processeurs possèdent une instruction permettant de décaler de
k
bits vers la gauche la représentation binaire
d’un entier, autrement dit de calculer 2ken coût constant.
Les comparaisons entre nombres (du moment que ceux-ci sont codés sur un nombre fixe de bits) seront aussi
considérées comme des opérations à coût constant, de même que la comparaison entre deux caractères. En
revanche, la comparaison entre deux chaînes de caractères ne pourra être considérée comme une opération
élémentaire, même s’il est possible de la réaliser en une seule instruction python. Il en sera de même des
opérations d’aectation : lire ou modifier le contenu d’un case d’un tableau est une opération élémentaire, mais
ce nest plus le cas s’il s’agit de recopier tout ou partie d’un tableau dans un autre, même si la technique du
slicing en python permet de réaliser très simplement ce type d’opération.
1. Voir cours de première année.
Jean-Pierre Becirspahic
1.2 informatique commune
1.2 Notations mathématiques
Une fois précisé la notion d’opération élémentaire, il convient de définir ce qu’on appelle la taille de l’entrée.
Cette notion dépend du problème étudié : pour de nombreux problèmes, il peut s’agir du nombre d’éléments
constituant les paramètres de l’algorithme (par exemple le nombre d’éléments du tableau dans le cas d’un
algorithme de tri) ; dans le cas d’algorithmes de nature arithmétique (le calcul de
nk
par exemple) il peut s’agir
du nombre de bits nécessaire à la représentation des données. Enfin, il peut être approprié de décrire la taille
de l’entrée à l’aide de deux entiers (le nombre de sommets et le nombre d’arêtes dans le cas d’un algorithme
portant sur les graphes).
Une fois la taille
n
de l’entrée définie, il reste à évaluer en fonction de celle-ci le nombre
f
(
n
) d’opérations
élémentaires requises par l’algorithme. Mais même s’il est parfois possible d’en déterminer le nombre exact, on
se contentera le plus souvent d’en donner l’ordre de grandeur à l’aide des notations de Landau.
La notation la plus fréquemment utilisée est le « grand O » :
f(n) = O(αn)⇒ ∃B>0
f(n)6Bαn.
Cette notation indique que dans le pire des cas, la croissance de
f
(
n
) ne dépassera pas celle de la suite (
αn
).
L’usage de cette notation exprime l’objectif qu’on se donne le plus souvent : déterminer le temps d’exécution
dans le cas le plus défavorable. On notera qu’un usage abusif est souvent fait de cette notation, en sous-
entendant qu’il existe des configurations de l’entrée pour lesquelles
f
(
n
) est eectivement proportionnel à
αn.
D’un usage beaucoup moins fréquent, la notation exprime une minoration du meilleur des cas :
f(n) = (αn)⇒ ∃B>0
f(n)>Bαn.
Lexpérience montre cependant que pour de nombreux algorithmes le cas « moyen » est beaucoup plus souvent
proche du cas le plus défavorable que du cas le plus favorable. En outre, on souhaite en général avoir la certitude
de voir s’exécuter un algorithme en un temps raisonnable, ce que ne peut exprimer cette notation.
Enfin, lorsque le pire et le meilleur des cas ont même ordre de grandeur, on utilise la notation Θ:
f(n) = Θ(αn)f(n) = O(αn) et f(n) = (αn).
Cette notation exprime le fait que quelle que soit le configuration de l’entrée, le temps d’exécution de l’algo-
rithme sera grosso-modo proportionnel à αn.
Ordre de grandeur et temps d’exécution
Nous l’avons dit, la détermination de la complexité algorithmique ne permet pas d’en déduire le temps
d’exécution mais seulement de comparer entre eux deux algorithmes résolvant le même problème. Cependant, il
importe de prendre conscience des diérences d’échelle considérables qui existent entre les ordres de grandeurs
usuels que l’on rencontre. En s’appuyant sur une base de 10
9
opérations par seconde, le tableau de la figure 1
est à cet égard significatif.
logn n nlogn n2n32n
1027 ns 100 ns 0,7µs 10 µs 1 ms 4 ·1013 années
10310 ns 1 µs 10 µs 1 ms 1 s 10292 années
10413 ns 10 µs 133 µs 100 ms 17 s
10517 ns 100 µs 2 ms 10 s 11,6 jours
10620 ns 1 ms 20 ms 17 mn 32 années
Figure 1 – Temps nécessaire à l’exécution d’un algorithme en fonction de son coût.
La lecture de ce tableau est édifiante : il faut autant que faire se peut éviter toute complexité temporelle
supérieure à un coût quadratique.
2. Structures de données linéaires
Dans son acceptation la plus générale, une structure de données spécifie la façon de représenter en mémoire
machine les données d’un problème à résoudre en décrivant :
Structures de données linéaires 1.3
O(logn) logarithmique
O(n) linéaire
O(nlogn) semi-linéaire
O(n2) quadratique
O(nk) (k>2) polynomiale
O(kn) (k > 1) exponentielle
Figure 2 – Qualifications usuelles des complexités.
la manière d’attribuer une certaine quantité de mémoire à cette structure ;
la façon d’accéder aux données qu’elle contient.
Dans certains cas, la quantité de mémoire allouée à la structure de donnée est fixée au moment de la création
de celle-ci et ne peut plus être modifiée ensuite ; on parle alors de structure de données statique. Dans d’autres
cas l’attribution de la mémoire nécessaire est eectuée pendant le déroulement de l’algorithme et peut donc
varier au cours de celui-ci ; il s’agit alors de structure de données dynamique. Enfin, lorsque le contenu d’une
structure de donnée est modifiable, on parle de structure de donnée mutable.
Par exemple, en python la classe
tuple
et la classe
str
sont des structures de données statiques et non mutables,
contrairement à la classe list qui est une structure de donnée dynamique et mutable.
>>> l = [1, 2, 3]
>>> l.append(4)
>>> l[0] = 5
>>> l
[5, 2, 3, 4]
>>> t = (1, 2, 3)
>>> t.append(4)
AttributeError:'tuple'object has no attribute 'append'
>>> t[0] = 5
TypeError:'tuple'object does not support item assignment
Figure 3 – la classe list est dynamique et mutable, pas la classe tuple.
Les structures de données classiques appartiennent le plus souvent aux familles suivantes :
les structures linéaires : il s’agit essentiellement des structures représentables par des suites finies ordon-
nées ; on y trouve les listes, les tableaux, les piles, les files;
les matrices ou tableaux multidimensionnels ;
les structures arborescentes (en particulier les arbres binaires) ;
les structures relationnelles (bases de données ou graphes pour les relations binaires).
Nous nous intéresserons avant tout aux deux premières.
2.1 Tableaux et listes
Tableaux et listes constituent les principales structures de données linéaires.
Tableaux
Les tableaux forment une suite de variables de même type associées à des emplacements consécutifs de la
mémoire.
ad
Figure 4 – Une représentation d’un tableau en mémoire.
Puisque tous les emplacements sont de même type, ils occupent tous le même nombre
d
de cases mémoire ;
connaissant l’adresse
a
de la première case du tableau, on accède en coût constant à l’adresse de la case d’indice
k
en calculant
a
+
kd
. En revanche, ce type de structure est statique : une fois un tableau créé, la taille de ce
dernier ne peut plus être modifiée faute de pouvoir garantir qu’il y a encore un espace mémoire disponible au
delà de la dernière case. En résumé :
Jean-Pierre Becirspahic
1.4 informatique commune
un tableau est une structure de donnée statique ;
les éléments du tableau sont accessibles en lecture et en écriture en temps constant O(1).
Les tableaux existent en python : c’est la classe array fournie par la bibliothèque numpy.
Listes chaînées
Les listes associent à chaque donnée (de même type) un pointeur indiquant la localisation dans la mémoire de
la donnée suivante (à l’exception de la dernière, qui pointe vers une valeur particulière indiquant la fin de la
liste).
anil
Figure 5 – Une représentation d’une liste en mémoire.
Dans une liste, il est impossible de connaître à l’avance l’adresse d’une case en particulier, à l’exception de la
première. Pour accéder à la
ne
case il faut donc parcourir les
n
1 précédentes : le coût de l’accès à une case est
linéaire. En contrepartie, ce type de structure est dynamique : une fois la liste crée, il est toujours possible de
modifier un pointeur pour insérer une case supplémentaire. En résumé :
une liste est une structure de donnée dynamique ;
le neélément d’une liste est accessible en temps O(n).
On notera que le type de liste que l’on vient de présenter est le plus courant (il s’agit de listes chaînées) mais il
en existe d’autres : listes doublement chaînées permettant l’accès non seulement à la donnée suivante mais aussi
à la donnée précédente, listes circulaires dans lesquelles la dernière case pointe vers la première, etc.
Contrairement à ce que pourrait laisser croire son nom, la classe
list
en python nest pas une liste au sens
qu’on vient de lui donner, mais une structure de donnée plus complexe qui cherche à concilier les avantages
des tableaux et des listes, à savoir être une structure de donnée dynamique dans laquelle les éléments sont
accessibles à coût constant. Bien que la description de ce type de structure sorte du cadre strict du programme,
il peut être intéressant d’en donner un aperçu pour en dévoiler l’ingéniosité ; c’est ce que nous allons faire dans
la section suivante.
2.2 La classe list de python
Avant de décrire la façon dont sont représentés les objets de la classe
list
en python, passons en revue les
principales opérations et méthodes qui mutent une liste. Dans le tableau suivant,
l
est une instance de la classe
list,iun entier et xun objet qu’on écrit, ajoute, supprime ou recherche dans l.
l[i] = x remplace l[i] par x
del l[i] supprime l’élément l[i]
l.append(x) ajoute un élément xen queue de liste
l.remove(x) supprime la première occurrence de x
l.insert(i, x) insère xen position i
l.pop(i) supprime et renvoie le ieélément de l
Chacune de ses opérations à un coût, mais il nous est pour l’instant impossible de dire lequel faute de connaître
la représentation en mémoire d’une liste.
Commençons par décrire la méthode de création
2
. Lorsqu’on crée une liste de taille
`
, un espace mémoire
légèrement plus grand est alloué (on verra plus loin dans quelle proportion). Par exemple, lors de la création de
la liste à trois éléments
l=['a', 2, 3.14]
, un espace mémoire à 4 emplacements est créé, les trois premiers
contenant des pointeurs en direction des valeurs de la liste. C’est pour cette raison qu’une liste peut accueillir
des valeurs de types diérents : une instance de la classe
list
nest en réalité qu’un tableau de pointeurs. Le
dernier espace pour l’instant ne contient rien.
2
. Ce qui va suivre nest valable que pour Cpython, l’implémentation écrite en C du langage python. Il s’agit de la principale des
implémentations du langage, celle sur laquelle est basée Pyzo, par exemple.
Structures de données linéaires 1.5
'a'23.14
Puisqu’il s’agit d’un tableau, chaque pointeur est accessible à coût constant et l’opération
l[1] = 5
s’exécute
en O(1) puisqu’il sut de modifier un pointeur :
'a'53.14
Les espaces libres sont alloués au fur et à mesure que la liste s’agrandit ; dans le cas de notre exemple, après
l’instruction l.append(8) la situation en mémoire devient :
'a'3.14 5 8
Bien entendu, tant qu’il reste des emplacements disponibles ces ajouts en fin de liste se font à coût constant.
Supposons maintenant que l’on souhaite insérer une valeur supplémentaire avec
l.insert(1, 'b')
. Puisqu’il
n’y a plus d’emplacements libres, la liste doit d’abord être redimensionnée : il faut lui allouer un espace plus
grand, qui pour notre exemple sera de 8 emplacements.
'a'3.14 5 8
Un nouveau pointeur est ensuite créé, et les pointeurs existants sont modifiés pour refléter le nouvel ordre des
éléments de la liste :
'a''b'3.14 5 8
Jean-Pierre Becirspahic
1 / 12 100%
La catégorie de ce document est-elle correcte?
Merci pour votre participation!

Faire une suggestion

Avez-vous trouvé des erreurs dans linterface ou les textes ? Ou savez-vous comment améliorer linterface utilisateur de StudyLib ? Nhésitez pas à envoyer vos suggestions. Cest très important pour nous !