跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C++算法

C++ 哈希表核心解析:冲突解决与实现细节

哈希通过哈希函数将数据映射为固定长度数值,实现 O(1) 查找。常见冲突解决方法包括闭散列(线性探测、二次探测)和开散列(链地址法)。闭散列需处理负载因子扩容及删除标记,开散列利用链表存储冲突元素。详细解析了 C++ 中哈希表的底层数据结构、插入查找删除逻辑及扩容机制,对比了不同方案的优劣。

292440837发布于 2026/3/26更新于 2026/10/2395 浏览
C++ 哈希表核心解析:冲突解决与实现细节

哈希通过哈希函数将任意大小的数据映射为固定长度的数值,其核心目标是高效地存储、查找和验证数据。在学哈希之前,我们对于数据的查找通常是建立于顺序表或树形结构的基础上进行的。随着数据量越来越庞大,数据的随机性和容量越发严峻,理想的搜索方法是不经过任何比较,一次直接从表中得到要搜索的元素。

如果构造一种存储结构,通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。因此就在此基础上发展出了一种平均时间复杂度几乎为 O(1) 的数据查找方式,即哈希,也称为散列。

1. 什么是哈希?

哈希概念示意图

2. 哈希的常见实现方法

2.1 直接定址法

直接定址法示意图

对于一段相对集中的数据段,就可以使用直接定址法。这里最大的数是 30,最小的数是 15,创建一个大小为 15 的数组,将所有值映射到数组上。

优点: 简单、均匀 缺点: 需要事先知道关键字的分布情况 使用场景: 适合查找比较小且连续的情况,数据太分散就不适合了,开的数组会太大,造成空间浪费

2.2 除留余数法

除留余数法示意图

除留余数法是一种通过固定的哈希函数计算方式将数据放入哈希表的常用方法。设散列表中允许的地址数为 m,取一个不大于 m,但最接近或者等于 m 的质数 p 作为除数,按照哈希函数:Hash(key) = key % p (p <= m),将关键码转换成哈希地址。

3. 哈希冲突

简单来说,通过除留余数法,将每个进来的值除以哈希表的大小得到的余数,必定是在所开哈希表的容器大小范围内的。但是有可能不同的 key 会除出相同的余数,造成同一位置的冲突,该种现象称为哈希冲突或哈希碰撞。

4. 哈希冲突的解决

4.1 闭散列

也叫开放定址法。当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把 key 存放到冲突位置中的下一个空位置中去。那如何寻找下一个空位置呢?

4.1.1 线性探测

下面将通过对借助哈希表的实现解析线性探测相关的知识。

4.1.1.1 哈希表的基本数据结构
enum STATE { EXIST, EMPTY, DELETE };

template<class K, class V>
struct  {
    pair<K, V> _kv;
    STATE _state = EMPTY;
};

< ,  ,   = DefaultHashFunc<K>>
 HashTable {
:
    () { _table.(); }
:
    vector<HashData<K, V>> _table;
     _n = ; 
};
HashData
template
class
K
class
V
class
HashFunc
class
public
HashTable
resize
10
private
size_t
0
// 存储有效数据的个数

设置 _kv 存储实际的键值对数据,_state 跟踪该位置的状态。

  • EXIST 表示位置已被占用(存在有效元素)
  • EMPTY 表示位置为空(从未被使用)
  • DELETE 表示位置已删除(曾被占用,现已删除)

为什么要用状态来表示呢? 因为用状态表示是最清晰直接的。有人说用零来表示已经删除的值不就好了,但是万一本身存储的值就是零呢?总而言之用状态表示是最方便的。

4.1.1.2 哈希表的 key 转换
template<class K>
struct DefaultHashFunc {
    size_t operator()(const K& key) {
        return (size_t)key;
    }
};

template<>
struct DefaultHashFunc<string> {
    size_t operator()(const string& str) {
        // BKDR hash
        size_t hash = 0;
        for (auto ch : str) {
            hash *= 131;
            hash += ch;
        }
        return hash;
    }
};

除留余数法一般是对整数进行操作,但是我们并不能保证 key 一定是整数。有人说直接强转不就好了,但是你能保证强转的数据一定是对的吗?可能是 double,也有可能是 string,因此最好的方法是利用仿函数进行统一操作。

整数小数等就走默认的 DefaultHashFunc 类,当 key 是 string 类型的时候,就走特化的版本 DefaultHashFunc<string>,这里特化是为了统一性,不然你再造一个仿函数就太麻烦了。

template<class K, class V, class HashFunc = DefaultHashFunc<K>>

将仿函数设为默认类型,这样子在创建例如 HashTable<string, string> dict 的哈希表的时候就不用显式写仿函数的类型。

值得注意的是: 这里的 string 特化版本的仿函数,进行了一些数学上的处理。假设有 "abc","bca" 两个字符串,这两个字符串其实是不一样的数据。如果没有进行 hash *= 131 的数据处理,那么这两个字符串的加和就是一样的,那么使用除留余数法的时候就会发生哈希冲突。

4.1.1.3 哈希表的插入
bool Insert(const pair<K, V>& kv) {
    // 扩容
    if (_n * 10 / _table.size() >= 7) {
        size_t newSize = _table.size() * 2;
        // 遍历旧表,重新映射到新表
        HashTable<K, V, HashFunc> newHT;
        newHT._table.resize(newSize);
        // 遍历旧表的数据插入到新表即可
        for (size_t i = 0; i < _table.size(); i++) {
            if (_table[i]._state == EXIST) {
                newHT.Insert(_table[i]._kv);
            }
        }
        _table.swap(newHT._table);
    }

    // 线性探测
    HashFunc hf;
    size_t hashi = hf(kv.first) % _table.size();
    while (_table[hashi]._state == EXIST) {
        ++hashi;
        hashi %= _table.size();
    }
    _table[hashi]._kv = kv;
    _table[hashi]._state = EXIST;
    ++_n;
    return true;
}

这里的插入,解决哈希冲突的方式利用的是线性探测的方式:先对哈希函数计算值所带入的 key 进行处理,转换为合理的计算值。如果计算得出的位置为 EXIST,就依次往后探测,直到找到空位置为止,然后插入即可。

那么哈希表满了之后的扩容是怎么一回事呢?

扩容示意图

我们要知道判断一个哈希表是否应该开始扩容的标准是负载因子,通过 size / capacity 判断哈希表的填充程度,我们这里设置为 0.7 即扩容。既然扩容了,之前的数据就必须重新计算位置放入哈希表,不然关系就全乱了。或许会有人想为什么不直接新创建一个数组来放?而是创建一个新对象 HashTable<K, V, HashFunc> newHT,再来创建新哈希表,这是因为在对象里操作可以使用插入等便捷操作,使得新哈希表的创建更方便。

值得注意的是:

  • 计算位置时除 size,而不是 capacity,因为 size 直接反映了数组的有效长度,[0, table_size-1] 是索引的合法范围。
  • hashi %= _table.size() 是为了避免超出数组有效索引范围,只要大于 size 就会被除余回到数组第一个位置。
  • 有人担心扩容会影响搜索效率,其实影响并不是很大,每次扩容都为之前的两倍,会比之前大很多,也就碰上扩容那一次效率不太高,整体来讲影响是不大的。
4.1.1.4 哈希表的查找
HashData<const K, V>* Find(const K& key) {
    // 线性探测
    HashFunc hf;
    size_t hashi = hf(key) % _table.size();
    while (_table[hashi]._state != EMPTY) {
        if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key) {
            return (HashData<const K, V>*)&_table[hashi];
        }
        ++hashi;
        hashi %= _table.size();
    }
    return nullptr;
}

循环条件为 _table[hashi]._state != EMPTY 是因为在插入的时候为空就必定插入,那么查找的过程中在找到新的空之前必定能找到想要的值(如果正确插入的话)。if 条件还必须加入 _table[hashi]._state == EXIST 是因为避免查找到的是已删除的值。

值得注意的是: 返回的是 HashData<const K, V>*,而不是 HashData<K, V>*,防止 key 被错误修改。

4.1.1.5 哈希表的删除
bool Erase(const K& key) {
    HashData<const K, V>* ret = Find(key);
    if (ret) {
        ret->_state = DELETE;
        --_n;
        return true;
    }
    return false;
}

删除可以说是我们学的那么多个结构里,比插入简单的了,直接删除修改状态即可。

4.1.2 二次探测

二次探测的位置计算基于平方序列探测的,下面将给出详细的计算步骤。

  1. 核心计算公式 给定初始哈希位置 h₀ 和探测次数 i,下一个探测位置为:

    h(i) = (h₀ + i²) % table_size
    
    • h₀:初始哈希值(例如 hash(key) % table_size)
    • i:探测次数,从 1 开始递增
    • table_size:哈希表的大小(必须为素数,否则可能无法覆盖所有槽位)
  2. 计算步骤示例 假设哈希表大小 table_size = 7(素数),初始哈希位置 h₀ = 3,插入时发生冲突,则二次探测的位置序列为:

探测次数 i计算公式结果 h(i)
1(3 + 1²) % 74
2(3 + 2²) % 70
3(3 + 3²) % 75
4(3 + 4²) % 72
5(3 + 5²) % 76
6(3 + 6²) % 71

序列: 3 → 4 → 0 → 5 → 2 → 6 → 1,覆盖所有 7 个槽位

  1. 为什么表大小必须是素数? 若 table_size 为合数,可能无法覆盖所有槽位。例如,当 table_size = 4(合数)时:
探测次数 i计算公式结果 h(i)
1(h₀ + 1²) % 4h₀ + 1
2(h₀ + 2²) % 4h₀
3(h₀ + 3²) % 4h₀ + 1

序列: h₀ → h₀+1 → h₀ → h₀+1,只能访问 2 个槽位,导致死循环

  1. 代码实现示例
bool Insert(const pair<K, V>& kv) {
    // 扩容逻辑(略)
    HashFunc hf;
    size_t h0 = hf(kv.first) % _table.size();
    // 二次探测
    for (size_t i = 1; i < _table.size(); ++i) {
        size_t hashi = (h0 + i * i) % _table.size();
        if (_table[hashi]._state != EXIST) {
            _table[hashi]._kv = kv;
            _table[hashi]._state = EXIST;
            ++_n;
            return true;
        }
    }
    return false; // 表满(实际不会触发,因提前扩容)
}
4.1.3 线性探测和二次探测对比
特性线性探测二次探测
探测序列h₀, h₀+1, h₀+2, ...h₀, h₀+1, h₀+4, h₀+9, ...
聚集问题严重(主聚集)较轻(二次聚集)
空间利用率低(易导致连续槽位被占用)高(更均匀分布)
表满检测遍历全量槽位即可检测需遍历约一半槽位

4.2 开散列

4.2.1 哈希桶

哈希桶示意图

从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷,那么有没有办法不把数据只局限在数组里?有的,可以使用拉链法,也叫哈希桶,将数据以单链表的形式挂起来。

4.2.1.1 哈希表的基本数据结构
template<class K, class V>
struct HashNode {
    pair<K, V> _kv;
    HashNode<K, V>* _next;
    HashNode(const pair<K, V>& _kv) : _kv(kv), _next(nullptr) {}
};

template<class K, class V, class HashFunc = DefaultHashFunc<K>>
class HashTable {
    typedef HashNode<K, V> Node;
public:
    HashTable() { _table.resize(10, nullptr); }
    ~HashTable() {
        for (size_t i = 0; i < _table.size(); i++) {
            Node* cur = _table[i];
            while (cur) {
                Node* next = cur->_next;
                delete cur;
                cur = next;
            }
            _table[i] = nullptr;
        }
    }
private:
    vector<Node*> _table; // 指针数组
    size_t _n = 0; // 存储了多少个有效数据
};

vector<Node*> 或者 vector<list> 都是可以的,节点都是指针需要释放,析构函数需要自己实现。

4.2.1.2 哈希表的插入
bool Insert(const pair<K, V>& kv) {
    if (Find(kv.first)) {
        return false;
    }
    HashFunc hf;
    // 负载因子到 1 就扩容
    if (_n == _table.size()) {
        size_t newSize = _table.size() * 2;
        vector<Node*> newTable;
        newTable.resize(newSize, nullptr);
        // 遍历旧表,顺手牵羊,把节点牵下来挂到新表
        for (size_t i = 0; i < _table.size(); i++) {
            Node* cur = _table[i];
            while (cur) {
                Node* next = cur->_next;
                // 头插到新表
                size_t hashi = hf(cur->_kv.first) % newSize;
                cur->_next = newTable[hashi];
                newTable[hashi] = cur;
                cur = next;
            }
            _table[i] = nullptr;
        }
        _table.swap(newTable);
    }
    size_t hashi = hf(kv.first) % _table.size();
    // 头插
    Node* newnode = new Node(kv);
    newnode->_next = _table[hashi];
    _table[hashi] = newnode;
    ++_n;
    return true;
}

由于每个哈希桶可以挂多个数据以节省空间,负载因子可以扩大到 1,平均下来一个桶一个数据。

头插示意图

这里悬挂操作是以如图头插的方式进行的,在扩容时,把原先的桶挂到新表上的时候,由于是头插,原先的单链表会在新表上倒置,但是这不影响查找元素,每条桶的元素还是固定的,只是顺序不一样而已。

4.2.1.3 哈希表的查找
Node* Find(const K& key) {
    HashFunc hf;
    size_t hashi = hf(key) % _table.size();
    Node* cur = _table[hashi];
    while (cur) {
        if (cur->_kv.first == key) {
            return cur;
        }
        cur = cur->_next;
    }
    return nullptr;
}

当查找的节点为头节点时,prev 为空。

4.2.1.4 哈希表的删除
bool Erase(const K& key) {
    HashFunc hf;
    size_t hashi = hf(key) % _table.size();
    Node* prev = nullptr;
    Node* cur = _table[hashi];
    while (cur) {
        if (cur->_kv.first == key) {
            if (prev == nullptr) {
                _table[hashi] = cur->_next;
            } else {
                prev->_next = cur->_next;
            }
            delete cur;
            return true;
        }
        prev = cur;
        cur = cur->_next;
    }
    return false;
}

当查找的节点为头节点时,prev 为空,直接让下一个节点成为头节点即可。当查找的节点为其他节点时,让前一个节点和下一个节点链接。记得释放删除的节点。

4.3 开散列与闭散列比较

应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。

事实上: 由于开放地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。

目录

  1. 1. 什么是哈希?
  2. 2. 哈希的常见实现方法
  3. 2.1 直接定址法
  4. 2.2 除留余数法
  5. 3. 哈希冲突
  6. 4. 哈希冲突的解决
  7. 4.1 闭散列
  8. 4.1.1 线性探测
  9. 4.1.1.1 哈希表的基本数据结构
  10. 4.1.1.2 哈希表的 key 转换
  11. 4.1.1.3 哈希表的插入
  12. 4.1.1.4 哈希表的查找
  13. 4.1.1.5 哈希表的删除
  14. 4.1.2 二次探测
  15. 4.1.3 线性探测和二次探测对比
  16. 4.2 开散列
  17. 4.2.1 哈希桶
  18. 4.2.1.1 哈希表的基本数据结构
  19. 4.2.1.2 哈希表的插入
  20. 4.2.1.3 哈希表的查找
  21. 4.2.1.4 哈希表的删除
  22. 4.3 开散列与闭散列比较

更多推荐文章

查看全部
  • Python 从零实现简单神经网络算法:原理与代码详解
  • MCP 协议详解:与 Function Call 的区别及 Python 实战
  • LangChain 速成课程:构建基于 OpenAI LLM 的应用
  • 人工智能模型评估:核心指标与方法体系
  • 企业级图像 AIGC 技术观察:Seedream 4.0 能力与应用场景分析
  • MySQL 权限管控与 C/C++ 客户端接入实战指南
  • Python GUI 可视化设计:3 步完成专业界面开发
  • AI 在前后端开发中的差异化落地指南:DAU 规模分层策略
  • llama.cpp 重大更新:内置 Web UI,性能超越 Ollama,本地大模型部署新选择
  • Python 数据科学与机器学习领域十大核心库详解
  • 解决 NVIDIA RTX 50 系列 (sm_120) 架构下的 PyTorch 与 Unsloth 依赖冲突
  • DIY 无人机:电源升压降压电路设计详解
  • PX4 与 ROS 集成:Offboard 模式解析及无人机轨迹控制实战
  • OpenBioLLM-Llama3 医疗大模型安装与应用指南
  • 二分查找实战:山峰数组的峰顶索引与寻找峰值
  • C++ 异常处理机制:捕获、自定义与实战
  • IntelliJ IDEA 切换 Git 用户配置详解
  • Kubernetes 集群管理与核心组件详解
  • Java 面向对象入门:类、对象与封装核心详解
  • TWIST2 全身 VR 遥操系统:基于视觉观测预测关节位置的自主策略

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online