散列的定义与整数散列

【引言】

散列(hash)是常用的算法思想之一,在很多程序中都会有意无意地使用到。

先来看一个简单的问题:给出N个正整数,再给出M个正整数,问这M个数中的每个数分别是否在N个数中出现过,其中N, M≤105,且所有正整数均不超过105

例如N=5、M=3,N个正整数为{8, 3, 7, 6, 2},欲查询的M个正整数为{7, 4, 2},于是后者中只有7和2在N个正整数中出现过,而4是没有出现过的。

(1)对这个问题, 最直观的思路是:对每个欲查询的正整数x,遍历所有N个数,看是否有一个数与x 相等。这种做法的时间复杂度为O(NM),当N和M都很大(105级别)时,显然是无法承受的。

(2)那么该如何做呢?不妨用空间换时间, 即设定一个 bool 型数组 hashTable[100010],其中hashTable[x] = true表示正整数x在N个正整数中出现过,而hashTable[x] = false表示正整数x在N个正整数中没有出现过。

hashTable 数组需要初始化为false,表示初始状态下所有数都未出现过,这样当读入的数为 x 时,就令hashTable[x] = true 。于是,对M个欲查询的数,就能直接通过hashTable数组判断出每个数是否出现过。显然这种做法的时间复杂度为O(N + M)。

#include<cstdio>
const int maxn = 100010;
bool hashTable[maxn] = {false};
int main()
{
	int n,m,x;
	scanf("%d%d",&n,&m);
	for(int i=0;i<n;i++)
	{
		scanf("%d",&x);
		hashTable[x] = true;
	}
	
	for(int i=0;i<m;i++)
	{
		scanf("%d",&x);
		if(hashTable[x]==true)
			printf("YES\n");
		else
			printf("NO\n");
	}
	
	return 0;
}

同样的,如果题目要求M个欲查询的数中每个数在N个数中出现的次数,那么可以把 hashTable 数组替换为 int 型,然后在输入N个数时进行预处理,即当输入的数为 x 时,就令hashTable[x]++,这样就可以用O(N + M)的时间复杂度输出每个欲查询的数出现的次数。

#include<cstdio>
const int maxn = 100010;
int hashTable[maxn] = {0};
int main()
{
	int n,m,x;
	scanf("%d%d",&n,&m);
	for(int i=0;i<n;i++)
	{
		scanf("%d",&x);
		hashTable[x]++;
	}
	
	for(int i=0;i<m;i++)
	{
		scanf("%d",&x);
		printf("%d\n",hashTable[x]);
	}
	
	return 0;
}

上面的两个问题都有一个特点,那就是直接把输入的数作为数组的下标来对这个数的性质进行统计这是一个很好的用空间换时间的策略,因为它将查询的复杂度降到了O(1)级别。

【概念】

但是,上述策略暂时还有一个问题,上面的题目中出现的每个数都不会超过105,因此直接作为数组下标是可行的,但是如果输入可能是109大小的整数(例如1111111),或者甚至是一个字符串(例如"I Love You"),就不能将它们直接作为数组下标了。要是有一种做法,可以把这些乱七八糟的元素转换为一个在能接受范围内的整数,那该多么美好呀!

这样的做法当然是存在的,那就是散列(hash)。一般来说,散列可以浓缩成句话“将元素通过一个函数转换为整数,使得该整数可以尽量唯一地代表这个元素”。
其中把这个转换函数称为散列函数H,也就是说,如果元素在转换前为key,那么转换后就是一个整数H(key)。

那么对key是整数的情况来说,有哪些常用的散列函数呢?一般来说, 常用的有直接定址法、平方取中法、除留余数法等。

  • 直接定址法是指恒等变换 ( 即H(key) = key,本节开始的问题就是直接把key作为数组下标,是最常见最实用的散列应用 ) 或是线性变换 ( 即H(key) = a*key+b );
  • 平方取中法是指取key的平方的中间若干位作为hash值(很少用)。
  • 除留余数法是指把key除以一个数 mod 得到的余数作为hash值的方法,即 H(key) = key % mod

【除留余数法】

下面特别介绍一下除留余数法:

通过这个散列函数,可以把很大的数转换为不超过 mod 的整数,这样就可以将它作为可行的数组下标 (注意:表长TSize必须不小于mod,不然会产生越界)。显然,当mod是一个素数时,H(key)能尽 可能覆盖 [0, mod) 范围内的每一个数。因此一般为了方便起见,下文中取TSize是一个素数,而mod直接取成与TSize相等

但是稍加思考便可以注意到,通过除留余数法可能会有两个不同的数 key1 和 key2 ,它们的hash值H(key1) 与 H(key2) 是相同的,这样当 key1 已经把表中位置为 H(key1) 的单元占据时,key2 便不能再使用这个位置了。我们把这种情况叫作“冲突”。

既然冲突不可避免,那就要想办法解决冲突。下面以三种方法来解决冲突为例,其中第一种和第二种都计算了新的hash值,又称为开放定址法。

1、线性探查法(Linear Probing)

当得到 key 的hash值H(key),但是表中下标为H(key的位置已经被某个其他元素使用了,那么就检查下一个位置 H(key) + 1是否被占,如果没有,就使用这个位置;否则就继续检查下一个位置(也就是将hash值不断加1)。如果检查过程中超过了表长,那么就回到表的首位继续循环,直到找到一个可以使用的位置,或者是发现表中所有位置都已被使用。

显然,这个做法容易导致扎堆,即表中连续若干个位置都被使用,这在一定程度上会降低效率。

2、平方探查法(Quadratic probing)

在平方探查法中,为了尽可能避免扎堆现象,当表中下标为H(key)的位置被占时,将按下面的顺序检查表中的位置:H(key) + 12、H(key) -12、 H(key) + 22、H(key) - 22、H(key) + 32……

如果检查过程中 H(key) + k2超过了表长TSize,那么就把 H(key) + k2 对表长TSize取模;

如果检查过程中出现 H(key) - k2< 0的情况(假设表的首位为0),那么将 ( (H(key) - k2) % TSize + TSize) % TSize 作为结果 (等价于将H(key) - k2不断加上TSize直到出现第一个非负数)。

如果想避免负数的麻烦,可以只进行正向的平方探查。可以证明,如果k在 [0, TSize) 范围内都无法找到位置,那么当 k ≥ TSize时,也一定无法找到位置。

3、链地址法(拉链法)

和上面两种方法不同,链地址法不计算新的hash值,而是把所有 H(key) 相同的 key 连接成一条单链表。这样可以设定一个数组 Link,范围是 Link[0] ~ Link[mod],其中 Link[h] 存放 H(key)= h 的一条单链表, 于是当多个关键字 key 的 hash值都是h时,就可以直接把这些冲突的key直接用单链表连接起来,此时就可以遍历这条单链表来寻找所有 H(key) = h 的key。

当然, 一般来说,可以使用标准库模板库中的map来直接使用 hash 的功能 (C++11以后可以用unordered map,速度更快),因此除非必须模拟这些方法或是对算法的效率要求比较高,一般不需要自己实现上面解决冲突的方法。

猜你喜欢

转载自blog.csdn.net/qq_42815188/article/details/88855434
今日推荐