【C++】深入底层:自己动手实现一个哈希表

哈希表,也常被称为散列表,它的核心思想非常直接:通过一个哈希函数,在"关键字(Key)"和"存储位置"之间建立一种确定的映射关系 。查找时,只需通过同一个函数计算出位置,就能直接去那个位置取值,从而实现近乎 O(1) 的平均查找效率。

可以把哈希表想象成一个有编号的储物柜。哈希函数就是规则,你告诉它物品的编号(Key),它就能算出该去几号柜子找。

1. 一个朴素但局限的方法:直接定址法

最直观的哈希函数就是直接定址法。比如,我们事先知道所有关键字都在 0 到 99 之间,那就直接开一个大小为 100 的数组,关键字的值就是它的存储下标。

比如 LeetCode 387 题"字符串中的第一个唯一字符"的解法,就是一种直接定址法的应用:

cpp

复制代码
class Solution {
public:
    int firstUniqChar(string s) {
        // 小写字母有26个,创建大小为26的数组
        // 用字母的ASCII码减去'a'的ASCII码作为下标
        int count[26] = {0};

        // 第一次遍历,统计每个字母出现的频率
        for (auto ch : s) {
            count[ch - 'a']++;
        }

        // 第二次遍历,找到第一个频率为1的字符并返回其索引
        for (size_t i = 0; i < s.size(); ++i) {
            if (count[s[i] - 'a'] == 1)
                return i;
        }
        return -1;
    }
};

优点: 非常简单,查找极快。
致命缺点: 当关键字范围很大、很分散时,比如只有几个数在 0 到 9999 之间,你也要开一个长度为 10000 的数组,会造成巨大的空间浪费,甚至不可能实现。

2. 两个无法回避的核心问题:冲突与负载因子

为了更通用,我们不让Key值直接作为下标,而是用一个哈希函数 h(key) 将其映射到一块有限大小 M 的内存空间里。这时,就一定会遇到两个关键问题。

哈希冲突

不同的关键字,通过哈希函数计算出了相同的存储位置,这种现象就叫哈希冲突。可以证明,冲突是理论上的必然,我们只能尽量设计好的哈希函数来减少冲突,并设计出完善的方案来解决它。

负载因子

负载因子 是衡量哈希表"拥挤程度"的重要指标,公式为 负载因子 = N / M,其中 N 是已存储元素个数,M 是哈希表总容量。负载因子越大,冲突概率越高;负载因子越小,空间利用率越低。我们实现哈希表的关键,就是在这对矛盾中找到一个平衡点。

3. 如何设计一个好的哈希函数

一个好的哈希函数应该能让关键字被等概率、均匀地 散列到整个空间里。由于最终计算需要整数,对于 string 等非整数Key,我们要先想办法把它转换成一个整数。下面介绍几种常见的哈希函数设计。

除法散列法(除留余数法)

这是最常用也最直观的方法:h(key) = key % M

  • 关于 M 的选择:通常建议M是一个不接近2的整数次幂的素数。因为如果M是2的幂,取模操作本质上只保留了key的低位,高位信息被浪费,容易增加冲突。

  • 实践中的变通 :像 Java 的 HashMap 恰恰使用了 2 的整数次幂作为 M,目的是将耗时的取模运算 % 替换为更快的位运算 &。但为了避免上述问题,它在计算哈希值时会对 key 的哈希码进行二次扰动(比如高16位和低16位做异或),让所有位都参与到运算中,最终再与 M-1 做与运算来定位。这告诉我们,理解原理后要灵活运用。

乘法散列法

这个方法对 M 的大小没有特殊要求。思路是用关键字 K 乘以一个常数 A(0<A<1),提取乘积的小数部分,再用这个小数部分乘以 M 并向下取整。h(key) = floor(M * ((A * key) % 1.0))。常数 A 的取值有讲究,Knuth 认为黄金分割点 (√5 - 1) / 2 ≈ 0.618 是个不错的选择。

全域散列法

如果一个恶意的攻击者知道了你的哈希函数,可以故意构造大量会碰撞的数据,让哈希表退化。全域散列法 通过在程序运行时,从一族哈希函数中随机选择一个来使用,从而让攻击者无法预测和针对。这大大增加了系统的健壮性。

4. 解决冲突的两种根本方法

冲突无法避免,但可以解决。主流的解决方案分为 开放定址法链地址法 两大流派。

方法一:开放定址法

核心思想是:所有数据都存在哈希表本体数组中。如果计算出位置 hash0 已被占用,就按某种规则去探测下一个为空的位置。

为了支持"删除"操作,我们需要给数组的每个存储单元增加状态标记:EXIST(存在)、EMPTY(为空)、DELETE(已删除)。如果直接删除某个元素,会打断探测链,导致后续插入的元素无法被找到。将它的状态改为 DELETE 就能解决这个问题。下面是实现代码,其中包含了核心的 Insert 和扩容逻辑。

cpp

复制代码
namespace open_address {
    // 存储单元的状态
    enum State {
        EXIST,
        EMPTY,
        DELETE
    };

    // 哈希表存储的数据结构
    template<class K, class V>
    struct HashData {
        pair<K, V> _kv;
        State _state = EMPTY;
    };

    // 默认的哈希函数(将key直接强转为size_t)
    template<class K>
    struct HashFunc {
        size_t operator()(const K& key) {
            return (size_t)key;
        }
    };

    // 针对string类型的特化哈希函数
    // 采用BKDR算法,每次乘以131再加当前字符,能有效避免不同排列的字符串碰撞
    template<>
    struct HashFunc<string> {
        size_t operator()(const string& key) {
            size_t hash = 0;
            for (auto e : key) {
                hash *= 131;
                hash += e;
            }
            return hash;
        }
    };
    
    template<class K, class V, class Hash = HashFunc<K>>
    class HashTable {
        // ...(此处省略查找、删除等操作以突出核心逻辑)
    public:
        // 插入操作的核心实现
        bool Insert(const pair<K, V>& kv) {
            // 1. 查重,key已存在则返回false
            if (Find(kv.first)) return false;

            // 2. 负载因子控制在0.7,超过则扩容
            // _n * 10 / _tables.size() >= 7 是为了避免浮点数计算
            if (_n * 10 / _tables.size() >= 7) {
                // 创建一个新的哈希表,并扩容至下一个合适的素数
                HashTable<K, V, Hash> newHT;
                newHT._tables.resize(__stl_next_prime(_tables.size() + 1));
                
                // 将旧表中的有效数据重新插入新表
                for (size_t i = 0; i < _tables.size(); i++) {
                    if (_tables[i]._state == EXIST) {
                        newHT.Insert(_tables[i]._kv);
                    }
                }
                // 新旧表交换,完成扩容
                _tables.swap(newHT._tables);
            }

            // 3. 使用线性探测法寻找插入位置
            Hash hash;
            size_t hash0 = hash(kv.first) % _tables.size();
            size_t hashi = hash0;
            size_t i = 1;
            // 如果当前位置状态为EXIST,则继续探测
            while (_tables[hashi]._state == EXIST) {
                // 线性探测:每次向后移动一个位置
                hashi = (hash0 + i) % _tables.size();
                ++i;
            }

            // 4. 找到非EXIST位置(EMPTY或DELETE),插入数据
            _tables[hashi]._kv = kv;
            _tables[hashi]._state = EXIST;
            ++_n;
            return true;
        }

    private:
        // 扩容时寻找下一个合适的素数
        inline unsigned long __stl_next_prime(unsigned long n) {
            static const unsigned long __stl_prime_list[28] = {
                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
            };
            const unsigned long* first = __stl_prime_list;
            const unsigned long* last = __stl_prime_list + 28;
            // lower_bound找到第一个不小于n的素数
            const unsigned long* pos = lower_bound(first, last, n);
            return pos == last ? *(last - 1) : *pos;
        }
        
        vector<HashData<K, V>> _tables;
        size_t _n = 0; // 有效数据个数
    };
}
  • 线性探测:hashi = (hash0 + i) % M,简单但可能产生"群集(堆积)"问题。

  • 二次探测:hashi = (hash0 ± i²) % M,能一定程度缓解群集效应。

方法二:链地址法(哈希桶)

这是标准库 unordered_mapunordered_set 采用的实现方式,也是更主流的做法。

  • 核心思想 :哈希表本体不再直接存储数据,而是存储一个指针数组 。每个数组元素指向一个链表(或红黑树等结构),所有映射到这个位置的冲突数据,都挂在这个链表上。因此,它也被形象地称为拉链法哈希桶

  • 负载因子:可以大于1,意味着链表允许不止一个节点。通常控制在1左右。

cpp

复制代码
namespace hash_bucket {
    // 链表节点
    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 Hash = HashFunc<K>>
    class HashTable {
        typedef HashNode<K, V> Node;
    public:
        HashTable() {
            _tables.resize(__stl_next_prime(0), nullptr);
        }

        bool Insert(const pair<K, V>& kv) {
            Hash hs;
            // 1. 扩容:当负载因子达到1(即_n == _tables.size())时扩容
            if (_n == _tables.size()) {
                // 创建新的桶数组,大小是下一个素数
                vector<Node*> newTables(__stl_next_prime(_tables.size() + 1), nullptr);
                
                // 2. 将旧表的节点,逐个迁移到新表(不是复制)
                for (size_t i = 0; i < _tables.size(); i++) {
                    Node* cur = _tables[i];
                    while (cur) {
                        Node* next = cur->_next;
                        // 重新计算在新表中的桶索引
                        size_t hashi = hs(cur->_kv.first) % newTables.size();
                        
                        // 头插法插入新桶
                        cur->_next = newTables[hashi];
                        newTables[hashi] = cur;
                        cur = next;
                    }
                    _tables[i] = nullptr; // 旧表指针置空
                }
                // 交换指针数组,完成扩容
                _tables.swap(newTables);
            }

            // 3. 计算桶索引,并进行头插
            size_t hashi = hs(kv.first) % _tables.size();
            Node* newnode = new Node(kv);
            newnode->_next = _tables[hashi];
            _tables[hashi] = newnode;
            ++_n;
            return true;
        }
        
        // 查找和删除逻辑清晰,此处省略...

        ~HashTable() {
            // 遍历并释放所有桶的链表节点
            for (size_t i = 0; i < _tables.size(); i++) {
                Node* cur = _tables[i];
                while (cur) {
                    Node* next = cur->_next;
                    delete cur;
                    cur = next;
                }
                _tables[i] = nullptr;
            }
        }

    private:
        // ...(__stl_next_prime函数与开放定址法类似)
        vector<Node*> _tables; // 指针数组,每个元素指向一个链表(桶)的头节点
        size_t _n = 0;        // 有效数据个数
    };
}
关于极端场景的优化

如果某个桶的链表过长,查找效率会下降。Java 8 的 HashMap 做法是,当链表长度超过阈值(比如8)且总容量超过64时,将这个链表树化为红黑树,将退化后的 O(N) 查找效率拉回到 O(log N)。这属于更深层次的优化,我们理解了思路即可。


总结与思考

到这里,我们从概念、函数设计到冲突解决的两种代码实现,完整地走了一遍哈希表的底层原理。你可能会觉得内容不少,但核心思想其实很清晰:

  • 用一个好函数减少冲突。

  • 用一个好策略解决冲突。

  • 用一个负载因子平衡时间与空间。

理解了底层,你就能更好地理解 unordered_set 为什么是单向迭代器且遍历无序(因为它是顺着桶和链表走的),以及它为Key同时要求哈希函数和相等比较器的深层原因了。

相关推荐
_深海凉_1 小时前
LeetCode热题100-小于 n 的最大数(字节高频题)
算法·leetcode·职场和发展
小雅痞1 小时前
[Java][Leetcode middle] 36. 有效的数独
java·算法·leetcode
小杍随笔1 小时前
【在 Rust + Tauri 2 应用中实现语言切换功能:完整技术指南】
开发语言·后端·rust
paeamecium1 小时前
【PAT甲级真题】- General Palindromic Number(20)
数据结构·c++·算法·pat考试·pat
minji...1 小时前
Linux 网络基础之UDP协议(四)传输层协议 UDP,再谈端口号,UDP 特点
linux·服务器·开发语言·网络·c++·tcp/ip·udp
逻辑驱动的ken1 小时前
Java高频面试考点场景题27
java·开发语言·面试·职场和发展·求职招聘
北顾笙9801 小时前
day43-数据结构力扣
数据结构·算法·leetcode
sali-tec1 小时前
C# 基于OpenCv的视觉工作流-章69-圆弧测量
图像处理·人工智能·opencv·算法·计算机视觉