📦 Раздел 4 · Вопрос #13

Что такое TreeSet и как он работает

В отличие от HashSet, где порядок элементов хаотичен и зависит от хэш-кодов, в TreeSet элементы упорядочены либо по их естественному порядку (Natural Ordering через интерфейс Co...


🟢 Junior Level

1. Назначение и основные свойства

TreeSet — это реализация интерфейса NavigableSet (расширяющего SortedSet), в которой все элементы автоматически хранятся в отсортированном порядке и дубликаты исключены.

В отличие от HashSet, где порядок элементов хаотичен и зависит от хэш-кодов, в TreeSet элементы упорядочены либо по их естественному порядку (Natural Ordering через интерфейс Comparable), либо с помощью переданного в конструктор объекта Comparator.

// Естественная сортировка (Natural Order)
Set<Integer> numbers = new TreeSet<>();
numbers.add(42);
numbers.add(10);
numbers.add(5);
numbers.add(99);

System.out.println(numbers); // Всегда выведет: [5, 10, 42, 99]

2. Ключевые особенности TreeSet

  1. Автоматическая сортировка: при каждой вставке элемент сразу занимает правильное место в структуре.
  2. Внутреннее устройство: является обёрткой над сбалансированным красно-чёрным деревом TreeMap.
  3. Запрет null: попытка добавить null в стандартный TreeSet приводит к выбросу NullPointerException, так как null невозможно сравнить с другими элементами (null.compareTo(...)).
  4. Сложность операций: основные операции (add, remove, contains) выполняются за $O(\log N)$, что медленнее, чем среднее время $O(1)$ у HashSet.

🟡 Middle Level

1. Архитектура под капотом: красно-чёрное дерево

TreeSet делегирует все вызовы внутреннему экземпляру NavigableMap<E, Object> m (по умолчанию TreeMap):

public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, java.io.Serializable {
    private transient NavigableMap<E, Object> m;
    private static final Object PRESENT = new Object();

    public TreeSet() {
        this(new TreeMap<>());
    }

    public boolean add(E e) {
        return m.put(e, PRESENT) == null;
    }
}

В основе TreeMap лежит красно-чёрное дерево (Red-Black Tree) — самобалансирующееся бинарное дерево поиска со следующими инвариантами:

  1. Каждый узел либо красный (RED), либо чёрный (BLACK).
  2. Корень дерева всегда чёрный.
  3. Все листья (NIL/null) считаются чёрными.
  4. Если узел красный, оба его дочерних узла обязаны быть чёрными (два красных узла не могут идти подряд).
  5. Любой простой путь от данного узла до любого листового узла содержит одинаковое число чёрных узлов (чёрная высота).

Благодаря этим инвариантам самый длинный путь от корня к листу не превышает самый короткий более чем в 2 раза, гарантируя высоту дерева $h \le 2 \log_2(N + 1)$ и время поиска/вставки/удаления строго $O(\log N)$. При нарушении баланса выполняются перекрашивания узлов и повороты (левый и правый).

        15(B)
       /     \
     9(R)    23(R)
    /   \        \
  3(B)  11(B)    30(B)

2. Принципиальный парадокс: compareTo() против equals()

[!WARNING] TreeSet полностью игнорирует методы hashCode() и equals()! Уникальность и равенство элементов определяются исключительно результатом Comparable.compareTo() или Comparator.compare(). Два элемента считаются эквивалентными тогда и только тогда, когда compare(a, b) == 0.

Это фундаментальное нарушение общего контракта Set, который предписывает проверку уникальности через equals():

// Классический пример с BigDecimal:
Set<BigDecimal> hashSet = new HashSet<>();
Set<BigDecimal> treeSet = new TreeSet<>();

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("1.00");

// equals() сравнивает значение И точность (scale):
System.out.println(d1.equals(d2));       // false (scale 1 vs scale 2)
// compareTo() сравнивает только числовое значение:
System.out.println(d1.compareTo(d2));    // 0

hashSet.add(d1);
hashSet.add(d2);
System.out.println(hashSet.size());      // 2 (оба элемента добавлены!)

treeSet.add(d1);
treeSet.add(d2);
System.out.println(treeSet.size());      // 1 (d2 отвергнут как дубликат!)

Если у объектов compareTo() == 0, но equals() == false, TreeSet не добавит второй объект. Это частая причина трудноуловимых багов при работе с бизнес-сущностями.


3. Навигационные методы NavigableSet

TreeSet реализует богатый интерфейс NavigableSet, предоставляя операции поиска ближайших граничных элементов и срезов:

NavigableSet<Integer> set = new TreeSet<>(List.of(10, 20, 30, 40, 50));

// Поиск граничных элементов:
System.out.println(set.lower(30));    // 20 (строго < 30)
System.out.println(set.floor(30));    // 30 (наибольший <= 30)
System.out.println(set.ceiling(35));  // 40 (наименьший >= 35)
System.out.println(set.higher(40));   // 50 (строго > 40)

// Очередь с приоритетом:
System.out.println(set.pollFirst()); // 10 (извлекает и удаляет наименьший)
System.out.println(set.pollLast());  // 50 (извлекает и удаляет наибольший)

🔴 Senior Level

1. Архитектура диапазонов (Live Views) и их ловушки

Методы получения срезов (subSet, headSet, tailSet) не создают новую коллекцию в памяти! Они возвращают легковесную «проекцию» (View — внутренний класс AscendingSubMap или DescendingSubMap), которая делегирует запросы исходному дереву с валидацией границ:

TreeSet<Integer> set = new TreeSet<>();
for (int i = 1; i <= 10; i++) set.add(i);

NavigableSet<Integer> view = set.subSet(3, true, 7, true);
System.out.println(view); // [3, 4, 5, 6, 7]

// Модификация через View меняет исходный TreeSet:
view.remove(5);
System.out.println(set.contains(5)); // false!

// Добавление в исходный TreeSet мгновенно отражается во View:
set.add(4);

// Попытка вставить элемент вне диапазона View:
view.add(100); // Выбросит java.lang.IllegalArgumentException: key out of range

Создание subSet() выполняется за константное время $O(1)$, но удержание ссылки на subSet не позволяет сборщику мусора освободить всё дерево целиком.


2. Анализ потребления памяти (Memory Footprint) и Cache Misses

Узел TreeMap$Entry — одна из самых «тяжеловесных» нод во всей стандартной библиотеке Java:

static final class Entry<K,V> implements Map.Entry<K,V> {
    K key;
    V value;
    Entry<K,V> left;
    Entry<K,V> right;
    Entry<K,V> parent;
    boolean color = BLACK;
}

На 64-битной JVM с Compressed OOPs (-XX:+UseCompressedOops):

  • Заголовок объекта: 12 байт
  • Поля: 5 ссылок (key, value, left, right, parent) по 4 байта = 20 байт
  • Примитивное поле color (boolean): 1 байт
  • Выравнивание (Padding) до кратности 8: 7 байт
  • Итого размер одной ноды: 40 байт (против 32 байт у HashMap$Node).
  • Без Compressed OOPs размер ноды составляет 64 байта!

Проблема аппаратной локальности кэша (Cache Locality):

Узлы дерева создаются в разное время и распределены хаотично по всей области Heap. При обходе дерева каждый переход по ссылке node.left или node.right приводит к CPU Cache Miss (штраф 100–300 циклов процессора на чтение из RAM). По этой причине последовательная итерация по миллиону элементов в TreeSet работает в 10–50 раз медленнее, чем итерация по плоскому массиву ArrayList или даже по HashSet.


3. Интеграция с Java 21: SequencedSet

В Java 21 TreeSet реализует интерфейс SequencedSet, предоставляющий унифицированный API для упорядоченных последовательностей:

TreeSet<String> set = new TreeSet<>(List.of("alpha", "beta", "gamma"));

System.out.println(set.getFirst()); // "alpha"
System.out.println(set.getLast());  // "gamma"

// Получение реверсивного представления за O(1):
SequencedSet<String> reversed = set.reversed();
System.out.println(reversed.getFirst()); // "gamma"

// Ловушка: методы явного позиционирования запрещены!
set.addFirst("omega"); // Выбросит java.lang.UnsupportedOperationException!
set.addLast("omega");  // Выбросит java.lang.UnsupportedOperationException!

Почему addFirst() / addLast() выбрасывают исключение: порядок элементов в дереве жестко детерминирован значениями ключей и компаратором. Пользователь не может вручную назначить элемент первым или последним в отсортированном множестве.


🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«TreeSet — это реализация NavigableSet, основанная на сбалансированном красно-чёрном дереве TreeMap. Обеспечивает хранение уникальных элементов в отсортированном виде со сложностью $O(\log N)$ для добавления, удаления и поиска. Принципиальная особенность: уникальность и равенство элементов проверяются исключительно через Comparable.compareTo() или переданный Comparator, игнорируя equals() и hashCode(). По умолчанию не допускает null (вызывает NullPointerException). Предоставляет мощный API диапазонов (subSet, floor, ceiling), возвращающий легковесные живые представления (Views) за $O(1)$».


4 каверзных вопроса на интервью с ответами

1. Что произойдет, если положить в HashSet и в TreeSet два объекта BigDecimal("2.0") и BigDecimal("2.00")?

Ответ:

  • В HashSet будет 2 элемента, потому что HashSet использует equals(). Метод BigDecimal.equals() сравнивает не только числовую величину, но и масштаб (scale). Так как scale равен 1 и 2 соответственно, equals() вернет false.
  • В TreeSet будет 1 элемент, потому что TreeSet использует compareTo(). Метод BigDecimal.compareTo() сравнивает только числовое значение (2.0 == 2.00), возвращая 0. Второй объект будет отброшен как дубликат.

2. Можно ли добавить null в TreeSet?

Ответ: По умолчанию нельзя — бросается NullPointerException. Однако если сконструировать TreeSet с кастомным компаратором, поддерживающим null (например, new TreeSet<>(Comparator.nullsFirst(Comparator.naturalOrder()))), то один или несколько null добавить можно (по компаратору они будут считаться равными дубликатами и храниться в одном экземпляре как минимальный элемент).

3. Как устроен метод subSet() и создает ли он копию данных?

Ответ: Метод subSet() не копирует данные ($O(1)$ по времени и памяти). Он возвращает внутреннее представление (AscendingSubMap / DescendingSubMap), накладывающее фильтрацию по диапазону ключей [fromKey, toKey] поверх исходного дерева. Все изменения в subSet мгновенно отражаются в исходном TreeSet и наоборот. Попытка вставить во View элемент за пределами его диапазона приводит к IllegalArgumentException.

4. Почему методы addFirst() и addLast(), появившиеся в Java 21 через SequencedSet, выбрасывают UnsupportedOperationException в TreeSet?

Ответ: В TreeSet порядок элементов не зависит от воли вызывающего кода — он строго определен значениями элементов и их компаратором. Разрешение addFirst(x) нарушило бы инвариант сортировки красно-черного дерева, поэтому данные методы объявлены неподдерживаемыми.


Типичные ошибки и Red Flags

  • ❌ Несогласованность compareTo() и equals(): Если a.compareTo(b) == 0, но a.equals(b) == false, TreeSet молча отбросит объект b, нарушая контракт Set.
  • ❌ Мутация полей объекта, участвующих в сортировке: Если изменить поле ключа после добавления в TreeSet, дерево перейдет в некорректное состояние: бинарный поиск пойдет не по той ветке, элемент невозможно будет найти или удалить.
  • ❌ Использование TreeSet ради итерации в порядке вставки: Для порядка вставки нужен LinkedHashSet ($O(1)$), а не TreeSet ($O(\log N)$).
  • ❌ Предположение, что TreeSet потокобезопасен: TreeSet не синхронизирован. Параллельная запись может разрушить структуру красно-черного дерева. Требуется внешняя синхронизация или ConcurrentSkipListSet.

Связанные темы