JavaRush /Курси /Kotlin SELF /Пайплайн Text Analyzer на колекціях

Пайплайн Text Analyzer на колекціях

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

1. Пайплайн аналізу тексту

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

Уявіть, що вам дали великий текст і запитали: «Які слова трапляються найчастіше?». Якщо писати це напролом, легко потонути в циклах, тимчасових змінних і перевірках. Пайплайн рятує тим, що ви робите рівно 5–6 стандартних кроків: нормалізація → токени → частоти → сортування → top‑N → форматування. На кожному кроці ви отримуєте структуру, з якою приємно працювати.

Ось схема (дуже технічна, але чесна):

flowchart TD
    A["Сирий текст (String)"] --> B["Нормалізований текст (String)"]
    B --> C["Токени (List⟨String⟩)"]
    C --> D["Частоти (Map⟨String, Int⟩)"]
    D --> E["Відсортовані елементи (List⟨Entry⟩)"]
    E --> F["Top-N (List⟨Pair⟨String, Int⟩⟩)"]
    F --> G["Звіт (String)"]

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

Карта типів на кроках пайплайна

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

Нижче — практична таблиця, до якої можна повертатися, коли ви вкотре зловите себе на думці: «А чому тут entries, а не map?».

Крок Що робимо Вхід Вихід Приклад операцій
1
Нормалізуємо текст
String
String
trim, lowercase, Regex.replace
2
Токенізуємо
String
List<String>
split, filter { it.isNotBlank() }
3
Рахуємо частоти
List<String>
Map<String, Int>
groupingBy { it }.eachCount()
4
Вибираємо top‑N
Map<String, Int>
List<Map.Entry<String, Int>> або List<Pair<String, Int>>
entries, sortedByDescending, take
5
Форматуємо список Pair/Entry
String
joinToString("\n")

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

2. Нормалізація тексту

Нормалізація тексту — це момент, коли ви перетворюєте введення користувача на вигляд, придатний для коректної обробки. Користувач може вставити текст із перенесеннями рядків, кількома пробілами, табуляціями та випадковими великими літерами. Якщо ми почнемо split() прямо по цій «сировині», отримаємо сміття: порожні токени, «Kotlin» окремо від «kotlin» і дивні результати в частотах.

Міні‑нормалізація: trim() і lowercase()

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

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

    println(normalized) // kotlin kotlin kotlin
}

Ідея проста: якщо регістр не важливий для змісту (а в нашому аналізаторі він не важливий), краще одразу зробити його однаковим.

Нормалізація пробілів через Regex("""\s+""").replace(...)

Тепер типова «біль новачка»: «чому в мене після split(" ") зʼявляються порожні рядки?». Зазвичай причина в тому, що в тексті багато пробілів, перенесень рядків і табуляцій. Нам потрібно «згорнути» будь-яку послідовність пробільних символів до одного звичайного пробілу.

import kotlin.text.Regex

fun main() {
    val raw = " Kotlin   Kotlin \n is\tgreat "
    val spaces = Regex("""\s+""")

    val normalized = spaces.replace(raw.trim().lowercase(), " ")
    println(normalized) // kotlin kotlin is great
}

Зверніть увагу на "\s+". Це «один або більше пробільних символів». Тобто і пробіли, і "\n", і "\t" ми приводимо до однієї форми. Після цього токенізація стає передбачуваною.

Якщо після нормалізації текст виявився порожнім, зручно одразу вибрати запасний сценарій через takeIf { ... } ?: ... . Це той самий стиль «беремо значення, якщо воно підходить, інакше — за замовчуванням». Він добре читається, бо йде лінійно.

3. Токенізація

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

Проста токенізація після нормалізації

Припустімо, у нас уже є рядок, де між словами рівно один пробіл. Тоді split(" ") працюватиме стабільно.

fun main() {
    val normalized = "kotlin kotlin is great"
    val tokens = normalized.split(" ")

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

Фільтрація порожніх токенів: filter { it.isNotBlank() }

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

fun main() {
    val normalized = "kotlin  kotlin   "  // спеціально "брудно"
    val tokens = normalized
        .split(" ")
        .filter { it.isNotBlank() }

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

Фільтр тут не робить магії — він просто викидає порожні рядки та рядки з пробілів. І це саме той «маленький крок», який робить увесь пайплайн стійкішим.

4. Підрахунок частот

Ось тут починається найприємніша частина: ми вже не возимося з рядком, а працюємо з нормальною колекцією List<String>. Наша мета — отримати Map<String, Int>, де ключ — слово, а значення — скільки разів воно трапилося.

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

fun main() {
    val tokens = listOf("kotlin", "kotlin", "is", "great", "great")
    val freq: Map<String, Int> = tokens.groupingBy { it }.eachCount()

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

Чому це зручно саме як крок пайплайна? Тому що на виході ми отримуємо структуру, яка ідеально відповідає завданню «top‑N»: частоти вже пораховані — залишилося вибрати найбільші.

5. Вибір top‑N

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

Top‑N через entriessortedByDescendingtake

fun main() {
    val freq = mapOf("kotlin" to 2, "is" to 1, "great" to 2, "fun" to 5)

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

    for (e in top2) {
        println("${e.key}: ${e.value}")
        // fun: 5
        // kotlin: 2   (або great: 2 — порядок за рівності не фіксуємо)
    }
}

Є важливий нюанс для початківців: якщо значення однакові (наприклад, kotlin=2 і great=2), конкретний порядок між ними вам зазвичай не принциповий. Пізніше можна буде додати друге сортування за абеткою, але в цій лекції ми тримаємо фокус на базовому пайплайні.

Перетворення Entry на Pair

Іноді Entry незручний для форматування. Тоді можна зробити ще один маленький крок: перетворити Entry на Pair<String, Int>.

fun main() {
    val freq = mapOf("kotlin" to 2, "is" to 1, "great" to 2)

    val top = freq.entries
        .sortedByDescending { it.value }
        .take(2)
        .map { it.key to it.value }

    println(top) // [(kotlin, 2), (great, 2)] (або навпаки за рівності)
}

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

6. Форматування звіту

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

Друк через joinToString("\n")

fun main() {
    val top = listOf("fun" to 5, "kotlin" to 2, "is" to 1)

    val report = top.joinToString(separator = "\n") { (word, count) ->
        "$word: $count"
    }

    println(report)
    // fun: 5
    // kotlin: 2
    // is: 1
}

Деконструкція (word, count) тут працює тому, що Pair підтримує компоненти. Виходить компактно й читабельно.

Невелике вирівнювання через padEnd і padStart

У нас уже була тема про padStart/padEnd у рядках, тож можна зробити звіт трохи охайнішим, щоб числа стояли рівним «стовпчиком».

fun main() {
    val top = listOf("fun" to 5, "kotlin" to 12, "is" to 1)

    val report = top.joinToString(separator = "\n") { (word, count) ->
        "${word.padEnd(10)} | ${count.toString().padStart(3)}"
    }

    println(report)
    // fun        |   5
    // kotlin     |  12
    // is         |   1
}

Це все ще не «великий форматер», а просто приємний вигляд для консолі.

7. Збирання міні‑застосунку Text Analyzer

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

Варіант із проміжними val

import kotlin.text.Regex

fun main() {
    println("Введіть текст:")
    val rawText = readln()

    val normalized = Regex("""\s+""").replace(rawText.trim().lowercase(), " ")
    val tokens = normalized.split(" ").filter { it.isNotBlank() }
    val freq = tokens.groupingBy { it }.eachCount()

    val topN = freq.entries
        .sortedByDescending { it.value }
        .take(5)
        .map { it.key to it.value }

    val report = topN.joinToString("\n") { (w, c) -> "$w: $c" }
    println(report)
}

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

Варіант‑ланцюжок зі scope‑функціями

Якщо ви вже знайомі зі scope‑функціями з минулої лекції, можна зібрати результат більш «конвеєрно». Тут also зручно вставляє налагоджувальний друк і не ламає основний результат (воно повертає вихідний обʼєкт).

import kotlin.text.Regex

fun main() {
    val report = readln()
        .let { Regex("""\s+""").replace(it.trim().lowercase(), " ") }
        .also { println("Нормалізовано: $it") } // Нормалізовано: ...
        .split(" ")
        .filter { it.isNotBlank() }
        .groupingBy { it }.eachCount()
        .entries
        .sortedByDescending { it.value }
        .take(5)
        .joinToString("\n") { "${it.key}: ${it.value}" }

    println(report)
}

Важливо не намагатися писати так завжди. Це хороший стиль, коли ланцюжок короткий і читабельний. Якщо ви починаєте плутатися, де у вас String, де List, а де Map, краще повернутися до проміжних val. Це не «програш», а нормальна інженерна обережність.

8. Типові помилки під час збирання пайплайна Text Analyzer

Помилка № 1: токенізація до нормалізації.
Якщо почати з split(" ") по сирому тексту, ви майже гарантовано отримаєте порожні токени й дивні результати. Проблема не в split, а в тому, що пробіли бувають множинні, плюс є "\n" і "\t". Спочатку зводьте пробільні символи до одного пробілу, а вже потім діліть на слова.

Помилка № 2: забули фільтрацію isNotBlank() і здивувалися порожнім словам.
Навіть після нормалізації інколи залишаються граничні випадки: порожнє введення, пробіли по краях, несподівані комбінації. Якщо не викидати порожні токени, у вас може зʼявитися «слово» "", і воно раптово стане найчастішим (бо порожнеча — дуже популярна… філософи схвалили б, а користувачі — ні).

Помилка № 3: спроба «відсортувати Map напряму».
Map — це структура «ключ → значення», а сортування — це історія про впорядковані колекції. Тому правильний шлях майже завжди такий: freq.entriessortedByDescending { it.value } → take(n). Щойно ви приймаєте це як норму, top‑N перестає бути загадкою.

Помилка № 4: змішування підрахунків і форматування в одну кашу.
Коли ви одночасно рахуєте частоти й тут же, у процесі, будуєте багаторядковий рядок, код швидко стає нечитабельним: незрозуміло, де закінчується логіка, а де починається «гарний вивід». Навіть якщо ви все пишете в одному main, тримайте етап форматування окремим останнім кроком (наприклад, окремою змінною report).

Помилка № 5: надто «розумний» ланцюжок, у якому ви загубили типи.
Scope‑функції та ланцюжки операцій — класні, але вони не повинні перетворюватися на фокус зі зниклим кроликом. Якщо в середині ланцюжка ви вже не впевнені, що у вас у руках (String? List<String>? Map<String, Int>?), зупиніться, зробіть проміжні val і дайте їм нормальні імена. Це не сповільнює програміста — це пришвидшує налагодження.

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