19、C++:哈希表的实现

引言:为什么哈希表如此重要?

在计算机高速发展的今天,越来越多的信息被存放到了服务器当中,那么,计算机是如何做到在茫茫数据中快速找到用户想要的数据?这一切的答案就是------哈希表。

阅读本篇博客,你将收获:

  • 哈希表的基本原理
  • 如何处理哈希冲突
  • 用C++实现一个简单的哈希表

一、哈希表基础概念

哈希又称散列,是一种组织数据的方式。本质是通过哈希函数把关键字 key 根存储位置建立一个映射关系,查找时通过哈希函数计算出 key 的位置,从而进行快速查找。最简单的哈希表就是将 0~9 依次存放在数组下标0~9的位置,如图1。

1.1、哈希表的核心思想

哈希表的核心思想就是通过关键值找到存储位置的时间复杂度为O(1)。根据给定的关键值 key ,通过哈希函数直接计算出相应的存储位置,其速度非常快。

1.2、哈希函数

哈希函数反映了关键字与存储位置的关系,一般用 h(key) 表示其位置(你可以认为 h 为因变量, key 为自变量 ,h为对应法则),图1的哈希函数为 h(key) = key 。当然,这个哈希函数非常简单,在实际操作中,哈希函数根据程序员设置而百花齐放。当然,哈希函数有几种通用的构成方法,下面将会逐一介绍。

1.3、直接定址法

图1 就是直接定址法的一种,当然,你也可以把 10~19存放在数组下标为0~9的位置,也可以把 a ~z 存放在数组下标为每个字母对应ACSII值的位置。这种哈希实现非常简单,本篇博客不予讨论。

1.4、哈希函数的一般构造方法

1.4.1、除留余数法 / 除法散列法

假设哈希表的大小为 M ,则可令哈希函数为:h(key) = key%M 为下标位置。例如将 5,6,7,8存放在大小为4的哈希表中,如图2:

显然,这样做有个很大的缺点,会同时存在两个 key 映射在一个位置(哈希冲突),所以用此种方法构造哈希函数需要尽量避免 M 为某些值:。如果 M 为 ,则 key%M 相当于保留 key 的后x位。

建议将 M 设为不太接近2的整数次幂的一个质数。

1.4.2、乘法散列法(了解)

此方法对哈希表大小 M 没有要求。先用关键字 key 乘以一个常数 A( 0 < A < 1),并抽取 key*A 的小数部分,再用 M 乘以该小数部分,向下取整即可。

1.4.3、全域散列法(了解)

如果存在一个恶意的竞争对手,破解了我们的哈希函数,他会根据这个哈希函数进行恶意攻击(特意构造出一个发生严重冲突的数据集),解决办法就是给我们的哈希函数增加随机性,攻击者就无法破解到准确的哈希函数了,这种方法就叫全域散列法。

例如:h(key) = [(a*key + b)%p]%M 。p为一个足够大的质数, a为[1 ,p-1] 之间的任意整数, b为[0 , p-1]之间的任意整数,则,不同的 a,b值构成了一个 p*(p-1) 组全域散列组。每次启动时随机选取一个a,一个b。

1.4.4、其他构建方法(了解)

其他方法感兴趣可以下去自己看看。其他方法还有:平方取中法,折叠法,随机数法,数学分析法等。

1.5、哈希冲突

两个不同的 key值映射到同一个位置,这种问题称为哈希冲突(哈希碰撞)。所以在实际设计中,我们要尽量设计一个好的方案,一个好的哈希函数来尽量避免哈希冲突。

1.6、负载因子(载荷因子、装载因子)

若哈希表的大小为 M ,已经映射存储了 N 个值,则负载因子为 。负载因子越大,哈希冲突的概率越高。

1.7、将关键字转化为整数

一般整型才好参与哈希函数的计算,如果 key不是整数,最好要想办法先转化为整数。

二、如何处理哈希冲突?

2.1、开放定址法

当一个 key 计算出的位置冲突了,则可以按照某种规则重新找到一个没有储存数据的位置进行储存。这里的规则一般有3种:线性探测、二次探测、双重探测。

2.1.1、线性探测

从冲突位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾部,就再绕回到哈希表头部,如图3演示。

因为负载因子小于1,所以最多探测 M-1 次就一定能找到一个空位置。

**·群集/堆积:**如果 hash0, hash1, hash2 的位置都有数据了,则后续映射到 hash0, hash1, hash2 的数据都会去争夺 hash3 的位置,这种现象叫做群集,也叫做堆积。显然,这会导致代码效率下降。

2.1.2、二次探测

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

例如: h(key) = hasho = key%M ,如果hash0的位置发生冲突,则可以向右(左)进行二次探测,二次探测的函数为:hc(key,i) = hashi = (hash0 ) %M , i = 1,2,3,4, ...... ,

Note: 当向左二次探测到负数时,则加上 M 即可回到表尾。

通俗点说,线性探测是每次往左右移动1个单位,而二次探测是移动平方个单位。

2.1.3、双重散列(双重探测)(了解)

第一个哈希函数计算出的值发生冲突,则使用第二个哈希函数计算出一个与 key 值有关的偏移量,不断向后探测,直到寻找到下一个没有存储数据的位置为止。

例如:h(key) = hash0 = key%M

hc(key, i) = hashi = ( hash0 + i*h2(key)) % M

2.2、链地址法

把冲突的数据连接成一个链表,挂在哈希表相应的位置下面,链地址法也叫做 拉链法或者哈希桶。哈希桶可以说彻底解决了哈希冲突的问题,如图4,展示了序列 19,8,5,3,2,9,10,1,8,2用哈希桶放入一个大小为10的哈希表。

Note:如果某个桶的长度超过一定长度,可以把这个桶的链表改写为红黑树,提高效率。

三、设计我们的哈希表

3.1、确定基本结构

选用C++语言,被存储的数据元素为 pair类型,用哈希桶来实现。首先,要构造一个哈希链表的结点结构,类似于单链表。

cpp 复制代码
//哈希节点
template<class K, class V>
struct HashNode
{
	std::pair<K, V> _kv;
	HashNode* _next;

	HashNode(std::pair<K, V>& kv, HashNode* next = nullptr)
		:_kv(kv)
		, _next(next)
	{   }
};

然后就可以编写哈希表的基本结构了,选用一个以哈希节点指针为类型的数组作为哈希表的主体

bash 复制代码
//哈希表主体
template<class K,class V>
class HashTable
{
	typedef HashNode Node;
public:
	//构造函数
	HashTable()
		:_num(0)
	{
		//给数组开拓10个空间
		_table.resize(10, nullptr);
	}

	//析构函数
	~HashTable()
	{
		clear();
	}

	//清空哈希表
	void clear()
	{
		//......
	}
private:
	std::vector<Node*> _table;
	size_t _num;    //记录哈希表中数据的个数
};

3.2、核心操作定义

  • size_t HashFun(K& key) :作为仿函数,把其他类型的数据转化为方便哈希计算的整型
  • bool Insert(const std::piar<K,V>):给哈希表插入元素
  • bool Erase(const K& key):给哈希表删除元素
  • Node* Find(cosnt K& key):在哈希表里查找元素,并返回元素所在节点的指针

四、逐步实现哈希表

4.1、size_t HashFun(K& key)

该函数被设计为一个仿函数,目的是为了有更好的灵活度,可以被程序员自己编写的函数替代。对于一般的数据,我们直接强制转换为整型就好:

cpp 复制代码
template<class K>
class HashFun
{
	//将数据类型转化为整型,方便进行哈希存储
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

但是对于其他与整型不接近,无法强制类型转化为整型的数据类型,我们需要进行特化。

  • string 的特化

字符串转化为整型,可以直接取各个字符串的ACSII码值相加,但是这样不同次序,但是字母种类一样的字符串得到的结果是一样的,例如"abcd" 和 "acbd"。

所以,我们可以给字符串的字母根据位置,进行加权:比如给第一个字母的权重为1,第二个字母的权重为2,第三个字母的权重为3,以此类推...... 这样做也有可能会产生相同的结果,因为字符串可以是无限长的,会有无穷多的情况。

这里可以采用 BKDR哈希的思路:hash = hash * seed + character ,其中 seed 是一个质数,character是当前字符对应的ASCII值。这样字符串前面计算的数值,会随着每次的计算而放大,一个微小的改动,都会对最后的结果产生巨大的影响。

cpp 复制代码
template<>
class HashFun<std::string>
{
	size_t operator()(const std::string& s)
	{
		size_t hash = 0;
		for (auto& e : s)
		{
			hash *= 31;
			hash += e;
		}
	}
};

4.2、bool Insert(const std::piar<K,V>)

插入元素,我们先根据仿函数计算出相应的整型,再通过哈希函数得到相应的数组下标,随后选择头插法,插入链表。

cpp 复制代码
bool Insert(std::pair<K,V>& kv)
{
	Hash hash;
	//把数据元素转化为可供哈希计算的整型
	size_t key = hash(kv.first);
	//根据得到的整型,进行哈希计算
	size_t hasi = key % _table.size();

	//创建一个新节点
	Node* newnode = new Node(kv);
	//头插法
	newnode->_next = _table[hashi];
	-table[hasi] = newndoe;
	_num++;
}

Note:这里要说明的是,配图给的哈希桶是具有头结点的单链表,但实际上我们这里的构造是不带头结点的!

完了吗?当然没有,我们缺少了扩容!

  • 哈希表的扩容

我们用哈希桶实现哈希表,理论上一个存储空间下可以链上无数个节点,所以我们还需要扩容?是的,当负载因子等于1时,哈希冲突急剧增加,链表长度也急剧增加,查找效率将会从 O(1)退化到 O(N),所以就需要对哈希表进行扩容。在一些性能要求极高的场所,负载因子达到0.7~0.8时就扩容了。

依次扩容多少是个问题?一般是扩容到下一个质数,要避免哈希表容量是一个合数,减少哈希冲突。我们可以自己编写一个寻找下一个质数的函数,最常用的办法就是直接给出一个质数序列,当然,我们这里给数组的第一个位置放0,是序列的下标与数组下标能够对齐。

cpp 复制代码
static int nextPrime(size_t n)
{
	int arr[20] = { 31,53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317,\
		196613, 393241, 786433,1572869, 3145739, 6291469, 12582917 };
	for (int i = 0; i < 20; i++)
	{
		if (arr[i] >= n)
		{
			return arr[i];
		}
	}
	return arr[19];
}

这里只给出了20个质数的序列,这里根据实际需求给就行。

4.3、bool Erase(const K& key)

删除一个元素,需要先找到相应的存储位置,然后遍历对应的链表。由于单链表的删除需要前一个节点的参与,所以我们需要对删除节点做分类:头结点的删除和中间结点的删除。

cpp 复制代码
bool Erase(const K& key)
{
	Hash hash;
	//计算相应的存储位置
	//size_t hashi = hash(key) % _table.size();
	int key2 = hash(key);
	size_t hashi = key2 % _table.size();
	Node* cur = _table[hashi];
	Node* prev = nullptr;
	while (cur)
	{
		if (cur->_kv.first == key)
		{
			//找到了,该删除了
			//如果是头结点
			if (prev == nullptr)
			{
				_table[hashi] = cur->_next;
				delete cur;
			}
			else
			{
				//如果不是头结点
				prev->_next = cur->_next;
				delete cur;
			}
			return true;
		}
		prev = cur;
		cur = cur->_next;
	}
	return false;
}

4.4、Node* Find(cosnt K& key)

查找一个元素就非常简单了,还是先计算出理论的存储位置,然后对相应的链表遍历。

cpp 复制代码
Node* Find(const K& key)
{
	Hash hash;
	//根据计算得到相应的储存位置
	size_t hashi = hash(key) % _table.size();
	Node* cur = _table[hashi];
	while (cur)
	{
		if (cur->_kv.first == key)
		{
			return cur;
		}
		else
		{
			cur = cur->_next;
		}
	}
	//走到这说明没找到
	return nullptr;
}

五、完整代码和测试代码

5.1、完整代码展示

cpp 复制代码
namespace C_Liu
{
	//哈希节点
	template<class K, class V>
	struct HashNode
	{
		std::pair<K, V> _kv;
		HashNode* _next;

		HashNode(std::pair<K, V>& kv, HashNode* next = nullptr)
			:_kv(kv)
			, _next(next)
		{   }
	};
	
	//哈希函数(把其他类型转化为整型)
	template<class K>
	struct HashFun
	{
		//将数据类型转化为整型,方便进行哈希存储
		size_t operator()(const K& key)
		{
			return (size_t)key;
		}
	};

	//哈希函数(把其他类型转化为整型)的特化
	template<>
	struct HashFun<std::string>
	{
		size_t operator()(const std::string& s)
		{
			size_t hash = 0;
			for (auto& e : s)
			{
				hash *= 31;
				hash += e;
			}
		}
	};

	//哈希表主体
	template<class K,class V,class Hash = HashFun<K>>
	class HashTable
	{
		typedef HashNode<K,V> Node;
	public:
		//寻找下一个质数的函数
		static int nextPrime(size_t n)
		{
			int arr[20] = { 31,53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317,\
				196613, 393241, 786433,1572869, 3145739, 6291469, 12582917 };
			for (int i = 0; i < 20; i++)
			{
				if (arr[i] >= n)
				{
					return arr[i];
				}
			}
			return arr[19];
		}

		//构造函数
		HashTable()
			:_num(0)
		{
			//给数组开拓10个空间
			_table.resize(10, nullptr);
		}

		//析构函数
		~HashTable()
		{
			clear();
		}

		//清空哈希表
		void clear()
		{
			//依次把每个桶释放
			for (int i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur != nullptr)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				_table[i] = nullptr;
			}
		}

		//插入数据
		bool Insert(std::pair<K,V>& kv)
		{
			Hash hash;
			//把数据元素转化为可供哈希计算的整型
			size_t key = hash(kv.first);
			//根据得到的整型,进行哈希计算
			size_t hashi = key % _table.size();

			//当负载因子等于1时扩容
			if (_num == _table.size())
			{
				//扩容
				//将旧表上的各个节点映射到新的表中
				std::vector<Node*> newtable(nextPrime(_table.size() + 1), nullptr);
				for (size_t i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;
						size_t hashi = hash(cur->_kv.first) % newtable.size();
						//采用头插法
						cur->_next = newtable[i];
						newtable[i] = cur;
						//更新 cur
						cur = next;
					}
					//将旧表的相应位置置为空
					_table[i] = nullptr;
				}
				_table = newtable;

			}

			//创建一个新节点
			Node* newnode = new Node(kv);
			//头插法
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			_num++;
			return true;
		}

		//查找功能
		Node* Find(const K& key)
		{
			Hash hash;
			//根据计算得到相应的储存位置
			size_t hashi = hash(key) % _table.size();
			Node* cur = _table[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				else
				{
					cur = cur->_next;
				}
			}
			//走到这说明没找到
			return nullptr;
		}

		//删除某个元素
		bool Erase(const K& key)
		{
			Hash hash;
			//计算相应的存储位置
			//size_t hashi = hash(key) % _table.size();
			int key2 = hash(key);
			size_t hashi = key2 % _table.size();
			Node* cur = _table[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					//找到了,该删除了
					//如果是头结点
					if (prev == nullptr)
					{
						_table[hashi] = cur->_next;
						delete cur;
					}
					else
					{
						//如果不是头结点
						prev->_next = cur->_next;
						delete cur;
					}
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;
		}


	private:
		std::vector<Node*> _table;
		size_t _num;    //记录哈希表中数据的个数
	};

}

5.2、测试代码示例

cpp 复制代码
void test1()
{
    std::cout << "=== 简单哈希表测试 ===\n";

    // 1. 创建一个哈希表
    C_Liu::HashTable<int, std::string> table;
    std::cout << "创建哈希表成功\n";

    // 2. 插入一些数据
    std::pair<int, std::string> kv1(1, "张三");
    std::pair<int, std::string> kv2(2, "李四");
    std::pair<int, std::string> kv3(3, "王五");

    std::cout << "插入数据: ";
    if (table.Insert(kv1)) std::cout << "1 ";
    if (table.Insert(kv2)) std::cout << "2 ";
    if (table.Insert(kv3)) std::cout << "3 ";
    std::cout << "\n";

    // 3. 查找数据
    std::cout << "查找测试:\n";
    auto node = table.Find(2);
    if (node) {
        std::cout << "找到key=2, value=" << node->_kv.second << "\n";
    }

    // 4. 删除数据
    std::cout << "删除测试:\n";
    if (table.Erase(2)) {
        std::cout << "删除key=2成功\n";
    }

    // 5. 再次查找被删除的数据
    node = table.Find(2);
    if (!node) 
    {
        std::cout << "key=2已不存在\n";
    }

    std::cout << "=== 测试完成 ===\n";
}

六、总结

本篇文章简要地介绍了哈希的概念和一些常用的哈希函数的构造方法,并挑选出哈希桶进行简单的模拟实现,希望对各位读者有所帮助。

相关推荐
2013编程爱好者10 小时前
【C++】树的基础
数据结构·二叉树··二叉树的遍历
NEXT0610 小时前
二叉搜索树(BST)
前端·数据结构·面试
化学在逃硬闯CS10 小时前
Leetcode1382. 将二叉搜索树变平衡
数据结构·算法
季明洵13 小时前
Java实现单链表
java·开发语言·数据结构·算法·单链表
elseif12314 小时前
【C++】ST表求RMQ问题--代码+分析
数据结构·c++·算法
tju新生代魔迷15 小时前
数据结构:栈和队列
数据结构
Bear on Toilet15 小时前
树_构建多叉树_41 . 实现Trie(前缀树)
开发语言·数据结构·c++·算法·leetcode
历程里程碑15 小时前
矩阵----=矩阵置零
大数据·线性代数·算法·elasticsearch·搜索引擎·矩阵·散列表
这波不该贪内存的16 小时前
双向链表实现与应用详解
数据结构·链表
he___H17 小时前
数组的全排列
java·数据结构·算法