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

Яку структуру обрати

Чотири сховища вже розібрані по деталях. Лишилось найважче й найкорисніше — навчитися дивитись на задачу й одразу бачити, яке з них відповідає саме на її питання. Нового синтаксису тут не буде жодного.

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

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

01 / ПоручЧотири структури на одній сторінці

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

структуращо зберігаєпорядокповторизмінюваначим може бути елемент
list
[1, 2, 3]
послідовність значеньє, порядок вставкитактакбудь-що
tuple
(1, 2, 3)
фіксований набір полівє, порядок вставкитакнібудь-що
dict
{"a": 1}
пари ключ → значенняє, порядок вставкиключі унікальнітакключ — лише хешований
set
{1, 2, 3}
набір унікальних значеньнемаєнітаклише хешований

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

Інтерактив 1 · Вимога відсіює структуру

Обери одну чи кілька вимог. Придатні структури підсвітяться, решта згасне.

вимог обрано1
підходить2
придатні структури
Спробуй три комбінації: «порядок + повтори» лишає список і кортеж; «швидкий пошук + ключ → значення» лишає один словник; а «порядок + повтори + швидкий пошук» не лишає нікого — жодна з чотирьох структур не вміє всього одразу. Саме звідти й ростуть комбінації з розділу 04.

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

02 / ЦінаВартість операцій

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

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

Запис O(…) читається як «порядок зростання» і навмисно ігнорує сталі множники: O(1) не означає «швидко», а O(n) не означає «повільно». Вони означають лише те, як змінюється час зі зростанням даних. Але саме ця зміна й вирішує долю програми, коли з тестових двадцяти записів вона переходить на робочий мільйон.

Пошук: місце, де програють найчастіше

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

Інтерактив 2 · Гонка пошуку

Та сама задача «чи є елемент», три структури. Рахуємо не секунди, а порівняння.

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

Кроки — це модель. Ось ті самі структури на справжньому годиннику: пошук слова, якого в структурі немає, CPython 3.12, час на одну перевірку.

елементівсписокмножинасловниксписок повільніший у
1002.1 мкс0.055 мкс0.072 мкс≈ 40 разів
1 00013.8 мкс0.064 мкс0.058 мкс≈ 200 разів
10 000129 мкс0.051 мкс0.058 мкс≈ 2 500 разів
100 0001 893 мкс0.051 мкс0.059 мкс≈ 37 000 разів
1 000 00015 806 мкс0.048 мкс0.056 мкс≈ 326 000 разів

Придивись до другого стовпчика: він росте рівно так, як росте перший, — удесятеро більше даних, удесятеро більше часу. А третій і четвертий стоять на місці всю дорогу, від сотні до мільйона. Це і є різниця між O(n) і O(1), показана не буквами, а секундоміром.

Для тих, хто вже пише код. Є поширена приказка, що «на маленьких наборах список швидший за множину». Її варто уточнити, бо в наведеному вигляді вона неправдива. Пошук множина виграє вже на двох елементах: порахувати хеш короткого рядка дешевше, ніж зробити навіть два порівняння. Правда в іншому — у побудові: зібрати множину з 300 рядків коштує близько 4.9 мкс проти 1.0 мкс на список, бо кожен елемент треба захешувати й покласти в комірку. Отже єдиний сценарій, де список чесно виграє, — зібрати набір і спитати в нього рівно один раз. Уже з другого запиту множина окупається: на тих самих 300 рядках один запит до списку коштує 3.3 мкс, до множини — 0.02 мкс, і зайві 3.9 мкс на побудову повертаються приблизно після першого ж запиту.

Уся сітка одразу

Пошук — не єдина операція. Ось повна картина: чотири структури проти семи найчастіших дій. Наведи курсор на клітинку (або торкнись її) — праворуч зʼявиться пояснення, чому саме так.

Інтерактив 3 · Ціна операцій зведено

Бірюзова клітинка — вартість не залежить від розміру. Помаранчева — залежить. Сіра — операції просто немає.

наведи курсор на клітинку сітки

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

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

03 / ПомилкиТипові помилки вибору

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

1 · Список там, де потрібна множина

Найдорожча з трьох. Виглядає вона так:

стоп_слова = ["і", "та", "але", "щоб", "як"]  # ще 300 штук

"але" in стоп_слова   # перебирає список від початку

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

стоп_слова = {"і", "та", "але", "щоб", "як"}  # фігурні дужки

"але" in стоп_слова   # хеш → комірка → відповідь

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

2 · Список кортежів там, де потрібен словник

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

книга = [("Аня", "067-111-22-33"), ("Богдан", "050-444-55-66")]

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

Коли список пар усе-таки виправданий? Коли пари — це події, а не відповідність: журнал платежів, історія змін, послідовність вимірювань. Там повтори законні, порядок важливий, а питання «дай мені запис за іменем» просто не виникає.

3 · Словник там, де вистачило б списку

Зворотна помилка, і в неї впадають ті, хто щойно прочитав тему 08 і закохався в O(1). Виглядає так:

кроки = {0: "залити воду", 1: "увімкнути", 2: "зачекати"}
print(кроки[1])
увімкнути

Формально працює. Але ключі тут — 0, 1, 2, тобто просто номери позицій. Це список, у якого вручну виписали індекси: більше памʼяті, більше символів, менше можливостей (жодного зрізу, жодного sort()), і жодного виграшу, бо доступ за індексом у списку теж O(1). Перевірка на цю помилку одна: якщо ключі — це 0, 1, 2, 3…, тобі потрібен список.

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

Інтерактив 4 · Задача → структура

Вісім задач із життя. Обери структуру — отримаєш розбір: чому ця підходить і чим погані інші.

відповіли0
влучили0
Читай позначки: ✓ — саме воно; ~ — працюватиме, але з платою (зайва памʼять, зайвий код, втрачена гарантія); ✗ — не годиться зовсім. Найцінніші задачі тут ті, де правильних відповідей ніби дві, — 4 і 7.

04 / КомбінаціїКоли структури комбінують

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

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

Інтерактив 5 · Одні дані, три форми

Ті самі оцінки, укладені трьома способами. Перемикай питання — і дивись, хто відповідає найлегше.

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

Обіцяний рецепт: дедуплікація зі збереженням порядку

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

записи = ["Аня", "Богдан", "Аня", "Галя", "Богдан"]

print(list(set(записи)))          # унікальні, але порядок довільний
['Галя', 'Аня', 'Богдан']  # у тебе вийде інший порядок
print(list(dict.fromkeys(записи)))  # унікальні, порядок першої появи
['Аня', 'Богдан', 'Галя']

Метод dict.fromkeys(послідовність) будує словник, у якому ключі — це елементи послідовності, а значення — None. Ключі унікальні (дублікати злилися) і йдуть у порядку вставки (гарантія з версії 3.7). Значення тут не потрібні взагалі — ми користуємось словником як «множиною, що памʼятає порядок».

05 / НезмінністьНезмінність як інструмент проєктування

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

крок 1 · адреса рахується з ключа

Словник і множина кладуть елемент у комірку, номер якої обчислили з нього самого: hash(ключ) % розмір. Іншого способу знайти елемент за один крок не існує.

крок 2 · змінний ключ ламає адресу

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

крок 3 · заборона замість поломки

Python не дає цьому статись, просто відмовляючись хешувати змінювані обʼєкти. Звідси TypeError: unhashable type: 'list' — не примха, а запобіжник.

Тому в ключі йде кортеж, а не список. І тому ж у множині лежать кортежі, а не списки:

відвідані = {(0, 0), (1, 2)}   # кортежі — можна
відвідані.add((3, 4))
print((1, 2) in відвідані)
True

хибно = {[0, 0]}
TypeError: unhashable type: 'list'

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

І окремо — незмінність як контракт, без жодного звʼязку з хешами. Коли ти передаєш кортеж у чужий код, ти знаєш напевно: він повернеться таким самим. Зі списком такої гарантії немає — передається ярлик на той самий обʼєкт, і будь-хто може дописати в нього що завгодно. Тому налаштування, константи, координати й будь-що, що «не має мінятись за задумом», варто тримати в кортежі, навіть коли ключем воно ніколи не стане. Структура даних тут говорить із читачем коду замість коментаря.

06 / ГотовеКоротко про collections

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

Counter — лічильник, який рахує сам

from collections import 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 — словник, який сам створює порожнє

from collections import 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 / ПідсумокЩо забрати з усього блоку

Блок про колекції закінчено. Якщо з нього лишиться пʼять речень, нехай це будуть ці:

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

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

Далі в темі

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