Examen final
CS-108 – corrigé
1. Égalité de gares
Partie 1
Les redéfinitions de hashCode
et equals
sont tout à fait standard et ne posent aucun problème.
@Override public int hashCode() { return Objects.hash(id, name); } @Override public boolean equals(Object thatO) { if (thatO instanceof Station) { Station that = (Station) thatO; return (this.id == that.id) && this.name.equals(that.name); } else return false; }
Partie 2
Pour rendre impossible la création de deux instances différentes de Station
ayant les mêmes attributs, il suffit de stocker toutes les instances créées dans une table associative, comme suggéré. La seule difficulté est de savoir quelle clef utiliser afin que chacune d'entre elles identifie une instance de manière unique. Une solution simple consiste à utiliser une chaîne de caractères formée à partir de l'identité et du nom de la gare, séparés par un espace.
La table contenant les instances doit bien entendu être stockée dans un attribut statique, afin d'être accessible à la méthode of
:
private static final Map<String, Station> memoMap = new HashMap<>();
Cet attribut est nommé memoMap
ici, car la technique utilisée ici se nomme memoization en anglais — ou parfois, pour d'obscures raisons historiques, hash consing.
Une fois cet attribut déclaré, la méthode of
n'a plus qu'à consulter la table pour voir si une instance correspondant aux arguments reçus a déjà été créée. Si ce n'est pas le cas, elle est créée et ajoutée à la table. Finalement, l'instance est retournée :
public static Station of(int id, String name) { String key = id + " " + name; Station station = memoMap.get(key); if (station == null) { station = new Station(id, name); memoMap.put(key, station); } return station; }
Cette méthode peut s'écrire de manière encore bien plus concise en utilisant la méthode computeIfAbsent
de Map
, prévue pour ce genre de situation :
public static Station of(int id, String name) { String key = id + " " + name; return memoMap.computeIfAbsent(key, k -> new Station(id, name)); }
2. Partition de gares
Une manière simple de déterminer la taille du plus gros sous-ensemble de la partition consiste à définir un tableau, nommé size
ci-dessous, qui associe à (l'index de) chaque représentant la taille du sous-ensemble qu'il représente. Ce tableau peut être rempli dans la même boucle qui construit la version aplatie de la partition. Cela fait, une seconde boucle permet d'obtenir le maximum de ce tableau.
public StationPartition build() { int[] size = new int[parent.length]; for (int i = 0; i < parent.length; i++) { int r = representative(i); parent[i] = r; size[r] += 1; } int maxSubsetSize = 0; for (int s : size) if (s > maxSubsetSize) maxSubsetSize = s; return new StationPartition(parent, maxSubsetSize); }
Le calcul du maximum peut aussi se faire au moyen de la programmation par flot :
int maxSubsetSize = Arrays.stream(size).max().orElseThrow();
3. Ensembles de chaînes de caractères
Partie 1
La méthode ofSortedArray
doit tout d'abord valider le tableau qu'elle reçoit afin de vérifier qu'il est bien trié par ordre croissant et ne contient aucun doublon. Cela peut se faire en vérifiant que chaque élément est bien strictement supérieur à son prédécesseur.
Cela fait, ofSortedArray
doit encore copier le tableau qu'elle reçoit en argument, afin de garantir l'immuabilité de la classe, car cela n'est pas fait par le constructeur.
public static StringSet ofSortedArray(String[] elements) { for (int i = 1; i < elements.length; i++) { if (elements[i - 1].compareTo(elements[i]) >= 0) throw new IllegalArgumentException(); } return new StringSet(elements.clone()); }
Il est bien entendu aussi possible d'utiliser la méthode copyOf
de Arrays
pour copier le tableau, ainsi :
return new StringSet(Arrays.copyOf(elements, elements.length));
Partie 2
Les méthodes size
et isEmpty
sont triviales à définir.
public int size() { return elements.length; } public boolean isEmpty() { return size() == 0; }
Partie 3
Étant donné l'existence de la méthode binarySearch
dans Arrays
, la méthode contains
est également triviale à définir. Il lui suffit de vérifier que l'index retourné par cette méthode n'est pas négatif.
public boolean contains(String e) { return Arrays.binarySearch(elements, e) >= 0; }
Partie 4
Pour effectuer l'union de deux ensembles, on peut commencer par comparer le premier élément de leurs tableaux respectifs et :
- si le premier est strictement inférieur au second, alors on sait qu'il n'apparaît pas dans le second ensemble ; on peut donc l'inclure dans l'union et continuer en comparant, de la même manière, le second élément du premier tableau avec le premier du second tableau,
- si le second est strictement inférieur au premier, alors on sait qu'il n'apparaît pas dans le premier ensemble ; on peut donc l'inclure dans l'union et continuer en comparant, de la même manière, le premier élément du premier tableau avec le second du second tableau,
- si les deux éléments sont égaux, alors on sait que cet élément commun apparaît dans les deux ensembles ; on peut donc l'inclure dans l'union et continuer en comparant, de la même manière, le second élément du premier tableau avec le second du second tableau.
Une fois qu'on a parcouru ainsi la totalité des éléments d'au moins un des deux tableaux, on sait que les éventuels éléments restants dans l'autre tableau font tous partie de l'union.
Cette idée est mise en œuvre ci-dessous. Les éléments faisant partie de l'union sont stockés dans le tableau elementsU
, dont la taille est égale à la somme des tailles des deux tableaux — la taille de l'union étant forcément plus petite ou égale à elle. La boucle while
parcourt les deux tableaux en parallèle comme expliqué ci-dessus, tandis que les appels à arraycopy
qui suivent se chargent de copier les éléments restants dans l'un des tableaux une fois que l'autre a été parcouru en totalité. Finalement, un appel à copyOf
est fait pour « redimensionner » le tableau contenant l'union s'il est trop grand, afin de pouvoir le passer au constructeur de StringSet
.
public StringSet union(StringSet that) { String[] elementsU = new String[this.size() + that.size()]; int newSize = 0; int thisI = 0, thatI = 0; while (thisI < this.size() && thatI < that.size()) { String e1 = this.elements[thisI]; String e2 = that.elements[thatI]; int c = e1.compareTo(e2); if (c < 0) { elementsU[newSize++] = e1; thisI += 1; } else if (c > 0) { elementsU[newSize++] = e2; thatI += 1; } else { elementsU[newSize++] = e1; thisI += 1; thatI += 1; } } if (thisI < this.size()) { System.arraycopy(this.elements, thisI, elementsU, newSize, this.size() - thisI); newSize += this.size() - thisI; } else if (thatI < that.size()) { System.arraycopy(that.elements, thatI, elementsU, newSize, that.size() - thatI); newSize += that.size() - thatI; } if (newSize != elementsU.length) elementsU = Arrays.copyOf(elementsU, newSize); return new StringSet(elementsU); }
Partie 5
La méthode iterator
n'est pas très difficile à définir étant donné que les éléments de l'ensemble sont stockés dans un tableau, qu'il suffit de parcourir. L'itérateur de StringSet
est donc similaire à celui de SArrayList
donné dans le cours, mais encore plus simple puisque sa méthode remove
ne doit rien faire d'autre que lever une exception.
public Iterator<String> iterator() { return new Iterator<String>() { int nextI = 0; public boolean hasNext() { return nextI < elements.length; } public String next() { if (!hasNext()) throw new NoSuchElementException(); return elements[nextI++]; } }; }
Notez qu'il n'est pas nécessaire de définir la méthode remove
, car l'interface Iterator
en possède une version par défaut, qui a le comportement attendu. Si on désire toutefois la redéfinir, il faut le faire ainsi :
public void remove() { throw new UnsupportedOperationException(); }
Partie 6
La méthode toString
s'écrit aisément au moyen de la classe StringJoiner
.
public String toString() { StringJoiner j = new StringJoiner(", ", "[", "]"); for (String s : this) j.add(s); return j.toString(); }
Notez que comme StringSet
implémente Iterable
, il est aussi possible d'ajouter les éléments au StringJoiner
en utilisant la méthode forEach
, ainsi :
forEach(j::add);
Partie 7
Oui, StringSet
doit redéfinir hashCode
et equals
afin que ses instances soient comparées par structure, car il s'agit d'une classe immuable. La redéfinition de ces méthodes ne pose aucun problème, il faut juste prendre garde à utiliser les méthodes de Arrays
pour déterminer la valeur de hachage et effectuer le test d'égalité, car les méthodes hashCode
et equals
des tableaux fonctionnent par référence.
public int hashCode() { return Arrays.hashCode(elements); } public boolean equals(Object thatO) { if (thatO instanceof StringSet) { StringSet that = (StringSet) thatO; return Arrays.equals(this.elements, that.elements); } else return false; }
Partie 8
Pour généraliser StringSet
en une classe capable de stocker n'importe quel type d'éléments comparables, il faut bien entendu définir une classe générique. Son paramètre de type doit être borné pour garantir que les éléments sont effectivement comparables entre eux.
public final class ComparableSet<E extends Comparable<E>> implements Iterable<E> { … }
Partie 9
Étant donné que le bâtisseur de StringSet
doit utiliser un tableau de type String[]
pour stocker ses éléments, il peut le gérer de la même manière que la classe SArrayList
décrite dans le cours. C'est-à-dire qu'un tableau d'une capacité initiale fixe (p.ex. 10) est créé, et dès qu'il devient trop petit pour contenir la totalité des éléments, son contenu est transféré dans un nouveau tableau deux fois plus grand.
Une fois cette constatation faite, la méthode add
n'est pas très difficile à écrire, et est une version simplifiée de la méthode add
de la classe SArrayList
décrite dans le cours.
La méthode compact
est quant à elle un peu plus difficile à écrire. Dans un premier temps, elle appelle simplement la méthode sort
de Arrays
afin de trier les éléments. Cela fait, elle doit encore éliminer les doublons. Une manière relativement simple (et efficace) de le faire consiste à parcourir les éléments du tableau en utilisant deux index :
- un premier index « lent » (
slow
ci-dessous), qui désigne toujours la position à laquelle le prochain élément unique (c-à-d qui n'est pas un doublon) pourra être inséré, - un second index « rapide » (
fast
ci-dessous) qui parcourt la totalité des éléments du tableau, à la recherche de ceux qui sont uniques.
Les deux index commencent à 1, car l'élément à l'index 0 ne peut en aucun cas être un doublon. Comme le tableau est trié, les doublons se suivent forcément, et pour déterminer si l'élément désigné par l'index rapide est unique, il suffit de vérifier s'il est différent de son prédécesseur. Si c'est le cas, il est copié à la position désignée par l'index lent, qui est ensuite incrémenté.
Une fois tous les éléments ainsi examinés par l'index rapide, il ne reste plus qu'à effacer tous les éléments d'index supérieur à l'index lent. Cela n'est pas absolument nécessaire, mais fortement conseillé, car évite de conserver des références vers des objets (ici des chaînes) inutiles, ce qui peut diminuer la quantité de mémoire utilisée par le programme.
Finalement, la méthode build
est facile à écrire puisqu'il lui suffit d'appeler compact
pour garantir que le tableau est trié par ordre croissant et ne contient pas de doublons, avant d'appeler le constructeur de StringSet
avec une copie de la partie du tableau contenant effectivement des éléments.
public static final class Builder { private String[] elements = new String[10]; private int size = 0; public Builder add(String e) { if (size == elements.length) elements = Arrays.copyOf(elements, 2 * size); elements[size++] = e; return this; } public Builder compact() { Arrays.sort(elements, 0, size); int slow = 1; for (int fast = 1; fast < size; fast++) { if (elements[fast - 1].compareTo(elements[fast]) != 0) elements[slow++] = elements[fast]; } // optionnel mais conseillé ! Arrays.fill(elements, slow, size, null); size = slow; return this; } public StringSet build() { compact(); return new StringSet(Arrays.copyOf(elements, size)); } }
Notez que cette solution utilise une variante de la méthode sort
qui n'était malheureusement pas mentionnée dans le formulaire. Pour utiliser la version donnée dans le formulaire, il faut remplacer l'appel à sort
au début de compact
par les trois lignes suivantes :
if (size < elements.length) elements = Arrays.copyOf(elements, size); Arrays.sort(elements);
Le but de la copie additionnelle de elements
est de faire en sorte que le tableau passé à sort
ne contienne plus aucun élément nul, car ceux-ci provoquent la levée d'une exception.
4. Chemins circulaires
Partie 1
La méthode isCircularVia
doit vérifier deux choses :
- le chemin est bien circulaire,
- le chemin passe par la gare donnée.
La première vérification se fait en comparant les gares aux extrémités du chemin (station1
et station2
), tandis que la seconde se fait en parcourant les routes à la recherche d'une qui passe par la gare donnée.
private boolean isCircularVia(Station s) { if (station2.equals(station1)) for (Route r : routes) if (r.stations().contains(s)) return true; return false; }
Partie 2
La méthode circular
est très similaire à longest
, et ne diffère qu'en trois aspects :
- les chemins construits à l'initialisation sont ceux qui débutent à la gare de départ uniquement,
- lors de l'extension d'un chemin, si le chemin étendu est circulaire et passe par la gare donnée, on peut immédiatement le retourner,
- lorsque tous les chemins possibles ont été générés, et qu'aucun satisfaisant les critères n'a été trouvé, on retourne
null
.
Ces trois différences signifient que la méthode circular
diffère de la méthode longest
en trois endroits : dans la boucle d'initialisation (lignes 3 à 6), dans la boucle d'extension (lignes 13 à 16) et à la fin (ligne 21). En faisant référence aux lignes inchangées de longest
, on peut donc l'écrire ainsi :
public static Trail circular(List<Route> routes, Station start, Station via) { // ligne 2 for (Route r : routes) { if (r.stations().contains(start)) { var s2 = r.stationOpposite(start); trails.add(new Trail(start, s2, List.of(r))); } } // lignes 8 à 12 for (Route route : unusedRoutes) { Trail extended = trail.tryToExtendWith(route); if (extended != null) { if (extended.isCircularVia(via)) return extended; newTrails.add(extended); } } // lignes 18 à 20 return null; }
5. État immuable
Partie 1
Les interfaces ressemblent beaucoup aux classes du même nom, la seule différence étant que leurs méthodes sont abstraites et qu'elles ne contiennent bien entendu aucun attribut. Pour qu'une valeur de type GameState
soit utilisable partout où une valeur de type PublicGameState
est attendue, il suffit de faire en sorte que cette première interface étende la seconde.
Notez que la méthode withShuffledCards
donnée dans l'énoncé prenait un argument inutile, que nous avons reproduit ci-dessous mais qui peut aussi être supprimé.
public interface PublicGameState { int cardCount(); } public interface GameState extends PublicGameState { GameState withShuffledCards(Card c); }
Partie 2
La classe ConcretePublicGameState
est identique à la classe PublicGameState
de l'énoncé, à deux différences près :
- elle est finale, comme toute classe immuable,
- elle implémente l'interface
PublicGameState
.
public final class ConcretePublicGameState implements PublicGameState { private final int cardCount; public ConcretePublicGameState(int cardCount) { this.cardCount = cardCount; } public int cardCount() { return cardCount; } }
Partie 3
La classe ConcreteGameState
ressemble à la classe GameState
de l'énoncé, les différences étant :
- elle implémente
GameState
au lieu d'hériter deConcretePublicGameState
(ce qui ne serait pas possible), - elle n'appelle dès lors pas le super-constructeur dans son constructeur à elle,
- elle contient une mise en œuvre de
cardCount
, car elle ne l'hérite plus de sa classe mère, - sa méthode
withShuffledCards
retourne une valeur de typeGameState
mais crée bien entendu une instance deConcreteGameState
.
public final class ConcreteGameState implements GameState { private final List<Card> cards; public ConcreteGameState(List<Card> cards) { this.cards = List.copyOf(cards); } public int cardCount() { return cards.size(); } public GameState withShuffledCards(Card c) { List<Card> newCards = new ArrayList<>(cards); Collections.shuffle(newCards); return new ConcreteGameState(newCards); } }