У попередній темі ми розібрали кортежі й натрапили на дивне слово —
хешованість. Тоді я пообіцяв, що воно знадобиться вже за одну тему.
Час платити за обіцянку: саме на хешованості тримається структура, без якої не обходиться
жодна серйозна програма на Python. Її звуть словником (dictionary,
скорочено dict).
Почнімо з болю. Уяви телефонну книгу як список пар:
[("Аня", "067-111-22-33"), ("Богдан", "050-444-55-66"), …]. Щоб знайти номер
Богдана, комп'ютер порівняє «Аня» з «Богдан», потім «Богдан» з «Богдан» — і аж тоді
відповість. Мільйон імен — до мільйона перевірок: список чесно переглядає все підряд,
бо іншого способу в нього немає.
Словник відповідає інакше. Скільки б у ньому не було записів — тисяча чи десять мільйонів — він робить приблизно одну перевірку. Не швидшу, не оптимізовану — саме одну.
01 / ІдеяВідповідність замість порядку
Подивись на цю різницю живцем. Тут рахуються не секунди — вони залежать від процесора й настрою операційної системи, — а кроки: скільки разів довелося порівняти шуканий ключ із наявним.
Інтерактив 1 · Пошук: словник проти списку
Рахуємо не секунди, а кроки — скільки разів довелося порівняти ключі.
Список відповідає на питання «що лежить третім». Словник відповідає на питання «що записано під цим іменем». Це різні питання, і структура даних має відповідати питанню, а не навпаки.
Формально словник — це набір пар ключ → значення. Ключі в межах одного словника унікальні: під одним іменем не може лежати два записи. Значення — які завгодно, зокрема однакові й зокрема інші словники.
"Аня": "067-111-22-33",
"Богдан": "050-444-55-66",
"Галя": "063-777-88-99",
}
print(книга["Богдан"]) # 050-444-55-66
Фігурні дужки, двокрапка між ключем і значенням, кома між парами. У квадратних дужках тепер стоїть не номер, а ключ — і в цьому вся різниця. Список нумерує позиції, словник іменує вміст.
Коли словник доречніший за список
Проста перевірка: спитай себе, чи має сенс питання «а що там під номером 5?». Якщо в задачі є природний порядок — кроки рецепта, історія платежів — бери список. Якщо важлива прив'язка одного до іншого — бери словник:
- ім'я → телефон, товар → ціна, місто → населення;
- налаштування програми: назва параметра → його значення;
- підрахунок: слово → скільки разів трапилось;
- кеш: те, що вже порахували, під ключем «для яких вхідних даних».
І окремий випадок, який зустрінеш дуже скоро: словник як компактна заміна довгих умов. Замість десятка перевірок «якщо код 404, то напиши таке-то» пишуть одну таблицю відповідностей і одне звертання до неї. У практиці до цієї теми ти зробиш саме так.
02 / ОсновиСтворити, взяти, видалити
Порожній словник — це {}, і це саме словник, а не множина: порожні фігурні
дужки історично закріплені за dict. Другий спосіб — конструктор
dict(), якому пари передають іменованими аргументами або списком кортежів:
з_пар = dict([("Аня", 1), ("Богдан", 2)])
з_імен = dict(мова="Python", версія=3.12)
print(з_пар, з_імен)
{'Аня': 1, 'Богдан': 2} {'мова': 'Python', 'версія': 3.12}
Додавання й зміна — це одна й та сама дія, і в цьому словник відрізняється від списку.
У списку дані[7] = "х" впаде з IndexError, якщо сьомого елемента
немає. У словнику книга["Ніна"] = "…" просто створить новий запис. А якщо ключ
уже є — тихо перезапише старе значення. Другого «Богдана» не з'явиться ніколи.
книга["Ніна"] = "096-000-00-00" # перезапис, записів досі 4
print(len(книга))
4
Видалення: del і pop
Способів прибрати запис два, і різниця між ними — чи потрібне тобі значення, яке зникає.
| дія | що робить | якщо ключа немає |
|---|---|---|
| del d[k] | прибирає пару, нічого не повертає | KeyError |
| d.pop(k) | прибирає пару й повертає значення | KeyError |
| d.pop(k, з) | те саме, але з запасним варіантом | поверне з, помилки не буде |
| d.clear() | прибирає геть усе | — |
| k in d | перевіряє наявність ключа | поверне False |
Оператор in заслуговує окремої згадки. Для списку "Богдан" in
імена — це прохід усього списку. Для словника це той самий одноходовий пошук, що й
звертання по ключу: перевірка наявності коштує стільки ж, скільки взяття значення.
03 / Доступ[] проти .get()
Квадратні дужки категоричні: якщо ключа немає, програма зупиняється з
KeyError. Це не грубість, а чесність — Python не вигадує значення, якого ти
не клав. Але часто відсутність ключа — нормальна ситуація: користувач не заповнив поле,
у налаштуваннях немає рядка, слово трапилось уперше.
Для таких випадків є метод .get(). Він ставить те саме питання, але замість
падіння повертає None. А якщо передати другий аргумент — поверне його:
книга.get("Петро", "невідомо") # 'невідомо'
книга["Петро"] # KeyError: 'Петро'
Спробуй обидва варіанти на наявному й на відсутньому ключі — і подивись, що саме друкує інтерпретатор:
Інтерактив 2 · Квадратні дужки проти .get()
Одне й те саме питання, дві різні реакції на «такого ключа немає».
.get() не «виправляє» словник і нічого до нього
не додає — він лише інакше відповідає на відсутність. Значення за замовчуванням існує
тільки в тому місці, де ти його попросив.setdefault: узяти або покласти
Є ще третій режим, який плутають із .get() частіше за все. Метод
setdefault(ключ, значення) робить дві дії одразу: якщо ключ є — повертає його
значення; якщо ключа немає — вставляє пару й повертає щойно вставлене
значення. Тобто, на відміну від .get(), він словник змінює.
запаси.setdefault("яблука", 0) # 5 — ключ був, нічого не сталось
запаси.setdefault("груші", 0) # 0 — ключа не було, він з'явився
print(запаси)
{'яблука': 5, 'груші': 0}
Найпоширеніше застосування — накопичувачі, коли треба гарантувати, що під ключем уже
щось лежить, перш ніж туди щось додавати. Для підрахунку частот зазвичай беруть
лічильник[слово] = лічильник.get(слово, 0) + 1 — конструкція, яку ти
використаєш у практиці.
04 / Вікнаkeys, values, items
Три методи повертають те, що всередині словника, під різними кутами:
.keys() — самі ключі, .values() — самі значення,
.items() — пари як кортежі.
dict_keys(['Аня', 'Богдан', 'Галя'])
print(книга.items())
dict_items([('Аня', '067-111-22-33'), ('Богдан', '050-444-55-66'), ('Галя', '063-777-88-99')])
Зверни увагу на назви типів: dict_keys, а не list. Це не списки
й не копії. Це представлення (view) — живі вікна у той самий словник.
Через них не можна нічого змінити, зате вони самі оновлюються, коли змінюється
словник. Копію, яка застигне назавжди, доводиться робити руками: list(книга.keys()).
Інтерактив 3 · Представлення — вікно, а не копія
Міняй словник і дивись, що стає з представленням і що — зі списком-копією.
list(…) у момент «скинути» —
вона зафіксувала стан і більше не змінюється ніколи. Представлення дивиться в живий
словник, тому показує його поточний вміст. Обидва об'єкти правильні — просто
відповідають на різні питання..items() використовують
у циклі: for ключ, значення in книга.items():. Циклів у нас іще не було —
вони чекають у темі 12. Поки що досить знати, що саме ці методи повертають;
обходити їх ти навчишся за чотири теми.05 / Злиттяupdate і об'єднання
Щоб перенести в словник купу пар одразу, є метод update(): він приймає інший
словник (або список пар) і вливає його вміст у наш — нові ключі додає, наявні перезаписує.
Змінюється при цьому той словник, якому ти сказав update, а не аргумент.
налаштування.update({"мова": "en", "розмір": 14})
print(налаштування)
{'тема': 'світла', 'мова': 'en', 'розмір': 14}
З Python 3.9 з'явився коротший запис для випадку, коли потрібен новий
словник, а вихідні мають лишитись недоторканими — оператор |:
користувач = {"мова": "en"}
підсумок = за_замовчуванням | користувач
print(підсумок)
{'тема': 'світла', 'мова': 'en'}
Правило простого запам'ятовування: при конфлікті ключів перемагає правий
операнд. Тому в парі «типові налаштування | налаштування користувача» користувач завжди
переважує — і це саме те, чого від такої конструкції чекають. Є ще
|=, який працює як update(), і старий трюк
{**a, **b}, який робить те саме й досі трапляється в чужому коді.
06 / МеханікаЯк працює хеш-таблиця
Тепер — головне. Звідки береться той самий один крок замість мільйона?
Уяви камеру схову з вісьмома комірками, пронумерованими від 0 до 7. Ти хочеш, щоб валізу потім знайшли миттєво, без обходу всіх комірок. Рішення: домовитись про правило, яке з самої валізи однозначно обчислює номер комірки. Тоді і той, хто кладе, і той, хто забирає, приходять одразу за потрібною адресою.
Це правило й називається хешуванням. Воно складається з двох кроків:
Функція hash() перетворює об'єкт на одне ціле число. Байти ключа
перемішуються так, щоб схожі ключі давали геть різні числа: hash("Аня")
і hash("Оля") не мають нічого спільного, хоча слова схожі.
Число величезне, а комірок лише 8. Отже беремо остачу від ділення — ту саму, що ми розбирали в темі про числа. Остача від ділення на 8 завжди лежить у діапазоні 0…7, тобто гарантовано вказує на існуючу комірку:
Обидва обчислення не залежать від того, скільки записів у словнику. Хеш рядка з п'яти літер рахується однаково швидко в порожньому словнику й у словнику з мільйона записів. Остача — одна машинна операція. Тому пошук не сповільнюється з ростом даних: це і є те, що позначають записом O(1).
Колізія: дві валізи в одну комірку
Комірок вісім, а різних ключів — нескінченно багато. Отже рано чи пізно два різні ключі дадуть одну остачу. Це колізія (collision) — не рідкісний збій, а щоденна подія: якщо кинути в таблицю на 8 комірок лише п'ять ключів, хоча б одна колізія станеться з імовірністю близько 90%.
Python розв'язує колізію способом, який зветься відкритою адресацією (open addressing): якщо потрібна комірка зайнята, він за суворим правилом обчислює наступного кандидата й пробує його. І так, поки не знайдеться вільна. Правило CPython таке:
Словами: наступна комірка залежить і від попередньої, і від решти бітів хеша (це і є «збурення», perturb). Другий доданок важливий: без нього всі ключі з однаковою остачею йшли б однаковим маршрутом і збивалися в купу. А оскільки правило детерміноване, пошук іде тим самим маршрутом, що й вставка, — і знаходить ключ там, куди його поклали.
Ось той самий процес наочно. Спробуй додати ключ, який стикнеться з наявним:
Інтерактив 4 · Хеш-таблиця: ключ, хеш, комірка, колізія
Вісім комірок. Дивись, як обчислюється адреса — і що робить Python, коли вона зайнята.
будь-яке слово — хеш порахується
hash() дає 19-значні числа й змінюється
між запусками — про це в розділі 10), але правило пошуку наступної комірки — точно те,
що в CPython.07 / КлючіЧому ключі мусять бути незмінними
Тепер механіка сама пояснює обмеження, яке ми зустріли ще в темі про
кортежі. Ключем словника може бути тільки
хешований об'єкт: число, рядок, кортеж із хешованих елементів,
None, True. Список, множина або інший словник — не можуть.
Причина суто інженерна: адреса запису обчислена з ключа. Якщо ключ змінити після вставки, його хеш стане іншим, отже й адреса — а запис лишиться лежати за старою. Ключ показуватиме на порожню комірку: значення фізично в словнику, але дістатися до нього неможливо.
Python не дає цьому статись, просто заборонивши брати змінювані об'єкти за ключі. У списку
метод __hash__ дорівнює None — тому hash([1, 2]) одразу
падає з TypeError: unhashable type: 'list'. Перемкни інтерактив на третій режим,
щоб побачити, що саме сталося б без цієї заборони:
Інтерактив 5 · Що буде, якщо ключ змінити
Кортеж — можна. Список — не можна. І показова катастрофа, якби було можна.
TypeError при спробі взяти список за ключ —
не примха мови, а запобіжник. Він зупиняє програму в тому місці, де помилку ще видно,
замість того щоб дозволити їй тихо загубити дані на третьому режимі.(1, 2) — чудовий ключ, а
(1, [2]) — уже ні: усередині живе список. Правило точне: кортеж хешований тоді
й лише тоді, коли хешовані всі його елементи, на будь-якій глибині.08 / ПорядокПорядок вставки
До Python 3.6 порядок ключів у словнику був фактично випадковим — таким, як лягло в хеш-таблицю. Надрукуєш словник двічі на різних машинах і отримаєш різний порядок. Це збивало з пантелику всіх новачків і ламало не одну програму, автор якої понадіявся на порядок.
З версії 3.6 у CPython з'явилось компактне представлення словника, а з 3.7 гарантія порядку стала частиною мови: словник зберігає порядок вставки. Ідея представлення така. Замість однієї таблиці стало дві:
- масив записів — усі пари підряд, суворо в порядку додавання. Тут немає дірок від хешування, тому він щільний;
- індексна таблиця — маленькі комірки, у яких лежить не сам запис, а лише його номер у масиві записів. Саме її адресує хеш.
Виграш подвійний. Порядок з'явився безкоштовно — він і є порядок масиву записів. А дірки від хешування переїхали в маленьку індексну таблицю (один байт на комірку, поки записів менше за 128) із великого масиву, де на запис ішло три машинні слова. Тому словник у 3.6 схуднув приблизно на чверть проти 3.5 — рідкісний випадок, коли структура стала одночасно й меншою, й зручнішою.
Інтерактив 6 · Два масиви: чому порядок зберігається
Сценарій із чотирьох кроків: додавання, видалення й повторне додавання того самого ключа.
×: комірка вільна для нових ключів,
але маршрут пошуку через неї не обривається.09 / РістКоли таблиця росте
Комірок вісім — а що буде на дев'ятому ключі? Питання не риторичне: заповнена таблиця не працює взагалі, бо пошук вільної комірки зациклиться. Але й майже заповнена працює погано: що менше вільного місця, то довші маршрути обходу колізій.
Тому Python стежить за коефіцієнтом заповнення й не дає йому перевищити
дві третини. Щойно кількість записів переходить за 2/3 від кількості комірок,
створюється нова таблиця — і всі ключі перехешовуються в неї заново.
Перехешування потрібне, бо остача рахується від нового розміру: ключ, який був у комірці 2
з восьми, у таблиці на 16 комірок опиниться зовсім не там.
Перевірити це можна не на віру, а на пам'яті. Функція sys.getsizeof показує
розмір об'єкта в байтах, і на CPython 3.12 вона стрибає рівно там, де відбувається ріст:
| записів | комірок | sys.getsizeof | що сталося |
|---|---|---|---|
| 0 | — | 64 Б | таблиці ще немає взагалі |
| 1…5 | 8 | 184 Б | перша таблиця, вміщає 5 записів |
| 6…10 | 16 | 272 Б | шостий ключ спричинив перехешування |
| 11…21 | 32 | 464 Б | і ще одне на одинадцятому |
| 22…42 | 64 | 832 Б | і на двадцять другому |
Порахуй самостійно: 2/3 від 8 — це 5.33, тому п'ять записів іще вміщаються, а шостий переповнює; 2/3 від 16 — 10.67; 2/3 від 32 — 21.33. Усі три числа з таблиці зійшлися. Новий розмір CPython 3.12 бере як найменший степінь двійки, не менший за подвоєну кількість наявних записів: 6 → 12 → 16, 11 → 22 → 32, 22 → 44 → 64.
Інтерактив 7 · Заповнення, перехешування, пам'ять
Додавай ключі повзунком і дивись, де таблиця не витримує й подвоюється.
10 / Профіhash() і його секрет
Функцію hash() можна викликати руками — і побачити те саме число, яким
користується словник. Кілька фактів звідти варті окремої уваги навіть тим, хто пише
на Python роками.
Хеш рядка змінюється між запусками
Запусти двічі python3 -c "print(hash('abc'))" — і отримаєш два різні числа.
Це не збій. З Python 3.3 хешування рядків і байтів рандомізоване: при старті
інтерпретатора генерується випадкове зерно, яке підмішується в обчислення. Причина — та сама
атака hash flooding із розділу 6: якщо хеші передбачувані, зловмисник заздалегідь підбирає
тисячі ключів з однаковою остачею й кладе сервіс.
Наслідок для тебе практичний: ніколи не зберігай hash(рядок)
на диск чи в базу — після перезапуску воно нічого не означатиме. Для стабільного
хешування бери hashlib.sha256. А якщо потрібна відтворюваність у тестах,
є змінна оточення:
-4594863902769663758 # те саме при кожному запуску
Значення 0 вимикає рандомізацію повністю, будь-яке інше задає конкретне
зерно. Числа рандомізації не підлягають: hash(42) завжди 42.
Однакові хеші означають однакові ключі
У Python діє залізне правило: якщо a == b, то hash(a) == hash(b).
Інакше словник ламався б. Але з нього випливає наслідок, який регулярно дивує:
1 1 1
d = {1: "ціле"}
d[1.0] = "дробове"
d[True] = "логічне"
print(d)
{1: 'логічне'}
Один ключ, не три. Бо 1 == 1.0 == True — для Python це одне значення,
записане трьома способами. Перший ключ лишився на місці (тому в результаті 1,
а не True), а значення перезаписалось двічі. Якщо побачиш словник, у якому
таємничо зникають ключі, — перевір, чи не змішались там числа з логічними значеннями.
hash(-1) дорівнює
-2, а не -1. Причина суто технічна: у сішному коді CPython
значення -1 зарезервоване як ознака помилки, тому справжній хеш мінус одиниці
довелося зсунути. Це єдине число, чий хеш не дорівнює йому самому.11 / ПідсумокЩо з цього забрати
Якщо в пам'яті залишиться п'ять пунктів, нехай це будуть ці:
- Словник — про відповідність, список — про порядок. Якщо ти шукаєш запис за іменем, а не за номером, список тут неправильний інструмент, і чим більше даних, тим дорожча помилка;
- Один крок замість мільйона — бо адреса рахується з самого ключа, а не шукається перебором. Ціна — пам'ять на вільні комірки та вимога до ключів;
- Ключі мусять бути незмінними, і це прямий наслідок першого пункту: змінився ключ — змінилась адреса — запис загубився. Список ключем бути не може ніколи, кортеж — якщо всередині немає списків;
[]падає,.get()повертає. Обери свідомо: якщо відсутність ключа — аварія, хай падає; якщо норма — бери.get()зі значенням за замовчуванням;- Порядок вставки гарантований із 3.7, і це саме порядок вставки, а не сортування. Видалив і додав знову — ключ поїхав у кінець.
І головне в дусі цього курсу. Словник виглядає як звичайні дужки з двокрапками, але
всередині це компроміс, вилизуваний десятиліттями: розмір таблиці, момент її зростання,
формула обходу колізій, рандомізація хеша, два масиви замість одного. Коли наступного разу
напишеш книга["Богдан"] й отримаєш відповідь миттєво — ти вже знаєш, що саме
там спрацювало.
practice.ipynb ти зараз збудуєш телефонну книгу, порахуєш частоти слів
через .get() і заміниш довгий ланцюжок умов однією таблицею відповідностей.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.