JavaRush /Курси /Kotlin SELF /groupingBy().eachCount() — рахуємо частоти без списків гр...

groupingBy().eachCount() — рахуємо частоти без списків груп

Kotlin SELF
Рівень 24 , Лекція 1
Відкрита

1. Частоти: що саме ми хочемо отримати

Коли ви вперше чуєте слово «частоти», легко уявити собі нудну статистику. Насправді це одна з найкорисніших мікрооперацій над даними. Частоти відповідають на запитання «скільки разів трапилося…». Скільки витрат у кожній категорії, скільки разів користувач вводив ту чи іншу команду, скільки слів кожного типу є в тексті — усе це про частоти.

Якщо говорити мовою Kotlin-типів, зазвичай нам потрібна така структура:

  • ключ — це «що рахуємо» (категорія, слово, перша літера, число тощо)
  • значення — це «скільки разів трапилося»

Тобто результатом буде мапа частот:

Map<K, Int>

Приклад «людською мовою»:

  • "food" -> 12 (у категорії food є 12 записів)
  • "taxi" -> 3
  • "kotlin" -> 2

У цей момент мозок часто підказує: «давайте зробимо MutableMap і вручну збільшуватимемо лічильник». Це нормальний план. Але стандартна бібліотека Kotlin уміє зробити те саме в один рядок. Саме тут і зʼявляється звʼязка groupingBy { ... }.eachCount().

2. Чому groupBy не завжди підходить для підрахунку

Коли ви вже знаєте groupBy, виникає цілком природна думка: «Окей, згрупую, а потім візьму size у кожної групи». Працює? Працює. Зручно? Іноді так. Але є нюанс: groupBy повертає Map<K, List<T>>. Тобто для кожного ключа він зберігає цілий список елементів.

А якщо вам потрібна лише відповідь «скільки», зберігати списки вже зайве:

  • списки займають памʼять;
  • списки треба створювати й наповнювати;
  • потім ви все одно перетворюєте список на число (size) і викидаєте подробиці.

Це трохи схоже на ситуацію: вам потрібно знати, «скільки людей прийшло на вечірку», а ви замість цього заводите окрему кімнату для кожного імені, заганяєте туди людей, а потім рахуєте, скільки людей у кімнаті… і тут же всіх виганяєте. Потужно, але трохи театрально.

Щоб «просто порахувати», Kotlin пропонує інший шлях. Спершу ви кажете: «я вмію обчислювати ключ для кожного елемента». А потім просите: «порахуй, скільки елементів має кожен ключ». Саме це й робить groupingBy { ... }.eachCount().

3. Що таке groupingBy { ... } і чому це ще не Map

Після groupBy у багатьох виникає очікування: «будь-яке групування повертає Map». Але groupingBy працює інакше. Він повертає не мапу, а спеціальний обʼєкт типу Grouping — своєрідну «заготівлю для групування». У ньому зберігається логіка обчислення ключа, а конкретний результат зʼявляється пізніше — коли ви застосовуєте агрегувальну операцію.

Спробуймо поглянути на це інтуїтивно, а не лише через типи. Думку можна розбити на два кроки:

  1. «Ось правило, як з елемента отримати ключ»
  2. «Застосуйте до цього правила операцію: наприклад, порахуйте елементи»

Код виглядає так:

fun main() {
    val words = listOf("one", "two", "one")

    val grouping = words.groupingBy { it } // поки що не Map!
    val freq = grouping.eachCount()        // ось тепер Map<String, Int>

    println(freq) // {one=2, two=1}
}

Тут важливо відчути: groupingBy { ... } — це як «оформити заявку», а eachCount() — як «отримати готовий звіт».

4. eachCount(): отримуємо мапу частот Map<K, Int>

Коли ви викликаєте eachCount(), Kotlin робить рівно те, що нам потрібно: повертає мапу, у якій для кожного ключа записано кількість елементів.

Найбазовіший приклад: частоти слів

fun main() {
    val words = listOf("one", "two", "one", "three", "two")

    val freq: Map<String, Int> = words
        .groupingBy { it }
        .eachCount()

    println(freq) // {one=2, two=2, three=1}
}

Зауважте приємний момент: нам не потрібні ні MutableMap, ні put, ні «якщо помітили ключ — збільш, інакше — постав 1». Kotlin робить усе це сам.

Приклад з обчислюваним ключем: групуємо за першою літерою

Цей приклад добрий тим, що ключ (Char) відрізняється від початкового елемента (String).

fun main() {
    val words = listOf("one", "two", "three", "four", "five")

    val freqByFirstChar = words.groupingBy { it.first() }.eachCount()

    for ((ch, count) in freqByFirstChar) {
        println("$ch -> $count")
        // o -> 1
        // t -> 2
        // f -> 2
    }
}

Тут ви одночасно закріплюєте одразу три навички: лямбду в groupingBy, роботу з Char і обхід Map через деконструкцію (key, value).

5. Нормалізація ключа перед підрахунком

У реальних даних найчастіше проблема не в підрахунку, а в тому, що «одне й те саме» виглядає по-різному. Пробіли, регістр, випадкові зайві символи — усе це створює «хибно різні ключі».

Тут варто запамʼятати просте правило: нормалізуйте ключ прямо всередині groupingBy { ... }. Саме там вирішується, що вважати «однаковим».

fun main() {
    val drinks = listOf("Tea", " tea ", "TEA", "Coffee")

    val freq = drinks
        .groupingBy { it.trim().lowercase() }
        .eachCount()

    println(freq) // {tea=3, coffee=1}
}

Це один із тих моментів, де Kotlin буквально підштовхує до правильного дизайну: «ключ групування» — окреме поняття. Не «як у початковому введенні», а «як у вашій внутрішній моделі даних».

Якщо у вашому проєкті категорії помітно «пливуть» (наприклад, користувач увів " Taxi " замість "taxi"), нормалізація не просто поліпшує статистику — вона рятує сам сенс звіту.

6. Практика: рахуємо кількість витрат за категоріями

Відсьогодні в курсі вже є практичний консольний проєкт (ми стартували його на колекціях): умовний облік витрат. Поки що без ООП, тож записи зберігатимемо максимально просто — як Triple(category, amount, note).

Зробімо невелику заготовку даних і функцію, яка рахує «скільки записів у кожній категорії». Зверніть увагу: сьогодні ми рахуємо кількість записів, а не суму грошей. Сума — теж важлива річ, але це вже інша операція.

Дані та підрахунок кількості за категорією

fun main() {
    val expenses: List<Triple<String, Int, String>> = listOf(
        Triple("food", 120, "groceries"),
        Triple("Food ", 80, "snack"),
        Triple("taxi", 250, "airport"),
        Triple("TAXI", 90, "home")
    )

    val countsByCategory = expenses
        .groupingBy { (category, _, _) -> category.trim().lowercase() }
        .eachCount()

    println(countsByCategory) // {food=2, taxi=2}
}

Тут корисно зупинитися й «прочитати» лямбду очима: (category, _, _) -> .... Це звичайна деконструкція Triple, яку ви вже знаєте з теми про Pair/Triple. Ми прямо кажемо: «ключ — це нормалізована категорія».

Виносимо у функцію (щоб main не розростався)

fun countExpensesByCategory(expenses: List<Triple<String, Int, String>>): Map<String, Int> {
    return expenses
        .groupingBy { (category, _, _) -> category.trim().lowercase() }
        .eachCount()
}

fun main() {
    val expenses = listOf(
        Triple("food", 120, "groceries"),
        Triple("Food ", 80, "snack"),
        Triple("taxi", 250, "airport")
    )

    val counts = countExpensesByCategory(expenses)
    println(counts) // {food=2, taxi=1}
}

Із практичного погляду це дуже важливий крок: countExpensesByCategory(...) стає маленькою цеглинкою для звітів. Вона повертає чистий результат (мапу), а не друкує все сама. Отже, пізніше ми зможемо красиво форматувати вивід так, як нам потрібно.

7. Ключ може бути будь-яким: рядки, числа, булеві значення

Іноді здається, що частоти — це «тільки для слів». Насправді ключем може бути будь-який тип: Int, Char, Boolean, навіть складніші речі (але ми поки не ускладнюємо — бережемо психіку).

Частоти чисел

fun main() {
    val numbers = listOf(10, 10, 20, 30, 30, 30)

    val freq = numbers.groupingBy { it }.eachCount()

    for ((n, count) in freq) {
        println("$n -> $count")
        // 10 -> 2
        // 20 -> 1
        // 30 -> 3
    }
}

Частоти «парне / непарне»

Ключем буде Boolean: true для парних і false для непарних. Звучить дивно рівно доти, доки ви не побачите, наскільки це зручно.

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, 6)

    val freq = numbers
        .groupingBy { it % 2 == 0 }
        .eachCount()

    println(freq) // {false=3, true=3}
}

Якщо потім ви захочете вивести це людськими словами («парних стільки-то»), це вже буде питання форматування, а не підрахунку. Тобто відповідальність розділено правильно.

Ручний лічильник vs eachCount()

Іноді корисно один раз побачити, «як було б вручну», щоб краще оцінити, що дає стандартна бібліотека. Уявіть: вам дали завдання «порахувати частоти категорій», і ви пишете це через MutableMap.

fun main() {
    val categories = listOf("food", "food", "taxi")

    val counts = mutableMapOf<String, Int>()
    for (c in categories) {
        val old = counts[c] ?: 0
        counts[c] = old + 1
    }

    println(counts) // {food=2, taxi=1}
}

Це нормальний код. Він навіть корисний, щоб зрозуміти суть алгоритму. Але в реальному проєкті хочеться коротше й виразніше:

fun main() {
    val categories = listOf("food", "food", "taxi")

    val counts = categories.groupingBy { it }.eachCount()
    println(counts) // {food=2, taxi=1}
}

Важлива думка: eachCount() — не «магія з космосу». Це просто акуратна, стандартна реалізація шаблону «лічильник за ключем», яка робить код менш шумним.

8. Типові помилки під час роботи з groupingBy().eachCount()

Помилка №1: очікувати, що groupingBy { ... } одразу поверне Map.
Після groupBy рука сама пише val m = list.groupingBy { ... }, і хочеться тут же звертатися як до мапи: m["key"]. Але groupingBy повертає проміжний обʼєкт (за змістом — «план групування»), а мапу ви отримуєте лише після операції на кшталт eachCount(). Якщо у вас у голові крутиться запитання «чому не виходить звертатися як до Map», значить, ви просто забули про другий крок.

Помилка №2: рахувати частоти за «брудним» ключем і дивуватися дивним результатам.
Якщо категорії "Taxi", " taxi " і "TAXI" вважаються різними, проблема не в eachCount(). Він чесно рахує те, що ви попросили рахувати. Рішення — нормалізувати ключ у groupingBy: найчастіше вистачає trim().lowercase(). Це особливо критично у звітах: неправильний ключ = неправильна статистика.

Помилка №3: плутати «кількість записів» і «суму значень».
eachCount() рахує, скільки елементів потрапило в групу. Якщо у вас витрати ("food", 100) і ("food", 200), результат за ключем "food" буде 2, а не 300. Це не помилка й не підступ — це різні завдання. Сьогодні ми розвʼязуємо завдання «скільки», а не «на яку суму».

Помилка №4: друкувати мапу й чекати гарного порядку.
Коли ви робите println(freq), Kotlin друкує мапу «як вийде» (порядок залежить від внутрішньої реалізації). Для налагодження це нормально, але для звіту майже завжди потрібен окремий крок: відсортувати, відформатувати, вирівняти. Якщо у вас зʼявляється відчуття «усе правильно, але виглядає негарно», ви вперлися не в проблему підрахунку, а в задачу подання результату. І це абсолютно очікувано.

Помилка №5: робити надто складний ключ прямо всередині лямбди й втрачати читабельність.
Іноді хочеться написати в groupingBy пів екрана логіки. Формально можна, але код швидко стає нечитабельним. Якщо ключ обчислюється складно, краще винести його в окрему функцію (наприклад, normalizeCategory(category: String): String), а в groupingBy залишити один зрозумілий рядок.

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