Planche : 1


Programmer avec les listes

Planche : 2


Notre amie, la classe des (cellules de) listes

Planche : 3


Opérations élémentaires

Il y en a trois.



Boucle idiomatique

Traiter les éléments un par un, dans l’ordre.

for (List p = … ; p != null ; p = p.next) {
  … // p.val est l'élément courant.
}

Planche : 4


Présentation théorique des listes

Planche : 5


Lecture au clavier

Une liste d’entiers lus au clavier (ou dans un fichier), terminée par Ctrl-d (fin de fichier).

class IntReader { // Lecteur des int
  int read() { … }
  boolean hasNext() { … }
}

class List {
  …
  static List lireInts(IntReader in) {
    List r = null ;
    while (in.hasNext()) {
      r = new List (in.read(), r) ;
    }
    return r ;
  }
}

Bonus : Le code de l’exemple.


Planche : 6


Fonctionnement

Initialement,

L’utilisateur entre 1,

puis 2,

puis 3,

etc.


Planche : 7


En résumé

Planche : 8


Retourner une liste

Il y a (au moins) deux façons d’aborder la question.


Planche : 9


Approche récursive

Généralisons le problème (comme souvent).

La methode revAppend(p1 ; …; pnq1 ; …; qm) doit renvoyer la liste pn; …; p1; q1 ; … qm.

Par induction sur la première liste, la fonction R suivante convient.

R(∅, Q)=Q 
R((aP), Q)=R(P, (a;Q))

En notant P la liste P retournée, et par hypothèse de récurrence :

R(P, (aQ)) = 
P
 ; a ; Q

Avec a; P = P ; a (disons par définition du retournement).


Planche : 10


Programmation de R

Les équations :

R(∅, Q)=Q 
R((aP), Q)=R(P, (a;Q))

Le code :

static List revAppend(List pList q) {
  if (p == null) {
    return q ;
  } else {
    return revAppend(p.nextnew List(p.valq)) ;
  }
}

static List rev(List p) {
  return revAppend(pnull) ;
}

Planche : 11


Élimination de la récursion (terminale)

La boucle,

  x = x0 ; r = r0 ;
  while (P(x)) {
    r = f(x, r) ;  x = g(x) ;
  }

Équivaut à la récursion

static … recLoop(… x, … r) {
  if (P(x)) {
     return recLoop(g(x), f(x, r)) ;
  } else {
     return r ;
  }
}
  … r = recLoop(x0r0) …

Remarquer C’est un complément : aller voir le poly.


Planche : 12


Un autre exemple : l’affichage

C’est plus simple, car il n’y a pas de résultat (accumulateur r).


Planche : 13


Encore un exemple : n-ième élement

Récursif

static int nth(int iList p) {
  if (p == null) { throw new Error() ; }
  if (i == 0) {
    return p.val ;
  } else {
    return nth(i-1, p.next) ;
}

Itératif: allons nous pouvoir utiliser la boucle idiomatique ? Oui

static int nth(int iList p) {
  for ( ; p != null ; p = p.next) {
    if (i == 0) return p.val ;
    i-- ;
  }
  throw new Error() ;
}

Planche : 14


À propos

On ne doit pas écrire :

  for (int i = 0 ; i < length(p) ; i++) {
    int x = nth(ip) ;
    …
  }

Mais plutôt.

  for (List q = p ; q != null ; q = q.next) {
    int x = q.val ;
    …
  }

Pourquoi


Planche : 15


Que retenir ?

La programmation récursive est la plus puissante (et de loin).

Par exemple, afficher à l’envers.

  static void revPrint(List p) {
    if (p != null) {
      revPrint(p.next) ;
      System.out.println(p.val) ;
    }
  }

(afficher a ; P à l’envers, c’est afficher P à l’envers, puis afficher a.)

Pas de version itérative… simple.


Planche : 16


Pourquoi trier ?

Planche : 17


Trier une liste simplement

Trions une main à la belote (ou au bridge).

L’insertion semble plus simple dans une liste que dans un tableau.


Planche : 18


Insertion, équations

Trouver la place où ranger la nouvelle valeur x dans une liste triée

static boolean ici(int xList p) {
  return (p == null || x <= p.val) ;
}

Noter : ici(x, P) faux entraîne P non-vide.

I(xP)=xP,  si ici(xP
I(x, (aP))a ; I(xP),  sinon

Correction :


Planche : 19


Programmation du tri des listes

D’abord le tri en supposant insert écrite.

static List sort(List p) {
  List r = null ;
  for ( ; p != null ; p = p.next) {
    r = insert(p.valr) ;
  }
  return r ;
}

Et l’insertion.

static List insert(int xList p) {
  if (ici(x,p)) {
    return new List (xp) ;
  } else { // (p != null) && (x > p.val)
    return new List (p.valinsert(xp.next)) ;
  }
}

Planche : 20


Combien ça coûte

Pour une liste de taille (longeur) n.

La borne O(n2) est bien serrée (listes triées de tailles croissante).


Planche : 21


Combien ça coûte ? II

On peut (plus simplement) compter les comparaisons.

n−1 ≤ I(n) ≤ 
n (n−1)
2

Ici, I(n) est le nombre de comparaisons d’éléments effectuées pour trier une liste de taille n.

Ce résultat donne une indication sensée du temps d’exécution.

On peut aussi compter le nombre de cellules de listes allouées.

n ≤ I(n) ≤ 
n (n+1)
2

Planche : 22


Coût en moyenne

Les arguments du tri sont pris dans un espace de probabilité (S). On calcule l’espérance de la variable aléatoire « coût » (X).

E(X) = 
 
s ∈ S
 ps X

Ici, avec la loi uniforme sur les permutations de n éléments

n ≤ Î(n) ≤ 
n (n−1)
2

Plus précisément, pour les permutations de n éléments distincts (voir le poly).

Î(n) = 
1
4
n2 + 
3
4
n − lnn + O(1)

Planche : 23


Insertion « en place »

La méthode insert précedente n’est pas conforme à l’intuition d’insertion dans un jeu de cartes.

Revenons aux equations

I(xP)=xP,  si ici(xP
I(x, (aP))=a ; I(xP),  sinon

Et interprétons les ainsi,

Si ici(x, P), alors ajouter en tête
Sinon, insérer x dans P.next

Par le premier « Si… », la méthode I doit renvoyer une liste. Soit finalement :

Si ici(x, P), alors renvoyer x ; P
Sinon, changer P.next en I(x,P.next) et renvoyer P.

Planche : 24


Programmation de l’insertion en place

En bref,

static List insert(int xList p) {
  if (ici(xp)) { // Ajouter en tête
    return new List (xp) ;
  } else {         // Insérer dans p.next
    p.next = insert(xp.next) ;
    return p ;
  }
}

Question : Changer le contenu d’une cellule pose-t-il un problème ? non !

Pourquoi ? Il n’y a aucun partage possible des cellules de la liste triée, dont toutes les cellules sont créées par insert (sont nouvelles)


Planche : 25


Insertion en place, itérative

Dans la solution récursive, au plus une seule des affections est utile.

On peut l’éviter ainsi :

static List insert(int xList p) {
  // Cas particulier : insertion en tête.
  if (ici(x,p)) return new List(xp) ;
  // Cas général, insertion après une cellule.
  List q = pprev ;
  do {
     prev = q ; q = q.next ;
     // prev est la cellule qui précède q  
  } while (!ici(x,q)) ;
  prev.next = new List(xq) ;
  return p ;
}

Au fond, c’est exagéré.


Planche : 26


Fonctionnement, vision itérative
Commencer
Trouver la place (sortie itération)

Planche : 27


Insérer

Exécuter

  prev.next = new List (5, q) ;

Planche : 28


Deux styles de structures de données

Planche : 29


Deux styles de programmation

Planche : 30


Comparaison des trois versions

Planche : 31


Changement d’échelle

I3 (destructif, itératif) semble bien en O(n2).


Planche : 32


La bonne réaction

Si un programme est trop lent…

Y a-t-il un algorithme plus efficace ?

Pour le tri…

Il y a des algorithmes en O(n logn).
Fusion des listes triées

Par définition :

La fusion de deux listes triées est une liste triée qui regroupe les éléments des deux listes fusionnées.

Planche : 33


Fusion correcte

Soit M(X,Y) la fusion (M pour merge).

Équations :

M(∅, Y)=Y  
M(X, ∅)=X  
M((xX), (yY))xM(X, (yY))  si xy
M((xX), (yY))=yM((x ; X), Y)  si yx

Correction : (du troisième cas)


Planche : 34


Code de la fusion
static List merge(List xsList ys) {
  if (xs == null) {
    return ys ;
  } else if (ys == null) {
    return xs ;
  } // NB: désormais xs != null && ys != null 
    else if (xs.val <= ys.val) {
    return new List (xs.valmerge(xs.nextys)) ;
  } else { // NB: ys.val < xs.val
    return new List (ys.valmerge(xsys.next)) ;
  }
}



Coût de la fusion

Si M(n1, n2) représente le nombre de comparaisons d’éléments.

min(n1n2) ≤ M(n1n2) ≤ n1 + n2

Planche : 35


Principe du tri fusion

Exemple de la classique méthode « divide and conquer ».



Note : Comment garantir la terminaison ?


Planche : 36


Tri fusion, diviser

En un parcours de liste : séparer les pairs des impairs.

static List mergeSort(List xs) {
  List ys = nullzs = null ;
  boolean even = true ; // zéro est pair
  for (List p = xs ; p != null ; p = p.next ) {
    if (even) {
      ys = new List (p.valys) ;
    } else {
      zs = new List (p.valzs) ;
    }
    even = !even ; // k+1 a la parité opposée à celle de k
  }
  ⋮

Planche : 37


Tri fusion, conquérir
  ⋮
  if (zs == null) { // xs a zéro ou un élément
    return xs ; // et alors xs est triée
  } else { // Les longueurs de ys et sz sont < à la longueur de xs
    return merge(mergeSort(ys), mergeSort(zs)) ;
  }
}

Coût à la louche :

  1. Une fusion → n, soit (au pire) n comparaisons.
  2. Deux fusions → n/2, soit (au pire) n comparaisons.
  3. Quatre fusions → n/4, soit (au pire) n comparaisons.
  4. Etc.

Etc. veut dire environ log2(n) fois. Au final : O(n log  n).


Planche : 38


Plus précisément

Équations du coût (nombre de comparaisons).

S(0) = 0
S(1) = 0
S(n) = M(n/2n/2) + S(n/2) + S(n/2),  pour n > 1

Soit (encadrer le coût de la fusion).

n/2S(n/2) + S(n/2) ≤ S(n) ≤ n + S(n/2) + S(n/2)

Si n = 2p+1 on a la simplification très nette

2p + 2·S(2p) ≤ S(2p+1) ≤ 2p+1 + 2·S(2p)

Et donc (diviser par 2p+1, recurrence T(p+1) = k + T(p)).

1/2·p·2p ≤ S(2p) ≤ p·2p

Planche : 39


Et donc, finalement

En fait, on peut généraliser (voir le poly).

1/2·log2(n/2)·n/2 ≤ S(n) ≤ log2(2n)·2n

Soit :

S(n)=Θ(n · log n
Ŝ(n)=Θ(n · log n

Le point important  Quelque soit la liste, le tri effectue toujours de l’ordre de n log  n comparaisons.


Planche : 40


Vérifions l’efficacité

I est le tri par insertion le plus efficace. L’accélération est impressionante.


Planche : 41


Passer aux grandes listes

On tente de trier des liste de taille k × 10000.

N=10000 T=37
N=20000 T=249
N=30000 T=238
Exception in thread "main" java.lang.StackOverflowError
        at List.merge(T.java:148)
        at List.merge(T.java:148)
        at List.merge(T.java:148)
  ⋮

J’ai effacé de l’ordre de 200 lignes d’appels de méthodes en attente.

En pratique, le nombre d’appels de méthode qui attendent le résultat d’un appel de méthode est limité.

Or merge appelle merge qui appelle merge qui…


Planche : 42


Quelle solution ?

Différemment veut dire sans appel récursif : programmer une fusion itérative (cf. poly 1.3.3).


Planche : 43


Performances

Noter : La courbe M1 est incomplète, en raison de l’échec. Ici programmer itérativement n’est pas une question d’efficacité, mais une nécessité (si on veut trier de grandes listes).


Planche : 44


Diversion: static, pas static ?

Slogan :

static ∼ La classe,   Pas static ∼ L’objet.



Je veux la représentation décimale de l’entier x dans la chaîne s.


Planche : 45


Diversion II: static, pas static ?

Planche : 46


Et la programmation objet ?

La programmation des listes vues comme telles, n’a rien d’une programmation « style objet ».

Pire, il serait absurde de s’imposer ce style ici.

Mais qu’est-ce que le style objet ?

Exemple, on veut des objets d’une classe Set, qui offrent :

Par exemple, on ajoute l’élément x à l’ensemble s par

     s.add(x)

Important : Le style objet n’abolit pas la distinction persistant/impératif. Au contraire il permet de la rendre visible.


Planche : 47


Ensembles persistants

Ajouter un ou des éléments renvoie un nouvel ensemble.

class Set {
  Set () // Construire un ensemble vide
  Set add(int x// Ajouter l'élément x.
  public String toString() // Comme d'habitude.
}

Usage :

  Set s0 = new Set () ;
  Set s1 = s0.add(3) ;
  Set s2 = s1.add(1) ;
  Set s3 = s2.add(2) ;

Important : L’ensemble vide n’est pas null.


Planche : 48


Ensembles impératifs

Ajouter un ou des éléments modifie l’ensemble.

class ISet {
  ISet () // Construire un ensemble vide
  void add(int x// Ajouter l'élément x.
  public String toString() // Comme d'habitude.
}

Usage :

  ISet s0 = new ISet () ;
  s0.add(3) ;
  s0.add(1) ;
  s0.add(2) ;

Important : Les méthodes ne renvoient rien (void). C’est la bonne façon de procéder.


Planche : 49


Implémentation I

On décide de représenter les ensembles par des listes triées.

Écrivons une méthode pour ajouter un élément dans un ensemble représenté par une liste triée.

// Comme insert, éviter les doublons.
static List add(int xList xs) {
   if (xs == null) { // Ajouter ici
     return new List(xxs) ;
   } else if (x < xs.val) { // Ajouter ici aussi
     return new List(xxs) ;
   else if (x > xs.val) { // Ajouter plus loin
     return new List(xs.valadd(xxs.next)) ;
   } else { // x == xs.val, déjà là
     return xs ;
   }
}

Dans la classe List, évidemment.


Planche : 50


Effet mémoire



  List xs = new List(1, new List(3, null)) ;
  List ys = List.add(2, xs) ;




Planche : 51


Implémentation II

La méthode List.add fait le vrai travail, reste à « encapsuler ».

class Set {
  private List elts ; // Liste des eléments, usage interne.

  Set () { elts = null ; } // Inutile, marque l'intention.
  private Set (List elts) { this.elts = elts ; } // Usage interne.

  Set add(int x) { return new Set (List.add(xthis.elts)) ; }
}
class ISet {
  private List elts ; // Liste des eléments, usage interne.

  ISet () { elts = null ; } // Inutile, marque l'intention.

  void add(int x) { this.elts = List.add(xelts) ; }
}

Planche : 52


Avec des listes non-persistantes

Le code de nadd se déduit facilement de celui de add.

static List nadd(int xList xs) {
   if (xs == null || x < xs.val) { // Ajouter ici
     return new List(xxs) ;
   else if (x > xs.val) { // Ajouter plus loin
     xs.next = nadd(xxs.next) ;
     return xs ;
   } else { // x == xs.val, déjà là
     return xs ;
   }
}

Noter que nadd ne peut pas renvoyer void, à cause du cas de l’élément ajouté en tête.


Planche : 53


Effet mémoire



  List xs = new List(1, new List(3, null)) ;
  List ys = List.nadd(2, xs) ; // Pas très malin



En effet la liste pointée par xs a changé !



On peut sans doute se contraindre à écrire.

  xs = List.nadd(2, xs) ; // Bien plus logique

Mais rien n’est contrôlé par Java.


Planche : 54


Ensemble impératif, implémenté impérativement

Dans la classe ISet.

   void add(int x) {
     this.elts = List.nadd(xthis.elts) ;
   }

Et grâce à la signature de la méthode void add(..), l’utilisateur ne peut pas se tromper : il n’y a qu’un seul ensemble, qui est modifié au cours de sa vie.

L’encapsulage de la structure de donnée non-persistante augmente la sûreté de la programmation.

ISet s0 = new ISet () ; s0.add(3) ; s0.add(1) ; s0.add(2) ;
ISet s1 = s0 ;

s0.add(4) ; // Il semble « logique » que s1 change aussi.

Ce document a été traduit de LATEX par HEVEA