【C++】用哈希表封装自己的 unordered_map 和 unordered_set

手写哈希表不是最终目的,我们的目标是像真正的 STL 那样,让 unordered_setunordered_map 都能复用同一套哈希表代码

1. 站在巨人的肩膀上:SGI STL 源码框架分析

观察 SGI STL(虽然不是 C++ 标准的一部分,但设计思路极具参考价值)的源码,会发现其 hash_sethash_map 的结构出奇地简洁。它们内部都只持有一个 hashtable 对象,所有操作都委托给这个对象完成。

cpp

复制代码
// 简化后的 SGI STL 框架

// hash_set 内部
template <class Value, ...>
class hash_set {
private:
    // 关键:hashtable 的模板参数
    typedef hashtable<Value, Value, HashFcn, identity<Value>, EqualKey, Alloc> ht;
    ht rep; // 底层就是一个哈希表对象
    // ... 所有接口都调用 rep 对应的方法
};

// hash_map 内部
template <class Key, class T, ...>
class hash_map {
private:
    // 关键:hashtable 的模板参数
    typedef hashtable<pair<const Key, T>, Key, HashFcn, select1st<pair<const Key, T>>, EqualKey, Alloc> ht;
    ht rep; // 底层也是一个哈希表对象
    // ... 所有接口都调用 rep 对应的方法
};

观察它们的模板参数,核心区别在于两点:

  1. 数据类型 T 不同hash_setT 就是 Value(也就是 Key 本身);而 hash_mapTpair<const Key, T>

  2. 提取 Key 的仿函数不同 :由于 hash_map 的数据类型是键值对,哈希表在计算哈希值或做比较时,只关心 pair 中的 first(即键)。因此,它需要额外传入一个函数对象,用来从 T 中提取出 Key。这个函数对象在 hash_set 中是 identity<Value>(直接返回自身),在 hash_map 中是 select1st<pair<...>>(返回 pair 的第一个成员)。

这个设计非常巧妙,它让同一套哈希表代码,通过调整这两个参数,就适配了两种完全不同的容器。

2. 动手实践:搭建兼容 setmap 的通用哈希表框架

借鉴上述思路,我们开始改造自己的 HashTable

首先,在 unordered_setunordered_map 这一层,分别定义好从数据类型中提取 Key 的仿函数 ,并作为模板参数传给 HashTable

cpp

复制代码
// MyUnorderedSet.h
namespace yyq {
    template<class K, class Hash = HashFunc<K>>
    class unordered_set {
        // 对于 set,数据类型就是 Key 本身,直接返回即可
        struct SetKeyOfT {
            const K& operator()(const K& key) { return key; }
        };
    public:
        bool insert(const K& key) { return _ht.Insert(key); }
    private:
        // 注意第二个模板参数:T 就是 K
        hash_bucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
    };
}

// MyUnorderedMap.h
namespace yyq {
    template<class K, class V, class Hash = HashFunc<K>>
    class unordered_map {
        // 对于 map,数据类型是 pair,需要返回其 first 成员
        struct MapKeyOfT {
            const K& operator()(const pair<K, V>& kv) { return kv.first; }
        };
    public:
        bool insert(const pair<K, V>& kv) { return _ht.Insert(kv); }
    private:
        // 注意第二个模板参数:T 是 pair<K, V>
        hash_bucket::HashTable<K, pair<K, V>, MapKeyOfT, Hash> _ht;
    };
}

接下来,改造底层的 HashTable。它现在的模板参数变成了 K(键类型)、T(实际存储的数据类型)、KeyOfT(提取 Key 的仿函数类型)和 Hash(哈希函数)。

cpp

复制代码
// HashTable.h
namespace hash_bucket {
    template<class T>
    struct HashNode {
        T _data;          // 存储的数据,可能是 K,也可能是 pair<K, V>
        HashNode<T>* _next;
        HashNode(const T& data) : _data(data), _next(nullptr) {}
    };

    template<class K, class T, class KeyOfT, class Hash>
    class HashTable {
        typedef HashNode<T> Node;
        // ...
        bool Insert(const T& data) {
            KeyOfT kot; // 用仿函数从 data 中提取出 Key
            // ...
            if (Find(kot(data))) return false; // 查重时,用 kot 获取 Key 比较
            
            Hash hs;
            // 用 kot 获取 Key 来计算哈希值
            size_t hashi = hs(kot(data)) % _tables.size();
            // ... 扩容、头插等操作
        }
        Node* Find(const K& key) {
            KeyOfT kot;
            Hash hs;
            size_t hashi = hs(key) % _tables.size();
            Node* cur = _tables[hashi];
            while (cur) {
                // 比较时,用 kot 提取出当前节点数据的 Key,再与目标 key 比较
                if (kot(cur->_data) == key) return cur;
                cur = cur->_next;
            }
            return nullptr;
        }
        // ...
    };
}

到这里,我们就成功搭建起了一个能让 setmap 完美复用的哈希表框架。

3. 最关键的挑战:迭代器的实现

要让我们的容器支持范围for循环和各种算法,就必须实现迭代器。哈希表的迭代器是单向迭代器 ,只支持 ++ 操作。实现它的难点在于 operator++ 的逻辑:你需要在一个桶的链表走完后,能自动跳转到下一个非空的桶。

为此,迭代器内部需要"知道"两个东西:

  • _node:一个指向当前链表节点的指针。

  • _pht:一个指向所属哈希表对象的指针。这样一来,当它需要寻找下一个桶时,就能通过这个指针访问到整个桶数组。

cpp

复制代码
// 前置声明,因为迭代器要用到 HashTable,而 HashTable 也要用它
template<class K, class T, class KeyOfT, class Hash>
class HashTable;

template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
struct HTIterator {
    typedef HashNode<T> Node;
    typedef HTIterator<K, T, Ptr, Ref, KeyOfT, Hash> Self;

    Node* _node;                                      // 当前节点
    const HashTable<K, T, KeyOfT, Hash>* _pht;        // 指向哈希表的指针

    HTIterator(Node* node, const HashTable<K, T, KeyOfT, Hash>* pht) 
        : _node(node), _pht(pht) {}

    Ref operator*()  { return _node->_data; }
    Ptr operator->() { return &_node->_data; }

    bool operator!=(const Self& s) const { return _node != s._node; }

    // 核心:++ 操作的实现
    Self& operator++() {
        if (_node->_next) {
            // 情况1:当前桶的链表还有下一个节点,直接走
            _node = _node->_next;
        } else {
            // 情况2:当前桶已经走完,需要寻找下一个非空的桶
            KeyOfT kot;
            Hash hs;
            // 1. 计算当前节点数据所属的桶索引
            size_t hashi = hs(kot(_node->_data)) % _pht->_tables.size();
            ++hashi; // 从下一个桶开始找
            while (hashi < _pht->_tables.size()) {
                if (_pht->_tables[hashi]) {
                    // 找到了,让_node指向这个桶的第一个节点
                    _node = _pht->_tables[hashi];
                    return *this;
                }
                ++hashi;
            }
            // 3. 所有桶都遍历完了,置为nullptr,等于 end()
            _node = nullptr;
        }
        return *this;
    }
};
为什么可以这样设计(友元和内部类访问权限)?

可能你会注意到,迭代器 HTIterator 内部直接访问了 _pht->_tables,而 _tablesHashTable 的私有成员。这是如何做到的?

  • C++ 中的"内部类"是"朋友" :在 C++ 中,一个类内部定义的类(或结构体)被称为嵌套类,它天生就能访问外部类的 私有(private 成员。这不是单方向的,嵌套类自己本身也隐式地是外部类的一个"友元"。

  • 反过来呢? 如果 HTIterator 是独立于 HashTable 外部定义的(就像我们代码中这样,仅仅为了代码组织清晰而分开),那么 HashTable 类就必须显式地将 HTIterator 声明为 friend,才能授予它访问私有成员的权利。

这种"迭代器持有容器指针,并访问其私有成员"的模式是 C++ 中非常经典的实现方式,兼顾了效率和封装性。

HashTable 内部需要做相应调整,提供 Begin()End() 方法,并正确定义迭代器类型:

cpp

复制代码
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
    // 为了让外部定义的迭代器能访问私有成员 _tables,声明为友元
    template<class, class, class, class, class, class>
    friend struct HTIterator;
    typedef HashNode<T> Node;
public:
    // 普通迭代器
    typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
    // const 迭代器
    typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;

    Iterator Begin() {
        // 找到第一个非空桶的第一个节点
        for (size_t i = 0; i < _tables.size(); ++i) {
            if (_tables[i]) return Iterator(_tables[i], this);
        }
        return End();
    }
    // End() 返回一个指向空节点的迭代器
    Iterator End() { return Iterator(nullptr, this); }
    
    // ConstBegin() 和 ConstEnd() 类似,返回的是 ConstIterator
    ConstIterator Begin() const { 
        // 需要返回 ConstIterator
        for (size_t i = 0; i < _tables.size(); ++i) {
            if (_tables[i]) return ConstIterator(_tables[i], this);
        }
        return End();
    }
    ConstIterator End() const { return ConstIterator(nullptr, this); }

    // …(其他成员不变)
};

unordered_setunordered_map 中,将 insert 等接口的返回值也修改为迭代器类型,这样就完美地支持了范围for循环。特别地,为了让用户不能通过迭代器修改 unordered_set 的元素和 unordered_map 的键,我们在实例化 HashTable 时会使用 const K 等带 const 限制的模板参数,从类型系统层面加以禁止。

4. 锦上添花:实现 operator[]

operator[]unordered_map 的特色功能。它的实现完全建立在 Insert 基础上:

cpp

复制代码
// MyUnorderedMap.h
V& operator[](const K& key) {
    // insert 如果成功,返回 {指向新节点的迭代器, true}
    // 如果失败(key已存在),返回 {指向已存在节点的迭代器, false}
    pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
    // 无论成功与否,ret.first 都指向正确的节点
    return ret.first->second;
}

这简洁地实现了"如果键不存在就插入一个默认值,最终返回该键对应值的引用"的语义。

5. 完整代码总览

将上述所有部分组合起来,就构成了我们的完整实现。下面的代码通过 HashTable 的泛型设计,让 unordered_setunordered_map 成为了极其轻量的"适配器"。理解透这个案例,你会对 C++ 的泛型编程和设计模式有全新的认识。

cpp

复制代码
// HashTable.h
namespace hash_bucket {
    // ... (HashNode, HTIterator 的实现见上)
    
    template<class K, class T, class KeyOfT, class Hash>
    class HashTable {
        // 友元声明...
    public:
        typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
        typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;

        Iterator Begin() { /* ... */ }
        Iterator End()   { return Iterator(nullptr, this); }
        ConstIterator Begin() const { /* ... */ }
        ConstIterator End()   const { return ConstIterator(nullptr, this); }

        pair<Iterator, bool> Insert(const T& data) {
            KeyOfT kot;
            Iterator it = Find(kot(data));
            if (it != End()) return make_pair(it, false);
            // ... (扩容和插入逻辑)
            return make_pair(Iterator(newnode, this), true);
        }
        Iterator Find(const K& key) { /* ... */ }
        bool Erase(const K& key) { /* ... */ }
    private:
        vector<Node*> _tables;
        size_t _n = 0;
    };
}

// MyUnorderedSet.h
namespace yyq {
    template<class K, class Hash = HashFunc<K>>
    class unordered_set {
        struct SetKeyOfT { const K& operator()(const K& key) { return key; } };
    public:
        typedef typename hash_bucket::HashTable<K, const K, SetKeyOfT, Hash>::Iterator iterator;
        // ... (begin, end, insert, find... 等直接委托给 _ht)
    private:
        hash_bucket::HashTable<K, const K, SetKeyOfT, Hash> _ht;
    };
}

// MyUnorderedMap.h
namespace yyq {
    template<class K, class V, class Hash = HashFunc<K>>
    class unordered_map {
        struct MapKeyOfT { const K& operator()(const pair<K, V>& kv) { return kv.first; } };
    public:
        typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::Iterator iterator;
        V& operator[](const K& key) { /* 封装 insert */ }
        // ... (begin, end, insert, find... 等直接委托给 _ht)
    private:
        hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
    };
}

我们通过"提取 Key"的仿函数这个巧妙的设计,优雅地复用了哈希表代码,并完整实现了迭代器和 operator[]。能把这一套走下来,你对 C++ 的理解就已经超过大多数人了。

相关推荐
莫生灬灬1 小时前
NewEmoji 93个组件演示,支持emoji,支持易语言/火山/C#/Python
开发语言·python·c#
bqq198610261 小时前
非关系型数据库概述
数据结构·数据库·非关系型数据库
半途鹅飞、1 小时前
Qt Creator 界面(菜单栏 / 工具栏 / 运行栏)消失解决方法
开发语言·qt
Omics Pro2 小时前
全流程可重复!R语言脂质组学:原始数据→功能解析
开发语言·人工智能·深度学习·语言模型·r语言·excel·知识图谱
Brilliantwxx3 小时前
【C++】 继承与多态(中)
开发语言·c++·笔记·算法
Aurorar0rua9 小时前
CS50 x 2024 Notes C -14
c语言·开发语言·学习方法
小短腿的代码世界10 小时前
从.qrc到rcc编译器:Qt资源系统的隐秘运作机制与大型项目性能突围
开发语言·qt
2401_8332693010 小时前
Java网络编程入门
java·开发语言
青瓦梦滋10 小时前
C++的IO流与STL的空间配置器
开发语言·c++