Что такое хеш-таблица (hash map) и как она реализована в C++?

Ответ

Хеш-таблица (ассоциативный массив) — это структура данных, обеспечивающая быстрый доступ к значению по ключу в среднем за O(1). В стандартной библиотеке C++ представлена контейнерами std::unordered_map, std::unordered_set, std::unordered_multimap, std::unordered_multiset.

Принцип работы:

  1. Хеш-функция: Ключ передается в хеш-функцию (std::hash<T> по умолчанию или пользовательская), которая возвращает числовой хеш-код.
  2. Индексирование: Хеш-код отображается на индекс в массиве «корзин» (buckets), обычно с помощью операции hash % bucket_count.
  3. Разрешение коллизий: Если разные ключи попали в одну корзину (коллизия), элементы хранятся в виде связного списка (метод цепочек) внутри этой корзины.

Пример использования std::unordered_map:

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

int main() {
    // Объявление хеш-таблицы (ключ - std::string, значение - int)
    std::unordered_map<std::string, int> phonebook;

    // Вставка элементов. Средняя сложность O(1).
    phonebook["Alice"] = 12345;
    phonebook.insert({"Bob", 67890});
    phonebook.emplace("Charlie", 11111);

    // Поиск по ключу. Средняя сложность O(1).
    auto it = phonebook.find("Alice");
    if (it != phonebook.end()) {
        std::cout << "Alice's number: " << it->second << 'n'; // 12345
    }

    // Прямой доступ через operator[]. Если ключа нет, он будет создан.
    std::cout << phonebook["Bob"]; // 67890

    // Итерация по всем элементам (порядок не гарантирован!)
    for (const auto& [name, number] : phonebook) {
        std::cout << name << ": " << number << 'n';
    }
}

Важные особенности и настройка в C++:

  • Производительность зависит от качества хеш-функции (минимизация коллизий) и коэффициента загрузки (load factor).
  • Коэффициент загрузки — это size() / bucket_count(). При превышении max_load_factor() происходит рехеширование — увеличение числа корзин и перераспределение всех элементов, что операция дорогая O(n).
  • Пользовательская хеш-функция и компаратор:
    struct MyKey { std::string a, b; };
    struct MyKeyHash {
        std::size_t operator()(const MyKey& k) const {
            return std::hash<std::string>()(k.a) ^
                   (std::hash<std::string>()(k.b) << 1);
        }
    };
    struct MyKeyEqual {
        bool operator()(const MyKey& lhs, const MyKey& rhs) const {
            return lhs.a == rhs.a && lhs.b == rhs.b;
        }
    };
    std::unordered_map<MyKey, int, MyKeyHash, MyKeyEqual> my_map;
  • В худшем случае (все ключи попали в одну корзину) сложность деградирует до O(n).

Ответ 18+ 🔞

А, хеш-таблица! Ну это ж, ебать мои старые костыли, классика жанра. Представь себе огромный шкаф с кучей ящиков, и ты по названию на бумажке моментально находишь нужный ящик и хватаешь оттуда носки. Вот это она и есть — ассоциативный массив, где ключ (название) ведёт тебя прямиком к значению (носкам). В плюсах это std::unordered_map и его братва.

Как это, блядь, работает, если по-простому:

  1. Хеш-функция: Ты суёшь туда ключ — строку, число, свою структуру — а она тебе выплёвывает число, типа номер ящика. Стандартная функция std::hash обычно справляется, но если она кривая — будет пиздец и коллизии.
  2. Индексирование: Этот номер ящика (хеш) потом прикидывают, сколько вообще ящиков есть, и получают итоговый индекс. Грубо говоря, хеш % количество_ящиков.
  3. Коллизии (ЁПРСТ): А вот если два разных ключа, например, "Алиса" и "Боб", получили один и тот же номер ящика? Вот это пизда, коллизия. Так вот, в каждой корзине (ящике) тогда висит просто связный список, куда пихают все элементы, которые в этот ящик попали. И когда ищешь, то сначала находишь ящик за O(1), а потом в этом списке внутри ящика уже линейно ищешь нужный ключ. Поэтому если хеш-функция — говно, и всё попало в одну корзину, то твоя O(1) превращается в O(n), и ты сидишь и плачешь.

Пример, чтобы вообще всё стало ясно:

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

int main() {
    // Объявляем нашу телефонную книгу. Ключ - имя (строка), значение - номер.
    std::unordered_map<std::string, int> phonebook;

    // Запихиваем контакты. В среднем - быстро, O(1).
    phonebook["Alice"] = 12345; // Просто через квадратные скобки
    phonebook.insert({"Bob", 67890}); // Через insert
    phonebook.emplace("Charlie", 11111); // Через emplace (эффективнее)

    // Ищем Алису. find() возвращает итератор.
    auto it = phonebook.find("Alice");
    if (it != phonebook.end()) { // Если нашли, а не упёрлись в конец
        std::cout << "Номер Алисы: " << it->second << 'n'; // 12345
    }

    // Можно и так, через operator[]. Но осторожно: если ключа нет, он его СОЗДАСТ со значением по умолчанию (для int это 0)!
    std::cout << phonebook["Bob"]; // 67890

    // Пройтись по всем. Порядок — хуй пойми какой, он не гарантирован!
    for (const auto& [name, number] : phonebook) {
        std::cout << name << ": " << number << 'n';
    }
}

Важные фишки, на которых можно обжечься:

  • Производительность — это священная корова. Зависит от двух вещей: чтобы хеш-функция разбрасывала ключи равномерно (минимум коллизий), и от коэффициента загрузки.
  • Коэффициент загрузки — это, грубо говоря, насколько ящики заполнены. Считается как размер / количество_корзин. Когда он превышает max_load_factor() (по умолчанию 1.0), происходит рехеширование — ёперный театр, создаётся новый массив корзин побольше, и ВСЕ элементы заново перераспределяются. Операция дорогая, O(n). Поэтому если ты заранее знаешь, сколько элементов примерно будет, позови reserve().
  • Своя хеш-функция и сравнение. Допустим, ключ — твоя структура. Надо объяснить компилятору, как её хешировать и сравнивать.
    struct MyKey { std::string a, b; };
    // Твоя хеш-функция. Должна возвращать size_t и быть const.
    struct MyKeyHash {
        std::size_t operator()(const MyKey& k) const {
            // Комбинируем хеши от полей. XOR — частое, но не идеальное решение.
            return std::hash<std::string>()(k.a) ^
                   (std::hash<std::string>()(k.b) << 1);
        }
    };
    // Функция сравнения на равенство (нужна на случай коллизий).
    struct MyKeyEqual {
        bool operator()(const MyKey& lhs, const MyKey& rhs) const {
            return lhs.a == rhs.a && lhs.b == rhs.b;
        }
    };
    // Объявляем мапу с нашими кастомными штуками.
    std::unordered_map<MyKey, int, MyKeyHash, MyKeyEqual> my_map;
  • Худший случай — это когда твоя хеш-функция — сосалка, и все ключи летят в одну корзину. Тогда поиск деградирует до O(n), и твоя красивая хеш-таблица превращается в fucking связный список. Доверия ебать ноль к такой функции.