JavaRush /Курсы /Swift SELF /Углубляемся в частотные карты и счётчики

Углубляемся в частотные карты и счётчики

Swift SELF
22 уровень , 0 лекция
Открыта

1. Вспоминаем счётчики и частотные карты

Когда вы впервые слышите «частотная карта», мозг может нарисовать что-то вроде пиратской карты сокровищ, только вместо крестика — процент встречаемости слова "banana". На самом деле идея максимально бытовая: у нас есть набор значений (слова, числа, символы, оценки), и мы хотим узнать, сколько раз каждое значение встретилось. Это встречается так часто, что Swift даже сделал для этого отдельный «удобный вход» — тот самый dict[key, default:].

Представьте, что у нас есть текст, и мы хотим понять, какие слова в нём самые частые. Наивный способ — для каждого слова снова и снова пробегать по всему тексту и считать повторы. Это работает… но медленно и грустно, потому что появляется вложенный цикл. Гораздо приятнее сделать один проход и накапливать счётчики в словаре.

Частотная карта (frequency map) — это просто Dictionary, где:

  • ключ (Key) — это “что именно считаем” (слово, символ, число),
  • значение (Value) — это счётчик Int (сколько раз встретили ключ).

То есть тип выглядит так:

var counts: [String: Int] = [:]

И теперь counts["swift"] — это “сколько раз встретилось слово "swift"”.

2. Паттерн dict[key, default: 0] += 1

Снаружи строка counts[word, default: 0] += 1 выглядит как магия, но это именно тот случай, когда магия — просто удачно спрятанная рутина.

Словарь в Swift обычно возвращает Optional, когда вы делаете counts[word], потому что ключа может не быть. Но для подсчёта частот нам хочется говорить: “если ключа нет — считаем, что там 0”. Именно под это в Swift существует сабскрипт с дефолтом: dict[key, default: someValue].

Мини-идея такая:

  • если ключ уже есть, мы берём его текущее значение и увеличиваем;
  • если ключа нет, мы временно считаем его значение равным default и увеличиваем, после чего ключ появится в словаре уже с новым значением.

Пример на игрушечных данных:

import Foundation

var counts: [String: Int] = [:]

counts["apple", default: 0] += 1
counts["apple", default: 0] += 1
counts["banana", default: 0] += 1

print(counts) // ["banana": 1, "apple": 2]

Отдельно важная деталь, которая спасает от неожиданных “почему у меня появились лишние ключи”: простое чтение через default: не добавляет ключ в словарь. В документации/обосновании этого API подчёркивается, что let x = dict[key, default: 0] эквивалентно dict[key] ?? 0, и при чтении ключ не “создаётся сам собой”.

То есть вот так ключ не появится:

import Foundation

var counts: [String: Int] = [:]

let x = counts["ghost", default: 0]
print(x)        // 0
print(counts)   // [:]

Но вот так — появится, потому что мы изменили значение (а значит, словарь обязан сохранить результат):

import Foundation

var counts: [String: Int] = [:]

counts["ghost", default: 0] += 1
print(counts) // ["ghost": 1]

3. Пример: считаем символы в строке

Давайте начнём с очень простой задачи, которая идеально подходит под частотную карту: посчитать, сколько раз встречается каждый символ в строке. Это хороший старт, потому что Character прекрасно подходит в качестве ключа словаря.

В “старом стиле” вы бы писали if counts[ch] == nil { ... } else { ... }, и довольно быстро устали бы от этого повторяющегося шаблона. Swift буквально предлагает вам более короткий и безопасный вариант.

Вот аккуратный код:

import Foundation

let text = "banana"
var freq: [Character: Int] = [:]

for ch in text {
    freq[ch, default: 0] += 1
}

print(freq["a", default: 0]) // 3
print(freq["b", default: 0]) // 1

Заметьте: мы вообще не трогаем Optional руками. И это хорошо. Чем меньше Optional-акробатики вы пишете сами, тем меньше шансов, что вы когда-нибудь увидите “Fatal error: Unexpectedly found nil…”.

4. Нормализация ключей в строках

Прежде чем писать большой пример, нужно сделать одну важную “человеческую” остановку. Частотная карта честно считает ключи. Если вы положили в ключ строку "Swift", а потом строку "swift", для словаря это два разных ключа. Он не обязан понимать, что “по смыслу это одно и то же”.

Поэтому в задачах с текстом почти всегда нужен шаг нормализации ключа. Это заранее выбранное правило: как именно мы приводим входные данные к “каноническому виду”. Самое популярное правило — lowercased() и (иногда) trimmingCharacters(in:).

Сделаем маленький пример: считаем слова, но приводим их к нижнему регистру.

import Foundation

let words = ["Swift", "is", "swift"]
var counts: [String: Int] = [:]

for w in words {
    let key = w.lowercased()
    counts[key, default: 0] += 1
}

print(counts) // ["swift": 2, "is": 1]

Здесь вы как разработчик фиксируете “контракт”: в нашем счётчике слова считаются без учёта регистра. Это не “правильно” или “неправильно” — это правило системы, и его полезно явно держать в голове.

5. Мини‑проект: консольный TextStats

Сейчас мы соберём небольшой консольный инструмент TextStats. Он будет читать строку из ввода, разбивать её на слова, нормализовать слова (например, по регистру), считать частоты и печатать результат. Это простая штука, но она тренирует сразу несколько полезных привычек: аккуратный ввод, чистую функцию токенизации, честную работу со словарём и понятный вывод.

Мы будем развивать этот мини‑проект постепенно: сначала научимся выделять слова, потом посчитаем частоты, потом красиво выведем топ самых частых.

Читаем строку и получаем массив слов

Самая частая ошибка новичка на этом этапе — пытаться “посчитать слова в строке”, не выделив слова как отдельные элементы. Пока у вас всё ещё одна строка, любая логика подсчёта будет либо костыльной, либо слишком сложной. Поэтому сначала превращаем строку в массив токенов.

Поскольку мы уже знаем split(separator:), сделаем максимально простую токенизацию: разделим по пробелам, уберём пустые куски, приведём к нижнему регистру.

import Foundation

func tokenize(_ line: String) -> [String] {
    let parts = line.split(separator: " ")
    return parts.map { part in
        part.lowercased()
    }
}

Этот вариант намеренно “не идеален”: он не убирает запятые и точки. Но для начала это нормально: наша цель — освоить паттерн счётчика, а не построить идеальный анализатор текста (идеальные анализаторы обычно пишутся слезами и регулярными выражениями… но это другая история).

Быстро проверим:

import Foundation

let tokens = tokenize("Swift is swift")
print(tokens) // ["swift", "is", "swift"]

Строим частотную карту слов

Теперь самое вкусное: из массива слов делаем [String: Int].

Обратите внимание на стиль: отдельная функция делает отдельную работу. Так код легче тестировать “в голове”, а не только запуском.

import Foundation

func buildWordFrequency(_ words: [String]) -> [String: Int] {
    var freq: [String: Int] = [:]

    for w in words {
        freq[w, default: 0] += 1
    }

    return freq
}

И используем:

import Foundation

let words = tokenize("Swift is swift")
let freq = buildWordFrequency(words)

print(freq["swift", default: 0]) // 2
print(freq["is", default: 0])    // 1

Важная мысль: мы не обязаны хранить в словаре ключи, которые встретились 0 раз. Если ключ не встречался — его просто нет. “Ноль” мы получаем через default: при чтении. Это как раз тот случай, где default: читается почти как обычная человеческая фраза: “дай значение, а если нет — считаем, что там ноль”.

Читаем ввод пользователя и печатаем частоты

Теперь соберём простейший CLI-скрипт: читаем строку, строим частотную карту, печатаем её.

import Foundation

print("Введите строку:")
let line = readLine() ?? ""

let words = tokenize(line)
let freq = buildWordFrequency(words)

print(freq) // например: ["swift": 2, "is": 1]

Да, вывод словаря не очень “красивый”, но пока терпимо. Главное — данные посчитаны правильно.

Почему «нет ключа» — не то же самое, что частота 0

На этом месте многие путаются: “Если у слова частота 0, почему бы не хранить 0?”. А потому что это обычно не нужно и неудобно.

Семантически “ключа нет” означает: “мы никогда не видели такое значение”. Это чуть более сильная информация, чем “частота 0”. В частотной карте мы обычно храним только то, что встречалось, а “0” используем как удобный дефолт для чтения.

В Swift это буквально закреплено удобным равенством: чтение dict[key, default: 0] эквивалентно чтению dict[key] ?? 0.

Посмотрим на разницу в поведении на примере:

import Foundation

var freq: [String: Int] = ["swift": 2]

print(freq["java"] as Any)               // nil
print(freq["java", default: 0])          // 0
print(freq.keys.contains("java"))        // false

Смысл такой:

  • freq["java"] даёт nil — “ключа нет”,
  • freq["java", default: 0] даёт 0 — “если ключа нет, считаем как 0”,
  • но при этом ключ по‑прежнему не появился (мы просто читали).

Это помогает писать код без лишних if, при этом сохраняя корректный смысл данных.

6. Топ‑N самых частых слов

Когда словарь построен, возникает следующая человеческая потребность: “окей, а какие слова самые частые?”. Для этого нам надо превратить словарь в массив пар и отсортировать по значению.

Мы не будем делать “идеальную статистику”, но сделаем простой и читаемый вывод. Да, сортировка добавляет стоимость, но на маленьких данных это нормально, а главное — код становится понятным.

Сделаем функцию, которая печатает топ‑N:

import Foundation

func printTopWords(freq: [String: Int], top n: Int) {
    let sortedPairs = freq.sorted { a, b in
        a.value > b.value
    }

    for (i, pair) in sortedPairs.prefix(n).enumerated() {
        print("\(i + 1). \(pair.key) — \(pair.value)")
    }
}

И используем в нашем TextStats:

import Foundation

let line = "Swift is swift and Swift is fun"
let words = tokenize(line)
let freq = buildWordFrequency(words)

printTopWords(freq: freq, top: 3)
// 1. swift — 3
// 2. is — 2
// 3. and — 1   (или fun — 1, порядок среди равных может отличаться)

Здесь важно помнить: если у двух слов одинаковая частота, порядок между ними может быть любым. Мы сейчас не добавляем “второй ключ сортировки” специально, чтобы не перегружать идею.

7. Частоты пар слов через ключ‑tuple

Иногда хочется считать не отдельные слова, а пары соседних слов: например, чтобы увидеть, какие сочетания в тексте встречаются часто ("machine learning", "swift is", "is fun"). Это всё тот же счётчик, только ключ теперь не String, а tuple из двух строк.

Плюс в том, что Swift умеет делать tuples хешируемыми (если элементы хешируемые), и мы можем использовать (String, String) как ключ. Это красиво сочетается с тем, что tuples мы уже знаем.

Сначала соберём пары:

import Foundation

func makeBigrams(_ words: [String]) -> [(String, String)] {
    guard words.count >= 2 else { return [] }

    var result: [(String, String)] = []
    for i in 0..<(words.count - 1) {
        result.append((words[i], words[i + 1]))
    }
    return result
}

Теперь считаем частоты bigram-ов:

import Foundation

func buildBigramFrequency(_ bigrams: [(String, String)]) -> [(String, String): Int] {
    var freq: [(String, String): Int] = [:]

    for b in bigrams {
        freq[b, default: 0] += 1
    }

    return freq
}

И пробуем:

import Foundation

let words = tokenize("swift is swift and swift is fun")
let bigrams = makeBigrams(words)
let freq = buildBigramFrequency(bigrams)

print(freq[("swift", "is"), default: 0]) // 2
print(freq[("is", "swift"), default: 0]) // 1

Если захотим найти “самую частую пару”, это можно сделать через проход по словарю:

import Foundation

func mostFrequentBigram(_ freq: [(String, String): Int]) -> ((String, String), Int)? {
    var best: ((String, String), Int)? = nil

    for (key, value) in freq {
        if best == nil || value > best!.1 {
            best = (key, value)
        }
    }

    return best
}

Использование:

import Foundation

if let best = mostFrequentBigram(freq) {
    print("\(best.0.0) \(best.0.1) — \(best.1)")
    // swift is — 2
} else {
    print("Недостаточно слов для пар.")
}

Тут мы уже видим важный дизайн-контракт: результат может отсутствовать (если слов меньше двух), поэтому возвращаем Optional. Это гораздо честнее, чем возвращать “пустую пару” вроде ("", "").

8. Схема: один проход по данным

Чтобы закрепить “картинку в голове”, полезно представить алгоритм как поток:

flowchart TD
    A[Массив элементов: words] --> B[Берём очередной элемент x]
    B --> C{Есть ли ключ x в словаре?}
    C -->|да| D[Увеличиваем текущее значение на 1]
    C -->|нет| E[Считаем default=0, получаем 0]
    E --> F[Записываем 1]
    D --> G[Переходим к следующему элементу]
    F --> G
    G -->|пока элементы есть| B
    G -->|конец| H[Готовая частотная карта]

В реальном коде всё это “упаковано” в одну строку freq[x, default: 0] += 1, но под капотом смысл именно такой.

9. Типичные ошибки при работе с частотными картами

Ошибка №1: counts[key]! += 1 как стиль “ну оно же работает”.
Такой код действительно может работать… ровно до первого нового ключа, который ещё не встречался. В этот момент counts[key] будет nil, и программа упадёт. Паттерн dict[key, default: 0] += 1 как раз и появился, чтобы не писать лишние проверки и не форсить !.

Ошибка №2: путаница между чтением и записью при default:.
Иногда ожидают, что let x = dict[key, default: 0] автоматически добавит ключ в словарь. Но чтение с дефолтом не “создаёт” ключ. Эквивалентность dict[key, default: 0] и dict[key] ?? 0 при чтении — важная подсказка, как это воспринимать: дефолт нужен для удобного значения, а не для мутации.

Ошибка №3: отсутствие нормализации ключа, особенно для строк.
Если вы считаете слова, но не приводите их к единому виду, вы получите раздельные счётчики для "Swift", "swift", "SWIFT" и "swift,". Формально словарь прав, но результат часто удивляет. Поэтому важно заранее договориться о правиле: хотя бы lowercased(), а дальше — по ситуации.

Ошибка №4: ожидание “красивого порядка” в словаре при выводе.
Dictionary создан для быстрого доступа по ключу, а не для хранения элементов в “правильном” порядке. Если вы печатаете словарь напрямую, порядок ключей не стоит воспринимать как часть контракта. Для красивого вывода почти всегда нужно отдельно собрать пары и отсортировать их.

Ошибка №5: смешивание логики подсчёта и логики вывода в один комок кода.
Когда подсчёт частот и печать результатов живут в одном цикле, код быстро превращается в “лапшу”: сложно добавить сортировку, сложно поменять формат, сложно переиспользовать подсчёт. Гораздо спокойнее (и по‑взрослому) разделить: сначала строим freq, потом отдельной функцией выводим то, что нужно.

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