сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Коллекции Java

Итерация и порядок в коллекциях

Сортировка и итерация: контракты, которые мстят

Компаратор через вычитание выглядит невинно. Я скормил ему два числа: минус два миллиарда и плюс два миллиарда. Результат вышел +294 967 296, то есть положительный - компаратор утверждает, что минус два миллиарда БОЛЬШЕ плюс двух миллиардов. Integer.compare на тех же числах даёт минус единицу.

Тема выглядит служебной, но здесь живут два продакшен-инцидента: сообщение «Comparison method violates its general contract» посреди ночи и коллекция, испорченная удалением на обходе. Оба - про нарушенные контракты.

// Формулировки: «Comparable или Comparator?», «почему сортировка кинула исключение?», «что такое стабильная сортировка?»

Comparable, Comparator и сборка компараторов

Comparable - естественный порядок, зашитый в сам класс через compareTo. У числа, строки, даты он один и очевиден. Comparator - внешний порядок, и таких может быть сколько угодно: по возрасту, по имени, по дате регистрации.

Современные компараторы собирают комбинаторами: comparing по ключу, дальше thenComparing для разрешения равенства. Есть и явная политика для пустых значений через nullsFirst и nullsLast - вместо NPE (NullPointerException) в середине сортировки.

// Про reversed я проверил обе формы на одном наборе. comparing(age).reversed().thenComparing(name) даёт возраст по убыванию, а имена внутри по алфавиту. А comparing(age).thenComparing(name).reversed() разворачивает ВСЮ цепочку: и возраст, и имена идут по убыванию. Разница в одной точке, а результат разный.

Comparable
естественный порядок внутри класса, он один
reversed
разворачивает всю цепочку до себя, а не последний ключ

Контракт сравнения: что ловится, а что нет

Контракт требует трёх вещей: антисимметрии, транзитивности и стабильности между вызовами. Сортировка в JDK - это TimSort, и он контракт ПРОВЕРЯЕТ. Я скормил ему нетранзитивный компаратор (считает «почти равными» числа, отличающиеся меньше чем на десять) и получил ровно то самое: Comparison method violates its general contract! Компаратор, который никогда не возвращает ноль, ловится так же.

А теперь неприятное. Тот же TimSort на компараторе через вычитание НЕ упал. Сто тысяч чисел отсортировались молча. Проверил результат на маленьком наборе - он просто неправильный: вычитанием получилось [1500000000, -1500000000, 0, 2000000000, -2000000000], а через Integer.compare нормальное [-2000000000, -1500000000, 0, 1500000000, 2000000000].

// Вывод для практики жёсткий: исключение про контракт - это подарок, оно хотя бы кричит. Переполнение при вычитании обычно не кричит вовсе и отдаёт молча перепутанный порядок. Сравнивать числа надо через Integer.compare или Long.compare, всегда.

Comparator<Integer> bad = (x, y) -> x - y;
bad.compare(-2_000_000_000, 2_000_000_000); // +294967296, знак врёт
// правильно: Integer.compare(x, y)
TimSort
сортировка JDK, проверяет контракт компаратора
молчаливое переполнение
вычитание врёт знаком, исключения при этом нет

Стабильность и два лагеря итераторов

TimSort стабилен: элементы, равные по компаратору, сохраняют исходный взаимный порядок. Проверил на четырёх людях - отсортировал сначала по имени, потом по возрасту, и внутри каждого возраста имена остались по алфавиту. Отсюда практический приём: многоключевую сортировку можно делать последовательными проходами, от менее важного ключа к более важному.

Итераторы обычных коллекций работают на опережение: счётчик модификаций ловит изменение во время обхода и бросает исключение. Но это детектор, а не гарантия - я показывал в теме про списки, что удаление предпоследнего элемента проходит молча.

// Другой лагерь - итераторы, которые не падают вовсе. CopyOnWriteArrayList обходит снимок, сделанный в момент создания итератора, ConcurrentHashMap даёт слабо согласованный обход. Исключений нет, но и свежести данных никто не обещает: увидишь состояние где-то между началом и концом обхода.

стабильная сортировка
равные элементы не меняют взаимный порядок
обход снимка
итератор работает по копии, исключений нет

Как отвечать: «Comparable или Comparator - когда что берёшь?»

Comparable беру, когда у класса есть один естественный бесспорный порядок: числа, даты, версии. Он зашивается в сам класс через compareTo. Comparator - для всех остальных случаев: внешних порядков бывает много, и им не место внутри доменного класса. Сортировку пользователей по возрасту или по имени я собираю комбинаторами comparing и thenComparing прямо на месте использования. Дальше три вещи из практики. Первая: сравнивать числа вычитанием нельзя, оно переполняется - я проверял, для минус двух и плюс двух миллиардов результат выходит положительным, то есть знак врёт; правильный способ Integer.compare. Вторая: метод reversed разворачивает всю цепочку до себя, а не последний ключ, и это регулярно ловят на код-ревью. Третья: compareTo стоит согласовывать с equals, потому что TreeSet считает дубликатом всё, что даёт ноль при сравнении, и один и тот же набор в HashSet и TreeSet может дать разное число элементов.

Сильный ответ: чёткий критерий выбора - один бесспорный порядок против многих внешних, плюс три подвоха с последствиями. Каждый подвох назван вместе с правильным решением, а не брошен упоминанием.

На чём валят

  • Пишут компаратор через вычитание. Переполнение врёт знаком, и сортировка молча даёт неправильный порядок - исключения при этом может и не быть.
  • Ловят «violates its general contract» в try и идут дальше. Чинить надо компаратор: сообщение означает, что он несогласован.
  • Ставят reversed в конце цепочки thenComparing. Разворачивается вся цепочка, а не последний ключ.
  • Не согласуют compareTo с equals. TreeSet съедает неравные объекты как дубликаты, живой пример - BigDecimal.
  • Считают детектор изменения на обходе гарантией. Он лучше, чем ничего, но пропуски бывают.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 17, остальные разбираются в тренажёре.

  1. #iteration_ordering1 / 5
    Что определяет Comparator, переданный в TreeMap или Collections.sort?
    A)Правило, по которому из коллекции удаляются дубликаты перед тем, как начать сортировку элементов
    B)Признак того, поддерживает ли коллекция параллельную сортировку сразу в нескольких рабочих потоках
    C)Порядок элементов, отличный или заменяющий естественный
    D)Функцию хеширования элементов, по которой они раскладываются по бакетам перед упорядочиванием
    показать ответ и разбор
    +C)Порядок элементов, отличный или заменяющий естественный

    // разбор: Comparator — объект/лямбда, задающий порядок сравнения элементов. Его передают в sort, TreeMap/TreeSet, min/max, priority-очереди, чтобы упорядочить не по естественному порядку (Comparable), а по своему правилу — или для классов, у которых Comparable нет. Удобно комбинировать: comparing(...).thenComparing(...).reversed(). В TreeMap Comparator ещё и определяет равенство ключей (compareTo==0), а не equals.

  2. #iteration_ordering2 / 5
    Гарантирует ли Collections.sort() стабильность (сохранение порядка равных элементов)?
    A)Нет, порядок равных элементов после сортировки случаен и меняется от запуска к запуску программы
    B)Да, но только если элементы реализуют Comparable; при использовании Comparator стабильность теряется
    C)Нет, стабильность нужно включать отдельным флагом, иначе сортировка переставляет равные элементы
    D)Да, сортировка списков стабильна — равные элементы сохраняют относительный порядок
    показать ответ и разбор
    +D)Да, сортировка списков стабильна — равные элементы сохраняют относительный порядок

    // разбор: Сортировка списков (Collections.sort / List.sort) — стабильная: элементы, равные по компаратору, остаются в исходном относительном порядке. Внутри это вариант merge sort (TimSort). Стабильность важна для многоуровневой сортировки: отсортировав сначала по одному ключу, потом по другому, получаем предсказуемый комбинированный порядок. Заметьте: сортировка примитивных массивов (Arrays.sort(int[])) — быстрая, но НЕ стабильная (quicksort).

  3. #iteration_ordering3 / 5
    Чем ListIterator мощнее обычного Iterator?
    A)Умеет идти в обе стороны, менять и вставлять элементы, знает индекс
    B)Он потокобезопасен и позволяет нескольким потокам одновременно обходить один и тот же список без блокировок
    C)Он автоматически пропускает null-элементы и дубликаты, обходя только уникальные ненулевые значения
    D)Он кэширует весь список в памяти заранее, поэтому его обход быстрее обычного итератора
    показать ответ и разбор
    +A)Умеет идти в обе стороны, менять и вставлять элементы, знает индекс

    // разбор: ListIterator (только у списков) расширяет Iterator: обход вперёд И назад (hasPrevious/previous), изменение текущего элемента set(), вставка add(), а также nextIndex/previousIndex. Обычный Iterator умеет лишь hasNext/next/remove в одном направлении. ListIterator удобен, когда по ходу обхода нужно править список или двигаться в обе стороны. Потокобезопасности он не добавляет — тоже fail-fast.

  4. #iteration_ordering4 / 5
    Во что компилятор разворачивает for-each по коллекции?
    A)В обычный индексный цикл for(i=0; i<size; i++) с обращением к элементам через get(i) по номеру
    B)В цикл с Iterator: hasNext()/next()
    C)В параллельный Stream, который распределяет элементы коллекции по потокам пула ForkJoinPool
    D)В рекурсивный обход, где каждый следующий элемент обрабатывается вложенным вызовом того же метода
    показать ответ и разбор
    +B)В цикл с Iterator: hasNext()/next()

    // разбор: for-each по Iterable компилируется в цикл, берущий Iterator и вызывающий hasNext()/next(). Поэтому он работает с любой Iterable-коллекцией (не только списками) и почему нельзя менять её структуру мимо итератора (CME). Для LinkedList это эффективнее, чем for по get(i) (у которого доступ по индексу O(n)). Для массивов же for-each разворачивается в индексный цикл — там итератора нет.

  5. #iteration_ordering5 / 5
    Что произойдёт при обходе HashMap: гарантирован ли порядок ключей?
    A)Да, HashMap обходит ключи в порядке их вставки, как LinkedHashMap, но чуть медленнее
    B)Да, ключи обходятся строго отсортированными по возрастанию их естественного порядка сравнения
    C)Нет, порядок обхода HashMap не гарантирован и может меняться
    D)Да, порядок фиксирован их хеш-кодами и одинаков в разных JVM и версиях Java для тех же ключей
    показать ответ и разбор
    +C)Нет, порядок обхода HashMap не гарантирован и может меняться

    // разбор: HashMap не даёт никаких гарантий порядка обхода: он зависит от хешей ключей и текущей ёмкости таблицы, а после resize может измениться. Полагаться на него — источник плавающих багов. Нужен порядок вставки — LinkedHashMap; отсортированный — TreeMap. Это частая ошибка: тесты «случайно» проходят на одном наборе данных, а на другом/в другой версии Java порядок другой.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.