Множина, яка не терпить повторів

Той самий словник, у якого забрали значення. Лишились самі ключі — і виявилось, що це окремий інструмент із власною алгеброю: обʼєднання, перетин, різниця. Розберімо, коли він рятує, а коли шкодить.

У попередній темі ми розібрали словник і хеш-таблицю під ним: адреса запису рахується з самого ключа, тому пошук коштує один крок незалежно від розміру. Наприкінці я пообіцяв, що далі буде легко. Тримаю слово: множина (set) — це той самий словник, у якого прибрали половину. Лишились ключі, зникли значення. Уся механіка переїхала без змін, а натомість зʼявилось те, чого у словника немає, — операції над наборами.

Задача, з якої почнемо, буденна. На день відкритих дверей записувались через форму, і дехто натиснув «надіслати» двічі. У тебе список імен, у якому є повтори, і два питання: скільки насправді різних людей і хто з них ходить одразу в обидва гуртки. Список на такі питання відповідає погано — доведеться порівнювати кожного з кожним. Множина відповідає на них одним символом.

ІдеяНабір без повторів

Множина — це набір несхожих елементів без порядку. Два слова тут головні, і кожне варте окремої уваги.

Без повторів. Той самий елемент не може лежати у множині двічі. Не «не варто класти», а фізично не може: спроба додати вже наявний елемент нічого не змінює й помилки не викликає. Вона просто безслідно зникає.

Без порядку. У множини немає ані першого елемента, ані останнього. Записи {1, 2} і {2, 1} — це одна й та сама множина, і Python вважає їх рівними. Питання «а що там під номером нуль?» до множини не ставлять — вона на нього не відповідає взагалі.

Подивись на першу властивість живцем. Нижче наш список записів прокручується по одному імені, і поруч видно два числа: скільки разів ми намагались додати й скільки елементів фактично лежить усередині.

Дублікати зникають безслідно

Вісім спроб додати, чотири різні імені. Дивись, що робить множина з повтором.

спроб додати0
у множині0
повторів відкинуто0
Що читати: угорі — список записів у тому порядку, в якому їх надіслали; внизу — сама множина. Рожевим підсвічено спробу, яка нічого не змінила. Зверни увагу: місце імені в нижньому блоці не збігається з порядком додавання — і це не примха малюнка, а справжня поведінка множини. Чому саме так, розберемо в розділі 10.

Вісім спроб — чотири елементи. Множина не рахує, скільки разів їй давали «Аню»: у неї є лише два стани, «є» і «немає». Якщо тобі потрібна саме кількість повторів, множина не підійде — бери dict і рахунок через .get(), як у минулій темі.

ОсновиЯк створити множину

Найкоротший спосіб — перелічити елементи у фігурних дужках через кому:

робототехніка = {"Аня", "Богдан", "Галя", "Оля"}
print(робототехніка)
{'Оля', 'Галя', 'Аня', 'Богдан'}  # порядок може бути іншим

Другий спосіб — конструктор set(). Він приймає будь-що, що можна пройти елемент за елементом: список, кортеж, рядок. І одразу викидає повтори:

print(set([3, 1, 3, 2, 1]))
{1, 2, 3}
print(set("привіт"))
{'и', 'в', 'т', 'і', 'п', 'р'}  # літери; порядок у тебе буде інший
Пастка, на якій спотикаються всі. Порожню множину не можна записати як {}. Порожні фігурні дужки історично закріплені за словником — ми про це говорили в темі 09. Порожня множина пишеться тільки одним способом: set(). Перевірити легко: type({}) дасть <class 'dict'>, а type(set()) — <class 'set'>. Помилка тиха й підступна: код не падає, просто далі ти працюєш зі словником і дивуєшся, чому add не існує.

Незручність тут одна й вона реальна: конструкція {} зайнята, а окремого короткого запису порожній множині не дісталось. Тому в коді, який створює множину поступово, майже завжди побачиш саме набір = set() у першому рядку.

Зміна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 вважає це нормою й мовчить.

remove проти discard

На наявному елементі різниці немає. Перемкни на відсутній — і подивись, що друкує кожен.

s.remove(x)—
s.discard(x)—
Як обирати: це те саме рішення, що між [] і .get() у словнику. Якщо відсутність елемента означає, що десь у логіці помилка, — бери remove, хай падає гучно й одразу. Якщо відсутність нормальна («приберемо, якщо було»), — бери discard. Мовчазне discard там, де треба гучне remove, ховає баг на місяці.
Про pop() без аргументу. У списку pop() знімає останній елемент — там є «останній». У множині останнього немає, тому set.pop() віддає довільний елемент: той, що першим трапиться в таблиці. Не випадковий і не найменший — просто той, який лежить найближче до початку внутрішнього масиву. Покладатись на конкретне значення не можна, і це єдиний метод множини, поведінку якого документація навмисно не уточнює.

Швидкістьin — головна перевага

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

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

Перевірка in: множина проти списку

Скільки разів довелося порівняти елементи, щоб відповісти «є» або «немає».

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

Різниця не косметична, і її розмір можна назвати точно, а не «в рази». Щоб відповісти «немає», списку треба стільки кроків, скільки в ньому елементів, а множині — один. Отже виграш у кроках дорівнює самій довжині набору: на стоп-списку з 800 слів це 800 порівнянь проти одного (саме це показує інтерактив вище на позначці 800), на двадцяти тисячах — двадцять тисяч проти одного. Якщо перевірок багато — скажімо, ти проганяєш через стоп-список кожне слово великого тексту, — заміна квадратних дужок на фігурні ([…] на {…}) прибирає з програми сотні мільйонів порівнянь. Це найдешевша оптимізація, яка взагалі буває в Python, і найчастіше пропущена новачками.

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

АлгебраОбʼєднання, перетин, різниця

Ось те, чого немає ні в списку, ні в словнику. Над множинами визначені операції, які ти вже бачив у школі на діаграмах із двома колами. У Python вони записуються двома способами — оператором і методом, — і ці способи не зовсім взаємозамінні.

операторметод«на місці»що це означає словами
A | BA.union(B)|= (update)усі, хто є хоч десь: обʼєднання
A & BA.intersection(B)&=лише ті, хто є в обох: перетин
A - BA.difference(B)-=ті, хто в A, але не в B: різниця
A ^ BA.symmetric_difference(B)^=ті, хто рівно в одному з двох

Наш приклад: два гуртки, у кожного свій склад. Дехто ходить в обидва. Покрути перемикач і подивись, яка операція яку частину картинки залишає.

Дві множини, чотири операції

Гурток робототехніки і гурток малювання. Підсвічена частина — це і є результат.

у результаті—
метод—
Головне спостереження: перемкнись із A−B на B−A і назад. Підсвічується різна частина, і результати не мають жодного спільного імені. Різниця множин не симетрична: питання «хто ходить тільки на робототехніку» і «хто ходить тільки на малювання» — це два різні питання. А от |, & і ^ від перестановки не залежать.

У коді це виглядає буквально так, як звучить:

робототехніка = {"Аня", "Богдан", "Галя", "Оля"}
малювання = {"Галя", "Оля", "Петро", "Ніна"}

print(робототехніка & малювання)  # хто ходить в обидва
{'Галя', 'Оля'}
print(робототехніка - малювання)  # лише робототехніка
{'Аня', 'Богдан'}
print(малювання - робототехніка)  # лише малювання — інша відповідь!
{'Петро', 'Ніна'}

Чим оператор відрізняється від методу

Різниця одна, зате важлива. Оператор вимагає, щоб праворуч теж стояла множина. Метод погоджується на будь-що, що можна пройти елемент за елементом, — і сам зробить із нього множину:

{1, 2}.union([3, 4])     # {1, 2, 3, 4} — список приймається
{1, 2} | [3, 4]          # TypeError: unsupported operand type(s)

Практичний висновок: оператор коротший і читабельніший, коли обидві сторони й так множини. Метод рятує, коли друга сторона — список чи щось інше, і позбавляє тебе зайвого set(...) навколо аргументу.

Є ще версії «на місці», які змінюють ліву множину замість того, щоб робити нову: |= (він же update), &=, -=, ^=. Логіка та сама, що в парі sort() і sorted() зі списків: форма «на місці» нічого не копіює й тому дешевша, зате вихідного набору після неї не лишається. Якщо на вихідну множину десь є ще одне імʼя — згадай тему про ярлики й подумай двічі.

ПорівнянняПідмножина й надмножина

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

записчитаєтьсяприклад, що дає True
A <= BA — підмножина B: усе з A є в B{1,2} <= {1,2,3}
A < Bпідмножина й до того ж не вся B{1,2} < {1,2,3}
A >= BA — надмножина B: усе з B є в A{1,2,3} >= {1,2}
A > Bнадмножина й до того ж не вся A{1,2,3} > {1,2}
A == Bоднаковий склад, порядок не важить{1,2} == {2,1}
A.isdisjoint(B)жодного спільного елемента{1,2}.isdisjoint({3,4})

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

Підмножина, надмножина — і ні те, ні те

Пʼять пар множин. Дивись на нижній ряд: для чисел так не буває ніколи.

A <= B—
A >= B—
A == B—
isdisjoint—
Дивись на позначку «перетин». Там A <= B і A >= B дають False одночасно — множини просто не порівнянні між собою. Для чисел такого не буває: із двох чисел завжди одне менше, або друге, або вони рівні. Тому висновок «раз не підмножина, отже надмножина» для множин хибний, і саме він дає найтихіші баги в перевірках доступу.
Для тих, хто любить точність. Числа впорядковані повністю: для будь-яких двох або одне менше, або більше, або вони рівні. Множини впорядковані лише частково. Візьми {1, 2} і {2, 3}: жодна не міститься в іншій, і вони не рівні. Тому A <= B дасть False і A >= B теж дасть False — одночасно. Для чисел таке неможливо, для множин — щоденна ситуація. Якщо десь у коді ти зробив висновок «раз не підмножина, отже надмножина» — це помилка.

ОбмеженняЩо можна класти всередину

Тепер обмеження, яке ти вже вгадав. Множина шукає елемент так само, як словник шукає ключ: рахує з нього адресу комірки. Отже елемент мусить уміти давати хеш — має бути хешованим. Це те саме слово, яке ми зустріли в темі про кортежі і яким пояснили обмеження на ключі словника.

Правило дослівно те саме: у множину можна класти те, що можна взяти ключем словника. Числа, рядки, None, логічні значення, кортежі з хешованих елементів — можна. Списки, словники й інші множини — ні.

Перевір себе перш ніж читати далі. Обери обʼєкт, передбач відповідь — і подивись, що скаже Python.

Що можна покласти в множину

Спершу передбач, потім перевір. Вісім обʼєктів, чотири з них — не пройдуть.

перевірено0 / 8
вгадано0
Закономірність: проходять рівно ті обʼєкти, які не можна змінити після створення. Причина та сама, що для ключів словника: адреса порахована з елемента, і якщо елемент змінити, запис лишиться лежати за старою адресою, а шукати його підуть за новою. frozenset проходить, а звичайна set — ні: перша незмінна, друга змінювана.
Кортеж хешований не завжди. Хеш кортежа складається з хешів його елементів, тому (1, 2) у множину лягає, а (1, [2]) — ні: всередині живе список. І помилка при цьому називається unhashable type: 'list', хоча кладеш ти кортеж, — тому й губиться. Правило точне: кортеж хешований тоді й лише тоді, коли хешовані всі його елементи на будь-якій глибині.

Заморозкаfrozenset

З попереднього розділу випливає прикра річ: множину не можна покласти в множину. А іноді дуже треба — наприклад, зібрати набір різних команд, де кожна команда сама є набором імен.

Для цього є frozenset — незмінна множина. Стосунок між set і frozenset точно такий самий, як між списком і кортежем: той самий вміст, ті самі операції читання, але жодного методу, який щось міняє. Немає add, немає discard, немає clear.

команда = frozenset({"Аня", "Богдан"})
print(команда & {"Богдан", "Галя"})  # операції працюють як завжди
frozenset({'Богдан'})
команда.add("Оля")
# AttributeError: 'frozenset' object has no attribute 'add'

Незмінність дає хешованість, а хешованість дає дві можливості, недоступні звичайній множині:

пари = {frozenset({"Аня", "Богдан"}), frozenset({"Богдан", "Аня"})}
print(len(пари))
1  # це одна й та сама пара — порядок у множині не важить

Той самий трюк із кортежами дав би два різні елементи, бо ("Аня", "Богдан") і ("Богдан", "Аня") — різні кортежі. Ось конкретна ситуація, де frozenset не просто можливий, а єдино правильний.

ЗастосуванняМножина як інструмент

Найчастіше множину беруть не як сховище, а як проміжний крок: перетворив, порахував, повернувся до списку. Три ідіоми покривають майже все.

1 · Прибрати дублікати

записи = ["Аня", "Богдан", "Аня", "Галя", "Богдан", "Оля", "Аня"]
print(len(записи), "записів,", len(set(записи)), "різних людей")
7 записів, 4 різних людей

Якщо потрібен саме список, а не множина, — обгорни назад: list(set(записи)). Тільки памʼятай, що порядок при цьому втрачається безповоротно. Як зберегти порядок — у розділі 10.

2 · Знайти спільне у двох наборах

Це той самий перетин, але варто побачити його в типовому оточенні. Дані рідко приходять множинами — зазвичай це списки, які треба спершу перетворити:

вчора = ["/головна", "/ціни", "/про-нас", "/головна"]
сьогодні = ["/ціни", "/контакти", "/головна"]

print(set(вчора) & set(сьогодні))  # сторінки, які дивились обидва дні
{'/головна', '/ціни'}
print(set(сьогодні) - set(вчора))  # що зʼявилось сьогодні
{'/контакти'}
print(set(вчора) - set(сьогодні))  # що зникло
{'/про-нас'}

Три рядки замість вкладеного порівняння кожного з кожним. І, що важливіше, три рядки, які читаються як речення українською.

3 · Перевірка належності

Якщо в коді є набір, по якому ти перевіряєш багато разів, зроби його множиною один раз і далі користуйся готовою. Порівняй два рядки:

стоп_список = ["і", "та", "але", "щоб", "як"]  # прийшло списком

стоп = set(стоп_список)            # зібрали один раз
print("але" in стоп)             # один крок
True

print("але" in set(стоп_список))  # ← так НЕ треба

Другий рядок виглядає так само коротко, але щоразу будує множину наново: обходить увесь список, хешує кожен елемент, розкладає по комірках — і аж тоді робить свій один крок. Це дорожче за звичайний перебір списку, тобто перевага перетворилась на збиток. Правило: set(...) стоїть там, де набір зʼявляється, а не там, де його перевіряють.

ПрофіЧому порядок такий дивний

Тепер обіцяне пояснення. Множина всередині — та сама хеш-таблиця, що й у словника, тільки без масиву значень. Комірка зберігає сам елемент і його хеш; місця під значення просто немає. Уся механіка з теми 09 — остача від ділення, колізії, маршрут обходу, перехешування — переїхала сюди без жодної зміни.

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

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

Чому порядок не той, у якому додавали

Та сама множина, записана трьома різними способами. Результат один і той самий.

як записали—
що надрукує print—
Перевір сам: набери в Python print({10, 3, 25, 7}) — отримаєш рівно {25, 10, 3, 7}, і так буде на будь-якій машині з CPython. Числа тут дають повторюваний результат, бо hash(n) == n для невеликих цілих. З рядками картинка та сама, але хеш рядка рандомізований при кожному запуску — тому множина слів друкується щоразу по-різному. Це не помилка, а захист, про який ми говорили в темі 09.

Множина розширюється раніше за словник

Словник тримається до двох третин заповнення. Множина такого терпіння не має: у CPython вона перебудовує таблицю вже приблизно на трьох пʼятих заповнення. Умова в сішному коді точна: таблиця росте, коли заповнення × 5 ≥ (розмір − 1) × 3. Для таблиці на вісім комірок це означає, що чотири елементи ще вміщаються, а пʼятий уже спричиняє повне перехешування.

import sys
множина = 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 — коли потрібен. І жодних ручних конструкцій із перевіркою «а чи вже бачили»: обидві ці однорядкові відповіді швидші й читаються краще.

ПідсумокЩо з цього забрати

Пʼять пунктів, які варто памʼятати навіть через рік:

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

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