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

news2025/4/17 11:48:26

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1635568.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【RAG 博客】Haystack 中的 DiversityRanker 与 LostInMiddleRanker 用来增强 RAG pipelines

Blog&#xff1a;Enhancing RAG Pipelines in Haystack: Introducing DiversityRanker and LostInTheMiddleRanker ⭐⭐⭐⭐ 文章目录 Haystack 是什么1. DiversityRanker2. LostInTheMiddleRanker使用示例 这篇 blog 介绍了什么是 Haystack&#xff0c;以及如何在 Haystack 框…

设计模式 基本认识

文章目录 设计模式的作用设计模式三原则设计模式与类图设计模式的分类 设计模式的作用 设计模式是在软件设计过程中针对常见问题的解决方案的一种通用、可重用的解决方案。设计模式提供了一种经过验证的方法&#xff0c;可以帮助开发人员解决特定类型的问题&#xff0c;并在软…

代码审计之SAST自动化

前言: 很久没写文章了&#xff0c;有点忙&#xff0c;落个笔&#xff0c;分享一些捣鼓或说适配好的一些好玩的东西。 脚本工具不开源&#xff0c;给一些思路&#xff0c;希望能给大家带来一些收获。 笔者能力有限&#xff0c;如有错误&#xff0c;欢迎斧正。 正文&#xff1a…

环形链表题

1.环形链表1 看题&#xff1a;. - 力扣&#xff08;LeetCode&#xff09; 思路1&#xff1a;哈希表 遍历所有节点&#xff0c;每次遍历一个节点时&#xff0c;判断该节点是否被访问过。 可以使用哈希表来存储所有已经访问过的节点。每次到达一个节点&#xff0c;如果该节点已…

Qt 6 开源版(免费) -- 安装图解

Qt6起&#xff0c;两项重大改变&#xff08;并非指技术&#xff09;&#xff1a; 必须在线安装&#xff0c;不再提供单独的安装包主推收费的商业版 当然的&#xff0c;为了培养市场&#xff0c;Qt6还提供了一个免费的&#xff1a;开源版。 开源版相对于收费的商业版&#xf…

《Fundamentals of Power Electronics》——Boost电路及仿真

Boost电路的拓扑结构如下所示&#xff1a; 下面是在simulink中搭建的一个Boost电路的仿真实验平台&#xff0c;其中直流输入电压为100V&#xff0c;电感值为1mH(模拟电阻为1毫欧)&#xff0c;电容值为470uF&#xff0c;负载为50欧姆&#xff0c;占空比选择为0.5&#xff0c;开关…

【Qt】QtCreator忽然变得很卡

1. 问题 Qt Creator忽然变得很卡。电脑里两个版本的Qt Creator&#xff0c;老版本的开启就卡死&#xff0c;新版本好一点&#xff0c;但是相比于之前也非常卡&#xff0c;最明显的是在 ctrl鼠标滚轮 放大缩小的时候&#xff0c;要卡好几秒才反应。 2. 解决方案 2.1 方法1 关…

239 基于matlab的EKF(扩展卡尔曼滤波)_UKF(无迹卡尔曼滤波)_PF(粒子滤波)三种算法的估计结果比较

基于matlab的EKF(扩展卡尔曼滤波)_UKF(无迹卡尔曼滤波)_PF&#xff08;粒子滤波&#xff09;三种算法的估计结果比较&#xff0c;输出估计误差&#xff0c;并单独对粒子滤波进行估计及其置信区间可视化。程序已调通&#xff0c;可直接运行。 239 EKF(扩展卡尔曼滤波) - 小红书 …

牛客网刷题 | CC1 获取字符串长度

目前主要分为三个专栏&#xff0c;后续还会添加&#xff1a; 专栏如下&#xff1a; C语言刷题解析 C语言系列文章 我的成长经历 感谢阅读&#xff01; 初来乍到&#xff0c;如有错误请指出&#xff0c;感谢&#xff01; 描述 键盘输入一个字符串…

redis故障中出现的缓存击穿、缓存穿透、缓存雪崩?

一、背景&#xff1a; 在维护redis服务过程中&#xff0c;经常遇见一些redis的名词&#xff0c;例如缓存击穿、缓存穿透、缓存雪崩等&#xff0c;但是不是很理解这些&#xff0c;如下就来解析一下缓存击穿、缓存穿透、缓存雪崩名词。 二、缓存穿透问题&#xff1a; 常见的缓存使…

update_min_vruntime()流程图

linux kernel scheduler cfs的update_min_vruntime() 看起来还挺绕的。含义其实也简单&#xff0c;总一句话&#xff0c;将 cfs_rq->min_vruntime 设置为&#xff1a; max( cfs_rq->vruntime, min(leftmost_se->vruntime, cfs_rq->curr->vruntime) )。 画个流…

Laravel5.4 反序列化

文章目录 0x01 环境搭建0x02 POP 链0x03 exp0x04 总结 前言&#xff1a;CC 链复现的头晕&#xff0c;还是从简单的 Laravel 开始吧。 laravel 版本&#xff1a;5.4 0x01 环境搭建 laravel安装包下载地址 安装后配置验证页面。在 /routes/web.php 文件中添加一条路由&#xf…

Java核心技术.卷I-上-笔记

目录 面向对象程序设计 使用命令行工具简单的编译源码 数据类型 StringBuilder 数组 对象与类 理解方法调用 继承 代理 异常 断言 日志 面向对象程序设计 面向对象的程序是由对象组成的&#xff0c;每个对象包含对用户公开的特定功能部分和隐藏的实现部分从根本上…

在Primavera P6 中维护自定义活动栏

前言 自从 Henry Gantt 在 1910 年左右提出这个想法以来&#xff0c;以图形方式显示项目进度表并沿时间刻度显示条形图一直延续到当今最复杂和流行的项目进度系统中。在本文中&#xff0c;我们将仔细研究 Primavera P6 Professional 中的甘特图&#xff0c;并探索一些自定义其…

一天狂涨2000亿的谷歌,看到了AI商业化的曙光

“人工智能是有史以来最深刻的平台变革之一&#xff0c;谷歌依旧会成为第一。” &#xff0d;谷歌CEO桑达尔皮查伊 在2024年一季度财报发布后&#xff0c;谷歌盘后涨超10%&#xff0c;终于站稳加入了“2 万亿美元俱乐部”。 从财报数据来看&#xff0c;谷歌一季度总营收805.4…

新手开通抖音小店的时候,必须要注意的6点!建议收藏!

大家好&#xff0c;我是电商小V 今天咱们就来详细的说一下开通抖音小店的时候需要注意的事项&#xff0c;避免咱们在开店的时候踩坑导致店铺后期的正常运营&#xff0c; 第一点&#xff1a;是关于营业执照的问题 营业执照咱们都知道&#xff0c;分为个体和企业的&#xff0c;咱…

正态分布的参数及意义

正态分布&#xff0c;也称为高斯分布&#xff0c;是统计学中最重要的分布之一&#xff0c;具有许多重要的特性。正态分布的参数包括均值&#xff08;μ&#xff09;和标准差&#xff08;σ&#xff09;&#xff0c;有时也使用方差&#xff08;σ^2&#xff09;来描述。下面是这…

【快速入门】数据库的增删改查与结构讲解

文章的操作都是基于小皮php study的MySQL5.7.26进行演示 what 数据库是能长期存储在计算机内&#xff0c;有组织的&#xff0c;可共享的大量数据的集合。数据库中的数据按照一定的数据模型存储&#xff0c;具有较小的冗余性&#xff0c;较高的独立性和易扩展性&#xff0c;并为…

[单机]仿官武林外传飞羽完美版本_附带GM工具_虚拟机架设_视频教程

概述 今天给大家带来一款单机游戏架设教程&#xff0c; 仿官武林外传飞羽完美版本 演示视频 [单机]仿官武林外传飞羽完美版本_附带GM工具_虚拟机架设_ 环境准备 windows电脑一台&#xff0c;安装好vmvare12虚拟机 &#xff0c;安装教程和激活码获取 githubs.xyz/boot?app3…

javase学习01-GUI设计中的菜单条,菜单及菜单项(简单的实现)

目录 一&#xff0c;效果及代码 二&#xff0c;相关内容 1&#xff0c;创建图片资源文件夹 2&#xff0c;菜单初识 3&#xff0c;图标大小设置 4&#xff0c;菜单高度设置 今天学习了Java的GUI&#xff08;graphics user interface&#xff09;图形用户界面中的窗口和菜单…