【数据结构】哈希表的C++实现与封装

前言:前面实现mapset时,底层使用的是搜索树,查找、插入和删除的时间复杂度可以稳定在O(log N),并且遍历结果是有序的。这篇文章继续认识另一类关联结构------哈希表。它不再沿着树逐层比较,而是通过哈希函数直接计算数据应该出现的位置;在分布比较均匀时,增删查的平均效率可以达到O(1)

这篇的代码量确实比较大我也是陆陆续续完成的,我实际上写了开放地址法和链地址法的哈希表,但我只封装了链地址法的mapset,因为桶哈希比较实用,库里面也是这么做的,但好像有人写的版本是在桶里挂个红黑树???,虽然能提升查找速度但感觉一般情况下不会这么极端,所以我这方面我也没去了解太多(不会写。。。)


1.哈希表的基本概念

1.1 从直接定址法到哈希映射

假设关键字全部集中在[0, 99]之间,那么最简单的做法就是开一个长度为100的数组,直接把关键字当作下标。小写字母也类似,可以使用字符 - 'a'得到[0, 26)之间的位置。这种方法就是直接定址法,它建立的是一种非常直接的key -> 存储位置关系。

直接定址法效率很高,但是它对关键字范围要求比较苛刻。如果只存几个数,关键字却分散在一个很大的范围中,按照最大关键字开数组会浪费大量空间。因此更一般的做法是准备一个大小为M的数组,再通过哈希函数把关键字转换到[0, M)之间:

text 复制代码
hash_address = hash(key) % M

哈希函数的目标不是从理论上彻底消灭冲突,因为当关键字可能的取值数量大于数组位置数量时,冲突通常无法避免。我们真正希望的是让数据尽量均匀地分布在各个位置,降低冲突发生的频率,并准备好冲突发生以后的处理方法。

1.2 负载因子与复杂度

哈希表中已经存储的有效元素个数记为N,表长或桶数记为M,负载因子可以写成:

text 复制代码
load_factor = N / M

负载因子越大,空间利用率越高,但冲突概率和平均探测长度也会增加;负载因子越小,冲突通常更少,不过空闲空间会更多,所以哈希表需要在时间和空间之间做取舍。

开放定址法的所有元素都直接放在数组槽位中,因此必须保留可供探测终止和新元素插入的空位,负载因子需要小于1。链地址法把冲突元素挂在桶下的链表中,负载因子可以超过1,但这并不表示越大越好,因为平均链长仍会随之增加。当前实现让开放定址表在负载因子大约达到0.7时扩容,哈希桶则在元素个数等于桶数时扩容。

在哈希函数分布较均匀、负载因子受到控制的情况下,哈希表插入、查找和删除的平均时间复杂度可以看成O(1);极端情况下,如果大量关键字集中到同一探测区间或同一个桶中,单次操作仍可能退化到O(N)。扩容本身需要重新处理已有元素,复杂度是O(N),所以这里所说的插入平均效率还包含了摊还分析,不能理解成任何一次操作都绝对只执行常数步。

1.3 哈希函数与非整数key

当前代码主要使用除留余数法,也就是把哈希仿函数得到的整数再对表长取模。表长如果与数据的某些规律重合,冲突可能会明显增加,因此实现中准备了一组逐渐增大的质数,每次寻找不小于目标值的下一个表长。

默认的HashFunc<K>适合能够转换成size_t的key;std::string不能直接转成整数,所以单独进行了模板特化,让每个字符和字符顺序都参与计算:

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

// 因为string比较常用也特化下
template<>
struct HashFunc<std::string>
{

	size_t operator()(const std::string& s) const
	{
		size_t ret = 0;
		for (const auto& e : s)
		{
			ret += e;
			ret *= 131;
		}

		return ret;
	}
};

// 这里是拷贝的远古标准库的做法
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 = std::lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

如果key是自定义类型,就需要提供能够处理该类型的哈希仿函数,同时还要支持key之间的相等比较。

这里最基本的一条约束是:两个相等的key必须得到相同的哈希值,否则插入和查找会落到不同位置。乘法散列、全域散列等方法也可以设计哈希函数,不过当前实现只围绕除留余数法展开。

2.开放定址法及其C++实现

2.1 线性探测解决冲突

开放定址法把所有元素直接保存在数组中。假设Hash0位置已经被其他元素占用,线性探测就从当前位置开始依次向后寻找,走到数组末尾后再绕回开头:

text 复制代码
Hashi = (Hash0 + i) % M,i = 1、2、3......

例如表长为11时,19 % 1130 % 11都等于8,所以19先放在8号位置,30再向后探测到9号位置。如果后面插入20,它的初始位置就是9,还要继续探测到10。

线性探测实现简单,但连续冲突的数据容易形成一片聚集区域,后续映射到附近的元素也会参与争夺。二次探测会按照平方距离跳跃,双重散列则用第二个哈希函数产生步长,它们都能从不同角度改善聚集问题;不过本篇代码实际采用的是线性探测,所以其他方法只需要知道思路即可。

2.2三种槽位状态

开放定址法不能在删除元素时直接把槽位恢复成"从未使用"。假设30因为冲突被放到9号位置,20又被放到10号位置;如果删除30后把9号位置改成普通空位,那么查找20从9开始时会误以为探测链已经结束,导致已经存在的20查找失败。

因此每个槽位需要保存三种状态:

  • EMPTY:从未存放过有效元素,查找走到这里可以停止。
  • EXIST:当前保存着有效元素。
  • DELETE:以前保存过元素但已经删除,查找必须继续向后,插入时可以重新利用。

对应结构如下:

cpp 复制代码
enum State
{
	EXIST,
	EMPTY,
	DELETE
};
cpp 复制代码
template<typename K, typename V>
struct Hashdata
{
	std::pair<K, V> _kv;
	State _state = EMPTY;
};

这也是图中删除30以后仍保留DELETE标记的原因。它不再算有效元素,却依然维持着后面元素的探测路径。

2.3 插入、扩容、查找与删除

开放定址表初始大小取素数表中的第一个值。当代码中的整数比例判断达到大约0.7时,先申请更大的表,再把所有状态为EXIST的元素重新插入。扩容后数组长度已经改变,原位置不能直接照搬,所有有效key都要使用新的表长重新计算位置。

完整的核心接口如下:

cpp 复制代码
HashTable()
	:_tables(__stl_next_prime(0))
	, _n(0)
{ }

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

	// 扩容
	if ((_n * 10) / _tables.size() >= 7)
	{
		HashTable<K, V, Hash> newHash;
		newHash._tables.resize(__stl_next_prime(_tables.size() + 1));

		// 拷贝原数据
		for (const auto& e : _tables)
		{
			if (e._state == EXIST)
			{
				newHash.Insert(e._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;
	// 处理全删除时产生的边界死循环问题
	// 添加一个计数器统计
	size_t count = 0;
	while (_tables[Hashi]._state != EMPTY && count < _tables.size())
	{
		if (key == _tables[Hashi]._kv.first && _tables[Hashi]._state == EXIST)
		{
			return &_tables[Hashi];
		}

		Hashi = (Hash0 + i) % _tables.size();
		i++;
		++count;
	} 

	return nullptr;
}

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

	return false;
}

private:
	std::vector<Hashdata<K, V>> _tables;
	size_t _n;

Insert先调用Find排除重复key,找到的槽位只要不是EXIST就可以写入,因此删除标记能够被复用。Find遇到DELETE不会停止,只有遇到EMPTY才说明这条探测链后面不可能存在目标;同时代码用count限制最多检查整张表,避免所有槽位都曾经使用过、表中没有EMPTY时循环一圈又一圈。

Erase也没有真的移动后面的元素,只把状态改成DELETE并让有效元素个数减一。这样删除成本较低,不过删除标记积累以后也可能增加探测长度,而扩容重建会顺便只迁移仍然有效的数据。

3.链地址法与哈希桶实现

3.1 哈希桶的结构

开放定址法发生冲突以后会继续占用其他数组位置,元素之间容易互相影响。链地址法换了一种思路:数组中不再直接保存数据,而是保存链表头指针;多个key映射到同一个下标时,就把对应结点连接到这个桶下面,因此它也叫拉链法或哈希桶。

每个结点只需要保存实际数据和下一个结点的指针。

为了让底层哈希表既能保存set中的单个key,也能保存map中的键值对,这里的结点直接使用泛型 T

cpp 复制代码
template<typename T>
struct HashNode
{
	T _data;
	HashNode<T>* _next;

	HashNode(const T& data)
		:_data(data)
		, _next(nullptr)
	{ }

};

查找时先用key计算桶号,然后只遍历当前桶下面的链表。正常分布下每条链都比较短,平均效率就能接近O(1);极端情况下所有数据都进入同一个桶,整张表会退化成一条链,查找也会退化到O(N)

3.2 插入、扩容、查找与删除

哈希桶中的负载因子可以大于1,但平均链长也会随负载因子增加。当前实现在_n == _tables.size()时扩容,也就是准备插入新元素而已有元素个数等于桶数时,选择素数表中的下一个桶数。

扩容时不需要重新创建所有数据结点。代码遍历旧桶,把每个结点从旧链上摘下,按照新桶数重新计算位置,再头插到newtables对应链表中。真正变化的是_next和桶头指针,结点对象本身可以继续使用。

实际桶数仍由代码中的素数表决定。

插入实现如下:

cpp 复制代码
std::pair<Iterator, bool> Insert(const T& data)
{
	KeyOfT kot;
	Iterator it = Find(kot(data));
	if (it != End())
	{
		return { it, false };
	}


	// 负载因子等于1就扩容
	if (_n == _tables.size())
	{
		Hash hash;
		std::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;
				size_t hashi = hash(kot(cur->_data)) % newtables.size();

				cur->_next = newtables[hashi];
				newtables[hashi] = cur;

				cur = next;
			}
			_tables[i] = nullptr;
		}

		newtables.swap(_tables);
	}


	// 完成头插入
	Hash hash;
	Node* newNode = new Node(data);
	size_t Hash0 = hash(kot(data)) % _tables.size();
	newNode->_next = _tables[Hash0];
	_tables[Hash0] = newNode;
	++_n;

	
	return { Iterator(newNode, this) , true };
}

Insert返回pair<Iterator, bool>。如果key已经存在,迭代器指向原元素,布尔值为false;插入成功时,迭代器指向新结点,布尔值为true。这个返回值后面还会被unordered_map::operator[]复用。

查找与删除只处理目标桶。删除时要区分待删结点是不是链表头:如果是头结点,直接修改桶头;否则让前一个结点跳过它。

cpp 复制代码
Iterator Find(const K& key)
{
	Hash hash;
	KeyOfT kot;
	size_t Hashi = hash(key) % _tables.size();
	Node* cur = _tables[Hashi];
	while (cur)
	{
		if (kot(cur->_data) == key)
		{
			return Iterator(cur, this);
		}
		else
		{
			cur = cur->_next;
		}
	}

	return Iterator(nullptr, this);
}

ConstIterator Find(const K& key) const
{
	Hash hash;
	KeyOfT kot;
	size_t hashi = hash(key) % _tables.size();
	Node* cur = _tables[hashi];

	while (cur)
	{
		if (kot(cur->_data) == key)
			return ConstIterator(cur, this);

		cur = cur->_next;
	}

	return End();
}

bool Erase(const K& key)
{
	KeyOfT kot;
	Hash hash;
	size_t hashi = hash(key) % _tables.size();

	Node* prev = nullptr;
	Node* cur = _tables[hashi];

	while (cur)
	{
		if (kot(cur->_data) == 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;
}

3.3 深拷贝与资源释放

哈希桶内部管理的是动态申请的链表结点,所以默认浅拷贝会让两个对象指向同一批结点。拷贝构造需要逐桶复制链表;赋值运算符使用传值加交换,让形参副本负责释放原来的资源;析构则逐桶删除所有结点。

cpp 复制代码
HashTable()
	:_tables(__stl_next_prime(0))
	,_n(0)
{ }

HashTable(const HashTable& tmp)
	: _tables(tmp._tables.size())
	, _n(tmp._n)
{
	for (size_t i = 0; i < tmp._tables.size(); i++)
	{
		Node* cur = tmp._tables[i];
		Node* prev = nullptr;

		while (cur)
		{
			Node* newNode = new Node(cur->_data);

			if (prev == nullptr)
			{
				_tables[i] = newNode;
			}
			else
			{
				// 后续节点接到前一个节点后面
				prev->_next = newNode;
			}

			prev = newNode;
			cur = cur->_next;
		}
	}

	// 好像也可以复用 Insert ,但写都写了直接用吧...
}

HashTable& operator=(HashTable tmp)
{
	_tables.swap(tmp._tables);
	_n = tmp._n;
	return *this;
}

~HashTable()
{
	for (size_t i = 0; i < _tables.size(); i++)
	{
		Node* next = nullptr;
		Node* cur = _tables[i];
		while (cur)
		{
			next = cur->_next;
			delete cur;
			cur = nullptr;
			cur = next;
		}

		_tables[i] = nullptr;
	}
}

4.通用哈希表与迭代器

4.1 KeyOfT解决两种数据模型

如果底层哈希表直接把数据类型写死成pair<K, V>,它就只能服务map;如果写死成K,又无法保存map中的value。这里把模板参数拆成了几个不同职责:

  • K表示真正参与哈希和比较的key类型。
  • T表示结点里完整保存的数据类型。
  • KeyOfT负责从一个T对象中取出key。
  • Hash负责把key转换成size_t整数。

对于unordered_setT就是const KSetKofT直接返回元素自身;对于unordered_mapTpair<const K, V>MapKofT返回first。这样InsertFindErase和扩容代码只需要写一份,任何需要key的地方都统一调用kot(data)

4.2 迭代器如何跨桶移动

在当前这种"桶数组+单链表"的实现中,哈希桶的迭代器不能只保存一个结点指针。当前结点后面还有链表结点时,++确实只需要走_next;但是当前桶已经结束时,迭代器还要知道整张哈希表的位置分布,才能寻找后面的第一个非空桶。因此迭代器同时保存_node_ht两个指针。

实现如下:

cpp 复制代码
// 由于迭代器类与哈希表类互相依赖所以加个前置声明
template<typename K, typename T, typename KeyOfT, typename Hash = HashFunc<K>>
class HashTable;

template<typename K, typename T, typename Ref, typename Ptr, typename KeyOfT, typename Hash>
class HashIterator
{
	typedef HashNode<T> Node;
	typedef HashTable<K, T, KeyOfT, Hash> Ht;
	typedef HashIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
public:
	
	Node* _node;
	const Ht* _ht;
	HashIterator(Node* node, const Ht* ht)
		:_node(node)
		, _ht(ht)
	{ }

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

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

	Ref operator*() const
	{
		return _node->_data;
	}

	Ptr operator->() const
	{
		return &_node->_data;
	}

	Self& operator++()
	{
		if (_node->_next)
		{
			_node = _node->_next;
		}
		else
		{
			const Ht* Hashtables = _ht;
			KeyOfT kot;
			Hash hash;
			// 这里访问了 _tables 而它是私有的所以迭代器类要
			// 作为哈希表类的友元 :D
			size_t Hashi = hash(kot(_node->_data)) % Hashtables->_tables.size();
			Hashi++;
			while (Hashi < Hashtables->_tables.size())
			{
				_node = Hashtables->_tables[Hashi];
				if (_node)
				{
					return *this;
				}
				++Hashi;

			}
		}

		_node = nullptr;
		return *this;
	}
};

_node->_next存在时,迭代器在当前链表中前进;否则先根据当前结点的key重新得到桶号,再从下一个桶开始向后寻找。后面再也没有非空桶时,把_node设成nullptr,它就与End()相等。

4.3 普通迭代器与常量迭代器

HashIterator把引用和指针类型分别抽成RefPtr,于是同一个类模板就能实例化出普通迭代器和常量迭代器。哈希表还要把迭代器声明为友元,因为跨桶时需要访问私有的_tables

cpp 复制代码
template<typename K, typename T, typename KeyOfT, typename Hash>
class HashTable
{
	// 友元声明
	template<typename K1, typename T1, typename Ref, typename Ptr, typename KeyOfT1, typename Hash1>
	friend class HashIterator;

	typedef HashNode<T> Node;

public:
	
	typedef HashIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;
	typedef HashIterator<K, T, const T&, const T*, KeyOfT, Hash> ConstIterator;

	Iterator Begin()
	{
		if (_n == 0)
			return { nullptr, this };

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

		return { nullptr, this };
	}

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

	ConstIterator Begin() const
	{
		if (_n == 0)
			return { nullptr, this };

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

		return { nullptr, this };
	}

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

Begin()从0号桶开始寻找第一个非空桶,End()则统一用空结点表示。对于set,即使调用的是普通Iterator,它的T本身也是const K,所以key仍然不能修改;对于map,Tpair<const K, V>,因此first不能修改,second可以修改。

5.封装unordered_set与unordered_map

5.1 unordered_set的封装

unordered_set只需要准备SetKofT,再把底层哈希表的接口向外转发。这里故意让哈希表保存const K,从类型上阻止迭代器修改key,因为key一旦被改掉,它所在的桶就可能与新的哈希值不一致。

cpp 复制代码
namespace jy
{
	template<typename K, typename Hash = HashFunc<K>>
	class unordered_set
	{
		// 这里是为了和map对齐
		struct SetKofT
		{
			const K& operator()(const K& key) const
			{
				return key;
			}
		};

		typedef typename Hash_bucket::HashTable<K, const K, SetKofT, Hash>::Iterator iterator;
		typedef typename Hash_bucket::HashTable<K, const K, SetKofT, Hash>::ConstIterator const_iterator;
	public:
		iterator begin()
		{
			return _ht.Begin();
		}

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


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

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

		std::pair<iterator, bool> insert(const K& key)
		{
			return _ht.Insert(key);
		}

		iterator find(const K& key)
		{
			return _ht.Find(key);
		}

		const_iterator find(const K& key) const
		{
			return _ht.Find(key);
		}

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


	private:
		// 实际中我们一般不期望 key 被修改,所以直接写成 const K
		Hash_bucket::HashTable<K, const K, SetKofT, Hash> _ht;

	};


}

外层容器不需要知道桶数组如何扩容,也不需要自己实现查找和删除。

5.2 unordered_map的封装

map结点保存的是pair<const K, V>const K保证key不能通过迭代器修改,V仍然是普通类型,所以value可以修改。MapKofT接收整个键值对并返回first,底层哈希表就能继续只围绕key计算桶号和判断相等。

cpp 复制代码
namespace jy
{
	template<typename K, typename V, typename Hash = HashFunc<K>>
	class unordered_map
	{
		struct MapKofT
		{
			const K& operator()(const std::pair<const K, V>& kv) const
			{
				return kv.first;
			}
		};

		typedef typename Hash_bucket::HashTable<K, std::pair<const K, V>, MapKofT, Hash>::Iterator iterator;
		typedef typename Hash_bucket::HashTable<K, std::pair<const K, V>, MapKofT, Hash>::ConstIterator const_iterator;
	public:
		iterator begin()
		{
			return _ht.Begin();
		}

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


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

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

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

		iterator find(const K& key)
		{
			return _ht.Find(key);
		}

		const_iterator find(const K& key) const
		{
			return _ht.Find(key);
		}

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

		// map 这里需要支持一下[]
		V& operator[](const K& key)
		{
			std::pair<iterator, bool> ret = insert({ key, V() });
			return ret.first->second;
		}

	private:
		Hash_bucket::HashTable<K, std::pair<const K, V>, MapKofT, Hash> _ht;

	};


}

6.总结

当前unordered_set和`unordered_map只是围绕核心原理完成的精简封装,我主要还是以学习的目的为主,所以只实现了几个接口,实现上实际参考了STL库的想法,写的肯定是玩具性质的,写的过程也磕磕绊绊的但好在感觉有点收获,也写到了大佬的想法,换是我肯定就肯定为 map 和 set 分别写哈希表了,也体现出泛型编程的强大


相关推荐
重生之后端学习1 小时前
239. 滑动窗口最大值[困难]✅
java·数据结构·算法·leetcode·职场和发展
fpcc2 小时前
算法和数据结构—动态规划法
数据结构·算法·动态规划
乐观勇敢坚强的老彭3 小时前
C++信奥GESP四级的常见题型和解题模板速查表
开发语言·c++
欧特克_Glodon3 小时前
OpenCV计算机视觉开发入门与实践<二十二>:图像平滑之线性滤波
c++·人工智能·opencv·计算机视觉
沙盘客3 小时前
AFSIM 示例解读(09)· 传感器全家桶 sensor_demos(下):ESM / SAR / 被动测向
c++·经验分享·后端
大鹏的NLP博客4 小时前
WSL2 资源治理:从 ONNX Runtime CUDA 编译 OOM 到 `.wslconfig` 防御配置
c++·深度学习·onnx runtime
星星.7224 小时前
C++算法竞赛|二分查找与二分答案:边界模板、浮点二分、STL
数据结构·c++·算法
动词ing4 小时前
【学习笔记】数据结构(数组长度和关键特性+快慢指针+左右指针)
数据结构·笔记·学习
老当益壮梁奶奶4 小时前
Linux 软件编程学习笔记(六):线程编程入门与实践
linux·c语言·c++·笔记·学习