c++之哈希表的介绍与实现

1.unordered_set/unordered_map与set的比较

1.二者的底层不同但功能相似

2.在用iterator遍历时,unordered系列是无序的,但set/map是有序的

3.二者在不用mutliple的情况下都是去重的

4.红黑树的增删查改为O(logN),哈希表的增删查改为O(1)

5.哈希表对K有比较==的需求,哈希表对K有比较>或<的需求

2.哈希表最简单的实现方法(直接定址法)

就是哈希函数只为+或-(甚至没有)的情况的映射,优点就是简单,缺点就是当数值的范围很大时有开辟的映射数组也会很大,且数据只能是可以直接转换为整形的类型。

使用例子:字符串中的第一个唯一字符

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s)
    {
    int count[26] = {0};
    for(int a = 0;a< s.size();a++)
    {
        count[s[a]-'a']++;
    }
    for(int a = 0;a < s.size();a++)
    {
if(count[s[a] - 'a'] == 1)
return a;
    }
    return -1;
    }
};

从这题能发现哈希表的本质就是将原数组的值通过映射关系变成另一个函数的下标,对应下标的空间就是该原数组的值的另一个性质。

3.应对开辟空间太大的情况(也就是映射的其他手段)

1.除法散列法(就是取模)

有N个值,对应的映射空间为M,有M>=N(必要的条件).此时的哈希函数为h(key) = key%M.(h(key)为映射空间的下标)。

负载因子 = N / M.

M要尽可能的避免为2^n或10^n这种幂的情况,如%2^n的情况就相当于只保留key二进制的后n位了(相当于一个有32位的数值最后就只有n位参与,减少了有效位),而多要可能使用质数.

上述行为的本质是增加有效位减少哈希冲突的情况。

保留二进制的后n位的方法(后两条就是JAVA处理2^n少有效位的情况,直接>>然后全部^在一起即可):

cpp 复制代码
int hashi = key%2^n;//使用除法效率低
int hashi = key&0xffff;//如果不是16位而是17..的情况是就会很难设计值
int hashi = key&((1 << n) - 1)//右移n位就是第17位,-1就是后16位为1
//n最好是大于等于16的情况,使得后续的右移的位数能小于n的位数
hashi = hashi^(key >> (32 - n))//此处是为了将前面被无视的为也参与hashi初始化

2.在除法散列法中会出现哈希矛盾的情况

例:30%11 == 8和52%11 == 8.的位置重复了,二者此时就要进行处理。

解法一:线性探测:一直玩后面找(到头了就后到开头找)直到找到空位置再储存。

每个位置都要设计一个标识符,目的是为了在发生删除时出现的错误。例:

30%11 == 8和52%11 == 8.,当52存在30的下一个位置时,如果30被删除了,查找52时,由于hashi的初始化还是为8,因此会从下标8开始玩后找,但由于下标8处的值已经被删除了,因此就会直接返回造成查找失败了。

4.代码实现

此处给出大框代码:

cpp 复制代码
#include<iostream>
using namespace std;
#include<vector>
enum state
{
	EXIIS,
	EMPTY,
	DELETE
};


//以unordered_map为了例子
template<class K,class V>
struct HashData
{
	pair<K, V>_kv;
	state _state = EMPTY;
};

template<class K, class V>
class HashTable
{
public:
	HashTable()
		//vector可以直接使用参数初始化大小
		:_tables(11)
		,_n(0)
	{ }
private:
	vector<HashData<K, V>> _tables;
	size_t _n;
};

2.insert大体逻辑

cpp 复制代码
enum state
{
	EXIST,
	EMPTY,
	DELETE
};


//以unordered_map为了例子
template<class K,class V>
struct HashData
{
	pair<K, V>_kv;
	state _state = EMPTY;
};

template<class K, class V>
class HashTable
{
public:
	HashTable()
		//vector可以直接使用参数初始化大小
		:_tables(11)
		,_n(0)
	{ }
	bool insert(const pair<K,V> kv)
	{
		//多规定负载因子大于0.7时就要扩容(因此哈希表是永远不会满的)
		if (_n * 10 / _tables.size() >= 7)
		{
			HashTable<K, V> newtable;
			newtable._tables.resize(_tables.size()*2);
			//不能采用赋值的操作,因为在扩容了之后M发生了变化
			//即原本_table中的值对应的位置会发生变化
			//因此可以采用回调的方式往新哈希表中插入数值
			//编译器会自动帮我们插入好
			for (auto e : _tables)
			{
				newtable.insert(e._kv);
			}
			_tables.swap(newtable._tables);
		}




		//必须使用size而不是capacity,
		//原因在于capacity的使用必须是连续的插入
		//然而哈希表的元素插入并不连续,因此要用size
		//说白了就是哈希表的capacity意义不大
		//hash0就是直接映射时的对应关系
		size_t hash0 = kv.first % _tables.size();
		//hashi是用于hash0不是对应kv时往后面走的
		size_t hashi = hash0;
		size_t  i = 1;
		while (_tables[hashi]._state == EXIST)
		{
			hashi = (hash0 + i) % _tables.size();
			i++;
		}
		_tables[hashi]._kv = kv;
		_n++;
		_tables[hashi]._state = EXIST;
		return true;
	}
private:
	vector<HashData<K, V>> _tables;
	size_t _n;
};

2.find

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
	size_t hash0 = key % _tables.size();
	size_t hashi = hash0;
	size_t i = 1;
	while (_tables[hashi]._state != EMPTY)
	{
		if (_tables[hashi]._kv.first && _tables[hashi]._state != DELETE)
			return &_tables[hashi];
		return &_tables[hashi];
		hashi = (hash0 + i) % _tables.size();
		i++;
	}
	return nullptr;
}

我们的哈希表不想要重复数据因此要在insert的开头加入一些判断

cpp 复制代码
if(find(kv.first)) return false;

一次探测的问题就在于一直往同一个方向走使得元素往同一个方向堆积。因此二次探测就是分别往两个方向堆积:

cpp 复制代码
//...
int flag = 1;
size_t hashi = (hashi0 + (i*i*flag))%_table.size();
//当hashi<0时,就是往后面走,加个_table_size()即可
if(hashi < _table.size())
{
hashi += _table.size();
}
//先走正方向,后走负方向,后方向走完就i++走下一步
if(flag == 1)
{
flag = -1;
}
else 
{
flag = 1;
i++;
}

在c++中的扩容值就是调用一个提前写好的一个素数表函数:

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
{
 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
 };

 const unsigned long* first = __stl_prime_list;
 const unsigned long* last = __stl_prime_list + __stl_num_primes;
 const unsigned long* pos = lower_bound(first, last, n);
 return pos == last ? *(last - 1) : *pos;
}

该函数会返回一个最临近的比n大的一个素数。

用法:

cpp 复制代码
构造函数中:
//....
:_table(__stl_next_prime(0))
//..

或扩容时
       //当size与函数内的素数相等时只会返回该素数的值,因此要+1来返回比_table.size()大的值
newtable._tables.resize(__stl_next_prime(_table.size()+1));

在java中扩容与得到hash0的方式

cpp 复制代码
//直接初始化为16即可,这个是一个新的成员变量
size_t _m = 16;
//用该值来初始化
//...
:_table(pow(2,_m));
//..


//扩容前先_m++
_m++;
newtable.tables.resize(pow(2,_m));

size_t hashunc(const K& key)
{
//与后_m为&
size_t hash = key&(_tables.size() -1 );
//将前32 - _m也并进来来实现全部二进制位的使用
hash ^= (key >> (32 - _m));
return hash;
}

//在insert函数中
hash0 = hashfun(key);
//在find函数中
hash0 = hashfun(kv.first);

很多时候我们传的变量并不时整数,如模板参数为string时,此时我们就要在传一个仿函数来对非整形变量进行映射(即转变为一个整形,目的是用与给hash0初始化(%只允许整形变量使用))

cpp 复制代码
//默认仿函数
template<class K>
struct hashfunc
{
    size_t operator()(const K& key)
    {
    return (size_t)key;
    }
}

//采用特化的方式来对string进行特殊处理
template<>
struct hashfunc<string>
{
    size_t operator()(const string& s)
    {
        //全部加在一起即可
        size_t ret = 0;
        for(auto e: s)
        {
        ret += e;
        //用于解决只是顺序不同的字符串的
        ret *= 131;
        }
        return ret;
    }
}


//下面是hashtable的类类型简化
template<... class hash = hashfunc<K>>
{
//这个仿函数就是用于这里的,将K转换为一个数字
hash0 = hash()(kv.first)%_tables.size();
}

总结一下哈希表对K的要求

1.K能转换为size_t的类型(仿函数)

2.K可以比较等于(==重载)

5.非常规解法

1.乘法散列法(哈希冲突时的解法)

K乘以A(0<A<1),取小数部分,再M*小数部分向下取整即可。A为黄金比例时最好。

2.全域散列法(用于防止有人可以设计一堆会在同一个位置冲突的值)

就是设计一个随机的哈希函数(运行时时固定的)。

意义:正常情况下例:string通过*131就可以解决一部分冲突,但有人刻意设计值使所有值都如同一个位置也是有可能的,因此类似于每次都随机一个值来乘(但又是能行的),来解决这组数据的带来的影响。

3.双重散列法

设计两个哈希函数h1(key),h2(key),有h2(key)<M且h2(key)与M互为质数

取互质的常规方式有以下两种:

1.M = 2^n时h2(key)为[0,M-1]的任意奇数

2.M为质数时h2(key) = key % (M-1)

互质的目的在于所有的位置都能走到

例:

h2(key) == 3,M==12,hash0==1时,就只能走1,4,7,11..循环.

但h2(key)==11时,就有11,10,9,8,这样走完全程的了。

6.真解法:链地址法

如图:就是原_table变成一个存单链表的地址的数组,发生冲突的地方就连接在下面即可。

大框:

cpp 复制代码
template<class K,class V>
struct _hash_node
{
	pair<K, V> _kv;
	_hash_node<K, V>* _next;
	_hash_node(const pair<K,V>& kv)
		:_kv(kv)
		,_next(nullptr)
	{ }
};
template<class K,class V>
class _hash_table
{
public:
	typedef _hash_node<K,V> Node;

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

insert

cpp 复制代码
bool insert(const pair<K,V>& kv)
{
	if (_n == _tables.size())
	{
		vector<Node*> newtable(__stl_next_prime(_tables.size() + 1));
		for (auto& e : _tables)
		{
			Node* cur = e;
			while (cur)
			{
				//采用的是将旧_tables的结点直接接到新的_tables上从而实现效率的提高
				Node* next = cur->_next;
				//给旧节点定位新节点
				size_t hashi = cur->_kv.first % newtable.size();
				cur->_next = newtable[hashi];
				newtable[hashi] = cur;
				cur = next;
			}
			e = nullptr;
			_tables.swap(newtable);
		}
	}
//仿函数hash()(kv.first)
	size_t hashi = kv.first % _tables.size();
	Node* newnode = new Node(kv);
	       //就是该位置的第一个结点的空间
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	_n++;
	return true;
}

find

cpp 复制代码
bool find(const K&key)
{
    //仿函数hash()(key)
    size_t hashi = key%_tables.size();
    Node*cur = _tables[hashi];
     while(cur)
     {
        if(cur->_kv,first == key)return cur;
        else cur = cur->_next;
     }
    return nullptr;
}

在insert开头处补

cpp 复制代码
//去重
if(find(kv.first))return false;

erase

cpp 复制代码
bool erase(const K& key)
{
//仿函数hash()(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;
			_n--;
			return true;
		}
		else	//没找到
		{
			prev = cur;
			cur = cur->_next;
		}
	}
	return false;
}

析构

cpp 复制代码
~_hash_table()
{
	for (int a = 0; a < _tables.size(); a++)
	{
		Node* cur = _tables[a];
		while (cur)
		{
			Node* next = cur->_next;
			delete cur;
			cur = next;
		}
		_tables[a] = nullptr;
	}
}

最后给_hasi_table加一个仿函数的模板参数然后修改一下key和kv.first处的代码即可.

相关推荐
网域小星球2 小时前
C 语言从 0 入门(十四)|文件操作:读写文本、保存数据持久化
c语言·开发语言·文件操作·fopen·fprintf
网域小星球2 小时前
C 语言从 0 入门(七)|字符数组与字符串完整精讲|VS2022 高质量实战
c语言·开发语言·字符串·vs2022·字符数组
Jia ming2 小时前
C语言实现日期天数计算
c语言·开发语言·算法
xh didida2 小时前
C++ -- string
开发语言·c++·stl·sring
m晴朗2 小时前
测试覆盖率从35%到80%:我用AI批量生成C++单元测试的完整方案
c++·gpt·ai
lly2024062 小时前
Bootstrap 折叠组件详解
开发语言
无限进步_3 小时前
【C++&string】大数相乘算法详解:从字符串加法到乘法实现
java·开发语言·c++·git·算法·github·visual studio
苏纪云3 小时前
蓝桥杯考前突击
c++·算法·蓝桥杯
‎ദ്ദിᵔ.˛.ᵔ₎3 小时前
模板template
开发语言·c++