[数据结构]——哈希表

哈希表

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN ),搜索的效率取决于搜索过程中元素的比较次数。而实际上我们希望理想的搜索效率是O(1),那到底有没有一种数据结构可以实现O(1)的搜索效率呢?

今天我们要介绍的东西叫做哈希表,说到哈希表我们不得不先提一下哈希的概念。哈希也可以叫做散列,他是通过映射的思想将数据存放到一个表中,所以之后我们通过已经映射了的位置查到数据,这样就实现了O(1)的搜索时间复杂度。如果你还是不明白这种思想,别着急,接着往下看。

小练习

找出字符中第一个出现的唯一字符

给定一个字符串,找到它的第一个不重复的字符,并返回它的索引。如果不存在,则返回 -1。
[数据结构]——哈希表_第1张图片
题目很简单,读懂了题目看看我们是怎么利用高效的解法来解决这个问题的:

int firstUniqChar(string s) {
        int arr[26] = {0};//使用一个数组来统计每个字符出现的次数
        for(int i = 0;i<s.size();i++)
        {
            arr[s[i]-'a']++;
        }
        for(int j = 0; j < s.size(); j++)//遍历数组查找第一个出现次数为0的字符
        {
            if(arr[s[j] - 'a'] == 1)
            {
                return j;
            }
        }        
        return -1;
    }

对照着代码,这里实际上我们就应用了哈希映射的思想,每个下标对应一个字母,统计这个字母出现的次数,然后再次遍历原字符串,如果当前这个字符对应的统计次数为1就返回这个字母的下标。
[数据结构]——哈希表_第2张图片

哈希的构建方法

利用上面的例子我们已经初步了解了哈希基本的映射思想,但是上面的方法对于其它的问题可能就存在非常大的局限性,比如我将题目修改为:查找某个数据流中的某个数字是否存在,如果这个数据流只有俩个数字,一个数字为1,另一个为500万,那么在对这俩个数进行上面那种方式进行映射时就需要开辟500万大小的数组,这明显是不合理的方式。

我们第一种使用的映射思想叫做直接定制法,基于上面提出的问题,第一种方法明显不合理,所以这里我们引出了第二种方法:除留余数法,什么是处理余数法?我们来举个例子

  • hash表size为10,我们需要映射1,13,25三个数据,我们就将1映射到第2个位置,13映射到第4个位置,25映射到第6个位置。
    [数据结构]——哈希表_第3张图片
    我们这里举了俩个非常重要的映射方式,当然了还有其他方式有兴趣的同学可以下去自己了解一下。下面总计一下可能会遇到的几种哈希函数。

  • 直接定制法:取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先
    知道关键字的分布情况 使用场景:适合查找比较小且连续的情况(如小练习)

  • 除留余数法:设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函
    数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址

  • 平方取中法:(了解)假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况

  • 折叠法(了解):折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加
    求和,并按散列表表长,取后几位作为散列地址

  • 随机数法(了解):选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为
    随机数函数。

我们在真正设计哈希表时一般都使用第一二种办较多,后面的方法仅仅作为了解即可

哈希冲突

讲到这里,你可能会觉得哈希表设计起来也很简单么,事实上真的是这个样子么?不如我们再次改变场景来一同探讨。

问题:
现在我们哈希表的大小依旧还是10,我们现在所要映射的数据为5,15,25,这时你会发现,经过num % hash.size()后,这三个数字都要映射到哈希表的第六个位置,这时就发生了所谓的哈希冲突问题。
[数据结构]——哈希表_第4张图片
为了解决哈希冲突,我们这里引出了闭散列开散列两种方式,接着往下看。

闭散列

闭散列也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
[数据结构]——哈希表_第5张图片
接下来我们就来一起来探讨这种闭散列如何实现,而当前这种方法叫做闭散列的线性探测法,即key存放到冲突位置中的“下一个” 空位置中去。

闭散列的结构定义

  • 这里hash表直接使用vector来代替,vector中就存放的是HashNode
  • 我们这里直接创建一个K-V的HashTable,HashNode中的state(状态)我们后面再做讨论用处
  • HashTable中成员变量size_t _size是指表中现在的有效数据
template
class HashNode
{
public:
	std::pair _kv;
	State _state;
};

template
class HashTable
{
private:
	vector _table;
	size_t _size;
};

我们现在来讨论一下为什么HashNode中为什么要有一个_state(状态)变量,问读者一个问题,如果我们现在需要从表中删除某个数据,我们应该怎么做,置空么?怎么将数组的这个位置置空,设置为0?如果我们删除的数据就是0呢。

enum State//使用三种状态分别代表: 1.空 2.删除 3.存在
{
	EMPTY,
	DELETE,
	EXITS,
};

所以我们这里引出了当前节点的状态,如果要删除某个数据时就将状态设置为DELETE,表示假删除。

闭散列的插入

现在到了闭散列的插入:

  • 如果要映射的位置没有数据那么就直接将数据放到相应的位置
  • 如果要映射的位置已经存在数据,那么就要找到这个位置之后第一个为空或者删除状态的位置将数据放入
  • 当查找到表位依旧没找到可插入的位置就需要从表头开始查找
  • 这个表不可能在放满数据后增容,因为表中数据越多那么发生冲突的可能就越大,效率就会越低
bool Insert(const std::pair<K, V>& kv)
	{
		CheckCapacity();//检查是否需要增容

		size_t index = kv.first % _table.size();//每个节点中first取模后的下标就是我们要映射的位置
		while (_table[index]._state == EXITS)//当前位置是删除或者空时表示当前的值就可以插入
		{
			if (_table[index]._kv.first == kv.first)//如果当前的first存在说明插入失败
			{
				return false;
			}

			++index;
			if (index == _table.size())//如果index已经到了表尾那么需要从表头开始搜索
			{
				index = 0;
			}
		}
		_table[index]._kv = kv;
		_table[index]._state = EXITS;
		++_size;		
		return true;
	}

闭散列的扩容

闭散列的扩容非常关键,因为闭散列的扩容时机决定了当前这张表查找效率是否高效,所以哈希表会引入一个叫做负载因子的概念,负载因子计算方式:有效数据个数/哈希表大小,负载因子最大为1表示表满。

当然了负载因子需要给一个合适的数字,因为太大会导致哈希冲突几率大大增加,太小又会导致空间浪费严重。这里我们负载因子给了7,是一个相对比较合适的数字。

void CheckCapacity()
	{
		if (_table.size() == 0 || (_size * 10) / _table.size() == 7)//如果表大小为0或者负载因子为7时增容
		{
			size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;

			HashTable<K, V> newht(newsize);//创建一个新表让新表帮助我们完成新的构建操作
			for (int i = 0; i < _table.size(); i++)
			{
				if (_table[i]._state == EXITS)
				{
					newht.Insert(_table[i]._kv);
				}
			}
			_table.swap(newht._table);
		}
	}

注意这里增容后创建一个新表帮助我们完成插入操作,最后一交换即可。

闭散列的查找

这里没什么好说的,注意假删除的数据实际上存在,但是他代表的已经不是有效数据

HashNode* Find(const K& key)//返回一个表节点的指针
	{
		size_t index = key % _table.size();
		while (_table[index]._state != EMPTY)
		{ 
			if (_table[index]._kv.first == key 
			&& _table[index]._state == EXITS)//这里注意被删除的数据也可能被找到
			{
				return &_table[index];
			}

			++index;
			if (index == _table.size())
			{
				index = 0;
			}
		}
		return nullptr;
	}

闭散列的删除

bool Erase(const K& key)
	{
		HashNode* node = Find(key);
		if (node == nullptr)
		{
			return false;
		}
		else
		{
			node->_state = DELETE;
			--_size;
		}
	}

以上就是闭散列中的线性探测法简单的增删查改,我们不再进一步完善,因为实际上我们的hash表不会使用这种方法(笔者第一次看到这个办法一脸懵逼,感觉这样的效率和数组顺序查找差不了多少啊),我们接着介绍效率更好的办法。

二次探测:

接下来有有人提出了一种叫二次探测的闭散列,这里的二次指的不是探测两次,而是在已经冲突的情况下将这个冲突的数据放到index+pow(n,2)这里的n为(0,1,2…),为什么要有这种办法?

这里已经插入了如图下的数据,但是如果要新插入8时我们发现本属于自己的位置却被其他数字占了,所以使用二次探测能够在一定程度上解决数据堆积的问题。
[数据结构]——哈希表_第6张图片

小结

其实从本质上闭散列的线性探测与二次探测都没有很有效的解决哈希冲突的问题,在哈希冲突较多的情况下效率肯定不会太理想,并且闭散列在空间上也没有将资源充分的利用,为了解决这些问题,我们接下来要说的就是开散列,那么开散列又是怎么样的呢?一起往下看。

开散列

开散列法又叫链地址法(拉链法,哈希桶),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中(如下图所示)
[数据结构]——哈希表_第7张图片
此时我们发现哈希冲突的问题得到了很好的解决,并且从空间的利用率上来说已经得到了大大的提高,此时负载可以在等于1时在增容,但是也不能超过1,因为在极端情况下所有的数据都存在一个单链中,此时查找的效率就会退化为单链表的查找效率。我们接下来实现的HashTable就可以供stl中的unordered_map/set使用。

开散列的基本框架

因为这里已经变为了链式结构,所以HashNode中存储的是下一个哈希节点的指针。

template<class V>
struct HashNode
{
	V _Value;
	HashNode<V>* _next;
	HashNode(const V& v)
		:_Value(v)
		, _next(nullptr)
	{}
};

template<class K, class V, class KeyOfValue, class HashFunc>
class HashTable
{
	typedef HashNode<V> Node;
private:
	vector<Node*> _table;
	size_t _size;
};

这里来详细讲一下为什么HashTable需要4个模板参数:

  • class K:很容易理解,K就是用来映射取模的key
  • class V:V有可能是K,也可能是pair类型的键值对,这里不懂的小伙伴我把之前封装map和set的图拿过来这里原理相同
  • class KeyOfValue:这里是取K值的仿函数,如果是pair类型就需要返回K
  • class HashFunc:这个模板参数非常重要,他也是一个仿函数,他的作用是如果遇到无法取模的类型(string…)帮助我们转化成数字
    [数据结构]——哈希表_第8张图片

获取哈希映射的下标

这里hf就是帮助我们将字符串转化为数字的仿函数,由上一层结构传递

size_t GetIndex(const K& key, size_t size)
	{
		HashFunc hf;
		return hf(key) % size;
	}

开散列的插入

  • 找到当前数据需要映射的位置,对当前的位置直接进行头插
    [数据结构]——哈希表_第9张图片
pair<iterator,bool> Insert(const V& v)//为了可以封装unordered_map/set返回pair类型
	{
		CheckCapacity();
		KeyOfValue kov;
		size_t index = GetIndex(KeyOfValue()(v), _table.size());//kov获取不同类型的key值
		Node* cur = _table[index];
		while (cur)
		{
			if (kov(cur->_Value) == kov(v))//表示表中已经存在了此数据
				return make_pair(iterator(cur,this),false);
			cur = cur->_next;
		}

		Node* newnode = new Node(v);//此处进行头插,相当于对数组的赋值
		newnode->_next = _table[index];
		_table[index] = newnode;
		++_size;

		return make_pair(iterator(newnode, this), true);//注意这里构造的迭代器需要传this
	}

开散列的增容

  • 对于开散列来说,负载因子控制在1就是合适的,所以当_size == _table.size()时进行增容
  • 这里我们不在像闭散列一样创建新的HashTable,而是创建一个vector _newht,因为我们旧节点可以拿过来直接进行插入,所以没必要再进行拷贝从而浪费时间
void CheckCapacity()
	{
		if (_size == _table.size())
		{
			size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
			vector<Node*> _newht;
			_newht.resize(newsize);
			for (int i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur)
				{
					Node* next = cur->_next;//保存下一个位置
					size_t index = GetIndex(KeyOfValue()(cur->_Value) , _newht.size());
					cur->_next = _newht[index];
					_newht[index] = cur;
					cur = next;
				}
				_table[i] = nullptr;//完毕后将此位置置空
			}
			_table.swap(_newht);
		}
	}	

查和删这里就不在实现了,因为需要被map/set封装,所以我们需要实现哈希表的迭代器。

哈希表的迭代器

我们需要找到第一个不为nullptr的链表开始遍历
[数据结构]——哈希表_第10张图片
当走到25后我们如何跳到7的位置呢?因为我们的迭代器实际上就是一个个HashNode的指针,所以我们可以拿到当前节点的K值,这样也就是可以计算出我们所在表中的位置,从而找到下一个位置,但是要知道我们当前所在表中的位置我们就需要表的大小,所以我们就需要拿到哈希表的指针,综上所述,struct HTIterator如下

template<class K, class V, class KeyOfValue, class HashFunc>
struct HTIterator
{
	typedef HashNode<V> Node;
	typedef HTIterator<K, V, KeyOfValue, HashFunc> self;
	Node* _node;
	HashTable<K, V, KeyOfValue, HashFunc>* _ht;//需要哈希表指针来寻找迭代器下一个要到达的位置

	HTIterator(Node* node, HashTable<K, V, KeyOfValue, HashFunc>* ht)
		: _node(node)
		, _ht(ht)
	{}

	self operator++()
	{
		if (_node->_next)//直接指向下一给
		{
			_node = _node->_next;
		}
		else
		{
			//找到当前所在表的位置
			size_t index = _ht->GetIndex(KeyOfValue()(_node->_Value), _ht->_table.size());
			++index;

			for (; index < _ht->_table.size(); index++)
			{
				if (_ht->_table[index] != nullptr)
				{
					_node = _ht->_table[index];//寻找表的下一个位置
					break;
				}
			}
			if (index == _ht->_table.size())//如果已经走到结尾,那么直接将迭代器置空
			{
				_node = nullptr;
			}
		}

		return *this;
	}

	V& operator*()
	{
		return _node->_Value;
	}

	V* operator->()
	{
		return &_node->_Value;
	}

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

因为在struct HTIterator中访问了_table,所以我们不得不把struct HTIterator设置为HashTable的友元:

template<class K, class V, class KeyOfValue, class HashFunc>//模板类型的迭代器
	friend struct HTIterator;

在HashTable中实现begin()和end().

iterator begin()
	{
		for (int i = 0; i < _table.size(); i++)
		{
			if (_table[i] != nullptr)
			{
				return iterator(_table[i], this);//this实际上就是哈希表的指针
			}
		}
		return iterator(nullptr, this);//走到这里说明此时这个哈希表为空
	}

	iterator end()
	{
		return iterator(nullptr, this);
	}

对哈希效率的进一步优化

我们知道,对素数进行取模能大幅度减少结果冲突的可能性:
15%7 == 1
25%7 == 4
5 %7 == 5

所以我们在对表的大小进行改变时,统一将表大小设置为素数:

static size_t GetNextPrime(size_t value)
{
	// 使用素数表对齐做哈希表的容量,降低哈希冲突
	const int _PrimeSize = 28;
	static const unsigned long _PrimeList[_PrimeSize] =
	{
		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
	};
 
	for (size_t i = 0; i < _PrimeSize; ++i)
	{
		if (_PrimeList[i] > value)
		{
			return _PrimeList[i];
		}
	} 
	return _PrimeList[_PrimeSize - 1];
}

Insert函数中将代码修改为:

size_t newSize = GetNextPrime(_tables.size());

总结

说到这里我们的HashTable就算是告一段落了,我们重在理解哈希表的创建原理和哈希冲突是怎么解决的,我们这里的开散列实际上已经是一种不错的优化方式了,但是解决哈希冲突的终极方法是:我们现在在表中挂载的是单链表,如果极端情况下数据都被累积在同一个链表中,这样搜索效率最糟情况下还是O(N),但是java8之后采用了在表下挂载红黑树的操作(一脸震惊),只要同一个位置挂载了超过8个节点后就会被转化成红黑树,这样即使在最早的情况下搜索时间复杂度也保持在O(logN ),这就大大提高了搜索的效率。这些大佬的操作真的不是一般人可以想到的。

关于哈希表的讲解就到这里,下篇文章我会用本节实现的Hash表去封装出unordered_map/set,本节中有什么错误或者不懂的地方还请读者提出。

附录(哈希表完整代码)

#pragma once
#include
#include
#include
using namespace std;

template<class V>
struct HashNode
{
	V _Value;
	HashNode<V>* _next;
	HashNode(const V& v)
		:_Value(v)
		, _next(nullptr)
	{}
};

template<class K, class V, class KeyOfValue,class HashFunc>//前置声名
class HashTable;

template<class K, class V, class KeyOfValue, class HashFunc>
struct HTIterator
{
	typedef HashNode<V> Node;
	typedef HTIterator<K, V, KeyOfValue, HashFunc> self;
	Node* _node;
	HashTable<K, V, KeyOfValue, HashFunc>* _ht;//需要哈希表指针来寻找迭代器下一个要到达的位置

	HTIterator(Node* node, HashTable<K, V, KeyOfValue, HashFunc>* ht)
		: _node(node)
		, _ht(ht)
	{}

	self operator++()
	{
		if (_node->_next)//直接指向下一给
		{
			_node = _node->_next;
		}
		else
		{
			//size_t index = KeyOfValue()(_node->_Value) % (_ht->_table.size());//找到当前所在表的位置
			size_t index = _ht->GetIndex(KeyOfValue()(_node->_Value), _ht->_table.size());
			++index;

			for (; index < _ht->_table.size(); index++)
			{
				if (_ht->_table[index] != nullptr)
				{
					_node = _ht->_table[index];//寻找表的下一个位置
					break;
				}
			}
			if (index == _ht->_table.size())//如果已经走到结尾,那么直接将迭代器置空
			{
				_node = nullptr;
			}
		}

		return *this;
	}

	V& operator*()
	{
		return _node->_Value;
	}

	V* operator->()
	{
		return &_node->_Value;
	}

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

template<class K, class V, class KeyOfValue, class HashFunc>
class HashTable
{
	typedef HashNode<V> Node;
	template<class K, class V, class KeyOfValue, class HashFunc>//模板类型的迭代器
	friend struct HTIterator;
public:
	typedef HTIterator<K, V, KeyOfValue, HashFunc> iterator;
	HashTable()
		:_size(0)
	{}

	iterator begin()
	{
		for (int i = 0; i < _table.size(); i++)
		{
			if (_table[i] != nullptr)
			{
				return iterator(_table[i], this);//this实际上就是哈希表的指针
			}
		}
		return iterator(nullptr, this);//走到这里说明此时这个哈希表为空
	}

	iterator end()
	{
		return iterator(nullptr, this);
	}

	void CheckCapacity()
	{
		if (_size == _table.size())
		{
			size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
			vector<Node*> _newht;
			_newht.resize(newsize);
			for (int i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur)
				{
					Node* next = cur->_next;
					//size_t index = KeyOfValue()(cur->_Value) % _newht.size();
					size_t index = GetIndex(KeyOfValue()(cur->_Value) , _newht.size());
					cur->_next = _newht[index];
					_newht[index] = cur;
					cur = next;
				}
				_table[i] = nullptr;
			}
			_table.swap(_newht);
		}
	}	

	pair<iterator,bool> Insert(const V& v)
	{
		CheckCapacity();
		KeyOfValue kov;
		//size_t index = kov(v)%_table.size();
		size_t index = GetIndex(KeyOfValue()(v), _table.size());
		Node* cur = _table[index];
		while (cur)
		{
			if (kov(cur->_Value) == kov(v))//表示表中已经存在了此数据
				return make_pair(iterator(cur,this),false);
			cur = cur->_next;
		}

		Node* newnode = new Node(v);//此处进行头插,相当于对数组的赋值
		newnode->_next = _table[index];
		_table[index] = newnode;
		++_size;

		return make_pair(iterator(newnode, this), true);//注意这里构造的迭代器需要传this
	}

	size_t GetIndex(const K& key, size_t size)
	{
		HashFunc hf;
		return hf(key) % size;
	}
private:
	vector<Node*> _table;
	size_t _size;
};

你可能感兴趣的:(哈希,哈希冲突,数据结构)