深入理解哈希表:闭散列与开散列两种实现方案解析
哈希表(Hash Table)作为一种高效的数据结构,在查找、插入和删除操作上能达到近似 O (1) 的时间复杂度,广泛应用于数据库索引、缓存系统、关联数组等场景。其核心思想是通过哈希函数将键(Key)映射到表中的特定位置,从而实现快速访问。
然而,哈希函数不可避免会产生哈希冲突(不同的键映射到同一位置),如何处理冲突直接决定了哈希表的实现方式和性能。本文将通过两种完整的 C++ 实现代码,详细解析闭散列(开放定址法)和开散列(链地址法)两种主流冲突解决策略,并对比它们的优缺点与适用场景。
一、哈希表的核心基础
在深入实现前,我们需要明确两个核心概念:
- 哈希函数:将键转换为哈希表索引的函数,要求尽可能均匀分布,减少冲突。本文实现了针对基本类型和
string
类型的哈希函数(string
采用经典的 BKDR 算法,通过 131 作为乘数提升分布均匀性)。 - 负载因子:哈希表中元素个数与表容量的比值(
load_factor = size / capacity
)。负载因子越大,冲突概率越高,因此需要通过扩容来控制负载因子在合理范围。
二、闭散列(开放定址法)实现:bobo1 命名空间
闭散列的核心思想是:当发生哈希冲突时,在哈希表的空闲位置中寻找新的位置存储元素,而非额外开辟空间。本文采用二次探测解决冲突,并通过 “标记删除” 避免影响后续查找。
2.1 关键结构定义
1. 元素状态枚举(State)
由于闭散列中删除元素不能直接置空(会导致后续查找中断),因此需要用状态标记位置的使用情况:
enum State
{EXIST, // 元素存在DELETE, // 元素已删除(标记删除)EMPTY // 位置为空
};
2. 哈希表存储单元(HashData)
每个单元存储键值对(pair<k, v>
)和对应的状态:
template<class k, class v>
struct HashData
{pair<k, v> _kv; // 键值对State _state = EMPTY; // 初始状态为空
};
3. 哈希函数(HashFunc)
- 默认模板:针对
int
等基本类型,直接转换为size_t
。 - 特化版本:针对
string
类型,采用 BKDR 算法,通过字符累加 + 131 乘数提升分布均匀性:
// 默认哈希函数(基本类型)
template<class k>
struct HashFunc
{size_t operator()(const k& _k) { return (size_t)_k; }
};// string类型特化
template<>
struct HashFunc<string>
{size_t operator()(const string& s){size_t ret = 0;for (auto e : s) { ret += e; ret *= 131; }return ret;}
};
2.2 核心操作实现
1. 构造函数与质数表
哈希表的容量需为质数(减少冲突概率),因此通过预定义的质数表,初始化时选择大于 1 的最小质数(53)作为初始容量:
HashTable(): _size(0), _tables(__stl_next_prime(1)) // 初始容量为53
{ }// 寻找大于等于n的最小质数
inline unsigned long __stl_next_prime(unsigned long n)
{static const unsigned long __stl_prime_list[] = {53, 97, 193, 389, ..., 4294967291 // 共28个质数};const unsigned long* pos = lower_bound(__stl_prime_list, __stl_prime_list+28, n);return pos == __stl_prime_list+28 ? *(pos-1) : *pos;
}
2. 查找操作(find)
- 计算初始哈希位置,通过二次探测(
hash1 = (hash0 + i) % 容量
,i
递增)遍历非空位置。 - 遇到
EMPTY
位置时停止(说明元素不存在);遇到EXIST
且键匹配时返回对应地址。
HashData<k, v>* find(const k& key)
{Hash hash;size_t hash0 = hash(key) % _tables.size();size_t hash1 = hash0;size_t i = 1;while (_tables[hash1]._state != EMPTY){if (_tables[hash1]._state == EXIST && _tables[hash1]._kv.first == key)return &_tables[hash1];hash1 = (hash1 + i) % _tables.size(); // 二次探测++i;}return nullptr; // 元素不存在
}
3. 插入操作(insert)
- 先通过
find
判断键是否已存在,避免重复插入。 - 当负载因子≥0.7 时,扩容为当前容量的下一个质数,并将旧表元素重新插入新表(重新哈希)。
- 通过二次探测找到
EMPTY
或DELETE
位置,插入元素并标记为EXIST
。
bool insert(const pair<k, v>& kv)
{if (find(kv.first) != nullptr) return false; // 键已存在// 负载因子过高,扩容if ((double)_size / _tables.size() >= 0.7){HashTable<k, v, Hash> newTable;newTable._tables.resize(__stl_next_prime(_tables.size() + 1));for (auto& e : _tables){if (e._state == EXIST)newTable.insert(e._kv); // 重新插入新表}_tables.swap(newTable._tables); // 交换新旧表}// 二次探测找插入位置Hash hash;size_t hash0 = hash(kv.first) % _tables.size();size_t hash1 = hash0;int i = 1;while (_tables[hash1]._state == EXIST){hash1 = (hash1 + i) % _tables.size();++i;}_tables[hash1]._kv = kv;_tables[hash1]._state = EXIST;++_size;return true;
}
4. 删除操作(erase)
- 通过
find
找到元素位置,采用 “标记删除”(将状态改为DELETE
),而非直接置空。 - 标记删除能避免后续查找时,因中间位置为空导致的查找中断。
bool erase(const k& key)
{auto pos = find(key);if (pos == nullptr) return false; // 元素不存在--_size;pos->_state = DELETE; // 标记删除return true;
}
三、开散列(链地址法)实现:bobo2 命名空间
开散列的核心思想是:哈希表的每个位置(称为 “桶”)存储一个链表的头指针,当发生冲突时,将元素插入对应桶的链表中。这种方式无需寻找空闲位置,实现更简单,且删除操作更高效。
3.1 关键结构定义
1. 链表节点(HashNode)
每个节点存储数据(T
,可自定义类型)和指向下一个节点的指针:
template<class T>
struct HashNode
{T _data; // 存储的数据(如pair<k, v>)HashNode<T>* _next; // 链表指针HashNode(const T& t) : _data(t), _next(nullptr) {}
};
2. 哈希表类模板参数
相比闭散列,开散列增加了KeyOfT
仿函数,用于从存储的T
类型中提取键(K
),灵活性更高(支持存储任意包含键的类型):
template<class K, class T, class KeyOfT, class Hash = HashFunc<K>>
class HashTable
{typedef HashNode<T> Node;
private:size_t _size = 0; // 有效元素个数vector<Node*> _tables; // 桶数组(每个元素是链表头指针)
};
3.2 核心操作实现
1. 构造与析构函数
- 构造函数:初始桶数组大小为 53,每个桶初始化为
nullptr
。 - 析构函数:遍历每个桶,释放链表中所有节点的内存,避免内存泄漏。
// 构造函数
HashTable(): _tables(__stl_next_prime(1), nullptr) // 初始53个桶,均为空, _size(0)
{ }// 析构函数
~HashTable()
{for (int i = 0; i < _tables.size(); ++i){Node* cur = _tables[i];while (cur){Node* next = cur->_next;delete cur; // 释放节点cur = next;}_tables[i] = nullptr; // 桶置空}
}
2. 插入操作(insert)
- 开散列的负载因子通常设为 1(桶数≈元素数),当
_size ≥ _tables.size()
时扩容。 - 扩容时,创建新的桶数组,将旧桶中所有节点重新哈希到新桶(链表节点无需重建,仅调整指针)。
- 插入时采用头插法(效率更高),将新节点插入对应桶的链表头部。
bool insert(const T& t)
{Hash hash;KeyOfT keyoft;if (find(keyoft(t)) != nullptr) return false; // 键已存在// 负载因子≥1,扩容if (_size >= _tables.size()){vector<Node*> newTables(__stl_next_prime(_tables.size() + 1), nullptr);for (int i = 0; i < _tables.size(); ++i){Node* cur = _tables[i];while (cur){Node* next = cur->_next;// 重新计算新桶位置size_t hash0 = hash(keyoft(cur->_data)) % newTables.size();// 头插法插入新桶cur->_next = newTables[hash0];newTables[hash0] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newTables);}// 插入新节点(头插法)size_t hash0 = hash(keyoft(t)) % _tables.size();Node* newNode = new Node(t);newNode->_next = _tables[hash0];_tables[hash0] = newNode;++_size;return true;
}
3. 查找与删除操作
- 查找:计算桶位置,遍历对应链表,通过
KeyOfT
提取键并匹配。 - 删除:遍历链表找到目标节点,调整链表指针(区分头节点和中间节点),释放节点内存。
// 查找操作
Node* find(const K& k)
{Hash hash;KeyOfT keyoft;size_t hash0 = hash(k) % _tables.size();Node* cur = _tables[hash0];while (cur){if (keyoft(cur->_data) == k)return cur;cur = cur->_next;}return nullptr;
}// 删除操作
bool erase(const K& key)
{Hash hash;KeyOfT keyoft;size_t hash0 = hash(key) % _tables.size();Node* pre = nullptr;Node* cur = _tables[hash0];while (cur){if (keyoft(cur->_data) == key){// 处理头节点和中间节点if (pre == nullptr)_tables[hash0] = cur->_next;elsepre->_next = cur->_next;delete cur; // 释放节点--_size;return true;}pre = cur;cur = cur->_next;}return false;
}
四、闭散列与开散列的对比分析
对比维度 | 闭散列(开放定址法) | 开散列(链地址法) |
---|---|---|
空间利用率 | 高(无链表指针开销) | 低(链表节点需额外存储指针) |
冲突处理 | 二次探测寻找空闲位置,冲突易堆积 | 冲突元素存入链表,冲突影响范围小 |
删除操作 | 标记删除(避免查找中断),需定期清理 | 直接删除链表节点,操作高效 |
扩容成本 | 需重新插入所有元素,成本较高 | 仅调整链表指针,成本较低 |
负载因子控制 | 严格(通常≤0.7) | 宽松(通常≤1) |
适用场景 | 元素个数固定、空间紧张的场景 | 动态数据、频繁插入删除的场景 |
五、总结
哈希表的性能核心取决于哈希函数的均匀性和冲突解决策略:
- 闭散列通过紧凑的数组存储实现高空间利用率,但删除操作需 “标记”,扩容成本较高,适合空间受限、数据变动较少的场景。
- 开散列通过链表化解冲突,实现简单、删除高效,扩容成本低,是工业界更常用的方案(如 C++ STL 的
unordered_map
/unordered_set
即采用开散列)。
掌握两种实现方案的原理与差异,能帮助我们在实际开发中根据场景选择更合适的数据结构,优化程序性能。