20.哈希表(哈希冲突,闭散列、线性探测,开散列、哈希桶)

1. unordered系列关联式容器

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到log_2 N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,本文中只对unordered_mapunordered_set进行介绍。

1.1 unordered_map

  1. unordered_map是存储<key, value>键值对的关联式容器,其允许通过key快速的索引到与其对应的value。
  2. unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
  3. 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的valueunordered_map将相同哈希值的键值对放在相同的桶中。
  4. unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
  5. unordered_maps实现了直接访问操作符operator[],它允许使用key作为参数直接访问value
  6. 它的迭代器至少是前向迭代器。

unordered_map的说明文档

1.2 unordered_map的使用

// unordered_map的类模板
// std::unordered_map
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;
  • 演示1
#include<iostream>
#include<unordered_set>
using namespace std;

int main()
{
    // 创建一个unordered_set对象,传入int对类模板进行实例化
	unordered_set<int> us;
    
    // 插入数据
	us.insert(3);
	us.insert(1);
	us.insert(3);
	us.insert(2);
	us.insert(0);

    // 使用迭代器遍历
	unordered_set<int>::iterator it = us.begin();
	while (it != us.end())
	{
		cout << *it << " ";
		++it;
	}
	cout << endl;

	return 0;
}
// 打印结果:3 1 2 0
  • 演示2
#include<iostream>
#include<unordered_map>
#include<string>

using namespace std;

int main()
{
	string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };

	unordered_map<string, int> countmap;
	for (auto& e : arr)
	{
		/*
		// 使用auto,或者直接标明类型都是可以的
		// find()的返回值是一个迭代器
		// unordered_map<string, int>::iterator it = countmap.find(e);
		auto it = countmap.find(e);

		// 如果满足it == countmap.end(),那就是没有找到key,则我们插入key
		if (it == countmap.end())
		{
		  countmap.insert(make_pair(e, 1));
		}
		else
		{
			// 运行到这里说明,key已经存在了,那么我们对Value进行++
		  it->second++;
		}
		*/

		// 直接使用operator[],和上面的代码的效果是一致的
		countmap[e]++;
	}

	for (const auto& kv : countmap)
	{
		cout << kv.first << ":" << kv.second << endl;
	}

	return 0;
}
// 打印结果:
// 西瓜:3
// 苹果:6
// 香蕉:3
// 草莓:1

unordered_set/unordered_map的高效查找性能

#include<iostream>
#include<unordered_set>
#include<set>
#include<string>

using namespace std;

int main()
{
	const size_t N = 1000000;

	unordered_set<int> us;
	set<int> s;

	// 我们用vector将产生的随机数进行存储
	vector<int> v;
	//  提前开辟空间(防止不断扩容,提高性能)
	v.reserve(N);

	srand(time(0));
	for (size_t i = 0; i < N; ++i)
	{
		//  case1:插入的都是随机数,但是重复的值比较多
		//  v.push_back(rand());

		//  case2:插入的都是随机数,但是重复的值比较少
		   v.push_back(rand()+i);

		//  case3:插入有序的数
		//  v.push_back(i);
	}

    
    
	// 1.set进行插入
	size_t begin1 = clock();
	for (auto e : v)
	{
		s.insert(e);
	}
	size_t end1 = clock();
	cout << "set insert:" << end1 - begin1 << endl;

    
    
	// 2.unordered_set进行插入
	size_t begin2 = clock();
	for (auto e : v)
	{
		us.insert(e);
	}
	size_t end2 = clock();
	cout << "unordered_set insert:" << end2 - begin2 << endl;

    
    
	// 3.set进行查找
	size_t begin3 = clock();
	for (auto e : v)
	{
		s.find(e);
	}
	size_t end3 = clock();
	cout << "set find:" << end3 - begin3 << endl;

    
    
	// 4.unordered_set进行查找
	size_t begin4 = clock();
	for (auto e : v)
	{
		us.find(e);
	}
	size_t end4 = clock();
	cout << "unordered_set find:" << end4 - begin4 << endl;

	// 用来查看产生了多少个随机数,插入进去了
	cout << s.size() << endl;
	cout << us.size() << endl;

	// 5.set的删除
	size_t begin5 = clock();
	for (auto e : v)
	{
		s.erase(e);
	}
	size_t end5 = clock();
	cout << "set erase:" << end5 - begin5 << endl;

	// 6.unordered_set的删除
	size_t begin6 = clock();
	for (auto e : v)
	{
		us.erase(e);
	}
	size_t end6 = clock();
	cout << "unordered_set erase:" << end6 - begin6 << endl;

	return 0;
}
  • 在vs2019,release版本下测试

  • case1:

插入的都是随机数,但是重复的值比较多
v.push_back(rand());
打印结果为:
set insert : 160
unordered_set insert : 17
set find : 0
unordered_set find : 0
32768
32768
set erase : 34
unordered_set erase : 4
可以看出,当重复值较多时,unordered_set不管是插入的性能还是删除的性能都高于set

  • case2:

插入的都是随机数,但是重复的值比较少
v.push_back(rand()+i);
打印结果为:
set insert : 224
unordered_set insert : 168
set find : 0
unordered_set find : 0
635152
635152
set erase : 277
unordered_set erase : 90
可以看出,当重复值较少时,unordered_set不管是插入的性能还是删除的性能都高于set

  • case3:

插入有序的数
v.push_back(i);
打印结果为:
set insert : 168
unordered_set insert : 228
set find : 0
unordered_set find : 0
1000000
1000000
set erase : 135
unordered_set erase : 134
可以看出,当是有序数列时,set不管是插入的性能还是删除的性能都高于unordered_set

注:vs2019并没有测试出find()的区别,但是其实unorder_set在任意情况下的查找效率都是大于或者等于set的查找效率的。

1.3 unordered_set

unordered_setunordered_map类似,就不过多赘述了

unordered_set的在线说明文档

1.4 字符串中的第一个唯一字符

image-20240425134330296

// 采用了hash映射的思想
//  方法一:
class Solution {
public:
    int firstUniqChar(string s) 
    {
        // 创建一个数组来记录每个字母出现的次数(一共有26个字母)
        int count[26] = {0};  

        // 遍历一遍string,记录字母的出现的个数(使用范围for来进行遍历)
        for(char ch : s) 
        {
            //字符使用ASCLL值进行存储的,因此减去'a',就可以得到相应的下标
            count[ch - 'a']++; 
        }

        // 再找出第一个不重复的字符
        for(size_t i = 0; i < s.size(); i++)
        {
            if(count[s[i] - 'a'] == 1)
                return i;
        }

        // 运行到这里说明并没有找到
        return -1;
    }
};
// 方法二:
class Solution {
public:
    int firstUniqChar(string s) {
    
        // 创建一个哈希对象
        std::unordered_map<char, int> count;

        // 统计每个字符出现的次数
        for (char c : s) {
            count[c]++;
        }

        // 找到第一个出现次数为 1 的字符的索引
        for (int i = 0; i < s.size(); ++i) {
            if (count[s[i]] == 1) {
                return i;
            }
        }

        // 未找到符合条件的字符,返回 -1
        return -1;
    }
};

2. 底层结构

unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。

2.1 哈希概念

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

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

当向该结构中:

  • 插入元素

    根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放

  • 搜索元素

    对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功

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

image-20240425140626313

2.2 哈希冲突

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

把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

发生哈希冲突该如何处理呢?

2.3哈希函数

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

哈希函数设计原则:

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

2.3.1直接定址法–(常用的哈希函数)

**特点:**适用于整型并且范围相对集中的数

如:我们将{2,5,7,9,1}进行映射,或者将{22,25,27.29,21}进行映射,则映射关系如下

image-20230408205510080

2.3.2除留余数法–(常用的哈希函数)

如果我们要进行映射的数据为{3,5,7,55,5,57,7,999999},这样的数据太过分散,如果按照直接定址法,则消耗的空间过大,因此我们采用除留余数法,具体如下:

image-20230408210053044

但是,我们发现需要映射的数据的值不同,但是映射到哈希表的地址是相同的,这被我们成为哈希/冲突碰撞

**2.4 **哈希冲突解决

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

2.4.1 闭散列

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

  • 线性探测(寻找下一个空位置的方法)

线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

插入:
通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。如下图:我们插入44时

image-20230408211735685

或者如下图所示:

image-20230408212807553

  • 删除:

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索(我们查找是查找到空就结束)。比如删除元素27,如果直接删除掉,38查找起来可能会受影响(当线性探测到27时,如果直接删除了27,那么此时这个位置为空,则查找结束,则我们就找不到38了)。因此线性探测采用标记的伪删除法来删除一个元素。

  • 因此:我们给哈希表每个空间给个标记状态
  • EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
  • enum State{EMPTY, EXIST, DELETE};
  • 这样我们删除掉27,则这个空间就会被标记位DELETE,而我们是查到到空间的标记位EMPTY才会结束查找

哈希表的模拟实现(线性探测)

hash表插入的数据的类模板

// 枚举每块空间标记的状态
enum State
{
	EMPTY,
	EXIST,
	DELETE,
};

//  hash中存储的元素的类模板
template<class K, class V>
struct HashData
{
    // 插入的元素是一个pair<K, V>类型的kv值
	pair<K, V> _kv;
    // 表示当前元素的状态
	State _state = EMPTY;
};

HashTable的构造函数

HashTable()
	:_n(0)
{
    // 将_tables(哈希表)的大小初始化为10,避免除0错误(使用hash函数时,需要除以哈希表的大小)
    // 如在insert中:if (_n * 10 / _tables.size() >= 7)
	_tables.resize(10);
}


// hash类模板的私有成员变量
private:
    vector<Data> _tables;
    size_t _n = 0;	// 表中存储的有效数据的个数

哈希函数(仿函数)

各种字符串转整型的hash函数

// 仿函数
// 可以将int、char、double、指针强转为size_t类型
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

// 将string转化为整型,并返回
// 这样我们才可以利用哈希函数来确定要插入的值的位置
// size_t hashi = hf(kv.first) % _tables.size();
// 取模得到hashi

// 特化模板
template<>
struct HashFunc<string>
{
    // BKDR
	//  本算法由于在Brian Kernighan与Dennis Ritchie的《The C Programming Language》一书被展示而得 名,是一种简单快捷的hash算法,也是Java目前采用的字符串的Hash算法(累乘因子为31)。  
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto ch : key)
		{
            // 也可以乘以31、131、1313、13131、131313..
            // 总之用这种方法之后,字符串运算最终得出的hashi基本不相同,则产生的哈希冲突就越少
			hash *= 131;
			hash += ch;
		}
		return hash;
	}
};

插入(insert)

思考:哈希表什么情况下进行扩容呢?如何扩容?

对于扩容有如下的规则:

负载因子也叫作载荷因子

  • 负载因子 = 表中有效数据的个数/表的大小

通过这个公式,我们可以判断出:

​ 1.负载因子越小(表中有效数据的个数越少),则数据地址冲突概率越小,但是消耗的空间越大(表的大小越大)

​ 2.负载因子越大(表中有效数据的个数越多),则数据地址冲突概率越大,但是哈希表空间的利用率越高

因此,规定当负载因子大于我们规定的大小时,哈希表就要进行扩容

bool Insert(const pair<K, V>& kv)
{
    // 假如实现的哈希表中元素唯一,
    // 即key相同的元素不再进行插入
	if (Find(kv.first))
		return false;

    // 1.插入一个元素前,需要先判断哈希表的容量够不够,不够的话就需要进行扩容
	// 大于标定负载因子,就需要扩容
    // 我们规定的负载因子为0.7
    // 但是两个整数相除得不到double类型的数据
    // 因此我们对_n(表中有效数据的个数)和负载因子(0.7)同时扩大10倍
    // 10*有效数据的个数/表的大小 >= 10*负载因子
	if (_n * 10 / _tables.size() >= 7)
	{
        // 扩容之后
		// 旧表数据,重新计算,映射到新表
        // 创建一个新的哈希表(newHT) 
        HashTable<K, V, Hash> newHT;
        
        // _tables.size()是旧表的vector的大小
        // 因此新表的大小我们扩容为旧表的两倍
		newHT._tables.resize(_tables.size() * 2);
		for (auto& e : _tables)
		{
            // 当e._state == EXIST说明旧表的这个空间是存在数据的
            // 那么我们将这个数据重新映射到新表中(标识状态)
			if (e._state == EXIST)
			{
				newHT.Insert(e._kv);
			}
		}
		
        // 我们将新表的vector的数据与旧表vector的数据再进行交换(旧表的容量被交换)
		_tables.swap(newHT._tables);
        
        // 新表的声明周期结束,新表会被系统回收
	}
    
    // 使用仿函数来将kv.first转化为整型
    Hash hf;
    
    // kv.first是拿到的是数据key
    // _tables.size()是哈希表的大小
    // hashi是数据key在哈希表中的下标
    // 利用除留余数法计算出hashi
	size_t hashi = hf(kv.first) % _tables.size();
    
    // 如果_tables[hashi]._state == EXIST
    // 说明hashi这个位置已经有数据了
    // 按照线性探测的方法找下一个位置,并判断下一个位置是否存在数据
    // 如果存在则继续探测下一个位置,如果不存在数据,则进行插入
	while (_tables[hashi]._state == EXIST)
	{
		// 线性探测
		++hashi;
        // hashi %= _tables.size()  防止hashi越界
		hashi %= _tables.size();
	}

    // 程序运行到这里说明hashi这个位置的状态不为EXIST,那么我们直接进行插入
	_tables[hashi]._kv = kv;
    // 并将这个位置的状态标记位EXIST
	_tables[hashi]._state = EXIST;
    // 且哈希表的有效数据个数n要++
	++_n;

    // 插入成功,返回真
	return true;
}

查找(Find)

Data* Find(const K& key)
{
    //  使用仿函数来将key转化为一个整型
	Hash hf;
    // 先找到key对应的hashi的位置
	size_t hashi = hf(key) % _tables.size();
    // 1.如果hashi的状态不为EMPTY,
    // 2.再来判断这个位置的状态是否为EXIST
    // 3.如果状态为EXIST,且数据为key,则hashi对应的数据就是我们要查找的
    
    // 将初始hashi的值进行保留,防止查找哈希表死循环
    size_t starti = hashi;
	while (_tables[hashi]._state != EMPTY)
	{
		if (_tables[hashi]._state == EXIST
			&& _tables[hashi]._kv.first == key)
		{
			return &_tables[hashi];
		}

        // 代码运行到这里,则我们需要线性探测下一个位置
		++hashi;
        // hashi %= _tables.size()是为了防止hashi越界
		hashi %= _tables.size();
        
        // 极端场下没有空,全是存在或者删除状态
        // 当hashi  == starti时,我们已经将哈希表找完了,并回到了起始位置
        // 但是此时,全是存在或者删除状态,如果按照状态不为空就接着循环
        // 那么就会变为死循环
		if (hashi == starti)
		{
			break;
		}
	}

	return nullptr;
}

删除(Erase)

bool Erase(const K& key)
{
	Data* ret = Find(key);
    // 如果ret不为nullptr,说明这个数据存在
	if (ret)
	{
        // 删除这个数据,只需要将这个数据的状态改为DELETE就可以了
		ret->_state = DELETE;
        // 再将哈希表的有效数据个数_n进行--就可以了
		--_n;
        // 成功删除,返回true
		return true;
	}
	else
	{
		return false;
	}
}

测试

void TestHT1()
{
    // 因为仿函数我们使用了缺省值,因此不需要传递仿函数
    // 且仿函数使用了特化,会自动匹配对应的仿函数
	HashTable<int, int> ht;
	int a[] = { 18, 8, 7, 27, 57, 3, 38, 18 };
	for (auto e : a)
	{
		ht.Insert(make_pair(e, e));
	}

	ht.Insert(make_pair(17, 17));
	ht.Insert(make_pair(5, 5));

	cout << ht.Find(7) << endl;
	cout << ht.Find(8) << endl;

	ht.Erase(7);
	cout << ht.Find(7) << endl;
	cout << ht.Find(8) << endl;
}

void TestHT2()
{
	string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };

	HashTable<string, int> countHT;
	for (auto& e : arr)
	{
		HashData<string, int>* ret = countHT.Find(e);
		if (ret)
		{
			ret->_kv.second++;
		}
		else
		{
			countHT.Insert(make_pair(e, 1));
		}
	}

	HashFunc<string> hf;
	cout << hf("abc") << endl;
	cout << hf("bac") << endl;
	cout << hf("cba") << endl;
	cout << hf("aad") << endl;
}

哈希表的完整模拟实现

#include<vector>

// 仿函数的类模板(将key转为整型)
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

// 仿函数(将字符串转化为整型)
// 特化
template<>
struct HashFunc<string>
{
	// BKDR  
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto ch : key)
		{
			hash *= 131;
			hash += ch;
		}
		return hash;
	}
};

namespace closehash
{
	// hash表元素的状态
	enum State
	{
		EMPTY,
		EXIST,
		DELETE,
	};


    // hash表数据节点的类模板
	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY;
	};

    // hash表的类模板
    // 仿函数是缺省值,可以不进行传参(使用的类模板,不是特化的类模板)
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
        // 将数据节点的类类型 定义为Data
		typedef HashData<K, V> Data;
	public:
        // 构造函数
		HashTable()
			:_n(0)
		{
			_tables.resize(10);
		}

        // 插入函数
		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
				return false;

			// 大于标定负载因子,就需要扩容
			if (_n * 10 / _tables.size() >= 7)
			{
				// 旧表数据,重新计算,映射到新表
				HashTable<K, V, Hash> newHT;
				newHT._tables.resize(_tables.size() * 2);
				for (auto& e : _tables)
				{
					if (e._state == EXIST)
					{
						newHT.Insert(e._kv);
					}
				}

				_tables.swap(newHT._tables);
			}

			Hash hf;
			size_t hashi = hf(kv.first) % _tables.size();
			while (_tables[hashi]._state == EXIST)
			{
				// 线性探测
				++hashi;
				hashi %= _tables.size();
			}

			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXIST;
			++_n;

			return true;
		}

        // 查找函数
		Data* Find(const K& key)
		{
			Hash hf;
			size_t hashi = hf(key) % _tables.size();
			while (_tables[hashi]._state != EMPTY)
			{
				if (_tables[hashi]._state == EXIST
					&& _tables[hashi]._kv.first == key)
				{
					return &_tables[hashi];
				}

				++hashi;
				hashi %= _tables.size();
			}

			return nullptr;
		}

        // 删除函数
		bool Erase(const K& key)
		{
			Data* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				--_n;
				return true;
			}
			else
			{
				return false;
			}
		}
	private:
		vector<Data> _tables;
		size_t _n = 0;	// 表中存储的有效数据的个数
	};

	void TestHT1()
	{
		HashTable<int, int> ht;
		int a[] = { 18, 8, 7, 27, 57, 3, 38, 18 };
		for (auto e : a)
		{
			ht.Insert(make_pair(e, e));
		}

		ht.Insert(make_pair(17, 17));
		ht.Insert(make_pair(5, 5));

		cout << ht.Find(7) << endl;
		cout << ht.Find(8) << endl;

		ht.Erase(7);
		cout << ht.Find(7) << endl;
		cout << ht.Find(8) << endl;
	}

	void TestHT2()
	{
		string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };

		//HashTable<string, int, HashFuncString> countHT;
		HashTable<string, int> countHT;
		for (auto& e : arr)
		{
			HashData<string, int>* ret = countHT.Find(e);
			if (ret)
			{
				ret->_kv.second++;
			}
			else
			{
				countHT.Insert(make_pair(e, 1));
			}
		}

		HashFunc<string> hf;
		cout << hf("abc") << endl;
		cout << hf("bac") << endl;
		cout << hf("cba") << endl;
		cout << hf("aad") << endl;
	}
}

二次探测(解决哈希冲突的方法)

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

2.4.2 开散列

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

image-20230409143523200

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

image-20230409144104564

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

哈希桶插入节点的类

// 所谓的桶,其实就是一个单链表
// 数组中存放的是单链表头节点的地址
// 插入节点的类模板
template<class K, class V>
struct HashNode
{
    // 插入的键对值
	pair<K, V> _kv;
    // 下一个节点的地址
	HashNode<K, V>* _next;

    // 对这个节点进行初始化
	HashNode(const pair<K, V>& kv)
		:_kv(kv)
		, _next(nullptr)
	{}
};

// 将 插入节点的类模板类型定义为Node
typedef HashNode<K, V> Node;

// hash表的私有成员
private:
    vector<Node*> _tables;  // 指针数组(存放的就是hash表的头节点)
    size_t _n = 0;     // 桶的个数

插入(insert)

bool Insert(const pair<K, V>& kv)
{
    // 同一个key不允许被插入两次
	if (Find(kv.first))
		return false;

    // 哈希桶的负载因子,在c++中有定义,因此我们直接按大佬定义的负载因子来扩容
	// 负载因子控制在1,超过就扩容(负载因子 = 有效数据的个数/表的大小 )
	if (_tables.size() == _n)
	{
        /*
        // 扩容
        // 方法一:
		// 创建一个新的哈希表,并将这个哈希表的大小调整为旧表的2倍
		HashTable<K, V, Hash> newHT;
		newHT._tables.resize(_tables.size() * 2);
		for (auto cur : _tables)
		{
			// cur为每个桶的头节点
			// 如果cur不为空,则说明此处是有数据的,那么我们将这个数据插入到新表
			while (cur)
			{
				newHT.Insert(cur->_kv);
				// 每个桶都为单链表,要确定这个桶里面的数据都被插入到新表
				cur = cur->_next;
			}
		}

		//  我们将新表和旧表的数据进行交换
		_tables.swap(newHT._tables);
		
		// 当新表的生命周期结束,新表会自动销毁
		// 方法一的缺点就是:
		// 每次往新表中每插入一个数据,我们都需要new一个新的节点,这样的话就会影响效率
		// 且新表的生命周期结束,新表虽然被销毁了,但是新表上面挂着的单链表的桶并没有被销毁
		// vector会调用它自己的析构函数来回收资源,但是桶的,需要我们自己去实现资源的回收
		// 如果我们可以重复利用旧表桶的节点,那么就不用开辟新的节点,这样就大大提高了扩容的效率
		*/

        
        
        
        // 扩容
        // 方法二:
        // 这种方法的优势在于,我们将旧表的key重新映射到新表时
        // 我们是将旧表的节点重复利用,一个节点一个节点的重新链接到新表的vector中
        // 不需要我们再去new新的节点,提高了映射的效率
        // 第一步:创建一个新的vector,用来重新映射
        // 这里并不需要创建一个哈希表,因为我们只是改变旧表vector的桶的节点的指向
        // 只需要将旧表vector的桶的节点放入到新表的vector中就可以了
		vector<Node*> newTables;
        
        
       // 当新表的vector进行扩容时,并不是按旧表的2倍来扩容,而是扩容的大小最好是一个素数,
       // 根据大佬的研究,如果表的大小为素数,那么得到的hashi的值将会越分散,这样哈希桶的效率将会得到提升
       // size_t hashi = Hash()(cur->_kv.first) % newTables.size();
		newTables.resize(__stl_next_prime(_tables.size()), nullptr);

		for (size_t i = 0; i < _tables.size(); ++i)
		{
            //  拿到旧表对应位置的头节点
			Node* cur = _tables[i];
            
            //  如果头节点不为空,那么说明这个头节点对应的桶存在数据
			while (cur)
			{
                // 再将头节点插入新的vector之前,我们先记录下一个节点的位置
				Node* next = cur->_next;

                // 计算cur在新的vector中的hashi
                // Hash()(cur->_kv.first) 是调用仿函数,强转其他类型的key为整型
				size_t hashi = Hash()(cur->_kv.first) % newTables.size();
                
				// 将cur头插到新表
                // newTables[hashi] 上存放节点prev
                // cur 为旧表的头节点
                // 将 cur 头插到 prev 前面
                // 1.cur->_next 指向prev
                // 2.将cur放在数组newTables[hashi]位置上面
				cur->_next = newTables[hashi];
				newTables[hashi] = cur;

                // 将next置为cur再次迭代
                // 直到cur为空,说明已经将这个桶的所有节点进行了映射
				cur = next;
			}

            //  最后再将旧表的vector的空间置空,防止从vector中找到桶的头节点地址,来访问桶(此时桶的所有节点都被重新映射,移动到了新表,因此是不允许旧表对其访问的,所以将旧表置空)
			_tables[i] = nullptr;
		}

        // 将旧表和新表的vector数据进行交换
		_tables.swap(newTables);
	}
    

    // 插入数据
    // 先通过哈希函数找到key对应的hashi
	size_t hashi = Hash()(kv.first) % _tables.size();
    
	// new一个新节点,并将这个新节点的头插到对应的hashi的桶中
    // 此时_tables[hashi] 存放着这个桶的头节点prev 
    // 头插的话,那么newnode->_next指向prev
    // 再将新的头节点的地址放入到_tables[hashi]
    // 这样我们就完成了头插
	Node* newnode = new Node(kv);
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
    // 插入节点后,数据的个数增加
	++_n;

	return true;
}

返回扩容后的素数大小

// 内联函数(hash表扩容后,新表的大小)
inline unsigned long __stl_next_prime(unsigned long n)
{
    // 初始化素数数组的下标为28
    static const int __stl_num_primes = 28;
    
    // 这是一个静态的素数数组,一共28个素数,对于32位的机器,42亿多已经是最大的素数了
    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
    };

    for (int i = 0; i < __stl_num_primes; ++i)
    {
        if (__stl_prime_list[i] > n)
        {
            // 返回比当前数组容量n更大的素数(这个素数近似是n的2倍)
            return __stl_prime_list[i];
        }
    }

    // 如果不满足上述条件,则返回素数数组中最大的元素
    return __stl_prime_list[__stl_num_primes - 1];
}

哈希的析构函数

~HashTable()
{
	for (size_t i = 0; i < _tables.size(); ++i)
	{
		// 释放桶
        // 依次拿到桶的头节点
		Node* cur = _tables[i];
		while (cur)
		{
            // 将每一个桶的所有节点都释放
			Node* next = cur->_next;
			delete cur;
			cur = next;
		}

        // 将vector中存放桶的头节点的位置置空,防止桶已经被释放了,但是还可以通过头节点的地址来进行访问
		_tables[i] = nullptr;
        
        // 将桶释放完之后,会自动调用vector的析构函数释放vector占用的资源
	}
}

哈希的构造函数

HashTable()
	:_n(0)
{
	//  _tables.resize(10);
    // 按照素数表来初始化vector的大小
	_tables.resize(__stl_next_prime(0));
}

查找(Find)

Node* Find(const K& key)
{
    // 通过hash函数确定key在哈希表中的下标hashi
	size_t hashi = Hash()(key) % _tables.size();
    
    // 通过hashi找到对应的桶的头节点
	Node* cur = _tables[hashi];
	while (cur)
	{
        // 判断这个单链表的桶中的节点的key是否是我们查找的key
        // 迭代去查找
		if (cur->_kv.first == key)
		{
			return cur;
		}
		else
		{
			cur = cur->_next;
		}
	}

	return nullptr;
}

删除(Erase)

bool Erase(const K& key)
{
    // 根据哈希函数找到key的hashi
	size_t hashi = Hash()(key) % _tables.size();
    
	Node* prev = nullptr;
    // 根据hashi找到存放key的桶的头节点
	Node* cur = _tables[hashi];
    
    // 依次遍历桶的每一个节点,直到找到对应的节点,或者遍历到空节点
    // 则循环结束,查找不到这个节点,返回false
	while (cur)
	{
		if (cur->_kv.first == key)
		{
            // 此时,找到了对应的节点
			// 准备删除
			if (cur == _tables[hashi])
			{
             // 如果我们要删除的节点就是头节点,这个需要我们单独处理
             // 直接指定cur下一个节点成为新的头节点,则将新的头节点的地址放到_tables[hashi],完成连接
				_tables[hashi] = cur->_next;
			}
			else
			{
                // 如果我们要删除的节点不是头节点,那么就是连接
                // prev  和  cur->next 这两个节点
				prev->_next = cur->_next;
			}

            // 当完成了连接之后,我们再释放cur节点就可以了
			delete cur;
            // 此时哈希桶中有效数据的个数要--
			--_n;

			return true;
		}
		else
		{
            // 如果当前cur节点不是我们要找的节点
            // 则继续遍历下一个节点
            // 则下一个节点变为新的cur节点
            // cur变为新的prev节点
			prev = cur;
			cur = cur->_next;
		}
	}

	return false;
}

哈希桶的完整模拟实现

namespace buckethash
{
    // hash桶中节点的类模板
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		HashNode<K, V>* _next;

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

    // hash表的类模板
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
			:_n(0)
		{
			//_tables.resize(10);
			_tables.resize(__stl_next_prime(0));
		}

		~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;

			// 负载因子控制在1,超过就扩容
			if (_tables.size() == _n)
			{
				vector<Node*> newTables;
				newTables.resize(__stl_next_prime(_tables.size()), nullptr);

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

						size_t hashi = Hash()(cur->_kv.first) % newTables.size();
						// 头插到新表
						cur->_next = newTables[hashi];
						newTables[hashi] = cur;

						cur = next;
					}

					_tables[i] = nullptr;
				}

				_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;
		}

        // 查找函数
		Node* Find(const K& key)
		{
			size_t hashi = Hash()(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				else
				{
					cur = cur->_next;
				}
			}

			return nullptr;
		}

        // 删除函数
		bool Erase(const K& key)
		{
			size_t hashi = Hash()(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					// 准备删除
					if (cur == _tables[hashi])
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}

					delete cur;
					--_n;

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

			return false;
		}

        // 素数表
		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
			};

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

			return __stl_prime_list[__stl_num_primes - 1];
		}

	private:
		vector<Node*> _tables;  // 指针数组
		size_t _n = 0;
	};
    
    void TestHT1()
	{
		HashTable<int, int> ht;
		int a[] = { 18, 8, 7, 27, 57, 3, 38, 18,17,88,38,28};
		for (auto e : a)
		{
			ht.Insert(make_pair(e, e));
		}

		ht.Insert(make_pair(5, 5));

		ht.Erase(17);
		ht.Erase(57);
	}

	void TestHT2()
	{
		string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };

		HashTable<string, int> countHT;
		for (auto& e : arr)
		{
			auto ret = countHT.Find(e);
			if (ret)
			{
				ret->_kv.second++;
			}
			else
			{
				countHT.Insert(make_pair(e, 1));
			}
		}
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值