从底层到应用:开散列哈希表与_map/_set 的完整实现(附逐行注释) - 实践

从 0 到 1 实现哈希关联容器:开散列哈希表与_map/_set(附完整可运行代码)

一、引言:为什么要搞懂哈希关联容器?

在 C++ 开发中,unordered_mapunordered_set是高频使用的容器 —— 前者用于键值映射(如配置表、缓存),后者用于存储唯一元素(如 ID 集合)。它们的高效性依赖开散列(链地址法)哈希表,但很多开发者只知其然,不知其所以然。

本文将从底层原理出发,手把手实现:

  1. 基础组件(哈希函数、质数工具)
  2. 开散列哈希表(节点、迭代器、核心操作)
  3. 上层封装(_map 与_set)
  4. 实战测试(验证功能完整性)

所有代码均附带详细注释,复制即可运行,帮你彻底吃透哈希关联容器的实现逻辑。

二、基础组件:哈希表的 “地基”

哈希表的高效运行依赖两个基础:均匀的哈希函数(将键映射为索引)和合理的表大小(质数减少冲突)。

2.1 哈希函数:键→索引的转换器

哈希函数的核心目标是 “均匀分布”,避免不同键映射到同一索引(哈希冲突)。我们实现通用模板(适配基本类型)和string特化版本(适配字符串)。

#include 
#include 
#include 
#include 
using namespace std;
// 1. 通用哈希函数模板(适配int、char、long等基本类型)
template
struct HashFunc {
    // 重载(),使结构体成为“函数对象”(可像函数一样调用)
    size_t operator()(const K& key) const {
        // 基本类型直接转换为size_t(哈希表索引的标准类型)
        return static_cast(key);
    }
};
// 2. 哈希函数特化:适配string类型(经典BKDR算法,减少字符串冲突)
template<>
struct HashFunc {
    size_t operator()(const string& str) const {
        size_t hashVal = 0;
        for (char ch : str) {
            hashVal += static_cast(ch);  // 累加字符ASCII值
            hashVal *= 131;                      // 乘以质数131(经验值,增强分布均匀性)
            // 为什么选131?131=128+2+1,二进制运算高效,且能避免“abc”与“cba”类冲突
        }
        return hashVal;
    }
};

2.2 质数工具:哈希表大小的 “调节器”

哈希表的大小必须为质数—— 质数的约数少,能显著降低不同键映射到同一索引的概率(若为合数,某些哈希值会集中映射到特定桶)。

我们实现 “寻找大于等于 n 的最小质数” 的工具函数,基于预定义质数表(覆盖常用大小):

// 查找大于等于n的最小质数(用于哈希表初始化和扩容)
inline unsigned long __stl_next_prime(unsigned long n) {
    // 预定义质数表(从53到4294967291,共28个,覆盖主流场景)
    static const int kPrimeCount = 28;
    static const unsigned long kPrimeList[kPrimeCount] = {
        53,        97,        193,       389,       769,
        1543,      3079,      6151,      12289,     24593,
        49157,     98317,     196613,    393241,    786433,
        1572869,   3145739,   6291469,   12582917,  25165843,
        50331653,  100663319, 201326611, 402653189, 805306457,
        1610612741, 3221225473, 4294967291
    };
    // 二分查找第一个≥n的质数(效率O(log2(28))≈5,极快)
    const unsigned long* pStart = kPrimeList;
    const unsigned long* pEnd = kPrimeList + kPrimeCount;
    const unsigned long* pTarget = lower_bound(pStart, pEnd, n);
    // 若n超过最大质数,返回最大质数;否则返回目标质数
    return (pTarget == pEnd) ? *(pEnd - 1) : *pTarget;
}

三、底层核心:开散列哈希表完整实现

开散列哈希表的结构是 “桶数组 + 链表”:

  • 桶数组:存储链表头指针,每个桶对应一个索引;
  • 链表:同一桶内的冲突元素通过链表串联。

下面分三部分实现,每部分均附核心逻辑解析。

3.1 哈希节点:链表的 “最小单元”

每个节点存储 “数据” 和 “下一个节点指针”,用于串联同一桶内的冲突元素:

// 哈希表节点结构(链地址法的基本存储单元)
template
struct HashNode {
    T _data;               // 存储的数据(_map存pair,_set存const K)
    HashNode* _next;    // 指向下一个节点的指针(串联冲突元素)
    // 构造函数:初始化数据和指针(避免野指针)
    HashNode(const T& data)
        : _data(data)
        , _next(nullptr)   // 初始无后续节点,指针置空
    {
    }
};

3.2 哈希迭代器:跨桶遍历的 “关键”

哈希表的迭代器和数组 / 链表迭代器不同 —— 需要支持 “跨桶遍历”(当一个桶的链表遍历完后,自动跳转到下一个非空桶)。

因此迭代器必须持有两个核心成员:

  • _pNode:当前指向的节点;
  • _pHashT:指向所属哈希表(用于访问桶数组,寻找下一个非空桶)
// 前置声明哈希表类(迭代器需访问哈希表的私有成员,如桶数组_tables)
template
class HashTable;
// 哈希表迭代器类模板(支持普通迭代器和const迭代器)
// 参数说明:
// K:键类型;T:存储数据类型;Ref:数据引用类型(T&/const T&);Ptr:数据指针类型(T*/const T*)
// KeyOfT:从T中提取K的仿函数;Hash:哈希函数
template
struct HTIterator {
    typedef HashNode Node;                  // 节点类型别名
    typedef HashTable Ht;  // 哈希表类型别名
    typedef HTIterator Self;  // 迭代器自身类型
    Node* _pNode;  // 当前指向的节点
    Ht* _pHashT;   // 指向所属哈希表(用于跨桶查找)
    // 构造函数:初始化节点和哈希表指针
    HTIterator(Node* pNode, Ht* pHashT)
        : _pNode(pNode)
        , _pHashT(pHashT)
    {
    }
    // 1. 解引用运算符:返回数据引用(支持访问数据)
    Ref operator*() const {
        return _pNode->_data;
    }
    // 2. 箭头运算符:返回数据指针(支持->访问成员,如it->first)
    Ptr operator->() const {
        return &(_pNode->_data);
    }
    // 3. 前置++:迭代到下一个有效节点(核心逻辑)
    Self& operator++() {
        // 情况1:当前链表有下一个节点,直接跳转到下一个节点
        if (_pNode->_next != nullptr) {
            _pNode = _pNode->_next;
        }
        // 情况2:当前链表遍历完,寻找下一个非空桶
        else {
            KeyOfT keyExtractor;  // 键提取仿函数(从T中取K)
            Hash hashFunc;        // 哈希函数(计算桶索引)
            // 步骤1:计算当前节点所在的桶索引
            size_t curBucketIdx = hashFunc(keyExtractor(_pNode->_data))
                                % _pHashT->_tables.size();
            curBucketIdx++;  // 从下一个桶开始查找
            // 步骤2:遍历后续桶,找到第一个非空桶
            while (curBucketIdx < _pHashT->_tables.size()) {
                if (_pHashT->_tables[curBucketIdx] != nullptr) {
                    _pNode = _pHashT->_tables[curBucketIdx];  // 指向非空桶的头节点
                    return *this;
                }
                curBucketIdx++;
            }
            // 步骤3:所有桶遍历完,迭代器指向nullptr(表示end())
            _pNode = nullptr;
        }
        return *this;
    }
    // 4. 迭代器比较:判断是否指向同一节点
    bool operator!=(const Self& other) const {
        return _pNode != other._pNode;
    }
    bool operator==(const Self& other) const {
        return _pNode == other._pNode;
    }
};

3.3 哈希表主体:核心操作封装

哈希表主体类封装 “桶数组、元素个数” 和三大核心操作(插入、查找、删除),模板参数设计支持_map_set复用。

核心设计思路:
  • T表示存储的数据类型(_map 存pair<const K, V>,_set 存const K);
  • KeyOfT仿函数从T中提取键(解耦数据类型和键的提取逻辑);
  • 扩容条件:负载因子≥1(开散列特性,负载因子 = 元素个数 / 桶数)。
// 开散列哈希表类模板(通用设计,支持_map/_set复用)
template>
class HashTable {
    // 声明迭代器为友元(允许迭代器访问私有成员,如桶数组_tables)
    template
    friend struct HTIterator;
    typedef HashNode Node;  // 节点类型别名
public:
    // 迭代器类型定义(普通迭代器和const迭代器)
    typedef HTIterator Iterator;
    typedef HTIterator ConstIterator;
    // -------------------------- 1. 迭代器接口 --------------------------
    // 普通迭代器begin:指向第一个非空桶的头节点
    Iterator begin() {
        for (size_t i = 0; i < _tables.size(); ++i) {
            if (_tables[i] != nullptr) {
                return Iterator(_tables[i], this);
            }
        }
        return end();  // 空表返回end()
    }
    // 普通迭代器end:指向nullptr(表示遍历结束)
    Iterator end() {
        return Iterator(nullptr, this);
    }
    // const迭代器begin/end:与普通迭代器逻辑一致,仅返回const版本
    ConstIterator begin() const {
        for (size_t i = 0; i < _tables.size(); ++i) {
            if (_tables[i] != nullptr) {
                return ConstIterator(_tables[i], const_cast(this));
            }
        }
        return end();
    }
    ConstIterator end() const {
        return ConstIterator(nullptr, const_cast(this));
    }
    // -------------------------- 2. 构造与析构 --------------------------
    // 构造函数:初始化桶数组(默认大小为53)
    HashTable()
        : _tables(__stl_next_prime(1), nullptr)  // 1的下一个质数是53
        , _size(0)                               // 初始元素个数为0
    {
    }
    // 析构函数:释放所有节点内存(避免内存泄漏)
    ~HashTable() {
        for (size_t i = 0; i < _tables.size(); ++i) {
            Node* pCur = _tables[i];  // 指向当前桶的头节点
            while (pCur != nullptr) {
                Node* pNext = pCur->_next;  // 保存下一个节点(避免释放后丢失)
                delete pCur;                // 释放当前节点
                pCur = pNext;               // 跳转到下一个节点
            }
            _tables[i] = nullptr;  // 桶指针置空(避免野指针)
        }
    }
    // -------------------------- 3. 核心操作:插入 --------------------------
    // 返回值:pair
    // - Iterator:指向插入的节点(或已存在的节点)
    // - bool:true=插入成功(新元素),false=插入失败(元素已存在)
    pair insert(const T& data) {
        KeyOfT keyExtractor;  // 键提取仿函数(从T中取K)
        Hash hashFunc;        // 哈希函数(计算桶索引)
        // 步骤1:检查元素是否已存在(哈希表键唯一,不允许重复)
        K key = keyExtractor(data);  // 从插入数据中提取键
        Iterator it = find(key);     // 查找键是否已存在
        if (it != end()) {
            return { it, false };  // 已存在,返回现有节点和false
        }
        // 步骤2:负载因子≥1时扩容(避免链表过长,导致查询效率下降)
        if (_tables.size() <= _size) {
            size_t newBucketCount = __stl_next_prime(_tables.size() + 1);  // 新桶数为下一个质数
            vector newTables(newBucketCount, nullptr);              // 创建新桶数组
            // 步骤2.1:旧节点重新哈希到新桶(扩容后桶数变,索引需重新计算)
            for (size_t i = 0; i < _tables.size(); ++i) {
                Node* pCur = _tables[i];
                while (pCur != nullptr) {
                    Node* pNext = pCur->_next;  // 保存下一个节点
                    // 计算当前节点在新桶中的索引
                    K nodeKey = keyExtractor(pCur->_data);
                    size_t newIdx = hashFunc(nodeKey) % newBucketCount;
                    // 头插法插入新桶(效率O(1),无需遍历链表)
                    pCur->_next = newTables[newIdx];
                    newTables[newIdx] = pCur;
                    pCur = pNext;  // 处理下一个旧节点
                }
                _tables[i] = nullptr;  // 旧桶置空(避免野指针)
            }
            // 步骤2.2:交换新旧桶数组(旧桶数组会在函数结束后被销毁)
            _tables.swap(newTables);
        }
        // 步骤3:插入新节点(头插法)
        size_t targetIdx = hashFunc(key) % _tables.size();  // 计算目标桶索引
        Node* pNewNode = new Node(data);                    // 创建新节点
        pNewNode->_next = _tables[targetIdx];               // 新节点的next指向桶的头节点
        _tables[targetIdx] = pNewNode;                      // 桶的头节点更新为新节点
        _size++;                                            // 元素个数+1
        return { Iterator(pNewNode, this), true };  // 插入成功,返回新节点迭代器
    }
    // -------------------------- 4. 核心操作:查找 --------------------------
    // 根据键查找,返回迭代器(未找到返回end())
    Iterator find(const K& key) {
        Hash hashFunc;        // 哈希函数
        KeyOfT keyExtractor;  // 键提取仿函数
        // 步骤1:计算键对应的桶索引
        size_t targetIdx = hashFunc(key) % _tables.size();
        Node* pCur = _tables[targetIdx];  // 指向目标桶的头节点
        // 步骤2:遍历桶内链表,查找匹配的键
        while (pCur != nullptr) {
            if (keyExtractor(pCur->_data) == key) {
                return Iterator(pCur, this);  // 找到,返回节点迭代器
            }
            pCur = pCur->_next;  // 未找到,继续遍历下一个节点
        }
        return end();  // 遍历完链表仍未找到,返回end()
    }
    // -------------------------- 5. 核心操作:删除 --------------------------
    // 根据键删除,返回是否删除成功(true=成功,false=未找到)
    bool erase(const K& key) {
        Hash hashFunc;        // 哈希函数
        KeyOfT keyExtractor;  // 键提取仿函数
        // 步骤1:计算键对应的桶索引
        size_t targetIdx = hashFunc(key) % _tables.size();
        Node* pCur = _tables[targetIdx];  // 指向目标桶的头节点
        Node* pPrev = nullptr;            // 前驱节点(用于删除节点)
        // 步骤2:遍历桶内链表,查找待删除节点
        while (pCur != nullptr) {
            if (keyExtractor(pCur->_data) == key) {
                // 情况1:待删除节点是桶的头节点(前驱为nullptr)
                if (pPrev == nullptr) {
                    _tables[targetIdx] = pCur->_next;  // 桶的头节点更新为下一个节点
                }
                // 情况2:待删除节点是中间节点(前驱非nullptr)
                else {
                    pPrev->_next = pCur->_next;  // 前驱的next跳过当前节点
                }
                delete pCur;  // 释放节点内存
                _size--;      // 元素个数-1
                return true;  // 删除成功
            }
            pPrev = pCur;     // 前驱节点后移
            pCur = pCur->_next;  // 当前节点后移
        }
        return false;  // 遍历完链表仍未找到,删除失败
    }
private:
    size_t _size = 0;               // 有效元素个数
    vector _tables;          // 桶数组(存储链表头指针)
};

四、上层封装:_map 与_set 的实现

基于底层哈希表,我们只需通过 “键提取仿函数” 和 “存储类型适配”,即可快速封装_map_set—— 这就是通用设计的魅力!

4.1 _set:唯一键集合

_set的核心特性:

  • 存储唯一键(键即值);
  • 键不可修改(避免哈希表索引失效)。

实现思路:

  • 哈希表的存储类型T设为const K(键不可修改);
  • 键提取仿函数直接返回const K(键即数据本身)。
// 命名空间bobo:避免与标准库冲突
namespace bobo {
// _set类:基于开散列哈希表的唯一键集合
template>
class _set {
    // 键提取仿函数:从存储的const K中提取键(键即数据本身)
    struct KeyOfT {
        const K& operator()(const K& data) const {
            return data;
        }
    };
public:
    // 迭代器复用哈希表的迭代器(_set迭代器不可修改键,故直接用const迭代器逻辑)
    typedef typename HashTable::Iterator iterator;
    typedef typename HashTable::ConstIterator const_iterator;
    // 迭代器接口(直接复用哈希表的begin/end)
    iterator begin() { return _ht.begin(); }
    iterator end() { return _ht.end(); }
    const_iterator begin() const { return _ht.begin(); }
    const_iterator end() const { return _ht.end(); }
    // 插入:返回pair(键唯一,重复插入返回false)
    pair insert(const K& key) {
        return _ht.insert(key);  // 直接调用哈希表的insert
    }
    // 查找:根据键查找,返回迭代器(未找到返回end())
    iterator find(const K& key) {
        return _ht.find(key);  // 直接调用哈希表的find
    }
    // 删除:根据键删除,返回是否成功
    bool erase(const K& key) {
        return _ht.erase(key);  // 直接调用哈希表的erase
    }
private:
    // 底层哈希表:键类型K,存储类型const K,键提取仿函数KeyOfT
    HashTable _ht;
};
}  // namespace bobo

4.2 _map:键值对映射

_map的核心特性:

  • 存储键值对(pair<const K, V>);
  • 键不可修改(firstconst),值可修改(secondconst);
  • 支持[]运算符(访问 / 插入值)。

实现思路:

  • 哈希表的存储类型T设为pair<const K, V>
  • 键提取仿函数返回pairfirst(键);
  • []运算符通过insert实现(插入默认值,返回值的引用)。
namespace bobo {
// _map类:基于开散列哈希表的键值对映射
template>
class _map {
    // 键提取仿函数:从pair中提取键(返回first)
    struct KeyOfT {
        const K& operator()(const pair& data) const {
            return data.first;  // 键是pair的first成员
        }
    };
public:
    // 迭代器复用哈希表的迭代器(解引用返回pair)
    typedef typename HashTable, KeyOfT, Hash>::Iterator iterator;
    typedef typename HashTable, KeyOfT, Hash>::ConstIterator const_iterator;
    // 迭代器接口(直接复用哈希表的begin/end)
    iterator begin() { return _ht.begin(); }
    iterator end() { return _ht.end(); }
    const_iterator begin() const { return _ht.begin(); }
    const_iterator end() const { return _ht.end(); }
    // 插入:返回pair(键唯一,重复插入返回false)
    pair insert(const pair& kv) {
        return _ht.insert(kv);  // 直接调用哈希表的insert
    }
    // 核心:[]运算符(访问/插入值)
    V& operator[](const K& key) {
        // 插入{key, V()}:若键不存在,插入默认值;若存在,返回已有值
        pair ret = _ht.insert({ key, V() });
        // 返回值的引用(ret.first是迭代器,解引用是pair,second是值)
        return ret.first->second;
    }
    // 查找:根据键查找,返回迭代器(未找到返回end())
    iterator find(const K& key) {
        return _ht.find(key);  // 直接调用哈希表的find
    }
    // 删除:根据键删除,返回是否成功
    bool erase(const K& key) {
        return _ht.erase(key);  // 直接调用哈希表的erase
    }
private:
    // 底层哈希表:键类型K,存储类型pair,键提取仿函数KeyOfT
    HashTable, KeyOfT, Hash> _ht;
};
}  // namespace bobo

五、实战测试:验证功能完整性

我们编写测试代码,验证_map_set的核心功能(插入、查找、删除、遍历、[]访问),确保代码可运行。

5.1 测试代码

// 测试函数:_set功能测试
void TestSet() {
    cout << "------------------- TestSet -------------------" << endl;
    bobo::_set s;
    // 1. 插入(包含重复键)
    auto ret1 = s.insert(10);
    auto ret2 = s.insert(20);
    auto ret3 = s.insert(10);  // 重复插入
    cout << "插入10:" << (ret1.second ? "成功" : "失败") << endl;
    cout << "插入20:" << (ret2.second ? "成功" : "失败") << endl;
    cout << "重复插入10:" << (ret3.second ? "成功" : "失败") << endl;
    // 2. 遍历
    cout << "遍历_set:";
    for (auto it = s.begin(); it != s.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl;
    // 3. 查找
    auto findIt1 = s.find(10);
    auto findIt2 = s.find(30);
    cout << "查找10:" << (findIt1 != s.end() ? "存在" : "不存在") << endl;
    cout << "查找30:" << (findIt2 != s.end() ? "存在" : "不存在") << endl;
    // 4. 删除
    bool eraseRet1 = s.erase(10);
    bool eraseRet2 = s.erase(30);  // 删除不存在的键
    cout << "删除10:" << (eraseRet1 ? "成功" : "失败") << endl;
    cout << "删除30:" << (eraseRet2 ? "成功" : "失败") << endl;
    // 5. 遍历验证删除结果
    cout << "删除后遍历_set:";
    for (auto it = s.begin(); it != s.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl << endl;
}
// 测试函数:_map功能测试
void TestMap() {
    cout << "------------------- TestMap -------------------" << endl;
    bobo::_map m;
    // 1. 插入(包含重复键)
    auto ret1 = m.insert({ "张三", 20 });
    auto ret2 = m.insert({ "李四", 25 });
    auto ret3 = m.insert({ "张三", 30 });  // 重复插入
    cout << "插入{张三,20}:" << (ret1.second ? "成功" : "失败") << endl;
    cout << "插入{李四,25}:" << (ret2.second ? "成功" : "失败") << endl;
    cout << "重复插入{张三,30}:" << (ret3.second ? "成功" : "失败") << endl;
    // 2. []访问/插入
    m["王五"] = 35;  // 插入新键值对
    m["李四"] = 28;  // 修改已有值
    cout << "[]访问王五:" << m["王五"] << endl;
    cout << "[]修改后李四:" << m["李四"] << endl;
    // 3. 遍历
    cout << "遍历_map:";
    for (auto it = m.begin(); it != m.end(); ++it) {
        cout << "{" << it->first << ":" << it->second << "} ";
    }
    cout << endl;
    // 4. 查找
    auto findIt1 = m.find("张三");
    auto findIt2 = m.find("赵六");
    cout << "查找张三:" << (findIt1 != m.end() ? "存在" : "不存在") << endl;
    cout << "查找赵六:" << (findIt2 != m.end() ? "存在" : "不存在") << endl;
    // 5. 删除
    bool eraseRet1 = m.erase("张三");
    bool eraseRet2 = m.erase("赵六");  // 删除不存在的键
    cout << "删除张三:" << (eraseRet1 ? "成功" : "失败") << endl;
    cout << "删除赵六:" << (eraseRet2 ? "成功" : "失败") << endl;
    // 6. 遍历验证删除结果
    cout << "删除后遍历_map:";
    for (auto it = m.begin(); it != m.end(); ++it) {
        cout << "{" << it->first << ":" << it->second << "} ";
    }
    cout << endl;
}
// 主函数:执行测试
int main() {
    TestSet();
    TestMap();
    return 0;
}

5.2 测试结果

编译运行代码后,输出如下(符合预期):

plaintext

------------------- TestSet -------------------
插入10:成功
插入20:成功
重复插入10:失败
遍历_set:10 20
查找10:存在
查找30:不存在
删除10:成功
删除30:失败
删除后遍历_set:20
------------------- TestMap -------------------
插入{张三,20}:成功
插入{李四,25}:成功
重复插入{张三,30}:失败
[]访问王五:35
[]修改后李四:28
遍历_map:{张三:20} {李四:28} {王五:35}
查找张三:存在
查找赵六:不存在
删除张三:成功
删除赵六:失败
删除后遍历_map:{李四:28} {王五:35}

六、常见问题与总结

6.1 关键问题解答

  1. 为什么_set的键和_mapfirstconst若允许修改键,会导致键的哈希值变化,原索引失效,哈希表无法找到该元素,因此必须设为const

  2. 迭代器什么时候会失效?扩容时(哈希表重建桶数组,旧节点指针迁移),原迭代器指向的节点可能已被重新哈希到新桶,此时原迭代器失效。删除时,仅被删除节点的迭代器失效,其他迭代器不受影响。

  3. 开散列为什么选负载因子 1 作为扩容阈值?开散列的冲突元素存储在链表中,负载因子 1 表示 “平均每个桶有 1 个元素”,此时链表长度较短,查询效率仍接近 O (1);若阈值过大(如 2),链表过长会导致效率下降。

6.2 总结

本文从底层到上层,完整实现了开散列哈希表与基于它的_map/_set,核心收获:

  1. 通用设计的重要性:通过KeyOfT仿函数和解耦的哈希函数,让哈希表支持不同存储类型(const Kpair<const K, V>);
  2. 迭代器的核心逻辑:哈希表迭代器需处理 “跨桶遍历”,这是区别于其他容器迭代器的关键;
  3. 性能权衡:开散列通过 “桶 + 链表” 平衡空间和时间效率,扩容和重新哈希是保证性能的核心手段。

该实现与 C++ STL 的unordered_map/unordered_set原理一致,只是 STL 还做了更多优化(如桶的负载均衡、异常安全、内存池等)。掌握本文内容,你就能轻松理解 STL 哈希容器的底层逻辑!

posted on 2025-10-13 17:12  slgkaifa  阅读(19)  评论(0)    收藏  举报

导航