Recherche dans les collections Java
Trouvez des éléments dans les collections Java avec contains, indexOf, binarySearch et les streams.
« Cet élément est-il dans la collection ? » semble être une seule question, mais Java y répond d'une demi-douzaine de façons différentes, avec des coûts différents et des types de retour différents. Savoir lequel utiliser peut transformer une boucle critique de 50 millisecondes en 50 microsecondes. Ce chapitre est un tour d'horizon des méthodes de recherche sur les collections, les maps qui les encapsulent, et les helpers statiques de Collections.
Le modèle mental basé sur le coût
Le coût de chaque méthode de recherche est déterminé par la collection sous-jacente, et non par le site d'appel. Choisissez la bonne collection dès le départ et vos recherches seront gratuites ; choisissez la mauvaise et aucune méthode astucieuse ne vous sauvera.
| Collection | contains / lookup | Pourquoi |
|---|---|---|
HashSet, LinkedHashSet, HashMap.keySet() | O(1) attendu | Lookup dans un bucket de hachage |
TreeSet, TreeMap.keySet() | O(log n) | Arbre rouge-noir |
ArrayList, LinkedList, Vector | O(n) | Balayage linéaire |
ArrayList trié + Collections.binarySearch | O(log n) | Recherche binaire sur une liste indexée |
LinkedList + Collections.binarySearch | O(n) | La recherche binaire doit indexer — O(n) par étape |
Deux règles empiriques :
- Si vous appelez
containssouvent, utilisez unSet. Construire unHashSetà partir d'uneListet l'interroger est presque toujours plus rapide quelist.containsdans une boucle. - Si les données sont triées et indexées, utilisez
Collections.binarySearch. Cela devient rentable à partir d'environ 30 éléments sur la plupart des JVM.
Collection.contains(o)
Toutes les Collection le possèdent. La sémantique est basée sur l'égalité :
boolean has = list.contains("alpha"); // uses .equalsPour une List, il s'agit d'un balayage linéaire — O(n). Pour un HashSet, c'est un lookup dans un bucket de hachage — O(1) attendu. Pour un TreeSet, c'est un parcours d'arbre en O(log n). La signature de la méthode est la même ; le coût ne l'est pas.
null est autorisé (la méthode retourne si la collection contient un élément null), sauf si la collection rejette null catégoriquement — TreeSet avec l'ordre naturel, EnumSet, ConcurrentHashMap.keySet().
List.indexOf et lastIndexOf
Les listes prennent en charge plus que le simple « oui/non » — elles retournent la position :
int firstA = list.indexOf("alpha"); // -1 if absent
int lastA = list.lastIndexOf("alpha");Balayage linéaire depuis le début (ou la fin). Pour un ArrayList<String> de mille éléments, c'est acceptable. Pour un million, construisez une Map<String, Integer> une fois et interrogez-la.
Map.containsKey, containsValue, get, getOrDefault
Les méthodes de recherche propres aux maps se divisent clairement :
map.containsKey("alpha"); // O(1) for HashMap, O(log n) for TreeMap
map.get("alpha"); // returns the value or null
map.getOrDefault("alpha", 0); // returns the value or your default
map.containsValue("v"); // O(n) — scans every entrycontainsValue est le piège. Elle parcourt chaque entrée à chaque fois. Si vous vous retrouvez à l'appeler plusieurs fois, construisez une map inverse (Map<V, K>) ou un Set<V> de valeurs une fois et interrogez-le.
getOrDefault est un petit mais important changement de paradigme : il remplace l'ancien idiome Integer n = map.get(k); if (n == null) n = 0; par une seule ligne, et la valeur par défaut n'est utilisée que quand la clé est absente — pas quand la valeur est null. (Pour « absent ou null », utilisez Objects.requireNonNullElse(map.get(k), 0).)
Collections.binarySearch
Recherche binaire sur une liste triée :
List<String> sorted = new ArrayList<>(...);
Collections.sort(sorted);
int hit = Collections.binarySearch(sorted, "delta"); // 2 (some index)
int miss = Collections.binarySearch(sorted, "zeta"); // negativeDeux prérequis :
- La liste doit être triée dans l'ordre utilisé par la recherche. Si vous avez trié avec un comparateur, passez le même comparateur à
binarySearch. Des ordres incompatibles produisent des résultats incohérents (silencieusement — pas d'exception). - La liste devrait être indexée (
ArrayList, pasLinkedList). Sur une liste chaînée, la recherche binaire est en O(n log n), ce qui est pire qu'un balayage linéaire.
La valeur de retour encode à la fois « trouvé » et « où insérer » :
int i = Collections.binarySearch(sorted, key);
if (i >= 0) {
// key is at index i
} else {
int insertAt = -i - 1;
sorted.add(insertAt, key); // keeps the list sorted
}L'arithmétique -i - 1 est la façon dont chaque routine « trouver ou insérer » dans le JDK gère un échec. Cela vaut la peine d'être mémorisé.
Collections.frequency et disjoint
Deux helpers qui encapsulent des patterns de recherche courants :
int n = Collections.frequency(coll, "alpha"); // how many times "alpha" appears
boolean none = Collections.disjoint(a, b); // no element of a is in bfrequency est O(n). Pour des requêtes répétées avec différentes cibles, comptez une fois avec un stream dans une Map<T, Long>.
disjoint est implémenté intelligemment : il itère sur la collection la plus petite et vérifie contains sur la plus grande si celle-ci est un Set, en échangeant les arguments en coulisses. Ainsi, Collections.disjoint(largeList, smallSet) est O(largeList) — et plus rapide que de l'implémenter soi-même.
Recherche basée sur les streams
Les streams gèrent « trouver le premier élément correspondant » avec findFirst / findAny, et « y a-t-il une correspondance » avec anyMatch / allMatch / noneMatch :
Optional<Person> match = people.stream()
.filter(p -> p.age() >= 18 && p.name().startsWith("A"))
.findFirst();
boolean any = people.stream().anyMatch(p -> p.age() >= 65);
boolean all = people.stream().allMatch(p -> p.age() >= 0);
boolean non = people.stream().noneMatch(p -> p.age() < 0);Les streams court-circuitent sur findFirst et anyMatch — ils s'arrêtent dès qu'une correspondance est trouvée. Ce sont les réponses les plus claires pour une recherche basée sur un prédicat. Ils ne sont pas plus rapides que contains pour une recherche par égalité sur la bonne structure de données — un HashSet.contains battra toujours stream().anyMatch(x -> x.equals(target)).
Optional<T> mérite une attention particulière (il a un chapitre dans la partie programmation fonctionnelle). Pour l'instant : findFirst().isPresent() est l'expression la plus claire pour « avons-nous trouvé quelque chose ? » pour un prédicat.
LinkedHashSet pour « contains et ordre »
Un pattern courant : vous avez besoin d'un contains rapide et d'une itération dans l'ordre d'insertion. LinkedHashSet est la réponse :
LinkedHashSet<String> seen = new LinkedHashSet<>();
for (String line : input) {
if (seen.add(line)) System.out.println(line); // print first occurrences only
}add retourne true uniquement la première fois. L'ensemble rejette les doublons en O(1) et préserve l'ordre d'insertion pour l'itération. C'est le bon outil pour « dédupliquer tout en conservant l'ordre » — ni HashSet (perd l'ordre) ni ArrayList (contains lent) n'est aussi adapté.
Exemple concret : comparaison de cinq stratégies de recherche sur les mêmes données
Le programme ci-dessous charge 100 000 chaînes dans différentes collections et chronomètre cinq stratégies de recherche pour 1 000 accès aléatoires : ArrayList.contains, HashSet.contains, TreeSet.contains, Collections.binarySearch sur une liste triée, et stream().anyMatch.
Ce qu'il faut retenir de l'exécution :
HashSet.containsetCollections.binarySearchsur l'ArrayListtriée sont nettement plus rapides qu'ArrayList.containspour les recherches répétées. La table de hachage gagne pour « toute égalité », la recherche binaire gagne quand les données doivent être maintenues triées pour d'autres raisons.TreeSet.containsest juste derrière, mais pas gratuit — chaque lookup parcourt un arbre de profondeur ~log₂(100 000) ≈ 17, avec des défauts de cache pour les pointeurs d'arbre.stream().anyMatchpour la recherche par égalité est la pire option ici : même O(n) quelist.containsmais avec un overhead d'allocation supplémentaire par requête. Utilisez-le pour les prédicats, pas pour l'égalité simple sur une liste.- L'appel avec une clé manquante a retourné une valeur négative, et
-i - 1a donné l'index où"zzz"serait inséré pour conserver la liste triée. C'est la même convention queTreeMap.subMapetArrays.binarySearchutilisent.
Et ensuite
Vous avez maintenant couvert l'itération, l'ordonnancement, le tri et la recherche — les quatre opérations mécaniques que le framework de collections existe pour faciliter. Le dernier chapitre de cette partie est l'histoire moderne pour la seule chose qu'aucun d'eux n'a abordée : l'immutabilité. Les collections non modifiables Java couvre List.of, Set.of, Map.of, et les wrappers Collections.unmodifiable* — quand chacun est le bon choix, et pourquoi un pattern de « copie défensive » qui prenait autrefois quatre lignes tient désormais en une seule.