JavaRush /Курси /Swift SELF /Частотна карта (frequency map)

Частотна карта (frequency map)

Swift SELF
Рівень 13 , Лекція 4
Відкрита

1. Модель частотної карти

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

І тут виникає типова проблема: якщо робити це напряму через масиви, ви починаєте писати вкладені цикли, перевіряти кожен елемент по кілька разів і відчувати, що комп’ютер сумує, а ви разом із ним. Частотна карта (frequency map) вирішує саме цей клас задач: швидко й просто накопичувати статистику повторень.

Що таке частотна карта: «елемент → кількість»

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

  • для слів: [String: Int]
  • для чисел: [Int: Int]
  • для символів: [Character: Int]

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

Щоб закріпити модель, зручно подивитися на неї як на маленьку таблицю:

Дані Частотна карта
["apple", "banana", "apple"]
["apple": 2, "banana": 1]
[10, 10, 7]
[10: 2, 7: 1]

Ключовий момент: ключі в словнику унікальні, тому «додати ще один apple» означає «збільшити лічильник за ключем apple».

Базовий шаблон: freq[x, default: 0] += 1

Тепер погляньмо на прийом, який робить частотні карти такими зручними. Якби в нас не було default:-сабскрипта, довелося б щоразу перевіряти, чи є ключ, а якщо ні — створювати його. У Swift це можна зробити значно охайніше: через dict[key, default: ...]. Саме цей механізм і придумали для зручного накопичення значень у словнику.

Ось мінімальний приклад частотної карти для масиву рядків:

var freq: [String: Int] = [:]
let words = ["swift", "cli", "swift", "code"]

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

print(freq) // наприклад: ["cli": 1, "swift": 2, "code": 1]

Важлива деталь, яка часто дивує новачків: запис freq[w, default: 0] повертає не Optional, а звичайний Int. Саме тому тут можна написати += 1 без розпакування. Ідея проста: «якщо ключа немає — вважати, що там 0».

Якщо вам потрібно порахувати частоти символів у рядку, шаблон той самий:

let text = "how now brown cow"
var letters: [Character: Int] = [:]

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

print(letters["o", default: 0]) // 4

Ця конструкція добре підкреслює думку: «отримати значення (або 0) і відразу змінити його», не розпаковуючи Optional вручну.

3. Нормалізація ключів

Коли ви рахуєте частоти рядків, дуже легко випадково отримати «різні» ключі, які для людини виглядають однаково. Наприклад, "Swift", "swift" і "swift " для словника — це три різні рядки, а для людини — майже одне й те саме слово.

Тому в реальних задачах майже завжди перед підрахунком виконують нормалізацію: приводять вхідні дані до єдиного вигляду. На цьому етапі достатньо двох простих дій: прибрати пробіли по краях і перевести текст у нижній регістр. Для trimmingCharacters(in:) потрібен Foundation, тож чесно імпортуємо його.

Зробімо маленьку функцію, яка перетворює те, як людина ввела слово, на зручний для підрахунку вигляд:

import Foundation

func normalize(word: Substring) -> String {
    let s = String(word)
    let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
    return trimmed.lowercased()
}

Зверніть увагу: вхідний тип тут — Substring, тому що split(separator:) повертає саме його. Ми перетворюємо значення на String — і вже далі спокійно працюємо з рядком.

Тепер можна спокійно рахувати слова:

import Foundation

let line = "  Swift swift  CLI  "
let parts = line.split(separator: " ")

var freq: [String: Int] = [:]
for p in parts {
    let w = normalize(word: p)
    if !w.isEmpty {
        freq[w, default: 0] += 1
    }
}

print(freq) // ["swift": 2, "cli": 1]

Тут ми ще й відсікаємо порожні рядки. Чому це важливо? Тому що split(separator:) при множинних пробілах поводиться зручно, але в реальних введеннях люди здатні дивувати: іноді трапляються табуляції, іноді копіюють текст із «невидимими» пробілами, іноді вводять порожній рядок. Перевірка isEmpty — дешева страховка.

4. Мінізастосунок TextStats

Тепер зберемо все в невеликий консольний застосунок, який ви можете запускати у Web-IDE або локальній IDE: користувач вводить рядок, а застосунок друкує частотну карту.

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

import Foundation

print("Введіть рядок:")
let line = readLine() ?? ""

let parts = line.split(separator: " ")
var freq: [String: Int] = [:]

for p in parts {
    let w = String(p).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    if !w.isEmpty {
        freq[w, default: 0] += 1
    }
}

print(freq) // наприклад: ["swift": 2, "is": 1, "fun": 1]

Тут спеціально все зібрано в один блок без додаткових функцій, щоб ви чітко бачили сам патерн частотної карти. На наступних кроках, коли ви почнете активніше рефакторити код, таку логіку буде зручно виносити у функцію. Але поки важливіше закріпити саму ідею.

5. Статистика й агрегати поверх частотної карти

Як витягнути корисну статистику

Частотна карта сама по собі — уже результат. Але зазвичай хочеться чогось більш «людського»: скільки всього унікальних слів, яке слово найчастіше, скільки слів повторилися більше одного разу.

Гарна новина: на початковому рівні все це робиться звичайним циклом for (key, value) in dict. Погана новина: словник не гарантує «красивого порядку», тому виведення може виглядати хаотично. Це нормально: словник не про порядок, а про швидкий доступ.

Порахуємо кількість унікальних слів і загальну кількість слів:

let freq: [String: Int] = ["swift": 2, "cli": 1, "fun": 3]

let uniqueWords = freq.count
var totalWords = 0

for (_, count) in freq {
    totalWords += count
}

print(uniqueWords) // 3
print(totalWords)  // 6

freq.count — це кількість ключів, тобто кількість унікальних елементів. А сума всіх значень дає загальну кількість елементів у вихідному тексті після нормалізації.

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

let freq: [String: Int] = ["swift": 2, "cli": 1, "fun": 3]

var bestWord = ""
var bestCount = 0

for (word, count) in freq {
    if count > bestCount {
        bestWord = word
        bestCount = count
    }
}

print("\(bestWord) = \(bestCount)") // fun = 3

Якщо freq порожній, bestWord так і залишиться порожнім рядком, а bestCount — нулем. Це не ідеальний UX, але на цьому етапі це чесна й безпечна поведінка.

Частотна карта як «універсальний накопичувач»

Важливо побачити ширшу ідею: частотна карта — це окремий випадок загального патерну «словник як накопичувач». У частотній карті накопичення відбувається в Int, але так само можна накопичувати суми, списки чи будь-які інші агрегати, які вам потрібні.

Наприклад, припустімо, що в нас є список покупок у вигляді «категорія товару» та «ціна», і ми хочемо порахувати сумарні витрати за категоріями. Це вже не frequency map, але за механікою — майже те саме.

let categories = ["food", "food", "books", "food"]
let prices = [10, 5, 30, 7]

var sums: [String: Int] = [:]

for i in 0..<categories.count {
    sums[categories[i], default: 0] += prices[i]
}

print(sums) // наприклад: ["books": 30, "food": 22]

Той самий патерн: default: 0, а потім +=.

А тепер приклад, де значенням буде масив. Припустімо, хочемо зібрати індекси позицій кожного слова — де воно траплялося. Це корисно, наприклад, щоб потім підсвічувати збіги в тексті. Ми не використовуємо жодних готових API для групування, а робимо все вручну — без скорочень:

let words = ["swift", "cli", "swift", "fun"]
var positions: [String: [Int]] = [:]

for i in 0..<words.count {
    positions[words[i], default: []].append(i)
}

print(positions["swift", default: []]) // [0, 2]

Тут default: [] створює «порожній список позицій» для слова, яке трапилося вперше, і далі ми просто виконуємо append.

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

6. Корисні тонкощі: порядок і мутації

Із частотними картами є кілька моментів, які важливо зрозуміти саме зараз, інакше пізніше ви натрапите на дивні помилки й будете підозрювати, що Swift живе своїм життям. Спойлер: Swift не живе своїм життям, це просто ми іноді пишемо так, ніби правила не існують.

По-перше, порядок обходу словника у for (k, v) in dict не варто сприймати як відсортований або стабільний. Якщо вам потрібен порядок, це вже окрема задача, і зазвичай її вирішують не самим словником.

По-друге, словник не можна безпечно змінювати — додавати або видаляти ключі — поки ви обходите його циклом for-in. Це схоже на ситуацію, коли ви переставляєте полиці в шафі, поки сидите всередині й шукаєте шкарпетки. Іноді здається, що так можна, але зазвичай усе закінчується падінням або дивною поведінкою.

По-третє, для частотної карти діє просте правило коректності: лічильник не повинен ставати від’ємним. Якщо ви реалізуєте зменшення частот, доведеться продумати, що робити при нулі: залишати ключ зі значенням 0 чи видаляти ключ. У межах сьогоднішньої лекції ми лише збільшуємо лічильник — це найпростіший і найнадійніший варіант патерну.

Щоб чітко зафіксувати сам алгоритм підрахунку, ось його схема:

flowchart TD
    A[Беремо наступний елемент x] --> B{Чи є в словнику ключ x?}
    B -- так --> C[Беремо поточне значення]
    B -- ні --> D[Вважаємо поточне значення нульовим]
    C --> E[Збільшуємо на 1]
    D --> E
    E --> F[Записуємо значення назад у словник]
    F --> G{Елементи закінчилися?}
    G -- ні --> A
    G -- так --> H[Готова частотна карта]

На практиці dict[x, default: 0] += 1 робить всю цю схему за вас в один рядок. Саме тому цей патерн так люблять: він і короткий, і виразний.

7. Типові помилки під час роботи з частотною картою

Помилка №1: намагатися писати freq[x] += 1.
Новачки часто роблять так за звичкою: «взяв значення, збільшив». Але freq[x] — це Int?, тому що ключа може не бути. Swift не дасть вам додати nil до 1. Правильний шлях — freq[x, default: 0] += 1, тому що він гарантує звичайний Int, навіть якщо ключ трапляється вперше.

Помилка №2: примусове витягування freq[x]! заради «аби працювало».
Іноді хочеться «перемогти компілятор» і написати freq[x]! += 1. Це працює рівно до першого нового ключа, а потім ви отримуєте падіння застосунку. Якщо дані надходять від користувача, файла або мережі, таке падіння буде не «рідкісним винятком», а гарантованою подією в майбутньому.

Помилка №3: не нормалізувати рядки й дивуватися, «чому так багато ключів».
Якщо ви рахуєте слова без lowercased() і без обрізання пробілів, то "Swift", "swift" і "swift " стануть різними ключами. Частотна карта при цьому буде формально правильною, але марною для людини. Нормалізація — це частина задачі, а не «косметика».

Помилка №4: плутати items.count і freq.count.
items.count — це скільки всього елементів у вихідних даних. freq.count — це скільки унікальних ключів вийшло. Коли ви аналізуєте текст, freq.count — це розмір словника, а не довжина рядка й не кількість слів у тексті.

Помилка №5: змінювати словник під час обходу й отримувати дивні результати.
Якщо ви ітеруєтеся за допомогою for (k, v) in freq і всередині видаляєте ключі або додаєте нові, ви руйнуєте передбачуваність обходу. У простих задачах це іноді «випадково працює», але це погана звичка. Якщо потрібно видалити частину ключів, спочатку зберіть список ключів, а потім видаляйте їх окремим проходом — або перебудуйте словник заново.

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