数据结构:哈希表讲解

哈希表

1.哈希概念

哈希:一种映射思想 ,也叫散列。即关键字和另一个值建立一个关联关系。注意这里指的关联关系是多样的 ,比如给你关键字,你可以通过映射关系确定该值在不在或者获得其它信息,不一定要存储另一个值。

哈希表 :也叫散列表,体现了哈希思想。即关键字和存储位置建立关联关系,这里的关系是比较具体的 。通常是哈希表中存储键值对 ,通过key来找到键值对的存储位置,从而进行对value的快速查找。

哈希表主要用来提高搜索效率,这里对比一下:

  • 顺序表:时间复杂度为O(N),暴力查找。
  • 平衡搜索树:时间复杂度为O( l o g 2 n log_2 n log2n),效率稳定,也比较快。
  • 哈希表:平均时间复杂度为O(1),平均常数级别查找(这里是平均复杂度,哈希表存在极端情况退化,后面会分析)。

2.通过关键码确定存储位置

2.1哈希方法

我们通常会对关键码进行转化来确定存储位置,这个转化的方式即为哈希方法 ,哈希方法中使用的转化函数称为哈希函数(方法是一种指导,哈希函数设计可以存在差别)。

⭐哈希函数关系哈希表中的两个常用操作:

  1. 插入元素
    根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
  2. 查找元素
    对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功

本文主要讲两种哈希方法:

  • 直接地址法
  • 除留余数法

2.2直接定址法

该方法的哈希函数hashi = a * key + b (其中a、b为自定义的常数,a != 0)。
概念值和位置建立唯一关系

适用场景关键码比较集中 的情况

(比如统计字母出现次数,关键码为字母,都集中在一段小区间)。

缺点 :对于关键码分散 的情况,会造成严重的空间浪费

2.3除留余数法

该方法的哈希函数hashi = key % len (其中hashi表示存储下标,key表示关键码,len表示哈希表的长度)。
概念 :通过对关键码的转化,让存储位置落在哈希表现有空间中

适用场景 :关键码集中或者分散都可以用这个方法 ,通过哈希函数计算后存储位置都是落在一段固定的空间内。

缺点不同的关键码通过哈希函数计算出的存储位置可能相同 ,从而引起冲突。这个现象称为哈希冲突,解决哈希冲突是后面的核心。

3.哈希冲突概念

概念不同关键字通过相同哈希函数计算出相同的哈希地址 ,该种现象称为哈希冲突或哈希碰撞

哈希冲突的发生与哈希函数有关,哈希函数设计的越合理哈希冲突就越少,这里介绍一下几种哈希方法:

  • 直接定址法(常用),前面分析过,不讲。
  • 除留余数法(常用),前面分析了,不讲。
  • 平方取中法(了解)
    假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
    再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址;
    平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
  • 折叠法(了解)
    折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
    折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
  • 随机数法(了解)
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
    通常应用于关键字长度不等时采用此法

小结:

  1. 直接定址法不存在哈希冲突 ,但适用场景比较局限
  2. 其它几种方法都存在哈希冲突的可能,以除留余数法 为例,适用场景更加广泛。本文采用的哈希方法是除留余数法。

4.解决哈希冲突

解决哈希冲突两种常见的方法是:闭散列开散列

4.1闭散列

4.1.1概念

闭散列 :又称开放定址法,即当前位置被占用(哈希冲突),在开放空间内按某种规则,找一个没被占用的位置存储。

至于寻找未被占用位置的方法,这里讲两种:

  • 线性探测
    从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
    hashi = hashi + i(i >= 0),重复这个过程。
  • 二次探测
    探测的公式变化了而已。
    hashi = hashi + i ^ 2,重复这个过程直到寻找到空位置。

其它细节

当探测过程中hashi超过了哈希表长度n ,要进行一次取余来修正下标,即hashi = hashi % n。不用担心哈希表中找不到空位置,后面会对哈希表扩容。

4.1.2哈希表扩容

负载因子 :即 _n / _table.size() ,前者为插入的元素个数,后者为哈希表的空间大小。为了减少探测的寻找次数,我们一般会控制负载因子在0.7以下,超过0.7进行扩容

哈希表扩容的要点:
不能直接申请空间后拷贝 ,因为我们原先确定存储位置依据 hashi = hashi % len (len为哈希表长度),现在len发生了变化,值与存储位置的映射已经发生变化,需要重新建立映射

⭐哈希表扩容的本质:

当冲突较多的时候,扩容重新建立映射可以有效的减少冲突 ,因此哈希表查找效率退化的情况是非常少见 的。

4.1.3存储位置的状态

表示存储位置的状态 在这里是很用必要的,因为插入的过程中不能覆盖别人,要判断当前位置是否冲突,就有必要知道当前位置的状态,当然还有别的原因,后面细讲。

这里引入三种状态:

  1. EMPTY ,表示该位置为
  2. EXIST ,表示该位置被占用了。
  3. DELETE ,表示该位置原来用数据,现在被删除了。
  4. 删除 的时候只要改变对应状态即可,不需要真的删除。

这里让状态和键值对组成一个结构体:

cpp 复制代码
enum Status  //对应位置的状态
{
	EMPTY,
	EXIST,
	DELETE
};

template<class K, class V>  //哈希表中每个位置存储的元素,初始状态默认为空
struct HashData
{
	pair<K, V> _kv;
	Status _s = EMPTY;
};

每个状态的意义(这个比较难理解):

  1. EMPTYEXIST比较简单,就是标识当前位置是否被占用。
  2. 至于DELETE 状态,主要服务于查找。
    对于查找,我们也是利用key值转化出存储位置信息 ,假设插入x时发生了哈希冲突 ,我们往后找就有下面三种情况:
    (1)当前位置位为EXIST ,但不是要查找的值,存储位置可能在后面,继续找。
    (2)当前位置为DELETE ,存储位置可能在后面,继续找。
    (原来冲突的值被删除了而已,x可能在后面未被删除
    (3)当前值为EMPTY ,不必向后找,可以确定x不存在。
    (这里要么x被删除 了,要么x没插入过,不然x是可以插入这里的)
  3. 如果不设置DELETE 状态,查找的时候只能遍历一次哈希表 ,时间复杂为O(N),哈希表就没有意义了。
4.1.4关于键值类型

实际中键值不一定是数值类型 ,可能是不同类型,典型的代表就是字符串 。所以一般都会设计一个模板参数 ,用来转化非数值类型为整形,C++这里采用的是仿函数。这样设计非常灵活,使用者可以依据实际需求自己设计仿函数。

代码:

cpp 复制代码
template < class Key,                                    // unordered_map::key_type
	class T,                                      // unordered_map::mapped_type
	class Hash = hash<Key>,                       // unordered_map::hasher
	class Pred = equal_to<Key>,                   // unordered_map::key_equal
	class Alloc = allocator< pair<const Key, T> >  // unordered_map::allocator_type
> class unordered_map;
//unordered_map的Hash参数即为这里所讲的仿函数类型

//这个是默认的,只要能转化为整形就可以用这个
template<class T>
struct HashFunc
{
	size_t operator()(const T& key)
	{
		return (size_t)key;
	}
};

//因为字符串做键值非常常见,库里面也特化了一份
//字符串哈希算法这里不展开讲,我这里采用的是BKDR算法
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		size_t hashi = 0;
		for (auto ch : key)
		{
			hashi = hashi * 31 + ch;
		}
		return hashi;
	}
};
4.1.5代码实现

理解前面后代码比较简单,我加了注释应该可以看懂。

cpp 复制代码
//这个是默认的,只要能转化为整形就可以用这个
template<class T>
struct HashFunc
{
	size_t operator()(const T& key)
	{
		return (size_t)key;
	}
};

//因为字符串做键值非常常见,库里面也特化了一份
//字符串哈希算法这里不展开讲,我这里采用的是BKDR算法
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		size_t hashi = 0;
		for (auto ch : key)
		{
			hashi = hashi * 31 + ch;
		}
		return hashi;
	}
};

// 闭散列
namespace closed_address
{
	enum Status  //对应位置的状态
	{
		EMPTY,
		EXIST,
		DELETE
	};

	template<class K, class V>  //哈希表中每个位置存储的元素,初始状态默认为空
	struct HashData
	{
		pair<K, V> _kv;
		Status _s = EMPTY;
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable()
		{
			//初始默认开10个空间
			_tables.resize(10);
		}

		//  插入
		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))  //已经存在不能插入,一个键值对占一个位置
			{
				return false;
			}
			Hash hf;   //用来转化非数值类型为整数类型

			//检查是否需要扩容
			if ((double)_n / _tables.size() >= 0.7)
			{
				// 开一个新表,复用insert重新建立映射
				size_t newsize = _tables.size() * 2;
				HashTable<K, V> newHT;
				newHT._tables.resize(newsize);
				//遍历旧表
				for (size_t i = 0; i < _tables.size(); i++)
				{
					if (_tables[i]._s == EXIST)
					{
						newHT.Insert(_tables[i]._kv);
					}
				}
				//交换两个表
				newHT._tables.swap(_tables);
			}

			size_t hashi = hf(kv.first) % _tables.size();
			//线性探测寻找空位置
			while (_tables[hashi]._s == EXIST)
			{
				hashi++;
				//超出哈希表长度要进行修正
				hashi %= _tables.size();
			}
			// 插入
			_tables[hashi]._kv = kv;
			_tables[hashi]._s = EXIST;
			_n++;  //更新插入个数
			return true;
		}

		///  查找
		HashData<K, V>* Find(const K& key)
		{
			Hash hf;
			size_t hashi = hf(key) % _tables.size();
			while (_tables[hashi]._s != EMPTY)  //走到空位置说明该值不在
			{
				// 存在并且键值为key代表找到了,返回结构体指针
				if (_tables[hashi]._kv.first == key && _tables[hashi]._s == EXIST)
				{
					return &_tables[hashi];
				}
				//继续往后找
				hashi++;
				//超出哈希表长度要进行修正
				hashi %= _tables.size();  
			}
			return nullptr;
		}

		// 删除
		bool Erase(const K& key)
		{
			// 查询非空表示找到了
			HashData<K, V>* ret = Find(key);
			if (ret)
			{
				// 修改对应位置状态并加一插入个数即可
				ret->_s = DELETE;
				_n--;
				return true;
			}
			else
			{
				return false;
			}
		}

		//后面的接口不是很重要
		size_t Size()const
		{
			return _n;
		}

		bool Empty() const
		{
			return _n == 0;
		}

		void Swap(HashTable<K, V>& ht)
		{
			swap(_n, ht._n);
			_tables.swap(ht._n);
		}

	private:
		vector<HashData<K, V>> _tables;
		size_t _n = 0;
	};
}

4.2开散列

4.2.1概念

开散列 :又称拉链法/哈希桶 ,即发生冲突时,采用挂链表的形式内部消化,即冲突的元素放在同一链表中,不影响其它位置。

节点定义:

cpp 复制代码
template<class K, class V>
struct HashNode
{
	HashNode* _next;
	pair<K, V> _kv;

	HashNode(const pair<K, V>& kv)
		:_kv(kv)
		, _next(nullptr)
	{}
};
4.2.2哈希表扩容

开散列扩容

  1. 和前面一样,扩容会改变原来的映射关系,需要重新建立映射
  2. 第一种做法是开新表,复用insert。这样比较简单但消耗比较大,因为需要重新申请节点空间并初始化。
  3. 第二种做法是开新表,计算每个节点的新存储位置,直接把节点拿下来插入到新表即可,不必重新开空间。

插入的代码:

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))  //原来已经存在不能插入
		return false;

	Hash hf;  //用来转化非数值类型为整形
	
	//对于开散列扩容
	if (_n == _tables.size())
	{
		vector<Node*> newTables;
		newTables.resize(_tables.size() * 2, nullptr);
		// 遍历旧表
		for (size_t i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				//先记录下一个节点,防止断掉
				Node* next = cur->_next;

				// 挪动到映射的新表(头插)
				size_t hashi = hf(cur->_kv.first) % newTables.size();
				cur->_next = newTables[hashi];
				newTables[hashi] = cur;

				cur = next;
			}

			_tables[i] = nullptr;
		}

		_tables.swap(newTables);
	}

	size_t hashi = hf(kv.first) % _tables.size();
	Node* newnode = new Node(kv);

	// 新节点头插即可
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	++_n;

	return true;
}
4.2.3代码实现
cpp 复制代码
//这个是默认的,只要能转化为整形就可以用这个
template<class T>
struct HashFunc
{
	size_t operator()(const T& key)
	{
		return (size_t)key;
	}
};

//因为字符串做键值非常常见,库里面也特化了一份
//字符串哈希算法这里不展开讲,我这里采用的是BKDR算法
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		size_t hashi = 0;
		for (auto ch : key)
		{
			hashi = hashi * 31 + ch;
		}
		return hashi;
	}
};

namespace hash_bucket
{
	template<class K, class V>
	struct HashNode
	{
		HashNode* _next;
		pair<K, V> _kv;

		HashNode(const pair<K, V>& kv)
			:_kv(kv)
			, _next(nullptr)
		{}
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
		{
			_tables.resize(10);
		}

		//节点是自己new的,需要写析构函数,遍历即可
		~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;
			}
		}

		// 插入
		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))  //原来已经存在不能插入
				return false;

			Hash hf;  //用来转化非数值类型为整形

			//对于开散列扩容
			if (_n == _tables.size())
			{
				vector<Node*> newTables;
				newTables.resize(_tables.size() * 2, nullptr);
				// 遍历旧表
				for (size_t i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						//先记录下一个节点,防止断掉
						Node* next = cur->_next;

						// 挪动到映射的新表(头插)
						size_t hashi = hf(cur->_kv.first) % newTables.size();
						cur->_next = newTables[hashi];
						newTables[hashi] = cur;

						cur = next;
					}

					_tables[i] = nullptr;
				}

				_tables.swap(newTables);
			}

			size_t hashi = hf(kv.first) % _tables.size();
			Node* newnode = new Node(kv);

			// 新节点头插即可
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;

			return true;
		}

		// 查找
		Node* Find(const K& key)
		{
			Hash hf;
			// 找到对应的桶遍历即可
			size_t hashi = hf(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}

				cur = cur->_next;
			}

			return nullptr;
		}

		/// 删除
		bool Erase(const K& key)
		{
			Hash hf;
			// 先找到对应桶,遍历的同时记录前置节点,链表删除就不多讲了
			size_t hashi = hf(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;

					return true;
				}

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

			return false;
		}

		//测试接口,大家可以随机生成大量数据插入看看每个桶的平均长度,应该1-2左右
		void Some()
		{
			size_t bucketSize = 0;
			size_t maxBucketLen = 0;
			size_t sum = 0;
			double averageBucketLen = 0;

			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				if (cur)
				{
					++bucketSize;
				}

				size_t bucketLen = 0;
				while (cur)
				{
					++bucketLen;
					cur = cur->_next;
				}

				sum += bucketLen;

				if (bucketLen > maxBucketLen)
				{
					maxBucketLen = bucketLen;
				}
			}

			averageBucketLen = (double)sum / (double)bucketSize;

			printf("all bucketSize:%d\n", _tables.size());
			printf("bucketSize:%d\n", bucketSize);
			printf("maxBucketLen:%d\n", maxBucketLen);
			printf("averageBucketLen:%lf\n\n", averageBucketLen);
		}

	private:
		vector<Node*> _tables;
		size_t _n = 0;
	};
}

4.3开闭散列的对比

先说结论:实际中开散列使用较多 ,C++STL中unordered_map和unordered_set底层是开散列。

原因:开散列采用拉链解决处理冲突的方式不会干扰其它位置 ,可以有效的提高哈希表插入查找 效率。

以线性探测解决冲突为例,向闭散列(空间为10)中插入3、33、333、4,4会因为冲突移动到下标6位置,查找4的时候就会多查找几次 。开散列就没有这样的问题。

相关推荐
luckys.one2 小时前
第9篇:Freqtrade量化交易之config.json 基础入门与初始化
javascript·数据库·python·mysql·算法·json·区块链
~|Bernard|3 小时前
在 PyCharm 里怎么“点鼠标”完成指令同样的运行操作
算法·conda
战术摸鱼大师3 小时前
电机控制(四)-级联PID控制器与参数整定(MATLAB&Simulink)
算法·matlab·运动控制·电机控制
Christo33 小时前
TFS-2018《On the convergence of the sparse possibilistic c-means algorithm》
人工智能·算法·机器学习·数据挖掘
汇能感知4 小时前
摄像头模块在运动相机中的特殊应用
经验分享·笔记·科技
阿巴Jun4 小时前
【数学】线性代数知识点总结
笔记·线性代数·矩阵
好家伙VCC4 小时前
数学建模模型 全网最全 数学建模常见算法汇总 含代码分析讲解
大数据·嵌入式硬件·算法·数学建模
茯苓gao5 小时前
STM32G4 速度环开环,电流环闭环 IF模式建模
笔记·stm32·单片机·嵌入式硬件·学习
是誰萆微了承諾5 小时前
【golang学习笔记 gin 】1.2 redis 的使用
笔记·学习·golang
DKPT5 小时前
Java内存区域与内存溢出
java·开发语言·jvm·笔记·学习