从零开始的C++学习生活 15:哈希表的使用和封装unordered_map/set

个人主页:Yupureki-CSDN博客

C++专栏:C++_Yupureki的博客-CSDN博客

目录

前言

[1. unordered系列容器详解](#1. unordered系列容器详解)

[1.1 unordered_set和unordered_map基本介绍](#1.1 unordered_set和unordered_map基本介绍)

[1.2 与map/set的主要差异](#1.2 与map/set的主要差异)

[1. 对Key的要求不同](#1. 对Key的要求不同)

[2. 迭代器差异](#2. 迭代器差异)

[1.3 基本使用示例](#1.3 基本使用示例)

unordered_set使用

unordered_map使用

[1.4 哈希相关接口](#1.4 哈希相关接口)

[2. 哈希表底层原理](#2. 哈希表底层原理)

[2.1 哈希概念](#2.1 哈希概念)

哈希冲突

负载因子

将key转为整数

[2.2 哈希函数](#2.2 哈希函数)

[2.2.1 除法散列法/除留余数法](#2.2.1 除法散列法/除留余数法)

[2.2.2 乘法散列法](#2.2.2 乘法散列法)

[2.3 处理哈希冲突](#2.3 处理哈希冲突)

[2.3.1 开放定址法](#2.3.1 开放定址法)

线性探测

二次探测

开放定址法代码实现

[2.3.2 链地址法(哈希桶)](#2.3.2 链地址法(哈希桶))

链地址法代码实现

[2.4 关键问题解决](#2.4 关键问题解决)

Key转换为整型

质数表扩容

[3. 封装实现unordered_map和unordered_set](#3. 封装实现unordered_map和unordered_set)

[3.2 迭代器实现](#3.2 迭代器实现)

哈希表完整实现

unordered_map完整实现

总结

核心知识点总结

实际应用建议


上一篇:从零开始的C++学习生活 14:map/set的使用和封装-CSDN博客

前言

红黑树和AVL树都是高级的增删查改的数据结构,我们甚至利用红黑树封装了map和set在C++的标准库容器中供我们使用。

但是树这种结构终究比较复杂,特别是还得保证效率,实现就更复杂了。因此有前人就实现了相对平民点的数据结构-哈希表,也能提供高效的增删查改

并且为了应对时代趋势,我们还会利用哈希表封装unorderd_map和unorderd_set。

无论你是希望在实际项目中合理选择容器类型,还是想要深入理解哈希表这一重要数据结构,我都将为你提供全面的指导。

1. unordered系列容器详解

1.1 unordered_set和unordered_map基本介绍

unordered_set和unordered_map是基于哈希表实现的关联式容器,具有以下特性:

  • 平均情况下O(1)的查找、插入、删除效率
  • 元素无序存储
  • 支持唯一键(unordered_set)或键值对(unordered_map)
cpp 复制代码
// unordered_set声明
template <class Key,                        // key_type/value_type
          class Hash = hash<Key>,           // hasher
          class Pred = equal_to<Key>,       // key_equal  
          class Alloc = allocator<Key>      // allocator_type
          > class unordered_set;

// unordered_map声明  
template <class Key,                        // key_type
          class T,                          // mapped_type
          class Hash = hash<Key>,           // hasher
          class Pred = equal_to<Key>,       // key_equal
          class Alloc = allocator<pair<const Key,T>> // allocator_type
          > class unordered_map;

1.2 与map/set的主要差异

1. 对Key的要求不同

map/set要求

  • Key支持小于比较(<运算符)

unordered_map/unordered_set要求

  • Key支持转换为整型(用于哈希计算)
  • Key支持相等比较(==运算符)

哈希表的底层实际是vector,需要进行下标的处理,因此key必须得转换成无符号整型,而我们日常传递的key肯定不只是无符号整型,还会是有符号整型,string或者是其他的自定义类型,因此我们必须传递把对应的key转换成无符号整型和比较大小的仿函数,如果不传递就用默认的函数,默认你是无符号整型然后进行处理。

2. 迭代器差异

map/set

  • 双向迭代器
  • 遍历时按键升序排列

unordered_map/unordered_set

  • 单向迭代器
  • 遍历时无序

在我们之后封装哈希表时就会知道,数据存在哈希表中是无需的,不是红黑树中的有序

1.3 基本使用示例

无论是unordered_set和set还是unordered_map和map在实际使用时没有太大的差别

unordered_set使用
cpp 复制代码
#include <iostream>
#include <unordered_set>
#include <string>
using namespace std;

void unordered_set_demo() {
    unordered_set<int> us = {4, 2, 7, 2, 8, 5, 9};
    
    // 插入元素
    us.insert(3);
    us.insert({1, 6, 10});
    
    // 遍历(无序)
    for (const auto& elem : us) {
        cout << elem << " ";
    }
    cout << endl;
    
    // 查找
    if (us.find(5) != us.end()) {
        cout << "5 found" << endl;
    }
    
    // 删除
    us.erase(2);
    cout << "Size after erase: " << us.size() << endl;
    
    // 统计
    cout << "Bucket count: " << us.bucket_count() << endl;
    cout << "Load factor: " << us.load_factor() << endl;
}
unordered_map使用
cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;

void unordered_map_demo() {
    unordered_map<string, string> dict = {
        {"apple", "苹果"},
        {"banana", "香蕉"},
        {"orange", "橙子"}
    };
    
    // 插入
    dict.insert({"grape", "葡萄"});
    dict["peach"] = "桃子";
    
    // 遍历
    for (const auto& pair : dict) {
        cout << pair.first << ": " << pair.second << endl;
    }
    
    // 查找和修改
    if (dict.find("apple") != dict.end()) {
        dict["apple"] = "苹果🍎";  // 修改
    }
    
    // 统计单词频率
    vector<string> words = {"apple", "banana", "apple", "orange", "banana", "apple"};
    unordered_map<string, int> word_count;
    for (const auto& word : words) {
        word_count[word]++;
    }
    
    for (const auto& pair : word_count) {
        cout << pair.first << ": " << pair.second << endl;
    }
}

1.4 哈希相关接口

unordered系列容器提供了一些与哈希策略相关的接口:

cpp 复制代码
unordered_set<int> us;

// 桶相关接口
cout << "Bucket count: " << us.bucket_count() << endl;
cout << "Max bucket count: " << us.max_bucket_count() << endl;

// 负载因子相关
cout << "Load factor: " << us.load_factor() << endl;
cout << "Max load factor: " << us.max_load_factor() << endl;

// 设置最大负载因子
us.max_load_factor(0.8f);

// 重整哈希表,减少冲突
us.rehash(1000);     // 设置至少1000个桶
us.reserve(1000);    // 预留至少1000个元素的空间

关于这些哈希表特有的术语,我们后面会特地地讲到

2. 哈希表底层原理

2.1 哈希概念

哈希(Hash)又称散列,是一种通过哈希函数建立关键字Key与存储位置映射关系的数据组织方式。

哈希表中底层是vector,所以是一个数组。说叫散列,就是因为数据放在哈希表中是相对无序的。但是无序就是完全的瞎搞吗?当然不是,我们可以联想到之前所使用的计数排序,还记得计数排序的时间效率吗?力压群雄,但唯一的缺点就是空间复杂度会开得太多。如果1和10000利用计数排序,就会开辟十万的空间,但实际上只有两个有效数据。

哈希冲突

在哈希表中,对于同样的key,我们可以key = key%_capacity,其中_capacity是vector的容量大小。对于初始容量,我们设置一个值,假设就1和100000有两个值,那么我们先设两个空间看看,之后1%2 = 1,100000%2 = 0,然后按照下标放到vector中,是不是就解决了空间问题?

看起来是这么简单,但实际上坑还是比较多的。如果1和3怎么说? 3%2=1,不就和1冲突了吗,这就是我们所说的哈希冲突,即多个不同的数据最后key值相同。

负载因子

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

  • 负载因子越大:哈希冲突概率越高,空间利用率越高
  • 负载因子越小:哈希冲突概率越低,空间利用率越低

负载因子是我们设计出来来避免哈希冲突的一个变量

将key转为整数

之前说过,我们传的key不可能都是无符号整型,因此我们得自己设计一个仿函数来把key转换成无符号整型

2.2 哈希函数

哈希函数是我们设计出来来尽量避免哈希冲突的方式,是用来对key的加工处理,然后作下标

2.2.1 除法散列法/除留余数法

除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为 映射位置的下标,也就是哈希函数为:h(key)=key%M。

当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。因为key%2^x本质相当于保留key的二进制的后x位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。因此我们最好使用距离2的幂较远且为素数的值

2.2.2 乘法散列法

乘法散列法对哈希表大小M没有要求,这个方法的大思路第一步:用关键字K乘上常数A(0<A<1),并抽 取出k*A的小数部分。第二步:后再用M乘以k*A的小数部分,再向下取整。其中我们一般使用常数A为黄金分割比例,即0.6180339887

乘法散列法对哈希表大小M是没有要求的,假设M为1024,key为1234,A=0.6180339887,A*key = 762.6539420558,取小数部分为0.6539420558, M×((A×key)%1.0)=0.6539420558*1024= 669.6366651392,那么h(1234)=669。

2.3 处理哈希冲突

我们所用的哈希函数只能够尽量避免哈希冲突,但无法完全处理。还是碰到哈希冲突时,我们就得想办法处理

2.3.1 开放定址法

在开放定址法中所有的元素都放到哈希表里,当⼀个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于的。这里的规则有三种:线性探测、二次探测、双重探测。

线性探测

1.从发生冲突的位置开始,依次线性向后 探测,直到寻找到下⼀个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。

h(key) = hash0 = key % M hc(key,i) = hashi = (hash0+i) % M , i = {1,2,3,...,M −1} , hash0位置冲突了,则线性探测公式为: ,因为负载因子小于1, 则最多探测M-1次,一定能找到⼀个存储key的位置。

线性探测的比较简单且容易实现,线性探测的问题假设hash0位置连续冲突,hash0,hash1, hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位 置,这种现象叫做群集/堆积。下面的⼆次探测可以⼀定程度改善这个问题。

二次探测

从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下⼀个没有存储数据的位置为 止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表 尾的位置;

h(key) = hash0 = key % M , hash0位置冲突了,则二次探测公式为: 2 hc(key,i) = hashi = (hash0±i ) % M , i = 2 hashi = (hash0−i )%M M {1,2,3,..., M/2}

当hashi<0时,需要hashi+=M

开放定址法代码实现

开放定址法在实践中,不如下面讲的链地址法,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题。所以开放定址法,我们简单选择线性探测实现即可。

cpp 复制代码
namespace open_address {
    enum State {
        EXIST,
        EMPTY, 
        DELETE
    };
    
    template<class K, class V>
    struct HashData {
        pair<K, V> _kv;
        State _state = EMPTY;
    };
    
    template<class K, class V, class Hash = HashFunc<K>>
    class HashTable {
    private:
        vector<HashData<K, V>> _tables;
        size_t _n = 0;  // 元素个数
        
    public:
        bool Insert(const pair<K, V>& kv) {
            if (Find(kv.first)) return false;
            
            // 负载因子 > 0.7 时扩容
            if (_n * 10 / _tables.size() >= 7) {
                // 扩容逻辑...
            }
            
            Hash hash;
            size_t hashi = hash(kv.first) % _tables.size();
            size_t i = 1;
            
            // 线性探测寻找空位置
            while (_tables[hashi]._state == EXIST) {
                hashi = (hashi + 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 hashi = hash(key) % _tables.size();
            size_t i = 1;
            
            while (_tables[hashi]._state != EMPTY) {
                if (_tables[hashi]._state == EXIST && 
                    _tables[hashi]._kv.first == key) {
                    return &_tables[hashi];
                }
                hashi = (hashi + i) % _tables.size();
                ++i;
            }
            return nullptr;
        }
    };
}
2.3.2 链地址法(哈希桶)

链地址法相当于是vector中的每个元素都变成了链表,冲突的元素利用链表连接即可,因此也被形象地称为哈希桶

链地址法代码实现
cpp 复制代码
namespace hash_bucket {
    template<class T>
    struct HashNode {
        T _data;
        HashNode<T>* _next;
        
        HashNode(const T& data)
            : _data(data)
            , _next(nullptr)
        {}
    };
    
    template<class K, class T, class KeyOfT, class Hash>
    class HashTable {
    private:
        vector<HashNode<T>*> _tables;
        size_t _n = 0;
        
    public:
        bool Insert(const T& data) {
            KeyOfT kot;
            if (Find(kot(data))) return false;
            
            // 负载因子 = 1 时扩容
            if (_n == _tables.size()) {
                vector<HashNode<T>*> new_tables(GetNextPrime(_tables.size()), nullptr);
                // 重新哈希所有元素...
                _tables.swap(new_tables);
            }
            
            Hash hs;
            size_t hashi = hs(kot(data)) % _tables.size();
            
            // 头插法
            HashNode<T>* new_node = new HashNode<T>(data);
            new_node->_next = _tables[hashi];
            _tables[hashi] = new_node;
            ++_n;
            
            return true;
        }
        
        // 其他方法...
    };
}

2.4 关键问题解决

Key转换为整型

Key有多种数据,在这里我们用string为例。转换成整型的一个最好的条件就是避免哈希冲突,因此我们需要设计出合理的转换方式,例如对于string,我们可以每次加上字符的ASCII码值,然后乘上131(前人大佬的方法)

cpp 复制代码
template<class K>
struct HashFunc {
    size_t operator()(const K& key) {
        return (size_t)key;
    }
};

// string特化
template<>
struct HashFunc<string> {
    size_t operator()(const string& key) {
        // BKDR哈希算法
        size_t hash = 0;
        for (auto ch : key) {
            hash *= 131;
            hash += ch;
        }
        return hash;
    }
};
质数表扩容

我们之前说过,哈希表的容量最好是跟2的幂相远并且最好也是素数,那么就有人专门发明了一个素数表供我们使用

cpp 复制代码
inline unsigned long GetNextPrime(unsigned long n) {
    static const int num_primes = 28;
    static const unsigned long prime_list[num_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, 3221225473,
        4294967291
    };
    
    const unsigned long* first = prime_list;
    const unsigned long* last = prime_list + num_primes;
    const unsigned long* pos = lower_bound(first, last, n);
    return pos == last ? *(last - 1) : *pos;
}

3. 封装实现unordered_map和unordered_set

unordered_map和unordered_set的底层都是哈希表

cpp 复制代码
    // MyUnorderedSet.h
    template<class K, class Hash = HashFunc<K>>
    class unordered_set {
        struct SetKeyOfT {
            const K& operator()(const K& key) {
                return key;
            }
        };
        
    public:
        bool insert(const K& key) {
            return _ht.Insert(key);
        }
        
    private:
        hash_bucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
    };

    // MyUnorderedMap.h  
    template<class K, class V, class Hash = HashFunc<K>>
    class unordered_map {
        struct MapKeyOfT {
            const K& operator()(const pair<K, V>& kv) {
                return kv.first;
            }
        };
        
    public:
        bool insert(const pair<K, V>& kv) {
            return _ht.Insert(kv);
        }
        
        V& operator[](const K& key) {
            auto ret = _ht.Insert(make_pair(key, V()));
            return ret.first->second;
        }
        
    private:
        hash_bucket::HashTable<K, pair<K, V>, MapKeyOfT, Hash> _ht;
    };

3.2 迭代器实现

哈希表迭代器的核心在于operator++的实现

为了高效处理,我们所用的哈希表是链地址法,因此每个哈希节点都是链表

那么++就以下两种情况:

1.在链表里面++

2.已走到当前链表的尾部,需要跳到下一个不为空的链表中

那么如何找到下一个不为空的链表?这里我在迭代器内加上哈希表的地址和当前位置的下标,方便我们查找

cpp 复制代码
template<class T,class Ptr,class Ref>
struct HashTableIterator {
	typedef HashNode<T> Node;
	typedef HashTableIterator<T, Ptr, Ref> Self;
	Node* _node;//哈希表的节点
	typename list<T>::iterator _it;//迭代器本体
	vector<Node>* _tables;////哈希表指针
	size_t _bucket_index;//当前位置的下标
    //......
}
cpp 复制代码
Self& operator++()
{
	if (_node && _it != _node->_kv.end()) {//不为末尾在链表内迭代
		++_it;
	}
	if (_it == _node->_kv.end())//为末尾则跳到下一个不为空的链表中
	{
		while (_node)
		{
			++_bucket_index;
			if (_bucket_index < _tables->size())
			{
				_node = &(*_tables)[_bucket_index];
				if (!_node->_kv.empty()) {
					_it = _node->_kv.begin();
					break;
				}
			}
			else
			{
				_node = nullptr;
				_it = typename list<T>::iterator();
				break;
			}
		}
	}
	return *this;
}

实现unordered_map和unordered_set的基本方法跟map和set差别不大,再次不在过多赘述

哈希表完整实现
cpp 复制代码
namespace hash_bucket {
    template<class K, class T, class KeyOfT, class Hash>
    class HashTable {
        template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
        friend struct HTIterator;
        
        typedef HashNode<T> Node;
        
    public:
        typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
        typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;
        
        Iterator Begin() {
            for (size_t i = 0; i < _tables.size(); i++) {
                Node* cur = _tables[i];
                if (cur) {
                    return Iterator(cur, this);
                }
            }
            return End();
        }
        
        Iterator End() {
            return Iterator(nullptr, this);
        }
        
        pair<Iterator, bool> Insert(const T& data) {
            KeyOfT kot;
            Iterator it = Find(kot(data));
            if (it != End()) {
                return make_pair(it, false);
            }
            
            // 扩容逻辑...
            
            Hash hs;
            size_t hashi = hs(kot(data)) % _tables.size();
            
            // 头插
            Node* newnode = new Node(data);
            newnode->_next = _tables[hashi];
            _tables[hashi] = newnode;
            ++_n;
            
            return make_pair(Iterator(newnode, this), true);
        }
        
        Iterator Find(const K& key) {
            KeyOfT kot;
            Hash hs;
            size_t hashi = hs(key) % _tables.size();
            Node* cur = _tables[hashi];
            
            while (cur) {
                if (kot(cur->_data) == key) {
                    return Iterator(cur, this);
                }
                cur = cur->_next;
            }
            return End();
        }
        
    private:
        vector<Node*> _tables;
        size_t _n = 0;
    };
}
unordered_map完整实现
cpp 复制代码
    template<class K, class V, class Hash = HashFunc<K>>
    class unordered_map {
        struct MapKeyOfT {
            const K& operator()(const pair<K, V>& kv) {
                return kv.first;
            }
        };
        
    public:
        typedef typename hash_bucket::HashTable<K, pair<const K, V>, 
                MapKeyOfT, Hash>::Iterator iterator;
        
        iterator begin() {
            return _ht.Begin();
        }
        
        iterator end() {
            return _ht.End();
        }
        
        pair<iterator, bool> insert(const pair<K, V>& kv) {
            return _ht.Insert(kv);
        }
        
        V& operator[](const K& key) {
            pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
            return ret.first->second;
        }
        
        iterator find(const K& key) {
            return _ht.Find(key);
        }
        
        bool erase(const K& key) {
            return _ht.Erase(key);
        }
        
    private:
        hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
    };
    
    void test_unordered_map() {
        unordered_map<string, string> dict;
        dict.insert({"sort", "排序"});
        dict.insert({"left", "左边"});
        dict.insert({"right", "右边"});
        
        dict["left"] = "左边,剩余";
        dict["insert"] = "插入";
        
        for (auto it = dict.begin(); it != dict.end(); ++it) {
            // it->first 是const,不能修改
            // it->second 可以修改
            it->second += "x";
            cout << it->first << ": " << it->second << endl;
        }
    }
}

总结

核心知识点总结

  1. 性能优势:unordered_map/unordered_set在平均情况下提供O(1)的查找、插入、删除性能,在大多数场景下优于map/set的O(logN)性能。

  2. 数据结构选择

    • 需要有序遍历:选择map/set
    • 追求极致性能:选择unordered_map/unordered_set
    • 内存敏感:根据实际情况测试选择
  3. 哈希表设计要点

    • 优秀的哈希函数减少冲突
    • 合理的负载因子控制空间效率
    • 合适的冲突解决策略
  4. 工程实践

    • 预分配空间(reserve)提升性能
    • 自定义哈希函数优化特定类型
    • 监控负载因子避免性能退化

实际应用建议

  1. 字符串处理:unordered_map<string, T>在词频统计、缓存实现等场景表现优异。

  2. 大数据处理:在需要快速查找的海量数据场景中,哈希表的O(1)平均复杂度优势明显。

  3. 缓存系统:LRU Cache等缓存系统通常基于哈希表+链表的组合实现。

哈希表作为计算机科学中最重要的数据结构之一,其思想和应用贯穿于各个领域。通过深入理解其原理和实现,我们不仅能够更好地使用STL提供的容器,还能够在需要时自己实现特定优化的哈希结构,解决实际问题。

相关推荐
向阳逐梦3 小时前
嵌入式软件算法之PID闭环控制原理
1024程序员节
忧郁的橙子.3 小时前
Kubernetes Calico 网络故障排查与修复:RBAC 权限问题完整解决记录
1024程序员节
我是华为OD~HR~栗栗呀3 小时前
华为OD-Java面经-21届考研
java·c++·后端·python·华为od·华为·面试
Mr.Jessy3 小时前
JavaScript学习第六天:函数
开发语言·前端·javascript·学习·html·1024程序员节
LEEBELOVED3 小时前
R语言高效数据处理-变量批量统计检验
1024程序员节·r语言高效处理数据
源来猿往3 小时前
基于window/ubuntu安装rknn-toolkit2【docker】
docker·1024程序员节·rknn-toolkit2
生物小卡拉3 小时前
指定列交集内容合并-Rscript_v1.0
笔记·学习·r语言
千禧皓月3 小时前
【Diffusion Model】发展历程
人工智能·深度学习·diffusion model·1024程序员节