У попередній темі ми розібрали кортежі й натрапили на дивне слово —
хешованість. Тоді я пообіцяв, що воно знадобиться вже за одну тему.
Час платити за обіцянку: саме на хешованості тримається структура, без якої не обходиться
жодна серйозна програма на Python. Її звуть словником (dictionary,
скорочено dict).
Почнімо з болю. Уяви телефонну книгу як список пар:
[("Аня", "067-111-22-33"), ("Богдан", "050-444-55-66"), …]. Щоб знайти номер
Богдана, компʼютер порівняє «Аня» з «Богдан», потім «Богдан» з «Богдан» — і аж тоді
відповість. Мільйон імен — до мільйона перевірок: список чесно переглядає все підряд,
бо іншого способу в нього немає.
Словник відповідає інакше. Скільки б у ньому не було записів — тисяча чи десять мільйонів — він робить приблизно одну перевірку. Не швидшу, не оптимізовану — саме одну.
ІдеяВідповідність замість порядку
Подивись на цю різницю живцем. Тут рахуються не секунди — вони залежать від процесора й настрою операційної системи, — а кроки: скільки разів довелося порівняти шуканий ключ із наявним.
Пошук: словник проти списку
Рахуємо не секунди, а кроки — скільки разів довелося порівняти ключі.
Список відповідає на питання «що лежить третім». Словник відповідає на питання «що записано під цим іменем». Це різні питання, і структура даних має відповідати питанню, а не навпаки.
Формально словник — це набір пар ключ → значення. Ключі в межах одного словника унікальні: під одним іменем не може лежати два записи. Значення — які завгодно, зокрема однакові й зокрема інші словники.
"Аня": "067-111-22-33",
"Богдан": "050-444-55-66",
"Галя": "063-777-88-99",
}
print(книга["Богдан"]) # 050-444-55-66
Фігурні дужки, двокрапка між ключем і значенням, кома між парами. У квадратних дужках тепер стоїть не номер, а ключ — і в цьому вся різниця. Список нумерує позиції, словник іменує вміст.
Коли словник доречніший за список
Проста перевірка: спитай себе, чи має сенс питання «а що там під номером 5?». Якщо в задачі є природний порядок — кроки рецепта, історія платежів — бери список. Якщо важлива привʼязка одного до іншого — бери словник:
- імʼя → телефон, товар → ціна, місто → населення;
- налаштування програми: назва параметра → його значення;
- підрахунок: слово → скільки разів трапилось;
- кеш: те, що вже порахували, під ключем «для яких вхідних даних».
І окремий випадок, який зустрінеш дуже скоро: словник як компактна заміна довгих умов. Уяви, що програма отримує від вебсервера числовий код відповіді — той самий 404, який показує браузер, коли сторінки немає, — і має пояснити його людині. Замість десятка перевірок «якщо код 404, то напиши таке-то, а якщо 403 — таке-то» пишуть одну таблицю відповідностей і одне звертання до неї:
200: "усе гаразд",
403: "доступ заборонено",
404: "сторінку не знайдено",
500: "помилка на сервері",
}
print(пояснення[404])
сторінку не знайдено
Виграш тут не лише в короткості. Щоб додати ще один код, треба дописати один рядок у таблицю, а не ще одну гілку в довгий ланцюжок перевірок. І таблицю видно всю одразу — тоді як ланцюжок доводиться читати згори донизу, щоб узагалі дізнатись, які варіанти в ньому є.
ОсновиСтворити, взяти, видалити
Порожній словник — це {}, і це саме словник, а не множина: порожні фігурні
дужки історично закріплені за 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
імена — це прохід усього списку. Для словника це той самий одноходовий пошук, що й
звертання по ключу: перевірка наявності коштує стільки ж, скільки взяття значення.
Доступ[] проти .get()
Квадратні дужки категоричні: якщо ключа немає, програма зупиняється з
KeyError. Це не грубість, а чесність — Python не вигадує значення, якого ти
не клав. Але часто відсутність ключа — нормальна ситуація: користувач не заповнив поле,
у налаштуваннях немає рядка, слово трапилось уперше.
Для таких випадків є метод .get(). Він ставить те саме питання, але замість
падіння повертає None. А якщо передати другий аргумент — поверне його:
книга.get("Петро", "невідомо") # 'невідомо'
книга["Петро"] # KeyError: 'Петро'
Спробуй обидва варіанти на наявному й на відсутньому ключі — і подивись, що саме друкує інтерпретатор:
Квадратні дужки проти .get()
Одне й те саме питання, дві різні реакції на «такого ключа немає».
.get() не «виправляє» словник і нічого до нього
не додає — він лише інакше відповідає на відсутність. Значення за замовчуванням існує
тільки в тому місці, де ти його попросив.setdefault: узяти або покласти
Є ще третій режим, який плутають із .get() частіше за все. Метод
setdefault(ключ, значення) робить дві дії одразу: якщо ключ є — повертає його
значення; якщо ключа немає — вставляє пару й повертає щойно вставлене
значення. Тобто, на відміну від .get(), він словник змінює.
запаси.setdefault("яблука", 0) # 5 — ключ був, нічого не сталось
запаси.setdefault("груші", 0) # 0 — ключа не було, він зʼявився
print(запаси)
{'яблука': 5, 'груші': 0}
Найпоширеніше застосування — накопичувачі, коли треба гарантувати, що під ключем уже щось лежить, перш ніж туди щось додавати.
Однорядковий рецепт, який варто знати напамʼять: підрахунок
Найчастіша задача зі словником узагалі — порахувати, скільки разів що трапилось: слова в тексті, покупки в чеку, коди помилок у журналі. Розвʼязують її одним рядком, і цей рядок варто розібрати по шматках:
Читаємо зсередини назовні. лічильник.get(слово, 0) питає «скільки вже
нарахували цьому слову» — і якщо слово трапилось уперше, повертає нуль замість того, щоб
упасти з KeyError. До результату додається одиниця. Отримане число кладеться
назад під тим самим ключем: наявний запис перезаписується, а якщо запису не було, він
створюється. Тобто один рядок робить одразу дві різні роботи — «завести з одиницею» і
«збільшити на одиницю» — і не мусить їх розрізняти:
лічильник["кава"] = лічильник.get("кава", 0) + 1 # ключа не було: 0 + 1
лічильник["кава"] = лічильник.get("кава", 0) + 1 # ключ уже є: 1 + 1
лічильник["чай"] = лічильник.get("чай", 0) + 1 # інший ключ: 0 + 1
print(лічильник)
{'кава': 2, 'чай': 1}
Без .get() той самий рядок довелося б розбивати на перевірку «а чи вже є
такий ключ» і дві різні гілки під кожну відповідь. Саме тому конструкцію впізнають з
першого погляду: вона трапляється в кожній другій програмі, що рахує частоти.
Вікна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()).
Представлення — вікно, а не копія
Міняй словник і дивись, що стає з представленням і що — зі списком-копією.
list(…) у момент «скинути» —
вона зафіксувала стан і більше не змінюється ніколи. Представлення дивиться в живий
словник, тому показує його поточний вміст. Обидва обʼєкти правильні — просто
відповідають на різні питання..items() використовують
у циклі: for ключ, значення in книга.items():. Циклів у нас іще не було —
вони чекають у темі 13. Поки що досить знати, що саме ці методи повертають;
обходити їх ти навчишся за чотири теми.Злиттяupdate і обʼєднання
Щоб перенести в словник купу пар одразу, є метод update(): він приймає інший
словник (або список пар) і вливає його вміст у наш — нові ключі додає, наявні перезаписує.
Змінюється при цьому той словник, якому ти сказав update, а не аргумент.
налаштування.update({"мова": "en", "розмір": 14})
print(налаштування)
{'тема': 'світла', 'мова': 'en', 'розмір': 14}
З Python 3.9 зʼявився коротший запис для випадку, коли потрібен новий
словник, а вихідні мають лишитись недоторканими — оператор |:
користувач = {"мова": "en"}
підсумок = за_замовчуванням | користувач
print(підсумок)
{'тема': 'світла', 'мова': 'en'}
Правило простого запамʼятовування: при конфлікті ключів перемагає правий
операнд. Тому в парі «типові налаштування | налаштування користувача» користувач завжди
переважує — і це саме те, чого від такої конструкції чекають. Є ще
|=, який працює як update(), і старий трюк
{**a, **b}, який робить те саме й досі трапляється в чужому коді.
МеханікаЯк працює хеш-таблиця
Тепер — головне. Звідки береться той самий один крок замість мільйона?
Уяви камеру схову з вісьмома комірками, пронумерованими від 0 до 7. Ти хочеш, щоб валізу потім знайшли миттєво, без обходу всіх комірок. Рішення: домовитись про правило, яке з самої валізи однозначно обчислює номер комірки. Тоді і той, хто кладе, і той, хто забирає, приходять одразу за потрібною адресою.
Це правило й називається хешуванням. Воно складається з двох кроків:
Функція hash() перетворює обʼєкт на одне ціле число. Байти ключа
перемішуються так, щоб схожі ключі давали геть різні числа: hash("Аня")
і hash("Оля") не мають нічого спільного, хоча слова схожі.
Число величезне, а комірок лише 8. Отже беремо остачу від ділення — ту саму, що ми розбирали в темі про числа. Остача від ділення на 8 завжди лежить у діапазоні 0…7, тобто гарантовано вказує на існуючу комірку:
Обидва обчислення не залежать від того, скільки записів у словнику. Хеш рядка з пʼяти літер рахується однаково швидко в порожньому словнику й у словнику з мільйона записів. Остача — одна машинна операція. Тому пошук не сповільнюється з ростом даних: це і є те, що позначають записом O(1).
Колізія: дві валізи в одну комірку
Комірок вісім, а різних ключів — нескінченно багато. Отже рано чи пізно два різні ключі дадуть одну остачу. Це колізія (collision) — не рідкісний збій, а щоденна подія. Порахуймо руками, наскільки щоденна. Перший ключ сідає куди завгодно: вільні всі вісім комірок із восьми. Другому, щоб ні з ким не зіткнутись, треба влучити в одну з семи вільних — імовірність 7/8. Третьому лишається 6/8, четвертому 5/8, пʼятому 4/8. Щоб усі пʼять розминулись, мусять справдитись усі ці умови разом, а отже дроби перемножуються:
Це шанс, що колізій не буде. Отже хоча б одна станеться приблизно у 80 випадках зі ста — і це лише на пʼятьох ключах у таблиці з вісьмома комірками. Колізія — норма, а не аварія, і хеш-таблиця мусить уміти з нею жити.
Python розвʼязує колізію способом, який зветься відкритою адресацією (open addressing): якщо потрібна комірка зайнята, він за суворим правилом обчислює наступного кандидата й пробує його. І так, поки не знайдеться вільна. Правило CPython таке:
Розберемо кожен символ, бо тут два нові. j — номер комірки, у
яку ми щойно поцілили й де виявилось зайнято; розмір — скільки всього
комірок у таблиці, у нашому прикладі вісім. Збурення (perturb) — це
увесь хеш ключа, а не сама лише його остача: на початку пошуку в нього кладуть
рівно hash(ключ). А знак >> — це зсув бітів
праворуч. Біти ми розбирали в темі 04:
число в памʼяті — це ряд нулів і одиниць, і зсув на пʼять позицій просто викидає пʼять
наймолодших із них. У звичайній арифметиці це те саме, що поділити націло на
2 × 2 × 2 × 2 × 2 = 32. Навіщо: щоразу зсуваючи, ми підставляємо у формулу нову порцію
бітів хеша, тож за кілька кроків у справу йде вся його довжина, а не лише хвіст.
Тепер словами: наступна комірка залежить і від попередньої, і від решти бітів хеша. Другий доданок важливий: без нього всі ключі з однаковою остачею йшли б однаковим маршрутом і збивалися в купу. А оскільки правило детерміноване, пошук іде тим самим маршрутом, що й вставка, — і знаходить ключ там, куди його поклали.
Ось той самий процес наочно. Спробуй додати ключ, який стикнеться з наявним:
Хеш-таблиця: ключ, хеш, комірка, колізія
Вісім комірок. Дивись, як обчислюється адреса — і що робить Python, коли вона зайнята.
будь-яке слово — хеш порахується
hash() дає 19-значні числа й змінюється
між запусками — про це в розділі 10), але правило пошуку наступної комірки — точно те,
що в CPython.КлючіЧому ключі мусять бути незмінними
Тепер механіка сама пояснює обмеження, яке ми зустріли ще в темі про
кортежі. Ключем словника може бути тільки
хешований обʼєкт: число, рядок, кортеж із хешованих елементів,
None, True. Список, множина або інший словник — не можуть.
Причина суто інженерна: адреса запису обчислена з ключа. Якщо ключ змінити після вставки, його хеш стане іншим, отже й адреса — а запис лишиться лежати за старою. Ключ показуватиме на порожню комірку: значення фізично в словнику, але дістатися до нього неможливо.
Python не дає цьому статись, просто заборонивши брати змінювані обʼєкти за ключі. У списку
метод __hash__ дорівнює None — тому hash([1, 2]) одразу
падає з TypeError: unhashable type: 'list'. Перемкни інтерактив на третій режим,
щоб побачити, що саме сталося б без цієї заборони:
Що буде, якщо ключ змінити
Кортеж — можна. Список — не можна. І показова катастрофа, якби було можна.
TypeError при спробі взяти список за ключ —
не примха мови, а запобіжник. Він зупиняє програму в тому місці, де помилку ще видно,
замість того щоб дозволити їй тихо загубити дані на третьому режимі.(1, 2) — чудовий ключ, а
(1, [2]) — уже ні: усередині живе список. Правило точне: кортеж хешований тоді
й лише тоді, коли хешовані всі його елементи, на будь-якій глибині.ПорядокПорядок вставки
До Python 3.6 порядок ключів у словнику був фактично випадковим — таким, як лягло в хеш-таблицю. Надрукуєш словник двічі на різних машинах і отримаєш різний порядок. Це збивало з пантелику всіх новачків і ламало не одну програму, автор якої понадіявся на порядок.
З версії 3.6 у CPython зʼявилось компактне представлення словника, а з 3.7 гарантія порядку стала частиною мови: словник зберігає порядок вставки. Ідея представлення така. Замість однієї таблиці стало дві:
- масив записів — усі пари підряд, суворо в порядку додавання. Тут немає дірок від хешування, тому він щільний;
- індексна таблиця — маленькі комірки, у яких лежить не сам запис, а лише його номер у масиві записів. Саме її адресує хеш.
Виграш подвійний. Порядок зʼявився безкоштовно — він і є порядок масиву записів. А дірки від хешування переїхали в маленьку індексну таблицю (один байт на комірку, поки записів менше за 128) із великого масиву, де на запис ішло три машинні слова. Тому словник у 3.6 схуднув приблизно на чверть проти 3.5 — рідкісний випадок, коли структура стала одночасно й меншою, й зручнішою.
Два масиви: чому порядок зберігається
Сценарій із чотирьох кроків: додавання, видалення й повторне додавання того самого ключа.
×: комірка вільна для нових ключів,
але маршрут пошуку через неї не обривається.РістКоли таблиця росте
Комірок вісім — а що буде на девʼятому ключі? Питання не риторичне: заповнена таблиця не працює взагалі, бо пошук вільної комірки зациклиться. Але й майже заповнена працює погано: що менше вільного місця, то довші маршрути обходу колізій.
Тому Python стежить за коефіцієнтом заповнення й не дає йому перевищити
дві третини. Щойно кількість записів переходить за 2/3 від кількості комірок,
створюється нова таблиця — і всі ключі перехешовуються в неї заново.
Перехешування потрібне, бо остача рахується від нового розміру: ключ, який був у комірці 2
з восьми, у таблиці на 16 комірок опиниться зовсім не там.
Перевірити це можна не на віру, а на памʼяті. Функція sys.getsizeof показує
розмір обʼєкта в байтах, і на CPython 3.12 вона стрибає рівно там, де відбувається ріст.
Числа нижче — для словника з рядковими ключами, тобто саме такого, як
наша телефонна книга. Для словника з числовими ключами CPython тримає в кожній комірці ще
й окреме поле під хеш, тому абсолютні значення там будуть більші — а моменти стрибків ті
самі:
| записів | комірок | 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 добирає так: бере потроєну кількість записів, які вже лежать у словнику, і під це число підбирає найменший степінь двійки, не менший за нього. Шостий ключ приходить, коли записів пʼять: 5 × 3 = 15, найближчий степінь двійки згори — 16. Одинадцятий приходить на десяти: 10 × 3 = 30 → 32. Двадцять другий на двадцяти одному: 21 × 3 = 63 → 64. Множник саме три, а не два, — щоб одразу після перебудови лишався запас і наступне перехешування настало не через пару вставок.
Заповнення, перехешування, памʼять
Додавай ключі повзунком і дивись, де таблиця не витримує й подвоюється.
Профі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 зарезервоване як ознака помилки, тому справжній хеш мінус одиниці
довелося зсунути. Це єдине число, чий хеш не дорівнює йому самому.ПідсумокЩо з цього забрати
Якщо в памʼяті залишиться пʼять пунктів, нехай це будуть ці:
- Словник — про відповідність, список — про порядок. Якщо ти шукаєш запис за іменем, а не за номером, список тут неправильний інструмент, і чим більше даних, тим дорожча помилка;
- Один крок замість мільйона — бо адреса рахується з самого ключа, а не шукається перебором. Ціна — памʼять на вільні комірки та вимога до ключів;
- Ключі мусять бути незмінними, і це прямий наслідок першого пункту: змінився ключ — змінилась адреса — запис загубився. Список ключем бути не може ніколи, кортеж — якщо всередині немає списків;
[]падає,.get()повертає. Обери свідомо: якщо відсутність ключа — аварія, хай падає; якщо норма — бери.get()зі значенням за замовчуванням;- Порядок вставки гарантований із 3.7, і це саме порядок вставки, а не сортування. Видалив і додав знову — ключ поїхав у кінець.
І головне в дусі цього курсу. Словник виглядає як звичайні дужки з двокрапками, але
всередині це компроміс, вилизуваний десятиліттями: розмір таблиці, момент її зростання,
формула обходу колізій, рандомізація хеша, два масиви замість одного. Коли наступного разу
напишеш книга["Богдан"] й отримаєш відповідь миттєво — ти вже знаєш, що саме
там спрацювало.