Examen intermédiaire

CS-108 – corrigé

1. Décorateurs de fonctions

Les décorateurs à écrire pour cet exercice étaient assez similaires de ceux des séries Images continues et Art ASCII.

Partie 1

Pour appliquer une symétrie horizontale à une fonction, il suffit d'inverser le signe de son argument, comme l'exemple donné le montrait. La définition de l'enregistrement FlippedH est donc très simple :

public record FlippedH(DoubleUnaryOperator f)
  implements DoubleUnaryOperator {

  public double applyAsDouble(double x) {
    return f.applyAsDouble(-x);
  }
}

Partie 2

Comme nous l'avions vu dans la série Images continues, pour appliquer une transformation donnée à une fonction, il faut appliquer la transformation inverse à son argument. Dès lors, l'argument à passer à la méthode applyAsDouble de la fonction décorée est l'argument reçu moins la distance de translation.

public record Translated(DoubleUnaryOperator f,
			 double tX)
  implements DoubleUnaryOperator {

  public double applyAsDouble(double x) {
    return f.applyAsDouble(x - tX);
  }
}

Partie 3

Pour obtenir le profil inversé d'une arête à partir de son profil il suffit de lui appliquer une symétrie horizontale puis de le translater vers la droite d'une distance égale à sa longueur, afin que le dernier point du profil se trouve à l'origine. La méthode d'inversion de profil s'écrit donc ainsi :

static DoubleUnaryOperator invertedEdgeProfile(Edge edge) {
  return new Translated(new FlippedH(edge.profile()),
			edge.length());
}

2. Itérateur indexé

Partie 1

Comme dit dans l'énoncé, la classe IndexedIterator est générique. Son paramètre de type, nommé E ci-dessous, représente le type des éléments fournis par l'itérateur sous-jacent.

Étant donné que la classe IndexedIterator est elle-même un itérateur, elle doit implémenter l'interface Iterator, et comme les éléments qu'elle fournit ont le type Indexed<E>, c'est ce type qui doit être passé en paramètre à Iterator.

public class IndexedIterator<E>
    implements Iterator<Indexed<E>> {}

Partie 2

Les attributs de IndexedIterator sont :

  • l'itérateur sous-jacent, qui fournit les valeurs à indexer,
  • l'index du prochain élément à fournir.

Le constructeur initialise le premier de ces attributs avec son argument, et initialise l'index à 0 — ce qui n'est pas indispensable, les attributs de type int étant automatiquement initialisés à 0, mais plus propre.

private final Iterator<E> underlyingIterator;
private int index;

public IndexedIterator(Iterator<E> underlyingIterator) {
  this.underlyingIterator = underlyingIterator;
  this.index = 0; // pas indispensable
}

Partie 3

La méthode hasNext est triviale, puisqu'elle ne fait qu'appler celle de l'itérateur sous-jacent. La méthode next est à peine plus complexe, puisqu'elle doit construire l'instance de Indexed à retourner avec l'index correct, puis mettre à jour l'index.

public boolean hasNext() {
  return underlyingIterator.hasNext();
}

public Indexed<E> next() {
  E next = underlyingIterator.next();
  return new Indexed<>(index++, next);
}

Partie 4

La méthode remove est similaire à next, en ce qu'elle appelle la méthode correspondante de l'itérateur sous-jacent puis met à jour l'index pour tenir compte de la suppression.

public void remove() {
  underlyingIterator.remove();
  index -= 1;
}

Partie 5

Le corps de la méthode over est très simple puisqu'elle n'a qu'à créer une nouvelle instance de IndexedIterator en lui passant un itérateur obtenu au moyen de la méthode iterator de l'itérable reçu en argument.

Comme over doit accepter un itérable de n'importe quel type, elle doit être générique, son paramètre de type (E ci-dessous) représentant le type des valeurs de l'itérable.

static <E> IndexedIterator<E> over(Iterable<E> iterable) {
  return new IndexedIterator<>(iterable.iterator());
}

Partie 6

L'idée de la méthode indexOf est simple :

  • on obtient un itérateur indexé sur les éléments de la liste, p. ex. au moyen de over,
  • tant que cet itérateur a encore un élément indexé à fournir, on l'obtient et regarde s'il est égal à celui recherché ; si oui, on retourne immédiatement son index,
  • si la boucle se termine, cela signifie que l'élément recherché n'a pas été trouvé et on retourne donc -1.

Traduit en Java, cela donne :

static <E> int indexOf(List<E> list, E element) {
  Iterator<Indexed<E>> it = IndexedIterator.over(list);
  while (it.hasNext()) {
    Indexed<E> next = it.next();
    if (next.element().equals(element))
      return next.index();
  }
  return -1;
}

Il faut noter que cette méthode ne fonctionne pas si la liste contient une valeur null, mais il n'était pas nécessaire de gérer ce cas. Si on désirait néanmoins le faire, le plus simple serait d'utiliser la méthode equals de Objects pour comparer les deux éléments, en récrivant la condition du if ainsi :

Objects.equals(next.element(), element)

3. Tableaux creux

Partie 1

Les attributs à mémoriser sont :

  • la valeur initiale des éléments,
  • la taille totale du tableau,
  • le tableau des morceaux.

Le constructeur doit simplement initialiser ces attributs en fonction des paramètres reçus, en vérifiant auparavant que la taille est bien supérieure ou égale à 0. La taille du tableau de morceaux s'obtient simplement en arrondissant par excès le résultat de la division de la taille du tableau par la taille d'un morceau (4096).

private final float initialValue;
private final int size;
private final float[][] chunks;

public SparseFloatArray(int size, float initialValue) {
  if (size < 0)
    throw new IllegalArgumentException();

  this.initialValue = initialValue;
  this.size = size;
  this.chunks = new float[ceilDiv(size, 4096)][];
}

Partie 2

L'existence de l'attribut size rend la méthode du même nom triviale :

public int size() {
  return size;
}

Partie 3

La méthode get commence par vérifier que l'index qu'elle reçoit est valide, même si cette vérification pourrait être omise sachant que la bonne exception serait de toute manière levée lors de l'accès au tableau des morceaux, mais avec un message moins clair.

Une fois cette vérification effectuée, elle détermine l'index du morceau contenant l'élément, en effectuant une division entière de l'index de l'élément par la taille d'un morceau. Comme cette taille est une puissance de deux, la division peut s'effectuer par un décalage à droite.

Lorsque l'index du morceau contenant l'élément est connu, il ne reste plus qu'à vérifier si ce morceau existe ou non. Si ce n'est pas le cas, alors l'élément est forcément égal à la valeur initiale. Si c'est le cas, il suffit d'obtenir son index dans le morceau, et à l'en extraire.

Bien entendu, l'index de l'élément dans le morceau est égal au reste de la division de l'index de l'élément dans le tableau par la taille d'un morceau. Là aussi, on peut tirer parti du fait que la taille d'un morceau est une puissance de deux pour calculer ce reste au moyen d'un simple masquage.

public float get(int i) {
  if (!(0 <= i && i < size)) // pas indispensable
    throw new IndexOutOfBoundsException();

  int chunkIndex = i >> 12; // ou : i / 4096
  return chunks[chunkIndex] == null
    ? initialValue
    : chunks[chunkIndex][i & 4095];  // ou : i % 4096
}

Partie 4

La méthode set commence également par vérifier que l'index qu'elle reçoit est valide, même si là encore cette vérification pourrait être omise.

Une fois cette vérification effectuée, elle calcule l'index du morceau contenant l'élément, comme get. Si ce morceau n'existe pas encore, et que la valeur reçue n'est pas égale à la valeur initiale, alors le morceau doit être créé. Sa taille est de 4096 éléments, sauf (peut-être) s'il s'agit du dernier morceau, dont la taille est égale à la taille totale du tableau moins celle des morceaux précédents. Après avoir été créé, le morceau doit être initialisé avec la valeur initiale, ce qui se fait facilement au moyen de la méthode fill de Arrays.

Finalement, une fois que l'existence du morceau contenant l'élément est garantie, sa valeur peut être modifiée au moyen d'une simple affectation.

public void set(int i, float value) {
  if (!(0 <= i && i < size)) // pas indispensable
    throw new IndexOutOfBoundsException();

  int chuckIndex = i >> 12; // ou : i / 4096
  if (chunks[chuckIndex] == null && value != initialValue) {
    chunks[chuckIndex] = (chuckIndex == chunks.length - 1)
      ? new float[size - (chuckIndex << 12)]
      : new float[4096];
    Arrays.fill(chunks[chuckIndex], initialValue);
  }
  if (chunks[chuckIndex] != null)
    chunks[chuckIndex][i & 4095] = value; // ou i % 4096
}

Partie 5

Si on prend soin d'utiliser la méthode get pour obtenir la valeur des éléments du tableau, et d'utiliser une instance de StringJoiner pour construire la chaîne, alors la méthode toString est triviale à écrire.

public String toString() {
  StringJoiner joiner = new StringJoiner(",", "[", "]");
  for (int i = 0; i < size(); i += 1)
    joiner.add(String.valueOf(get(i)));
  return joiner.toString();
}

Partie 6

La méthode density doit calculer les deux valeurs dont elle doit déterminer le rapport :

  • le nombre total d'éléments présents dans les morceaux existants (ci-dessous totalElementCount),
  • le nombre d'entre eux différents de la valeur initiale (ci-dessous nonDefaultElementCount).

Pour ce faire, elle parcourt le tableau des morceaux, en ignorant les morceaux inexistants. Pour chaque morceau rencontré, elle augmente totalElementCount de la taille du morceau, et nonDefaultElementCount du nombre de ses éléments différents de la valeur initiale.

Cela fait, elle peut simplement retourner leur rapport, en prenant bien soin de traiter le cas particulier où aucun morceau n'existe — et donc que totalElementCount vaut 0. Dans ce cas, comme dit dans l'énoncé, la densité vaut 1 par définition.

public double density() {
  int totalElementCount = 0;
  int nonDefaultElementCount = 0;
  for (float[] chunk : chunks) {
    if (chunk == null) continue;

    totalElementCount += chunk.length;
    for (float v : chunk)
      if (v != initialValue) nonDefaultElementCount += 1;
  }
  return totalElementCount == 0
    ? 1
    : nonDefaultElementCount / (double) totalElementCount;
}