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)), як ми робили вище.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ