Чотири теми — чотири сховища. Список виявився полицею з ярликами: упорядкований, змінюваний, з дешевим кінцем і дорогим початком. Кортеж — той самий ряд комірок, тільки закамʼянілий, зате хешований. Словник відповів на питання «що записано під цим іменем» за один крок замість мільйона. Множина виявилась тим самим словником, у якого забрали значення, — і принесла унікальність та операції над наборами.
Ця тема не додає жодної нової конструкції. Вона робить інше, і, чесно кажучи, складніше: вчить обирати. Бо код, який працює, пишуть усі, а код, який не почне повзати на реальних даних, — ті, хто вибрав структуру свідомо. Різниця між списком і множиною в одному місці програми може означати різницю між відповіддю за мілісекунду й відповіддю за хвилину. Це не перебільшення: числа будуть у розділі 02.
01 / ПоручЧотири структури на одній сторінці
Спершу зведімо все, що ми знаємо, в одну таблицю. Кожен стовпчик — це властивість, через яку структури й відрізняються одна від одної.
| структура | що зберігає | порядок | повтори | змінювана | чим може бути елемент |
|---|---|---|---|---|---|
| list [1, 2, 3] | послідовність значень | є, порядок вставки | так | так | будь-що |
| tuple (1, 2, 3) | фіксований набір полів | є, порядок вставки | так | ні | будь-що |
| dict {"a": 1} | пари ключ → значення | є, порядок вставки | ключі унікальні | так | ключ — лише хешований |
| set {1, 2, 3} | набір унікальних значень | немає | ні | так | лише хешований |
Таблиця правдива, але користі від неї менше, ніж здається. Читати її треба не згори вниз, а справа наліво: не «що вміє список», а «мені потрібен швидкий пошук і не потрібен порядок — хто це вміє». Задача формулює вимоги, вимоги відсіюють структури. Спробуй сам: постав вимоги й подивись, хто вцілів.
Інтерактив 1 · Вимога відсіює структуру
Обери одну чи кілька вимог. Придатні структури підсвітяться, решта згасне.
Останній випадок вартий окремої уваги, бо саме він найчастіше й трапляється в реальній роботі. Коли жодна структура не закриває всі вимоги, вибір не зникає — він просто перестає бути вибором «однієї з чотирьох» і стає вибором комбінації. До цього ми ще дійдемо.
02 / ЦінаВартість операцій
Це головний розділ теми. Усе інше — властивості, які видно оком: порядок є або немає, змінити можна або ні. А вартість операцій оком не видно взагалі, і саме тому на ній обпікаються.
Домовмось про мову. Про будь-яку операцію можна спитати одне-єдине питання: чи стане вона повільнішою, якщо даних побільшає в тисячу разів? Відповідей рівно дві.
- Не залежить від розміру. Хоч десять елементів, хоч десять
мільйонів — робота однакова. Взяти
покупки[3], дописати в кінець, спитатиlen(), знайти значення за ключем словника. Позначають це записом O(1) і кажуть «стала складність». - Залежить прямо пропорційно. Удесятеро більше даних — удесятеро більше роботи. Знайти елемент у списку, вставити на початок, видалити зі середини. Це O(n), «лінійна складність», де n — кількість елементів.
Запис O(…) читається як «порядок зростання» і навмисно ігнорує сталі
множники: O(1) не означає «швидко», а O(n) не означає «повільно». Вони означають лише
те, як змінюється час зі зростанням даних. Але саме ця зміна й вирішує долю
програми, коли з тестових двадцяти записів вона переходить на робочий мільйон.
Пошук: місце, де програють найчастіше
Питання «чи є такий елемент» — найпоширеніша операція в житті. І це рівно те питання, на яке чотири структури відповідають найбільш по-різному. Список і кортеж чесно перебирають усе підряд, поки не натраплять. Словник і множина рахують хеш і одразу йдуть у потрібну комірку — ту саму механіку ми розібрали в темі 08.
Інтерактив 2 · Гонка пошуку
Та сама задача «чи є елемент», три структури. Рахуємо не секунди, а порівняння.
Кроки — це модель. Ось ті самі структури на справжньому годиннику: пошук слова, якого в структурі немає, CPython 3.12, час на одну перевірку.
| елементів | список | множина | словник | список повільніший у |
|---|---|---|---|---|
| 100 | 2.1 мкс | 0.055 мкс | 0.072 мкс | ≈ 40 разів |
| 1 000 | 13.8 мкс | 0.064 мкс | 0.058 мкс | ≈ 200 разів |
| 10 000 | 129 мкс | 0.051 мкс | 0.058 мкс | ≈ 2 500 разів |
| 100 000 | 1 893 мкс | 0.051 мкс | 0.059 мкс | ≈ 37 000 разів |
| 1 000 000 | 15 806 мкс | 0.048 мкс | 0.056 мкс | ≈ 326 000 разів |
Придивись до другого стовпчика: він росте рівно так, як росте перший, — удесятеро більше даних, удесятеро більше часу. А третій і четвертий стоять на місці всю дорогу, від сотні до мільйона. Це і є різниця між O(n) і O(1), показана не буквами, а секундоміром.
Уся сітка одразу
Пошук — не єдина операція. Ось повна картина: чотири структури проти семи найчастіших дій. Наведи курсор на клітинку (або торкнись її) — праворуч зʼявиться пояснення, чому саме так.
Інтерактив 3 · Ціна операцій зведено
Бірюзова клітинка — вартість не залежить від розміру. Помаранчева — залежить. Сіра — операції просто немає.
наведи курсор на клітинку сітки
Із цієї сітки випливає одне практичне правило, яке варто вивчити напамʼять: якщо в задачі є слово «чи є» або «чи вже було» — структура має бути хеш-таблицею, тобто множиною або словником. Усе решта — деталі.
03 / ПомилкиТипові помилки вибору
Три помилки трапляються частіше за всі інші разом. Кожна виглядає невинно й кожна має ту саму природу: структуру взяли за звичкою, а не за питанням задачі.
1 · Список там, де потрібна множина
Найдорожча з трьох. Виглядає вона так:
"але" in стоп_слова # перебирає список від початку
На пʼяти словах різниці немає. На трьохстах — теж майже немає, якщо перевірка одна. Але стоп-слова перевіряють на кожному слові тексту, а текстів у роботі буває мільйон. Триста порівнянь помножити на мільйон — це триста мільйонів дій там, де могло бути мільйон. Виправлення займає один символ:
"але" in стоп_слова # хеш → комірка → відповідь
Ознака, за якою цю помилку впізнають у чужому коді: список, який
створюють один раз і далі тільки питають у нього in. Якщо
порядок ніде не використовується й додавання не відбувається — це множина, записана
неправильно.
2 · Список кортежів там, де потрібен словник
Ця помилка народжується природно: дані приходять парами, пари складають у список, і все ніби логічно.
Структура чесно зберігає пари. Проблема в тому, що на питання «який номер у Богдана» вона відповідає перебором — а це рівно та задача, заради якої словник і винайшли. Плюс друга біда, тихіша: ніщо не заважає покласти сюди двох Богданів із різними номерами. Список не знає, що імена мають бути унікальними, — і дізнається про це у вигляді дивного багу через півроку.
Коли список пар усе-таки виправданий? Коли пари — це події, а не відповідність: журнал платежів, історія змін, послідовність вимірювань. Там повтори законні, порядок важливий, а питання «дай мені запис за іменем» просто не виникає.
3 · Словник там, де вистачило б списку
Зворотна помилка, і в неї впадають ті, хто щойно прочитав тему 08 і закохався в O(1). Виглядає так:
print(кроки[1])
увімкнути
Формально працює. Але ключі тут — 0, 1, 2, тобто просто номери
позицій. Це список, у якого вручну виписали індекси: більше памʼяті, більше символів,
менше можливостей (жодного зрізу, жодного sort()), і жодного виграшу,
бо доступ за індексом у списку теж O(1). Перевірка на цю помилку одна:
якщо ключі — це 0, 1, 2, 3…, тобі потрібен список.
Тепер потренуйся на реальних задачах. У кожній подумай спершу сам, потім тисни структуру — і подивись розбір усіх чотирьох варіантів.
Інтерактив 4 · Задача → структура
Вісім задач із життя. Обери структуру — отримаєш розбір: чому ця підходить і чим погані інші.
04 / КомбінаціїКоли структури комбінують
Повернімось до випадку, на якому спіткнувся перший інтерактив: вимог більше, ніж уміє одна структура. Розвʼязок завжди той самий — вкласти одну структуру в іншу. Три комбінації покривають майже все, що трапляється в роботі.
- Словник зі списками. Ключ веде до кількох значень, у яких важливий порядок: учень → його оцінки, день → події дня, автор → його статті. Одне звертання за ключем, далі звичайний список.
- Список кортежів. Послідовність подій, у кожної з яких кілька
полів:
[("Аня", 5), ("Богдан", 4)]. Порядок збережено, повтори дозволені, кожен запис — одна цілісна річ. - Множина кортежів. Унікальні комбінації: відвідані клітинки
поля
{(0, 0), (1, 2)}, пари «хто кого вже бачив». Працює лише тому, що кортеж хешований, — і в цьому вся суть наступного розділу.
Одні й ті самі дані можна вкласти по-різному, і «правильної» форми взагалі не існує — є форма, зручна для твого питання. Порівняй три форми на одному наборі оцінок і подивись, як переможець змінюється разом із питанням.
Інтерактив 5 · Одні дані, три форми
Ті самі оцінки, укладені трьома способами. Перемикай питання — і дивись, хто відповідає найлегше.
Обіцяний рецепт: дедуплікація зі збереженням порядку
У темі 09 ми прибирали дублікати через
set(записи) — і втрачали порядок, бо множина його не зберігає. Виправлення
теж є комбінацією, тільки хитрішою: узяти унікальність від одної структури,
а порядок — від іншої. Обидві властивості разом є в ключів словника:
print(list(set(записи))) # унікальні, але порядок довільний
['Галя', 'Аня', 'Богдан'] # у тебе вийде інший порядок
print(list(dict.fromkeys(записи))) # унікальні, порядок першої появи
['Аня', 'Богдан', 'Галя']
Метод dict.fromkeys(послідовність) будує словник, у якому ключі — це
елементи послідовності, а значення — None. Ключі унікальні (дублікати
злилися) і йдуть у порядку вставки (гарантія з версії 3.7). Значення тут не потрібні
взагалі — ми користуємось словником як «множиною, що памʼятає порядок».
05 / НезмінністьНезмінність як інструмент проєктування
У темі 07 незмінність кортежа виглядала обмеженням: не можна дописати, не можна відсортувати на місці. Тепер, коли поруч лежить хеш-таблиця, видно її справжню роль. Незмінність — це не заборона, а перепустка.
Словник і множина кладуть елемент у комірку, номер якої обчислили з нього
самого: hash(ключ) % розмір. Іншого способу знайти елемент за один
крок не існує.
Якщо ключ після вставки змінити, його хеш стане іншим — а запис лишиться лежати за старою адресою. Дані фізично в структурі, дістатись до них неможливо.
Python не дає цьому статись, просто відмовляючись хешувати змінювані обʼєкти.
Звідси TypeError: unhashable type: 'list' — не примха, а запобіжник.
Тому в ключі йде кортеж, а не список. І тому ж у множині лежать кортежі, а не списки:
відвідані.add((3, 4))
print((1, 2) in відвідані)
True
хибно = {[0, 0]}
TypeError: unhashable type: 'list'
Є і дзеркальний випадок: коли потрібна множина всередині ключа. Звичайна
set змінювана, отже нехешована, — але для цього існує
frozenset: та сама множина, тільки закамʼяніла. Її можна покласти в ключ
словника чи в іншу множину.
І окремо — незмінність як контракт, без жодного звʼязку з хешами. Коли ти передаєш кортеж у чужий код, ти знаєш напевно: він повернеться таким самим. Зі списком такої гарантії немає — передається ярлик на той самий обʼєкт, і будь-хто може дописати в нього що завгодно. Тому налаштування, константи, координати й будь-що, що «не має мінятись за задумом», варто тримати в кортежі, навіть коли ключем воно ніколи не стане. Структура даних тут говорить із читачем коду замість коментаря.
06 / ГотовеКоротко про collections
Розділ для тих, хто вже пише код: у стандартній бібліотеці є модуль
collections — три готові структури, побудовані поверх тих самих чотирьох.
Кожна закриває конкретну незручність, і кожну варто впізнавати в чужому коді.
Counter — лічильник, який рахує сам
скільки = Counter("абракадабра")
print(скільки)
Counter({'а': 5, 'б': 2, 'р': 2, 'к': 1, 'д': 1})
print(скільки.most_common(2))
[('а', 5), ('б', 2)]
Це словник, у якого відсутній ключ дає 0 замість KeyError,
плюс метод most_common(). Рятує там, де ти писав би
лічильник[слово] = лічильник.get(слово, 0) + 1 — тобто в кожній другій
задачі про частоти. Платиш нічим: Counter і є dict.
defaultdict — словник, який сам створює порожнє
оцінки = defaultdict(list)
оцінки["Аня"].append(5) # ключа не було — зʼявився зі списком
print(dict(оцінки))
{'Аня': [5]}
Точно та сама комбінація «словник зі списками» з розділу 04, тільки без ручного
setdefault перед кожним додаванням. Пастка одна, зате класична:
звертання до відсутнього ключа його створює. Проста перевірка
«а чи є в нас Богдан» тихо додасть Богдана з порожнім списком.
deque — черга, дешева з обох кінців
Про неї вже йшлося в темі 06: у списку
insert(0, x) зсуває всі елементи й коштує O(n), а
deque.appendleft(x) — O(1). Бери deque, коли дані додаються
з одного кінця, а забираються з іншого: черга завдань, останні N подій, обхід у
ширину. Плата — доступ до середини перестає бути сталим: черга[5000]
у deque вже не безкоштовний.
Counter там, де насправді потрібна множина, ти просто отримав ту саму
помилку в гарнішій обгортці.07 / ПамʼятьКоли памʼять справді має значення
Останнє питання, яке ставлять при виборі структури, — «а скільки воно важить». Тут
багато міфів, тому подивімось на справжні числа. Функція sys.getsizeof
показує розмір самого контейнера в байтах на CPython 3.12.
Інтерактив 6 · Скільки важить контейнер
Справжні заміри sys.getsizeof на CPython 3.12. Повзунок міняє кількість елементів.
getsizeof міряє лише контейнер: самі рядки й числа
лежать окремо, і в списку, і в множині це ті самі обʼєкти.Висновок із цих чисел спокійніший, ніж очікуєш. Хеш-таблиця справді дорожча за масив — вона тримає вільні комірки, щоб пошук лишався швидким. Але поки елементів тисячі, уся різниця вимірюється десятками кілобайтів, і думати про неї шкідливо: економія на памʼяті коштуватиме тобі O(n)-пошуку, який дорожчий у сотні разів.
Памʼять стає аргументом у двох випадках. Перший: структур багато —
не одна множина на мільйон елементів, а мільйон дрібних словників, кожен з яких
тягне свої 200+ байтів накладних витрат. Другий: даних однорідно й дуже багато —
і тоді відповідь узагалі не в цьому розділі, а в масиві numpy,
який зберігає числа підряд, без посилань і без запасу.
08 / ПідсумокЩо забрати з усього блоку
Блок про колекції закінчено. Якщо з нього лишиться пʼять речень, нехай це будуть ці:
- Питання задачі визначає структуру. «Що там за номером?» — список. «Що записано під іменем?» — словник. «Чи вже було?» — множина. «Одна річ із кількох полів, яка не змінюється?» — кортеж.
- Пошук у списку — лінійний, у множині й словнику — сталий. На тисячі елементів це різниця у 200 разів, на мільйоні — у 300 тисяч. Це найдорожча помилка вибору з усіх можливих.
- Кінець списку дешевий, початок дорогий.
append— O(1),insert(0, x)— O(n). Потрібні обидва кінці — бериdeque. - Незмінність — це перепустка в ключ. Кортеж можна покласти в
ключ словника чи в множину, список — ніколи. Потрібна множина в ключі —
frozenset. - Коли одна структура не тягне всі вимоги — комбінуй. Словник зі списками, список кортежів, множина кортежів закривають майже все.
І головне, що варто винести з усього блоку. Структура даних — це не сховище, це записане рішення. Обираючи список, ти кажеш: «порядок важливий, кількість плаватиме». Обираючи множину: «мене цікавить лише факт наявності». Обираючи кортеж: «це одна цілісна річ, і вона не зміниться». Той, хто читатиме твій код, — включно з тобою через півроку, — прочитає ці рішення ще до першого рядка логіки.
"хліб" in покупки й отримати True — але не вміємо нічого
з цією відповіддю зробити. Програма поки що йде згори вниз одним шляхом, ніколи не
повертаючи ліворуч. У темі 11
зʼявляться умови, і True нарешті перетвориться на
розвилку. А в practice.ipynb ти зараз розвʼяжеш одну задачу двома
структурами й порівняєш їх власними руками — з assert, який мовчить,
коли все правильно.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.