【C++】哈希表实现指南:从原理到开放定址法、链地址法的实现

文章目录


前言

二叉搜索树、AVL 树、红黑树,查找效率都是 O (logN)。但还有一种更快的结构 ------哈希表,平均情况下增删查都是 O (1),靠的不是比较,而是直接通过哈希函数算出存储位置。

STL 里的unordered_map、unordered_set底层就是哈希表。这篇我们就从哈希原理、哈希函数、冲突解决,到开放定址法和链地址法两种完整实现,一步步实现哈希表。


一、哈希的基本概念

1. 直接定址法:最理想的情况

如果关键字的范围很集中,直接用关键字本身当数组下标,这就是直接定址法。

比如关键字都是 0~99 的整数,开个 100 大小的数组,key 是几就放几号位置。再比如小写字母,key - 'a'就是下标。

cpp 复制代码
// 统计字符串中第一个唯一字符
int firstUniqChar(string s) {
    int count[26] = {0};
    for (auto ch : s)
        count[ch - 'a']++; // 直接定址
    // ...
}

优点:最快,O (1) 直接访问。

缺点:太受限了。关键字范围一大,要么开不起那么大数组,要么空间浪费严重。

2. 哈希函数与哈希冲突

现实中关键字范围通常很大很分散,我们需要一个哈希函数 ,把任意 key 映射到[0, M)的数组下标范围内。

h(key) = 下标

但问题来了:不同的 key,可能算出同一个下标。这就叫哈希冲突(哈希碰撞)。

冲突不可避免,我们能做的两件事:

  1. 设计好的哈希函数,尽量让 key 均匀分布,减少冲突
  2. 设计冲突解决方案,冲突了也能正常存、正常查

3. 负载因子

哈希表里存了多少数据,和表总大小的比值,叫负载因子(load factor)。

负载因子 = 已存元素数 / 哈希表大小

  • 负载因子越大,冲突概率越高,空间利用率越高
  • 负载因子越小,冲突概率越低,空间越浪费

一般到了某个阈值就要扩容。比如开放定址法通常 0.7 扩容,链地址法通常到 1 扩容。

4. 常见哈希函数

除留余数法(最常用)

h(key) = key % M

M 是哈希表大小。简单直接,实践中最常用。

注意点:

  • M 尽量取质数,减少冲突。比如 11、53、97、193......
  • 如果 M 是 2 的幂,相当于只取 key 的后几位,高位没用,冲突会多。当然也有玩法就是用 2 的幂配合位运算,再叠加异或混合高位,比如 Java 的 HashMap。

乘法散列法(了解)

用 key 乘一个 0~1 之间的常数,取小数部分再乘 M 取整。对 M 没要求,经典取值是黄金分割点(√5-1)/2 ≈ 0.618。

全域散列法(了解)

给哈希函数加随机因素,防止有人故意构造数据让所有 key 都冲突。每次初始化随机选一个哈希函数,后续统一用它。

二、冲突解决方法一:开放定址法

1. 线性探测

核心思路:冲突了就往后找,直到找到空位。

h(key, i) = (hash0 + i) % M

i 从 1 开始,依次往后探测,找到空位就插入。到表尾了就绕回表头。

优点:简单,容易实现,所有数据都在数组里,缓存友好。

缺点:容易产生堆积(群集)------ 冲突的位置连成片,后面来的都往后面挤,越挤越长,探测效率下降。

2. 删除的大坑:不能直接删

开放定址法删除不能直接把位置清空。因为后面的元素可能是因为前面冲突才挪到后面的,你把前面删空了,后面的元素查找时遇到空就停了,会以为不存在。

解决方案:加状态标记:每个位置有三种状态

  • EMPTY:空的,没存过东西
  • EXIST:存着有效数据
  • DELETE:数据被删了,不是真的空

查找的时候,遇到EMPTY才停;遇到DELETE继续往后找。插入的时候,遇到EMPTY或DELETE都可以放。

3. 二次探测 & 双重散列(了解)

  • 二次探测:步长是 i²,左右跳着找,一定程度缓解堆积。
  • 双重散列:用第二个哈希函数算步长,更不容易堆积。

4. 开放定址法代码实现

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>      
using namespace std;

enum State {
    EXIST,
    EMPTY,
    DELETE
};

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

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

// string特化:BKDR哈希
template <>
struct HashFunc<string> {
    size_t operator()(const string& key) const {
        size_t hash = 0;
        for (char ch : key) {
            hash = hash * 131 + ch;
        }
        return hash;
    }
};

namespace open_address {

    template <class K, class V, class Hash = HashFunc<K>>
    class HashTable {
    public:
        HashTable()
            : _tables(__stl_next_prime(0))
            , _n(0)
        {
        }

        bool Insert(const pair<K, V>& kv) {
            if (Find(kv.first))
                return false;

            // 负载因子超过0.7扩容
            if (_n * 10 / _tables.size() >= 7) {
                HashTable<K, V, Hash> newHash;
                newHash._tables.resize(__stl_next_prime(_tables.size() + 1));
                for (auto& data : _tables) {
                    if (data._state == EXIST)
                        newHash.Insert(data._kv);
                }
                _tables.swap(newHash._tables);
            }

            Hash hash;
            size_t hash0 = hash(kv.first) % _tables.size();
            size_t hashi = hash0;
            size_t i = 1;

            while (_tables[hashi]._state == EXIST) {
                hashi = (hash0 + i) % _tables.size();
                ++i;
            }

            _tables[hashi]._kv = kv;
            _tables[hashi]._state = EXIST;
            ++_n;
            return true;
        }

        HashData<K, V>* Find(const K& key) {
            Hash hash;
            size_t hash0 = hash(key) % _tables.size();
            size_t hashi = hash0;
            size_t i = 1;

            while (_tables[hashi]._state != EMPTY && i <= _tables.size()) {
                if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key) {
                    return &_tables[hashi];
                }
                hashi = (hash0 + i) % _tables.size();
                ++i;
            }
            return nullptr;
        }

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

    private:
        // 质数表:返回>=n的最小质数
        static unsigned long __stl_next_prime(size_t n) {
            static const unsigned long primes[] = {
                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, 3221225473ul, 4294967291ul
            };
            const int cnt = sizeof(primes) / sizeof(primes[0]);
            auto pos = lower_bound(primes, primes + cnt, n);
            return pos == primes + cnt ? *(primes + cnt - 1) : *pos;
        }

        vector<HashData<K, V>> _tables;
        size_t _n; // 有效元素个数
    };

} // namespace open_address

三、冲突解决方法二:链地址法(哈希桶)

1. 原理

数组里不直接存数据,存链表头指针。冲突的 key 都挂在同一个位置的链表里,像一个个桶,所以也叫哈希桶。

优点:

  • 不会有堆积问题
  • 负载因子可以大于 1,空间利用率高
  • 删除简单,就是链表删除

缺点:

  • 链表有指针开销,缓存局部性不如开放定址
  • 极端情况某个桶特别长,效率退化。Java 8 + 的 HashMap 会在桶太长时转红黑树
  • 开放定址法简单紧凑,但有堆积,负载因子不能太高
  • 链地址法更通用,也是unordered_map的主流实现方式

2. 扩容

一般负载因子到 1 的时候扩容。扩容的时候,所有元素要重新哈希到新表。

优化点:扩容不用重新 new 结点,直接把旧表的结点摘下来,重新挂到新表对应位置,移动指针就行,效率更高。

3. 链地址法代码实现

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>      
using namespace std;

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)
    {
    }
};

namespace hash_bucket {

    template <class K, class V, class Hash = HashFunc<K>>
    class HashTable {
        typedef HashNode<K, V> Node;

    public:
        HashTable()
            : _tables(__stl_next_prime(0), nullptr)
            , _n(0)
        {
        }

        // 拷贝构造
        HashTable(const HashTable& ht)
            : _tables(ht._tables.size(), nullptr)
            , _n(0)
        {
            for (size_t i = 0; i < ht._tables.size(); ++i) {
                Node* cur = ht._tables[i];
                while (cur) {
                    Insert(cur->_kv);
                    cur = cur->_next;
                }
            }
        }

        void Swap(HashTable& ht) {
            swap(_tables, ht._tables);
            swap(_n, ht._n);
        }

        HashTable& operator=(HashTable ht) {
            Swap(ht);
            return *this;
        }

        ~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;
            }
        }

        bool Insert(const pair<K, V>& kv) {
            if (Find(kv.first))
                return false;

            Hash hash;
            // 负载因子到1扩容
            if (_n == _tables.size()) {
                vector<Node*> newTable(__stl_next_prime(_tables.size() + 1), nullptr);
                for (size_t i = 0; i < _tables.size(); ++i) {
                    Node* cur = _tables[i];
                    while (cur) {
                        Node* next = cur->_next;
                        size_t hashi = hash(cur->_kv.first) % newTable.size();
                        // 头插新表
                        cur->_next = newTable[hashi];
                        newTable[hashi] = cur;
                        cur = next;
                    }
                    _tables[i] = nullptr;
                }
                _tables.swap(newTable);
            }

            size_t hashi = hash(kv.first) % _tables.size();
            // 头插
            Node* newnode = new Node(kv);
            newnode->_next = _tables[hashi];
            _tables[hashi] = newnode;
            ++_n;
            return true;
        }

        Node* Find(const K& key) {
            Hash hash;
            size_t hashi = hash(key) % _tables.size();
            Node* cur = _tables[hashi];
            while (cur) {
                if (cur->_kv.first == key)
                    return cur;
                cur = cur->_next;
            }
            return nullptr;
        }

        bool Erase(const K& key) {
            Hash hash;
            size_t hashi = hash(key) % _tables.size();
            Node* prev = nullptr;
            Node* cur = _tables[hashi];

            while (cur) {
                if (cur->_kv.first == key) {
                    if (prev == nullptr)
                        _tables[hashi] = cur->_next;
                    else
                        prev->_next = cur->_next;
                    delete cur;
                    --_n;
                    return true;
                }
                prev = cur;
                cur = cur->_next;
            }
            return false;
        }

    private:
        static unsigned long __stl_next_prime(size_t n) {
            static const unsigned long primes[] = {
                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, 3221225473ul, 4294967291ul
            };
            const int cnt = sizeof(primes) / sizeof(primes[0]);
            auto pos = lower_bound(primes, primes + cnt, n);
            return pos == primes + cnt ? *(primes + cnt - 1) : *pos;
        }

        vector<Node*> _tables; // 指针数组
        size_t _n = 0;
    };

} // namespace hash_bucket

四、自定义类型的哈希函数

如果 key 不是整数,需要自己写哈希仿函数,把 key 转成能取模的整数。

1. string 的哈希

上面代码里已经特化了 string 版本,用的 BKDR 思路:每次乘一个质数再加字符。

BKDR 是字符串哈希算法,核心:把字符串当成一个基于大基数的多项式,不断累积计算哈希值。

cpp 复制代码
template <>
struct HashFunc<string> {
    size_t operator()(const string& key) const {
        size_t hash = 0;
        for (char ch : key) {
            hash = hash * 131 + ch;
        }
        return hash;
    }
};

比单纯 ASCII 相加好很多,至少 "abcd" 和 "bcad" 不会算出一样的值。

2. 自定义类型示例

比如 Date 类型:

cpp 复制代码
struct Date {
    int _year;
    int _month;
    int _day;
};

struct DateHashFunc {
    size_t operator()(const Date& d) const {
        size_t hash = 0;
        hash = hash * 131 + d._year;
        hash = hash * 131 + d._month;
        hash = hash * 131 + d._day;
        return hash;
    }
};

核心原则:尽量让 key 的每一位都参与运算,减少冲突概率。


总结

  1. 哈希表核心:通过哈希函数把 key 映射成下标,平均 O (1) 增删查。
  2. 哈希冲突不可避免,常用解决方法:开放定址(线性探测)和链地址法(哈希桶)。
  3. 开放定址法:冲突往后找空位;删除要用 DELETE 标记,不能直接清空。
  4. 链地址法:每个位置挂链表,冲突都放链里;扩容可直接移动结点。
  5. 哈希函数:除留余数法最常用,表长取质数减少冲突;自定义 key 要写对应的哈希仿函数。
  6. 负载因子:控制冲突率,到阈值扩容,重新哈希所有元素。

哈希表是用空间换时间的典型结构。如果这篇对你有帮助,点个赞收藏一下~

相关推荐
PC2005-cloud1 小时前
MySQL学习笔记:GTID 主从复制,从原理到实践,告别传统 Binlog+Position 的痛点
笔记·学习·mysql
starsky762382 小时前
Agent学习——上下文工程(二)
学习
积流成海2 小时前
用代码实现哈希计算器:Web Crypto API 实战
前端·算法·哈希算法·哈希函数·盘子工具站
WL学习笔记2 小时前
类和对象(下)
c++·类和对象
傲世仙尊3 小时前
拷贝构造-传值传参的连锁反应与隐式函数的三个局限
c++
垆边人似月.3 小时前
华为机试题 :最大数
c++·算法·华为
Dark_Fairy3 小时前
CMeKG_tools 知识图谱项目速成教程(13课共24H)
学习
悟乙己3 小时前
AFAC2026 赛题四金融长文本 Agent竞赛学习笔记:关联技术(二)
笔记·学习·金融
方便面不加香菜4 小时前
C++ 关于多态的知识点讲解
开发语言·c++