JavaRush /Курси /Swift SELF /Поглиблюємося в роботу з Set

Поглиблюємося в роботу з Set

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

1. Коли потрібен Set: швидкі перевірки «чи є елемент»

Коли ви тільки починаєте програмувати, може здаватися, що масив — універсальна відповідь на всі запитання. Потрібні дані? Масив. Потрібно перевірити, чи є елемент? Ну… пройдемося масивом і порівняємо.

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

Перевірка належності — це будь-яке запитання на кшталт: «чи є елемент X у наборі?». Такі запитання трапляються постійно: не додавати тег повторно, не реєструвати одну й ту саму адресу електронної пошти двічі, не обробляти той самий ID повторно, знаходити спільні інтереси, перевіряти, чи дозволено значення.

Для таких задач Set у Swift — базовий інструмент: він зберігає факт наявності елемента й швидко виконує такі перевірки.

2. Set у Swift: унікальність, Hashable і базові операції

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

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

Ще один важливий момент: елементи Set мають бути Hashable. На цьому рівні достатньо простого правила: Int, String, Character і багато інших базових типів підходять, а складні користувацькі типи ми обговоримо пізніше. Наразі досить знати: якщо тип можна покласти в Set, це означає, що Swift уміє порівнювати його та хешувати так, щоб використовувати як «ключ».

Створення множини і contains

import Foundation

let bannedWords: Set<String> = ["spam", "scam", "ads"]

print(bannedWords.contains("spam"))   // так
print(bannedWords.contains("swift"))  // ні

Тут Set читається буквально як «набір заборонених слів». Перевірка contains виглядає як природне людське запитання.

Insert і «вставилося чи вже було»

import Foundation

var ids: Set<Int> = [10, 20, 30]

let r1 = ids.insert(20)
print(r1.inserted) // ні

let r2 = ids.insert(40)
print(r2.inserted) // так

insert повертає результат, у якому можна побачити, додали ви новий елемент чи лише спробували додати те, що вже існувало. Це часто допомагає писати код без зайвих if.

3. Практичні патерни: seen set і дедуплікація

Є особлива перевага в тому, щоб замінити вкладені цикли одним проходом. Це як прибрати з кімнати зайві речі: той самий зміст, менше спотикань. Патерн seen set якраз про це: ви проходите дані послідовно й зберігаєте в Set усе, що вже бачили. Якщо зустріли елемент, який уже є в Set, значить, це повтор.

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

Невелика схема процесу (це лише схема, не Swift):

flowchart TD
    A[Беремо черговий елемент x] --> B{seen уже містить x?}
    B -- так --> C[Це повтор — реагуємо]
    B -- ні --> D["seen.insert(x)"]
    D --> A

Seen set: перший повтор у масиві

import Foundation

let nums = [5, 1, 4, 1, 9]

var seen: Set<Int> = []
var firstDuplicate: Int? = nil

for x in nums {
    if seen.contains(x) {
        firstDuplicate = x
        break
    }
    seen.insert(x)
}

print(firstDuplicate as Any) // Optional(1)

Зверніть увагу на порядок: спочатку перевіряємо contains, потім insert. Так ми не даємо елементу випадково збігтися із самим собою в межах однієї ітерації.

Дедуплікація через Set: швидко, але без гарантії порядку

Коли ви чуєте слово «дедуплікація», перша спокуса — найкоротше рішення:

let unique = Set(array)

І це справді корисний трюк: отримати множину унікальних елементів із масиву можна буквально одним рядком. Але є важливий нюанс: Set не зобов’язаний зберігати порядок елементів. Тобто, якщо вам важливий порядок появи, одного Set(array) недостатньо.

І це не «особливість Swift сьогодні», а сама ідея множини: вона відповідає за унікальність, а не за порядок. Тому в задачах «прибрати повтори, але залишити порядок як у початковому списку» часто використовують комбінацію: Set для перевірки «бачили/не бачили» і Array для результату.

До речі, у матеріалах про вдосконалення стандартної бібліотеки окремо підкреслюють, що після деяких операцій (наприклад, lazy.filter) ви можете отримати не Set, а «обгортку-колекцію», і тоді інколи корисно знову явно зібрати Set(...), щоб повернути саме множину та її операції.

import Foundation

let tags = ["swift", "cli", "swift", "set"]
let uniqueTags = Set(tags)

print(uniqueTags) // порядок може бути будь-яким

Вивід може бути в різному порядку, і це нормально.

Дедуплікація зі збереженням порядку

import Foundation

let tags = ["swift", "cli", "swift", "set"]

var seen: Set<String> = []
var orderedUnique: [String] = []

for t in tags {
    if !seen.contains(t) {
        seen.insert(t)
        orderedUnique.append(t)
    }
}

print(orderedUnique) // ["swift", "cli", "set"]

Це один із найпрактичніших рецептів: Set відповідає за «унікальність», масив — за «порядок».

4. Математика множин у коді

У Set є ще одна суперсила: він уміє виражати «математику множин» просто в коді. Завдяки цьому код стає значно читабельнішим: замість «пройди по одному масиву, потім по іншому, потім порівняй…» ви пишете «перетин» або «об’єднання», і зміст видно одразу.

Найкорисніші операції:

  • intersection — спільні елементи;
  • union — усі елементи обох множин без повторів;
  • subtracting — елементи першої множини без елементів другої;
  • symmetricDifference — елементи, які є рівно в одній із множин (як «XOR», але для колекцій).

Так, це звучить трохи математично, але на практиці це запитання рівня «які теги спільні для двох книг» або «які користувачі є в списку A, але відсутні в списку B».

Перетин інтересів

import Foundation

let alice: Set<String> = ["swift", "music", "travel"]
let bob: Set<String> = ["java", "music", "swift"]

let common = alice.intersection(bob)
print(common) // {"music", "swift"} (порядок не має значення)

Різниці та symmetricDifference

import Foundation

let alice: Set<String> = ["swift", "music", "travel"]
let bob: Set<String> = ["java", "music", "swift"]

let onlyAlice = alice.subtracting(bob)
let onlyBob = bob.subtracting(alice)
let exactlyOne = alice.symmetricDifference(bob)

print(onlyAlice)   // {"travel"}
print(onlyBob)     // {"java"}
print(exactlyOne)  // {"travel", "java"}

З такими операціями Set перестає бути «просто контейнером» і стає способом компактно формулювати думку.

5. Мініприклад: бібліотека і підмножини

Зараз ми ще не дійшли до власних типів через struct, тому давайте чесно використаємо те, що вже можна: tuple, масиви та множини. Уявімо мінікаталог книг у вигляді масиву кортежів (id, title, tags). А далі зробимо дві корисні функції: дедуплікацію тегів у межах усієї бібліотеки та перевірку «книга містить усі потрібні теги».

Дані «як є»

import Foundation

let books: [(id: Int, title: String, tags: [String])] = [
    (1, "Swift Basics", ["swift", "beginner", "cli"]),
    (2, "Algorithms", ["arrays", "set", "dictionary"]),
    (3, "Swift CLI Tools", ["swift", "cli", "set"])
]

Зібрати всі унікальні теги в межах бібліотеки

import Foundation

func allUniqueTags(in books: [(id: Int, title: String, tags: [String])]) -> Set<String> {
    var result: Set<String> = []
    for b in books {
        for t in b.tags {
            result.insert(t)
        }
    }
    return result
}

print(allUniqueTags(in: books))

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

Перевірити, що книга містить усі потрібні теги

import Foundation

func hasAllTags(bookTags: [String], required: Set<String>) -> Bool {
    let tagSet = Set(bookTags)
    return required.isSubset(of: tagSet)
}

print(hasAllTags(bookTags: ["swift", "cli", "set"], required: ["swift", "set"])) // так
print(hasAllTags(bookTags: ["swift", "cli"], required: ["swift", "set"]))       // ні

Тут є важливий бонус: ми перетворюємо масив тегів на Set, тому що для множин є природна операція isSubset(of:). Це якраз той випадок, коли код прямо виражає вашу думку: required є підмножиною тегів книги.

6. Продуктивність і чесні обмеження

Дуже хочеться сказати: «Set — це магія, і все працює миттєво». Але краще тримати реалістичну картину. Зазвичай Set.contains і Set.insert працюють швидко, тому що всередині використовується хешування: за значенням обчислюється «відбиток», а потім за ним швидко знаходиться місце в структурі. Навіть якщо трапляються колізії (коли різні значення дають схожі відбитки), структура все одно вміє коректно розрізняти елементи через порівняння.

Однак «швидко» не означає «однаково миттєво за будь-яких даних». Це модель «у середньому», для звичайних вхідних даних. На практиці для нас достатньо правила: якщо ви багато разів питаєте «чи є елемент» на великих даних, масив може почати гальмувати, а Set зазвичай почувається набагато впевненіше.

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

Запитання задачі Що обрати Чому за змістом
«Чи є такий елемент?»
Set
зберігає факт наявності
«Прибрати повтори» Set або Set + Array Set прибирає, Array зберігає порядок
«Знайти спільні елементи двох списків»
Set
є intersection та споріднені операції
«Скільки разів трапляється?»
Dictionary<Key, Int>
потрібен лічильник, а не просто факт наявності

Якщо звикнути до такого «питального» стилю мислення, ви почнете обирати структуру даних майже автоматично.

7. Типові помилки під час використання Set для перевірок належності

Помилка №1: очікувати, що Set зберігає порядок.
Дуже часта пастка: ви робите Set(array) і потім дивуєтеся, чому елементи «перемішалися». Set призначений для унікальності та швидких перевірок, а порядок не є частиною його контракту. Якщо порядок важливий, зберігайте результат у масиві й використовуйте seen set як фільтр.

Помилка №2: розв’язувати задачу «перший унікальний» лише за допомогою Set.
Set відповідає на запитання «чи є елемент взагалі», але не зберігає інформації про кількість появ і першу позицію. Тому для задач на кшталт «знайди перший унікальний символ» зазвичай потрібні словник частот і другий прохід. Set тут може бути частиною рішення, але не всією логікою.

Помилка №3: робити перевірку належності в масиві в циклі, коли перевірок багато.
Код на кшталт «для кожного елемента перевіряю array.contains(...)» часто непомітно перетворюється на квадратичну складність, особливо якщо всередині ще один цикл. Якщо всередині циклу ви регулярно питаєте «чи вже бачили», майже завжди вигідно завести Set.

Помилка №4: плутати «дедуплікацію» з «унікалізацією зі збереженням порядку».
Якщо задача звучить «видали дублікати», не поспішайте. Іноді мається на увазі «видали дублікати, але порядок залиш як у початковому списку». Один Set не збереже порядок, тому використовуйте зв’язку: Set для перевірки й масив для результату.

Помилка №5: намагатися зберігати в Set дані, яким потрібна додаткова інформація.
Якщо ви розумієте, що вам потрібно не лише «чи є елемент», а й «скільки разів», «у яких позиціях», «з яким контекстом», то Set стає надто бідним інструментом. У цей момент краще перейти на Dictionary, де значенням може бути лічильник, масив позицій або будь-яке інше корисне навантаження.

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