Интерфейсы SortedSet, NavigableSet и класс TreeSet в Java
Расписание паромов хранится в TreeSet: [1830, 2010]. Нужен последний паром до 16:00. Вызов times.headSet(1600).last() бросает NoSuchElementException: подмножество пустое. А times.lower(1600) просто вернёт null. Оба способа ищут одно и то же, но первый взят из SortedSet, а второй — из NavigableSet.
SortedSet (java.util.SortedSet<E>) — интерфейс отсортированного множества: элементы без повторов хранятся по возрастанию в естественном порядке (Comparable) или по правилу Comparator. NavigableSet расширяет его методами поиска ближайших элементов: ceiling(), floor(), higher(), lower(). TreeSet — основная реализация обоих интерфейсов на красно-чёрном дереве, операции add(), remove() и contains() выполняются за O(log n).
Интерфейс SortedSet: методы first, last, headSet, subSet, tailSet
SortedSet в Java — это Set, который всегда хранит элементы отсортированными. Он добавляет шесть методов: first() и last() возвращают крайние элементы, headSet(), subSet() и tailSet() — диапазоны, а comparator() — правило сортировки. Порядок задаёт Comparable элементов или переданный Comparator.
| Метод SortedSet | Что возвращает | Граничный случай |
|---|---|---|
Comparator<? super E> comparator() | Компаратор множества | При естественном порядке — null |
E first() | Наименьший (первый) элемент | Пустое множество — NoSuchElementException |
E last() | Наибольший (последний) элемент | Пустое множество — NoSuchElementException |
SortedSet<E> headSet(E toElement) | Элементы, строго меньшие toElement | toElement не включается |
SortedSet<E> subSet(E fromElement, E toElement) | Элементы от fromElement включительно до toElement не включительно | fromElement больше toElement — IllegalArgumentException: fromKey > toKey |
SortedSet<E> tailSet(E fromElement) | Элементы, большие или равные fromElement | fromElement включается |
Пример использования методов subSet(), headSet(), tailSet(), first() и last():
import java.util.SortedSet;
import java.util.TreeSet;
public class SortedSetExample {
public static void main(String[] args) {
SortedSet<String> set = new TreeSet<>();
set.add("Харьков");
set.add("Киев");
set.add("Львов");
set.add("Кременчуг");
set.add("Харьков");
System.out.println(set);
SortedSet<String> subSet = set.subSet("Киев", "Львов");
System.out.println("SubSet: " + subSet);
System.out.println("HeadSet: " + set.headSet("Львов"));
System.out.println("TailSet: " + set.tailSet("Львов"));
System.out.println("Первый элемент: " + set.first());
System.out.println("Последний элемент: " + set.last());
}
} Результат выполнения программы:
[Киев, Кременчуг, Львов, Харьков]
SubSet: [Киев, Кременчуг]
HeadSet: [Киев, Кременчуг]
TailSet: [Львов, Харьков]
Первый элемент: Киев
Последний элемент: Харьков Повторный «Харьков» не добавился — это всё ещё множество. «Львов» не попал ни в subSet("Киев", "Львов"), ни в headSet("Львов"): верхняя граница диапазона в SortedSet всегда исключается, а нижняя — включается. Строки сравниваются по кодам символов, поэтому для словарного порядка с буквой «Ё» нужен Collator — подробно в уроке Comparable и Comparator.
SortedSet в Java 21
С Java 21 SortedSet расширяет SequencedSet, поэтому у TreeSet есть getFirst(), getLast(), removeFirst(), removeLast() и reversed(). А вот addFirst() и addLast() бросают UnsupportedOperationException: место элемента в отсортированном множестве определяет компаратор, а не программист. На Java 17 вместо getFirst() пишут first().
Интерфейс NavigableSet: ceiling, floor, higher, lower
NavigableSet в Java — интерфейс, который расширяет SortedSet методами навигации: найти ближайший элемент к заданному значению, извлечь крайний элемент с удалением, обойти множество в обратном порядке и взять диапазон с явным включением границ. Интерфейс появился в Java 6, его реализуют TreeSet и ConcurrentSkipListSet.
Методы поиска не бросают исключений: если подходящего элемента нет, они возвращают null. Разница между парами — в строгости сравнения: ceiling() и floor() принимают равный элемент, higher() и lower() — нет.
| Метод NavigableSet | Что возвращает | Если элемента нет |
|---|---|---|
E ceiling(E obj) | Наименьший элемент, больший или равный obj | null |
E floor(E obj) | Наибольший элемент, меньший или равный obj | null |
E higher(E obj) | Наименьший элемент, строго больший obj | null |
E lower(E obj) | Наибольший элемент, строго меньший obj | null |
E pollFirst() | Первый (наименьший) элемент, удаляя его | null для пустого множества |
E pollLast() | Последний (наибольший) элемент, удаляя его | null для пустого множества |
NavigableSet<E> headSet(E toElement, boolean inclusive) | Элементы меньше toElement; при inclusive = true — и равный ему | Пустое представление |
NavigableSet<E> tailSet(E fromElement, boolean inclusive) | Элементы больше fromElement; при inclusive = true — и равный ему | Пустое представление |
NavigableSet<E> subSet(E fromElement, boolean fromInclusive, E toElement, boolean toInclusive) | Элементы между границами; каждая граница включается, если её флаг true | fromElement больше toElement — IllegalArgumentException |
NavigableSet<E> descendingSet() | Представление того же множества в обратном порядке | Пустое представление |
Iterator<E> descendingIterator() | Итератор от наибольшего элемента к наименьшему | hasNext() сразу false |
Что выведет этот код? Множество одно, а четыре метода дают четыре разных ответа:
import java.util.List;
import java.util.NavigableSet;
import java.util.TreeSet;
public class NearestExample {
public static void main(String[] args) {
NavigableSet<Integer> set = new TreeSet<>(List.of(10, 20, 30));
System.out.println(set.ceiling(20)); // 20 — равный подходит
System.out.println(set.higher(20)); // 30 — только строго больший
System.out.println(set.floor(20)); // 20
System.out.println(set.lower(20)); // 10
System.out.println(set.ceiling(35)); // null — больше 35 ничего нет
System.out.println(set.floor(5)); // null — меньше 5 ничего нет
}
} Вывод:
20
30
20
10
null
null Пример использования интерфейса NavigableSet на практике — расписание паромов. Одна и та же задача решается методами SortedSet и методами NavigableSet:
import java.util.NavigableSet;
import java.util.SortedSet;
import java.util.TreeSet;
public class Ferry {
public static void main(String[] args) {
NavigableSet<Integer> times = new TreeSet<>();
times.add(1205);
times.add(1505);
times.add(1545);
times.add(1830);
times.add(2010);
times.add(2100);
// Через методы SortedSet: headSet() и tailSet()
System.out.println("SortedSet");
SortedSet<Integer> headSet = times.headSet(1600);
System.out.println("Последний паром перед 16:00 - " + headSet.last());
SortedSet<Integer> tailSet = times.tailSet(2000);
System.out.println("Первый паром после 20:00 - " + tailSet.first());
System.out.println();
// Через методы NavigableSet: lower() и higher()
System.out.println("NavigableSet");
System.out.println("Последний паром перед 16:00 - " + times.lower(1600));
System.out.println("Первый паром после 20:00 - " + times.higher(2000));
}
} Вывод:
SortedSet
Последний паром перед 16:00 - 1545
Первый паром после 20:00 - 2010
NavigableSet
Последний паром перед 16:00 - 1545
Первый паром после 20:00 - 2010 Результаты совпадают, но вариант с lower() и higher() короче и безопаснее: если паромов до 16:00 нет, он вернёт null, а headSet(1600).last() бросит NoSuchElementException, как в примере из начала урока.
headSet, tailSet и subSet с флагами inclusive
Методы диапазонов в SortedSet работают с полуоткрытым интервалом: нижняя граница включается, верхняя — нет. В NavigableSet у тех же методов есть перегрузки с параметром boolean inclusive, и включение каждой границы задаётся явно. Обе версии возвращают представление исходного множества, а не копию.
import java.util.List;
import java.util.NavigableSet;
import java.util.TreeSet;
public class RangeExample {
public static void main(String[] args) {
NavigableSet<Integer> nums = new TreeSet<>(List.of(10, 20, 30, 40, 50));
System.out.println(nums.headSet(30)); // [10, 20]
System.out.println(nums.headSet(30, true)); // [10, 20, 30]
System.out.println(nums.tailSet(30)); // [30, 40, 50]
System.out.println(nums.tailSet(30, false)); // [40, 50]
System.out.println(nums.subSet(20, 40)); // [20, 30]
System.out.println(nums.subSet(20, false, 40, true)); // [30, 40]
System.out.println(nums.subSet(40, 20)); // IllegalArgumentException
}
} Вывод:
[10, 20]
[10, 20, 30]
[30, 40, 50]
[40, 50]
[20, 30]
[30, 40]
Exception in thread "main" java.lang.IllegalArgumentException: fromKey > toKey Границы не обязаны быть элементами множества: nums.subSet(15, 35) вернёт [20, 30]. Версия без флагов эквивалентна headSet(toElement, false), tailSet(fromElement, true) и subSet(fromElement, true, toElement, false).
Подмножества — это представления (backed-collection)
Методы headSet(), tailSet(), subSet() и descendingSet() не копируют элементы, а возвращают представление исходного множества — по-другому это называется backed-collection. Изменения видны в обе стороны: добавление в исходное множество появляется в подмножестве, удаление из подмножества удаляет элемент из исходного. Добавить в представление значение вне его диапазона нельзя.
import java.util.List;
import java.util.NavigableSet;
import java.util.TreeSet;
public class BackedSetExample {
public static void main(String[] args) {
TreeSet<Integer> all = new TreeSet<>(List.of(10, 20, 30, 40, 50));
NavigableSet<Integer> head = all.headSet(30, true);
System.out.println(head); // [10, 20, 30]
all.add(25);
System.out.println(head); // [10, 20, 25, 30]
head.remove(10);
System.out.println(all); // [20, 25, 30, 40, 50]
head.add(45); // 45 вне диапазона "до 30 включительно"
}
} Вывод:
[10, 20, 30]
[10, 20, 25, 30]
[20, 25, 30, 40, 50]
Exception in thread "main" java.lang.IllegalArgumentException: key out of range Если нужна независимая копия диапазона, оберните представление: new TreeSet<>(all.headSet(30, true)). Тот же механизм у subMap() в TreeMap и у Arrays.asList() разобран в уроке Backed Collections.
pollFirst, pollLast, descendingSet и обратный итератор
pollFirst() и pollLast() возвращают наименьший и наибольший элементы и сразу удаляют их, а для пустого множества возвращают null. descendingSet() даёт представление в обратном порядке, descendingIterator() — итератор от большего к меньшему. Обычный iterator() у TreeSet всегда обходит элементы по возрастанию.
import java.util.Iterator;
import java.util.List;
import java.util.TreeSet;
public class PollDescendingExample {
public static void main(String[] args) {
TreeSet<String> tasks = new TreeSet<>(List.of("b", "d", "a", "c"));
System.out.println(tasks.descendingSet()); // [d, c, b, a]
Iterator<String> it = tasks.descendingIterator();
while (it.hasNext()) {
System.out.print(it.next() + " "); // d c b a
}
System.out.println();
System.out.println(tasks.pollFirst()); // a
System.out.println(tasks.pollLast()); // d
System.out.println(tasks); // [b, c]
TreeSet<String> empty = new TreeSet<>();
System.out.println(empty.pollFirst()); // null
System.out.println(empty.first()); // NoSuchElementException
}
} Вывод:
[d, c, b, a]
d c b a
a
d
[b, c]
null
Exception in thread "main" java.util.NoSuchElementException Пара pollFirst() в цикле while удобна, чтобы обработать элементы по возрастанию и одновременно очистить множество. Итератор TreeSet fail-fast: если изменить множество во время обхода не через сам итератор, следующий next() бросит ConcurrentModificationException — подробнее в уроке Iterator.
Класс TreeSet: устройство и конструкторы
TreeSet в Java — это класс, реализующий NavigableSet (а значит, и SortedSet, и Set). Внутри он хранит элементы как ключи TreeMap — красно-чёрного дерева, поэтому add(), remove() и contains() выполняются за O(log n). Это медленнее, чем O(1) в среднем у HashSet, но быстрее линейного поиска O(n) в списке.
Пример использования класса TreeSet:
import java.util.SortedSet;
import java.util.TreeSet;
public class TreeSetExample {
public static void main(String[] args) {
SortedSet<String> set = new TreeSet<>();
set.add("Харьков");
set.add("Киев");
set.add("Львов");
set.add("Кременчуг");
set.add("Харьков");
System.out.println(set);
}
} Элементы были добавлены в произвольном порядке, но при выводе на консоль они отсортированы:
[Киев, Кременчуг, Львов, Харьков] Порядок задаётся при создании множества и потом не меняется. Конструкторы класса TreeSet:
| Конструктор | Порядок элементов | Нюанс |
|---|---|---|
TreeSet() | Естественный (Comparable) | Элемент без Comparable — ClassCastException при первом add(); null — NullPointerException |
TreeSet(Collection<? extends E> collection) | Естественный | Дубликаты отбрасываются; компаратор исходной коллекции не наследуется |
TreeSet(Comparator<? super E> comparator) | По компаратору | Дубликатом считается элемент, для которого compare() вернул 0 |
TreeSet(SortedSet<E> sortedSet) | Как у sortedSet | Копирует и элементы, и компаратор |
Какой из двух последних конструкторов вызовется, решает компилятор по объявленному типу переменной, а не по реальному объекту:
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
public class TreeSetConstructorExample {
public static void main(String[] args) {
SortedSet<String> desc = new TreeSet<>(Comparator.reverseOrder());
desc.addAll(List.of("a", "b", "c"));
System.out.println(new TreeSet<>(desc)); // [c, b, a]
Collection<String> asCollection = desc;
System.out.println(new TreeSet<>(asCollection)); // [a, b, c]
}
} Вывод:
[c, b, a]
[a, b, c] В первом вызове выбран TreeSet(SortedSet), и копия сохранила обратный порядок. Во втором та же коллекция объявлена как Collection, вызывается TreeSet(Collection) — и копия молча сортируется по возрастанию.
TreeSet не смотрит на equals()
Уникальность элементов в TreeSet проверяется через compareTo() или compare(), а не через equals() и hashCode(). Компаратор, который сравнивает объекты только по одному полю, «склеит» разные объекты с одинаковым значением этого поля. Почему так происходит и как это исправить через thenComparing() — в разделе Интерфейс Comparator, пример с BigDecimal — в разделе Согласованность compareTo с equals.
SortedSet, NavigableSet и TreeSet: что выбрать
SortedSet и NavigableSet — интерфейсы, TreeSet — класс, который их реализует, поэтому вопрос «SortedSet vs TreeSet» сводится к тому, каким типом объявить переменную. Объявляйте NavigableSet, если нужны ceiling(), floor() или флаги диапазонов, SortedSet — если хватает first(), last() и subSet(). В new указывается реализация.
| Тип | Что это | Ключевые методы | Реализации |
|---|---|---|---|
SortedSet<E> | Интерфейс отсортированного множества (Java 1.2) | first(), last(), headSet(), subSet(), tailSet(), comparator() | TreeSet, ConcurrentSkipListSet |
NavigableSet<E> | Расширение SortedSet (Java 6) | ceiling(), floor(), higher(), lower(), pollFirst(), pollLast(), descendingSet() | TreeSet, ConcurrentSkipListSet |
TreeSet<E> | Класс на красно-чёрном дереве, не потокобезопасен | Все методы NavigableSet, операции O(log n) | — |
ConcurrentSkipListSet<E> | Потокобезопасный класс на списке с пропусками (Java 6) | Все методы NavigableSet, операции O(log n) в среднем | — |
Если сортировка не нужна, берите HashSet или LinkedHashSet — их различия описаны в уроке Set, HashSet и LinkedHashSet. Аналог NavigableSet для пар «ключ–значение» — NavigableMap и класс TreeMap из урока Map, а общая таблица выбора коллекции — в разделе Какую реализацию выбрать.
Где чаще всего ошибаются
Большинство ошибок с TreeSet связано с границами диапазонов, пустым множеством и тем, что подмножества — представления, а не копии. Ниже — типичные промахи, что при этом происходит и как написать правильно.
| Ошибка | Что происходит | Как правильно |
|---|---|---|
set.first() или headSet(x).last() на пустом множестве | NoSuchElementException | Проверить isEmpty() или использовать lower(x), pollFirst() с проверкой на null |
int next = set.ceiling(100); для TreeSet<Integer> | Если элемента нет, ceiling() возвращает null, и автораспаковка бросает NullPointerException | Integer next = set.ceiling(100); if (next != null) { ... } |
Ожидание, что subSet(20, 40) включит 40 | Верхняя граница исключена: [20, 30] | subSet(20, true, 40, true) |
higher() вместо ceiling() | Равный элемент пропускается: higher(20) вернёт 30 | ceiling()/floor(), если равный подходит |
subSet(40, 20) | IllegalArgumentException: fromKey > toKey | Меньшая граница первой; для обратного порядка — descendingSet().subSet(40, 20) |
Добавление в headSet() значения вне диапазона | IllegalArgumentException: key out of range | Добавлять в исходное множество |
| Изменение подмножества как независимой копии | Меняется и исходный TreeSet | new TreeSet<>(set.subSet(a, b)) |
set.add(null) при естественном порядке | NullPointerException | Не хранить null или задать Comparator.nullsFirst(...) |
Часто задаваемые вопросы
Как отсортировать Set в Java?
У Set нет метода sort(): у множества нет позиций. Отсортированную копию получают через new TreeSet<>(set) (естественный порядок) или new TreeSet<>(comparator) и затем addAll(set). Если нужен список, пишут set.stream().sorted().toList(). Исходный HashSet при этом не меняется, и порядок у него остаётся произвольным.
Есть ли в Java потокобезопасный TreeSet?
TreeSet не потокобезопасен. Готовая замена — ConcurrentSkipListSet из java.util.concurrent: он реализует NavigableSet и не блокирует всё множество. Обёртки Collections.synchronizedSortedSet() и Collections.synchronizedNavigableSet() (Java 8) тоже работают, но используют одну блокировку на все операции, а перебор нужно вручную заключать в synchronized.
Как получить k-й по величине элемент TreeSet?
Метода get(int) у TreeSet нет. k-й наименьший элемент берут перебором: set.stream().skip(k).findFirst() — это O(k). Позицию элемента можно узнать через set.headSet(x).size(), но size() у представления считается обходом, то есть O(n). Если такие запросы частые, удобнее отсортированный ArrayList и Collections.binarySearch().
Есть ли в Java класс OrderedSet?
Нет. Множество с порядком вставки — LinkedHashSet, отсортированное множество — TreeSet (интерфейсы SortedSet и NavigableSet). В Java 21 у обоих появился общий интерфейс SequencedSet с методами getFirst(), getLast() и reversed(). Чем «упорядоченная» коллекция отличается от «отсортированной» — в уроке Коллекции в Java.
Практика по теме
Закрепите урок на задачах — у каждой есть уровень и оценка времени.
Все задачи модуля →Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии