3.1 哈希表

260 阅读14分钟

简介

在第二章,已经看了各种查找算法,但是查找的过程都需要比较。那么有没有一种不需要比较就得到目的数据的呢?我们可以通过关键字得到对应的数据,即在记录的存储位置和关键字之间建立一个确定的关系,使每个关键字和一个唯一的存储位置相对应。(如果学习了离散数学的朋友对这些可能熟悉)

哈希表的概念

在查找时,如果给定值key在表中存在,则只需根据key并通过对应关系H,即可得到key在表中的存储位置H(key)。因此不需要进行比较,而是仅通过H(key)的计算,就能获得所要查找的记录。称这样的对应关系H为哈希(Hash)函数或散列函数或杂凑函数,而根据这个思想所建立的表称之为哈希表(Hash Table)或散列表或杂凑表,根据哈希函数所得的储存位置称为哈希地址(Hash Address)或散列地址,而这种映像过程则被称为哈希造表或散列。

可以将算法思想分为两个部分:

  • 向哈希表中插入一个关键字:哈希函数决定该关键字的对应值应该存放到表中的哪个区块,并将对应值存放到该区块中
  • 在哈希表中搜索一个关键字:使用相同的哈希函数从哈希表中查找对应的区块,并在特定的区块搜索该关键字对应的值

哈希表的原理示例图如下图所示:

image.png

哈希函数

哈希函数:将哈希表中元素的关键键值映射为元素存储位置的函数。一般来说,哈希函数会满足以下几个条件:

  • 哈希函数应该易于计算,并且尽量使计算出来的索引值均匀分布,这能减少哈希冲突
  • 哈希函数计算得到的哈希值是一个固定长度的输出值。。
  • 如果 Hash(key1) 不等于 Hash(key2),那么 key1、key2 一定不相等。
  • 如果 Hash(key1) 等于 Hash(key2),那么 key1、key2 可能相等,也可能不相等(会发生哈希碰撞)。
  • 每个关键字所对应的哈希地址分布均匀。函数值要尽量均匀分布在地址空间上,这样才能保证存储空间的有效利用,并且减少冲突。

在哈希表的实际应用中,关键字的类型除了数字类型,还有可能是字符串类型、浮点数类型、大整数类型,甚至还有可能是几种类型的组合。一般会将各种类型的关键字先转换为整数类型,再通过哈希函数,将其映射到哈希表中。 而关于整数类型的关键字,通常用到的哈希函数方法有:直接定址法、除留余数法、平方取中法、基数转换法、数字分析法、折叠法、随机数法、乘积法、点积法等。

直接定址法:

每个关键字所对应的哈希地址分布均匀。函数值要尽量均匀分布在地址空间上,这样才能保证存储空间的有效利用,并且减少冲突。

H(key)=akey+bH(key) = a * key + b

其中a和b为常数。通常而言,这类函数是一一对应函数,因此不会产生冲突,但要求地址集合与关键字集合的大小相同,并且当关键字跨度非常大时并不适用。因此该方法在实际生活中并不常用。

例如:现有关键字集合{25,15,5,40,45,0,30},选用哈希函数H(key)=key/5H(key)= key/5来进行散列,所构造出的哈希表如下所示。

image.png

数字分析法:

在关键字集合中,若每个关键字均由m位组成,而每位上有r种不同的取值(如一位数字可以有0~9这10种取值,英文字母则有a~z这26种取值),通过分析r中不同符号在每一位上的分布情况,选择其中某几位分布较为均匀的符号组合成哈希地址。

例如:有一组关键字{6537685,6533251,6536543,6542019,6539834,6541234,6545437},对这些关键字进行如下分析,见下表:

image.png

其中所有关键字的第一、二位均为6、5,而第三位取值也只有3和4,因此这三位不用作哈希地址,剩余4位的取值分布较为均匀,可以作为哈希地址,因此可选取这四位中任意两位组合成哈希地址,也可以对这四位进行适当的处理来获得哈希地址。

平方取中法:

平方取中法是取关键字平方后的中间几位作为哈希地址,这是一种较为常见的哈希函数构造方法。通常在选定哈希函数时不一定能知道关键字的所有情况,且取其中几位作为哈希地址也不一定适合,因此平方可以使随机分布的关键字得到的哈希地址也随机,而其所取的地址位数则由表长决定。

例如有关键字集合{3456,2564,3466,3454},则对关键字进行平方处理后可以得到如下表数据。

image.png

平方后可取其中第4、5位作为哈希地址。

折叠法:

折叠法是将关键字按位数分割成几部分(其中最后一部分的长度可能会较小),然后将这些部分按一定的方式进行求和,按哈希表表长取后几位作为哈希地址。

通常折叠法有两种形式:位移法和间接叠加法。其中位移法是将各部分按最后一位对齐相加;接叠加法是从一端向另一端沿分割界来回折叠,然后对齐相加。

例如:一个关键字为83950261436,哈希表表长为3,则分别用位移法和间接叠加法对其进行取哈希地址的处理过程如下:首先将关键字按表长分割成若干部分:83950261436。然后分别用位移法和间接叠加法进行处理:

image.png

由于哈希表长度为3,因此分别取后三位991和721作为关键字按位移法和间接叠加法所得到的哈希地址。

除余留数法:

选择一个常数p,取关键字除以p所得的余数作为哈希地址,即:

H(key)=key mod  pH(key) = key \ mod \ \ p

方法对于p的选取非常重要,若哈希表长度为m,则要求p小于等于m且接近m,并且一般选用质数作为p,或者是一个不包含小于20质因子的合数。除留余数法是一种最简单也最常见的哈希函数构造方法,它不仅可以对关键字直接取模,也可以在折叠法、平方取中法之后取模。

例如:现有关键字集合{35,51,36,43,12,8,44,29, 18}。哈希表长度为12,若用p=11进行除留余数运算,其所得的哈希表如下图所示。

image.png

考研408常考

哈希冲突处理

哈希冲突:不同的关键字通过同一个哈希函数可能得到同一哈希地址,即 key1 ≠ key2,而 Hash(key1) = Hash(key2),这种现象称为哈希冲突。

处理冲突的方法也有很多。

开放定址法:

所谓开放定址,即是一旦根据关键字所得到的哈希地址发生了冲突(该地址已经存放了数据元素),则继续按某种规则寻找下一个空闲单元的哈希地址(通常将寻找下一个空闲单元的过程称为探测),只要哈希地址足够大,空的哈希地址总是能够找到的。其函数定义为:

Hi=(H(key)+di)   mod   m(0im)H_i = (H(key) + d_i)\ \ \ mod \ \ \ m (0\leq i \leq m)

其中,H为哈希函数,m为哈希表的表长,di为所取的增量序列。每种再散列方法的区别在于di的取值不同。寻找下一个空闲单元的哈希地址的方法较多,下面介绍3种比较常用的方法:线性探测再散列、二次探测再散列和伪随机探测再散列。

(1)线性探测再散列:

线性探测再散列是取增量序列di为1,2,…,m-1的方法。其过程可描述为:当哈希地址i发生冲突时,查看哈希地址i+1是否为空,若为空则将数据放入,否则查看i+2是否为空,依次类推。

例如:现有关键字集合{35,51,36,43,12,6,17}。哈希表表长为12,若用p=11进行除留余数运算,其所得的哈希表如下图所示。

image.png

其中35、51、36、43、12均是由哈希函数得到的没有冲突的哈希地址而直接放入的数据元素。当存放6时,由于H(6)=6,此时发生了冲突,因此根据线性探测再散列的方法,检测哈希地址7,发现该地址为空,将关键字为6的数据元素放入地址7。当存放17时,由于H(17)=6,此时发生了冲突,因此根据线性探测再散列的方法,检测哈希地址7,发现该地址也冲突,检测哈希地址8,发现该地址为空,将关键字为17的数据元素放入地址8。

(2)二次探测再散列。

二次探测再散列是取增量序列di为12,12,22,22,,q2,q2(q1/2(m1))1^2,-1^2,2^2,-2^2,…,q^2,-q^2(q≤1/2(m-1))的再散列方法。其过程可描述为:当哈希地址i发生冲突时,查看哈希地址i+1是否为空,若为空则将数据放入,否则查看i-1是否为空,若为空则将数据放入,否则查看i+22i+2^2是否为空,依此类推。

仍以上例为例,用二次探测再散列进行冲突处理,得到的哈希表如下图所示。

image.png

当处理关键字6的冲突时,查看哈希地址7,发现该地址为空,将关键字为6的数据元素放入地址7。当处理关键字17的冲突时,查看哈希地址7,发现该地址不为空,继续查看哈希地址5,发现该地址为空,将关键字为17的数据元素放入地址5。

(3)伪随机函数再散列。

伪随机函数再散列是取增量序列did_i为一个伪随机数的再散列方法。其过程可描述为:当哈希地址i发生冲突时,产生一个伪随机数d1d_1,查看哈希地址i+d1i+d_1是否为空,若为空则将数据放入,否则重新产生一个伪随机数d2d_2查看i+d2i+d_2是否为空,以此类推。

再哈希法:

再哈希法用数学表达式可以描述为:

Hi=RHi(key)   i=1,2,3...k H_i = RH_i(key) \ \ \ i=1,2,3...k

其中,RHi均为不同的哈希函数。再哈希法的本质是使用k个哈希函数,若第一个函数发生冲突,则利用第二个函数再生成一个地址,直到产生的地址不冲突为止。

链地址法: 链地址法是将每个哈希地址都作为一个指针,指向一个链表。若哈希表长为m,则建立m个空链表,将哈希函数对关键字进行转换为i后,映射到统一哈希地址i的同义词均加入到地址i所指向的链表中。

例如:现有关键字集合{35,51,36,43,12,6,17,40,69,29}。哈希表长度为12,若用p=11进行除留余数运算,其所得的哈希表用链地址法进行冲突处理如下图所示:

image.png

建立一个公共溢出区:

设哈希函数产生的哈希地址集为[0,m-1],则分配两个表。一个表为基本表,其每个存储单元仅存放一个数据元素;另一个表为溢出表,只要关键字对应的哈希地址在基本表上发生了冲突(即为同义词),则将发生冲突的元素一律放入该表中。查找时,对于给定关键字key通过哈希函数计算出哈希地址为i,则先与基本表中地址为i的数据元素进行比较,若相等则查找成功;否则再在溢出表中进行查找。

在哈希查找的过程中,不同的冲突处理方法会构造出不同的哈希表,而哈希表查找的过程和构造过程基本相同。其中一些关键字通过哈希函数转换成哈希地址便可找到,但另外一些关键字在哈希函数转换的地址上会发生冲突,这时需要按一定的冲突处理方法进行查找。由于产生冲突后的查找依然是用给定值与关键字进行比较的过程,因此对哈希表查找效率的度量依然用平均查找长度来衡量。查找过程中,关键字的比较次数取决于产生冲突的次数,冲突产生越少,查找效率就越高。而影响冲突产生的因素主要有如下3种。

(1)哈希函数是否均匀。哈希函数是直接影响冲突产生频率的因素,但一般而言,认为所选的哈希函数是均匀的,因此可以不考虑哈希函数对平均查找长度的影响。

(2)冲突的处理方法。使用的关键字集合相同且哈希函数相同时,在数据元素查找等概率的情况下,如果所采取的冲突处理方法不同,其平均查找长度并不相同。比如对于关键字集合{35,50,36,44,12,6,17,29},给定哈希表长度为12,用p=11进行除留余数运算,考虑线性探测再散列和二次探测再散列两种方法进行冲突处理,对所得到的哈希表进行查找时,其平均查找长度分别为:

线性探测再散列平均查找长度:ASL=1×5+1×2+2×39=139ASL=\frac{1×5+1×2+2×3}{9}=\frac{13}{9}

二次探测再散列平均查找长度:ASL=1×5+2×2+1×39=129ASL=\frac{1×5+2×2+1×3}{9}=\frac{12}{9}

(3)哈希表的装填因子。将哈希表中元素的个数和哈希表长度的比值作为哈希表的装填因子,即α=哈希表中元素个数哈希表长度 α = \frac{哈希表中元素个数}{哈希表长度},其中,a是哈希表装满程度的指标,即装填因子。由于表长为定值,因此a与填入表中的元素个数成正比,填入表中的元素越多则a越大,冲突产生的可能性也就越大。

实际上哈希表的平均查找长度可以看作是装填因子a的一个函数,而不同的冲突处理方法对应不同的函数,下表给出了几种不同冲突处理方法的平均查找长度。

image.png

一般来说,对同一组记录而言,哈希表的平均查找长度比顺序查找和折半查找的平均查找长度都要小,但哈希表的建造过程耗费较多。

哈希表的实现

顺序表:

#include <iostream>
#include<vector>
using namespace std;

class hash_map{
	private:
	int count;
	int size;
	vector<int> values;
	public:
	hash_map(int s,vector<int> v){
		size = s;
		vector<int> cv(size,-99);
		values = cv;
		create_hash(v);
		};
	int cal(int num){
		num = num % size;
		while(num < size&&values[num] != -99) num++;
		if(num == size) return -1;
		return num;
	}
	void create_hash(vector<int> v){
		for(int vi:v) insert(vi);
	}
	void insert(int v){
			int index = cal(v);
			if(index == -1) {
				cout<<"Error:already full"<<endl;
				return;
			}
			values[index] = v;
	}
	int find(int v){
		int index = v % size;
		while (v < size && values[index] != v) index++;
		if(index == size) {
			cout<<"Error:not find"<<endl;
			return -1;
		}
		return index;
	}
};
int main() {
	vector<int> num = {2,5,3,5,0,1};
	hash_map hashmap(9,num);
	int n = hashmap.find(2);
	cout<<n<<endl;
	return 0;
}

例题

2283. 判断一个数的数字计数是否等于数位的值

class Solution {
public:
    bool digitCount(string num) {
        unordered_map<int,int> map;
        for(char n:num) map[n - '0']++;
        for(int i = 0;i < num.length();i++) 
            if(num[i] - '0' != map[i])
                 return false;
        return true;
    }
};

1807. 替换字符串中的括号内容

class Solution {
public:
    unordered_map<string,string> map;
    string evaluate(string s, vector<vector<string>>& knowledge) {
        string temp;
        string answer;
        for(auto know : knowledge) map[know[0]] = know[1];
        bool flag = false;
        for(int i = 0;i < s.size();i++){
            if(s[i] == '(') flag = true;
            else if(s[i] == ')') 
            {   if(map.count(temp) > 0) answer += map[temp];
                else answer += "?";
                flag = false,temp.clear();
            }
            else {
                if(flag) temp += s[i]; 
                else answer += s[i];
            }
        }
        return answer;
    }
};

参考

[1] 哈希表(HashTable),吃米饭

[2] <<数据结构与算法分析(C++版),张琨,张宏,朱保平