Données Liste Implémentation

Identification

Infoforall

13 - Implémenter une Liste 2/3


Nous avons implémenté une Liste Récursive sous forme d'une Liste Chaînée utilisant en interne un tuple (tête, queue).

L'idée abstraite est la suivante :

Nous l'avons implémenté sous forme d'une liste chaînée dont la structure est proche de ceci :

Aujourd'hui, une Liste Itérative sous forme d'une Liste Contiguë de cases utilisant en interne un tableau.

L'idée abstraite est la suivante :

Nous allons l'implémenter sous forme d'un tableau, ce qui donnera une structure de données proche de ceci :

Principe du tableau

Prérequis : l'activité sur les Listes et la première implémentation sous forme de tuple (pour comprendre l'intérêt de celle-ci).

Documents de cours : open document ou pdf

1 - Implémentation en tableau statique, bonne idée ?

1.1 Principe d'un tableau

Propriétés générales d'organisation

En informatique, un tableau désigne un conteneur de données qui a les propriétés suivantes :

  • Les octets sont tous consécutifs en mémoire à partir d'une première adresse A.
  • Le tableau occupe un largeur de L octets.
  • Chaque case contient :
    • exactement le même type de données qui occupe
    • exactement le même nombre d'octets C en mémoire.

Exemple : considérons un tableau de 1000 integers sur 4 octets.

    Principe des octets consécutifs
  • Imaginons que le premier octet du tableau soit stocké à l'adresse A = 500 dans la mémoire.
  • Chaque case contient C = 4 octets puisqu'on a un tableau d'integers sur 4 octets.
  • Le tableau occupe donc L = 1000 * 4 = 4000 octets allant de 500 pour le premier à 4500 pour le dernier puisque les octets sont consécutifs.
  • Chaque octet est bien entendu compréhensible comme un entier naturel entre 0 et 255.
  • Les 4 octets oranges représentent collectivement le premier entier.
Indice des cases
  • On identifie chaque case par un simple numéro qu'on appelle indice.
  • Attention : l'indice de la première case n'est pas 1 mais 0
  • t[0] veut donc dire d'aller décoder le contenu DES CASES qui correspondent à l'indice 0 : les cases 500 - 501 - 502 - 503 ici.
  • t[1] veut donc dire d'aller décoder le contenu DES CASES qui correspondent à l'indice 1 : les cases 504 - 505 - 506 - 507 ici.
Principe du tableau dans l'espace des noms

L'espace des noms permet de faire le lien entre le nom d'une variable (ici t) et les données du tableau (ici stockées à partir de l'octet dont l'adresse est 500).

1.2 Accès aux cases à coût CONSTANT

Accès à Coût CONSTANT

L'accès à n'importe quelle case du tableau se fait à coût constant : quelque soit la taille réelle du tableau, atteindre une case particulière prend le même temps.

Voyons pourquoi.

Explications

RAM

Il faut d'abord se souvenir les données de notre programme sont stockées dans la mémoire dite RAM : Random Access Memory.

C'est donc une mémoire dans laquelle on peut lire n'importe quelle case immédiatement pourvu qu'on connaisse son adresse.

On utilise le mot "accès aléatoire" par opposition à "accès séquentiel".

Espace des noms : adresse du tableau

On trouve facilement l'adresse A du premier octet du tableau à l'aide de l'espace des noms.

On peut donc accéder au tableau à coût CONSTANT.

Adresses des cases du tableau

Néanmoins, nous voulons accéder ici aux cases du tableau, et pas à tout le tableau.

Reprenons notre exemple de tableau d'integers 4 octets en y notant l'indice d'un des intégers en première ligne et les adresses des octets correspondants en dernière ligne.

L'espace des noms permet d'apprendre que le tableau t est stocké à partir de l'adresse 500.

Sachant que chaque case possède 4 octets, on peut trouver facilement l'adresse de début de chaque nombre stocké, pourvu qu'on reçoive son indice :

  • t[0] : commence à 500
  • t[1] : commence à 504
  • t[2] : commence à 508
  • t[3] : commence à 512
  • ...

Conclusion

On peut déterminer l'adresse du premier octet encodant l'une des cases si on connaît :

  • L'adresse A du début du tableau t
  • L'indice i
  • Le nombre fixe C d'octets dans chaque case

Adresse du premier octet à lire = A + i * C

Exemple

Principe du tableau dans l'espace des noms

Lorsqu'on tape t[2], Python

  • on obtient via l'espace des noms l'adresse 500 (action à coût CONSTANT)
  • calcule l'adresse de la case 2 avec 500 + 2 * 4 = 508 (action à coût CONSTANT)
  • va lire le contenu des cases 508, 509, 510 et 511 (4 actions à coût CONSTANT)
  • décode le contenu et renvoie la réponse (action à coût CONSTANT)

L'ensemble de cette opération est donc à coût CONSTANT.

1.3 - Une case en plus ou en moins par compréhension

La création par compréhension consiste à donner entre crochet une expression liée à une boucle et qui va permettre de remplir automatiquement les cases du nouveau tableau.

Création d'un tableau de 5 cases contenant 0
>>> t = [0 for x in range(5)] >>> t [0, 0, 0, 0, 0]

La variable de boucle x va prendre d'abord la valeur 0, puis 1, puis 2, puis 3 et finalement 4. Sa valeur n'a pas d'importance ici mais elle prend bien 5 valeurs différentes. Et pour chaque valeur, on va rajouter une case contenant 0 dans le tableau.

On lit une construction par compréhension de la droite vers la gauche. En mode pas à pas, cela donne quelque chose comme :

t = [0 for x in range(5)] t = [0 for x in (0, 1, 2, 3, 4)] t = [rajoute une case contenant 0 à la fin du tableau pour chaque x que tu vas lire] t = [0, 0, 0, 0, 0]

S'agissant d'une boucle POUR, cette création est donc réalisée avec un coût linéaire.

Autant de cases que l'original

Si on veut autant de cases qu'un autre tableau, il suffit d'utiliser la fonction native len() lors de la création :

>>> a = [7, 15, 12, 25] >>> t = [0 for x in range( len(a) )] >>> t [0, 0, 0, 0]
Une case en plus

Si on veut une case en plus par rapport à un autre tableau, il suffit d'utiliser la fonction native len() et de rajouter 1 au résultat :

>>> a = [7, 15, 12, 25] >>> t = [0 for x in range( len(a)+1 )] >>> t [0, 0, 0, 0, 0]
Une case en moins

Si on veut une case en moins par rapport à un autre tableau, il suffit d'utiliser la fonction native len() et de soustraire 1 au résultat :

>>> a = [7, 15, 12, 25] >>> t = [0 for x in range( len(a)-1 )] >>> t [0, 0, 0]
1.4 - coûts des actions sur les tableaux en Python

Accès à une case ou au tableau

Coût CONSTANT.

Créer un tableau d'environ n cases

Puisqu'on utilise une boucle POUR avec n boucles environ lorsqu'on veut créer un tableau de taille n-1, n ou n+1, cette création est une action à coût LINEAIRE.

Déterminer le nombre de cases

L'utilisation de la fonction native len() est à coût CONSTANT.

Nous partons donc de l'idée abstraite d'une Liste itérative que nous allons implémenter sous forme d'une Liste contigüe en utilisant des tableaux statiques.

Cela va être utile si on lit plus souvent les données qu'on ne les insère ou les supprime. Pourquoi ? Simplement car lire les éléments d'un tableau se fait à coût CONSTANT : Θ(1). On considère également que le coût de len() est constant sur le type list de Python.

Nous allons réaliser un module contenant les fonctions d'interface d'une Liste.

1.5 - Les primitives d'une Liste contiguë

Voici les opérations primitives qu'on sélectionne classiquement pour manipuler une Liste contiguë :

  1. nouvelle_liste_vide() → Liste VIDE
  2. est_liste_vide(lst:Liste) → bool
  3. longueur(lst:Liste) → int
  4. inserer(lst:Liste, elt:Elément, i:int VALIDE) → Liste NON VIDE
  5. supprimer(lst:Liste NON VIDE, i:int VALIDE) → Liste

Comme avec la liste chaînée, nous introduirons rapidement une "fonction raccourci" nommée ici ieme() qui n'est pas une vraie primitive mais qui est bien pratique pour se passer des primitives liées à la gestion des cases. De cette façon, l'utilisateur de la liste n'a pas à se soucier des cases lui-même.

  1. ieme(lst:Liste NON VIDE, i:int VALIDE) → Element

Il reste bien entendu les 3 fonctions usuelles d'interaction avec les Cases (si l'utilisateur peut s'en passer c'est mieux).

  1. acces(lst:Liste NON VIDE, i:int VALIDE) → Case
  2. contenu(c:Case) → Elément
  3. successeur(c:Case NON FIN) → Case

Dans le cadre de cette activité, je vous fournis un module que nous allons analyser et compléter.

Le MODULE version 1 : PRIMITIVES de la liste contiguë de ce TP

Voici le module non finalisé implémentant une "Liste contiguë basée sur un tableau statique".

Voici l'ébauche de travail du module :

Dans la documentation du module :

V indique que la fonction est déjà réalisée.

x indique que la fonction sera à réaliser.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
"""Première ébauche du module Liste contiguë basée sur des tableaux statiques Description des types non natifs utilisés dans les fonctions + Element : le type des éléments disponibles dans votre Liste si elle est homogène. + Case : la référence d'un tuple (Liste, Indice) + Liste : la référence d'un tableau. Opérations primitives de la Liste contiguë ----------------------------------------- V nouvelle_liste_vide() -> Liste VIDE V est_liste_vide(lst:Liste) -> bool V longueur(lst:Liste) -> int V ieme(lst:Liste NON VIDE, i:int VALIDE) -> 'Elément' x inserer(lst:Liste, elt:Element, i:int VALIDE) -> Liste NON VIDE x supprimer(lst:Liste NON VIDE, i:int VALIDE) -> Liste Fonctions d'interface supplémentaires ------------------------------------- V inserer_tete(lst:Liste, elt:Element) -> Liste NON VIDE x supprimer_tete(lst:Liste NON VIDE) -> Liste V representer_liste(lst:Liste) -> str """ # Importation : aucune # Déclaration des CONSTANTES : aucune # Déclaration des fonctions d'interface fondamentales def nouvelle_liste_vide() -> 'Liste VIDE': """Renvoie une liste vide""" return [] def est_liste_vide(lst:'Liste') -> bool: """Prédicat qui renvoie True si la liste est vide""" return lst == [] def longueur(lst:'Liste') -> int: """Renvoie le nombre d'éléments stockés dans lst""" return len(lst) def ieme(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Element': """Renvoie le contenu de la Case de position i VALIDE dans la liste NON VIDE lst""" return lst[i] def inserer(lst:'Liste', elt:'Elément', i:'int VALIDE') -> 'Liste NON VIDE': """Renvoie une Liste basée sur lst où elt est en position VALIDE i""" pass def supprimer(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la case i""" pass # Déclaration des fonctions d'interface supplémentaires def inserer_tete(lst:'Liste', elt:'Elément') -> 'Liste NON VIDE': """Renvoie une Liste basée sur lst où elt est en Tête""" nb = len(lst) + 1 nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec une case en plus for k in range(0, len(lst)): # 2 - pour chaque indice possible de lst nouveau[k+1] = lst[k] # décalage à droite des éléments nouveau[0] = elt # 3 - elt à la position 0 return nouveau def supprimer_tete(lst:'Liste NON VIDE') -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la Tête""" pass def representer_liste(lst:'Liste') -> str: """Renvoie une représentation de la Liste sous forme d'une séquence commençant par la tête""" return str(lst).replace(',',' -> ').replace('[','').replace(']','') # Instructions du programme principal if __name__ == '__main__': # Ces fonctions ne seront en mémoire qu'en lancement direct def tester_inserer_tete(): a = nouvelle_liste_vide() a = inserer_tete(a, 5) assert a == [5] a = inserer_tete(a, 2) assert a == [2, 5] b = inserer_tete(a, 20) assert a == [2, 5] assert b == [20, 2, 5] print('Test OK pour inserer_tete()') def tester_supprimer_tete(): a = nouvelle_liste_vide() a = inserer_tete(a, 5) a = inserer_tete(a, 2) b = supprimer_tete(a) assert a == [2, 5] assert b == [5] c = supprimer_tete(b) assert est_liste_vide(c) print('Test OK pour supprimer_tete()') def tester_longueur(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) assert longueur(a) == 0 assert longueur(b) == 1 assert longueur(c) == 2 print('Test OK pour longueur()') def tester_ieme(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) assert ieme(b, 0) == 5 assert ieme(c, 0) == 2 assert ieme(c, 1) == 5 print('Test OK pour lire()') def tester_inserer(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) d = inserer(c, 20, 1) assert d == [2, 20, 5] print('Test OK pour inserer()') def tester_supprimer(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) d = inserer(c, 20, 1) e = supprimer(d, 2) assert d == [2, 20, 5] assert e == [2, 20] print('Test OK pour supprimer()') print("Module lancé directement, il ne s'agit pas d'une importation")

01° Mettre le module en mémoire en le lançant directement. Observer les 4 fonctions suivantes :

  • nouvelle_liste_vide(),
  • est_liste_vide(),
  • longueur() et
  • ieme().
Répondre ensuite à ces questions :

  1. comment se nomme dans Python le type de la structure de données qui nous sert à construire la notre ?
  2. Si on veut utiliser uniquement des tableaux statiques, va-t-on avoir le droit d'utiliser toutes les méthodes qu'offre Python sur ce type ?
  3. L'utilisateur aura-t-il le droit d'utiliser directement des expressions comme liste[i] pour lire le contenu de ses données ?
  4. Estimer le coût de ces 4 primitives en analysant leur code respectif.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
def nouvelle_liste_vide() -> 'Liste VIDE': """Renvoie une liste vide""" return [] def est_liste_vide(lst:'Liste') -> bool: """Prédicat qui renvoie True si la liste est vide""" return lst == [] def longueur(lst:'Liste') -> int: """Renvoie le nombre d'éléments stockés dans lst""" return len(lst) def ieme(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Element': """Renvoie le contenu de la Case de position i VALIDE dans la liste NON VIDE lst""" return lst[i]

...CORRECTION...

  1. Nous utilisons le type natif list.
  2. Néanmoins, nous allons l'utiliser comme un simple tableau statique : après création, vous ne devrez pas utiliser les deux méthodes disponibles sur le type natif list :
    • append() pour rajouter une case à la fin
    • pop() pour supprimer l'actuelle case de fin
  3. Non, l'utilisateur devra se limiter à utiliser nos fonctions d'interface. S'il manipule directement les données, ses programmes risquent de ne plus fonctionner si nous décidons de modifier les codes internes des primitives lors d'une mise à jour.
  4. Puisqu'elles ne contiennent toutes que des instructions à coût constant, elles sont toutes à coût constant.

Comment va-t-on gérer les insertions dans notre liste implémentée sous forme de tableau ?

Le choix a été fait de créer des listes IMMUABLES puisque les fonctions d'insertion et de suppression renvoient une nouvelle liste : il va donc falloir créer un deuxième tableau avec une case en plus et y décaler les éléments vers la droite.

Exemple : on veut insérer l'élément 23 en tête dans la liste initiale (5, 10, 15, 25, 30, 35) Principe de l'insertion dans une liste sur une implémentation tableau

On voit donc que la Liste après insertion est un tableau qui :

  1. doit posséder une case en plus
  2. contient les éléments de la première liste mais avec un décalage de 1 vers la droite : new[i+1] = old[i]
  3. possède le nouvel élément en indice 0

02° Observer la fonction inserer_tete() (version simplifiée de la primitive inserer() que vous aurez à concevoir plus loin) :

1 2 3 4 5 6 7 8 9 10
def inserer_tete(lst:'Liste', elt:'Elément') -> 'Liste NON VIDE': """Renvoie une Liste basée sur lst où elt est en Tête""" nb = len(lst) + 1 nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec une case en plus for k in range(0, len(lst)): # 2 - pour chaque indice possible de lst nouveau[k+1] = lst[k] # --> décalage à droite des éléments nouveau[0] = elt # 3 - elt à la position 0 return nouveau

Questions

  1. Cette fonction modifie-t-elle la liste en place ou crée-t-elle une copie qu'elle renvoie ?
  2. Pourquoi le tableau nouveau (créé par compréhension ligne 5 ci-dessus) est-il créé avec une case supplémentaire par rapport au paramètre lst ?
  3. Comment est-on parvenu à décaler les bonnes valeurs dans le second tableau ? (attention : à savoir expliquer et refaire)

...CORRECTION...

  1. Pas d'effet de bord : on crée un tableau ayant une case en plus, on le remplit puis on le renvoie.
  2. On insère une nouvelle valeur, il faut bien une case en plus non ?
  3. Voir Lignes 4-7 : pour chaque case du tableau d'origine, on place la valeur de la case i dans la case i+1 du nouveau tableau.

On sait maintenant créer des listes vides, lire les éléments et insérer des têtes. Reste à savoir supprimer les têtes.

Principe de la suppression dans une liste sur une implémentation tableau

03° Quelques questions :

  1. Combien de cases va devoir avoir le nouveau tableau contenant la liste modifiée ?
  2. Va-t-on devoir décaler les cases vers la gauche (on diminue l'indice) ou vers la droite (on augmente l'indice) ?
  3. Doit-on décaler tous les indices du tableau initial ou y-a-t-il un indice à ne pas décaler ?

...CORRECTION...

  1. Une case en moins, puisqu'on supprime.
  2. On voit bien qu'on va devoir décaler vers la gauche, en diminuant les indices des éléments qu'on va déplacer.
  3. On ne doit pas décaler l'indice 0 du tableau initial puisqu'on veut le faire disparaître.

04° Compléter la fonction supprimer_tete() dont on vous donne le prototype et la documentation.

PRECONDITION : la liste fournie est NON VIDE.

Prototype

supprimer_tete(lst:'Liste NON VIDE') -> 'Liste'

Déclaration à modifer

1 2 3
def supprimer_tete(lst:'Liste NON VIDE') -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la Tête""" pass

Vous pouvez utiliser la fonction tester_supprimer_tete() pour tester votre proposition.

...CORRECTION...

1 2 3 4 5 6 7 8 9
def supprimer_tete(lst:'Liste NON VIDE') -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la Tête""" nb = len(lst) - 1 nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec une case en moins for k in range(1, len(lst)): # 2 - décalage à gauche des éléments d'indice 1 et plus nouveau[k-1] = lst[k] return nouveau

Pour l'instant, on ne parvient à agir que sur les têtes mais on commence à voir que cela ressemble bien à une liste.

05° Enregistrer le module exactement sous le nom liste_contigue_statique.py. Enregistrer au même endroit ce programme :

1 2 3 4 5 6 7 8 9 10 11 12 13
from liste_contigue_statique import nouvelle_liste_vide as new from liste_contigue_statique import inserer_tete as mettreDevant from liste_contigue_statique import representer_liste as visuel from random import randint NBR = randint(4, 10) a = new() for _ in range(NBR): a = mettreDevant(a, randint(0, 20)) print(visuel(a))

Questions

  1. Quel mot-clé permet de changer localement le nom d'un module ou d'une fonction importée ?
  2. A qui fait-on appel lorsqu'on utilise visuel() dans ce programme ?
  3. L'utilisateur n'utilisant que la fonction d'interface autorisée visuel() pourra-t-il se rendre compte qu'on implémente notre structure de données dans un tableau ?
  4. L'utilisateur décidé à accéder directement à la mémoire (en tapant le nom de la variable) pourra-t-il se rendre compte qu'on implémente notre structure de données Liste comme un tableau ?

...CORRECTION...

  1. Il faut utiliser le mot-clé as.
  2. visuel() est le nom local de representer_liste
  3. 3
    from liste_contigue_statique import representer_liste as visuel
  4. Comme on peut le voir sur les exemples, l'utilisateur ne voit que la représentation de la liste comme une séquence ordonnée. Il ne peut donc pas savoir sous quelle forme elle est réellement implémentée...
  5. 20 -> 3 -> 8 -> 18 -> 2 -> 14 -> 16 -> 11 -> 2 >>> a [20, 3, 8, 18, 2, 14, 16, 11, 2]
  6. ...Sauf à demander directement l'accès à la variable, mais dans ce cas, il décide de passer au dessus de l'interface.

Je vous ai montré comment supprimer la tête et insérer une tête. Il vous reste à le faire à n'importe quelle position.

Commençons par l'insertion. Un exemple d'insertion d'un élément valant 23 sur l'indice 2 dans une Liste contiguë a :

>>> inserer(a, 23, 2)
Principe de l'insertion dans une liste sur une implémentation tableau

06-A° Répondre aux question ci-dessous :

  1. Pour insérer en indice 2, quels sont les indices des cases qui ne seront pas décalées ?
  2. Pour insérer en indice 2, quels sont les indices des cases qui seront décalées ?
  3. Pour insérer en indice i, quels sont les indices des cases qui ne seront pas décalées ?
  4. Pour insérer en indice i, quels sont les indices des cases qui seront décalées ?

...CORRECTION...

  1. On ne décale pas les cases de 0 à 1.
  2. On décale vers la droite les cases 2 et plus
  3. On ne décale pas les cases de 0 à (i-1).
  4. On décale vers la droite les cases i et plus

06-B° Compléter la fonction inserer() dont on vous donne le prototype et la documentation dans le module. N'utilisez pas la fonction inserer_tete() à l'interne : on veut réaliser une fonction indépendante puisqu'on désire en faire une primitive. Par contre, vous pourrez vous inspirer de inserer_tete().

Précondition : i doit être un indice VALIDE. Attention, fournir un indice correspondant au nombre d'éléments de la liste de base est valide dans le cadre d'une insertion : cela veut dire d'insérer en fin de liste.

Prototype

1
def inserer(lst:'Liste', elt:'Elément', i:'int VALIDE') -> 'Liste NON VIDE':

Vous pouvez utiliser la fonction tester_inserer() pour tester votre implémentation.

...CORRECTION...

1 2 3 4 5 6 7 8 9 10 11 12
def inserer(lst:'Liste', elt:'Elément', i:'int VALIDE') -> 'Liste NON VIDE': """Renvoie une Liste basée sur lst où elt est en position VALIDE i""" nb = len(lst) + 1 nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec une case en plus for k in range(0, i): # 2 - copie les éléments des indices 0 à i-1 sans décalage nouveau[k] = lst[k] for k in range(i, len(lst)): # 3 - décalage à droite des éléments d'indice i et plus nouveau[k+1] = lst[k] nouveau[i] = elt # 4 - elt à la position i return nouveau

Passons à la suppression n'importe où.

Principe de la suppression dans une liste sur une implémentation tableau

07-A° Répondre aux question ci-dessous :

  1. Pour supprimer l'élément en indice 2, quels sont les indices des cases qui ne seront pas décalées ?
  2. Pour supprimer l'élément en indice 2, quels sont les indices des cases qui seront décalées ?
  3. Pour supprimer l'élément en indice i, quels sont les indices des cases qui ne seront pas décalées ?
  4. Pour supprimer l'élément en indice i, quels sont les indices des cases qui seront décalées ?

...CORRECTION...

  1. On ne décale pas les cases de 0 à 1.
  2. On décale vers la gauche les cases 3 et plus
  3. On ne décale pas les cases de 0 à (i-1).
  4. On décale vers la gauche les cases i+1 et plus

07-B° Compléter la fonction supprimer() dont on vous donne le prototype et la documentation dans le module. N'utilisez la fonction supprimer_tete() à l'interne : il faut faire une fonction indépendante. Par contre, vous pourrez vous inspirer de supprimer_tete().

Préconditions : la Liste doit être NON VIDE et l'indice i doit être VALIDE, c'est à dire qu'on ne peut demander que de supprimer une case qui existe réellement.

Prototype

1
def supprimer(lst:'Liste NON VIDE', i:int) -> 'Liste':

Vous pouvez utiliser la fonction tester_supprimer() pour tester votre implémentation.

...CORRECTION...

1 2 3 4 5 6 7 8 9 10 11
def supprimer(lst:'Liste NON VIDE', i:int) -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la case i""" nb = len(lst) - 1 nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec une case en moins for k in range(0, i): # 2 - copie les éléments des indices 0 à i-1 sans décalage nouveau[k] = lst[k] for k in range(i+1, len(lst)): # 3 - décalage à gauche des éléments d'indice i+1 et plus nouveau[k-1] = lst[k] return nouveau

08° Estimer les coûts des quatre fonctions suivantes :

  • inserer(),
  • supprimer(),
  • inserer_tete() et
  • supprimer_tete()

Demandez-vous ensuite si pour chacune d'entre elles s'il existe un meilleur des cas et un pire des cas. En déduire alors la complexité de ces 4 fonctions.

...CORRECTION...

La création du nouveau tableau contenant None est LINEAIRE.

Ensuite, on doit toujours copier toutes les cases une à une, soit à la même place, soit en la déplaçant à droite ou à gauche, mais on copie le contenu néanmoins. Le fait de ne pas en gérer une ne change rien : n-1 donne n lorsqu'on recherche le coût asymptotique (c'est à dire qu'on se demande comment évolue le nombre d'actions lorsque n tend vers l'infini). Le remplissage du nouveau tableau est donc LINEAIRE également.

Il n'y a donc pas de pire des cas ou de meilleur des cas.

Le coût est donc toujours LINEAIRE.

La complexité peut donc s'écrire 𝞗(n).

Nous avons maintenant tout ce qu'il faut pour parvenir à gérer notre Liste, même si les insertions et les suppressions ne seront clairement pas de bonne qualité puisqu'on a un coût linéaire partout.

Nous n'avons pas réellement besoin des 3 primitives acces(), successeur() et contenu() puisqu'on peut gérer tout cela à la main en utilisant l'indice de la case.

Par contre, pour vous montrer que notre Liste est bien rigoureusement une Liste, nous allons les créer.

1.6 Gestion des cases dans notre Liste contiguë

Problématique

Pour accéder à une Case, on utilise par exemple a[i], qui contient deux informations :

  1. La structure a dans laquelle se trouve la case
  2. L'indice i de la case

La seule connaissance de i n'est donc pas suffisant.

Réponse possible

Nous faisons le choix de faire des cases de simple tuples contenant :

Case = (Liste contenant cette Case, Indice de cette case)

1 2 3
def acces(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Case': """Renvoie la référence de la case d'indice i dans la liste lst NON VIDE""" return (lst, i) # on crée un tuple-case (quelle liste ?, quel indice ?)

Puisqu'une case contient bien les informations "Quelle Liste ?" et "Quel numéro ?", on peut facilement réaliser les deux autres fonctions :

1 2 3 4 5 6
def contenu(c:'Case') -> 'Elément': """Renvoie le contenu de la case valide fournie""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return lst[i] # on renvoie l'élément stocké à cet endroit
1 2 3 4 5
def successeur(c:'Case NON FIN') -> 'Case': """Renvoie le case qui succède à la case fournie (qui ne doit pas être la dernière)""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return (lst, i+1) # on crée un tuple-case correspond à la case suivante

09-A° Lire les 3 fonctions ci-dessous gérant les Cases.

Estimer ensuite leurs coûts.

1 2 3
def acces(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Case': """Renvoie la référence de la case d'indice i dans la liste lst NON VIDE""" return (lst, i) # on crée un tuple-case (quelle liste ?, quel indice ?)
1 2 3 4 5 6
def contenu(c:'Case') -> 'Elément': """Renvoie le contenu de la case valide fournie""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return lst[i] # on renvoie l'élément stocké à cet endroit
1 2 3 4 5
def successeur(c:'Case NON FIN') -> 'Case': """Renvoie le case qui succède à la case fournie (qui ne doit pas être la dernière)""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return (lst, i+1) # on crée un tuple-case correspond à la case suivante

...CORRECTION...

Les coûts sont CONSTANTS puisqu'on ne fait que lire deux cases de tuple ou créer un tuple à deux cases à l'aide d'opérations à coût constant. Et le nombre de ces actions ne dépend pas du nombre d'éléments n dans la liste.

09-B° Créer la fonction predecesseur() qui doit renvoyer la case précédente. PRECONDITION : la case ne doit pas être la Tête de liste.

1 2 3
def predecesseur(c:'Case NON TETE') -> 'Case': """Renvoie le case qui précède la case fournie (qui ne doit pas être la première)""" return c # fausse réponse (du bon type) du prototype

...CORRECTION...

1 2 3 4 5
def predecesseur(c:'Case Non Tête') -> 'Case': """Renvoie le case qui précède la case fournie (qui ne doit pas être la première)""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return (lst, i-1) # on crée un tuple-case correspond à la case précédente
1.7 Coûts des primitives d'une liste contiguë basée sur des tableaux statiques

Contrairement à la Liste couple (Tête, Queue), les primitives de la Liste Contiguë tableau statique ne sont pas toutes des fonctions à coût constant.

  1. CST O(1)   nouvelle_liste_vide() → Liste VIDE
  2. CST O(1)   est_liste_vide(lst:Liste) → bool
  3. CST O(1)   longueur(lst:Liste) → int
  4. LIN θ(n)   inserer(lst:Liste, elt:Elément, i:int) → Liste
  5. LIN θ(n)   supprimer(lst:Liste Non Vide, i:int) → Liste
  6. CST O(1)   acces(lst:Liste Non Vide, i:int) → Case
  7. CST O(1)   contenu(c:Case) → Elément
  8. CST O(1)   successeur(c:Case Non Finale) → Case
  9. CST O(1)   ieme(lst:Liste Non Vide, i:int) → Element

Avantage : l'accès est TOUJOURS à coût CONSTANT, quelque soit l'endroit.

Problème majeur : l'insertion et la suppression de données est TOUJOURS à coût LINEAIRE, quelque soit l'endroit où on veut les insérer. Donc en θ(n), pas de meilleur ou pire des cas. On ne peut donc pas obtenir une primitive a coût constant sur au moins l'une des extrémités.

C'est la raison pour laquelle cette implémentation n'est jamais utilisée. En réalité, quelque soit l'utilisation qu'on veut faire de nos données, il existe une meilleure implémentation pour les gérer.

1.8 Concaténation de deux listes contiguës statiques

Penchons nous sur la concaténation de deux listes statiques. Imaginons qu'on dispose des deux listes suivantes :

  • la première a 20 000 éléments
  • la seconde 20 000 éléments également.

Avec la liste chaînée tuple (tête, queue), il ne faudrait que 20000 opérations liées à la taille de gauche : on rajoute une à une les valeurs de la liste de gauche dans la liste de droite.

Si on désire insérer la deuxième liste après la première liste, cela risque d'être compliqué avec des tableaux :


D'abord, il faut réserver une nouvelle place mémoire de 40 000 places :

Création d'un nouveau tableau

Ensuite, il faut déplacer un par un les 20 000 éléments du premier tableau.

Déplacement des éléments de A

Puis on déplace les 20 000 éléments du deuxième tableau.

Déplacement des éléments de B

Au total, 40 000 opérations. Le double de la liste tuple.

Et imaginez les performances sur la concaténation de 10 éléments à gauche et 1 milliard à droite :

  • 10 opérations pour la liste-tuple
  • 1 milliard et 10 opérations pour la liste-tableau statique.

Pas terrible.

Au vu des coûts assez médiocres, nous n'allons pas aller au bout de ce module comme le précédent. Il va par contre falloir lire et comprendre ces fonctions. Lors du DS, je peux vous demander de recréer telle ou telle fonction.

MODULE Liste Contiguë statique (ATTENTION, c'est une mauvaise implémentation)

Ce module devra être nommé liste_contigue_statique.py.

Vous remarquerez que inserer_tete() a bien été recréée comme une fonction utilisant les primitives plutôt qu'une fonction manipulant directement les données.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 400 401 402
"""INTERFACE d'une structure de données IMMUABLE Liste Contiguë Description des types non natifs utilisés dans les fonctions + Element : le type des éléments disponibles dans votre Liste si elle est homogène. + Case : la référence d'une case : un tuple (Liste, indice) + Liste : la référence d'un tableau statique Opérations primitives de la Liste contiguë ------------------------------------------ + CST O(1) nouvelle_liste_vide() -> Liste VIDE + CST O(1) est_liste_vide(lst:Liste) -> bool + CST O(1) longueur(lst:Liste) -> int + CST O(1) ieme(lst:Liste NON VIDE, i:int VALIDE) -> Elément + LIN θ(n) inserer(lst:Liste, elt:Element, i:int) -> Liste NON VIDE + LIN θ(n) supprimer(lst:Liste NON VIDE, i:int) -> Liste + CST O(1) acces(lst:Liste NON VIDE, i:int VALIDE) -> Case + CST O(1) contenu(c:Case) -> Element + CST O(1) successeur(c:Case NON FIN) -> Case Fonctions d'interface supplémentaires ------------------------------------- + LIN θ(n) inserer_tete(lst:Liste, elt:Element) -> Liste NON VIDE + LIN θ(n) supprimer_tete(lst:Liste NON VIDE) -> Liste + CST O(1) acces_tete(lst:Liste NON VIDE) -> Case + CST O(1) premier(lst:Liste NON VIDE) -> Element + LIN θ(n) inserer_fin(lst:Liste, elt:Element) -> Liste NON VIDE + LIN θ(n) supprimer_fin(lst:Liste NON VIDE) -> Liste + CST O(1) acces_fin(lst:Liste NON VIDE) -> Case + CST O(1) dernier(lst:Liste NON VIDE) -> Element + LI θ(ng+nd) concatener(gauche:Liste, droite:Liste) -> Liste + LIN O(n) rechercher(lst:Liste, elt:Element) -> int + representer_liste(lst:Liste) -> str + LIN θ(n) liste(x:list|tuple|int|float|str) -> Liste + LIN θ(n) copier(lst:Liste) -> Liste + CST O(1) predecesseur(c:Case NON TETE) -> Case """ # Importation : aucune # Déclaration des CONSTANTES : aucune # Déclaration des opérations primitives def nouvelle_liste_vide() -> 'Liste VIDE': """Renvoie une liste vide""" return [] def est_liste_vide(lst:'Liste') -> bool: """Prédicat qui renvoie True si la liste est vide""" return lst == [] def longueur(lst:'Liste') -> int: """Renvoie le nombre d'éléments stockés dans lst""" return len(lst) def ieme(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Element': """Renvoie le contenu de la Case de position i VALIDE dans la liste NON VIDE lst""" return lst[i] def inserer(lst:'Liste', elt:'Elément', i:'int VALIDE') -> 'Liste NON VIDE': """Renvoie une Liste basée sur lst où elt est en position VALIDE i""" nb = len(lst) + 1 nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec une case en plus for k in range(0, i): # 2 - copie les éléments des indices 0 à i-1 sans décalage nouveau[k] = lst[k] for k in range(i, len(lst)): # 3 - décalage à droite des éléments d'indice i et plus nouveau[k+1] = lst[k] nouveau[i] = elt # 4 - elt à la position i return nouveau def supprimer(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la case i""" nb = len(lst) - 1 nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec une case en moins for k in range(0, i): # 2 - copie les éléments des indices 0 à i-1 sans décalage nouveau[k] = lst[k] for k in range(i+1, len(lst)): # 3 - décalage à gauche des éléments d'indice i+1 et plus nouveau[k-1] = lst[k] return nouveau def acces(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Case': """Renvoie la référence de la case d'indice i dans la liste lst NON VIDE""" return (lst, i) # on crée un tuple-case (quelle liste ?, quel indice ?) def contenu(c:'Case') -> 'Elément': """Renvoie le contenu de la case valide fournie""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return lst[i] # on renvoie l'élément stocké à cet endroit def successeur(c:'Case NON FIN') -> 'Case': """Renvoie le case qui succède à la case fournie (qui ne doit pas être la dernière)""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return (lst, i+1) # on crée un tuple-case correspond à la case suivante # Déclaration des fonctions d'interface supplémentaires def inserer_tete(lst:'Liste', elt:'Elément') -> 'Liste NON VIDE': """Renvoie une Liste basée sur lst où elt est en Tête""" return inserer(lst, elt, 0) def supprimer_tete(lst:'Liste NON VIDE') -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la Tête""" return supprimer(lst, 0) def acces_tete(lst:'Liste NON VIDE') -> 'Case': """Renvoie la référence de la case de tête dans lst NON VIDE""" return acces(lst, 0) def insererFin(lst:'Liste', elt:'Elément') -> 'Liste NON VIDE': """Renvoie une Liste basée sur lst où elt est en Fin""" return inserer(lst, elt, longueur(lst)) def supprimerFin(lst:'Liste NON VIDE') -> 'Liste': """Renvoie une Liste basée sur lst NON VIDE en supprimant la fin""" return supprimer(lst, longueur(lst) - 1) def accesFin(lst:'Liste NON VIDE') -> 'Case': """Renvoie la référence de la case de fin dans lst NON VIDE""" return acces(lst, longueur(lst) - 1) def premier(lst:'Liste NON VIDE') -> 'Element': """Renvoie la valeur de tête de lst NON VIDE""" return ieme(lst, 0) def dernier(lst:'Liste NON VIDE') -> 'Element': """Renvoie la valeur de fin de lst NON VIDE""" return ieme(lst, longueur(lst) - 1) def representer_liste(lst:'Liste') -> str: """Renvoie une représentation de la Liste sous forme d'une séquence commençant par la tête""" return str(lst).replace(',',' -> ').replace('[','').replace(']','') def concatener(gauche:'Liste', droite:'Liste') -> 'Liste': """Renvoie une nouvelle liste commençant par la tête de gauche vers la fin de droite""" nouveau = nouvelle_liste_vide() # 1 - nouvelle liste pour la concaténation ng = longueur(gauche) for i in range(ng): # 2 - on copie les éléments de gauche nouveau = inserer(nouveau, ieme(gauche, i), i) nd = longueur(droite) for i in range(nd): # 3 - on copie les éléments de droite nouveau = inserer(nouveau, ieme(droite, i), ng + i) return nouveau def concatener_version_primitive(gauche:'Liste', droite:'Liste') -> 'Liste': """Renvoie une nouvelle liste commençant par la tête de gauche vers la fin de droite""" nb = len(gauche) + len(droite) nouveau = [None for _ in range(nb)] # 1 - nouveau tableau avec autant de cases que les deux autres for i in range(0, len(gauche)): # 2 - on copie les éléments de gauche nouveau[i] = gauche[i] ng = len(gauche) # on récupère le nb d'élémnets à gauche for i in range(0, len(droite)): # 3 - on copie les éléments de droite nouveau[ng+i] = droite[i] return nouveau def rechercher(lst:'Liste', elt:'Element') -> int: """Renvoie la position éventuelle de elt dans lst, -1 si non trouvé""" for i in range(longueur(lst)): # Pour chaque indice valide de la liste lst if ieme(lst, i) == elt: # si la case i contient l'élément cherché return i # répondre en renvoyant l'indice de la case return -1 # si on arrive ici, c'est qu'on a pas trouvé def liste(x) -> 'Liste': """Renvoie une liste générée à partir de x""" nouvelle = nouvelle_liste_vide() if type(x) == list or type(x) == tuple: for i in range(len(x)-1, -1, -1): nouvelle = [v for v in x] elif type(x) == dict: for couple in dict.items(): nouvelle = inserer_tete(nouvelle, couple) elif type(x) == int or type(x) == float or type(x) == str or type(x) == bool: nouvelle = inserer_tete(nouvelle, x) return nouvelle def copie(lst:'Liste') -> 'Liste': """Renvoie une copie peu profonde de la liste lst envoyée""" return [v for v in lst] def predecesseur(c:'Case NON TETE') -> 'Case': """Renvoie le case qui précède la case fournie (qui ne doit pas être la première)""" lst = c[0] # on récupère la liste contenant notre case i = c[1] # on récupère l'indice de notre case return (lst, i-1) # on crée un tuple-case correspond à la case précédente # Instructions du programme principal if __name__ == '__main__': # Ces fonctions ne seront en mémoire qu'en lancement direct def tester_inserer_tete(): a = nouvelle_liste_vide() a = inserer_tete(a, 5) assert a == [5] a = inserer_tete(a, 2) assert a == [2, 5] b = inserer_tete(a, 20) assert a == [2, 5] assert b == [20, 2, 5] print('Test OK pour inserer_tete()') def tester_supprimer_tete(): a = nouvelle_liste_vide() a = inserer_tete(a, 5) a = inserer_tete(a, 2) b = supprimer_tete(a) assert a == [2, 5] assert b == [5] c = supprimer_tete(b) assert est_liste_vide(c) print('Test OK pour supprimer_tete()') def tester_acces_tete(): a = nouvelle_liste_vide() a = inserer_tete(a, 5) a = inserer_tete(a, 2) assert acces_tete(a) == ([2, 5], 0) print('Test OK pour acces_tete()') def tester_contenu(): a = nouvelle_liste_vide() a = inserer_tete(a, 5) b = inserer_tete(a, 2) assert contenu(acces_tete(a)) == 5 assert contenu(acces_tete(b)) == 2 print('Test OK pour contenu()') def tester_successeur(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) d = inserer_tete(c, 20) assert successeur(acces_tete(d)) == ([20, 2, 5], 1) assert successeur(acces_tete(c)) == ([2, 5], 1) print('Test OK pour successeur()') def tester_longueur(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) assert longueur(a) == 0 assert longueur(b) == 1 assert longueur(c) == 2 print('Test OK pour longueur()') def tester_acces(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) assert acces(b, 0) == ([5], 0) assert acces(c, 0) == ([2, 5], 0) assert acces(c, 1) == ([2, 5], 1) print('Test OK pour acces()') def tester_ieme(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) assert ieme(b, 0) == 5 assert ieme(c, 0) == 2 assert ieme(c, 1) == 5 print('Test OK pour lire()') def tester_inserer(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) d = inserer(c, 20, 1) assert d == [2, 20, 5] print('Test OK pour inserer()') def tester_supprimer(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) d = inserer(c, 20, 1) e = supprimer(d, 2) assert d == [2, 20, 5] assert e == [2, 20] print('Test OK pour supprimer()') def tester_concatener(): a = nouvelle_liste_vide() b1 = inserer_tete(a, 5) b2 = inserer_tete(b1, 2) b3 = inserer_tete(b2, 50) c1 = inserer_tete(a, 20) c2 = inserer_tete(c1, 200) d = concatener(b3, c2) assert d == [50, 2, 5, 200, 20] print('Test OK pour concatener()') def tester_rechercher(): a = nouvelle_liste_vide() b = inserer_tete(a, 5) c = inserer_tete(b, 2) d = inserer(c, 20, 1) assert rechercher(d, 30) == -1 assert rechercher(d, 20) == 1 assert rechercher(d, 2) == 0 assert rechercher(d, 5) == 2 print('Test OK pour rechercher()') def tester(): # Tous les tests tester_inserer_tete() tester_supprimer_tete() tester_acces_tete() tester_contenu() tester_successeur() tester_longueur() tester_acces() tester_ieme() tester_inserer() tester_supprimer() tester_concatener() tester_rechercher() print("Module lancé directement, il ne s'agit pas d'une importation") tester()

10° Lire et comprendre les fonctions non encore vues pour cette implémentation.

Estimer le coût pour chacune d'entre elles.

  • Coût de premier() ?
  • Coût de dernier() ?
  • Coût de concatener() en fonction de ng et nd, nombre d'éléments à gauche et à droite ?
  • Coût de rechercher() ?

Un peu d'aide ?

Il faut se souvenir de la formule d'Euler :

Formule de la série

Ou encore :

Formule de la série formule 1'

Une somme d'opérations de ce type mène donc à un coût quadratique.

...CORRECTION...

  • Coût de premier() ? Constant.
  • Coût de dernier() ? Constant.
  • Coût de concatener() en fonction de ng et nd ? C'est plus compliqué.

    On ramène le premier élément et on crée un tableau de 1 élément (en 1 action élémentaire).

    On ramène le deuxième élément et on crée un tableau de 2 éléments (en 2 actions élémentaires).

    Ensuite, on ramène un encore élément et on crée un tableau de 3 éléments (en 3 actions élémentaires).

    Puis un autre (en 4 actions élémentaires) jusqu'à avoir récupérer toutes les valeurs des deux tableaux.

    On doit faire la somme de 1 + 2 + 3 +.... (ng+nd) opérations.

    Le coût est donc quadratique en θ( (ng+nd)2 ) !

  • Coût de rechercher() ? Linéaire, en 𝞞(n)

11° Observer la fonction concatener_version_primitive() qui manipule directement le type list de Python.

Quel est son coût ?

Quel est l'avantage de cette version ?

Quel est le désavantage de cette version ?

...CORRECTION...

  • Cette fois, on voit que le coût est linéaire et la complexité en θ(ng+nd)
  • Avantage : linéaire, c'est beaucoup mieux que quadratique
  • Désavantage : cette fonction n'utilise pas les primitives mais interagit seule avec les données. Si on change la gesion interne, il faut modifier cette fonction ci également, sinon toutes les fonctions qui en dépendent ne fonctionneront plus !.

Conclusion : la gestion des concaténations devraient être gérées par le concepteur d'une structure de données. Pas toujours facile d'être efficace en manipulant juste les primitives.

12° Un utilisateur observant sa liste avec representer_liste() peut-il savoir qu'on travaille avec une implémentation tableau statique ?

...CORRECTION...

Non, un utilisateur ne pourra pas depuis l'extérieur savoir comment notre implémentation est réalisée. L'intérêt pour lui étant que son programme n'aura pas à être modifié même si l'implémentation change mais que les fonctions d'interface restent inchangées.

2 - Implémentation en utilisant directement list de Python

Connaissance au programme de NSI : vous devez savoir que le type list de Python n'est pas une liste chaînée.

Nous allons voir aujourd'hui ce qui se cache derrière ce type list de Python : une liste contiguë dynamique (on peut faire varier le nombre de cases) et muable.

Nous allons l'utiliser pour réaliser une implémentation mais vous allez constater qu'elle est inutile : chaque fonction d'interface utilise juste une méthode déjà présente pour le type list de Python.

Le but de cette activité est donc de vous permettre d'avoir du recul sur ce type natif et de comprendre son comportement en terme de liste.

2.1 - Le tableau dynamique de Python (hors programme mais utile)

A - Le type list de Python

Le tableau statique n'est pas une très bonne implémentation de liste contiguë : la moindre insertion ou suppresion est à coût linéaire quelque soit l'endroit d'intervention, puisque la complexité est θ(n).

Nous allons maintenant voir ce qu'est réellement le type list de Python : un tableau mutable dynamique. C'est un tableau mais :

  • On peut insérer à coût constant à la fin, en utilisant append()
  • On peut supprimer à coût constant à la fin, en utilisant pop()
B - Le principe de l'insertion dans un tableau dynamique

Voici comment on parvient à agir à coût constant avec la fin du tableau : on crée un tableau possèdant plus de cases que nécessaire.

Imaginons qu'on veuille initialement créer un tableau de 5 cases :

>>> t = [1, 3, 4, 1, 2]

Python réserve en réalité le double de cases : il vous en faut 5, l'interpréteur va générer un tableau STATIQUE de 10 cases. Mais les indices 05 à 09 sont juste vides. Si vous demandez la taille du tableau, on vous répondra bien 5 car le dernier indice utilisé est 4.

>>> len(t) 5

Rajouter un élément est donc facile : il suffit de le placer sur l'indice 5 (qui existe déjà) et de décaler la valeur du dernier indice réellement utilisé.

>>> t.append(3)

En mémoire, on a juste décalé de 1 la valeur maximale de l'indice possible et on place le 3 dans cette case finale. L'opération se fait donc à temps CONSTANT.

Si on continue à rajouter des choses, nous allons aboutir à un tableau totalement rempli.

>>> t.append(1) >>> t.append(9) >>> t.append(18) >>> t.append(8)

Problème : notre tableau dynamique est plein !

Pourtant, on peut continuer à rajouter des éléments... Comment est-ce que cela est possible ?

>>> t.append(7)

Le rajout se fait en deux temps.

ETAPE 1 : Python crée un nouveau tableau deux fois plus grand (on passe de 10 à 20 cases sur notre exemple).

ETAPE 2 : Python copie le contenu, case par case puis rajoute la nouvelle valeur dans la nouvelle valeur maximale d'indice !

Cette fois, l'insertion a été faite à coût LINEAIRE, puisqu'il a fallu recopier les valeurs avant de parvenir à mettre le 7 en fin de tableau.

Par contre, insérer encore un élément se fera à nouveau à coût constant : il suffit de décaler l'indice maximum et d'y placer le nouvel élément.

>>> t.append(5)

CONCLUSION : l'insertion n'est pas exactement à coût CONSTANT puisqu'une fois de temps à autre, l'insertion sera à coût LINEAIRE.

Mais, en moyenne, c'est quasiment LINEAIRE.

Exemple avec un tableau initialement vide.

  • 1er insertion : création d'un tableau de 1 case.
  • 2e insertion : tableau plein, on double à 2 cases, linéaire (2).
  • 3e insertion : tableau plein, on double à 4 cases, linéaire (4)
  • 4e insertion : coût constant (1 au total)
  • 5e insertion : tableau plein, on double à 8 cases, linéaire (8).
  • 6e insertion : coût constant
  • 7e insertion : coût constant
  • 8e insertion : coût constant
  • 9e insertion : tableau plein, on double à 16 cases, linéaire (16).
  • 10e insertion : coût constant
  • 11e insertion : coût constant
  • 12e insertion : coût constant
  • 13e insertion : coût constant
  • 14e insertion : coût constant
  • 15e insertion : coût constant

Si on calcule le nombre d'opérations nécessaires pour insérer les 15 données en moyenne, il faut calculer ceci :

n = (1+2+4+1+8+1+1+1+16+1+1+1+1+1+1)/15 = 2.73

Si l'insertion était à coût linéaire, nous aurions eu par exemple :

n = (1+2+3+4+5+6+7+8+9+10+11+12+13+14+15)/15 = 8.00

C - Suppression dans un tableau dynamique

Pour la suppression, c'est encore plus simple : il suffit de décaler l'indice maximum d'une valeur vers la gauche. Même pas besoin de supprimer le contenu de la case, on la rend juste inaccessible pour l'utilisateur.

>>> t.pop() 5
L'essentiel

On peut insérer en fin de list Python avec un coût amorti constant avec append().

On peut supprimer en fin de list Python avec un coût amorti constant avec pop().

Maintenant vous savez vraiment pourquoi.

2.2 - Coûts de certaines méthodes de list

Vous avez maintenant compris que :

  • append() possède un coût amorti constant.
  • pop() possède un coût constant, si on l'utilise pour supprimer la fin.

Il reste à voir deux utilisations qui ont l'air "magique", mais qui ont un coût non constant...

Insertion de nouveaux éléments avec la méthode insert()

La méthode append() permet de rajouter EN FIN un nouvel élément à d'une list-Python.

Mais il existe également une méthode nommée insert() qui permet de faire la même chose MAIS en choisissant la position de l'insertion via un indice, comme notre fonction inserer().

Dans ce cas, il faut faire 3 choses :

  1. On augmente de 1 l'indice maximum.
  2. On décale à droite n-i valeurs : celles d'indice i à n-1, qu'on place dans les cases d'indice i+1 à n.
  3. On place la valeur à insérer dans la case i.

Coût de l'insertion en i

L'insertion est donc linéaire et la complexité en θ(n-i). On a donc du 𝞞(n).

1 2 3
def inserer(lst:'Liste', x:'Elément', i:'int VALIDE') -> None: """Modifie en place la liste en insérant l'élément elt à l'indice i fourni""" lst.insert(i, x)

La liste est muable, les données ont été modifiées directement en mémoire. Votre fonction n'a donc pas besion de renvoyer une nouvelle référence.

Vu la complexité, on comprend qu'il est plus difficile d'insérer près de la tête que de la fin. C'est l'inverse de la liste chaînée tuple.

Suppression d'un élément via la méthode pop()

La méthode nommée pop() fait deux choses : elle supprime une case ET renvoie l'élément correspondant.

La suppression de l'élément en position i se fait en plusieurs étapes :

  1. On décale à gauche n-i valeurs : celles d'indice i+1 à n-1, qu'on place dans les cases d'indice i à n-2.
  2. On diminue de 1 la valeur de l'indice maximum.

Coût de la suppression en i

La suppression est donc linéaire et la complexité en θ(n-i). On a donc du 𝞞(n).

Pour supprimer ET récupérer l'ancien élément d'indice 0 :

1
valeur_extraite = t.pop(0)

Pour juste supprimer l'ancien élément d'indice 0 sans le mémoriser :

1
t.pop(0)

Voici donc notre nouvelle fonction d'interface supprimer() :

1 2 3
def supprimer(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Elément': """Renvoie l'élément supprimé à l'indice i VALIDE dans lst NON VIDE""" return lst.pop(i)
Bilan

Si on résume :

  • append(elt)) : un coût amorti constant pour l'insertion en fin.
  • pop() : un coût constant pour la suppression en fin.
  • insert(i, elt) : un coût linéaire pour l'insertion, le pire des cas étant sur l'indice 0.
  • pop(i) : un coût linéaire pour la suppression, le pire des cas étant en sur l'indice 0.
2.3 - Version incomplète de la liste contiguë MUABLE basée sur le type list

Puisque cette version sera muable, notez bien que :

  • les différentes versions des primitives inserer...() ne renvoient rien puisque la liste est modifiée directement.
  • les différentes versions des primitives supprimer...() n'auraient rien à renvoyer et on en profite pour leur faire renvoyer l'élément qu'on vient de supprimer.

Voici les opérations primitives qu'on sélectionne classiquement pour manipuler une Liste contiguë MUABLE :

  1. nouvelle_liste_vide() → Liste VIDE
  2. est_liste_vide(lst:Liste) → bool
  3. longueur(lst:Liste) → int
  4. inserer_fin(lst:Liste, elt:Elément) → None
  5. supprimer_fin(lst:Liste NON VIDE) → Element

Comme avec la liste chaînée, nous introduirons rapidement une "fonction raccourci" nommée ici ieme() qui n'est pas une vraie primitive mais qui est bien pratique pour se passer des primitives liées à la gestion des cases. De cette façon, l'utilisateur de la liste n'a pas à se soucier des cases lui-même.

  1. ieme(lst:Liste NON VIDE, i:int VALIDE) → Element

Il reste bien entendu les 3 fonctions usuelles d'interaction avec les Cases.

  1. acces(lst:Liste NON VIDE, i:int VALIDE) → Case
  2. contenu(c:Case) → Elément
  3. successeur(c:Case NON FIN) → Case
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
"""Implémentation d'une LISTE MUABLE en utilisant un tableau dynamique list-Python Description des primitives ------------------------------------ + CST O(1) nouvelle_liste_vide() -> Liste NON VIDE + CST O(1) est_liste_vide(lst:Liste) -> bool + CST O(1) longueur(lst:Liste) -> int + CST O(1) ieme(lst:Liste NON VIDE, i:int VALIDE) -> Elément + CST O(1) inserer_fin(lst:Liste, elt:Elément) -> None + CST O(1) supprimer_fin(lst:Liste NON VIDE) -> Elément Description d'autres fonctions' ------------------------------ + LIN O(n-i) inserer(lst:Liste, elt:Elément, i:int VALIDE) -> None + LIN O(n-i) supprimer(lst:Liste NON VIDE, i:int VALIDE) -> Elément + representer_liste(lst:Liste) -> str """ def nouvelle_liste_vide() -> 'Liste VIDE': """Renvoie une liste vide""" return [] def est_liste_vide(lst:'Liste') -> bool: """Renvoie True si la liste est vide""" return lst == [] def longueur(lst:'Liste') -> int: """Renvoie le nombre de cases utilisées dans le tableau""" return len(lst) def ieme(lst:'Liste NON VIDE', i:int) -> 'Element': """Renvoie la valeur stockée à l'indice VALIDE voulu de lst NON VIDE""" return lst[i] def inserer_fin(lst:'Liste', elt:'Elément') -> None: """Modifie en place la liste en insérant l'élément en fin de liste""" lst.append(elt) def supprimer_fin(lst:'Liste NON VIDE') -> 'Element': """Renvoie l'élément de fin supprimé dans lst NON VIDE""" return lst.pop() def inserer(lst:'Liste', elt:'Elément', i:'int VALIDE') -> None: """Modifie en place la liste en insérant l'élément à l'indice fourni""" lst.insert(i, elt) def supprimer(lst:'Liste NON VIDE', i:'int VALIDE') -> 'Element': """Renvoie l'élément supprimé à l'indice VALIDE dans lst NON VIDE""" return lst.pop(i) def representer_liste(lst:'Liste'): """Renvoie une représentation de la Liste sous forme d'une séquence commençant par la tête""" return str(lst).replace(',',' -> ').replace('[','').replace(']','')

13° Placer cette nouvelle implémentation en mémoire : cette fois, elle utilise directement les méthodes du type list de Python pour créer une LISTE MUABLE.

Réalisation à faire : utiliser des instructions en console pour créer une liste dont la tête est 200 et les éléments suivants 100 et 10. Attention : la liste est muable et les fonctions d'interface ne s'utilisent pas de la même façon en MUTABLE et en IMMUABLE.

14° Utiliser representer_liste() pour observer le contenu de votre liste muable. L'utilisateur peut savoir que la liste est muable puisqu'il a dû l'utiliser. Peut-il savoir que les données de la liste sont stockées sous forme d'un tableau dynamique ?

...CORRECTION...

Non, un utilisateur ne pourra toujours pas savoir comment notre implémentation est réalisée. Normalement, à ce stade des activités d'implémentation, vous devriez avoir compris :o)

Remarquez bien que certaines fonctions permettent de voir la nature muable de cette implémentation. Notamment inserer() qui renvoie None.

15° Réaliser la fonction concatener() qui devra renvoyer une nouvelle liste. Ni la liste gauche, ni la liste gauche ne devront avoir été donc modifiées.

Prototype

def concatener(gauche:'Liste', droite:'Liste') -> 'Liste':

Votre fonction devra avoir un coût LINEAIRE par rapport au nombre d'éléments de gauche et de droite et devra uniquement manipuler les primitives et pas le type list directement.

...CORRECTION...

1 2 3 4 5 6 7
def concatener(gauche:'Liste', droite:'Liste') -> 'Liste': nouveau = nouvelle_liste_vide() for i in range(longueur(gauche)): inserer_fin(nouveau, ieme(gauche, i)) for i in range(longueur(droite)): inserer_fin(nouveau, ieme(droite, i)) return nouveau

16° Même question mais en manipulant directement le type list. Vous devriez obtenir le même coût.

Prototype

def concatener(gauche:'Liste', droite:'Liste') -> 'Liste':

Votre fonction devra avoir un coût LINEAIRE par rapport au nombre d'éléments de gauche et de droite et devra uniquement manipuler uniquement les primitives, pas le type list directement.

...CORRECTION...

1 2 3 4 5 6 7
def concatener_primitive(gauche:'Liste', droite:'Liste') -> 'Liste': nouveau = [] for i in range(len(gauche)): nouveau.append(gauche[i]) for i in range(len(droite)): nouveau.append(droite[i]) return nouveau
CONCLUSION sur le type list Python

Chaque fonction codant une primitive ne possède qu'une seule instruction. Comme vous le voyez, il est plutôt inutile de vouloir créer une interface pour gérer une liste contiguë muable en utilisant le type list de Python : pourquoi recréer des fonctions d'interface alors que le créateur de Python a justement créer le type list avec cette idée en tête. De plus, beaucoup d'opérations disponibles sur le type list de Python sont optimisées (tri, concaténation...)

Néanmoins, vous en savez maintenant beaucoup plus sur cette structure de données native de Python.

Par contre, je rappelle la connaissance à avoir pour l'épreuve du BAC : vous devez savoir que le type list de Python n'implémente pas une liste chaînée mais bien un tableau dynamique.

3 - FAQ

On peut avoir le module déjà réalisé ?

Vous pouvez maintenant directement télécharger ce module :

MODULE LISTE CONTIGUE TABLEAU STATIQUE

MODULE LISTE CONTIGUE MUABLE TABLEAU DYNAMIQUE

On m'a dit de faire attention aux copies de tableaux. C'est vrai ?

Oui. Si vous voulez obtenir une copie d'un tableau, ne faites surtout pas cela :

>>> a = [2, 40, 3] >>> b = a >>> b [2, 40, 3]

Pourquoi ? Ca a l'air de marcher !

Oui mais non. La variable a ne contient pas les DONNEES du tableau mais son ADRESSE-MEMOIRE (ou son équivalent en Python). Et donc b contient aussi en réalité cette adresse.

>>> id(a) 50505050 >>> id(b) 50505050 >>> id(a)

Du coup, a et b sont des alias des mêmes données. Si vous modifiez a, vous modifiez b.

>>> a [2, 40, 3] >>> b [2, 40, 3] >>> a[0] = 1000 >>> b [1000, 40, 3]

Comment faire alors ?

Si votre tableau ne contient que des données immuables, vous pouvez recréer un tableau par compréhension :

>>> a = [2, 40, 3] >>> b = [valeur for valeur in a] >>> b [2, 40, 3] >>> a[0] = 1000 >>> a [1000, 40, 3] >>> b [2, 40, 3]

Si votre tableau contient des données muables, le mieux est encore d'utiliser le module copy et sa fonction deepcopy() de copie profonde.

Exemple avec a qui est un tableau contenant des tableaux

>>> import copy >>> a = [[4,2,0], [40,20,0], [3,4,8]] >>> b = copy.deepcopy(a) >>> b [[4,2,0], [40,20,0], [3,4,8]] >>> a[0][2] = 1000 >>> a [[4, 2, 1000], [40, 20, 0], [3, 4, 8]] >>> b [[4, 2, 0], [40, 20, 0], [3, 4, 8]]

Nous venons donc de voir clairement qu'un utilisateur ne peut pas savoir comment est implémenté une structure de données dont on lui fournit juste l'interface.

Par contre, chaque implémentation va avoir des avantages et des défauts.

  • Version 1 Tuple (tête, queue) : bien si on désire souvent manipuler uniquement les têtes, mais coûts linéaires en lecture, insertion et suppression ailleurs.
  • Version 2 tableaux : coût constant en lecture n'importe où mais coût linéaire en insertion et suppression.
  • Version 3 list Python : comme au dessus mais les insertions et suppressions en fin de liste sont à coût constant.

La fois prochaine, nous verrons une dernière implémentation du type abstrait LISTE : la liste chaînée muable en version programmation objet.

Activité publiée le 14 10 2020
Dernière modification : 23 10 2020
Auteur : ows. h.