哈希表介绍

背景

  • 在 C++98 中,STL 提供了底层为红黑树结构的一系列关联式容器,这些容器在进行查询时的效率最坏也只有 log2N,最差情况也就是需要比较红黑树的高度次,虽然这样的效率已经非常高了,但是当树中的节点非常非常多时,查询效率也就变得不理想了;
  • 而理想中最好的查询是,进行很少的比较次数就能够将元素找到,因此在 C++11 中,STL 又提供了四个unordered系列的关联式容器,这四个容器与红黑树系列的四个关联式容器使用方式基本类似,只是其底层结构不同,这四个容器分别是unordered_mapunordered_setunordered_multimap以及unordered_multiset,这些容器的底层结构采用了哈希结构,本篇博客就让我们来细细看看哈希是怎么一回事;

哈希概念

哈希概念
  • 之前的结构:顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较,而顺序表中查找时间复杂度为 O(N),平衡树中查找时间复杂度为树的高度 O(log2N),这些结构进行搜索的效率取决于搜索过程中元素的比较次数,这样的效果并不是很理想;
  • 现在的结构:构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一 一映射的关系,那么在查找时通过该函数可以很快找到指定元素,也就是不经过任何比较,通过一次计算得到待查找元素在表中的位置;
  • 哈希:上面所说的方式即为哈希 (散列) 方法,哈希方法中使用的转换函数 (计算函数) 称为哈希 (散列) 函数,构造出来的结构称为哈希表 (Hash Table 或者称为散列表);
哈希操作
  • 插入元素:根据待插入元素的关键码,通过哈希函数计算出该元素的存储位置,并按此位置在哈希表中进行存放;
  • 搜索元素:对元素的关键码进行同样的计算,把求得的结果当做元素在哈希表中的的存储位置,然后在哈希表中拿到此位置的元素并进行比较,若关键码相等,则搜索成功;
  • 举例:数据—— {1, 7, 6, 4, 5, 9},哈希函数——hash(key) = key % capacity,也就是元素关键码对哈希表容量取余计算;
    在这里插入图片描述
哈希冲突
  • 概念:对于两个数据元素的关键字 m 和 n,虽然有 m != n,但是却有:Hash(m) == Hash(n),即:不同关键字通过相同哈希函数计算出了相同的哈希地址,该种现象称为哈希冲突或哈希碰撞,把具有不同关键码而具有相同哈希地址的数据元素称为——同义词;
  • 性质:哈希冲突可以被减小,但是不能被避免;
  • 原因之一:引起哈希冲突的某个可能的原因是——哈希函数设计不够合理;
哈希函数
  • 哈希函数设计原则:
    • 哈希函数的定义域必须包括需要存储的全部关键码,而如果哈希表允许有 m 个地址时,其值域必须在 0 ~ m-1 之间;
    • 哈希函数计算出来的地址最好能均匀分布在整个空间中;
    • 哈希函数的计算最好比较简单;
  • 常见哈希函数
    1. 直接定制法:取关键字的某个线性函数为散列地址,即 Hash (Key) = A * Key + B,适合查找比较小且连续的情况;
    2. 除留余数法:设散列表中允许的地址数为 m,取一个不大于 m,但最接近或者等于 m 的质数 p 作为除数,然后按照哈希函数:Hash (key) = key % p (p<=m),将关键码转换成哈希地址;
    3. 平方取中法:假设关键字为 1234,对它平方就是 1522756,抽取中间的 3 位 227 作为哈希地址;再比如关键字为 4321,对它平方就是 18671041,抽取中间的 3 位 671 (或 710) 作为哈希地址,适合不知道关键字的分布,而位数又不是很大的情况;
    4. 折叠法:折叠法是将关键字从左到右分割成位数相等的几部分 (最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址,适合事先不需要知道关键字的分布,适合关键字位数比较多的情况;
    5. 随机数法:选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random(key),其中 random 为随机数函数,通常关键字长度不等时采用此法;
    6. 数学分析法:设有 n 个 d 位数,每一位可能有 r 种不同的符号,这 r 种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现,可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址;
负载因子
  • 概念:用哈希表中的有效元素个数除以哈希表的总容量得到的值被称为负载因子,该值往往小于 1;
  • 原因:哈希表中的元素个数越少,那么产生哈希冲突的几率也就越小,因此在向哈希表中插入元素时,我们需要检查哈希表的负载因子是否合适,避免将元素插入到一个冲突较大的位置,使得查找时时间复杂度较高;
  • 增容:在负载因子超过某一阈值时(阈值可由自己决定,一般 0.5 ~ 0.8 合适),我们需要对哈希表进行增容,该过程为:创建新的容量大的哈希表——>将原表中的元素一个一个重新插入新表;

解决哈希冲突

闭散列
概念
  • 概念:闭散列也叫开放定址法,当发生哈希冲突时,由于负载因子的存在,所以哈希表永远有空位置,那么可以把 key 存放到冲突位置后的 “下一个” 空位置中去,这里的下一个并不是真正意义上的下一个,而是从冲突位置向后找到的第一个空位置再插入;
线性探测
  • 概念:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止;
  • 插入
    1. 通过哈希函数获取待插入元素在哈希表中的位置;
    2. 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素;
    3. 在插入的过程中走到了表尾仍没有找到,那么循环到表头继续查找;
      在这里插入图片描述
  • 查找
    • 先使用哈希函数查找到待查找元素的位置,然后从该位置开始取每一个表中元素的 key 和待插入元素的 key 进行比较,如果相等则找到,如果不相等则向后继续,直到找到一个为空的位置都没有找到,那么则说明表中没有这个元素;
  • 删除
    • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索,因为删除元素的前提是查找到该元素,如果直接删除某一个元素就会使得查找元素时很容易遇到空位置,这样就会导致查找结束从而找不到待删除元素;
    • 因此线性探测法采用标记的伪删除法来删除一个元素,就是每个节点都有一个状态标志,分别为:EXIST——有效结点,DELETE——被删除,EMPTY——空节点,初始时,每个节点都是 EMPTY 标志,插入结点后改为 EXIST 标志,删除时改为 EMPTY 标志而不要动哈希结构;
  • 优点:实现的代码非常简单;
  • 缺点:一旦发生哈希冲突,所有的冲突将会连在一起,这样容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低;
二次探测
  • 概念:线性探测的缺陷是产生冲突的数据堆积在一块,这是因为产生冲突时我们是顺序的向后查找一个空位置,所以会差生堆积,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:h = ( idx + i2 ) % m 或者 h = ( idx - i2 ) % m,其中:i = 1,2,3…,h 为最后得到的结果,idx 是通过散列函数 Hash(x) 对元素的关键码 key 进行计算得到的位置,m 是表的大小;
    在这里插入图片描述
实现
#include<iostream>
#include<vector>
using namespace std;

//一个标志位,用来指明当前节点的状态:有元素、被删除、为空
enum STATE {
	EXIST,
	DELETE,
	EMPTY
};

//哈希元素节点
template<class K, class V>
struct HashNode{
	//存放数据
	pair<K, V> _val;
	//状态位
	STATE _state;

	//构造函数
	HashNode(const pair<K, V>& val)
		:_val(val)
		,_state(EMPTY)
	{}
};

//哈希表
template<class K, class V>
class HashTable {
public:
	typedef HashNode<K, V> Node;
	typedef HashTable<K, V> Table;

	//构造函数
	HashTable(const int num = 10)
		:_ht(num)
		,_size(0)
	{}

	//插入操作
	bool insert(const pair<K, V>& val) {
		//检查容量
		CheckCapacity();
		//计算元素位置
		int idx = val.first % _ht.size();
		//判断是否存在重复元素以及找到实际插入位置
		while (_ht[idx]._state != EMPTY) {
			//如果当前节点是被删除的或者该节点的值不等于插入的节点,那么就继续寻找
			if (_ht[idx]._state == DELETE || _ht[idx]._val.first != val.first)
				idx++;
			//否则就是说明遇到了相同节点,那么插入失败
			else
				return false;
			//当走到idx > _ht.size(),那么就从表的头部开始继续查找
			if (idx == _ht.size())
				idx = 0;
		}
		//能走到这说明遇到空的位置了,将数据插入
		_ht[idx]._val = val;
		//并将该节点状态置为EXIST
		_ht[idx]._state = EXIST;
		//更新变量
		++_size;
		return true;
	}

	//检查容量操作
	void CheckCapacity() {
		//检查负载因子是否超过阈值:0.7(阈值自己定,一般0.5~0.8)
		//超过阈值或者此时哈希表为空,那么就需要增容
		if (_ht.size() == 0 || _size * 10 / _ht.size() > 7) {
			int newc = _ht.size() == 0 ? 10 : 2 * _ht.size();
			//创建新的哈希表,这里为什么不创建新的数组而是选择创建新的哈希表
			//创建数组的话需要重新插入每一个元素,而创建哈希表的话,可以直接调用insert接口
			HashTable<K, V> newht(newc);
			//将原有元素重新放入新表
			for (int i = 0; i < _ht.size(); i++) {
				if (_ht[i]._state == EXIST)
					newht.insert(_ht[i]._val);
			}
			//重新插入完成后,在交换新旧哈希表的内容
			Swap(newht);
		}
	}

	//交换接口
	void Swap(Table& t) {
		swap(_ht, t._ht);
		swap(_size, t._size);
	}

	//查找接口
	Node* find(const K& key) {
		//无元素直接返回
		if (_size == 0)
			return nullptr;
		//计算位置
		int idx = key % _ht.size();
		//循环查找,找到则返回,找到空的位置则失败
		while (_ht[idx]._state != EMPTY) {
			//如果状态是存在且等于待查找元素,则找到返回
			if (_ht[idx]._state == EXIST && _ht[idx]._val.first == key)
				return &_ht[idx];
			//否则就继续向后查找
			idx++;
			//当走到末尾之后,那么就重置到起始位置
			if (idx == _ht.size())
				idx = 0;
		}
		//走到这说明没找到,返回空
		return nullptr;
	}

	//删除操作
	bool erase(const K& key) {
		//没有元素就算删除成功
		if (_size == 0)
			return true;
		//找到待删除元素的位置
		Node* cur = find(key);
		//如果返回的是空,则说明没有,删除失败,否则就将其状态置为EMPTY,成功
		if (cur) {
			cur->_state = EMPTY;
			--_size;
			return true;
		}
		return false;
	}
private:
	vector<Node> _ht;
	int _size; //有效元素个数
};

int main() {

	return 0;
}
开散列
概念
  • 概念:开散列法又叫链地址法(开链法),首先对关键码集合用哈希函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中;
    在这里插入图片描述
  • 插入
    • 先使用哈希函数计算插入位置;
    • 然后从计算出的位置处,拿到链表的头结点,然后遍历该单链表,检测是否存在 key 值相同的元素,如果存在则插入失败;
    • 如果不存在相同元素则插入,因为元素本身无序,所以在计算出的位置处,将元素以头插的方式插入单链表中即可,无需进行尾插;
  • 查找
    • 先计算位置;
    • 然后从计算出的位置处,拿到链表的头结点,然后向后遍历,一 一比对 key,相同则说明找到了;
  • 删除
    • 先计算位置;
    • 然后从计算出的位置处,拿到链表的头结点,然后向后遍历,找到待删除元素后,将其从链表上拿下来,然后重新链接链表结点的指针;
  • 增容
    • 哈希表中桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?
    • 开散列最好的情况是:每个哈希桶中刚好挂一个节点,每个桶都挂入一个节点后,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容,也就是负载因子为 1 时就进行增容;
迭代器
  • 开散列哈希表的迭代器并不是简单的节点地址,而是和链表的迭代器一样,是一个封装了节点的结构体,在这个结构体中比较复杂的接口就是++运算符,因为结构中存在单链表,所以不是连续的,因此在迭代器++的时候可能需要跳转,具体看下面的分析:
    在这里插入图片描述
实现
#include<iostream>
#include<vector>
using namespace std;

//哈希结点
template<class V>
struct HashNode {
	//数据
	V _val;
	//指向下一个节点的指针
	HashNode<V>* _next;
	//构造函数
	HashNode(const V& val = V())
		:_val(val)
		,_next(nullptr)
	{}
};

//对于那些不是整形的数据,要将他们的key值转换为能进行计算的整数数据
template<class K>
struct hashfun {
	size_t operator()(const K& key) {
		return key;
	}
};
//对于如果是string类型,那么就对上面的模板进行特化
template<>
struct hashfun<string> {
	size_t operator()(const string& key) {
		size_t h = 0;
		for (auto& e : key)
			h = h * 131 + e;
		return (h & 0x7FFFFFFF);
	}
};

//哈希表的前置声明,这样在迭代器中就可以使用了
template<class K, class V, class keyofval, class hashfun = hashfun<K>>
class HashTable;

//哈希表的迭代器
template<class K, class V, class keyofval, class hashfun = hashfun<K>>
struct HashIterator {
	typedef HashNode<V> Node;
	typedef HashIterator<K, V, keyofval,hashfun> Self;
	//当前迭代器中封装的结点指针
	Node* _node;
	//拿到哈希表中的数组,这样就可以进行遍历访问了
	vector<Node*> _vht;

	//构造函数
	HashIterator(Node* node, vector<Node*> vht)
		:_node(node)
		,_vht(vht)
	{}

	//重载 * 运算符
	V& operator*() {
		return _node->_val;
	}

	//重载 -> 运算符
	V* operator->(){
		return &(_node->_val);
	}

	//重载 != 运算符
	bool operator!=(const Self& it) {
		return _node != it._node;
	}

	//重载 ++ 运算符
	Self& operator++() {
		//如果当前迭代器中的结点的_next指针不为空,则就更新到_next
		if (_node->_next)
			_node = _node->_next;
		//如果当前迭代器中的结点的_next为空,那么需要进入哈希表中向后查找
		else {
			//首先拿到当前节点在哈希表中的位置的后一个位置
			hashfun fun;
			keyofval kov;
			int idx = fun(kov(_node->_val)) % _vht.size() + 1;
			//然后从哈希表的这个位置向后查找,找到第一个不为空的指针
			while (idx < _vht.size() && _vht[idx] == nullptr) {
				idx++;
			}
			//然后判断这个位置是否超出哈希表的大小
			if (idx < _vht.size())
				//如果小于,则更新到这个位置
				_node = _vht[idx];
			else
				//否则,令其为空
				_node = nullptr;
		}
		return *this;
	}
};

//哈希表
template<class K, class V, class keyofval, class hashfun>
class HashTable {
public:
	typedef HashNode<V> Node;
	typedef HashIterator<K, V, keyofval, hashfun> iterator;

	//构造函数
	HashTable(const int n = 10)
		:_ht(n)
		,_size(0)
	{}

	//begin()迭代器
	iterator begin() {
		//begin迭代器封装了哈希表中第一个不为空的结点指针
		int idx = 0;
		while (idx < _ht.size()) {
			if (_ht[idx]) {
				return iterator(_ht[idx], _ht);
			}
			idx++;
		}
		return iterator(nullptr, _ht);
	}

	//begin()迭代器
	iterator end() {
		//end迭代器中就是封装了nullptr指针
		return iterator(nullptr, _ht);
	}

	//插入操作,成功与否都返回一个pair<iterator, bool>键值对
	//成功返回(插入的迭代器位置,bool),失败返回(已存在的迭代器位置,false)
	pair<iterator, bool> insert(const V& val) {
		//检查容量
		CheckCapacity();
		//计算在哈希表中的位置
		hashfun fun;
		keyofval kov;
		int idx = fun(kov(val)) % _ht.size();
		//然后开始插入,先创建节点
		Node* cur = new Node(val);
		//再检查哈希表中是否已存入相同节点
		Node* tmp = _ht[idx];
		while (tmp) {
			if (fun(kov(tmp->_val)) == fun(kov(val)))
				return make_pair(iterator(tmp, _ht), false);
			tmp = tmp->_next;
		}
		//如果没有查找到相同的结点,那么就插入表中
		cur->_next = _ht[idx];
		_ht[idx] = cur;
		_size++;
		return make_pair(iterator(cur, _ht), true);
	}

	//容量检查
	void CheckCapacity() {
		//开散列哈希表的负载因子可以为1,所以就以1为基准
		if (_size == _ht.size()) {
			//先查看原先容量的情况,然后再适量分配
			int newc = _ht.size() == 0 ? 10 : 2 * _size;
			vector<Node*> newht(newc);
			//将原先哈希表的内容重新插入新表中
			for (int i = 0; i < _ht.size(); i++) {
				//遍历每一个以哈希结点为头的单链表
				Node* cur = _ht[i];
				//非空就开始遍历
				while (cur) {
					//先保留节点的下一个节点
					Node* temp = cur->_next;
					//计算节点在新表中的位置
					hashfun fun;
					keyofval kov;
					int idx = fun(kov(cur->_val));
					//插入新表中
					cur->_next = newht[idx];
					newht[idx] = cur;
					cur = temp;
				}
				//将旧表中的指针置为空
				_ht[i] == nullptr;
			}
			//此时交换两个表,这样新表就成为当前哈希对象的成员
			swap(_ht, newht);
		}
	}
private:
	vector<Node*> _ht;
	int _size;
};

//用开散列的哈希表来实现unordered_map
template<class K, class V>
class unorderedmap {
	//这就是提供给底层结构的仿函数,作用就是获取到存入元素的key值
	struct keyofval {
		const K& operator()(const pair<K, V>& val) {
			return val.first;
		}
	};
	//成员变量
	HashTable<K, pair<K, V>, keyofval> _ht;
public:
	//因为这是泛型参数未确定的类的迭代器,所以编译器识别不了,因此需要加上一个typename,告诉编译器这个类的泛型参数可以延迟确定,所以这个迭代器可以使用
	typedef typename HashTable<K, pair<K, V>, keyofval>::iterator iterator;
	//插入操作
	pair<iterator, bool> insert(const pair<K, V>& val) {
		return _ht.insert(val);
	}
	//[]操作
	V& operator[](const K& key) {
		pair<iterator, bool> ret = _ht.insert(make_pair(key, V()));
		return ret.first->second;
	}
	//迭代器
	iterator begin() {
		return _ht.begin();
	}
	iterator end() {
		return _ht.end();
	}
};

//用开散列的哈希表来实现unordered_set
template<class V>
class unorderedset {
	//这就是提供给底层结构的仿函数,作用就是获取到存入元素的key值
	struct keyofval {
		const V& operator()(const V& val) {
			return val;
		}
	};
	//成员变量
	HashTable<V, V, keyofval> _ht;
public:
	//因为这是泛型参数未确定的类的迭代器,所以编译器识别不了,因此需要加上一个typename,告诉编译器这个类的泛型参数可以延迟确定,所以这个迭代器可以使用
	typedef typename HashTable<V, V, keyofval>::iterator iterator;
	//插入操作
	pair<iterator, bool> insert(const V& val) {
		return _ht.insert(val);
	}
	//迭代器
	iterator begin() {
		return _ht.begin();
	}
	iterator end() {
		return _ht.end();
	}
};

int main() {
	unorderedmap<string, int> mp;
	mp.insert(make_pair("123", 6));
	mp.insert(make_pair("456", 4));
	mp.insert(make_pair("123", 7));
	mp.insert(make_pair("789", 3));
	for (auto e : mp) {
		cout << e.first << "->" << e.second << endl;
	}
	return 0;
}
最优哈希表长度
  • 研究表明,当表的长度为质数且表的负载因子 a 不超过 0.5 时,新的元素一定能够一次性插入,任何一个位置都不会被探查两次,因此只要表中有一半的空位置,就不会存在表满的问题;
  • 在搜索时可以不用考虑表装满的情况,但在插入时需要考虑这个问题,因此最好确保表的负载因子 a 不超过 0.5,如果超出就要考虑增容了,否则效率就会降低,因此闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷;
  • 自己实现的哈希表的增容是二倍的增长,但是源码中并不是这样的,因为空间容量为一个质数时效率更高,哈希冲突更小,所以一般是在增容时,先确定所需的容量 num,然后再拿到一个大于 num 的质数 newnum,然后将 newnum 作为增容的大小进行增容,具体操作如下:
const int PRIMECOUNT = 28;
const size_t primeList[PRIMECOUNT] =
{
	53ul, 97ul, 193ul, 389ul, 769ul,
	1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
	49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
	1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
	50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
	1610612741ul, 3221225473ul, 4294967291ul
};
size_t GetNextPrime(size_t prime){
	size_t i = 0;
	for(; i < PRIMECOUNT; ++i){
		if(primeList[i] > primeList[i])
		return primeList[i];
	}
	return primeList[i];
}
不是整形元素数据类型怎么处理
  • 对于不是整形的数据的数据类型,一般处理是将其转化为整型值,至于怎么转化,这些请自行查阅资料,这里面涉及到了许多数学原理来减少哈希冲突,所以不多介绍;
  • 这里就是说一下怎么来将其转化:思路就是写一个转化函数,然后将其当做泛型参数传入哈希表中,然后在对哈希结点计算存储位置时,先使用传入的函数对其进行转化(我们写的这个函数是一个泛型模板,也就是说能能将任何数据类型的数转化为整型数值返回),然后再通过转化的值来确定哈希位置;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值