Séance 1 — ADT et interfaces Java

Listes, piles, files et deques~: un contrat, plusieurs réalisations

Dr. El Hadji Bassirou TOURÉ

Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar

Objectifs de la séance

Ce que la séance construit

Le geste qui ouvre le cours : séparer ce qu’une structure promet (le type abstrait, écrit en interface Java générique) de la manière dont elle tient sa promesse (tableau, nœuds chaînés, tableau circulaire). Chaque réalisation est déroulée sur le code Java du cours, pas à pas, et ses coûts sont comptés.

  • Distinguer un type abstrait de données (ADT) d’une structure de données, et l’écrire en interface générique.
  • Réaliser l’ADT List par un tableau redimensionnable et par des nœuds chaînés ; compter décalages, copies et références suivies.
  • Établir le coût amorti du doublement : moins de 2 copies par ajout.
  • Parcourir une liste par un itérateur (Iterable, boucle for-each).
  • Vérifier un parenthésage et évaluer une expression postfixe avec une pile.
  • Réaliser une file et un deque sur un tableau circulaire, sans décalage.
  • Choisir l’ADT d’après le besoin, puis la structure d’après les opérations fréquentes.

Une promesse, pas une construction

Au point Wave de Colobane, le premier arrivé est servi le premier, un nouveau client se place à la fin : la règle se connaît sans savoir si les clients sont debout, assis ou inscrits sur un cahier.

Type abstrait de données (ADT)

Un ensemble de valeurs et d’opérations, spécifiées par leur comportement (ce qu’elles produisent), non par leur programmation.

Structure de données

Une réalisation concrète d’un ADT : une disposition en mémoire et les algorithmes de chaque opération.

Un même ADT admet plusieurs structures : le contrat ne change pas, le coût des opérations change beaucoup. C’est la matière du cours.

L’interface générique Liste<T>

public interface Liste<T> extends Iterable<T> {
    void ajouter(T x);         // en fin
    void ajouter(int i, T x);  // au rang i
    T get(int i);              // l'element de rang i
    T supprimer(int i);        // retire le rang i
    int taille();
    boolean estVide();
}

Une interface Java énonce les opérations sans aucun corps de méthode : c’est le contrat. Le paramètre <T> est le type des éléments, fixé à l’usage : Liste<String> pour des noms, Liste<Integer> pour des montants.

extends Iterable<T> ajoute au contrat la méthode iterator(), qui permet le parcours par for-each (section 3).

Lecture. Les commentaires font partie du contrat : ajouter(x) écrit en fin, ajouter(i, x) au rang \(i\), de 0 à taille().

Un client, deux réalisations

static void inscrire(Liste<String> l) {
    l.ajouter("Dior");     // en fin
    l.ajouter(0, "Kine");  // au rang 0
    System.out.println(l.get(0) + " " + l.get(1) + " " + l.taille());
}
// dans main :
inscrire(new ListeTableau<>());
inscrire(new ListeChainee<>());

Résultat \(\rightarrow\)

Kine Dior 2
Kine Dior 2

Lecture. inscrire ne nomme que l’interface : elle reçoit une ListeTableau puis une ListeChainee, et donne la même sortie. Changer de réalisation ne change pas une ligne du client.

Une confusion fréquente

ArrayList et LinkedList ne sont pas deux ADT, mais deux réalisations de l’interface java.util.List. On déclare le besoin (List<String> l) et l’on ne nomme la classe qu’à la création (new ArrayList<>()).

Le compilateur, garant du contrat

public class ExErreur {
    public static void main(String[] args) {
        Liste<String> noms = new ListeTableau<>();
        noms.ajouter("Daba");
        String x = noms.get(0);  // aucun cast : le type est connu
        noms.ajouter(42);        // refuse par le compilateur
        System.out.println(x);
    }
}

Compilation \(\rightarrow\)

ExErreur.java:6: error: incompatible types: int cannot be converted to String
        noms.ajouter(42);        // refuse par le compilateur
                     ^
1 error

Lecture. Le type des éléments est vérifié à la compilation : get rend un String sans conversion, et ajouter(42) est refusé avant toute exécution, avec le numéro de la ligne fautive.

Exercice : contrat ou implémentation ?

Six affirmations

  1. dequeue() rend l’élément entré le premier.
  2. Les éléments sont rangés dans un tableau de 8 cases.
  3. pop() sur une pile vide lève NoSuchElementException.
  4. Quand le tableau est plein, sa capacité double.
  5. get(i) d’une ListeChainee suit \(i\) références.
  6. Après ajouter(0, x), get(0) rend x.
(a) (b) (c) (d) (e) (f)
contrat \(\times\) \(\times\) \(\times\)
implémentation \(\times\) \(\times\) \(\times\)

Correction

  • Critère : une clause du contrat s’observe par les opérations publiques, une clause d’implémentation ne s’observe pas.
  • Contrat : (a), (c), (f), ce que le client constate en appelant les méthodes.
  • Implémentation : (b), (d), (e), des choix internes, que le client ne voit pas.
  • Remarque : le doublement et le parcours se remarquent pourtant par le temps : le coût n’est pas dans le contrat (Séance 2).

Le tableau contigu : ListeTableau<T>

private Object[] donnees;  // cases 0 a taille - 1
private int taille;        // nombre d'elements
@SuppressWarnings("unchecked")
public T get(int i) {
    verifier(i, taille);
    return (T) donnees[i];  // acces direct
}

public void ajouter(T x) {
    ajouter(taille, x);     // en fin
}
public void ajouter(int i, T x) {
    verifier(i, taille + 1);
    if (taille == donnees.length) agrandir();
    for (int k = taille; k > i; k--) {
        donnees[k] = donnees[k - 1];
        decalages++;
    }
    donnees[i] = x;
    taille++;
}

Lecture. Les éléments occupent les cases \(0\) à \(\texttt{taille}-1\) : l’élément de rang \(i\) est en case \(i\), à l’adresse « base \(+\;i\times\) taille d’une case », et get y accède en un pas, quel que soit \(i\). Insérer au rang \(i\) décale les \(\texttt{taille}-i\) éléments suivants, du dernier au \(i\)-ième.

Insérer dans un tableau, pas à pas

Sortie \(\rightarrow\) 

[Malick, Yacine, Oumou, Adama, Fallou, null, null, null]

Lecture. Lignes 2, 10 : ajouter(1, "Oumou") sur la liste Yacine, Adama, Fallou : taille \(=3\), capacité 4, la case 3 est libre ; pas d’agrandissement.

Lecture. Lignes 3–4 : \(k=3\) : donnees[3] = donnees[2] ; Fallou passe de la case 2 à la case 3.

Lecture. Lignes 3–4 : \(k=2\) : donnees[2] = donnees[1] ; Adama passe de la case 1 à la case 2.

Lecture. Lignes 3, 6–7 : \(k=1\) : la boucle s’arrête ; Oumou est écrit en case 1 et taille passe à 4 : le tableau est plein. Deux décalages.

Lecture. Lignes 2, 11 : ajouter(0, "Malick") : taille \(=\) donnees.length \(=4\) : agrandir() alloue 8 cases et y recopie les 4 éléments.

Lecture. Lignes 3–4 : \(k=4\) : Fallou passe de la case 3 à la case 4.

Lecture. Lignes 3–4 : \(k=3\) : Adama passe de la case 2 à la case 3.

Lecture. Lignes 3–4 : \(k=2\) : Oumou passe de la case 1 à la case 2.

Lecture. Lignes 3–4 : \(k=1\) : Yacine passe de la case 0 à la case 1.

Lecture. Lignes 3, 6–7 : \(k=0\) : la boucle s’arrête ; Malick est écrit en case 0, taille \(=5\). Quatre décalages pour une insertion en tête.

Lecture. Ligne 12 : deux insertions : 6 décalages et 4 copies. Insérer au rang \(i\) décale les \(\texttt{taille}-i\) éléments qui suivent.

Agrandir et supprimer

private void agrandir() {
    Object[] neuf = new Object[2 * donnees.length];
    for (int k = 0; k < taille; k++) {
        neuf[k] = donnees[k];  // recopie complete
        copies++;
    }
    donnees = neuf;
    agrandissements++;
}
@SuppressWarnings("unchecked")
public T supprimer(int i) {
    verifier(i, taille);
    T x = (T) donnees[i];
    for (int k = i; k < taille - 1; k++) {
        donnees[k] = donnees[k + 1];
        decalages++;
    }
    taille--;
    donnees[taille] = null;  // case liberee
    return x;
}

Lecture. agrandir alloue deux fois plus de cases et recopie les taille éléments : une insertion coûte alors taille copies. supprimer(i) décale vers la gauche les éléments qui suivent, puis vide la dernière case pour que le ramasse-miettes récupère l’objet.

Le coût amorti du doublement

ListeTableau<Integer> t = new ListeTableau<>();  // capacite 4
long pire = 0;                                   // copies d'un seul ajout
for (int n = 1; n <= (1 << 20) + 1; n++) {
    long avant = t.copies;
    t.ajouter(n);
    pire = Math.max(pire, t.copies - avant);
    if (n == 1024 || n == 1025 || n == 1 << 20 || n == (1 << 20) + 1) {
        System.out.printf(Locale.ROOT, "%7d ajouts : %2d agrandissements, "
            + "%7d copies (%.3f par ajout), pire %7d%n",
            n, t.agrandissements, t.copies, (double) t.copies / n, pire);
    }
}

Résultat \(\rightarrow\)

   1024 ajouts :  8 agrandissements,    1020 copies (0.996 par ajout), pire     512
   1025 ajouts :  9 agrandissements,    2044 copies (1.994 par ajout), pire    1024
1048576 ajouts : 18 agrandissements, 1048572 copies (1.000 par ajout), pire  524288
1048577 ajouts : 19 agrandissements, 2097148 copies (2.000 par ajout), pire 1048576

Lecture. 1 025 ajouts : 2 044 copies, soit \(2n-6\) ; un seul ajout peut en coûter \(n-1\) (pire). Pour 1 048 577 ajouts, 2,000 est l’arrondi de 1,999 994.

Moins de deux copies par ajout

Coût amorti (CLRS, § 16.4 ; Sedgewick et Wayne, § 1.4)

Coût amorti : le coût total de \(n\) opérations, divisé par \(n\). Après \(n\geq5\) ajouts, la capacité est passée par \(4, 8, \dots, 2^{j}\), avec \(2^{j-1}<n\) : \(4+8+\dots+2^{j-1}=2^{j}-4\leq2n-6\) copies, moins de 2 par ajout.

Lecture. En ajoutant \(c\) cases à chaque fois, environ \(n^2\)\(/(2c)\) copies : le coût par ajout croît avec \(n\).

Quoi, pourquoi, quand ça échoue : le doublement

Quoi

Les éléments dans des cases contiguës ; quand le tableau est plein, un tableau deux fois plus grand reçoit une copie de tous les éléments.

Pourquoi on l’utilise

Accès par rang en un pas, ajout en fin à coût amorti constant (moins de 2 copies par ajout), une référence par case et rien d’autre. C’est la réalisation de java.util.ArrayList (qui agrandit de moitié : oldCapacity >> 1 dans le code d’OpenJDK 17) et du tas binaire de la Séance 4.

Quand ça échoue

Insertions et retraits en tête : chacun décale tout le tableau. Un ajout isolé peut coûter \(n-1\) copies, gênant quand chaque opération doit être rapide. Juste après un doublement, la moitié des cases est vide.

Les nœuds chaînés : ListeChainee<T>

private static class Noeud<T> {
    T valeur;
    Noeud<T> suivant;    // null : dernier noeud

    Noeud(T valeur, Noeud<T> suivant) {
        this.valeur = valeur;
        this.suivant = suivant;
    }
}

private Noeud<T> tete;   // null si vide
private Noeud<T> queue;  // dernier noeud
private int taille;
private Noeud<T> noeud(int i) {
    Noeud<T> x = tete;
    for (int k = 0; k < i; k++) {
        x = x.suivant;  // i references suivies
        parcourus++;
    }
    return x;
}

public T get(int i) {
    verifier(i, taille);
    return noeud(i).valeur;
}

Lecture. Chaque élément est rangé dans un nœud qui contient aussi une référence au nœud suivant ; les nœuds sont dispersés en mémoire. Atteindre le rang \(i\) oblige à suivre \(i\) références depuis la tête : le compteur parcourus les compte.

Insérer par les références

public void ajouter(int i, T x) {
    verifier(i, taille + 1);
    if (i == 0) {
        tete = new Noeud<>(x, tete);
        if (queue == null) queue = tete;
    } else if (i == taille) {
        queue.suivant = new Noeud<>(x, null);
        queue = queue.suivant;
    } else {
        Noeud<T> prec = noeud(i - 1);
        prec.suivant = new Noeud<>(x, prec.suivant);
    }
    taille++;
}

Lecture. En tête et en fin (grâce à queue), l’insertion écrit deux références sans rien parcourir. Au rang \(i\), elle suit \(i-1\) références jusqu’au nœud précédent, puis le rebranche. supprimer(i) contourne le nœud retiré de la même manière.

Insérer dans une liste chaînée, pas à pas

Sortie \(\rightarrow\) 

Lat

Lecture. Lignes 2, 13 : Sidy, Soda, Gora (nœuds de String, sans référence queue) : \(i=0\), insertion en tête.

Lecture. Lignes 3–4 : le nœud reçoit l’ancienne tête comme suivant, puis tete le désigne.

Lecture. Lignes 2, 6, 14 : ajouter(2, "Lat") : \(i\neq0\) ; prec part de la tête, Mame, au rang 0.

Lecture. Lignes 7–8 : \(k=1\) : prec suit une référence et atteint Sidy, au rang 1.

Lecture. Ligne 7 : \(k=2\) : \(2<2\) est faux : prec est le nœud de rang \(i-1=1\).

Lecture. Ligne 10 : Lat reçoit Soda comme suivant, puis prec.suivant désigne Lat.

Lecture. Ligne 15 : tete.suivant.suivant suit deux références et atteint Lat, au rang 2 ; l’insertion au rang 2 n’en a suivi qu’une (\(i-1\)).

Parcourir une liste : Iterable et for-each

public Iterator<T> iterator() {
    return new Iterator<T>() {
        private Noeud<T> courant = tete;

        public boolean hasNext() {
            return courant != null;
        }

        public T next() {
            if (courant == null) {
                throw new NoSuchElementException();
            }
            T x = courant.valeur;
            courant = courant.suivant;
            return x;
        }
    };
}
Liste<String> l = new ListeChainee<>();
l.ajouter("Daba");
l.ajouter("Mbene");
l.ajouter("Kine");
for (String s : l) {
    System.out.print(s + " ");
}
System.out.println();
Iterator<String> it = l.iterator();  // le for-each
while (it.hasNext()) {
    System.out.print(it.next().length() + " ");
}
System.out.println();

Résultat \(\rightarrow\)

Daba Mbene Kine 
4 5 4 

Lecture. for-each appelle iterator(), hasNext(), next() : \(n\) références, contre \(n(n-1)/2\) par get(k).

Les deux réalisations mesurées

for (int n : new int[] {1000, 4000}) {
    ListeTableau<Integer> t = new ListeTableau<>();
    ListeChainee<Integer> c = new ListeChainee<>();
    for (int k = 0; k < n; k++) {
        t.ajouter(0, k);  // insertion en tete
        c.ajouter(0, k);
    }
    for (int k = 0; k < n; k++) {
        t.get(k);         // chaque rang
        c.get(k);
    }
    System.out.println(n + " : tableau " + t.decalages + " decalages, "
        + t.copies + " copies ; chainee " + c.parcourus + " references");
}

Résultat \(\rightarrow\)

1000 : tableau 499500 decalages, 1020 copies ; chainee 499500 references
4000 : tableau 7998000 decalages, 4092 copies ; chainee 7998000 references

Lecture. En tête : \(n(n-1)/2\) décalages contre 0 ; get(k) : 0 contre \(n(n-1)/2\) références.

Exercice : une suite sur les deux listes

Capacité initiale 2

ListeTableau<>(2) et ListeChainee<>() reçoivent les opérations du tableau, dans l’ordre.

  1. Les cases de donnees (. : vide).
  2. Les décalages et les copies.
  3. La chaîne, de la tête à la queue.
  4. Les références suivies.
opération **donnees** déc. cop. chaîne réf.
ajouter(47) 47 . 0 0 47 0
ajouter(0, 12) 12 47 1 0 12 47 0
ajouter(1, 58) 12 58 47 . 1 2 12 58 47 0
ajouter(33) 12 58 47 33 0 0 12 58 47 33 0
supprimer(2) 12 58 33 . 1 0 12 58 33 1
get(2) 12 58 33 . 0 0 12 58 33 2
total 3 2 3

Correction

  1. ajouter(1, 58) : tableau plein, 4 cases, puis 47 décalé.
  2. 3 décalages, 2 copies ; capacité finale 4.
  3. Même ordre, sans case vide.
  4. supprimer(2) : 1 référence ; get(2) : 2.

L’ADT Stack et PileTableau<T>

public interface Pile<T> {
    void push(T x);  // empile
    T pop();         // depile le sommet
    T peek();        // lit le sommet
    boolean estVide();
    int taille();
}

Le dernier élément empilé est le premier dépilé (LIFO, last in, first out) ; aucune opération n’atteint le milieu. À la mairie de Rufisque, le dernier dossier posé sur la pile est le premier traité.

private Object[] donnees = new Object[16];
private int taille = 0;

public void push(T x) {
    if (taille == donnees.length) agrandir();
    donnees[taille] = x;  // au sommet
    taille++;
}

@SuppressWarnings("unchecked")
public T pop() {
    if (estVide()) throw new NoSuchElementException();
    taille--;
    T sommet = (T) donnees[taille];
    donnees[taille] = null;
    return sommet;
}

Lecture. Le sommet est la case \(\texttt{taille}-1\) : aucun décalage. Avec le sommet en case 0, chaque opération décalerait toute la pile.

Vérifier un parenthésage, pas à pas

Sortie \(\rightarrow\) 

true

Lecture. Lignes 2, 15 : l’expression a 17 caractères ; la pile des ouvrantes en attente est vide.

Lecture. Lignes 3–5 : caractère 0, { : ouvrante, empilée ; 1 en attente.

Lecture. Lignes 3–5 : caractère 3, [ : ouvrante, empilée (2 ignorés) ; 2 en attente.

Lecture. Lignes 3–5 : caractère 8, ( : ouvrante, empilée (4 ignorés) ; 3 en attente.

Lecture. Lignes 3, 6–9 : caractère 14, ) : pop() rend (, qui correspond (5 ignorés).

Lecture. Lignes 3, 6–9 : caractère 15, ] : pop() rend [, qui correspond.

Lecture. Lignes 3, 6–9 : caractère 16, } : pop() rend {, qui correspond.

Lecture. Ligne 12 : fin de l’expression : aucune ouvrante en attente, la méthode rend true.

Lecture. Ligne 15 : équilibrée : au plus 3 ouvrantes en attente, la profondeur d’imbrication.

Exercice : trois façons d’échouer

Quatre expressions

estEquilibree du pas à pas précédent.

  1. [(7+2)*3
  2. (4*[5+1)]
  3. 2+3)*(4
  4. {[1+2]*(3)}

Pour chacune : le verdict, la ligne qui rejette, la hauteur maximale de la pile.

expression verdict rejet hauteur max.
[(7+2)*3 false ligne 12 : pile non vide 2
(4*[5+1)] false [ligne 9 : ` et )` 2
2+3)*(4 false ligne 7 : pile vide 0
{[1+2]*(3)} true --- 2

Correction

  1. Aucune fermante pour [ : la boucle finit, return pile.estVide() rend false.
  2. ) dépile [, qui ne lui correspond pas : rejet ligne 9.
  3. ) arrive sur une pile vide : rejet ligne 7, avant même la lecture de (.
  4. Équilibrée ; au plus 2 ouvrantes en attente.

Évaluer une expression postfixe, pas à pas

Sortie \(\rightarrow\) 

1250

Lecture. Lignes 2, 15 : riz, 3 kg à 450 ; huile, 2 l à 1 200 ; billet de 5 000 FCFA : 9 jetons.

Lecture. Lignes 3–4, 8–9 : 5000 : un nombre, empilé ; 1 opérande en attente.

Lecture. Lignes 3–4, 8–9 : 450 : un nombre, empilé ; 2 opérandes en attente.

Lecture. Lignes 3–4, 8–9 : 3 : un nombre, empilé ; 3 opérandes en attente.

Lecture. Lignes 3–7 : * : b \(=3\), a \(=450\) ; \(450\times3=1350\) est empilé.

Lecture. Lignes 3–4, 8–9 : 1200 : un nombre, empilé ; 3 opérandes en attente.

Lecture. Lignes 3–4, 8–9 : 2 : un nombre, empilé ; 4 opérandes en attente.

Lecture. Lignes 3–7 : * : b \(=2\), a \(=1200\) ; \(1200\times2=2400\) est empilé.

Lecture. Lignes 3–7 : + : b \(=2400\), a \(=1350\) ; \(1350+2400=3750\) est empilé.

Lecture. Lignes 3–7 : - : b \(=3750\), a \(=5000\) ; \(5000-3750=1250\) est empilé.

Lecture. Ligne 12 : le seul élément restant est le résultat : 1 250 FCFA de monnaie.

Lecture. Ligne 15 : au plus 4 opérandes en attente ; un seul parcours, aucune parenthèse.

L’ordre des opérandes

int a = pile.pop();  // les deux depilements echanges
int b = pile.pop();
// dans main :
for (String e : new String[] {"1200 450 -", "1200 450 +", "6000 4 /"}) {
    int juste = Postfixe.evaluer(e);
    int faux = evaluerInverse(e);
    System.out.println(e + " : " + juste + " ; echanges : " + faux);
}

Résultat \(\rightarrow\)

1200 450 - : 750 ; echanges : -750
1200 450 + : 1650 ; echanges : 1650
6000 4 / : 1500 ; echanges : 0

Lecture. b reçoit le sommet, le second opérande : \(1\,200-450\), et non \(450-1\,200\). L’échange ne change rien pour + et *, commutatifs : un test de Postfixe contient une soustraction ou une division. La division entière donne \(4/6000=0\).

Exercice : une évaluation postfixe

Sur un billet de 2 500 FCFA

4 tickets de bus à 300 FCFA, le reste en pièces de 100 : 2500 300 4 * - 100 /.

  1. La pile après chaque jeton.
  2. Le résultat.
  3. La hauteur maximale de la pile.
  4. Le résultat si les deux dépilements sont échangés.
jeton 2500 300 4 * - 100 /
pile 2500 300
2500
4
300
2500
1200
2500
1300 100
1300
13

Correction

  1. Le jeton - dépile 1 200 puis 2 500 : \(2\,500-1\,200=1\,300\).
  2. \(1\,300/100=13\) pièces.
  3. 3, après le jeton 4.
  4. * donne encore 1 200, mais - calcule \(1\,200-2\,500=-1\,300\), puis \(100/(-1\,300)=0\).

L’ADT Queue et le tableau circulaire

public interface File<T> {
    void enqueue(T x);  // ajoute en fin
    T dequeue();        // retire la tete
    T peek();           // lit la tete
    boolean estVide();
    int taille();
}

Premier entré, premier sorti (FIFO, first in, first out). Avec la tête en case 0, chaque dequeue décalerait tous les clients restants : le tableau circulaire déplace l’indice de la tête, pas les éléments.

La fin se calcule : \((\texttt{tete}+\texttt{taille}) \bmod m\), où \(m\) est la capacité ; le modulo ramène la fin, puis la tête, en case 0.

private Object[] donnees;
private int tete = 0;    // prochain a servir
private int taille = 0;  // nombre d'elements
public void enqueue(T x) {
    if (taille == donnees.length) agrandir();
    int fin = (tete + taille) % donnees.length;
    donnees[fin] = x;
    taille++;
}

@SuppressWarnings("unchecked")
public T dequeue() {
    if (estVide()) throw new NoSuchElementException();
    T premier = (T) donnees[tete];
    donnees[tete] = null;
    tete = (tete + 1) % donnees.length;
    taille--;
    return premier;
}

La file circulaire, pas à pas

Sortie \(\rightarrow\) 

Sokhna Assane

Lecture. Lignes 2–3, 13 : Sokhna et Assane en cases 4, 5 ; Thierno en case \((4+2) \bmod 6=0\).

Lecture. Lignes 2–3, 14 : Abdou en case \((4+3) \bmod 6=1\) : cases 4, 5, 0, 1, sans déplacement.

Lecture. Lignes 6, 15 : premier reçoit la case 4 : Sokhna, arrivée la première.

Lecture. Ligne 7 : la case 4 est vidée : plus de référence vers Sokhna.

Lecture. Lignes 8–10 : tete avance en case 5, taille \(=3\) : aucun élément n’a bougé.

Lecture. Lignes 6–10, 15 : Assane sort, case 5 vidée ; tete \(=(5+1) \bmod 6=0\) : la tête boucle.

Lecture. Ligne 15 : Sokhna puis Assane, l’ordre d’arrivée : une case et un indice par opération.

Agrandir la file circulaire, pas à pas

Sortie \(\rightarrow\) 

[Arame, Ndiaga, Demba, Samba, Codou, null, null, null]

Lecture. Lignes 1–2, 10 : enqueue("Codou") sur une file pleine : tête en case 2, ordre d’arrivée Arame, Ndiaga, Demba, Samba ; agrandir() alloue 8 cases.

Lecture. Lignes 3–4 : \(k=0\) : la case \((2+0) \bmod 4=2\), Arame, est recopiée en case 0 du nouveau tableau.

Lecture. Lignes 3–4 : \(k=1\) : la case \((2+1) \bmod 4=3\), Ndiaga, est recopiée en case 1.

Lecture. Lignes 3–4 : \(k=2\) : la case \((2+2) \bmod 4=0\), Demba, est recopiée en case 2.

Lecture. Lignes 3–4 : \(k=3\) : la case \((2+3) \bmod 4=1\), Samba, est recopiée en case 3.

Lecture. Lignes 6–7 : le nouveau tableau remplace l’ancien et tete redevient 0 : l’ordre logique coïncide avec l’ordre des cases.

Lecture. Ligne 10 : enqueue reprend : la fin vaut \((0+4) \bmod 8=4\), Codou y est écrit, taille \(=5\).

Lecture. Ligne 11 : 4 copies, dans l’ordre logique : une copie dans l’ordre physique (System.arraycopy(donnees, 0, neuf, 0, taille)) aurait mis Demba en tête de file.

Quoi, pourquoi, quand ça échoue : le tableau circulaire

Quoi

Un tableau de \(m\) cases, un indice tete et un compteur taille ; les indices avancent modulo \(m\), et agrandir remet l’anneau à plat dans l’ordre logique.

Pourquoi on l’utilise

enqueue et dequeue écrivent une case et changent un indice : aucun décalage, et des copies en nombre amorti constant, comme pour ListeTableau. C’est la réalisation de java.util.ArrayDeque.

Quand ça échoue

Sans taille, la file vide et la file pleine donnent toutes deux \(\texttt{tete}=\texttt{fin}\) : il faut un compteur, ou sacrifier une case (le choix d’ArrayDeque). Le test tete < fin est faux dès que la file a bouclé. Une recopie dans l’ordre des cases (de 0 à \(\texttt{taille}-1\)) détruit l’ordre de sortie.

Exercice : l’arithmétique de l’anneau

Sept cases, tickets 501 à 508

Une FileCirculaire de capacité \(m=7\), avec tete \(=5\) et taille \(=4\) (tickets 501 à 504).

  1. Les cases occupées.
  2. La case écrite par chaque enqueue, puis tete et taille.
  3. L’effet de enqueue(508).
  4. tete et taille après deux dequeue.
opération case écrite tete taille capacité
enqueue(505) 2 5 5 7
enqueue(506) 3 5 6 7
enqueue(507) 4 5 7 7
enqueue(508) 7 0 8 14
dequeue() \(\times2\) --- 2 6 14

Correction

  1. \((5+k) \bmod 7\) pour \(k=0..3\) : cases 5, 6, 0, 1.
  2. \((5+4) \bmod 7=2\), puis 3 et 4 : la file est pleine.
  3. 7 copies dans l’ordre 501, …, 507, tete \(=0\), 508 en case 7.
  4. tete \(=2\), taille \(=6\) : 501 et 502 sont sortis.

L’ADT Deque et le tableau circulaire

public interface Deque<T> {
    void addFirst(T x);
    void addLast(T x);
    T removeFirst();
    T removeLast();
    T peekFirst();
    T peekLast();
    int size();
    boolean isEmpty();
}

Un deque (double-ended queue) ajoute et retire aux deux bouts. Restreint à addLast et removeLast, c’est une pile ; à addLast et removeFirst, une file.

private int precedent(int i) {
    return (i - 1 + donnees.length) % donnees.length;
}
public void addFirst(T x) {
    if (taille == donnees.length) agrandir();
    tete = precedent(tete);
    donnees[tete] = x;
    taille++;
}

public void addLast(T x) {
    if (taille == donnees.length) agrandir();
    donnees[(tete + taille) % donnees.length] = x;
    taille++;
}

Lecture. addFirst recule la tête, addLast écrit après la fin : chaque opération touche une case et au plus un indice, hors agrandissement.

Le modulo négatif

public class ExModulo {
    public static void main(String[] args) {
        System.out.println((3 - 1) % 8 + " " + (0 - 1) % 8);
        System.out.println((0 - 1 + 8) % 8 + " " + Math.floorMod(0 - 1, 8));
        String[] donnees = new String[8];
        int tete = (0 - 1) % donnees.length;  // sans le + donnees.length
        donnees[tete] = "Daba";
    }
}

Résultat \(\rightarrow\)

2 -1
7 7

Erreur \(\rightarrow\)

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 8
    at ExModulo.main(ExModulo.java:7)

Lecture. % est un reste, du signe du dividende : \((0-1)\,\%\,8=-1\), d’où l’exception. Ajouter \(m\) ramène l’indice entre 0 et \(m-1\) ; Math.floorMod rend le modulo.

Les files et les piles du JDK

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Queue;
Queue<String> guichet = new ArrayDeque<>();
guichet.add("Daba");
guichet.add("Mbene");
guichet.add("Kine");
System.out.println(guichet.remove() + " " + guichet);
Deque<String> dossiers = new ArrayDeque<>();
dossiers.push("acte 1");
dossiers.push("acte 2");
dossiers.push("acte 3");
System.out.println(dossiers.pop() + " " + dossiers);

Résultat \(\rightarrow\)

Daba [Mbene, Kine]
acte 3 [acte 2, acte 1]

Lecture. java.util.ArrayDeque, tableau circulaire, réalise Queue et Deque : add en queue, remove en tête ; push est addFirst, pop est removeFirst : le sommet est en tête. La documentation de Stack renvoie à Deque.

Exercice : un deque de huit cases

ArrayDeque, capacité 8, vide

Les opérations du tableau, dans l’ordre (tete \(=0\) au départ).

  1. La case écrite, tete et taille après les trois ajouts.
  2. Les deux opérations suivantes.
  3. Le dernier retrait et l’état final.
  4. La valeur de tete après addFirst(24) si l’on oublie + donnees.length.
opération case tete taille rend
addLast(61) 0 0 1 ---
addFirst(24) 7 7 2 ---
addFirst(85) 6 6 3 ---
removeLast() 0 6 2 61
addLast(39) 0 6 3 ---
removeFirst() 6 7 2 85

Correction

  1. addFirst(24) : \((0-1+8) \bmod 8=7\) ; puis 6 pour 85.
  2. Le dernier est en case \((6+3-1) \bmod 8=0\) : 61 ; 39 reprend la case 0.
  3. 85 sort de la case 6 : restent 24 (case 7) et 39 (case 0).
  4. \(-1\), puis ArrayIndexOutOfBoundsException à l’écriture.

Du besoin à l’ADT, puis à la structure

  1. Quelles opérations le problème demande-t-il ? On identifie l’ADT : annuler la dernière action est une pile, servir dans l’ordre d’arrivée est une file.
  2. Quelles opérations sont fréquentes ? On choisit la structure d’après ce profil.
  3. Que mesure-t-on ? On compte, et l’on révise si la mesure contredit la prévision (Séance 2).
besoin ADT réalisation courante
accès par rang, ajout en fin List tableau redimensionnable
insertions et retraits en tête List nœuds chaînés
annuler, revenir en arrière Stack tableau, sommet en fin
servir dans l’ordre d’arrivée Queue tableau circulaire
ajouter et retirer aux deux bouts Deque tableau circulaire

Lecture. Un historique où l’on consulte le plus récent et d’où l’on purge le plus ancien, une fois la capacité atteinte, est un deque : on écrit d’un côté, on retire de l’autre.

Le coût caché

for (int n : new int[] {1000, 4000}) {
    ListeTableau<Integer> l = new ListeTableau<>();
    FileCirculaire<Integer> f = new FileCirculaire<>();
    for (int k = 0; k < n; k++) {
        l.ajouter(k);    // enqueue : en fin
        f.enqueue(k);
    }
    for (int k = 0; k < n; k++) {
        l.supprimer(0);  // dequeue : tout se decale
        f.dequeue();
    }
    System.out.println(n + " clients : liste " + l.decalages
        + " decalages ; file circulaire " + f.copies + " copies");
}

Résultat \(\rightarrow\)

1000 clients : liste 499500 decalages ; file circulaire 1016 copies
4000 clients : liste 7998000 decalages ; file circulaire 4088 copies

Lecture. Contrat respecté, mais \(n(n-1)/2\) décalages pour \(n\) clients : correct n’est pas efficace.

Les structures côte à côte

opération tableau redimensionnable nœuds chaînés tableau circulaire
lecture au rang \(i\) 1 accès \(i\) références 1 accès, case \((\texttt{tete}+i) \bmod m\)
ajout en fin moins de 2 copies (amorti) 2 références (queue) moins de 2 copies (amorti)
ajout en tête \(n\) décalages 2 références 1 case (addFirst)
retrait en tête \(n-1\) décalages 1 référence 1 case (dequeue)
mémoire \(n\) à \(2n\) cases (ajouts seuls) \(n\) nœuds (valeur, référence) \(n\) à \(2n\) cases (ajouts seuls)

Lecture. Mesures de la séance : \(n=1\,000\) insertions en tête, 499 500 décalages sur le tableau, aucun sur la liste chaînée ; \(n\) lectures get(k), 499 500 références suivies sur la liste chaînée, aucune sur le tableau ; 1 000 clients servis par supprimer(0), 499 500 décalages, aucun par la file circulaire.

À retenir

À retenir

  • Un ADT est un contrat : des opérations spécifiées par leur comportement ; une structure de données le réalise. En Java : une interface générique, et l’on déclare avec l’interface.
  • Tableau et nœuds chaînés réalisent le même contrat List avec des coûts opposés : accès direct contre insertion en tête sans décalage.
  • Le doublement coûte moins de 2 copies par ajout sur toute suite de \(n\) ajouts (\(2n-6\) au plus pour \(n\geq5\)), même si un ajout isolé en coûte \(n-1\) : c’est un coût amorti.
  • Iterable et Iterator : un parcours complet en \(n\) pas, par la boucle for-each.
  • La pile (LIFO) vérifie un parenthésage et évalue une expression postfixe en un seul parcours ; b reçoit le sommet.
  • La file (FIFO) et le deque sur un tableau circulaire : indices modulo \(m\), (i - 1 + m) % m pour reculer, taille pour distinguer vide et plein, agrandissement dans l’ordre logique.

Prochaine séance : analyse algorithmique

Séance 2

Cette séance a dit « en un pas », « \(n\) décalages », « moins de 2 copies par ajout » sur des comptes mesurés. La Séance 2 compte les opérations élémentaires d’un algorithme, définit \(\mathcal{O}\), \(\Omega\) et \(\Theta\), distingue le meilleur cas, le pire cas et le cas moyen, et résout les récurrences des algorithmes récursifs, jusqu’au théorème maître : les coûts de cette séance deviennent des énoncés démontrés.

Références

  • Sedgewick, Wayne — Algorithms, 4e éd., Addison-Wesley, 2011 (§ 1.2 : abstraction des données ; § 1.3 : sacs, files et piles, tableaux redimensionnables, listes chaînées, itérateurs, évaluation d’expressions ; § 1.4 : coût amorti).
  • Cormen, Leiserson, Rivest, Stein — Introduction to Algorithms, 4e éd., MIT Press, 2022 (ch. 10 : piles, files, listes chaînées ; § 16.4 : tables dynamiques et coût amorti).
  • Skiena — The Algorithm Design Manual, 3e éd., Springer, 2020 (ch. 3 : structures de données).
  • Bloch — Effective Java, 3e éd., Addison-Wesley, 2018 (item 64 : désigner les objets par leur interface).
  • OpenJDK 17 — java.util.ArrayList, java.util.ArrayDeque, java.util.Stack (code source).