TreeMap, SortedMap и NavigableMap в Java
Замените new HashMap<>() на new TreeMap<>(), чтобы ключи выводились по алфавиту, — и код, который годами работал, падает на map.put(null, 0) с NullPointerException. HashMap хранит null-ключ в отдельном бакете, а TreeMap должен сравнить ключ с другими через compareTo(), и для null это невозможно. Это не единственное различие: у отсортированной карты свои правила, свои исключения и набор методов поиска, которых у HashMap нет вовсе.
TreeMap — реализация интерфейса Map на основе красно-чёрного дерева, которая хранит пары «ключ–значение» отсортированными по ключу: в естественном порядке (Comparable) или по переданному Comparator. TreeMap реализует интерфейсы SortedMap (Java 1.2: первый/последний ключ, поддиапазоны) и NavigableMap (Java 6: поиск ближайшего ключа — floorKey(), ceilingKey(), lowerKey(), higherKey()). Операции get(), put(), remove() и containsKey() выполняются за O(log n).
Класс TreeMap: как устроен и как создать
TreeMap в Java хранит записи в узлах красно-чёрного дерева — самобалансирующегося двоичного дерева поиска, у которого высота всегда порядка log n. Поэтому поиск, вставка и удаление по ключу занимают O(log n), а обход карты всегда идёт по возрастанию ключей, независимо от порядка добавления.
Ключи сравниваются не через equals() и hashCode(), а через compareTo() или compare() компаратора: если сравнение вернуло 0, ключи считаются одинаковыми. Иерархия выглядит так:
Map<K,V>
└── SortedMap<K,V> (Java 1.2; с Java 21 расширяет SequencedMap)
└── NavigableMap<K,V> (Java 6)
├── TreeMap<K,V>
└── ConcurrentSkipListMap<K,V> (потокобезопасная) Базовые методы put(), get(), entrySet() и способы обхода общие для всех карт — они разобраны в уроке Интерфейс Map. Пример: зарплаты, добавленные не по алфавиту, TreeMap выводит отсортированными по фамилии:
import java.util.Map;
import java.util.TreeMap;
public class TreeMapExample {
public static void main(String[] args) {
Map<String, Double> salaries = new TreeMap<>();
salaries.put("Петров", 123.22);
salaries.put("Сидоров", 1378.00);
salaries.put("Иванов", 3434.34);
salaries.forEach((key, value) -> System.out.println(key + ": " + value));
}
} Вывод:
Иванов: 3434.34
Петров: 123.22
Сидоров: 1378.0 Строки сравниваются по кодам символов, поэтому кириллица сортируется по алфавиту, но все заглавные буквы идут раньше строчных (подробнее — в разделе Как compareTo сравнивает строки).
Конструкторы TreeMap
У TreeMap четыре конструктора: пустая карта с естественным порядком, пустая карта с компаратором и копирование из другой карты — обычной или отсортированной.
| Конструктор | Что создаёт | Граничный случай |
|---|---|---|
TreeMap() | Пустая карта, ключи в естественном порядке (Comparable) | Ключ без Comparable — ClassCastException на первом put() |
TreeMap(Comparator<? super K> comparator) | Пустая карта, порядок задаёт компаратор | null вместо компаратора — используется естественный порядок |
TreeMap(Map<? extends K, ? extends V> m) | Копия любой карты, ключи сортируются в естественном порядке | Компаратор исходной карты не наследуется, даже если передан TreeMap в переменной типа Map |
TreeMap(SortedMap<K, ? extends V> m) | Копия отсортированной карты с тем же компаратором | Строится за O(n): данные уже отсортированы |
TreeMap с Comparator
Чтобы отсортировать TreeMap не в естественном порядке, в конструктор передают Comparator: например, Comparator.reverseOrder() для убывания или String.CASE_INSENSITIVE_ORDER для строк без учёта регистра. Компаратор определяет не только порядок обхода, но и то, какие ключи карта считает одинаковыми.
NavigableMap<Integer, String> desc = new TreeMap<>(Comparator.reverseOrder());
desc.put(1, "a");
desc.put(5, "e");
desc.put(3, "c");
System.out.println(desc); // Вывод: {5=e, 3=c, 1=a}
System.out.println(desc.firstKey()); // Вывод: 5 — «первый» по правилу компаратора Что выведет этот код?
import java.util.TreeMap;
public class CaseInsensitiveKeys {
public static void main(String[] args) {
TreeMap<String, Integer> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
map.put("Java", 1);
map.put("JAVA", 2);
map.put("python", 3);
System.out.println(map);
System.out.println(map.get("jAvA"));
System.out.println(map.containsKey("PYTHON"));
}
} Вывод:
{Java=2, python=3}
2
true Компаратор вернул 0 для «Java» и «JAVA», поэтому второй put() не добавил новую запись, а заменил значение существующей. Ключ при этом остался прежним — «Java», а не «JAVA». Для словарей, логинов и HTTP-заголовков это удобно; для данных, где регистр важен, — источник тихой потери записей. Почему так происходит и как писать компаратор, согласованный с equals(), объясняет урок Comparable и Comparator.
Интерфейс SortedMap и его методы
SortedMap — интерфейс отсортированной карты: он гарантирует обход по возрастанию ключей и добавляет к Map методы для первого и последнего ключа и для вырезания поддиапазона ключей (headMap(), subMap(), tailMap()). Нижняя граница поддиапазона включается, верхняя — нет.
| Метод | Что возвращает | Граничный случай |
|---|---|---|
Comparator<? super K> comparator() | Компаратор карты | null, если используется естественный порядок |
K firstKey() | Наименьший ключ | Пустая карта — NoSuchElementException |
K lastKey() | Наибольший ключ | Пустая карта — NoSuchElementException |
SortedMap<K,V> headMap(K toKey) | Ключи строго меньше toKey | toKey не входит в результат |
SortedMap<K,V> subMap(K fromKey, K toKey) | Ключи от fromKey включительно до toKey не включительно | fromKey > toKey — IllegalArgumentException: fromKey > toKey |
SortedMap<K,V> tailMap(K fromKey) | Ключи больше или равные fromKey | fromKey входит в результат, если он есть в карте |
Пример: ключи добавлены в произвольном порядке, а методы SortedMap работают с ними как с отсортированным рядом 1, 2, 3, 4, 5, 100:
import java.util.SortedMap;
import java.util.TreeMap;
public class SortedMapExample {
public static void main(String[] args) {
SortedMap<Integer, Double> treeMap = new TreeMap<>();
treeMap.put(3, 3434.34);
treeMap.put(2, 1378.00);
treeMap.put(1, 123.22);
treeMap.put(4, 444.00);
treeMap.put(5, 555.00);
treeMap.put(100, 123.22);
System.out.println("treeMap: " + treeMap);
System.out.println("firstKey: " + treeMap.firstKey());
System.out.println("lastKey: " + treeMap.lastKey());
System.out.println("headMap(3): " + treeMap.headMap(3));
System.out.println("subMap(3, 5): " + treeMap.subMap(3, 5));
System.out.println("tailMap(4): " + treeMap.tailMap(4));
}
} Вывод:
treeMap: {1=123.22, 2=1378.0, 3=3434.34, 4=444.0, 5=555.0, 100=123.22}
firstKey: 1
lastKey: 100
headMap(3): {1=123.22, 2=1378.0}
subMap(3, 5): {3=3434.34, 4=444.0}
tailMap(4): {4=444.0, 5=555.0, 100=123.22} headMap(3) вырезает начало карты до ключа 3, не включая его. subMap(3, 5) берёт середину: ключ 3 входит, ключ 5 — нет. tailMap(4) возвращает «хвост» от ключа 4 включительно до конца. Те же правила границ действуют для множеств — методы headSet(), subSet(), tailSet() из урока SortedSet и TreeSet.
headMap, subMap и tailMap — живые представления
Методы поддиапазонов не копируют данные: они возвращают представление (view) исходной карты. Изменения в исходной карте сразу видны в представлении и наоборот, а добавить в представление ключ за пределами его диапазона нельзя.
TreeMap<Integer, String> map = new TreeMap<>(Map.of(10, "десять", 20, "двадцать", 30, "тридцать"));
SortedMap<Integer, String> head = map.headMap(25); // ключи меньше 25
map.put(15, "пятнадцать"); // добавили в исходную карту
System.out.println(head);
head.remove(10); // удалили через представление
System.out.println(map);
head.put(40, "сорок"); // 40 вне диапазона < 25 Вывод:
{10=десять, 15=пятнадцать, 20=двадцать}
{15=пятнадцать, 20=двадцать, 30=тридцать}
Exception in thread "main" java.lang.IllegalArgumentException: key out of range Если нужна независимая копия диапазона, оберните представление: new TreeMap<>(map.headMap(25)). Другие представления коллекций, включая Arrays.asList(), разобраны в уроке Backed Collections.
Интерфейс NavigableMap: поиск ближайших ключей
NavigableMap в Java — интерфейс, который расширяет SortedMap методами навигации: найти ближайший ключ меньше, не больше, не меньше или больше заданного, взять и удалить первую или последнюю запись, получить карту в обратном порядке и поддиапазоны с явными флагами включения границ. Появился в Java 6; реализуют его TreeMap и ConcurrentSkipListMap.
Главное отличие от get(): методы навигации не требуют точного совпадения ключа. Если ключа 35 нет, floorKey(35) вернёт ближайший ключ снизу, а не null.
| Метод NavigableMap | Что возвращает | Если подходящего ключа нет |
|---|---|---|
lowerKey(K key) / lowerEntry(K key) | Наибольший ключ (запись) строго меньше key: < | null |
floorKey(K key) / floorEntry(K key) | Наибольший ключ (запись) меньше или равный key: ≤ | null |
ceilingKey(K key) / ceilingEntry(K key) | Наименьший ключ (запись) больше или равный key: ≥ | null |
higherKey(K key) / higherEntry(K key) | Наименьший ключ (запись) строго больше key: > | null |
firstEntry() / lastEntry() | Первая / последняя запись без удаления | null для пустой карты (в отличие от firstKey()) |
pollFirstEntry() / pollLastEntry() | Первая / последняя запись с удалением из карты | null для пустой карты |
headMap(K toKey, boolean inclusive) | Ключи меньше toKey (или равные, если inclusive) | Пустое представление |
tailMap(K fromKey, boolean inclusive) | Ключи больше fromKey (или равные, если inclusive) | Пустое представление |
subMap(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive) | Ключи между границами, каждая включается отдельным флагом | fromKey > toKey — IllegalArgumentException |
descendingMap() | Представление карты в обратном порядке | — |
navigableKeySet() / descendingKeySet() | Ключи как NavigableSet по возрастанию / убыванию | — |
Записи, которые возвращают firstEntry(), floorEntry() и другие *Entry-методы TreeMap, — неизменяемые снимки: вызов entry.setValue(...) бросит UnsupportedOperationException. Значение меняют через map.put(entry.getKey(), newValue).
Пример ниже показывает все четыре метода поиска на одной карте, в том числе для ключа 35, которого в карте нет, и для крайних ключей:
import java.util.NavigableMap;
import java.util.TreeMap;
public class NavigableMapExample {
public static void main(String[] args) {
NavigableMap<Integer, String> map = new TreeMap<>();
map.put(30, "c");
map.put(10, "a");
map.put(50, "e");
map.put(20, "b");
map.put(40, "d");
System.out.println("lowerKey(30) = " + map.lowerKey(30));
System.out.println("floorKey(30) = " + map.floorKey(30));
System.out.println("ceilingKey(30) = " + map.ceilingKey(30));
System.out.println("higherKey(30) = " + map.higherKey(30));
System.out.println("floorKey(35) = " + map.floorKey(35));
System.out.println("ceilingKey(35) = " + map.ceilingKey(35));
System.out.println("lowerKey(10) = " + map.lowerKey(10));
System.out.println("higherKey(50) = " + map.higherKey(50));
System.out.println("floorEntry(35) = " + map.floorEntry(35));
}
} Вывод:
lowerKey(30) = 20
floorKey(30) = 30
ceilingKey(30) = 30
higherKey(30) = 40
floorKey(35) = 30
ceilingKey(35) = 40
lowerKey(10) = null
higherKey(50) = null
floorEntry(35) = 30=c Когда ключ есть в карте, floorKey() и ceilingKey() возвращают его самого, а lowerKey() и higherKey() — соседей. Когда ключа нет, строгие и нестрогие методы дают одинаковый результат. За пределами карты все четыре возвращают null, поэтому результат нужно проверять перед использованием. Те же методы для множеств называются lower(), floor(), ceiling(), higher() — урок NavigableSet.
Диапазоны и обратный порядок в NavigableMap
В NavigableMap у headMap(), tailMap() и subMap() есть перегрузки с флагами inclusive: true включает границу в результат, false исключает. Это снимает главное неудобство SortedMap, где верхняя граница всегда исключена. Для обхода от большего ключа к меньшему служит descendingMap().
Для той же карты {10=a, 20=b, 30=c, 40=d, 50=e}:
System.out.println(map.headMap(30)); // {10=a, 20=b}
System.out.println(map.headMap(30, true)); // {10=a, 20=b, 30=c}
System.out.println(map.tailMap(30, false)); // {40=d, 50=e}
System.out.println(map.subMap(20, false, 40, true)); // {30=c, 40=d}
System.out.println(map.descendingMap()); // {50=e, 40=d, 30=c, 20=b, 10=a}
System.out.println(map.descendingKeySet()); // [50, 40, 30, 20, 10] Методы pollFirstEntry() и pollLastEntry() превращают карту в очередь, отсортированную по ключу: каждый вызов возвращает крайнюю запись и удаляет её.
NavigableMap<Integer, String> queue = new TreeMap<>(map);
System.out.println("firstEntry: " + queue.firstEntry());
System.out.println("pollFirstEntry: " + queue.pollFirstEntry());
System.out.println("lastEntry: " + queue.lastEntry());
System.out.println("pollLastEntry: " + queue.pollLastEntry());
System.out.println("queue: " + queue); Вывод:
firstEntry: 10=a
pollFirstEntry: 10=a
lastEntry: 50=e
pollLastEntry: 50=e
queue: {20=b, 30=c, 40=d} firstEntry() и lastEntry() только читают крайние записи, а poll-методы возвращают те же записи и удаляют их, поэтому в итоговой карте нет ключей 10 и 50.
TreeMap в Java 21
С Java 21 (JEP 431) SortedMap расширяет SequencedMap, поэтому у TreeMap появился метод reversed() — то же, что descendingMap(). Но методы putFirst() и putLast() у TreeMap бросают UnsupportedOperationException: место ключа определяет сортировка, а не программист. Где эти методы действительно работают, показано в уроке LinkedHashMap.
Практические примеры NavigableMap
NavigableMap удобна везде, где значение зависит от диапазона, в который попадает ключ: шкалы оценок и скидок, тарифы по весу или сумме, расписания по времени. Ключом служит нижняя граница диапазона, а нужный диапазон находит floorEntry() за O(log n) вместо цепочки if-else.
Шкала оценок через floorEntry
import java.util.NavigableMap;
import java.util.TreeMap;
public class GradeScale {
public static void main(String[] args) {
NavigableMap<Integer, String> grades = new TreeMap<>();
grades.put(0, "неудовлетворительно");
grades.put(60, "удовлетворительно");
grades.put(75, "хорошо");
grades.put(90, "отлично");
for (int score : new int[]{95, 75, 74, 12}) {
System.out.println(score + " -> " + grades.floorEntry(score).getValue());
}
}
} Вывод:
95 -> отлично
75 -> хорошо
74 -> удовлетворительно
12 -> неудовлетворительно Для 75 floorEntry() находит точное совпадение, для 74 — ближайшую границу снизу, 60. А для отрицательного балла, например -5, ключа ≤ -5 в карте нет: floorEntry(-5) вернёт null, и getValue() бросит NullPointerException. Поэтому шкала должна начинаться с минимально возможного значения, или результат нужно проверять на null.
Расписание: события в интервале времени
import java.time.LocalTime;
import java.util.NavigableMap;
import java.util.TreeMap;
public class Schedule {
public static void main(String[] args) {
NavigableMap<LocalTime, String> schedule = new TreeMap<>();
schedule.put(LocalTime.of(9, 0), "Планёрка");
schedule.put(LocalTime.of(10, 30), "Код-ревью");
schedule.put(LocalTime.of(13, 0), "Обед");
schedule.put(LocalTime.of(15, 0), "Демо");
schedule.put(LocalTime.of(17, 30), "Ретро");
LocalTime now = LocalTime.of(12, 0);
System.out.println("С 10:00 до 15:00: "
+ schedule.subMap(LocalTime.of(10, 0), true, LocalTime.of(15, 0), true));
System.out.println("Следующее: " + schedule.ceilingEntry(now));
System.out.println("Предыдущее: " + schedule.lowerEntry(now));
}
} Вывод:
С 10:00 до 15:00: {10:30=Код-ревью, 13:00=Обед, 15:00=Демо}
Следующее: 13:00=Обед
Предыдущее: 10:30=Код-ревью LocalTime реализует Comparable, поэтому компаратор не нужен. Флаг true у верхней границы включил событие ровно в 15:00 — с subMap(from, to) из SortedMap «Демо» в результат бы не попало.
TreeMap vs HashMap: что выбрать
HashMap выбирают по умолчанию: операции в среднем O(1), но порядок ключей не гарантирован. TreeMap медленнее — O(log n) на операцию, — зато хранит ключи отсортированными, умеет искать ближайший ключ и вырезать диапазоны. Если нужен только порядок добавления, а не сортировка, подходит LinkedHashMap.
| Признак | HashMap | TreeMap |
|---|---|---|
| Порядок обхода | Не гарантирован | По возрастанию ключей или по компаратору |
| Структура внутри | Хеш-таблица (массив бакетов) | Красно-чёрное дерево |
get(), put(), remove() | O(1) в среднем | O(log n) гарантированно |
| Требования к ключу | Корректные equals() и hashCode() | Comparable или Comparator, желательно согласованный с equals() |
null-ключ | Один разрешён | NullPointerException при естественном порядке; допустим с компаратором вроде Comparator.nullsFirst(...) |
null-значения | Разрешены | Разрешены |
| Поиск ближайшего ключа, диапазоны | Нет | floorKey(), ceilingEntry(), subMap() и другие методы NavigableMap |
| Потокобезопасность | Нет; замена — ConcurrentHashMap | Нет; замена — ConcurrentSkipListMap |
| Когда выбирать | Поиск по ключу, порядок не важен | Нужен отсортированный вывод, минимум/максимум, поиск по диапазону |
Почему HashMap работает за O(1) и что происходит при коллизиях, разобрано в уроке HashMap: как устроена внутри, а сводная таблица всех реализаций Map — в уроке Интерфейс Map. Частый приём: собрать данные в HashMap, а отсортировать один раз перед выводом — new TreeMap<>(hashMap).
На чём чаще всего ошибаются
Большинство ошибок с TreeMap связаны с тем, что ключи сравниваются через compareTo()/compare(), а не через equals(), и с тем, что границы диапазонов включаются по-разному. Типичные промахи и их исправления:
| Что сделали | Что произойдёт | Как исправить |
|---|---|---|
put(null, value) в new TreeMap<>() | NullPointerException | Не использовать null-ключи или передать Comparator.nullsFirst(Comparator.naturalOrder()) |
Ключ своего класса без Comparable и без компаратора | ClassCastException: ... cannot be cast to class java.lang.Comparable на первом put() | Реализовать Comparable или передать Comparator в конструктор |
Изменили поле ключа, которое участвует в сравнении, после put() | Ключ оказывается не на своём месте в дереве: get() и remove() его не находят | Использовать неизменяемые ключи: String, Integer, LocalDate, record |
Компаратор возвращает 0 для разных ключей | Записи «склеиваются»: значение перезаписывается, ключ остаётся первым | Добавить второй критерий через thenComparing() |
Ждали, что headMap(k) или subMap(a, k) включит k | Ключ k не попадает в результат | headMap(k, true), subMap(a, true, k, true) |
put() в представление ключа вне его диапазона | IllegalArgumentException: key out of range | Добавлять в исходную карту |
Часто задаваемые вопросы
Почему firstKey() на пустом TreeMap бросает исключение, а firstEntry() возвращает null?
Так сложилось исторически: firstKey() и lastKey() пришли из SortedMap (Java 1.2) и для пустой карты бросают NoSuchElementException, а firstEntry(), lastEntry() и poll-методы добавлены в NavigableMap (Java 6) и возвращают null. Если карта может быть пустой, удобнее firstEntry() с проверкой на null.
Потокобезопасен ли TreeMap?
Нет. Для общего доступа из нескольких потоков есть ConcurrentSkipListMap — он тоже реализует NavigableMap с теми же методами, но работает без глобальной блокировки и не допускает null ни в ключах, ни в значениях. Обёртка Collections.synchronizedSortedMap(new TreeMap<>()) тоже подходит, но блокирует всю карту на каждую операцию, а перебор нужно синхронизировать вручную.
Можно ли получить элемент TreeMap по индексу?
Нет, метода get(int index) у TreeMap нет: дерево не хранит позиции узлов. N-й ключ находят перебором (map.keySet().stream().skip(n).findFirst()) за O(n). Позицию ключа можно узнать через map.headMap(key).size(), но размер представления тоже считается перебором. Если такие запросы частые, держите отдельный отсортированный список ключей.
Как перебрать TreeMap в обратном порядке?
Через map.descendingMap() — это представление без копирования, по нему работают и forEach(), и entrySet(); на Java 21 то же делает map.reversed(). Если обратный порядок нужен всегда, создайте карту сразу с Comparator.reverseOrder(). Пример с выводом — в разделе Диапазоны и обратный порядок в NavigableMap.
Комментарии