Examen final

CS-108 – corrigé

1. Mélange de tableau d'entiers

Partie 1

Le corps de la classe abstraite retournée par intArrayAsList est presque identique à celui de la classe ArrayAdapter du cours sur le patron Adapter. La seule différence est que, contrairement à ArrayAdapter, la classe dont une instance est retournée par intArrayAsList n'est pas générique, puisque les éléments de la liste qu'elle représente ont un type connu, à savoir Integer.

public static List<Integer> intArrayAsList(int[] array) {
  return new AbstractList<>() {
    @Override
    public Integer get(int index) {
      return array[index];
    }

    @Override
    public Integer set(int index, Integer element) {
      int oldElement = array[index];
      array[index] = element;
      return oldElement;
    }

    @Override
    public int size() {
      return array.length;
    }
  };
}

Il faut noter que même si le code des méthodes de cette classe est en apparence identique à celui de ArrayAdapter, il ne l'est pas en réalité. En effet, en raison de l'emballage automatique (autoboxing), le code de la méthode get ci-dessus est récrit automatiquement pas Java en celui-ci :

public E get(int index) {
  return Integer.valueOf(array[index]);
}

et celui de set est récrit de manière similaire.

C'est la raison pour laquelle il n'est pas possible, comme dit dans l'énoncé de l'examen, d'utiliser ArrayAdapter — ou Arrays.asList de la bibliothèque Java — à la place de intArrayAsList.

Partie 2

La méthode shuffle est une traduction directe en Java de la description de l'algorithme donnée dans l'énoncé. La seule petite difficulté est d'échanger deux éléments du tableau, ce qui peut se faire au moyen d'une variable temporaire, nommée t ci-dessous.

static void shuffle(int[] array,
                    RandomGenerator randomGenerator) {
  for (int i = 0; i < array.length - 1; i += 1) {
    int j = randomGenerator.nextInt(i, array.length);
    int t = array[i];
    array[i] = array[j];
    array[j] = t;
  }
}

Cet algorithme de mélange est généralement connu sous le nom d'algorithme de Fisher-Yates, ou algorithme de Knuth.

2. Itérateurs

Partie 1

La manière la plus simple de définir l'itérateur retourné par concat est d'utiliser une classe anonyme implémentant l'interface Iterator. Comme cette dernière possède une définition par défaut de la méthode remove qui fait déjà ce qui est demandé — c.-à-d. qu'elle lève une UnsupportedOperationException — seules les méthodes hasNext et next doivent être définies dans cette classe.

La méthode hasNext retourne vrai si au moins l'un des deux itérateurs à concaténer peut encore fournir une valeur, tandis que next fourni soit la prochaine valeur du premier itérateur — s'il en a encore une à fournir — soit celle du second itérateur.

Il faut noter qu'il n'est pas strictement nécessaire de vérifier, au début de la méthode next, que hasNext retourne vrai et de lever une exception si ce n'est pas le cas, car cela sera fait au besoin par la méthode next du second itérateur.

static <T> Iterator<T> concat(Iterator<T> it1,
                              Iterator<T> it2) {
  return new Iterator<T>() {
    @Override
    public boolean hasNext() {
      return it1.hasNext() || it2.hasNext();
    }

    @Override
    public T next() {
      if (it1.hasNext()) return it1.next();
      return it2.next();
    }
  };
}

Partie 2

Tout comme concat, map s'écrit relativement facilement au moyen d'une classe anonyme implémentant l'interface Iterator et redéfinissant ses méthodes hasNext et next.

La méthode hasNext retourne le résultat de la méthode hasNext de l'itérateur sous-jacent, tandis que la méthode next obtient le prochain élément de l'itérateur sous-jacent et le passe à la méthode apply de la fonction afin de le transformer, puis retourne la valeur ainsi obtenue.

static <T, R> Iterator<R> map(Iterator<T> it,
                              Function<T, R> f) {
  return new Iterator<R>() {
    @Override
    public boolean hasNext() {
      return it.hasNext();
    }

    @Override
    public R next() {
      return f.apply(it.next());
    }
  };
}

Partie 3

La méthode max commence par vérifier que l'itérateur donné peut fournir au moins une valeur, c.-à-d. que sa méthode hasNext retourne initialement vrai. Si ce n'est pas le cas, une NoSuchElementException est levée, comme demandé dans l'énoncé. (Cette vérification n'est pas strictement nécessaire, car next lève cette même exception si l'itérateur n'a aucun élément à fournir.)

Sinon, la première valeur fournie par l'itérateur est utilisée comme valeur initiale du maximum, après quoi les éventuels autres éléments sont parcourus, et si l'un d'entre eux est plus grand que le maximum actuel, il le remplace.

Une fois tous les éléments ainsi parcourus, le maximum actuel est retourné.

static <T extends Comparable<T>> T max(Iterator<T> it) {
  if (!it.hasNext()) throw new NoSuchElementException();
  T max = it.next();
  while (it.hasNext()) {
    T next = it.next();
    if (next.compareTo(max) > 0) max = next;
  }
  return max;
}

Il faut noter que l'appel next.compareTo(max) est valide car on sait que les éléments fournis par l'itérateur possèdent une méthode compareTo en raison de la borne inférieure placée sur le paramètre de type T (T extends Comparable<T>).

3. Comparateur lexicographique

L'interface Comparator étant une interface fonctionnelle, la manière la plus simple d'écrire le comparateur retourné par lexicographic est d'utiliser une lambda.

Cette lambda représente le corps de la méthode compare du comparateur, et elle reçoit donc en arguments deux listes (l1 et l2 ci-dessous) qu'elle doit comparer selon l'ordre lexicographique. Pour cela, elle les parcourt au moyen d'itérateurs (i1 et i2) et compare les éléments deux à deux au moyen de la méthode compare du comparateur cmp reçu en argument.

Si lors de ce parcours, deux éléments comparés ne sont pas égaux, alors le résultat de leur comparaison est retourné comme résultat de la comparaison des deux listes. Sinon, cela signifie qu'au moins une des deux listes a été parcourue en totalité sans qu'une différence ait été trouvée avec l'autre. Le résultat de la comparaison peut donc être déterminé en comparant leur longueur, ce qui peut se faire en regardant si l'un des deux itérateurs peut encore fournir un élément.

static
<T> Comparator<List<T>> lexicographic(Comparator<T> cmp) {
  return (l1, l2) -> {
    Iterator<T> i1 = l1.iterator();
    Iterator<T> i2 = l2.iterator();
    while (i1.hasNext() && i2.hasNext()) {
      int c = cmp.compare(i1.next(), i2.next());
      if (c != 0) return c;
    }
    if (i1.hasNext()) return 1;
    if (i2.hasNext()) return -1;
    return 0;
  };
}

On notera que le comparateur retourné par lexicographic pourrait en fait être utilisé pour comparer des valeurs itérables quelconques, pas seulement des listes, et le type de retour de cette méthode pourrait donc être généralisé ainsi :

<T> Comparator<Iterable<T>> lexicographic(Comparator<T> cmp)

4. Flot d'entiers à taille variable

Pour lire le prochain entier à taille variable, la méthode readVarInt doit :

  • lire le prochain octet du flot sous-jacent (b ci-dessous),
  • retourner -1 si celui-ci vaut -1, car la fin du flot sous-jacent a alors été atteinte,
  • sinon, déterminer le nombre d'octets supplémentaires à lire du flot sous-jacent (n) en comptant le nombre de bits 0 successifs dans les bits de poids faible du premier octet,
  • supprimer de l'octet initial les n+1 bits indiquant la longueur de l'entier encodé, au moyen d'un décalage à droite,
  • lire n octets du flot sous-jacent afin d'en extraire les bits restants de l'entier à décoder.

Si la fin du flot est détectée lors de la lecture des octets restants, cela signifie que celui-ci n'a pas le bon format, et une exception est donc levée.

int readVarInt() throws IOException {
  int b = stream.read();
  if (b == -1) return -1;

  int n = Integer.numberOfTrailingZeros(b);
  if (n > 3) throw new IOException();

  b >>= n + 1;
  for (int i = 0; i < n; i += 1) {
    int b2 = stream.read();
    if (b2 == -1) throw new IOException();
    b = (b << 8) | b2;
  }
  return b;
}

5. Bâtisseur de chaînes

Pour la petite histoire, le tableau à trou (gap buffer) présenté dans cet exercice est une des techniques couramment utilisée dans les éditeurs de texte pour gérer efficacement l'insertion de texte au milieu d'un fichier. Emacs est un exemple d'un éditeur célèbre utilisant un tel tableau.

Partie 1

Sachant que la chaîne en cours de construction est constituée de tous les caractères du tableau sauf ceux du trou, et sachant que le trou a une taille de gapSize caractères, la méthode length s'écrit au moyen d'une simple soustraction :

int length() {
  return buffer.length - gapSize;
}

Partie 2

La méthode insert est assez similaire à la méthode add de la classe SArrayList, mais plus complexe en raison du fait que le trou — la capacité du tableau actuellement inutilisée — peut se trouver à une position quelconque.

La première chose que fait cette méthode est de valider l'index reçu, et elle lève une exception s'il n'est pas valide.

Cela fait, elle détermine ensuite si le trou a une taille nulle. Dans ce cas, elle créée un nouveau tableau deux fois plus grand que l'actuel et y copie les éléments de l'actuel de manière à ce que le trou débute à la position d'insertion.

Si le trou n'a pas une taille nulle, mais qu'il ne commence pas à la position d'insertion, insert le déplace afin que ce soit le cas. Pour cela, elle distingue les deux cas mentionnés dans l'énoncé, et fait les copies nécessaires.

Une fois ces ajustements effectués, le trou commence à la position d'insertion et il n'est pas vide. L'insertion du nouveau caractère se fait donc simplement en remplaçant le premier caractère du trou par celui à insérer, et en ajustant la position de départ et la taille du trou en fonction.

SStringBuilder insert(int i, char c) {
  if (!(0 <= i && i <= length()))
    throw new IndexOutOfBoundsException();

  if (gapSize == 0) {
    int newGapSize = buffer.length;
    char[] newBuffer = new char[buffer.length + newGapSize];
    System.arraycopy(buffer, 0,
                     newBuffer, 0,
                     i);
    System.arraycopy(buffer, i,
                     newBuffer, i + newGapSize,
                     buffer.length - i);
    buffer = newBuffer;
    gapSize = newGapSize;
    gapPos = i;
  } else if (gapPos < i) {
    System.arraycopy(buffer, gapPos + gapSize,
                     buffer, gapPos,
                     i - gapPos);
    gapPos = i;
  } else if (gapPos > i) {
    System.arraycopy(buffer, i,
                     buffer, i + gapSize,
                     gapPos - i);
    gapPos = i;
  }

  buffer[gapPos++] = c;
  gapSize -= 1;
  return this;
}

Partie 3

La méthode toString doit simplement concaténer les chaînes se trouvant des deux côtés du trou, qui peuvent être construites chacune au moyen d'un appel à la méthode valueOf mentionnée dans l'énoncé :

String toString() {
  return String.valueOf(buffer, 0, gapPos)
    + String.valueOf(buffer,
                     gapPos + gapSize,
                     buffer.length - (gapPos + gapSize));
}