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