JavaRush /Курсы /Kotlin SELF /Сортировка по ключу и нескольким критериям в Kotlin

Сортировка по ключу и нескольким критериям в Kotlin

Kotlin SELF
23 уровень , 2 лекция
Открыта

1. Введение

Когда вы сортируете список чисел, всё просто: число и есть значение, по которому сравниваем. Но реальная жизнь быстро подсовывает данные сложнее: «категория–сумма», «имя–баллы», «товар–цена», «покупка–стоимость–комментарий». Элементы уже не “сами просят” сравнить их напрямую, и нам нужно решить: по чему именно сортируем.

Вот это «по чему» и называют ключом сортировки: мы берём элемент, вычисляем из него сравнимое значение (ключ) и сортируем по ключу. Kotlin как раз даёт для этого очень удобные инструменты: sortedBy и sortedByDescending.

Представьте, что у вас коробка с посылками, а на каждой посылке есть наклейка “вес”. Сортировать можно коробки (элементы списка), но сравнивать удобнее по наклейке (ключу). Если наклейка — вес, сортируем по весу; если наклейка — город, сортируем по городу.

2. Сортировка по ключу через sortedBy и sortedByDescending

Начнём с самого частого сценария: у нас есть список, элементы которого не обязательно «сравнимы сами по себе», зато из каждого элемента мы можем достать что-то сравнимое (число, строку, длину строки). Kotlin-операция sortedBy { selector } как раз принимает функцию‑селектор ключа и возвращает новый список, отсортированный по возрастанию ключа.

Сортировка по возрастанию: sortedBy {...}

Сначала — мини‑пример без нашего проекта, просто чтобы увидеть механику. Мы сортируем строки по длине (ключ — it.length):

fun main() {
    val words = listOf("bbb", "a", "cc")
    val sorted = words.sortedBy { it.length }

    println(sorted) // [a, cc, bbb]
}

Теперь перенесём это на практическое приложение. Напомню модель данных нашего текущего консольного трекера расходов (пока без классов): расход — это Triple(category, amount, note). Категория и заметка — строки, сумма — Int.

Сортировка всех расходов по сумме (по возрастанию) выглядит так:

fun main() {
    val expenses = listOf(
        Triple("food", 450, "coffee"),
        Triple("taxi", 1200, "airport"),
        Triple("food", 150, "snack")
    )

    val sortedByAmount = expenses.sortedBy { it.second }
    println(sortedByAmount)
    // [(food, 150, snack), (food, 450, coffee), (taxi, 1200, airport)]
}

Здесь важно «прочитать» код правильно: мы сортируем сами Triple, но сравниваем их по ключу it.second, то есть по сумме.

Сортировка по убыванию: sortedByDescending {...}

Чуть более «прикладной» вариант — отсортировать по убыванию: в отчётах мы почти всегда хотим видеть сначала самое большое. Для этого есть sortedByDescending { selector }. Это тот же принцип ключа сортировки, просто порядок обратный.

Например, хотим вывести самые дорогие покупки сверху:

fun main() {
    val expenses = listOf(
        Triple("food", 450, "coffee"),
        Triple("taxi", 1200, "airport"),
        Triple("books", 700, "kotlin book")
    )

    val sorted = expenses.sortedByDescending { it.second }
    println(sorted)
    // [(taxi, 1200, airport), (books, 700, kotlin book), (food, 450, coffee)]
}

И вот здесь уже просится следующий шаг: «а можно не весь список, а только первые N?». Можно — и это будет наш классический паттерн top‑N.

3. Top‑N: sortedByDescending {...}.take(n) и почему take без сортировки не топ

Когда в требованиях пишут «покажи топ‑3 категорий», почти всегда имеется в виду: отсортируй по метрике и возьми первые три. Kotlin позволяет выразить это в одну цепочку: sortedByDescending(...).take(n).

Важно не перепутать: take(n) не ищет лучшие элементы, он просто берёт первые n элементов текущего порядка. Поэтому «взять top‑N» без сортировки — это буквально «взять первые N как попало».

Сначала простой пример на числах:

fun main() {
    val scores = listOf(10, 70, 50, 20, 90)

    val top3 = scores.sortedDescending().take(3)
    println(top3) // [90, 70, 50]
}

Теперь сделаем top‑2 самых дорогих расходов в нашем формате Triple:

fun main() {
    val expenses = listOf(
        Triple("food", 450, "coffee"),
        Triple("taxi", 1200, "airport"),
        Triple("books", 700, "kotlin book"),
        Triple("food", 150, "snack")
    )

    val top2 = expenses
        .sortedByDescending { it.second }
        .take(2)

    println(top2)
    // [(taxi, 1200, airport), (books, 700, kotlin book)]
}

Обратите внимание на приятный момент: если элементов меньше, чем n, take(n) просто вернёт «сколько есть», без падений и драм.

4. Несколько критериев: sortedWith и compareBy

Одна из самых частых «бытовых» задач: сортировать по нескольким правилам. Например, хотим отсортировать список пар (category, total) так, чтобы сначала шли категории по имени (для красивого вывода), а если имя одинаковое, то вторым ключом была сумма или возраст.

Сортировка по нескольким критериям — это как сортировка документов в папке: сначала по “Фамилия”, а внутри одинаковых фамилий — по “Имя”, а внутри одинаковых имён — по “Дата”. Один критерий перестаёт быть достаточным.

В Kotlin для случаев «нужно задать явное правило сравнения» есть sortedWith(comparator), а чтобы компаратор не писать руками, есть фабрика компараторов compareBy(...). Стандартный путь выглядит так: sortedWith(compareBy {...}).

sortedWith(comparator): сортируем по явному правилу

sortedWith(...) принимает объект Comparator<T> и возвращает новый отсортированный список. Это полезно, когда:

  • элемент не Comparable,
  • у вас несколько критериев,
  • у вас хитрая логика сравнения (например, «сначала VIP, потом по сумме»).

Сначала пример попроще: сортируем строки по длине через компаратор:

fun main() {
    val words = listOf("aaa", "bb", "c")
    val sorted = words.sortedWith(compareBy { it.length })

    println(sorted) // [c, bb, aaa]
}

Технически это похоже на sortedBy { it.length }, но идея другая: мы явно говорим «вот правило сравнения». Это особенно удобно, когда критериев несколько.

compareBy(...): компаратор по одному ключу

compareBy — это способ сказать: «построй мне компаратор, который сравнивает элементы по таким-то ключам». Мы сейчас не углубляемся в устройство компаратора, а используем его как готовый инструмент.

Например, сортируем наши расходы (Triple) по категории (по алфавиту):

fun main() {
    val expenses = listOf(
        Triple("taxi", 1200, "airport"),
        Triple("food", 450, "coffee"),
        Triple("books", 700, "kotlin book")
    )

    val sorted = expenses.sortedWith(compareBy { it.first })
    println(sorted)
    // [(books, 700, kotlin book), (food, 450, coffee), (taxi, 1200, airport)]
}

compareBy({ ... }, { ... }): компаратор по нескольким ключам

compareBy умеет принимать несколько селекторов, и тогда сортировка идёт так: сначала по первому ключу, при равенстве — по второму, и так далее.

Сделаем пример на парах (category, total) и отсортируем сначала по названию категории, а затем по сумме (по возрастанию):

fun main() {
    val totals = listOf(
        "food" to 200,
        "taxi" to 250,
        "food" to 150
    )

    val sorted = totals.sortedWith(
        compareBy<Pair<String, Int>>({ it.first }, { it.second })
    )

    println(sorted) // [(food, 150), (food, 200), (taxi, 250)]
}

Здесь тип Pair<String, Int> я указал явно, чтобы компилятору было проще (и читателю тоже). Если вы пока не уверены в выводе типов — не стесняйтесь писать типы: это не «слабость», это страховка от загадочных ошибок, которые в 23:00 кажутся мистикой.

5. Мини‑интеграция: отчёт top‑N категорий по сумме

Теперь соберём мини‑сценарий, который очень похож на реальную фичу в трекере расходов: у нас есть Map<String, Int> с итогами по категориям, и мы хотим вывести top‑N категорий по сумме.

Мы не используем groupBy (это будет позже), поэтому представим, что totalsByCategory уже посчитан ранее нашим кодом (например, в обработчике команды total). Дальше — чистая механика сортировки.

Схема пайплайна:

Map<String, Int>
    → entries
    → List<Pair<String, Int>>
    → sortedByDescending { сумма }
    → take(n)
    → печать

Код (разобьём на маленькие шаги, чтобы читалось человечески):

fun main() {
    val totalsByCategory = mapOf(
        "food" to 1200,
        "taxi" to 2500,
        "books" to 900
    )

    val topN = 2

    val pairs = totalsByCategory.entries.map { it.key to it.value }
    val top = pairs.sortedByDescending { it.second }.take(topN)

    println(top) // [(taxi, 2500), (food, 1200)]
}

Здесь две вещи особенно важны.

Во-первых, мы сортируем не Map напрямую, а его элементы, превращённые в список пар. Это нормальная практика: сортировка — операция про последовательность (список), а Map сам по себе “про доступ по ключу”.

Во-вторых, top‑N делается строго в таком порядке: сначала сортируем, потом берём первые n. Если поменять местами — получим “первые N по внутреннему порядку Map”, что не является топом примерно никогда (кроме случайных совпадений, которые потом ломают доверие к программе).

6. Как выбирать инструмент и читать sortedBy

В какой-то момент студенты начинают спрашивать: «А что лучше — sortedBy или sortedWith(compareBy(...))?». Вопрос нормальный: оба варианта часто выглядят похоже.

Ниже — маленькая таблица, чтобы закрепить выбор:

Задача Что писать Почему так
Один простой ключ, по возрастанию
sortedBy { ... }
Самый короткий и читаемый вариант.
Один простой ключ, по убыванию
sortedByDescending { ... }
Тот же принцип, просто “сначала большое”.
Несколько критериев
sortedWith(compareBy({ ... }, { ... }))
Явно видно набор критериев и порядок их применения.
Нестандартное правило сравнения
sortedWith { a, b -> ... }
Когда нужно вручную описать логику сравнения.

И маленькое «человеческое правило»: если у вас один ключ — берите sortedBy. Если критериев несколько — чаще всего sortedWith(compareBy(...)) читается лучше и масштабируется спокойнее.

Перед финалом давайте прямо проговорим типичную путаницу. В sortedBy {...} внутри лямбды it — это элемент списка, а результат лямбды — ключ сортировки.

Пример, где люди часто путаются (потому что Pair и так короткий):

fun main() {
    val totals = listOf("food" to 1200, "taxi" to 2500)

    val sorted = totals.sortedBy { it.second }
    println(sorted) // [(food, 1200), (taxi, 2500)]
}

it — это пара (String, Int). it.second — это Int, ключ сортировки. Мы сортируем пары, а сравниваем числа.

Если вы поймали себя на мысли «почему я сортирую Int, если у меня список пар?» — поздравляю: вы как раз заметили ключ сортировки, значит мозг всё делает правильно.

7. Типичные ошибки

Ошибка №1: использовать take(n) без сортировки и называть это “топ‑N”.
take(n) — это не “выбери лучшие”, а “отрежь первые n”. Если исходный порядок — порядок добавления, то вы получите «первые добавленные», а не «самые большие». Исправление почти всегда одно: сначала сортировка по метрике, потом take.

Ошибка №2: сортировать по неправильному ключу и перепутать first/second/third.
Когда данные хранятся в Pair/Triple, очень легко сделать сортировку не по тому полю и несколько минут смотреть на результат с ощущением, что программа “издевается”. Спасает привычка: перед сортировкой называть смысл полей в голове (“first=категория, second=сумма”), а в коде иногда временно печатать пару элементов для проверки.

Ошибка №3: пытаться отсортировать Map “как есть”, а потом удивляться.
Map — это не список; в нём основной смысл — доступ по ключу, а не порядок. Для сортировки почти всегда нужно сделать entries.map { ... }, получить список, и уже его сортировать. Это не костыль, а нормальная трансформация контейнера под задачу.

Ошибка №4: пытаться запихнуть несколько критериев в одну “монструозную” лямбду.
Когда критериев два-три, новички иногда пишут огромный sortedWith { a, b -> ... }, где сравнения перемешаны, скобки живут своей жизнью, и всё это напоминает заклинание. В большинстве случаев читаемее и безопаснее использовать sortedWith(compareBy({ ... }, { ... })), где прямо видно, в каком порядке применяются ключи.

Ошибка №5: “потерять” результат сортировки.
Сортировки вроде sortedBy и sortedWith возвращают новый список, а исходный не меняют. Если вы написали expenses.sortedBy { it.second } и дальше печатаете expenses, то ничего “магически” не произошло. Лечится просто: сохраняйте в val или продолжайте цепочку сразу (например, sortedByDescending { ... }.take(3)), как мы делали выше.

1
Задача
Kotlin SELF, 23 уровень, 2 лекция
Недоступна
Слова по длине
Слова по длине
1
Задача
Kotlin SELF, 23 уровень, 2 лекция
Недоступна
Top чисел
Top чисел
1
Задача
Kotlin SELF, 23 уровень, 2 лекция
Недоступна
Лидерборд игроков
Лидерборд игроков
1
Задача
Kotlin SELF, 23 уровень, 2 лекция
Недоступна
Сортировка расходов
Сортировка расходов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ