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

Словник, який знає одразу

Щоб знайти запис у списку з мільйона, треба переглянути мільйон. Щоб знайти його у словнику — одну комірку. Розберімо, як влаштований фокус, і яку ціну він за себе править.

У попередній темі ми розібрали кортежі й натрапили на дивне слово — хешованість. Тоді я пообіцяв, що воно знадобиться вже за одну тему. Час платити за обіцянку: саме на хешованості тримається структура, без якої не обходиться жодна серйозна програма на 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-222-33-44"   # новий запис
книга["Ніна"] = "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("Петро")              # None
книга.get("Петро", "невідомо")  # 'невідомо'
книга["Петро"]                  # KeyError: 'Петро'

Спробуй обидва варіанти на наявному й на відсутньому ключі — і подивись, що саме друкує інтерпретатор:

Інтерактив 2 · Квадратні дужки проти .get()

Одне й те саме питання, дві різні реакції на «такого ключа немає».

книга[ключ]
книга.get(ключ)
Головне: .get() не «виправляє» словник і нічого до нього не додає — він лише інакше відповідає на відсутність. Значення за замовчуванням існує тільки в тому місці, де ти його попросив.

setdefault: узяти або покласти

Є ще третій режим, який плутають із .get() частіше за все. Метод setdefault(ключ, значення) робить дві дії одразу: якщо ключ є — повертає його значення; якщо ключа немає — вставляє пару й повертає щойно вставлене значення. Тобто, на відміну від .get(), він словник змінює.

запаси = {"яблука": 5}
запаси.setdefault("яблука", 0)  # 5 — ключ був, нічого не сталось
запаси.setdefault("груші", 0)   # 0 — ключа не було, він з'явився
print(запаси)
{'яблука': 5, 'груші': 0}

Найпоширеніше застосування — накопичувачі, коли треба гарантувати, що під ключем уже щось лежить, перш ніж туди щось додавати. Для підрахунку частот зазвичай беруть лічильник[слово] = лічильник.get(слово, 0) + 1 — конструкція, яку ти використаєш у практиці.

04 / Вікнаkeys, values, items

Три методи повертають те, що всередині словника, під різними кутами: .keys() — самі ключі, .values() — самі значення, .items() — пари як кортежі.

print(книга.keys())
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, а не аргумент.

налаштування = {"тема": "світла", "мова": "uk"}
налаштування.update({"мова": "en", "розмір": 14})
print(налаштування)
{'тема': 'світла', 'мова': 'en', 'розмір': 14}

З Python 3.9 з'явився коротший запис для випадку, коли потрібен новий словник, а вихідні мають лишитись недоторканими — оператор |:

за_замовчуванням = {"тема": "світла", "мова": "uk"}
користувач = {"мова": "en"}
підсумок = за_замовчуванням | користувач
print(підсумок)
{'тема': 'світла', 'мова': 'en'}

Правило простого запам'ятовування: при конфлікті ключів перемагає правий операнд. Тому в парі «типові налаштування | налаштування користувача» користувач завжди переважує — і це саме те, чого від такої конструкції чекають. Є ще |=, який працює як update(), і старий трюк {**a, **b}, який робить те саме й досі трапляється в чужому коді.

06 / МеханікаЯк працює хеш-таблиця

Тепер — головне. Звідки береться той самий один крок замість мільйона?

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

Це правило й називається хешуванням. Воно складається з двох кроків:

крок 1 · ключ → хеш

Функція hash() перетворює об'єкт на одне ціле число. Байти ключа перемішуються так, щоб схожі ключі давали геть різні числа: hash("Аня") і hash("Оля") не мають нічого спільного, хоча слова схожі.

hash(ключ) → велике ціле число
крок 2 · хеш → номер комірки

Число величезне, а комірок лише 8. Отже беремо остачу від ділення — ту саму, що ми розбирали в темі про числа. Остача від ділення на 8 завжди лежить у діапазоні 0…7, тобто гарантовано вказує на існуючу комірку:

номер комірки = hash(ключ) % розмір таблиці
крок 3 · чому це один крок

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

Колізія: дві валізи в одну комірку

Комірок вісім, а різних ключів — нескінченно багато. Отже рано чи пізно два різні ключі дадуть одну остачу. Це колізія (collision) — не рідкісний збій, а щоденна подія: якщо кинути в таблицю на 8 комірок лише п'ять ключів, хоча б одна колізія станеться з імовірністю близько 90%.

Python розв'язує колізію способом, який зветься відкритою адресацією (open addressing): якщо потрібна комірка зайнята, він за суворим правилом обчислює наступного кандидата й пробує його. І так, поки не знайдеться вільна. Правило CPython таке:

jнаст = (5 × j + 1 + збурення) % розмір,   збурення = збурення >> 5

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

Ось той самий процес наочно. Спробуй додати ключ, який стикнеться з наявним:

Інтерактив 4 · Хеш-таблиця: ключ, хеш, комірка, колізія

Вісім комірок. Дивись, як обчислюється адреса — і що робить Python, коли вона зайнята.

будь-яке слово — хеш порахується

хеш ключа
хеш % 8
лягло в комірку
спроб
Що підсвічено: рожевим — комірка, куди ключ цілився за остачею; бурштиновим — комірки, які виявились зайняті; бірюзовим — де він урешті ліг. Хеш тут навчальний (справжній hash() дає 19-значні числа й змінюється між запусками — про це в розділі 10), але правило пошуку наступної комірки — точно те, що в CPython.
Ціна колізій. Один крок — це середній випадок, а не гарантія. У найгіршому, коли всі ключі дають одну остачу, пошук вироджується в лінійний обхід. На цьому побудована атака hash flooding: зловмисник надсилає сервісу тисячі спеціально підібраних ключів, і словник, який мав працювати за один крок, починає повзати. Саме через неї Python робить із хешем рядків те, про що ми поговоримо в розділі 10.

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 · Два масиви: чому порядок зберігається

Сценарій із чотирьох кроків: додавання, видалення й повторне додавання того самого ключа.

ключів
записів у масиві
Ключове спостереження на кроці 3: Богдана видалили й додали знову — і в масиві записів він опинився в кінці, а не на своєму старому місці. Словник пам'ятає порядок вставки, а не якийсь «правильний» порядок імен. На кроці 2 видалений запис лишає в індексній таблиці позначку ×: комірка вільна для нових ключів, але маршрут пошуку через неї не обривається.
Гарантія й обіцянка — різні речі. У 3.6 порядок був деталлю реалізації: він працював у CPython, але покладатись на нього офіційно не дозволялось. У 3.7 його внесли в специфікацію мови — відтоді будь-яка реалізація Python зобов'язана його зберігати. Якщо читаєш старі книжки й бачиш «словник невпорядкований» — книжка написана до 3.7.

09 / РістКоли таблиця росте

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

Тому Python стежить за коефіцієнтом заповнення й не дає йому перевищити дві третини. Щойно кількість записів переходить за 2/3 від кількості комірок, створюється нова таблиця — і всі ключі перехешовуються в неї заново. Перехешування потрібне, бо остача рахується від нового розміру: ключ, який був у комірці 2 з восьми, у таблиці на 16 комірок опиниться зовсім не там.

Перевірити це можна не на віру, а на пам'яті. Функція sys.getsizeof показує розмір об'єкта в байтах, і на CPython 3.12 вона стрибає рівно там, де відбувається ріст:

записівкомірокsys.getsizeofщо сталося
064 Бтаблиці ще немає взагалі
1…58184 Бперша таблиця, вміщає 5 записів
6…1016272 Бшостий ключ спричинив перехешування
11…2132464 Бі ще одне на одинадцятому
22…4264832 Бі на двадцять другому

Порахуй самостійно: 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 · Заповнення, перехешування, пам'ять

Додавай ключі повзунком і дивись, де таблиця не витримує й подвоюється.

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

10 / Профіhash() і його секрет

Функцію hash() можна викликати руками — і побачити те саме число, яким користується словник. Кілька фактів звідти варті окремої уваги навіть тим, хто пише на Python роками.

Хеш рядка змінюється між запусками

Запусти двічі python3 -c "print(hash('abc'))" — і отримаєш два різні числа. Це не збій. З Python 3.3 хешування рядків і байтів рандомізоване: при старті інтерпретатора генерується випадкове зерно, яке підмішується в обчислення. Причина — та сама атака hash flooding із розділу 6: якщо хеші передбачувані, зловмисник заздалегідь підбирає тисячі ключів з однаковою остачею й кладе сервіс.

Наслідок для тебе практичний: ніколи не зберігай hash(рядок) на диск чи в базу — після перезапуску воно нічого не означатиме. Для стабільного хешування бери hashlib.sha256. А якщо потрібна відтворюваність у тестах, є змінна оточення:

$ PYTHONHASHSEED=0 python3 -c "print(hash('abc'))"
-4594863902769663758  # те саме при кожному запуску

Значення 0 вимикає рандомізацію повністю, будь-яке інше задає конкретне зерно. Числа рандомізації не підлягають: hash(42) завжди 42.

Однакові хеші означають однакові ключі

У Python діє залізне правило: якщо a == b, то hash(a) == hash(b). Інакше словник ламався б. Але з нього випливає наслідок, який регулярно дивує:

print(hash(1), hash(1.0), hash(True))
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 / ПідсумокЩо з цього забрати

Якщо в пам'яті залишиться п'ять пунктів, нехай це будуть ці:

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

Що далі. У наступній темі — множини. Це буде легко: множина — це той самий словник, у якого забрали значення й лишили самі ключі. Уся хеш-таблиця, колізії й вимога незмінності переїжджають туди без змін, зате з'являються операції, яких у словника немає: перетин, об'єднання, різниця. А в practice.ipynb ти зараз збудуєш телефонну книгу, порахуєш частоти слів через .get() і заміниш довгий ланцюжок умов однією таблицею відповідностей.

Далі в темі

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