一、散列(哈希)介绍
- 散列使用一个散列函数(也称为哈希函数)把字典的数对映射到一个散列表(也称为哈希表)的具体位置
- 散列的存储与查找:
- 查找:如果数对p的关键字是k,散列函数为f,那么在理想的情况下,p在散列表中的位置为f(k),我们首选计算f(k),然后查看在散列表的f(k)处是否存在要查找的值
- 存储:与查找相同,使用f(k)函数算出键值对k在散列表的位置,然后把元素插入到散列表对应的位置
- 复杂度:在理想情况下,初始化一个空字典的时间为O(b)(b为散列表拥有的位置数),查找、插入、删除操作的时间均为Θ(1)
二、桶和起始桶
- 桶:散列表的每一个位置叫一个桶
- 起始桶:对关键字为k的数对,f(k)是起始桶
- 桶的数量等于散列表的长度或大小
三、散列函数
- 散列函数是一个将关键字k映射到散列表对应位置的函数
- 散列函数根据需求,可以有多种不同的种类
普通的散列函数
- 例如一个班级最多有100个学生,它们的ID号位于951000~952000之间,那么可以设计一个散列函数f(k)=k-951000把学生ID号映射到散列表的位置0到1000之间,所以可以用数组table[10001]来存储这些ID号
除法散列函数
- 在多种散列函数中,除法散列函数是最常用的
- 其形式如下:
- k是关键字,D是散列表的长度(即桶的数量),%为求模操作符
- 例如下面D为11,序号从0到10,则24的散列索引为2(24%11=2)、80对应的散列索引为3(80%11=3)、40对应的散列索引为7(40%11=7)、65的散列索引为10(65%11=10)。如下图所示
四、散列冲突和散列溢出
散列冲突
- 当多个不同的关键字所对应的起始桶相同时,就会发生冲突
- 散列冲突之后可以根据策略来进行解决,例如将冲突数据向后存储或者在同一个桶处存储多个数对
- 比如一上面那张图来说,索引3处已经存在关键字80了,如果此时58也要加入散列表中,其根据散列函数求得58%11=3,因此跟80产生冲突,这个就叫做散列冲突
散列溢出
- 一个桶之可以存储多个数对,那么发生散列冲突也没事,因为可以将多个数据存储在一个桶中
- 但是如果散列表中的一个桶数量用完时,没有多余的空间来存储新数对,那么就称之为散列溢出
- 散列溢出时有很多的解决办法,其中最常用的方法是线性探针法(见后面的文章)
五、均匀散列函数、良好散列函数
均匀散列函数
- 因为有散列冲突和散列溢出问题的存在,所以我们要设计一个优良的散列函数,使的散列表中每一个桶可以存储的关键字数量大致相等且均匀,那么冲突和溢出就会减少。我们将这样的函数成为均匀散列函数
例如:
- 非均匀散列函数:假设散列有b个桶,且b>1。散列函数f(k)=0,那么无论什么关键字都只能存储在散列表的0索引处,其他桶都使用不到就造成冲突与浪费了,那么这个散列函数就不是均匀散列函数
- 均匀散列函数:假设b=11,关键字的范围为[0,98],散列函数为f(k)=k%b。那么关键字[0,98]会把大约每9个关键字映射到一个桶中,这样一来,每个桶存储的数字都比较均匀,那么产生散列冲突的概率就会减少,那么这个散列函数相对来说就是均匀的散列函数
良好散列函数
- 遗憾的是,我们无法使用某一种固定的方法选择一个关键字来设计均匀散列函数。在应用中,关键字都有某种程序的关联性
- 例如,当关键字是整数时,可能是奇数占优或者偶数占优,不会是奇数和偶数均等。当关键字是字母数字形式的时候,前缀或后缀相同的关键字可能会占堆儿
- 在实际应用中,关键字不是从关键字范围内均匀选择的,因此有的均匀散列函数表现好一点,有一些就差一些。在实际应用中性能表现好的均匀散列函数称为良好散列函数
六、散列函数除数D的选择
- 除法散列函数的格式如下:
- 原则:对于D的选择,有些会产生良好散列函数,有些会产生不良散列函数。但是只要D>1,对D的所有选择,都会产生均匀散列函数
- D的选择:
- 如果D选择为偶数:
- 当k是偶数时,f(k)结果为偶数;当k是奇数时,f(k)结果为奇数;
- 例如如果应用中以偶数关键字为主,则大部分关键字会被映射到序号为偶数的起始桶中。如果应用中以奇数关键字为主,则大部分关键字会被映射到序号为奇数的起始桶中
- 因此使用D为偶数,得到的是不良散列函数
- 如果D可以被诸如3、5、7这样的小奇数整除时,不会产生良好散列函数
- 因此,选择的除数D应该既不是偶数也不能被小的奇数整除
- 理想的D是一个素数/质数(素数与质数是一个概念)。如果你找到一个接近散列表长度的素数时,你应该选择不能被2和19之间的整数整除的D。D的其它考量在后面还有介绍
- 如果D选择为偶数:
- 总结:当使用除法散列函数时,选择除数D为奇数,可以是散列值依赖关键字的所有位。当D即是素数又不能被小于20的整数除,就能得到良好散列函数
- 除数D的选择也可以参考文章:https://blog.csdn.net/qq_41453285/article/details/103533372
七、非整型关键字
- 如果用哈希存储字典,如果字典的关键字不是一个整型,那么就需要将字典的关键字转换为整型,然后插入到对应的桶中
程序①
- 下面创建一个函数,用来将一个长度为3的字符串转换为一个长整型
long threeToLong(std::string s) { //最左边的字符 long answer = s.at(0); //左移8位,加入下一个字符 answer = (answer << 8) + s.at(1); //左移8位,加入下一个字符 return ((answer << 8) + s.at(2)); }
- 当输入s为abc时,s.at(0)=a、s.at(1)=b、s.at(2)=c,它们的值分别为97、98、99
- 3个字符构成的串不同,转换的长整型数也不同,因此此函数可以把一个长度为3的字符串转换为唯一的长整型数(长整型数的范围为[0,
-1])
- 因为一个左移8位的操作符等价于乘以
,所以输出abc会输出((97*256+98)*256)+99=6382179
程序②
long threeToInt(std::string s) { int length = (int)s.length(); //s中的字符个数 int answer = 0; //如果字符串长度为奇数 if (length % 2 == 1) { answer = s.at(length - 1); length--; } //长度为偶数 for (int i = 0; i < length; i += 2) { //同时转换两个字符 answer += s.at(i); answer += ((int)s.at(i + 1)) << 8; } return (answer < 0) ? -answer : answer; }
- 上面的程序①只可以把长度为3个字符的字符串转换为一个唯一的整数,但是这样方法比较有局限性
- 在实际应用中,散列函数可以把若干个关键字散列到相同的起始桶处,因此我们不必要把每个字符串转换为唯一的一个整数,此处我们的函数采用一个算法,用来把一个任意长的字符串转换为一个整数,不同字符串转换之后可能得到的值会相同
- 函数的思想:我们取输入的字符串的每个字符,并把每个字符转换为整数,然后将这些整数相加(相加的时候可以自己设计一算算法,例如上面每两个字符进行一次<<8的操作)
- 例如下面是两个演示案例
hash函数
template<class K> class hash; template<> class hash<std::string> { public: std::size_t operator()(const std::string theKey)const { unsigned long hashValue = 0; int length = (int)theKey.length(); for (int i = 0; i < length; i++) { hashValue = hashValue * 5 + theKey.at(i); } return std::size_t(hashValue); } };
- 我们定义了一个模板类,这个类用来将一个字符串转换为一个size_t类型的非负整数
- 当然,我们也可以把一个int或long类型的整数转换为size_t类型的非负整数
template<> class hash<int> { public: std::size_t operator()(const int theKey) const { return std::size_t(theKey); } }; template<> class hash<long> { public: std::size_t operator()(const long theKey) const { return std::size_t(theKey); } };