用哈希表封装 unordered_map 和 unordered_set —— 手撕 C++ 哈希容器

一、为什么要用哈希表封装两个容器?

在 C++ 标准库中,unordered_set 和**unordered_map** 底层都是哈希表。它们的主要区别在于:

  • unordered_set 存储键值本身,且键不可重复、不可修改;

  • unordered_map 存储 键值对(key-value),键不可修改,值可以修改。

既然底层数据结构相同,我们完全可以复用同一份哈希表代码 ,通过模板参数的不同实例化来得到两种容器。这正是 STL 的设计精髓------泛型编程

参考 SGI STL 3.0 版本(C++11 之前)的**hash_set / hash_map** 源码,可以看到它们底层都是**hashtable** ,只不过传递给**hashtable** 的类型参数不同:

cpp 复制代码
// stl_hash_set 简化
template <class Value, class HashFcn = hash<Value>, ...>
class hash_set {
private:
    typedef hashtable<Value, Value, HashFcn, identity<Value>, ...> ht;
    ht rep;
public:
    typedef typename ht::const_iterator iterator;  // 注意:set 的迭代器是 const_iterator
};

// stl_hash_map 简化
template <class Key, class T, class HashFcn = hash<Key>, ...>
class hash_map {
private:
    typedef hashtable<pair<const Key, T>, Key, HashFcn, select1st<pair<const Key, T>>, ...> ht;
    ht rep;
public:
    typedef typename ht::iterator iterator;
};

可以看到,hash_set 传给 hashtable 的模板参数中,Value 出现了两次(表示键值相同),而 hash_map 传入的是 pair<const Key, T>。同时,它们各自提供了一个提取键(ExtractKey) 的仿函数:identity 直接返回自身,select1st 返回 pair 的第一个元素。

我们今天要模拟的正是这种设计。

二、整体框架与 KeyOfT 仿函数

我们的目标:

  • 实现一个通用的 HashTable 类模板,它存储的数据类型为 T,但能够通过一个 KeyOfT 仿函数从中提取出键 K

  • 实现 unordered_set,其元素类型为 K,内部使用 HashTable<K, const K, SetKeyOfT, Hash>

  • 实现 unordered_map,其元素类型为 pair<const K, V>,内部使用 HashTable<K, pair<const K, V>, MapKeyOfT, Hash>

这样,哈希表内部所有的操作(插入、查找、删除、迭代器)都基于键 K 进行,而不依赖 T 的完整类型。

2.1 哈希表节点与哈希函数

我们先定义哈希节点:

cpp 复制代码
template<class T>
struct HashNode
{
	T _data;
	HashNode<T>* _next;
	HashNode(const T& data)
		:_data(data)
		, _next(nullptr)
	{
	}
};

哈希函数我们提供默认版本,并针对 string 进行特化(BKDR 算法):

cpp 复制代码
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};
//除了写仿函数还可以写特化
template<>
struct HashFunc<string>
{
	// BKDR
	size_t operator()(const string& str)
	{
		size_t hash = 0;
		for (auto ch : str)
		{
			hash += ch;
			hash *= 131;//可以很好的避免ASCII码值相同
		}

		return hash;
	}
};

2.2 素数表 ------ 减少哈希冲突

为了让桶的数量尽量是素数,我们采用 STL 的素数表,并实现**__stl_next_prime**函数:

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
{
	// Note: assumes long is at least 32 bits.
	static const int __stl_num_primes = 28;
	static const unsigned long __stl_prime_list[__stl_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 = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

2.3 HashTable 类框架

哈希表内部由**vector<Node*>** 作为桶数组,_n 记录实际元素个数。构造函数中初始桶数为第一个素数。

cpp 复制代码
template<class K, class T, class KeyOfT, class Hash>
class HashTable {
    typedef HashNode<T> Node;
    // ...
public:
    HashTable() {
        _tables.resize(__stl_next_prime(1), nullptr);
    }
    ~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:
    vector<Node*> _tables;
    size_t _n = 0;
};

三、Insert 与 Find ------ 依赖 KeyOfT

哈希表的 Insert 需要:

  1. 通过 KeyOfT 提取出 data 的键;

  2. 检查键是否已存在(调用 Find);

  3. 若不存在,则计算哈希值,处理负载因子(等于1时扩容),头插新节点。

先看 Find

cpp 复制代码
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 { cur,this};
		}
		cur = cur->_next;
	}
	return End();
}

这里 KeyOfT 是一个仿函数,对于 unordered_set,它接受 const K& 返回自身;对于 unordered_map,它接受 const pair<K,V>& 返回 first

接下来是 Insert

cpp 复制代码
pair<Iterator,bool> Insert(const T& data)
{
	KeyOfT kot;
	auto it = Find(kot(data));
	if (it != End())
		return { it,false };
	Hash hs;
	//扩容(负载因子==1)
	if (_n == _tables.size())
	{
		vector<Node*> newtables(__stl_next_prime(_tables.size() + 1));
		for (size_t i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			//当前的桶节点重新映射挂到新表
			while (cur)
			{
				Node* next = cur->_next;
				//插入到新表
				Hash hs;
				size_t hashi = hs(kot(data)) % newtables.size();
				cur->_next = newtables[hashi];
				newtables[hashi] = cur;
				cur = next;
			}
			_tables[i] = nullptr;//以前的桶的数据要变空
		}
		_tables.swap(newtables);
	}
	size_t hashi = hs(kot(data)) % _tables.size();
	//头插
	Node* newNode = new Node(data);
	newNode->_next = _tables[hashi];
	_tables[hashi] = newNode;
	++_n;
	return { Iterator(newNode,this),true };
}

注意 :这里**Insert** 返回**pair<Iterator, bool>** ,后面实现**operator[]**会用到。

四、迭代器的实现 ------ 重点难点

哈希表的迭代器是前向迭代器 (单向),必须支持 ++*->!=== 等操作。

4.1 迭代器需要保存什么?

标准库的哈希表迭代器通常包含两个成员:

  • 当前节点的指针 _node

  • 所属哈希表的指针 _ptr(或引用),用于在某个桶走完后找到下一个非空桶。

我们定义 HTIterator 如下:

cpp 复制代码
// 前置声明
template<class K, class T, class KeyOfT, class Hash>
class HashTable;
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
struct HTIterator
{
	typedef HashNode<T> Node;
	typedef HashTable<K, T, KeyOfT, Hash> HT;
	typedef HTIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
	Node* _node;
	const HT* _ht;

	HTIterator(Node* node, const HT* ht)
		:_node(node)
		, _ht(ht)
	{
	}
	Ref operator*()
	{
		return _node->_data;
	}

	Ptr operator->()
	{
		return &_node->_data;
	}
	Self& operator++()
	{
		if (_node->_next)  // 当前还有节点
		{
			_node = _node->_next;
		}
		else  // 当前桶为空,找下一个不为空的桶的第一个
		{
			size_t hashi = Hash()(KeyOfT()(_node->_data)) % _ht->_tables.size();
			++hashi;
			while (hashi != _ht->_tables.size())
			{
				if (_ht->_tables[hashi])
				{
					_node = _ht->_tables[hashi];
					break;
				}

				hashi++;
			}

			// 最后一个桶的最后一个节点已经遍历结束,走到end()去,nullptr充当end()
			if (hashi == _ht->_tables.size())
			{
				_node = nullptr;
			}
		}

		return *this;
	}
	bool operator!=(const Self& s) const
	{
		return _node != s._node;
	}

	bool operator==(const Self& s) const
	{
		return _node == s._node;
	}
};

RefPtr 的本质用途是**"包裹迭代器底层指针的访问权限"**:

  • Ref 控制 解引用后的读写属性 (是 T& 可读写,还是 const T& 只读);

  • Ptr 控制 operator-> 返回的指针属性 (是 T* 还是 const T*)。

4.2 operator++ 的逻辑

cpp 复制代码
Self& operator++()
{
	if (_node->_next)  // 当前还有节点
	{
		_node = _node->_next;
	}
	else  // 当前桶为空,找下一个不为空的桶的第一个
	{
		size_t hashi = Hash()(KeyOfT()(_node->_data)) % _ht->_tables.size();
		++hashi;
		while (hashi != _ht->_tables.size())
		{
			if (_ht->_tables[hashi])
			{
				_node = _ht->_tables[hashi];
				break;
			}

			hashi++;
		}

		// 最后一个桶的最后一个节点已经遍历结束,走到end()去,nullptr充当end()
		if (hashi == _ht->_tables.size())
		{
			_node = nullptr;
		}
	}

	return *this;
}

关键点:当当前节点无后继时,我们通过 _ht 访问桶数组,从当前桶的下一个位置开始查找第一个非空桶,若找到则指向该桶的第一个节点,否则置 nullptr 作为 end。

4.3 Begin 和 End

Begin() 需要找到第一个非空桶,返回指向该桶首节点的迭代器;End() 返回 nullptr 构造的迭代器。

cpp 复制代码
Iterator Begin()
{
	for (size_t i = 0; i < _tables.size(); i++)
	{
		if (_tables[i])
		{
			return Iterator(_tables[i], this);
		}
	}

	return End();
}

Iterator End()
{
	return Iterator(nullptr, this);
}

ConstIterator Begin() const
{
	for (size_t i = 0; i < _tables.size(); i++)
	{
		if (_tables[i])
		{
			return ConstIterator(_tables[i], this);
		}
	}

	return End();
}

ConstIterator End() const
{
	return ConstIterator(nullptr, this);
}

4.4 友元声明

因为 HTIterator 需要访问 HashTable 的私有成员 _tables,我们需要在 HashTable 中将 HTIterator 声明为友元:

cpp 复制代码
template<class K,class T,class KeyOfT,class Hash>
class HashTable
{
	// 友元声明
	template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
	friend struct HTIterator;

	typedef HashNode<T> Node;
    //...
}

五、封装 unordered_set

unordered_set 存储键值 K,并且键不可修改 。因此我们传递给 HashTableT 类型为 const K,同时提供 SetKeyOfT 仿函数。

cpp 复制代码
namespace HWX
{
	template<class K,class Hash = HashFunc<K>>
	class unordered_set
	{
		struct SetKeyOfT
		{
			const K& operator()(const K& key)
			{
				return key;
			}
		};
	public:
		typedef typename HashTable<K, const K, SetKeyOfT, Hash>::Iterator iterator;
		typedef typename HashTable<K, const K, SetKeyOfT, Hash>::ConstIterator const_iterator;

		iterator begin()
		{
			return _t.Begin();
		}

		iterator end()
		{
			return _t.End();
		}

		const_iterator begin() const
		{
			return _t.Begin();
		}

		const_iterator end() const
		{
			return _t.End();
		}

		pair<iterator, bool> insert(const K& k)
		{
			return _t.Insert(k);
		}
		bool erase(const K& key)
		{
			return _t.Erase(key);
		}

		iterator find(const K& key)
		{
			return _t.Find(key);
		}
	private:
		HashTable<K, const K, SetKeyOfT, Hash> _t;
	};
}

注意:unordered_set 的普通迭代器实际上是哈希表的普通迭代器,但由于存储的是 const K,所以无法通过迭代器修改键值,达到了"不可修改"的效果。

六、封装 unordered_map

unordered_map 存储 pair<const K, V>,键不可改,值可改。同样提供 MapKeyOfT 提取键。

cpp 复制代码
namespace HWX
{
	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 HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::Iterator iterator;
		typedef typename HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::ConstIterator const_iterator;

		iterator begin()
		{
			return _t.Begin();
		}

		iterator end()
		{
			return _t.End();
		}

		const_iterator begin() const
		{
			return _t.Begin();
		}

		const_iterator end() const
		{
			return _t.End();
		}

		pair<iterator, bool> insert(const pair<K, V>& kv)
		{
			return _t.Insert(kv);
		}

		V& operator[](const K& key)
		{
			pair<iterator, bool> ret = insert({ key, V() });

			return ret.first->second;
		}

		bool erase(const K& key)
		{
			return _t.Erase(key);
		}

		iterator find(const K& key)
		{
			return _t.Find(key);
		}
	private:
		HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _t;
	};
}

operator[] 的实现非常简洁:调用 insert 插入键值对(如果不存在则插入默认值),然后返回迭代器指向的 second 引用。这正是标准库的做法。

七、完整实现代码以及测试代码与使用示例

7.1 测试 unordered_set

cpp 复制代码
void test_uset1()
{
	HWX::unordered_set<int> s1;
	s1.insert(45);
	s1.insert(5);
	s1.insert(13);
	s1.insert(45);
	s1.insert(452);
	s1.insert(4513);
	s1.insert(333);
	s1.insert(123);

	Func(s1);
}

对于自定义类型,我们需要提供哈希仿函数和相等比较:

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

	bool operator==(const Date& d) const
	{
		return _year == d._year
			&& _month == d._month
			&& _day == d._day;
	}
};

struct DateHashFunc
{
	// BKDR
	size_t operator()(const Date& d)
	{
		//2025 1 9
		//2025 9 1
		//2025 2 8
		size_t hash = 0;
		hash += d._year;
		hash *= 131;

		hash += d._month;
		hash *= 131;

		hash += d._day;
		hash *= 131;

		return hash;
	}
};


void test_uset2()
{
	HWX::unordered_set<Date, DateHashFunc> s1;
	s1.insert({ 2025, 9, 15 });
	s1.insert({ 2025, 9, 18 });
	auto it = s1.begin();
	while (it != s1.end())
	{
		cout << it->_year <<" "<< it->_month <<" "<< it->_day << endl;
		++it;
	}
	cout << endl;
}

7.2 测试 unordered_map

cpp 复制代码
void test_umap()
{
	HWX::unordered_map<string, string> dict;
	dict.insert({ "insert", "插入" });
	dict.insert({ "sort", "排序" });
	dict.insert({ "test", "测试" });

	for (auto& [k, v] : dict)
	{
		// k += 'x';

		cout << k << ":" << v << endl;
	}

	dict["string"] = "字符串";
	dict["key"];
	dict["key"] = "关键字";
	dict["for"];


	for (auto& [k, v] : dict)
	{
		cout << k << ":" << v << endl;
	}
}

同时我们验证 const 迭代器和不可修改键的特性:

cpp 复制代码
void Func(const HWX::unordered_set<int>& s)
{
	auto it1 = s.begin();
	while (it1 != s.end())
	{
		// *it1 = 1;

		cout << *it1 << " ";
		++it1;
	}
	cout << endl;
}

八、几个关键设计细节总结

  1. KeyOfT 仿函数 :将哈希表与具体容器解耦,哈希表只关心如何从 T 中提取 K,不同的容器提供不同的提取方式。

  2. const K 的使用 :在 unordered_set 中存储 const K,在 unordered_map 中存储 pair<const K, V>,从类型层面禁止修改键。

  3. 迭代器中的哈希表指针 :为了完成 operator++,必须持有哈希表指针才能访问桶数组,这是迭代器实现的常见技巧。

  4. 素数扩容:采用素数表可以有效减少哈希冲突,提高性能。

  5. 负载因子的控制:当元素个数等于桶数时扩容,使得平均每个桶的节点数不超过1,维持常数时间查找。

  6. 友元声明:迭代器需要访问哈希表私有成员,必须声明为友元。

相关推荐
熊猫钓鱼>_>1 小时前
用 OHAudioSuite 空间渲染节点搭一座「3D 有声博物馆」——从三种模式到 FreeBuds 头追的实战记录
c++·3d·ai·harmonyos·arkts·audio·ohaudiosuite
_约书亚_2 小时前
chapter 9 高级线程管理 — 归纳总结
c++
linx2952 小时前
阶段三讲义:类型与对象(Classes & Essential Operations)
c++
飞鸟真人2 小时前
C++ lambda 捕获完整梳理
c++·闭包·lamda
東隅已逝,桑榆非晚2 小时前
c++内存管理
c++·笔记
是个西兰花2 小时前
C++11:线程库与线程安全问题
开发语言·c++
少司府2 小时前
C++进阶:智能指针
开发语言·数据结构·c++·b树·算法·c·智能指针
水饺编程3 小时前
编程数学:三角函数基础01,直角三角函数
c语言·c++·windows·visual studio
初願致夕霞3 小时前
C/C++传统程序内存分布(代码实测)
java·c语言·c++