C++

Власні структури даних: хеш-таблиця

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

Власні структури даних: хеш-таблиця

🎯 Мета лекції

  • Перейти від лінійних (масиви, списки) та ієрархічних (дерева) структур до асоціативних: зв'язок ключ-значення для моментального пошуку.
  • Опанувати концепцію хешування (hashing): перетворення довільних ключів (рядків, об'єктів) у цілочислені індекси масиву через детерміновану функцію.
  • Реалізувати хеш-таблицю з ланцюговим розв'язанням колізій (hash table with chaining): кожен бакет (bucket) містить зв'язаний список елементів із однаковим хешем.
  • Дослідити динамічне збільшення розміру (rehashing): коли коефіцієнт завантаження (load factor) перевищує поріг, подвоюємо кількість бакетів та повторно хешуємо всі елементи.
  • Зрозуміти, чому середня складність операцій вставки, пошуку та видалення — O(1), навіть попри наявність колізій.

🔑 Ключові терміни

  • Hash Function (Хеш-функція): функція, що перетворює ключ довільного типу в ціле число (індекс масиву); має бути детермінованою, швидкою та рівномірно розподіляти ключі.
  • Bucket (Бакет): комірка хеш-таблиці, що зберігає елементи з однаковим хеш-значенням (зазвичай зв'язаний список).
  • Collision (Колізія): ситуація, коли різні ключі отримують однаковий хеш-індекс; потребує стратегії розв'язання (chaining, open addressing).
  • Load Factor (Коефіцієнт завантаження): відношення кількості елементів до кількості бакетів λ = N / M; визначає, коли потрібно збільшити таблицю.
  • Rehashing (Перехешування): процес збільшення розміру хеш-таблиці та повторного обчислення індексів для всіх існуючих елементів.

Hook: словник на мільйон слів — як знайти переклад за одну операцію?

Уявіть електронний словник англійсько-український з 1 мільйоном пар (слово → переклад). Користувач вводить "algorithm", програма має миттєво повернути "алгоритм".

Варіант 1: Невідсортований масив

Зберігаємо пари у масиві std::vector<std::pair<std::string, std::string>> dict;. Пошук вимагає лінійного сканування — перебираємо всі 1 000 000 елементів, поки не знайдемо "algorithm". Складність: O(n) — до 1 мільйона порівнянь у гіршому випадку.

Варіант 2: Відсортований масив + бінарний пошук

Сортуємо масив за ключами, використовуємо бінарний пошук. Складність пошуку: O(log n) — ~20 порівнянь для 1 мільйона елементів. Це вже значно краще.

Проблема: Вставка нового слова вимагає зсуву половини масиву (середня складність O(n)). Для динамічного словника, що постійно поповнюється, це катастрофа.

Варіант 3: Бінарне дерево пошуку (BST)

У попередній лекції (стаття 91) ми бачили, що BST дає O(log n) для вставки та пошуку без зсуву елементів. Для збалансованого дерева це відмінне рішення.

Але чи можна ще швидше? Уявіть ідеальний світ: ви знаєте, що слово "algorithm" має бути на позиції 42 масиву. Ви одразу звертаєтесь до dict[42] — одна операція, O(1). Жодних порівнянь, жодних переходів по дереву.

Ключова ідея хеш-таблиць: перетворити ключ (слово) у індекс масиву за допомогою математичної функції, яка працює за O(1). Якщо функція обрана правильно, ми отримуємо майже миттєвий доступ до даних, незалежно від розміру таблиці.

Ось звідки береться термін хешування (hashing): ми "перемішуємо" (to hash) вхідний ключ (рядок, число, об'єкт) у компактне ціле число — хеш-код (hash code). Цей хеш-код стає індексом у масиві, який називається хеш-таблицею (hash table).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "Ключ: \"algorithm\"" as key1 #DBEAFE
rectangle "Хеш-функція\nhash(key)" as hashFunc #DCFCE7
rectangle "Хеш-код: 42" as hash1 #FEF3C7
rectangle "Масив бакетів\n(Hash Table)" as table #F1F5F9 {
    rectangle "Індекс 0: ∅" as bucket0 #E2E8F0
    rectangle "..." as dots1
    rectangle "Індекс 42: \"algorithm\" → \"алгоритм\"" as bucket42 #FEF3C7
    rectangle "..." as dots2
    rectangle "Індекс 99: ∅" as bucket99 #E2E8F0
}

key1 -right-> hashFunc
hashFunc -right-> hash1
hash1 -down-> bucket42

note right of hashFunc #DBEAFE
  Перетворює ключ
  у ціле число:
  hash("algorithm") = 42
end note

note right of bucket42 #FEF3C7
  Доступ за O(1):
  table[42] → переклад
end note

@enduml

Реальність складніша: Різні ключі можуть отримати однаковий хеш-код — це називається колізією (collision). Якщо hash("algorithm") = 42 і hash("zebra") = 42, обидва слова претендують на індекс 42. Як зберегти обидва елементи?

Існує два класичних підходи:

  1. Метод ланцюгів (chaining): кожен бакет містить зв'язаний список пар (ключ, значення). Якщо колізія — додаємо елемент у список.
  2. Відкрита адресація (open addressing): шукаємо наступний вільний бакет за певним алгоритмом (лінійне пробування, квадратичне, подвійне хешування).

У цій лекції ми реалізуємо хеш-таблицю з ланцюговим методом, оскільки він простіший для розуміння та природно поєднується з нашими знаннями про зв'язані списки (стаття 90).


Фундаментальні концепції хешування

Хеш-функція: перетворення ключів у індекси

Хеш-функція (hash function) — це детермінована функція, що приймає ключ довільного типу (рядок, число, об'єкт) і повертає невід'ємне ціле число — хеш-код (hash code):

size_t hash(const std::string& key);

Властивості гарної хеш-функції:

ВластивістьПоясненняНаслідки порушення
ДетермінованістьОдин і той же ключ завжди дає однаковий хешНедетермінована функція робить пошук неможливим
Рівномірний розподілХеші рівномірно розподілені по діапазону [0, M)Нерівномірність → кластеризація → багато колізій → деградація до O(n)
Швидкість обчисленняОбчислення хешу має бути O(1)Повільна функція знищує переваги хеш-таблиці
Лавинний ефектНевелика зміна ключа → велика зміна хешуПогана чутливість → передбачувані колізії для схожих ключів
У хеш-таблицях не потрібні криптографічні функції (SHA-256, MD5), які дорогі в обчисленні. Нам достатньо швидких некриптографічних функцій з хорошим розподілом: FNV-1a, MurmurHash, xxHash. Для навчальних цілей ми реалізуємо спрощену версію.

Проста хеш-функція для рядків

Розглянемо базову хеш-функцію для рядків — поліноміальний хеш з простим числом як основою:

size_t simpleHash(const std::string& key)
{
    size_t hash = 0;
    const size_t prime = 31;  // Просте число для зменшення колізій
    
    for (char c : key)
    {
        hash = hash * prime + static_cast<size_t>(c);
    }
    
    return hash;
}

Логіка: Кожен символ рядка впливає на фінальний хеш через множення на просте число (31 — популярний вибір у Java String.hashCode()). Це забезпечує лавинний ефект: зміна одного символу змінює всі наступні біти хешу.

Приклад обчислення для "cat":

hash = 0
Символ 'c' (ASCII 99): hash = 0 * 31 + 99 = 99
Символ 'a' (ASCII 97): hash = 99 * 31 + 97 = 3166
Символ 't' (ASCII 116): hash = 3166 * 31 + 116 = 98262
Фінальний хеш: 98262

Проблема: Хеш-код може бути дуже великим (для довгих рядків). Ми маємо масив розміром M (наприклад, 100 бакетів). Як перетворити довільне число 98262 у індекс [0, M)?

Рішення: Операція модуля (modulo):

size_t index = simpleHash("cat") % M;  // M = 100 → index = 98262 % 100 = 62

Тепер index гарантовано знаходиться у допустимому діапазоні [0, 99].

Використання простих чисел (31, 37, 53) у хеш-функціях зменшує ймовірність систематичних колізій. Якщо основа — ступінь двійки (наприклад, 32), останні біти хешу визначаються лише останніми символами рядка, що призводить до кластеризації схожих ключів. Прості числа "перемішують" біти ефективніше.

Колізії: неминуча реальність

Навіть з ідеальною хеш-функцією колізії неминучі через принцип Діріхле (pigeonhole principle): якщо кількість можливих ключів (нескінченна для рядків) більша за кількість бакетів (скінченна), принаймні два ключі отримають однаковий індекс.

Приклад:

simpleHash("listen") % 100 → 42
simpleHash("silent") % 100 → 42  // Колізія!

Обидва слова мають однаковий індекс (42), але це різні ключі з різними значеннями. Як зберегти обидва?

Метод ланцюгів (chaining): Кожен бакет table[42] стає головою зв'язаного списку. Коли виникає колізія, додаємо новий елемент у кінець списку.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Хеш-таблиця з M=7 бакетів

rectangle "Масив бакетів" as table #F1F5F9 {
    rectangle "Індекс 0: ∅" as b0 #E2E8F0
    rectangle "Індекс 1: ∅" as b1 #E2E8F0
    rectangle "Індекс 2: \"apple\" → \"яблуко\"" as b2 #DBEAFE
    rectangle "Індекс 3: \"cat\" → \"кіт\" → \"dog\" → \"собака\"" as b3 #FEF3C7
    rectangle "Індекс 4: ∅" as b4 #E2E8F0
    rectangle "Індекс 5: \"zebra\" → \"зебра\"" as b5 #DCFCE7
    rectangle "Індекс 6: ∅" as b6 #E2E8F0
}

note right of b3 #FEF3C7
  Колізія: "cat" і "dog"
  мають hash % 7 = 3
  Обидва елементи зберігаються
  у зв'язаному списку бакета 3
end note

note bottom of table #E2E8F0
  Пусті бакети (∅) містять nullptr
  Заповнені бакети — покажчики на голову списку
end note

@enduml

Складність операцій при колізіях:

  • Середній випадок: Якщо хеш-функція рівномірна, елементи розподілені по бакетах приблизно порівну. Середня довжина списку = N / M = λ (коефіцієнт завантаження). Пошук у списку — O(λ).
  • Якщо λ ≈ 1 (кількість елементів ≈ кількість бакетів): середня довжина списку = 1 → O(1) операції.
  • Якщо λ >> 1 (багато елементів, мало бакетів): списки довгі → деградація до O(n).

Рішення: Коли λ перевищує поріг (зазвичай 0.75), подвоюємо розмір таблиці та перехешуємо (rehash) всі елементи. Це тримає λ під контролем, гарантуючи O(1) у середньому.


Проєктування класу HashTable: структура вузла та інтерфейс

Структура вузла HashNode

Кожен елемент хеш-таблиці — це пара (ключ, значення), збережена у вузлі зв'язаного списку:

template <typename K, typename V>
struct HashNode
{
    K key;          // Ключ (наприклад, std::string)
    V value;        // Значення (наприклад, std::string)
    HashNode* next; // Покажчик на наступний вузол у ланцюжку колізій
    
    HashNode(const K& k, const V& v)
        : key(k), value(v), next(nullptr)
    {
    }
};

Чому зберігаємо ключ? Навіть якщо два ключі мають однаковий хеш-індекс (колізія), вони можуть бути різними ключами. При пошуку потрібно порівнювати фактичні ключі, а не лише індекси.

Оголошення шаблонного класу HashTable

// HashTable.h
#ifndef HASH_TABLE_H
#define HASH_TABLE_H

#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>

template <typename K, typename V>
struct HashNode
{
    K key;
    V value;
    HashNode* next;
    
    HashNode(const K& k, const V& v)
        : key(k), value(v), next(nullptr)
    {
    }
};

template <typename K, typename V>
class HashTable
{
private:
    std::vector<HashNode<K, V>*> buckets;  // Масив покажчиків на голови списків
    size_t count;                          // Кількість елементів у таблиці
    size_t bucketCount;                    // Кількість бакетів
    
    // Константа для обчислення порогу завантаження
    static constexpr double MAX_LOAD_FACTOR = 0.75;
    
    // Приватні допоміжні методи
    size_t hashFunction(const K& key) const;
    size_t getBucketIndex(const K& key) const;
    void rehash();
    void destroyBucket(HashNode<K, V>* node);

public:
    // Конструктор: створює таблицю із заданою початковою ємністю
    explicit HashTable(size_t capacity = 16);
    
    // Деструктор: звільняє всі вузли (RAII)
    ~HashTable();
    
    // Заборона копіювання (для простоти)
    HashTable(const HashTable&) = delete;
    HashTable& operator=(const HashTable&) = delete;
    
    // Вставити пару (ключ, значення)
    void insert(const K& key, const V& value);
    
    // Видалити елемент за ключем
    bool remove(const K& key);
    
    // Знайти значення за ключем (повертає покажчик або nullptr)
    V* find(const K& key);
    const V* find(const K& key) const;
    
    // Перевірити, чи таблиця порожня
    bool isEmpty() const;
    
    // Отримати кількість елементів
    size_t size() const;
    
    // Отримати поточну ємність (кількість бакетів)
    size_t capacity() const;
    
    // Обчислити поточний коефіцієнт завантаження
    double loadFactor() const;
    
    // Вивести статистику таблиці (для відлагодження)
    void printStatistics() const;
};

#endif

Ключові рішення дизайну:

  1. Шаблонний клас: K — тип ключа, V — тип значення. Універсальність як у STL-контейнерах.
  2. std::vector<HashNode*>: Масив покажчиків на голови списків. Вектор дозволяє легко змінювати розмір при rehash.
  3. Відстежування розміру: count — поточна кількість елементів, bucketCount — кількість бакетів. Коефіцієнт завантаження λ = count / bucketCount.
Стандартні контейнери STL (std::unordered_map) зазвичай починають з ємності 8–16 бакетів. Це баланс між витратами пам'яті (не витрачати багато на пусту таблицю) та частотою перехешувань (не робити rehash після кожної вставки). Ємність завжди має бути ступенем двійки для оптимізації (можна використовувати побітове & замість %), але для спрощення ми використаємо звичайний модуль.

Реалізація конструктора, деструктора та допоміжних методів

Конструктор та ініціалізація бакетів

// HashTable.cpp (реалізація шаблонного класу зазвичай у .h, але для наочності виносимо окремо)

template <typename K, typename V>
HashTable<K, V>::HashTable(size_t capacity)
    : buckets(capacity, nullptr)
    , count(0)
    , bucketCount(capacity)
{
    if (capacity == 0)
    {
        throw std::invalid_argument("Capacity must be greater than zero");
    }
}

Логіка: Створюємо вектор із capacity покажчиків, всі ініціалізовані як nullptr (пусті бакети). Початковий розмір таблиці (count) — 0, оскільки немає елементів.

Деструктор: звільнення всіх ланцюжків

template <typename K, typename V>
HashTable<K, V>::~HashTable()
{
    // Проходимо по всіх бакетах та знищуємо їхні списки
    for (size_t i = 0; i < bucketCount; ++i)
    {
        destroyBucket(buckets[i]);
    }
}

// Рекурсивне знищення зв'язаного списку в бакеті
template <typename K, typename V>
void HashTable<K, V>::destroyBucket(HashNode<K, V>* node)
{
    if (node == nullptr)
    {
        return;  // Базовий випадок: порожній бакет
    }
    
    // Рекурсивно знищуємо наступний вузол
    destroyBucket(node->next);
    
    // Тепер можна видалити поточний вузол
    delete node;
}

Порядок видалення: Спочатку видаляємо хвіст списку (рекурсивно), потім голову. Це гарантує, що ми не втратимо доступ до наступних вузлів.

Якщо забути реалізувати деструктор, всі вузли у бакетах залишаться у купі після знищення об'єкта HashTable → витік пам'яті. Для великих таблиць це може спричинити вичерпання пам'яті процесу.

Хеш-функція та обчислення індексу бакета

// Спеціалізація для std::string (для інших типів потрібні окремі перевантаження)
template <>
size_t HashTable<std::string, std::string>::hashFunction(const std::string& key) const
{
    size_t hash = 0;
    const size_t prime = 31;
    
    for (char c : key)
    {
        hash = hash * prime + static_cast<size_t>(c);
    }
    
    return hash;
}

// Загальний метод для будь-яких типів K, V
template <typename K, typename V>
size_t HashTable<K, V>::getBucketIndex(const K& key) const
{
    return hashFunction(key) % bucketCount;
}

Два кроки:

  1. Обчислити хеш-код: hashFunction(key) → велике ціле число.
  2. Звести до діапазону бакетів: hash % bucketCount → індекс [0, bucketCount).

Для узагальнених типів K: У продакшн-коді потрібно використовувати шаблонну спеціалізацію або вимагати від користувача надання власної хеш-функції (як у std::unordered_map через std::hash<K>). Для навчальних цілей ми фокусуємось на std::string.


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

Алгоритм вставки

  1. Обчислити індекс бакета: index = getBucketIndex(key).
  2. Перевірити, чи ключ вже існує у ланцюжку бакета:
    • Якщо так → оновити значення (політика перезапису).
    • Якщо ні → створити новий вузол і додати його на початок списку (O(1) вставка).
  3. Збільшити count.
  4. Перевірити коефіцієнт завантаження: якщо λ > MAX_LOAD_FACTOR → виконати rehash().
template <typename K, typename V>
void HashTable<K, V>::insert(const K& key, const V& value)
{
    // Крок 1: Знайти індекс бакета
    size_t index = getBucketIndex(key);
    
    // Крок 2: Перевірити, чи ключ вже існує
    HashNode<K, V>* current = buckets[index];
    while (current != nullptr)
    {
        if (current->key == key)
        {
            // Ключ знайдено → оновлюємо значення
            current->value = value;
            return;  // Розмір не змінюється
        }
        current = current->next;
    }
    
    // Крок 3: Ключ не знайдено → створюємо новий вузол
    HashNode<K, V>* newNode = new HashNode<K, V>(key, value);
    
    // Вставка на початок списку (O(1))
    newNode->next = buckets[index];
    buckets[index] = newNode;
    
    // Крок 4: Збільшити розмір
    ++count;
    
    // Крок 5: Перевірити коефіцієнт завантаження
    if (loadFactor() > MAX_LOAD_FACTOR)
    {
        rehash();
    }
}

template <typename K, typename V>
double HashTable<K, V>::loadFactor() const
{
    return static_cast<double>(count) / bucketCount;
}

Чому вставка на початок списку? Вставка в кінець вимагала б проходу по всьому ланцюжку (O(λ)). Вставка на початок — O(1): просто змінюємо покажчик next нового вузла на стару голову, а голову бакета — на новий вузол.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Вставка "dog" → "собака" (індекс 3)

rectangle "До вставки" as before #F1F5F9 {
    rectangle "Бакет 3" as b3_before #DBEAFE
    circle "\"cat\" → \"кіт\"" as cat_before #DCFCE7
    label "nullptr" as null_before
    b3_before -down-> cat_before
    cat_before -down-> null_before
}

rectangle "Після вставки" as after #F1F5F9 {
    rectangle "Бакет 3" as b3_after #DBEAFE
    circle "\"dog\" → \"собака\"" as dog_after #FEF3C7
    circle "\"cat\" → \"кіт\"" as cat_after #DCFCE7
    label "nullptr" as null_after
    b3_after -down-> dog_after
    dog_after -down-> cat_after
    cat_after -down-> null_after
}

note right of dog_after #FEF3C7
  Новий вузол додається
  на **початок** списку
  (O(1) операція)
end note

before -[hidden]right-> after

@enduml

Складність:

  • Середній випадок: O(1) — обчислення хешу O(1), вставка на початок O(1), rehash виконується рідко (амортизована O(1)).
  • Гірший випадок: O(n) — всі ключі потрапили в один бакет (погана хеш-функція або навмисна атака). Тоді пошук у списку — O(n).

Операція пошуку: знаходження значення за ключем

Алгоритм пошуку

  1. Обчислити індекс бакета: index = getBucketIndex(key).
  2. Пройтись по ланцюжку бакета, порівнюючи ключі:
    • Якщо знайдено співпадіння → повернути покажчик на значення.
    • Якщо дійшли до nullptr → ключ не знайдено, повернути nullptr.
template <typename K, typename V>
V* HashTable<K, V>::find(const K& key)
{
    size_t index = getBucketIndex(key);
    HashNode<K, V>* current = buckets[index];
    
    // Проходимо по ланцюжку бакета
    while (current != nullptr)
    {
        if (current->key == key)
        {
            return &current->value;  // Знайдено → повертаємо покажчик на значення
        }
        current = current->next;
    }
    
    return nullptr;  // Не знайдено
}

// Константна версія для константних об'єктів HashTable
template <typename K, typename V>
const V* HashTable<K, V>::find(const K& key) const
{
    size_t index = getBucketIndex(key);
    HashNode<K, V>* current = buckets[index];
    
    while (current != nullptr)
    {
        if (current->key == key)
        {
            return &current->value;
        }
        current = current->next;
    }
    
    return nullptr;
}

Чому повертаємо покажчик, а не копію? Якщо ключ не знайдено, не можемо повернути "фіктивне" значення (що якщо V не має дефолтного конструктора?). Покажчик дозволяє чітко розрізнити "знайдено" (!= nullptr) та "не знайдено" (== nullptr).

Використання:

HashTable<std::string, std::string> dict;
dict.insert("cat", "кіт");

std::string* translation = dict.find("cat");
if (translation != nullptr)
{
    std::cout << "Переклад: " << *translation << '\n';  // Виведе: Переклад: кіт
}
else
{
    std::cout << "Слово не знайдено\n";
}

Складність: O(1) у середньому (довжина ланцюжка ≈ λ ≈ 1), O(n) у гіршому випадку (всі елементи в одному бакеті).


Операція видалення: вилучення елемента зі списку

Алгоритм видалення

Видалення з хеш-таблиці складніше за вставку, оскільки потрібно:

  1. Знайти попередній вузол у ланцюжку (щоб перенаправити його next).
  2. Видалити знайдений вузол та з'єднати попередній з наступним.
  3. Обробити спеціальний випадок: видаляємий вузол — голова списку.
template <typename K, typename V>
bool HashTable<K, V>::remove(const K& key)
{
    size_t index = getBucketIndex(key);
    HashNode<K, V>* current = buckets[index];
    HashNode<K, V>* previous = nullptr;
    
    // Проходимо по ланцюжку, відстежуючи попередній вузол
    while (current != nullptr)
    {
        if (current->key == key)
        {
            // Знайдено вузол для видалення
            
            if (previous == nullptr)
            {
                // Випадок 1: Видаляємо голову списку
                buckets[index] = current->next;
            }
            else
            {
                // Випадок 2: Видаляємо вузол всередині/в кінці списку
                previous->next = current->next;
            }
            
            delete current;  // Звільняємо пам'ять
            --count;         // Зменшуємо розмір таблиці
            return true;     // Успішно видалено
        }
        
        previous = current;
        current = current->next;
    }
    
    return false;  // Ключ не знайдено
}

Два випадки:

  1. Видалення голови: previous == nullptr → перенаправляємо покажчик бакета на current->next.
  2. Видалення з середини/кінця: previous->next = current->next → "перескакуємо" через видаляємий вузол.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Видалення "cat" з бакета 3

rectangle "До видалення" as before #F1F5F9 {
    rectangle "Бакет 3" as b3_before #DBEAFE
    circle "\"dog\"" as dog_before #DCFCE7
    circle "\"cat\"" as cat_before #FEF3C7
    circle "\"mouse\"" as mouse_before #E2E8F0
    b3_before -down-> dog_before
    dog_before -down-> cat_before
    cat_before -down-> mouse_before
    mouse_before -down-> " nullptr"
}

rectangle "Після видалення" as after #F1F5F9 {
    rectangle "Бакет 3" as b3_after #DBEAFE
    circle "\"dog\"" as dog_after #DCFCE7
    circle "\"mouse\"" as mouse_after #E2E8F0
    b3_after -down-> dog_after
    dog_after -down-> mouse_after
    mouse_after -down-> " nullptr"
}

note right of cat_before #FEF3C7
  previous->next = cat->next
  Вузол "cat" видаляється
  delete cat;
end note

before -[hidden]right-> after

@enduml

Складність: O(1) у середньому (λ ≈ 1), O(n) у гіршому випадку (довгий ланцюжок).


Динамічне збільшення розміру: операція rehash

Коли потрібен rehash?

Коефіцієнт завантаження λ = N / M показує середню довжину ланцюжків у бакетах. Якщо λ зростає (багато елементів на малу кількість бакетів), списки стають довгими, операції сповільнюються.

Поріг: Зазвичай обирають λ = 0.75. Коли кількість елементів перевищує 0.75 * capacity, подвоюємо кількість бакетів.

Алгоритм rehash

  1. Створити новий масив бакетів із розміром 2 * bucketCount.
  2. Пройтись по всіх старих бакетах:
    • Для кожного вузла обчислити новий індекс (хеш той же, але модуль інший: hash % (2 * M)).
    • Перемістити вузол у новий бакет.
  3. Замінити старий масив на новий.
template <typename K, typename V>
void HashTable<K, V>::rehash()
{
    size_t newCapacity = bucketCount * 2;
    std::vector<HashNode<K, V>*> newBuckets(newCapacity, nullptr);
    
    // Проходимо по всіх старих бакетах
    for (size_t i = 0; i < bucketCount; ++i)
    {
        HashNode<K, V>* current = buckets[i];
        
        // Проходимо по ланцюжку поточного бакета
        while (current != nullptr)
        {
            HashNode<K, V>* nextNode = current->next;  // Зберігаємо next (будемо змінювати)
            
            // Обчислюємо новий індекс для вузла
            size_t newIndex = hashFunction(current->key) % newCapacity;
            
            // Вставляємо вузол на початок нового бакета
            current->next = newBuckets[newIndex];
            newBuckets[newIndex] = current;
            
            current = nextNode;  // Переходимо до наступного у старому ланцюжку
        }
    }
    
    // Замінюємо старий масив на новий
    buckets = std::move(newBuckets);
    bucketCount = newCapacity;
}

Ключовий момент: Ми не створюємо нові вузли — перемішуємо існуючі. Це економить пам'ять та час (не потрібно копіювати ключі/значення).

Хеш-код ключа не змінюється (детермінованість), але індекс hash % Mзалежить від M. Коли M подвоюється, елемент може переміститися з бакета 3 у бакет 19, якщо hash % (2*M) = 19.

Складність rehash: O(N) — потрібно відвідати всі елементи. Але rehash виконується рідко (лише коли λ > 0.75), тому амортизована складність вставки залишається O(1).

Якщо вставити N елементів у таблицю, rehash викличеться O(log N) разів (подвоєння: 16 → 32 → 64 → ...). Загальна робота всіх rehash: N + N/2 + N/4 + ... ≈ 2N = O(N). Розподілена на N вставок: O(N) / N = O(1) на одну вставку.

Допоміжні методи та статистика таблиці

Базові геттери

template <typename K, typename V>
bool HashTable<K, V>::isEmpty() const
{
    return count == 0;
}

template <typename K, typename V>
size_t HashTable<K, V>::size() const
{
    return count;
}

template <typename K, typename V>
size_t HashTable<K, V>::capacity() const
{
    return bucketCount;
}

Статистика для діагностики

Для розуміння поведінки хеш-таблиці корисно виводити:

  • Кількість пустих та заповнених бакетів.
  • Максимальна довжина ланцюжка (найгірший випадок пошуку).
  • Середня довжина ланцюжка (реальна продуктивність).
template <typename K, typename V>
void HashTable<K, V>::printStatistics() const
{
    size_t emptyBuckets = 0;
    size_t maxChainLength = 0;
    size_t totalChainLength = 0;
    
    for (size_t i = 0; i < bucketCount; ++i)
    {
        size_t chainLength = 0;
        HashNode<K, V>* current = buckets[i];
        
        while (current != nullptr)
        {
            ++chainLength;
            current = current->next;
        }
        
        if (chainLength == 0)
        {
            ++emptyBuckets;
        }
        else
        {
            totalChainLength += chainLength;
            maxChainLength = std::max(maxChainLength, chainLength);
        }
    }
    
    size_t filledBuckets = bucketCount - emptyBuckets;
    double avgChainLength = (filledBuckets > 0) 
        ? static_cast<double>(totalChainLength) / filledBuckets 
        : 0.0;
    
    std::cout << "=== Статистика хеш-таблиці ===\n";
    std::cout << "Кількість елементів: " << count << '\n';
    std::cout << "Кількість бакетів: " << bucketCount << '\n';
    std::cout << "Коефіцієнт завантаження: " << loadFactor() << '\n';
    std::cout << "Пустих бакетів: " << emptyBuckets << '\n';
    std::cout << "Заповнених бакетів: " << filledBuckets << '\n';
    std::cout << "Максимальна довжина ланцюжка: " << maxChainLength << '\n';
    std::cout << "Середня довжина ланцюжка: " << avgChainLength << '\n';
}

Ідеальна статистика: Якщо хеш-функція рівномірна:

  • Середня довжина ланцюжка ≈ λ (коефіцієнт завантаження).
  • Максимальна довжина ≈ 2λ (нормальне відхилення).
  • Багато пустих бакетів (50–70%) — це нормально при λ < 1.
Якщо максимальна довжина >> середньої, це сигнал про нерівномірний розподіл. Наприклад:
  • Середня довжина: 1.2
  • Максимальна довжина: 50 ← Один бакет містить майже всі елементи!
Це означає, що хеш-функція перетворює різні ключі у один індекс. Потрібно змінити функцію або збільшити bucketCount.

Практичне використання: приклад програми

#include "HashTable.h"
#include <iostream>

int main()
{
    std::cout << "=== Демонстрація хеш-таблиці ===\n\n";
    
    // Створюємо словник англійсько-український
    HashTable<std::string, std::string> dictionary(8);  // Початкова ємність 8
    
    // Вставка елементів
    std::cout << "Додаємо слова до словника:\n";
    dictionary.insert("cat", "кіт");
    dictionary.insert("dog", "собака");
    dictionary.insert("apple", "яблуко");
    dictionary.insert("computer", "комп'ютер");
    dictionary.insert("algorithm", "алгоритм");
    dictionary.insert("data", "дані");
    dictionary.insert("structure", "структура");
    dictionary.insert("hash", "хеш");
    dictionary.insert("table", "таблиця");
    dictionary.insert("function", "функція");
    
    std::cout << "Додано " << dictionary.size() << " слів\n\n";
    
    // Пошук перекладів
    std::cout << "Пошук перекладів:\n";
    std::string words[] = {"cat", "algorithm", "zebra", "table"};
    
    for (const auto& word : words)
    {
        std::string* translation = dictionary.find(word);
        if (translation != nullptr)
        {
            std::cout << "  " << word << " → " << *translation << " ✓\n";
        }
        else
        {
            std::cout << "  " << word << " → не знайдено ✗\n";
        }
    }
    
    std::cout << '\n';
    
    // Статистика таблиці
    dictionary.printStatistics();
    std::cout << '\n';
    
    // Оновлення значення
    std::cout << "Оновлюємо переклад 'cat':\n";
    dictionary.insert("cat", "кішка");  // Перезаписує стару
    std::string* catTranslation = dictionary.find("cat");
    std::cout << "  cat → " << *catTranslation << '\n';
    std::cout << '\n';
    
    // Видалення елементів
    std::cout << "Видаляємо слова:\n";
    bool removed1 = dictionary.remove("dog");
    bool removed2 = dictionary.remove("zebra");
    std::cout << "  dog: " << (removed1 ? "видалено ✓" : "не знайдено ✗") << '\n';
    std::cout << "  zebra: " << (removed2 ? "видалено ✓" : "не знайдено ✗") << '\n';
    std::cout << "  Елементів залишилось: " << dictionary.size() << '\n';
    std::cout << '\n';
    
    // Фінальна статистика
    dictionary.printStatistics();
    
    return 0;
}
g++ -std=c++17 main.cpp -o hashtable && ./hashtable
$ ./hashtable
=== Демонстрація хеш-таблиці ===
Додаємо слова до словника:
Додано 10 слів
Пошук перекладів:
cat → кіт ✓
algorithm → алгоритм ✓
zebra → не знайдено ✗
table → таблиця ✓
=== Статистика хеш-таблиці ===
Кількість елементів: 10
Кількість бакетів: 16 (подвоїлось після λ > 0.75)
Коефіцієнт завантаження: 0.625
Пустих бакетів: 9
Заповнених бакетів: 7
Максимальна довжина ланцюжка: 2
Середня довжина ланцюжка: 1.43
Оновлюємо переклад 'cat':
cat → кішка
Видаляємо слова:
dog: видалено ✓
zebra: не знайдено ✗
Елементів залишилось: 9
=== Статистика хеш-таблиці ===
Кількість елементів: 9
Кількість бакетів: 16
Коефіцієнт завантаження: 0.5625
Пустих бакетів: 10
Заповнених бакетів: 6
Максимальна довжина ланцюжка: 2
Середня довжина ланцюжка: 1.5

Порівняння з іншими структурами даних

Структура данихВставка (середня)Пошук (середня)Видалення (середня)УпорядкованістьВитрати пам'яті
Масив (невідсортований)O(1)*O(n)O(n)❌Низькі (компактний)
Масив (відсортований)O(n)O(log n)O(n)✅Низькі
Зв'язаний списокO(1)O(n)O(n)❌Високі (покажчики)
Бінарне дерево пошукуO(log n)O(log n)O(log n)✅ (in-order обхід)Середні
Хеш-таблицяO(1)O(1)O(1)❌Високі (порожні бакети)

* Вставка у кінець масиву O(1), але вставка в довільну позицію O(n) через зсув.

Хеш-таблиця — найшвидша структура для пошуку за ключем, якщо не потрібна впорядкованість. Якщо потрібно:
  • Зберігати асоціативні дані (словник, кеш, база даних у пам'яті).
  • Перевіряти наявність елемента за O(1) (унікальність, дедуплікація).
  • Підраховувати частоту елементів (гістограма).
Не обирайте хеш-таблицю, якщо:
  • Потрібно ітерувати елементи у відсортованому порядку → використовуйте std::map (збалансоване дерево).
  • Критично економити пам'ять → хеш-таблиця витрачає багато на порожні бакети та покажчики ланцюжків.

Альтернативні методи розв'язання колізій

Відкрита адресація (Open Addressing)

Замість зв'язаних списків, всі елементи зберігаються безпосередньо у масиві бакетів. Якщо бакет зайнятий, шукаємо наступний вільний за певним алгоритмом:

  1. Лінійне пробування (linear probing): index, index+1, index+2, ...
  2. Квадратичне пробування (quadratic probing): index, index+1², index+2², index+3², ...
  3. Подвійне хешування (double hashing): використовуємо другу хеш-функцію для обчислення кроку.

Переваги: Не потрібні додаткові покажчики → кращий cache locality, менші витрати пам'яті.

Недоліки: Складніше видалення (потрібні маркери "deleted"), проблема кластеризації при високому λ.

Ідеальне хешування (Perfect Hashing)

Якщо множина ключів статична (не змінюється), можна побудувати хеш-функцію без колізій. Застосовується у компіляторах (таблиці ключових слів) та базах даних.

Cuckoo Hashing

Використовує дві хеш-функції та дві таблиці. Кожен ключ може бути у одній із двох позицій. Якщо обидві зайняті, "виштовхуємо" старий елемент у його альтернативну позицію. Гарантує O(1) пошук у гіршому випадку (не середньому!).


Практичні завдання


Обмеження та вдосконалення реалізації

Обмеження нашої навчальної версії

  1. Підтримка лише std::string: Реальна хеш-таблиця має бути параметризована типом ключа через std::hash<K> trait.
  2. Відсутність ітераторів: Не можемо обійти всі елементи у циклі for. STL-контейнери надають begin() / end().
  3. Відсутність operator[]: У std::unordered_map можна писати map["key"] = value. Потрібна підтримка вставки-якщо-не-існує.
  4. Відсутність винятків безпеки: У реальному коді потрібні гарантії безпеки (strong exception guarantee) при rehash.
  5. Фіксована хеш-функція: У продакшн-коді користувач має змогу передати власну хеш-функцію через шаблонний параметр.

Можливі вдосконалення

1. Додавання ітераторів:

Реалізувати клас Iterator, який обходить всі бакети послідовно та рухається по ланцюжках:

class Iterator
{
private:
    HashTable* table;
    size_t bucketIndex;
    HashNode<K, V>* currentNode;

public:
    Iterator& operator++();  // Перехід до наступного елемента
    std::pair<K, V>& operator*();  // Доступ до пари (ключ, значення)
    bool operator!=(const Iterator& other) const;
};

2. Підтримка move-семантики:

Додати move-конструктор та move-присвоювання для ефективного переміщення великих таблиць без копіювання всіх вузлів.

3. Налаштовувана хеш-функція:

Зробити хеш-функцію шаблонним параметром:

template <typename K, typename V, typename Hash = std::hash<K>>
class HashTable
{
    Hash hasher;  // Об'єкт хеш-функції
    
    size_t hashFunction(const K& key) const
    {
        return hasher(key);
    }
};

4. Оператор індексації []:

V& operator[](const K& key)
{
    V* value = find(key);
    if (value == nullptr)
    {
        insert(key, V());  // Вставити дефолтне значення
        value = find(key);
    }
    return *value;
}

Зв'язок зі стандартною бібліотекою: std::unordered_map

У стандартній бібліотеці C++ (STL) хеш-таблиці реалізовані як контейнери std::unordered_map та std::unordered_set (C++11):

#include <unordered_map>
#include <string>
#include <iostream>

int main()
{
    std::unordered_map<std::string, std::string> dictionary;
    
    // Вставка
    dictionary["cat"] = "кіт";
    dictionary["dog"] = "собака";
    dictionary.insert({"apple", "яблуко"});
    
    // Пошук
    if (dictionary.find("cat") != dictionary.end())
    {
        std::cout << "cat → " << dictionary["cat"] << '\n';
    }
    
    // Видалення
    dictionary.erase("dog");
    
    // Обхід всіх елементів (маємо ітератори!)
    for (const auto& [key, value] : dictionary)
    {
        std::cout << key << " → " << value << '\n';
    }
    
    return 0;
}

Відмінності від нашої реалізації:

МожливістьНаша HashTablestd::unordered_map
Типи данихФіксовані (std::string)Будь-які типи через шаблони
Ітератори❌✅ (повний STL-інтерфейс)
operator[]❌✅
Винятки безпекиБазоваСильна гарантія
Власна хеш-функція❌✅ (через std::hash<K>)
Алокатори❌✅ (кастомна пам'ять)
У реальних проєктах завжди використовуйтеstd::unordered_map замість власної реалізації, якщо немає специфічних вимог. Вона:
  • Оптимізована командою експертів.
  • Підтримує всі можливості STL (ітератори, алгоритми, allocators).
  • Має гарантії безпеки та тестована мільйонами програм.
Власна реалізація корисна для навчання або специфічних випадків (embedded-системи, кастомні хеш-функції для нестандартних типів).

Резюме

У цій лекції ми пройшли повний шлях проєктування та реалізації хеш-таблиці — однієї з найшвидших структур даних для асоціативного пошуку.

Ключові висновки:

  1. Хешування перетворює ключі у індекси — це дозволяє досягти O(1) операцій у середньому.
  2. Колізії неминучі через принцип Діріхле; метод ланцюгів розв'язує їх через зв'язані списки у бакетах.
  3. Коефіцієнт завантаження λ контролює продуктивність: коли λ > 0.75, виконуємо rehash для підтримки швидкості.
  4. Хеш-таблиця — найшвидша для пошуку, але не впорядковує елементи (на відміну від бінарного дерева).
  5. У продакшн-коді використовуйте std::unordered_map — промислова реалізація з усіма оптимізаціями.

Наступні кроки:

  • Стаття 93: Готові структури даних STL — послідовні контейнери (vector, deque, list), асоціативні (map, set), хеш-контейнери (unordered_map), адаптери (stack, queue).
  • Стаття 94: Ітератори STL, алгоритми <algorithm>, предикати та лямбди для роботи з контейнерами.
  • Стаття 95: Move-семантика та розумні покажчики — std::unique_ptr, std::shared_ptr, std::move.
Хеш-таблиця об'єднує знання з багатьох попередніх лекцій:
  • Зв'язані списки (стаття 90): ланцюжки колізій.
  • Динамічна пам'ять (стаття 19): керування вузлами через new/delete.
  • Шаблони класів (стаття 82): параметризація типів ключа та значення.
  • Глибоке копіювання (стаття 66): правильне клонування вузлів (якби додали копіювання).
  • Перевантаження операторів (стаття 64): потенційний operator[] для зручного синтаксису.
Це демонструє, як фундаментальні концепції ООП складаються у високорівневі інженерні конструкції.

Запитання для самоконтролю


Наступна лекція: Стаття 93 — Готові структури даних STL: контейнери, ітератори та алгоритми

Copyright © 2026