Tableaux

Note

Le type tableau présenté ici (numpy.array) n’est pas le plus courant en Python, qui préfère l’utilisation des listes. Il présente cependant un certain nombre d’intérêts pédagogiques, dont celui d’être plus proche des types tableaux disponibles dans d’autres langages de programmation.

Un tableau est une liste ordonnée de n valeurs du même type. On appelle n la taille du tableau, et les valeurs qu’ils contient sont ses éléments. Chaque élément est repéré dans le tableau par son indice, un nombre entier compris entre 0 et n-1 (inclus).

Pré-requis

Afin d’utiliser des tableaux en Python, il est nécessaire :

  • d’installer le module Numpy,

  • d’inclure la ligne suivante dans tous les programmes utilisant les tableaux :

    from numpy import *
    

Déclaration d’un tableau

Un tableau peut-être déclaré de plusieurs manière :

>> from numpy import *
>>> array([5,3,2,1,1]) # crée un tableau à partir de ses éléments
array([5, 3, 2, 1, 1])
>>> zeros(3, float)    # crée un tableau rempli de 0
array([ 0., 0., 0.])
>>> empty(4, int)      # crée un tableau non initialisé
array([       0,  8826784, 31983376,        0])

On passe à array la liste des éléments du tableau à créer ; on note que les valeurs doivent être encadrées par des crochets [...] en plus des parenthèses.

On passe à zeros et empty la taille du tableau et le type de ses éléments. Notons cependant que le nom empty prête à confusion : il ne crée pas un tableau vide (le tableau contient des éléments), mais il n’initialise pas ses éléments, donc ceux-ci ont une valeur aléatoire. Lorsqu’on utilise empty, il est donc impératif d’initialiser ensuite chaque élément du tableau, sans quoi la suite de l’algorithme risque de donner des résultats aléatoires.

Opérations sur les tableaux

Les opérations disponibles sur les tableaux sont très similaires aux opérations disponibles sur les chaînes de caractères :

>>> a = array([5,3,2,1,1])
>>> len(a)  # longueur
5
>>> a.size  # autre manière d'obtenir la longueur d'un tableau
5
>>> a[0]    # premier élément
5
>>> a[:3]   # sous-tableau correspondant aux 3 premiers éléments
array([5, 3, 2])
>>> a[3:]   # sous-tableau correspondant aux éléments à partir du 4ème
array([1, 1])

On peut par ailleurs modifier un élément d’un tableau (identifié par son indice) en utilisant une affectation :

>>> a = array([5,3,2,1,1])
>>> a[1] = 9  # modification du 2ème élément du tableau
>>> a
array([5, 9, 2, 1, 1])

Tableaux et mutabilité

Toutes les opérations portant sur les types de données vus précédemment (entier, flottant, booléen et chaîne [1]) produisent une nouvelle valeur à partir des valeurs des opérandes. Chaque affectation d’une variable remplace donc la valeur de cette variable par une autre valeur.

Une conséquence est que la modification d’une variable ne peut pas avoir d’impact sur une autre variable. Par exemple :

>>> a = 42
>>> b = a   # b prend la même valeur que a, c.à.d. 42
>>> a = a+1 # a+1 produit la valeur 43, qui remplace 42 dans la variable a
>>> a
43
>>> b       # b, en revanche, contient toujours 42
42

Avec les tableaux, l’affectation d’un élément modifie l’état du tableau sans remplacer ce dernier par un autre tableau. On dit que les tableaux sont des objets mutables (c’est à dire modifiable; le terme anglais est également mutable). Il en résulte que, si deux variables font référence au même tableau, une modification sur l’une des variables sera répercutée sur l’autre :

>>> a = array([5,3,2,1,1])
>>> b = a  # a et b font maintenant référence au MÊME tableau
>>> b
array([5, 3, 2, 1, 1])
>>> a[1] = 9
>>> a      # l'état du tableau a été modifié...
array([5, 9, 2, 1, 1])
>>> b      # ... ce qui se voit aussi sur b, puisqu'il s'agit du même tableau
array([5, 9, 2, 1, 1])

De plus, les sous-tableaux produits par les opérations a[:i] et a[i:] partagent également l’état du tableau qui a servi à les produire. Ce ne sont pas des copies partielles, mais des vues restreintes du tableau global. Ainsi, si on modifie un élément du sous-tableau, le tableau global est également modifié (et inversement) :

>>> a = array([5,3,2,1,1])
>>> b = a[3:]
>>> b
array([1, 1])
>>> b[1] = 7
>>> b
array([1, 7])
>>> a
array([5, 3, 2, 1, 7])  # la modification de b est répercutée sur a
>>> a[3] = 8
>>> a
array([5, 3, 2, 8, 7])
>>> b
array([8, 7])           # la modification de a est répercutée sur b

Paramètres d’entrée-sortie

La notion d’objet mutable demande d’étendre légèrement la notion de problème telle qu’on l’a définie au chapitre Problème.

Considérons par exemple le problème consistant à diviser par deux tous les éléments d’un tableau de flottants. On peut spécifier ce problème ainsi :

:entrée a:  tableau de flottants
:pré-cond:  Ø
:sortie b:  tableau de flottants
:post-cond: len(b) == len(a), et
            pour tout i entre 0 et len(a)-1, b[i] == a[i]/2

Un algorithme résolvant ce problème devra donc créer un nouveau tableau b, et l’initialiser en fonction des valeurs des éléments de a, tandis que a ne sera pas modifié.

Mais comment spécifier le problème de sorte que l’algorithme modifie directement les éléments de a, sans créer un nouveau tableau ?

Dans un tel problème, a serait à la fois paramètre d’entrée (puisque les valeurs initiales de ses éléments conditionnent la résolution du problème) et paramètre de sortie (puisque les valeurs finales de ses éléments constituent la solution). On propose donc un nouveau type de paramètre : les paramètres d’entrée-sortie. Ces paramètres contiennent nécessairement des objets mutables, dont l’état initial décrit le problème (ou une partie du problème), et dont l’état final décrit (une partie de) la solution. Dans la spécification, on les déclarera par le texte :entrée/sortie x: (où x est le nom de ce paramètre).

Reste le problème d’exprimer la post-condition : le nom du paramètre ne suffit plus, puisqu’il faut distinguer sont état initial et son état final. Pour cela, on ajoutera au nom un e (entrée) en indice pour l’état initial, et un s (sortie) en indice pour l’état final.

On peut donc spécifier ainsi la variante du problème ci-dessus :

:entrée/sortie a: tableau de flottants
:pré-cond:        Ø
:post-cond:       pour tout i entre 0 et len(a)-1, aₛ[i] == aₑ[i]/2

On remarque que ce problème n’a aucun paramètre qui soit strictement un paramètre de sortie. Or seuls les paramètres de sortie sont retournés (avec le mot-clé return) ; les paramètres d’entrée-sortie ne le sont pas (leurs modifications sont accessibles directement dans l’objet passé en paramètres). L’algorithme qui résout le problème ci-dessus sera donc une procédure : il ne retourne aucune valeur.

Mutabilité, égalité et identité

Nous avons vu précédemment que l’opérateur == permet de vérifier l’égalité deux deux éléments.

Cela dit, cet opérateur a un comportement pour le moins étonnant lorsqu’on l’utilise avec des tableaux. Considérez le fragment de code suivant.

taba = array([1,2,3])
tabb = array([1,2,3])
tabc = array([1,2,4])
tabd = array([1,2,4,5])

print(taba == tabb)
print(taba == tabc)
print(taba == tabd)

L’opérateur == sur des tableaux numpy va retourner :

  • False si les tableaux sont de taille différentes
  • Un tableau de booléens comparant les éléments un a un

Par conséquent, comme le tableau résultat n’a pas de valeur logique unique [2], on ne peut pas écrire :

if taba == tabb:
   print("les tableaux sont égaux")

Comme nous venons de le voir, l’opérateur == permet de vérifier, élément par élément, l’égalité de deux tableaux. Mais dans certains cas, on peut vouloir vérifier que deux noms de variables représentent le même objet “tableau”. Pour cela, on va utiliser l’opérateur is, qui permet de vérifier l’identité des objets (rassurez-vous, vous en apprendrez plus long sur les objets au prochain semestre). Considérez l’exemple suivant :

 >>> taba = array([1,2,3])
 >>> tabb = array([1,2,3])
 >>> tabc = taba
 >>> print(taba == tabb)
 [ True  True  True]
 >>> print(taba == tabc)
 [ True  True  True]
 >>> print(taba is tabb)
 False
 >>> print(taba is tabc)
 True
>>> taba[2] = 8
>>> print(taba)
[1 2 8]
>>> print(tabb)
[1 2 3]
>>> print(tabc)
[1 2 8]

Vous constatez bien que taba et tabb sont égaux car toutes leurs valeurs sont égales une à une. En revanche, taba et tabb sont deux objets différents (si l’on modifie taba, tabb ne sera pas modifié) tandis que taba et tabc sont deux noms différents pour le même objet (si l’on modifie taba, tabc sera modifié).

Tableaux de chaînes de caractères

Les tableaux de chaînes de caractères nécessitent quelques précautions, car la taille maximale de leurs éléments doit être connue à la création du tableau.

Considérons les instructions suivantes :

>>> a = array(["un", "deux"])
>>> a[0] = "autre chose"
>>> a
>>> a[0]
'autr'

Lorsqu’on crée un tableau de chaînes avec array, la taille maximale des éléments est fixée à la longueur du plus grand élément fourni. Il faut être très attentif à cela, car on voit qu’ensuite, les valeurs affectées au tableau sont silencieusement tronquées à cette taille.

Par ailleurs, si l’on souhaite créer un tableau de chaîne de caractères avec zeros ou empty et que l’on passe str comme type de données, la taille maximale des éléments sera fixée à un caractère.

Si l’on souhaite un tableau de chaînes de caractères de longueur supérieure, il faudra passer en guise de type un chaîne de caractères formée du préfixe <U suivi de la longueur souhaitée. Par exemple, la ligne suivante :

a = zeros(10, "<U1000")

créera un tableau de 10 chaînes de caractères de longueur maximale 1000.

Notons enfin que, pour les chaînes de caractères, zeros initialise les éléments du tableau par une chaîne vide ('').

Notes de bas de page

[1]Dans d’autres langages (comme le C), seul le type caractère est un type élémentaire ; les chaînes de caractères sont représentées par un tableau de caractères, et donc mutable (contrairement aux chaînes de Python).
[2]Cela dit, numpy nous propose une méthode pour tester l’égalité de deux tableaux : numpy.array_equal(a, b)