У попередній темі ми розібрали словник і хеш-таблицю під ним: адреса запису рахується з самого ключа, тому пошук коштує один крок незалежно від розміру. Наприкінці я пообіцяв, що далі буде легко. Тримаю слово: множина (set) — це той самий словник, у якого прибрали половину. Лишились ключі, зникли значення. Уся механіка переїхала без змін, а натомість з'явилось те, чого у словника немає, — операції над наборами.
Задача, з якої почнемо, буденна. На день відкритих дверей записувались через форму, і дехто натиснув «надіслати» двічі. У тебе список імен, у якому є повтори, і два питання: скільки насправді різних людей і хто з них ходить одразу в обидва гуртки. Список на такі питання відповідає погано — доведеться порівнювати кожного з кожним. Множина відповідає на них одним символом.
01 / ІдеяНабір без повторів
Множина — це набір несхожих елементів без порядку. Два слова тут головні, і кожне варте окремої уваги.
Без повторів. Той самий елемент не може лежати у множині двічі. Не «не варто класти», а фізично не може: спроба додати вже наявний елемент нічого не змінює й помилки не викликає. Вона просто безслідно зникає.
Без порядку. У множини немає ані першого елемента, ані останнього.
Записи {1, 2} і {2, 1} — це одна й та сама множина, і Python
вважає їх рівними. Питання «а що там під номером нуль?» до множини не ставлять — вона на
нього не відповідає взагалі.
Подивись на першу властивість живцем. Нижче наш список записів прокручується по одному імені, і поруч видно два числа: скільки разів ми намагались додати й скільки елементів фактично лежить усередині.
Інтерактив 1 · Дублікати зникають безслідно
Вісім спроб додати, чотири різні імені. Дивись, що робить множина з повтором.
Вісім спроб — чотири елементи. Множина не рахує, скільки разів їй давали «Аню»: у неї
є лише два стани, «є» і «немає». Якщо тобі потрібна саме кількість повторів, множина не
підійде — бери dict і рахунок через .get(), як у минулій темі.
02 / ОсновиЯк створити множину
Найкоротший спосіб — перелічити елементи у фігурних дужках через кому:
print(робототехніка)
{'Оля', 'Галя', 'Аня', 'Богдан'} # порядок може бути іншим
Другий спосіб — конструктор set(). Він приймає будь-що, що можна пройти
елемент за елементом: список, кортеж, рядок. І одразу викидає повтори:
{1, 2, 3}
print(set("привіт"))
{'и', 'в', 'т', 'і', 'п', 'р'} # рядок розібрано по літерах
{}. Порожні фігурні дужки історично
закріплені за словником — ми про це говорили в темі 08. Порожня множина пишеться тільки
одним способом: set(). Перевірити легко:
type({}) дасть <class 'dict'>, а type(set()) —
<class 'set'>. Помилка тиха й підступна: код не падає, просто далі ти
працюєш зі словником і дивуєшся, чому add не існує.Незручність тут одна й вона реальна: конструкція {} зайнята, а окремого
короткого запису порожній множині не дісталось. Тому в коді, який створює множину
поступово, майже завжди побачиш саме набір = set() у першому рядку.
03 / Змінаadd, discard, remove
Множина змінювана — як список і як словник. Її можна поповнювати й чистити після створення.
| дія | що робить | якщо елемента немає |
|---|---|---|
| s.add(x) | додає елемент | — |
| s.discard(x) | прибирає елемент | мовчки нічого не робить |
| s.remove(x) | прибирає елемент | KeyError |
| s.pop() | прибирає довільний елемент і повертає його | KeyError на порожній |
| s.clear() | прибирає геть усе | — |
| x in s | перевіряє наявність | поверне False |
| len(s) | скільки елементів | — |
Перший рядок таблиці, який справді треба запам'ятати, — різниця між
discard і remove. Обидва прибирають елемент і на наявному
елементі поводяться абсолютно однаково. Розходяться вони рівно тоді, коли елемента немає:
remove вважає це аварією й зупиняє програму, discard вважає це
нормою й мовчить.
Інтерактив 2 · remove проти discard
На наявному елементі різниці немає. Перемкни на відсутній — і подивись, що друкує кожен.
[] і
.get() у словнику. Якщо відсутність елемента означає, що десь у логіці
помилка, — бери remove, хай падає гучно й одразу. Якщо відсутність
нормальна («приберемо, якщо було»), — бери discard. Мовчазне
discard там, де треба гучне remove, ховає баг на місяці.pop() без аргументу. У списку
pop() знімає останній елемент — там є «останній». У множині останнього немає,
тому set.pop() віддає довільний елемент: той, що першим трапиться
в таблиці. Не випадковий і не найменший — просто той, який лежить найближче до початку
внутрішнього масиву. Покладатись на конкретне значення не можна, і це єдиний метод
множини, поведінку якого документація навмисно не уточнює.04 / Швидкістьin — головна перевага
Тепер найпрактичніша причина взагалі знати про множини. Питання «чи є цей елемент у наборі» — одне з найчастіших у програмуванні: чи є слово в стоп-списку, чи є користувач у чорному списку, чи бачили ми вже цю сторінку.
Список відповідає на нього чесним перебором: порівнює по черзі, поки не знайде або поки не закінчиться. Множина рахує адресу з самого елемента — рівно так, як словник рахує її з ключа, — і робить одну перевірку. Знову рахуємо не секунди, а кроки: секунди залежать від процесора, кроки — тільки від структури даних.
Інтерактив 3 · Перевірка in: множина проти списку
Скільки разів довелося порівняти елементи, щоб відповісти «є» або «немає».
Різниця не косметична. Якщо перевірок багато — скажімо, ти проганяєш через стоп-список
кожне слово великого тексту, — заміна одного символу в коді ([…] на
{…}) прискорює програму в сотні разів. Це найдешевша оптимізація, яка взагалі
буває в Python, і найчастіше пропущена новачками.
05 / АлгебраОб'єднання, перетин, різниця
Ось те, чого немає ні в списку, ні в словнику. Над множинами визначені операції, які ти вже бачив у школі на діаграмах із двома колами. У Python вони записуються двома способами — оператором і методом, — і ці способи не зовсім взаємозамінні.
| оператор | метод | що це означає словами |
|---|---|---|
| A | B | A.union(B) | усі, хто є хоч десь: об'єднання |
| A & B | A.intersection(B) | лише ті, хто є в обох: перетин |
| A - B | A.difference(B) | ті, хто в A, але не в B: різниця |
| A ^ B | A.symmetric_difference(B) | ті, хто рівно в одному з двох |
Наш приклад: два гуртки, у кожного свій склад. Дехто ходить в обидва. Покрути перемикач і подивись, яка операція яку частину картинки залишає.
Інтерактив 4 · Дві множини, чотири операції
Гурток робототехніки і гурток малювання. Підсвічена частина — це і є результат.
A−B на
B−A і назад. Підсвічується різна частина, і результати не мають
жодного спільного імені. Різниця множин не симетрична: питання «хто ходить
тільки на робототехніку» і «хто ходить тільки на малювання» — це два різні питання.
А от |, & і ^ від перестановки не залежать.У коді це виглядає буквально так, як звучить:
малювання = {"Галя", "Оля", "Петро", "Ніна"}
print(робототехніка & малювання) # хто ходить в обидва
{'Галя', 'Оля'}
print(робототехніка - малювання) # лише робототехніка
{'Аня', 'Богдан'}
print(малювання - робототехніка) # лише малювання — інша відповідь!
{'Петро', 'Ніна'}
Чим оператор відрізняється від методу
Різниця одна, зате важлива. Оператор вимагає, щоб праворуч теж стояла множина. Метод погоджується на будь-що, що можна пройти елемент за елементом, — і сам зробить із нього множину:
{1, 2} | [3, 4] # TypeError: unsupported operand type(s)
Практичний висновок: оператор коротший і читабельніший, коли обидві сторони й так
множини. Метод рятує, коли друга сторона — список чи щось інше, і позбавляє тебе зайвого
set(...) навколо аргументу.
Є ще версії «на місці», які змінюють ліву множину замість того, щоб робити нову:
|= (він же update), &=, -=,
^=. Логіка та сама, що у списку з +=: коротка форма економить
пам'ять, але псує оригінал. Якщо на вихідну множину десь є ще одне ім'я — згадай
тему про ярлики й подумай двічі.
06 / ПорівнянняПідмножина й надмножина
Крім операцій, множини вміють порівнюватись між собою. Знаки ті самі, що для чисел, але означають вони геть інше — не «менше», а «міститься всередині».
| запис | читається | приклад, що дає True |
|---|---|---|
| A <= B | A — підмножина B: усе з A є в B | {1,2} <= {1,2,3} |
| A < B | підмножина й до того ж не вся B | {1,2} < {1,2,3} |
| A >= B | A — надмножина B: усе з B є в A | {1,2,3} >= {1,2} |
| A == B | однаковий склад, порядок не важить | {1,2} == {2,1} |
| A.isdisjoint(B) | жодного спільного елемента | {1,2}.isdisjoint({3,4}) |
Найчастіше застосування <= — перевірка прав доступу: «чи всі потрібні
дозволи є в користувача». Одне порівняння замість переліку окремих перевірок. А
isdisjoint — це коротший і дешевший спосіб спитати «перетин порожній?»: він
зупиняється на першому ж спільному елементі, замість того щоб будувати весь перетин
і потім міряти його довжину.
{1, 2} і {2, 3}: жодна не міститься
в іншій, і вони не рівні. Тому A <= B дасть False і
A >= B теж дасть False — одночасно. Для чисел таке
неможливо, для множин — щоденна ситуація. Якщо десь у коді ти зробив висновок «раз не
підмножина, отже надмножина» — це помилка.07 / ОбмеженняЩо можна класти всередину
Тепер обмеження, яке ти вже вгадав. Множина шукає елемент так само, як словник шукає ключ: рахує з нього адресу комірки. Отже елемент мусить уміти давати хеш — має бути хешованим. Це те саме слово, яке ми зустріли в темі про кортежі і яким пояснили обмеження на ключі словника.
Правило дослівно те саме: у множину можна класти те, що можна взяти ключем
словника. Числа, рядки, None, логічні значення, кортежі з хешованих
елементів — можна. Списки, словники й інші множини — ні.
Перевір себе перш ніж читати далі. Обери об'єкт, передбач відповідь — і подивись, що скаже Python.
Інтерактив 5 · Що можна покласти в множину
Спершу передбач, потім перевір. Вісім об'єктів, чотири з них — не пройдуть.
frozenset проходить, а звичайна set — ні: перша
незмінна, друга змінювана.(1, 2) у множину лягає, а (1, [2]) — ні:
всередині живе список. І помилка при цьому називається unhashable type: 'list',
хоча кладеш ти кортеж, — тому й губиться. Правило точне: кортеж хешований тоді й лише
тоді, коли хешовані всі його елементи на будь-якій глибині.08 / Заморозкаfrozenset
З попереднього розділу випливає прикра річ: множину не можна покласти в множину. А іноді дуже треба — наприклад, зібрати набір різних команд, де кожна команда сама є набором імен.
Для цього є frozenset — незмінна множина. Стосунок між
set і frozenset точно такий самий, як між
списком і
кортежем: той самий вміст, ті самі операції
читання, але жодного методу, який щось міняє. Немає add, немає
discard, немає clear.
print(команда & {"Богдан", "Галя"}) # операції працюють як завжди
frozenset({'Богдан'})
команда.add("Оля")
# AttributeError: 'frozenset' object has no attribute 'add'
Незмінність дає хешованість, а хешованість дає дві можливості, недоступні звичайній множині:
- Ключ словника. Наприклад, «яка пара учнів здала проєкт разом →
оцінка». Ключем тут природно виглядає саме набір із двох імен, а не кортеж: пара
«Аня й Богдан» і пара «Богдан і Аня» — та сама пара, і
frozensetце відображає точно, а кортеж — ні; - Елемент іншої множини. Множина команд, множина унікальних наборів тегів, множина різних станів — усе це вимагає, щоб внутрішній набір був замороженим.
print(len(пари))
1 # це одна й та сама пара — порядок у множині не важить
Той самий трюк із кортежами дав би два різні елементи, бо ("Аня", "Богдан")
і ("Богдан", "Аня") — різні кортежі. Ось конкретна ситуація, де
frozenset не просто можливий, а єдино правильний.
09 / ЗастосуванняМножина як інструмент
Найчастіше множину беруть не як сховище, а як проміжний крок: перетворив, порахував, повернувся до списку. Три ідіоми покривають майже все.
1 · Прибрати дублікати
print(len(записи), "записів,", len(set(записи)), "різних людей")
7 записів, 4 різних людей
Якщо потрібен саме список, а не множина, — обгорни назад: list(set(записи)).
Тільки пам'ятай, що порядок при цьому втрачається безповоротно. Як зберегти порядок —
у розділі 10.
2 · Знайти спільне у двох наборах
Це той самий перетин, але варто побачити його в типовому оточенні. Дані рідко приходять множинами — зазвичай це списки, які треба спершу перетворити:
сьогодні = ["/ціни", "/контакти", "/головна"]
print(set(вчора) & set(сьогодні)) # сторінки, які дивились обидва дні
{'/головна', '/ціни'}
print(set(сьогодні) - set(вчора)) # що з'явилось сьогодні
{'/контакти'}
print(set(вчора) - set(сьогодні)) # що зникло
{'/про-нас'}
Три рядки замість вкладеного порівняння кожного з кожним. І, що важливіше, три рядки, які читаються як речення українською.
3 · Перевірка належності
Якщо в коді є набір, по якому ти перевіряєш багато разів, зроби його множиною один раз
і далі користуйся. Головне — не робити set(...) на кожній перевірці: тоді
ти щоразу будуватимеш таблицю заново й перетвориш перевагу на збиток.
10 / ПрофіЧому порядок такий дивний
Тепер обіцяне пояснення. Множина всередині — та сама хеш-таблиця, що й у словника, тільки без масиву значень. Комірка зберігає сам елемент і його хеш; місця під значення просто немає. Уся механіка з теми 08 — остача від ділення, колізії, маршрут обходу, перехешування — переїхала сюди без жодної зміни.
А от що не переїхало — компактне представлення з двох масивів, яке подарувало словнику порядок вставки. У множини його немає: там одна таблиця, і елемент лежить рівно в тій комірці, куди вказав хеш. Тому порядок виводу визначають комірки, а не порядок додавання.
Ключова думка, яку зазвичай формулюють неправильно: цей порядок не випадковий. Він жорстко визначений хешами й розміром таблиці. Для цілих чисел, чий хеш дорівнює їм самим, його можна порахувати на папері — що ми зараз і зробимо.
Інтерактив 6 · Чому порядок не той, у якому додавали
Та сама множина, записана трьома різними способами. Результат один і той самий.
print({10, 3, 25, 7}) —
отримаєш рівно {25, 10, 3, 7}, і так буде на будь-якій машині з CPython.
Числа тут дають повторюваний результат, бо hash(n) == n для невеликих
цілих. З рядками картинка та сама, але хеш рядка рандомізований при кожному
запуску — тому множина слів друкується щоразу по-різному. Це не помилка, а захист,
про який ми говорили в темі 08.Множина розширюється раніше за словник
Словник тримається до двох третин заповнення. Множина цього не робить: у CPython вона
збільшує таблицю приблизно на трьох п'ятих. Умова в сішному коді точна:
таблиця росте, коли заповнення × 5 ≥ (розмір − 1) × 3. Для таблиці на вісім
комірок це означає, що чотири елементи ще вміщаються, а п'ятий уже спричиняє повне
перехешування.
множина = set()
множина.add(1); множина.add(2); множина.add(3); множина.add(4)
print(sys.getsizeof(множина))
216 # таблиця на 8 комірок, вбудована в сам об'єкт
множина.add(5)
print(sys.getsizeof(множина))
728 # п'ятий елемент — і таблиця вже на 32 комірки
Чому запас більший, ніж у словника? Бо множині нікуди подітись від колізій. У словника дірки живуть у маленькій індексній таблиці по байту на комірку, і тримати їх дешево. У множини діра — це повноцінна комірка з місцем під елемент і його хеш, і що ближче до заповнення, то довші маршрути обходу. Дешевше витратити пам'ять, ніж кроки.
set проти dict.fromkeys для дедуплікації
І остання деталь, яку варто знати напам'ять. Класична задача: прибрати дублікати зі збереженням порядку. Через множину це неможливо — вона порядок не зберігає за конструкцією. Але словник зберігає, і в нього є конструктор, який робить рівно те, що треба:
print(list(set(записи)))
['Аня', 'Оля', 'Галя', 'Богдан'] # порядок втрачено — і в тебе буде інший
print(list(dict.fromkeys(записи)))
['Аня', 'Богдан', 'Галя', 'Оля'] # порядок першої появи
dict.fromkeys будує словник, у якому ключі — це елементи списку, а всі
значення дорівнюють None. Ключі унікальні, отже дублікати зникають; словник
зберігає порядок вставки, отже лишається порядок першої появи. Коштує це трохи більше
пам'яті, ніж множина, зате відповідь виходить передбачувана.
Мнемоніка на все життя: set — коли порядок не потрібен,
dict.fromkeys — коли потрібен. І жодних ручних конструкцій із
перевіркою «а чи вже бачили»: обидві ці однорядкові відповіді швидші й читаються краще.
11 / ПідсумокЩо з цього забрати
П'ять пунктів, які варто пам'ятати навіть через рік:
- Множина — це набір без повторів і без порядку. Додати наявний елемент — не помилка, а тиха відмова. Питання «а що там перше?» до множини не ставлять узагалі;
{}— це словник. Порожня множина пишеться тільки якset(), і ця пастка ловить кожного новачка рівно один раз;- Перевірка
in— головна причина брати множину. Один крок замість перебору, і різниця росте разом із даними. Найчастіше виграє саме відповідь «немає», бо для списку це найдовший випадок; - Елементи мусять бути хешованими — рівно за тією самою причиною, що й ключі словника. Список у множину не покласти ніколи, кортеж — якщо всередині немає списків, множину — тільки заморожену;
removeпадає,discardмовчить. Обери свідомо, а не за звичкою: мовчазне видалення там, де мала бути аварія, ховає помилку надовго.
І окремо — про красу, яку легко проґавити. Множина в Python не «список зі зниклими дублікатами». Це математичний об'єкт з алгеброю, перенесений у мову майже дослівно: об'єднання, перетин, різниця, підмножина. Коли задача формулюється словами «які з них є в обох» або «що з'явилось нового» — вона вже сформульована мовою множин, і залишається просто це записати.
practice.ipynb ти зараз приберешся в списку записів,
знайдеш спільних учасників двох гуртків і власноруч збереш перетин через in,
щоб assert довів: вбудований & робить рівно те саме.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.