Python з нуля · Блок 2 · Тема 09

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

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

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

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

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

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

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

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

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

Інтерактив 1 · Дублікати зникають безслідно

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

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

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

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

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

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

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

print(set([3, 1, 3, 2, 1]))
{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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

у результаті
метод
Головне спостереження: перемкнись із 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), &=, -=, ^=. Логіка та сама, що у списку з +=: коротка форма економить пам'ять, але псує оригінал. Якщо на вихідну множину десь є ще одне ім'я — згадай тему про ярлики й подумай двічі.

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

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

записчитаєтьсяприклад, що дає 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однаковий склад, порядок не важить{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 · Що можна покласти в множину

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

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

08 / Заморозкаfrozenset

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

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

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

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

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

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

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

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

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

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

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

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.