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

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

У попередній темі ми розібрали кортежі й натрапили на дивне слово — хешованість. Тоді я пообіцяв, що воно знадобиться вже за одну тему. Час платити за обіцянку: саме на хешованості тримається структура, без якої не обходиться жодна серйозна програма на 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-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 імена — це прохід усього списку. Для словника це той самий одноходовий пошук, що й звертання по ключу: перевірка наявності коштує стільки ж, скільки взяття значення.

Доступ[] проти .get()

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

Для таких випадків є метод .get(). Він ставить те саме питання, але замість падіння повертає None. А якщо передати другий аргумент — поверне його:

книга.get("Петро")              # None
книга.get("Петро", "невідомо")  # 'невідомо'
книга["Петро"]                  # KeyError: 'Петро'

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

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

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

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

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

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

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

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

Однорядковий рецепт, який варто знати напамʼять: підрахунок

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

лічильник[слово] = лічильник.get(слово, 0) + 1

Читаємо зсередини назовні. лічильник.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() — пари як кортежі.

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()).

Представлення — вікно, а не копія

Міняй словник і дивись, що стає з представленням і що — зі списком-копією.

у представленні—
у копії—
Що сталося: копію зроблено list(…) у момент «скинути» — вона зафіксувала стан і більше не змінюється ніколи. Представлення дивиться в живий словник, тому показує його поточний вміст. Обидва обʼєкти правильні — просто відповідають на різні питання.
Забігання наперед. Найчастіше .items() використовують у циклі: for ключ, значення in книга.items():. Циклів у нас іще не було — вони чекають у темі 13. Поки що досить знати, що саме ці методи повертають; обходити їх ти навчишся за чотири теми.

Злиттяupdate і обʼєднання

Щоб перенести в словник купу пар одразу, є метод update(): він приймає інший словник (або список пар) і вливає його вміст у наш — нові ключі додає, наявні перезаписує. Змінюється при цьому той словник, якому ти сказав update, а не аргумент.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Комірок вісім, а різних ключів — нескінченно багато. Отже рано чи пізно два різні ключі дадуть одну остачу. Це колізія (collision) — не рідкісний збій, а щоденна подія. Порахуймо руками, наскільки щоденна. Перший ключ сідає куди завгодно: вільні всі вісім комірок із восьми. Другому, щоб ні з ким не зіткнутись, треба влучити в одну з семи вільних — імовірність 7/8. Третьому лишається 6/8, четвертому 5/8, пʼятому 4/8. Щоб усі пʼять розминулись, мусять справдитись усі ці умови разом, а отже дроби перемножуються:

8/8 × 7/8 × 6/8 × 5/8 × 4/8 = 6720 / 32768 ≈ 0.21

Це шанс, що колізій не буде. Отже хоча б одна станеться приблизно у 80 випадках зі ста — і це лише на пʼятьох ключах у таблиці з вісьмома комірками. Колізія — норма, а не аварія, і хеш-таблиця мусить уміти з нею жити.

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

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

Розберемо кожен символ, бо тут два нові. j — номер комірки, у яку ми щойно поцілили й де виявилось зайнято; розмір — скільки всього комірок у таблиці, у нашому прикладі вісім. Збурення (perturb) — це увесь хеш ключа, а не сама лише його остача: на початку пошуку в нього кладуть рівно hash(ключ). А знак >> — це зсув бітів праворуч. Біти ми розбирали в темі 04: число в памʼяті — це ряд нулів і одиниць, і зсув на пʼять позицій просто викидає пʼять наймолодших із них. У звичайній арифметиці це те саме, що поділити націло на 2 × 2 × 2 × 2 × 2 = 32. Навіщо: щоразу зсуваючи, ми підставляємо у формулу нову порцію бітів хеша, тож за кілька кроків у справу йде вся його довжина, а не лише хвіст.

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

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

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

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

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

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

КлючіЧому ключі мусять бути незмінними

Тепер механіка сама пояснює обмеження, яке ми зустріли ще в темі про кортежі. Ключем словника може бути тільки хешований обʼєкт: число, рядок, кортеж із хешованих елементів, 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 — рідкісний випадок, коли структура стала одночасно й меншою, й зручнішою.

Два масиви: чому порядок зберігається

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

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

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

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

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

Перевірити це можна не на віру, а на памʼяті. Функція sys.getsizeof показує розмір обʼєкта в байтах, і на CPython 3.12 вона стрибає рівно там, де відбувається ріст. Числа нижче — для словника з рядковими ключами, тобто саме такого, як наша телефонна книга. Для словника з числовими ключами CPython тримає в кожній комірці ще й окреме поле під хеш, тому абсолютні значення там будуть більші — а моменти стрибків ті самі:

записівкомірокsys.getsizeofщо сталося
0—64 Бтаблиці ще немає взагалі
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 добирає так: бере потроєну кількість записів, які вже лежать у словнику, і під це число підбирає найменший степінь двійки, не менший за нього. Шостий ключ приходить, коли записів пʼять: 5 × 3 = 15, найближчий степінь двійки згори — 16. Одинадцятий приходить на десяти: 10 × 3 = 30 → 32. Двадцять другий на двадцяти одному: 21 × 3 = 63 → 64. Множник саме три, а не два, — щоб одразу після перебудови лишався запас і наступне перехешування настало не через пару вставок.

Заповнення, перехешування, памʼять

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

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

Профі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 зарезервоване як ознака помилки, тому справжній хеш мінус одиниці довелося зсунути. Це єдине число, чий хеш не дорівнює йому самому.

ПідсумокЩо з цього забрати

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

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

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