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(
        "Coffee and sandwich",
        "coffee to go",
        "Taxi home"
    )

    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: пытаться сделать «идеальную токенизацию» и утонуть в деталях.
Очень легко начать добавлять обработку всех возможных символов, языков и исключений — и потерять суть урока. На практике лучше держать токенизацию в отдельной функции и улучшать её постепенно. Сегодня достаточно, чтобы она была предсказуемой, а не совершенной.

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