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 стал текстом. Для первых попыток это лучше, чем «суперцепочка на 1 строку».

Вариант‑цепочка со 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 и дайте им нормальные имена. Это не замедляет программиста — это ускоряет отладку.

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