Поиск в коллекциях Java
Поиск элементов в коллекциях Java: contains, indexOf, binarySearch и поиск через stream.
«Есть ли этот элемент в коллекции?» — звучит как один вопрос, но Java отвечает на него полудюжиной разных способов с разными затратами и разными типами возвращаемых значений. Умение выбрать нужный способ превращает горячий цикл продолжительностью 50 миллисекунд в 50-микросекундный. Эта глава — обзор методов поиска в коллекциях, отображениях и статических помощниках класса Collections.
Мысленная модель с учётом стоимости
Стоимость каждого метода поиска определяется используемой коллекцией, а не местом вызова. Выберите правильную коллекцию заранее — и поиск будет почти бесплатным; выберите неправильную — и никакой умный вызов метода не поможет.
| Коллекция | contains / поиск | Почему |
|---|---|---|
HashSet, LinkedHashSet, HashMap.keySet() | O(1) ожидаемое | Поиск по хэш-бакету |
TreeSet, TreeMap.keySet() | O(log n) | Красно-чёрное дерево |
ArrayList, LinkedList, Vector | O(n) | Линейный перебор |
Отсортированный ArrayList + Collections.binarySearch | O(log n) | Бинарный поиск по индексированному списку |
LinkedList + Collections.binarySearch | O(n) | Бинарный поиск требует индексации — O(n) на каждом шаге |
Два практических правила:
- Если вы часто вызываете
contains, используйтеSet. СозданиеHashSetизListи последующие запросы к нему почти всегда быстрее, чемlist.containsв цикле. - Если данные отсортированы и индексированы, используйте
Collections.binarySearch. Это окупается примерно после 30 элементов на большинстве JVM.
Collection.contains(o)
Этот метод есть в каждой Collection. Семантика основана на равенстве:
boolean has = list.contains("alpha"); // uses .equalsДля List это линейный перебор — O(n). Для HashSet — поиск по хэш-бакету — O(1) ожидаемое. Для TreeSet — обход дерева — O(log n). Сигнатура метода одинакова; стоимость — нет.
null допускается (метод возвращает true, если коллекция содержит элемент null), если только коллекция не отвергает null явно — как TreeSet с естественным упорядочиванием, EnumSet, ConcurrentHashMap.keySet().
List.indexOf и lastIndexOf
Списки поддерживают не только ответ «да/нет» — они возвращают позицию:
int firstA = list.indexOf("alpha"); // -1 if absent
int lastA = list.lastIndexOf("alpha");Линейный перебор от начала (или с конца). Для ArrayList<String> из тысячи элементов это нормально. Для миллиона — создайте Map<String, Integer> один раз и запрашивайте его.
Map.containsKey, containsValue, get, getOrDefault
Методы поиска, специфичные для отображений, чётко разделяются:
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 — это ловушка. Он перебирает все записи каждый раз. Если вы вызываете его больше одного раза, создайте обратное отображение (Map<V, K>) или Set<V> значений один раз и запрашивайте его.
getOrDefault — небольшой, но важный сдвиг в подходе: он заменяет старый идиом Integer n = map.get(k); if (n == null) n = 0; одной строкой, и значение по умолчанию используется только при отсутствии ключа — не когда значение равно null. (Для «отсутствует или null» используйте Objects.requireNonNullElse(map.get(k), 0).)
Collections.binarySearch
Бинарный поиск по отсортированному списку:
List<String> sorted = new ArrayList<>(...);
Collections.sort(sorted);
int hit = Collections.binarySearch(sorted, "delta"); // 2 (some index)
int miss = Collections.binarySearch(sorted, "zeta"); // negativeДва предусловия:
- Список должен быть отсортирован в том порядке, в котором будет выполняться поиск. Если сортировка выполнялась с компаратором, передайте тот же компаратор в
binarySearch. Несовпадающие порядки дают бессмысленные результаты (без исключений). - Список должен быть индексированным (
ArrayList, а неLinkedList). На связном списке бинарный поиск работает за O(n log n) — хуже линейного.
Возвращаемое значение кодирует и «найдено», и «куда вставить»:
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
}Арифметика -i - 1 — это способ, которым каждая процедура «найти или вставить» в JDK обрабатывает промах. Стоит запомнить.
Collections.frequency и disjoint
Два вспомогательных метода, обёртывающих распространённые паттерны поиска:
int n = Collections.frequency(coll, "alpha"); // how many times "alpha" appears
boolean none = Collections.disjoint(a, b); // no element of a is in bfrequency работает за O(n). Для повторных запросов с разными целями подсчитайте значения один раз с помощью stream в Map<T, Long>.
disjoint реализован умно: он перебирает меньшую коллекцию и вызывает contains на большей, если та является Set, меняя аргументы местами под капотом. Поэтому Collections.disjoint(largeList, smallSet) работает за O(largeList) — и быстрее, чем написанная вручную реализация.
Поиск через Stream
Stream обрабатывают «найти первый подходящий элемент» с помощью findFirst / findAny, а «есть ли хоть одно совпадение» — с помощью 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);Stream выполняют сокращённое вычисление для findFirst и anyMatch — они останавливаются, как только находят совпадение. Это самый чистый ответ для поиска по предикату. Они не быстрее contains при поиске по равенству на правильной структуре данных — HashSet.contains всегда обгонит stream().anyMatch(x -> x.equals(target)).
Optional<T> заслуживает отдельного внимания (у него есть глава в части про функциональное программирование). Пока: findFirst().isPresent() — самое чистое выражение «нашли ли мы что-нибудь?» для предиката.
LinkedHashSet для «contains и порядок»
Распространённый паттерн: вам нужен быстрый contains и итерация в порядке вставки. LinkedHashSet — это решение:
LinkedHashSet<String> seen = new LinkedHashSet<>();
for (String line : input) {
if (seen.add(line)) System.out.println(line); // print first occurrences only
}add возвращает true только в первый раз. Множество отвергает дубликаты за O(1) и сохраняет порядок вставки для итерации. Это правильный инструмент для «дедуплицировать с сохранением порядка» — ни HashSet (теряет порядок), ни ArrayList (медленный contains) не справятся так же хорошо.
Практический пример: сравнение пяти стратегий поиска на одних данных
Программа ниже помещает 100 000 строк в разные коллекции и измеряет время пяти стратегий поиска для 1 000 случайных запросов: ArrayList.contains, HashSet.contains, TreeSet.contains, Collections.binarySearch на отсортированном списке и stream().anyMatch.
Что стоит вынести из запуска:
HashSet.containsиCollections.binarySearchна отсортированномArrayListзначительно быстрееArrayList.containsдля повторных запросов. Хэш-таблица побеждает при «любом равенстве», бинарный поиск — когда данные нужно держать отсортированными и по другим причинам.TreeSet.containsсовсем рядом, но не бесплатен — каждый запрос проходит по дереву глубиной ~log₂(100 000) ≈ 17 с промахами кэша для указателей дерева.stream().anyMatchдля поиска по равенству — худший вариант здесь: то же O(n), что иlist.contains, но с дополнительными накладными расходами на аллокацию при каждом запросе. Используйте его для предикатов, а не для простого равенства по списку.- Вызов с отсутствующим ключом вернул отрицательное значение, а
-i - 1дало индекс, куда вставить"zzz", чтобы список остался отсортированным. Это та же конвенция, которую используютTreeMap.subMapиArrays.binarySearch.
Что дальше
Вы теперь рассмотрели итерацию, упорядочивание, сортировку и поиск — четыре механических операции, ради которых существует collections framework. Последняя глава этой части посвящена современной истории о том, чего ни одна из них не касалась: неизменяемости. Java Unmodifiable Collections охватывает List.of, Set.of, Map.of и обёртки Collections.unmodifiable* — когда каждый из них является правильным выбором и почему паттерн «защитной копии», который раньше занимал четыре строки, теперь умещается в одну.