JavaRush /Курси /Kotlin SELF /Прикладний розбір: частоти слів

Прикладний розбір: частоти слів

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

1. Вступ

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

Ми зберемо мініаналізатор тексту: він бере рядок, приводить його до нормального вигляду, розбиває на слова й будує частотну мапу.

Одразу важлива думка: частоти майже ніколи не рахують «просто так». Перед підрахунком дані зазвичай потрібно привести до єдиного стандарту. Інакше одне й те саме слово розпадеться на кілька ключів. Наприклад, "Kotlin", "kotlin" і " kotlin " — це три різні рядки, але за змістом це одне слово. Тому ми й зберемо зрозумілий конвеєр обробки.

Ось «карта маршруту» сьогоднішньої лекції:

flowchart LR
    A[Сирий текст] --> B[Нормалізація]
    B --> C[Токени: слова]
    C --> D["Частоти: Map⟨String, Int⟩"]
    D --> E[Сортування і виведення]

2. Крок 1: нормалізація тексту

Нормалізація — це момент, коли ми перестаємо бути романтиками й починаємо бути інженерами. Текст від користувача може бути будь-яким: зайві пробіли, різний регістр, випадкові переноси рядків, інколи навіть «КоТлІн». Якщо ми не вирівняємо введення, частотна мапа втратить сенс: різні написання перетворяться на різні ключі.

Найбазовіший мінімум нормалізації, який ми вже вміємо робити, — це trim() і lowercase(). Цього часто достатньо, щоб прибрати «сміття» по краях і звести слова до єдиного регістру.

Мініприклад: trim() + lowercase()

fun main() {
    val raw = "  Kotlin  kotlin  KOTLIN  "
    val normalized = raw.trim().lowercase()

    println(normalized) // kotlin  kotlin  kotlin
}

Зверніть увагу: ми поки не розвʼязували проблему подвійних пробілів усередині рядка. Це буде наступний епізод цієї історії: під час токенізації ми все одно відфільтруємо порожні фрагменти.

Нормалізація пунктуації (спрощено)

В ідеальному світі ми хотіли б прибрати розділові знаки, щоб "kotlin," і "kotlin" стали одним словом. Але сьогодні ми не робимо складну токенізацію й не використовуємо регулярні вирази. Зате можемо застосувати невеликий «побутовий» трюк: замінити кілька найпоширеніших символів на пробіл.

fun main() {
    val raw = "Kotlin, kotlin! Kotlin?"
    val normalized = raw
        .lowercase()
        .replace(",", " ")
        .replace("!", " ")
        .replace("?", " ")

    println(normalized) // kotlin  kotlin  kotlin
}

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

3. Крок 2: токенізація

Токенізація — страшне слово, яке на практиці означає «розбити текст на частини». Сьогодні токени — це слова, і ми отримаємо їх через split. Головний підводний камінь: якщо в тексті кілька пробілів підряд, то split(" ") може повернути порожні рядки "". А порожній рядок — це теж рядок. Він спокійно стане ключем у Map, якщо ми його не приберемо.

Тому наш стандартний прийом: після split обовʼязково робимо filter { it.isNotBlank() }. Це як «сито»: усе порожнє й таке, що складається лише з пробілів, відлітає в смітник.

Мініприклад: split і фільтрація порожніх токенів

fun main() {
    val normalized = "kotlin  kotlin   is   great"
    val tokens = normalized
        .split(" ")
        .filter { it.isNotBlank() }

    println(tokens) // [kotlin, kotlin, is, great]
}

Типова пастка: зʼявився ключ — порожній рядок

Щоб відчути проблему «руками», корисно подивитися, що буде без фільтра:

fun main() {
    val normalized = "a  b"
    val tokens = normalized.split(" ")

    println(tokens) // [a, , b]
}

Оце порожнє місце посередині — і є "". Якщо далі порахувати частоти «як є», ви отримаєте ключ "" із частотою 1. А потім дивитиметеся на звіт приблизно так, як кіт дивиться на огірок: наче існує, але навіщо — незрозуміло.

4. Крок 3: частоти через groupingBy { ... }.eachCount()

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

У термінах типів це виглядає так: у нас є List<String>, а на виході буде Map<String, Int>. Це і є частотна мапа: слово → скільки разів трапилося. У Kotlin стандартна бібліотека напряму підтримує такий сценарій: groupingBy повертає спеціальний обʼєкт Grouping, до якого можна застосувати агрегацію eachCount().

Мініприклад: частоти слів

fun main() {
    val tokens = listOf("kotlin", "kotlin", "is", "great")
    val freq = tokens.groupingBy { it }.eachCount()

    println(freq) // {kotlin=2, is=1, great=1}
}

Зауваження про ключ: нормалізація всередині keySelector

Якщо ми працюємо не з уже нормалізованими токенами, а, наприклад, із «сирими» словами, то краще нормалізувати їх прямо в keySelector. Так менший ризик забути обробку десь посеред програми.

fun main() {
    val rawWords = listOf("Tea", " tea ", "TEA", "Coffee")
    val freq = rawWords.groupingBy { it.trim().lowercase() }.eachCount()

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

5. Крок 4: сортування і виведення

Майже завжди Map, яку ми отримали, — це ще не звіт, а «сировина для звіту». Користувачу хочеться побачити результат у певному порядку: наприклад, найчастіші слова — зверху. Але Map не зобовʼязана зберігати порядок так, як нам потрібно для виведення. Тому зазвичай ми беремо entries, сортуємо їх — і лише потім виводимо.

З погляду структури це зручно читати так: freq.entries — це набір пар «ключ‑значення», тобто «слово‑частота». Його легко перетворити на список, відсортувати й далі вивести рядок за рядком.

Сортування за спаданням частоти

fun main() {
    val freq = mapOf("kotlin" to 5, "java" to 2, "go" to 3)

    val sorted = freq.entries.sortedByDescending { it.value }
    for (e in sorted) {
        println("${e.key} -> ${e.value}")
        // kotlin -> 5
        // go -> 3
        // java -> 2
    }
}

Top‑N: показуємо лише найчастіші слова

fun main() {
    val freq = mapOf("kotlin" to 5, "java" to 2, "go" to 3, "swift" to 1)

    val top2 = freq.entries
        .sortedByDescending { it.value }
        .take(2)

    for ((word, count) in top2) {
        println("$word -> $count")
        // kotlin -> 5
        // go -> 3
    }
}

Тут ми використовуємо деконструкцію (word, count) у циклі, щоб код читався «по‑людськи», а не через e.key і e.value.

Невелике вирівнювання виведення

Ми вже вміємо робити прості операції з рядками, тож можемо зробити виведення трохи акуратнішим. Наприклад, вирівняти слова за шириною найдовшого слова. Це маленький крок до того, щоб програма виглядала як інструмент, а не як чернетка.

fun main() {
    val freq = mapOf("kotlin" to 5, "java" to 2, "go" to 3)
    val width = freq.keys.maxOf { it.length }

    for ((word, count) in freq.entries.sortedByDescending { it.value }) {
        val left = word.padEnd(width)
        println("$left : $count")
        // kotlin : 5
        // go     : 3
        // java   : 2
    }
}

6. Мінізастосунок Text Analyzer

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

Уявіть, що в нашому навчальному проєкті вже є консольне меню команд, і ми додаємо команду аналізу тексту. Навіть якщо меню поки просте, сама логіка аналізу буде корисним модулем. Її можна застосовувати до описів витрат, нотаток, відгуків — до чого завгодно, де є текст.

Функція нормалізації тексту

Зробімо функцію, яка приводить текст до «більш‑менш стабільного» вигляду. Ми не обіцяємо, що обробимо всі символи світу, але для навчального прикладу нам цього вистачить.

fun normalizeText(raw: String): String {
    return raw
        .trim()
        .lowercase()
        .replace(",", " ")
        .replace(".", " ")
        .replace("!", " ")
        .replace("?", " ")
}

Якщо хочете додати ще символів — додавайте. Але важливо зберігати принцип: нормалізація має відбуватися до токенізації.

Функція токенізації: рядок → список слів

Тепер зробімо функцію, яка бере нормалізований текст і перетворює його на список слів. Ключовий момент — фільтрація isNotBlank().

fun tokenize(text: String): List<String> {
    return text
        .split(" ")
        .filter { it.isNotBlank() }
}

Якщо в майбутньому ви захочете поліпшити токенізацію, у вас буде рівно одне місце, де це змінювати.

Функція підрахунку частот

Тепер найкоротше: беремо List<String> і отримуємо Map<String, Int>. Це наш «двигун статистики». eachCount() дає нам частотну мапу без зберігання списків груп, тобто економно й за змістом правильно.

fun countFrequencies(tokens: List<String>): Map<String, Int> {
    return tokens.groupingBy { it }.eachCount()
}

Функція звіту рядком

Виведення напряму через багато println працює. Але часто приємніше спочатку зібрати текст звіту, а потім показати його одним шматком. Це дисциплінує: обчислення — окремо, виведення — окремо. І так, StringBuilder для довгого тексту дружить і з продуктивністю, і з нервовою системою.

import kotlin.text.StringBuilder

fun buildReport(freq: Map<String, Int>, top: Int): String {
    val sb = StringBuilder()
    val sorted = freq.entries.sortedByDescending { it.value }.take(top)

    for ((word, count) in sorted) {
        sb.append(word).append(" -> ").append(count).append('\n')
    }
    return sb.toString()
}

Зверніть увагу: тут лише кілька рядків, а ми вже отримали функцію, яку можна перевикористовувати хоч сто разів.

Зʼєднуємо все в analyzeText(...)

Тепер зробімо функцію «під ключ»: вона приймає сирий текст, проганяє його через конвеєр і повертає звіт.

fun analyzeText(raw: String, top: Int): String {
    val normalized = normalizeText(raw)
    val tokens = tokenize(normalized)
    val freq = countFrequencies(tokens)

    return buildReport(freq, top)
}

Це пряма ілюстрація принципу «нормалізація → токени → частоти → виведення», тільки в коді.

main: читаємо рядок і виводимо результат

Зберемо мінімальний запуск. Користувач вводить текст одним рядком, а ми виводимо топ‑10 слів. Якщо слів менше — виведеться стільки, скільки є: take(top) не «свариться».

fun main() {
    println("Введіть текст одним рядком:")
    val raw = readln()

    val report = analyzeText(raw, top = 10)
    println("Топ слів:")
    print(report)
}

Якщо запустити програму й ввести, наприклад:

Kotlin, kotlin! Is great. Kotlin is pragmatic.

то результат буде приблизно таким (за однакових частот порядок може відрізнятися):

Топ слів:
kotlin -> 3
is -> 2
great -> 1
pragmatic -> 1

Привʼязка до реальності: аналізуємо список текстів

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

Важливо: ми не зобовʼязані знати «розумні» операції колекцій, щоб це зробити. Можна спокійно зібрати текст через StringBuilder у циклі — це вже знайомо й прозоро.

import kotlin.text.StringBuilder

fun joinTexts(lines: List<String>): String {
    val sb = StringBuilder()
    for (line in lines) {
        sb.append(line).append(' ')
    }
    return sb.toString()
}

А тепер застосуємо аналіз:

fun main() {
    val notes = listOf(
        "Кава і сендвіч",
        "кава з собою",
        "Таксі додому"
    )

    val raw = joinTexts(notes)
    val report = analyzeText(raw, top = 5)

    println("Часті слова в нотатках:")
    print(report)
}

Вийде корисний мінізвіт: які слова найчастіше трапляються в коментарях. Так, це ще не «бізнес-аналітика рівня корпорації», але це чесна автоматизація. Компʼютер робить нудну роботу, людина читає результат і думає.

7. Типові помилки

Помилка № 1: рахувати частоти за «брудними» ключами й дивуватися результату.
Якщо не зробити trim() і lowercase() до підрахунку, ви швидко отримаєте ситуацію, де "Tea", "tea" і " tea " вважаються різними словами. Частотна мапа виглядатиме «правильною» технічно, але марною за змістом. Тому нормалізація — не прикраса, а частина контракту ключа.

Помилка № 2: забути фільтрацію після split і випадково порахувати слово "".
split(" ") на тексті з подвійними пробілами створює порожні токени. Якщо їх не прибрати через filter { it.isNotBlank() }, то порожній рядок стане звичайним ключем у частотній мапі. Далі можна довго шукати, хто ж такий цей загадковий ключ, у якого частота 17.

Помилка № 3: змішати обчислення й виведення в одне «простирадло», а потім не змогти налагодити.
Коли код робить усе в одному ланцюжку й одразу виводить результат, будь-яка проблема перетворюється на квест. Значно простіше тримати проміжні результати в зрозумілих змінних: normalized, tokens, freq, sorted. Такий стиль здається «більш багатослівним», але на практиці він економить час і нерви.

Помилка № 4: виводити Map безпосередньо й очікувати, що порядок буде «як треба».
Навіть якщо в якійсь версії або середовищі Map виглядає відсортованою, це не контракт для звіту. Якщо потрібен порядок — його треба задати явно: взяти entries, відсортувати за частотою й лише потім виводити. Інакше програма може «стрибати» у результаті, і користувач думатиме, що частоти змінилися, хоча змінився лише порядок відображення.

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

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