哈希及模拟实现

文章目录

  • 哈希
    • 1. 哈希相关概念
      • 1.1 哈希概念
      • 1.2 哈希冲突
      • 1.3 哈希函数
      • 1.4 哈希冲突解决
        • 1.4.1 闭散列/开放定址法
          • (1)线性探测
          • (2) 二次探测
        • 1.4.2 开散列/哈希桶
    • 2. 开放定址法的实现
      • 2.1 结构
      • 2.2 插入Insert
        • 2.2.1 传统写法
        • 2.2.2 现代写法
      • 2.3 查找Find
      • 2.4 删除Erase
      • 2.5 整体代码
    • 3. 哈希桶法的实现
      • 3.1 结构
      • 3.2 插入Inert
        • 析构函数
        • 插入代码
      • 3.3 查找Find
      • 3.4 删除Erase
      • 3.5 添加仿函数
      • 3.6 除留余数法的扩容
      • 3.7 计算最大桶
      • 3.8 性能方面测试
      • 3.9 整体代码
    • 4. 开放定址法 VS 哈希桶法

哈希

1. 哈希相关概念

1.1 哈希概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

当向该结构中:

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

该方式即为哈希(散列)方法哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

哈希及模拟实现_第1张图片

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?

1.2 哈希冲突

对于两个数据元素的关键字 k i k_i ki k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==Hash( k j k_j kj),即: 不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?

1.3 哈希函数

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数应该比较简单

常见哈希函数

1. 直接定址法–(常用)

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

2. 除留余数法–(常用)

设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key % p(p<=m), 将关键码转换成哈希地址。
使用场景:数据范围不集中, 分布分散

1.4 哈希冲突解决

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

1.4.1 闭散列/开放定址法

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?

(1)线性探测

比如1.1中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

  • 插入

    • 通过哈希函数获取待插入元素在哈希表中的位置

    • 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

    哈希及模拟实现_第2张图片

  • 删除
    采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

线性探测优点:实现非常简单。
线性探测缺点:**一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。**如何缓解呢?

(2) 二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: H i H_i Hi = ( H 0 H_0 H0 + i 2 i^2 i2 )% m, 或者: H i H_i Hi = ( H 0 H_0 H0 - i 2 i^2 i2 )% m。其中:i =1,2,3…, H 0 H_0 H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。

哈希及模拟实现_第3张图片

对于1.1中如果要插入44,产生冲突,使用解决后的情况为:

哈希及模拟实现_第4张图片

因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷

1.4.2 开散列/哈希桶

开散列概念

开散列法又叫**链地址法(**开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中

哈希及模拟实现_第5张图片

从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素

2. 开放定址法的实现

2.1 结构

实现具体函数之前,我们先根据上面对开放定址法介绍,写出此结构的大框架。

  1. 首先我们要将具体数据存储在哈希表中,存储的是一个pair类型的键值对,同时要考虑删除数据后的处理,是要置空吗?不是,置空的话会影响查找(查找到空就结束),所以必须要定义一个枚举类型的状态值来表示该元素在哈希表中的状态;将这两项封装成HashData的类,来表示开放定址法中的存储节点。
  2. 需要一个哈希表里面来存储数据,并且需要一个变量来表示存储数据的有效个数;那么这个哈希表怎么实现呢?可以像顺序表部分一样给成:节点的指针,size和capacity形式,但是考虑到后续哈希表存满情况下要扩容时,这样实现就比较麻烦;更好的方式是直接给一个vector里面保存节点的结构
enum State
{
    EMPTY,
    EXIST,
    DELETE
};

template <class K, class V>
struct HashData
{
    pair<K, V> _kv;
    State _state = EMPTY;
};

template <class K, class V>
class HashTable
{
public:
private:
    // 可以实现成原始的方式: HashTable* tables, size, capacity
    // 直接用vector实现
    vector<HashData<K, V>> _tables;
    size_t _n = 0; // 存储的数据有效个数
};

2.2 插入Insert

对于插入来说,我们这里使用的是线性探测法,那就意味着会发生冲突,从发生冲突的位置开始,依次向后++,直到寻找到下一个空位置为止;此时还会引发另一个问题,若空间不足了,需要扩容,但是对于哈希表并不能直接等到空间存满了才扩容,一旦达到某个程度,就会因哈希冲突出现造成效率下降的问题,为了解决这类问题我们引入了一个负载因子(载荷因子)的概念,负载因子 = 表中元素个数 / 散列长度

  • 负载因子越大,冲突的概率越高,查找效率越低,空间利用率越高
  • 负载因子越小,冲突的概率越低,查找效率越高,空间利用率越低

负载因子的出现是一种以空间换时间的方法,一般将负载因子限制在0.7左右,超过了这个值就需要扩容了

Insert有两种写法:

  1. 给一个新表newtables,遍历旧表将元素重新映射到新表,最后交换新旧表。但是这个过程的代码与将元素映射到旧表的代码相 同,出现了代码冗余,可以将这部分代码封装成一个类的成员函数,直接调用这个函数

  2. 重新创建一个HashTable对象, 直接去复用Insert,最后交换新旧表

2.2.1 传统写法

我这里没有封装成员函数,是直接写的,代码冗余

bool Insert(const pair<K, V> &kv)
{
	//提前处理一下表为0的情况
    if (_tables.size() == 0)
    {
        _tables.resize(10);    //一定要使用resize, reserve只会改变capacity,不会改变size
    }

    // 当前表为0直接除时, 会出现除0错误,所以上面有提前处理
    if (_n * 10 / _tables.size() >= 7)    //这里也可以考虑强转成double
    {
        size_t newsize = _tables.size() * 2;

        // 重新开一个新表
        vector<HashData<K, V>> newtables(newsize);    

        // 遍历旧表, 重新映射到新表
        for (auto &data : _tables)
        {
            if (data._state == EXIST)
            {
                // 重新计算在新表的位置
                int hashi = data._kv.first % newtables.size();
                size_t i = 1;
                size_t index = hashi;
                while (newtables[index]._state == EXIST)
                {
                    index = hashi + i; 
                    index %= newtables.size();
                    ++i;
                }
                newtables[index]._kv = data._kv;
                newtables[index]._state = EXIST;
            }
        }
        _tables.swap(newtables); 
    }

    size_t hashi = kv.first % _tables.size();

    // 线性探测
    size_t i = 1;
    size_t index = hashi;
    while (_tables[index]._state == EXIST)
    {
        index = hashi + i;         
        index %= _tables.size();
        ++i;
    }

    // vector的[]会检查下标是否小于capacity
    _tables[index]._kv = kv;
    _tables[index]._state = EXIST;
    ++_n;

    return true;
}
2.2.2 现代写法
bool Insert(const pair<K, V> &kv)
{
    if (Find(kv.first)) //元素已经存在, 就不要插入了
    {
        return false;
    }

    // 负载因子 = 表中元素个数 / 散列长度
    // 这里控制负载因子超过0.7就扩容

    // 当前表为0直接除时, 会出现除0错误
    if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) //这里也可以考虑强转成double
    {
        // 1. 表为空,扩不上去
        // 2. reserve改变vector的capacity,size不变
        // v.reserve(_tables.capacity() * 2);

        size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;

        // 采用复用的方式,当然也可以考虑把代码封装成类
        HashTable<K, V> newht; //重新创建一个对象
        newht._tables.resize(newsize);

        // 这里逻辑是: 先重新映射旧表的关系, 映射完后交换, 再去插入新的元素
        for (auto &data : _tables)
        {
            if (data._state == EXIST)
            {
                newht.Insert(data._kv);
            }
        }
        _tables.swap(newht._tables); // 底层交换的是vector的3个指针
    }

    size_t hashi = kv.first % _tables.size();

    // 线性探测
    size_t i = 1;
    size_t index = hashi;
    while (_tables[index]._state == EXIST)
    {
        index = hashi + i;          //  hashi + i*i;  二次探测
        index %= _tables.size();
        ++i;
    }

    //vector的[]会检查下标是否小于capacity
    _tables[index]._kv = kv;
    _tables[index]._state = EXIST;
    ++_n;

    return true;
}

Insert执行逻辑,最好调试观察一下

哈希及模拟实现_第6张图片

2.3 查找Find

查找时按照Key值去查找,逻辑是: 如果当前表为空就直接返回nullptr;在当前位置状态不为空的前提下,如果此位置元素状态是存在且刚好等于key,则返回此位置的地址,否则返回nullptr,但是还存在这样一种极端情况,所以在处理时如果已经查找了一圈, 那么说明全是存在 + 删除,直接跳出循环返回nullptr

哈希及模拟实现_第7张图片

HashData<K, V> *Find(const K &key)
{
    if (_tables.size() == 0)
        return nullptr;

    size_t hashi = key % _tables.size();

    // 线性探测
    size_t i = 1;
    size_t index = hashi;
    while (_tables[index]._state != EMPTY)
    {
        if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
        {
            return &_tables[index];
        }
        index = hashi + i;
        index %= _tables.size();
        ++i;

        // 如果已经查找了一圈, 那么说明全是存在 + 删除
        if (index == hashi)
        {
            break;
        }
    }
    return nullptr;
}

2.4 删除Erase

删除这里采用伪删除法,即并不是真正意义上的删除,而只是将这个位置对应的状态改为DELETE

删除的逻辑是: 先查找,如果此元素在表中, 则将这个位置对应状态改为DELETE,表中元素个数减1;如果此元素不在表中,则直接返回false

// 伪删除法: 没有真正地把这个数据删除, 只是把这位置对应的状态标记为删除
bool Erase(const K &key)
{
    HashData<K, V> *ret = Find(key);   //找的是地址
    if (ret)
    {
        ret->_state = DELETE;
        --_n;
        return true;
    }
    else
    {
        return false;
    }
}

2.5 整体代码

namespace OpenAddress
{
	enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};

	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state=EMPTY;
	};

	template<class K,class V>
	class HashTable
	{
	public:

		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))  //元素已经存在, 就不要插入了
			{
				return false;
			}

			//负载因子 = 表中元素个数 / 散列长度
			//这里控制负载因子超过0.7就扩容

			//当前表为0直接除时, 会出现除0错误
			if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)    
			{
				//1. 表为空,扩不上去
				//2. reserve改变vector的capacity,size不变
				//v.reserve(_tables.capacity() * 2);

				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;

				//采用复用的方式,当然也可以考虑把代码封装成类
				HashTable<K, V> newht;		//重新创建一个对象
				newht._tables.resize(newsize);  

				//这里逻辑是: 先重新映射旧表的关系, 映射完后交换, 再去插入新的元素
				for (auto& data : _tables)
				{
					if (data._state == EXIST)
					{
						newht.Insert(data._kv);
					}
				}
				_tables.swap(newht._tables);    //底层交换的是vector的3个指针
			}


			size_t hashi = kv.first % _tables.size();

			//线性探测
			size_t i = 1;
			size_t index = hashi;
			while (_tables[index]._state == EXIST)
			{
				index = hashi + i;			 //hashi + i*i;  二次探测
				index %= _tables.size();
				++i;
			}

			//vector的[]会检查下标是否小于capacity
			_tables[index]._kv = kv;
			_tables[index]._state = EXIST;
			++_n;

			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			if (_tables.size() == 0)
				return nullptr;

			size_t hashi = key % _tables.size();

			//线性探测
			size_t i = 1;
			size_t index = hashi;
			while (_tables[index]._state != EMPTY)
			{
				if (_tables[index]._state == EXIST
					&& _tables[index]._kv.first == key)
				{
					return&_tables[index];
				}
				index = hashi + i;			
				index %= _tables.size();
				++i;

				//如果已经查找了一圈, 那么说明全是存在 + 删除
				if (index == hashi)
				{
					break;
				}
			}
			return nullptr;
		}

		//伪删除法: 没有真正地把这个数据删除, 只是把这位置对应的状态标记为删除
		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				--_n;
				return true;
			}
			else
			{
				return false;
			}
		}
	private:
		//可以实现成原始的方式: HashTable* tables, size, capacity
		//直接用vector实现
		vector<HashData<K,V>> _tables;
		size_t _n=0;   //存储的数据个数
	};
}

3. 哈希桶法的实现

3.1 结构

实现具体函数之前,我们先根据上面对哈希桶法介绍,写出此结构的大框架。

首先哈希桶法不像开放定址法需要给出节点的状态值,它是来了一个元素就挂在当前桶对应的位置下面,所以需用当前节点的指针,所以HashNode中存储的是节点指针和键值对;同时vector中里面不在是这个节点本身了,而是以指针的形式保存这个节点的地址,是一种指针数组类型,就像数组里面挂链表的形式,然后这个哈希桶类需要在添加一个模板参数,稍后介绍。

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

    HashNode(const pair<K, V> &kv)
        : _next(nullptr), _kv(kv)
    {
    }
};
template <class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
    typedef HashNode<K, V> Node;
private:
    vector<Node*> _tables; //vector里面挂节点(链表),是指针数组类型
    size_t _n = 0;          //存储有效数据个数
};

3.2 插入Inert

由于是数组挂链表的形式,实现的是链表的插入所以采用头插,虽然这里并不会存在像开放定址法一样所有的桶都挂满了,看起来每个桶可以无限的向后挂元素,但是一旦桶中链表节点非常多还是会影响哈希表的性能,所以还是要对哈希表进行增容。开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以对哈希表增容

Insert的逻辑: 向vector中挂数组的思路,就是构建当前的映射位置,在此位置上头插节点就行。

重点来看扩容的思路:

  • 扩容时我们可以像开放定址法的现代写法一样创建一个新的HashTable,完成代码复用交换新旧表,对于这种扩容方式,实际上很浪费空间,因其在插入过程中都是在拷贝节点,在一定程度上浪费了空间,并且在析构时会析构两次,又是一次性能的缺失。
  • 优化: 我们可以将旧表中的节点头插到新表指定的映射位置, 这样就不需要拷贝创建新节点,但这样需要注意的是:要将旧表的每一个元素:_tables[i]清理掉或者都置nullptr,防止同一块空间析构两次造成浅拷贝。

因为是链表结构,默认生成的析构函数会释放掉vecto本身,但是不能将链表的节点释放,所以我们需要自己写一个析构函数将链表节点的空间释放。

HashTable<K, V> newht;
newht._tables.resize(newsize);
for (auto cur : _tables)
{
    while (cur)
    {
        newht.Insert(cur->_kv);
        cur = cur->_next;
    }
}
_tables.swap(newht._tables);
析构函数
~HashTable()   //保存下一个位置,释放当前位置
{
    for (auto &cur : _tables)
    {
        while (cur)
        {
            Node *next = cur->_next;
            delete cur;
            cur = next;
        }
        cur = nullptr;
    }
}
插入代码
bool Insert(const pair<K, V> &kv)
{
    if (Find(kv.first))  //元素已经存在, 就不要插入了
    {
        return false;
    }

    // 负载因子等于1时扩容   --- 还是实现成传统写法
    if (_n == _tables.size())
    {
        size_t newsize =_tables.size() == 0 ? 10 : _tables.size() * 2;

        vector<Node *> newtables(newsize, nullptr);

        for(auto & cur: _tables)
		{
			while (cur)
		    {
				Node* next = cur->_next;

				size_t hashi = cur->_kv.first % newtables.size();

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

				cur = next;
			}
		}
        _tables.swap(newtables);
    }

    size_t hashi = kv.first % _tables.size();

    // 直接插入就行 —— 这里用头插
    Node *newnode = new Node(kv);
    newnode->_next = _tables[hashi];
    _tables[hashi] = newnode;

    ++_n;
    return true;
}

3.3 查找Find

与开放定址法思路相同,只是这里走链表节点去查找的

Node *Find(const K &key)
{
    if (_tables.size() == 0)
        return nullptr;

    size_t hashi = key % _tables.size();
    Node *cur = _tables[hashi];
    while (cur)
    {
        if (cur->_kv.first == key)
        {
            return cur;
        }
        cur = cur->_next;
    }
    return nullptr;
}

3.4 删除Erase

就是一个单链表头删的过程

bool Erase(const K &key)  //从链表中删除
{
    size_t hashi = key % _tables.size();
    Node *cur = _tables[hashi];
    Node *prev = nullptr; //保存前一个节点

    while (cur) //链表头删的过程
    {
        if (cur->_kv.first == key)
        {
            if (prev == nullptr)
            {
                _tables[hashi] = cur->_next;
            }
            else
            {
                prev->_next = cur->_next;
            }

            delete cur;
            return true;
        }
        else
        {
            prev = cur;
            cur = cur->_next;
        }
    }
    return false;
}

3.5 添加仿函数

上面的代码几个重要接口已经写完了,我们向其中插入这样的键值对完全是没有问题的,但是一旦想要向之前map和set中一样统计水果次数这个代码就运行不起来了,原因是插入的是这样的键值对,string无法转换成整型,没有办法取模构建映射关系,所以我们要提供一个仿函数把字符串转整型,同时我们在自己使用时不需要显示传这个仿函数,因此我们可以考虑模板的特化,整型还是整型,参数是string就转成整型,这样就保证了整型系列本身可以转换,字符串通过模板的特化转换。

我们要这样写时,要在上面几个接口取模操作时,外面套一层仿函数对象,具体请看整体代码

字符串仿函数逻辑: 定义一个int变量,每次去字符串元素加到这个变量中,同时这个变量乘31。

参考: 各种字符串Hash函数 - clq - 博客园 (cnblogs.com)

// 整型系列本身可以转换
template <class K>
struct HashFunc
{
    size_t operator()(const K &key)
    {
        return key;
    }
};

// 模板特化  --- 实现字符串比较不用自己显示传仿函数, 和库里保持一致
template <>
struct HashFunc<string>
{
    // BKDR算法
    size_t operator()(const string &s)
    {
        size_t hash = 0;
        for (auto ch : s)
        {
            hash += ch;
            hash *= 31;
        }
        return hash;
    }
};

3.6 除留余数法的扩容

对于哈希来说SGI版本采用了这种方式扩容避免哈希冲突

// size_t newsize = GetNextPrime(_tables.size());
size_t GetNextPrime(size_t prime)
{
    // SGI  --- 除留余数法模素数
    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};

    size_t i = 0;
    for (; i < __stl_num_primes; ++i)
    {
        if (__stl_prime_list[i] > prime)
            return __stl_prime_list[i];
    }

    return __stl_prime_list[i];
}

3.7 计算最大桶

直接遍历更新这个哈希表计算出所挂节点最长的桶即可,定义一个变量遍历的过程去比较更新

// 最大桶
size_t MaxBucketSize()
{
    size_t max = 0;

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

        printf("[%d]->[%d]\n", i, size);
        if (size > max)
        {
            max = size;
        }
    }
    return max;
}

3.8 性能方面测试

void TestHashTable4()
{
	size_t N = 100000;
	HashBucket::HashTable<int, int> ht;
	srand(time(0));
	for (size_t i = 0; i < N; ++i)
	{
		size_t x = rand() + i;
		ht.Insert(make_pair(x, x));
	}

	cout << ht.MaxBucketSize() << endl;
}

运行结果:

哈希及模拟实现_第8张图片

我们可以看到哈希表的性能还是很好的,增删查改时间复杂度: O(1)。虽然说哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

哈希函数提高效率方式:

  1. 负载因子的控制
  2. 单个桶超过一定长度,这个桶改挂红黑树

3.9 整体代码

namespace HashBucket
{

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

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

	//整型系列本身可以转换
	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};

	//模板特化  --- 实现字符串比较不用自己显示传仿函数, 和库里保持一致
	template<>
	struct HashFunc<string>
	{
		//BKDR算法
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto ch : s)
			{
				hash += ch;
				hash *= 31;
			}
			return hash;
		}
	};

	template<class K,class V, class Hash= HashFunc<K>>
	class HashTable
	{
	public:
		typedef HashNode<K, V> Node;

		//size_t newsize = GetNextPrime(_tables.size());
		size_t GetNextPrime(size_t prime)
		{
			// SGI  --- 除留余数法模素数
			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
			};

			size_t i = 0;
			for (; i < __stl_num_primes; ++i)
			{
				if (__stl_prime_list[i] > prime)
					return __stl_prime_list[i];
			}

			return __stl_prime_list[i];
		}

		~HashTable()			//保存下一个位置,释放当前位置
		{
			for (auto&cur : _tables)
			{
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}

				cur = nullptr;
			}
		}


		Node* Find(const K& key)
		{
			Hash hash;
			if (_tables.size() == 0)
				return nullptr;

			size_t hashi = hash(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 hash;		//这是仿函数需要的
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr;       //保存前一个节点

			while (cur)					//链表头删的过程
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}

					delete cur;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

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

			Hash hash;

			//负载因子等于1时扩容
			if (_n == _tables.size())
			{
				//size_t newsize =_tables.size() == 0 ? 10 : _tables.size() * 2;
				size_t newsize = GetNextPrime(_tables.size());    //模素数

				vector<Node*> newtables(newsize, nullptr);

				for(auto & cur: _tables)
				{
					while (cur)
				    {
						Node* next = cur->_next;
		
						size_t hashi = cur->_kv.first % newtables.size();
		
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;
		
						cur = next;
					}
				}
				_tables.swap(newtables);
			}

			size_t hashi = hash(kv.first) % _tables.size();

			//直接插入就行 —— 这里用头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			
			++_n;
			return true;
		}

		//最大桶
		size_t MaxBucketSize()
		{
			size_t max = 0;

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

				printf("[%d]->[%d]\n", i, size);
				if (size > max)
				{
					max = size;
				}
			}
			return max;
		}

	private:
		vector<Node*> _tables;   //vector里面挂节点(链表),是指针数组类型
		size_t _n=0;                //存储有效数据个数
	};
}

4. 开放定址法 VS 哈希桶法

应用哈希桶法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开放地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用哈希桶法反而比开地址法节省存储空间。

你可能感兴趣的:(C++,哈希算法,数据结构,c++)