简易STL实现 | Unordered_map 的实现

1、特性

1、 键值对存储
unordered_map 存储的元素是 一组键值对,其中 每个键都是唯一的,并且 每个键映射到一个值。这使得 通过键 快速检索值成为可能

2、 哈希表实现
底层 通过哈希表实现,意味着 它使用哈希函数 将键转换为索引,这些索引 将用于在内部数组中找到值的位置

3、 无序
与 std::map 不同,unordered_map 中的元素是无序的。元素的顺序 取决于 哈希函数和元素的添加顺序,而不是 元素的键值

4、 自定义哈希函数和相等函数
用户可以提供 自定义的哈希函数 和 键相等性判断函数,以适应特殊的需求

使用 std::pair<int, int> 作为 unordered_map 的键,并希望 根据特殊的哈希规则 对键进行管理

自定义键相等性判断函数 的目的是 为特定的数据类型 提供一种自定义的比较规则,确保 在使用哈希容器(如 std::unordered_map 或 std::unordered_set)时,能够 根据特定需求 定义键的“相等性”标准
虽然标准库 默认提供了比较规则(如对于基本类型使用 ==),但在 以下几种情况下,自定义键相等性判断函数 可能是必要的或有益的:

  1. 复杂数据结构
    使用一个结构体 Person 作为 unordered_map 的键,而相等性判断 仅基于 Person 的姓名而非其他字段
struct Person {
    
    
    std::string name;
    int age;
    bool operator==(const Person& other) const {
    
    
        return name == other.name; // 忽略 age,仅比较 name
    }
};

// 哈希函数
struct PersonHash {
    
    
    std::size_t operator()(const Person& p) const {
    
    
        return std::hash<std::string>{
    
    }(p.name); // 仅基于 name 进行哈希计算
    }
};

// 在 unordered_map 中使用自定义的相等性判断
std::unordered_map<Person, std::string, PersonHash> people;

  1. 非标准的比较规则
    默认的 == 运算符 无法满足需求,因为 需要对键有不同的相等性标准。例如,两个字符串 可以被认为相等,尽管它们的大小写不同
struct CaseInsensitiveEqual {
    
    
    bool operator()(const std::string& a, const std::string& b) const {
    
    
        return strcasecmp(a.c_str(), b.c_str()) == 0; // 忽略大小写
    }
};

// 哈希函数
struct CaseInsensitiveHash {
    
    
    std::size_t operator()(const std::string& s) const {
    
    
        std::size_t hash = 0;
        for (char c : s) {
    
    
            hash ^= std::hash<char>{
    
    }(tolower(c)); // 忽略大小写
        }
        return hash;
    }
};

// 使用忽略大小写的自定义哈希和相等性比较函数
std::unordered_map<std::string, int, CaseInsensitiveHash, CaseInsensitiveEqual> myMap;

1)a.c_str():将 std::string 转换为 C 风格的字符串(即 const char* 类型)

2)strcasecmp 是 C 标准库中的一个函数,用于忽略大小写比较两个 C 风格的字符串。它的声明通常在 <cstring><strings.h> 头文件中

int strcasecmp(const char *s1, const char *s2);

strcasecmp 逐个比较字符串 s1 和 s2 中的字符。如果两个字符串相同(忽略大小写),它返回 0;如果它们不同,则返回非零值(正值或负值,具体值 取决于 字符串的字典序关系)

#include <iostream>
#include <unordered_map>
#include <utility>

// 自定义哈希函数
struct PairHash {
    
    
    template <typename T1, typename T2>
    std::size_t operator()(const std::pair<T1, T2>& p) const {
    
    
        // 将两个值的哈希值混合
        return std::hash<T1>{
    
    }(p.first) ^ (std::hash<T2>{
    
    }(p.second) << 1);
    }
};

// 自定义键相等性判断函数
struct PairEqual {
    
    
    bool operator()(const std::pair<int, int>& p1, const std::pair<int, int>& p2) const {
    
    
        // 根据需要定义相等性,比如可以是简单的比较
        return p1.first == p2.first && p1.second == p2.second;
    }
};

int main() {
    
    
    // 定义一个以 std::pair<int, int> 为键的 unordered_map,使用自定义的哈希和相等性判断函数
    std::unordered_map<std::pair<int, int>, std::string, PairHash, PairEqual> myMap;

    // 插入键值对
    myMap[{
    
    1, 2}] = "Pair 1";
    myMap[{
    
    3, 4}] = "Pair 2";

    // 访问并打印值
    std::cout << "Value for key (1, 2): " << myMap[{
    
    1, 2}] << std::endl;
    std::cout << "Value for key (3, 4): " << myMap[{
    
    3, 4}] << std::endl;

    return 0;
}

1)std::hash<T1>{}(p.first)
std::hash<T1> 是标准库提供的哈希函数模板,它为类型 T1 生成哈希值,std::hash<T1>{} 创建一个 std::hash<T1> 的实例,然后对 p.first 调用该实例 来获取 p.first 的哈希值

2)(std::hash<T2>{}(p.second) << 1)
对 p.second 的哈希值 进行位移操作,将其 向左移动一位(相当于乘以 2)
这种位移操作的目的是 为了避免两个哈希值简单相加时 冲突。如果 没有位移,某些情况下 不同的 p.first 和 p.second 可能会产生相同的总哈希值。位移操作 有效地将两个值在哈希空间中分开,减少 哈希冲突的概率(意味着第二个哈希值的所有位都向左移动了一位,腾出最低位,并且使得它与 p.first 的哈希值区别更大)

假设 p1 = {10, 20} 和 p2 = {20, 10},它们的哈希值相加 可能会产生相同的结果,因为 hash(10) + hash(20) 与 hash(20) + hash(10) 相同

位移的作用总结:
分散哈希值:位移操作 将两个独立哈希值混合得更为复杂,确保不同的键 能生成不同的哈希值
减少对称性冲突:通过 对一个值进行位移,可以避免类似 {10, 20} 和 {20, 10} 的键产生相同的哈希值,从而减少冲突
增强哈希随机性:位移操作 将二进制表示的不同部分移入高位,增强了 哈希值的随机性,使得哈希表中键值对的分布 更加均匀

3)异或运算符 ^ 用于将两个哈希值合并
异或 是一种常见的哈希值组合方法,它具有对称性 且 可以有效混合两个哈希值
异或的特点是,如果 两个输入位相同,则结果为 0;如果不同,结果为 1。这使得生成的哈希值 更具随机性,降低哈希冲突的概率

2、性能

访问性能 在理想情况下,访问元素(增加、删除、查找)的时间复杂度为 O(1)。但是,如果 发生哈希冲突,这些操作的性能可能会降至 O(n),其中 n 是同一个哈希桶中元素的数量

unordered_map 通过 链表(或其他数据结构,具体取决于实现)解决哈希冲突,当多个元素 被映射到同一个哈希桶时,它们将被存储在一个链表中

加载因子是 存储在哈希表中的元素数量 与哈希桶数量的比率。当加载因子 超过某个阈值时(通常为1),unordered_map 将进行重哈希操作,以增加哈希桶的数量 并重新分配元素,以保持操作的高效性

3、标准库的基本用法

#include <unordered_map>

int main() {
    
    
    // 创建一个unordered_map,键类型为std::string,值类型为int
    std::unordered_map<std::string, int> wordCount;

    // 插入元素
    wordCount["apple"] = 1; // 方法1:使用下标运算符插入或修改元素
    wordCount.insert({
    
    "banana", 2}); // 方法2:使用insert函数插入元素
    wordCount.emplace("cherry", 3); // 方法3:使用emplace直接构造元素,效率更高

    // 访问元素
    std::cout << "apple count: " << wordCount["apple"] << std::endl; // 使用下标运算符访问元素
    std::cout << "banana count: " << wordCount.at("banana") << std::endl; // 使用at方法访问元素,如果键不存在,会抛出std::out_of_range异常

    // 检查元素是否存在
    if (wordCount.find("cherry") != wordCount.end()) {
    
    
        std::cout << "cherry found" << std::endl;
    }

    // 更新元素
    wordCount["apple"] = 5; // 更新apple的计数

    // 删除元素
    wordCount.erase("banana"); // 删除键为"banana"的元素

    // 遍历unordered_map
    for (const auto& pair : wordCount) {
    
    
        std::cout << pair.first << ": " << pair.second << std::endl;
    }

    // 获取unordered_map的大小
    std::cout << "Size of unordered_map: " << wordCount.size() << std::endl;

    return 0;
}

使用下标运算符 (operator[]) 和 at() 方法访问元素 有一些关键的不同点,主要体现在 处理不存在的键 和 性能方面

1)下标运算符 (operator[]):

std::cout << "apple count: " << wordCount["apple"] << std::endl;

行为:
如果指定的键 存在,下标运算符 返回与该键相关联的值
如果指定的键 不存在,下标运算符 会自动插入该键,并将该键的值 初始化为默认值(对于 int 类型是 0),然后返回该值

性能:
下标运算符 可能会导致插入操作,因此 如果键不存在,会导致额外的开销
因为 有可能插入新的键值对,它不适合在 只想读取元素且不希望改变 unordered_map 内容的场景下使用

2)at() 方法:

std::cout << "banana count: " << wordCount.at("banana") << std::endl;

行为:
如果指定的键 存在,at() 返回与该键相关联的值
如果指定的键 不存在,at() 会抛出 std::out_of_range 异常,表示该键不在容器中

性能:
at() 方法 不会进行插入操作,因此 性能更好 用于只读取已有元素的场景,特别是 当希望确保容器内容不被修改时
适合在 确定键一定存在,或者 愿意捕获异常 以处理键不存在的情况时使用

try {
    
    
    std::cout << wordCount.at("grape") << std::endl; // 如果 "grape" 不存在,抛出异常
} catch (const std::out_of_range& e) {
    
    
    std::cout << "Key not found" << std::endl;
}

4、实现

#include <algorithm> // 不加find就会出问题
#include <iostream>
#include <sstream>
#include <functional>
#include <list>
#include <cstddef>
#include <utility>
#include <vector>
#include <string>

template <typename Key, typename Value, typename Hash = std::hash<Key>> // 创建了一个 std::hash<Key> 类型的临时对象
// std::hash<Key> 是标准库中为基本类型和部分标准库类型(如 int, std::string 等)定义的默认哈希函数对象
// 它接受一个 Key 类型的值,并返回一个 std::size_t 类型的哈希值
// std::hash<Key> 是一个模板类,提供了一个用于生成哈希值的仿函数。标准库为许多常见类型(如 int、std::string、char 等)提供了专门化版本的 std::hash
// 这个仿函数的主要目的是将一个 Key 类型的值转化为 std::size_t 类型的哈希值,这个哈希值通常用在哈希表(如 std::unordered_map)中
// 它的主要作用是将 Key 类型的对象映射为一个哈希值。具体来说,std::hash<Key>() 构造出的对象本身并没有返回值,但你可以通过调用该对象的 operator() 来生成哈希值
// std::hash<int> hash_fn;
// std::size_t hash_value = hash_fn(42); // 计算整数 42 的哈希值
// 或
// std::size_t hash_value = std::hash<int>()(42);
class HashTable {
    
    

    class HashNode {
    
    
    public:
        Key key;
        Value value;
        // 直接调用默认构造函数 value() explicit HashNode(const Key& key_) : key(key_), value() {}
        explicit HashNode(const Key& key_) : key(key_), value(Value{
    
    }) {
    
    }
        HashNode(const Key& key_, const Value& value_) : key(key_), value(value_) {
    
    }

        bool operator==(const HashNode& hn) const {
    
     return key == hn.key; }
        bool operator!=(const HashNode& hn) const {
    
     return key != hn.key; }
        bool operator<(const HashNode& hn) const {
    
     return key < hn.key; }
        bool operator>(const HashNode& hn) const {
    
     return key > hn.key; }
        bool operator==(const Key& key_) const {
    
     return key == key_; }
        // std::find(bucket.begin(), bucket.end(), key) == bucket.end()需要使用bool operator==(const Key& key_)

        void print() const {
    
    
            std::cout << key << " " << value << " ";
        }
    };

private:
    using Bucket = std::list<HashNode>;
    std::vector<Bucket> buckets;
    Hash hashFunction;
    size_t numBucket;
    size_t numNode;

    double maxLoadFactor = 0.75; // 默认的最大负载因子

    size_t hash(const Key& key) {
    
    
        return hashFunction(key) % numBucket;
    }

    void reHash(size_t newSize) {
    
    
        std::vector<Bucket> newBuckets(newSize);
        for (Bucket& bucket : buckets) {
    
    
            for (HashNode& hn : bucket) {
    
     // 链表也可以遍历
                size_t i = hashFunction(hn.key) % newSize; // hashFunction(hn.key)
                newBuckets[i].push_back(hn); // 直接push_back(hn)
            }
        }
        buckets = std::move(newBuckets);
        numBucket = newSize;
    }

public:
    HashTable(size_t numBucket_ = 10, Hash hashFunction_ = Hash()) // 临时变量不能使用引用
        : buckets(numBucket_), hashFunction(hashFunction_), numBucket(numBucket_), numNode(0) {
    
    }
    // buckets(numBucket_)直接调用buckets(numBucket_)构造函数初始化

    void insert(const Key& key, const Value& value) {
    
    
        // if ((numNode + 1) / numBucket > maxLoadFactor) { 不行,如果numBucket为0就会数组越界
        if ((numNode + 1) > maxLoadFactor * numBucket) {
    
    
            reHash(numBucket == 0 ? 1 : 2 * numBucket);
        }
        size_t i = hash(key);
        if (std::find(buckets[i].begin(), buckets[i].end(), key) == buckets[i].end()) {
    
    // 前提键要表里没有,不是值
            buckets[i].push_back(HashNode(key, value));
            numNode++;
        }
    }

    void erase(const Key& key) {
    
    
        // 移除链表中全部元素
        size_t i = hash(key);
        // 迭代器不能使用引用赋值的原因主要与迭代器的本质和行为特性有关
        // 迭代器是一个对象或指针,可以通过迭代器的操作符来遍历容器。迭代器的实现可以是指针(如原生数组的迭代器)或更复杂的类类型(如 std::vector、std::list 等 STL 容器的迭代器)。当迭代器指向不同的元素时,它的内部状态会改变
        // 引用本质上是一个别名,不能被重新赋值以指向不同的对象
        auto it = std::find(buckets[i].begin(), buckets[i].end(), key);
        if (it != buckets[i].end()) {
    
    
            // 一个链表上可能有多个不同的key,一个key只有唯一一个表中元素
            buckets[i].erase(it); // 别忘了加bucket[i].
            numNode--;
        }
    }

    // 查找键是否存在于哈希表中,返回指向值的指针
    Value* find(const Key& key) {
    
    
        size_t i = hash(key);
        auto it = std::find(buckets[i].begin(), buckets[i].end(), key);
        if (it == buckets[i].end())
            return nullptr;
        else
            return &it->value;
    }

    size_t getNum() const {
    
    
        return numNode;
    }

    void print() {
    
    
        for (Bucket& bucket : buckets) {
    
    
            for (HashNode& hn : bucket) {
    
    
                hn.print();
            }
        }
        if (numNode == 0) {
    
    
            std::cout << "empty";
        }
        std::cout << std::endl;
    }

    void clear() {
    
    
        buckets.clear(); // vector.clear()
        numNode = 0;
        numBucket = 0;
    }
};

// Unordered_map
template<typename Key, typename Value> class Unordered_map {
    
    
private:
    HashTable<Key, Value> ht; // 别忘了template参数

public:
    Unordered_map() :ht() {
    
    }

    ~Unordered_map() {
    
    }

    bool empty() const {
    
     return ht.getNum() == 0; }

    size_t size() const {
    
     return ht.getNum(); }

    void clear() {
    
     ht.clear(); }

    void insert(const Key& key, const Value& value) {
    
    
        ht.insert(key, value);
    }

    void erase(const Key& key) {
    
    
        ht.erase(key);
    }

    bool find(const Key& key) {
    
    
        return ht.find(key) != nullptr;
    }

    Value& operator[](const Key& key) {
    
    
        Value* vpos = find(key);
        if (vpos != nullptr) {
    
    
            return *vpos;
        }
        ht.insert(key, Value()); // 值就插入默认构造函数的值
        vpos = ht.find(key);
        return *vpos;
    }
};

int main() {
    
    
    Unordered_map<int, int> map;

    int N;
    std::cin >> N;
    getchar();

    std::string line;

    for (int i = 0; i < N; i++) {
    
    
        std::getline(std::cin, line);
        std::istringstream iss(line);
        std::string command;
        iss >> command;

        int key;
        int value;

        if (command == "insert") {
    
    
            iss >> key >> value;
            map.insert(key, value);
        }

        if (command == "erase") {
    
    
            iss >> key;
            map.erase(key);
        }

        if (command == "find") {
    
    
            iss >> key;
            if (map.find(key)) {
    
    
                std::cout << "true" << std::endl;
            }
            else {
    
    
                std::cout << "false" << std::endl;
            }
        }

        // size 命令
        if (command == "size") {
    
    
            std::cout << map.size() << std::endl;
        }

        // empty 命令
        if (command == "empty") {
    
    
            if (map.empty()) {
    
    
                std::cout << "true" << std::endl;
            }
            else {
    
    
                std::cout << "false" << std::endl;
            }
        }
    }
    return 0;
}

5、与标准库之间的区别

异常安全性:std::unordered_map 会确保在异常发生时不破坏现有数据

迭代器支持:std::unordered_map 提供了正向和常量迭代器,使得用户 可以遍历容器中的所有元素

方法的完整性和命名:std::unordered_map 提供了 一系列完整的成员函数,包括 emplace, emplace_hint, find, equal_range, bucket, load_factor, rehash 等

内存管理:std::unordered_map 使用了 高度优化的内存分配器来管理内存

模板参数:
std::unordered_map 允许用户指定哈希函数和键比较函数

拷贝控制操作:
std::unordered_map 定义了 拷贝构造函数、移动构造函数、拷贝赋值运算符 和 移动赋值运算符,以及 析构函数

6、常见面试题

1、unordered_map 和 map 的区别是什么
底层实现:unordered_map底层通过哈希表实现,而 map 通常 通过红黑树(一种自平衡二叉查找树)实现

性能:对于键的插入、删除和查找操作,unordered_map 通常提供平均 O(1) 时间复杂度,而 map 提供 O(log n) 的时间复杂度

顺序:unordered_map 中的元素是无序的,而 map 中的元素是 根据键排序的

键类型限制:在 unordered_map 中使用的键 需要提供 哈希函数和相等性判断函数,而在 map 中键只需要能够进行小于比较

2、unordered_map处理哈希冲突的一种常见方法是链地址法

3、unordered_map的性能瓶颈 主要在于 哈希函数的质量 和 哈希冲突的处理
如果 哈希函数 不能将键均匀分布到不同的桶中,会导致 某些桶过于拥挤,增加查找、插入 和 删除操作的时间复杂度
此外,当 unordered_map 的加载因子(元素数量与桶数量的比率)变得太高时,性能也会下降,这时 可能需要进行 重哈希操作 来增加桶的数量,分散元素,但 重哈希是一个成本较高的操作

优化unordered_map的性能:
使用 高质量的哈希函数:确保哈希函数 可以均匀地分布键,减少哈希冲突
调整加载因子:通过 调整加载因子的阈值,可以 控制重哈希的时机。较低的加载因子 会减少冲突但增加内存使用,而较高的加载因子 可能会增加冲突
预留空间:如果事先知道 将要存储的元素数量,可以使用 reserve 方法预留足够的空间,减少 重哈希的次数

https://kamacoder.com/ 手写简单版本STL,内容在此基础上整理补充