十三、【数据结构】散列表(hashtable)的详解及实现

散列在数据结构算法中占据独特而重要地位。此类方法以最基本的向量作为底层支撑结构,通过适当的散列函,在词条的关键码与向量单元的秩之间建立起映射关系,理论分析和实验统计均表明,只要散列散列函数以及冲突排解策略设计得当,散列技术可在期望的常数时间内实现词典的所有接口

尤为重要的是,散列技术完全摒弃了基于关键码大小比较式的设计思路,其算法性能不受CBA(compare based algothrim)式算法的下界限制 (nlogn)。

一、散列表

散列表(hashtable)是散列方法的底层基础,逻辑上由一系列可存放词条(或其引用)的单元组成,故这些单元也称桶(bucket)或桶单元。与之对应地,各桶单元也应按其逻辑次在物理上连续排列。因此,这种线性的底层结构用向量来实现再自然不过,为简化实现并进步提高效率,往往直接使用数组。若桶数组的容量为R,则其中合法秩的区间[0,R)也称作地址空间(address space)。

二、散列函数

一组词条在散列表内部的具体分布,取决于所谓的散列(hashing)方案----事先在词条与桶地址(桶数组的秩)之间约定的某种映射关系,可描述为从关键码空间到桶数组地址空间的函数:

        桶数组的秩 <----->  hash(key)

上面的hash()称为散列函数。

设计原则:

(1)  必须具有确定性:无论所含的数据项如何,词条E在散列表中的映射地址 hash(E.key) 必须完全取决于其关键码 E.key。

(2) 映射过程自身不能过于复杂,唯此方能保证散列地址的计算可快速完成,从而保证查询或修改操作整体的O(1)期望执行时间。

(3) 所有关键码经映射后应尽量覆盖整个地址空间 [0, M),唯此方可充分利用有限的散列表空间。也就是说,函数hash()最好是满射。

在此,最为重要的一条原则就是,关键码映射到各桶的概率应尽量接近于1/M ---- 若关键码均匀且独立地随机分布,这也是任意一对关键码相互冲突的概率。就整体而言,这等效于将关键码空间“均匀地”映射到散列地址空间,从而避免导致极端低效的情况。总而言之,随机却强,规律性越弱的散列函数越好。要尽可能地消除导致关键码分布不均匀的因素,最大限度地模拟理想的随机函数,尽最大可能降低冲突发生的概率

几种常见的散列函数:

(1) 除余法(division method)

一种最简单的映射方法,就是将散列长度M取作为素数(质数),并将关键码key映射至key关于M整除的余数。

        hash(key) = key mod M

注意:采用除余法时必须将M选做素数,否则关键码被映射到 [0,M)范围内的均匀度将大幅度降低,发生冲突的概率将随M所含因子的增多而迅速增大。

(2) MAD法(multiply-add-divide method)

以素数为表长的除余法尽管可在一定程度上保证词条的均匀分布,但从关键码空间到散列地址空间映射的角度看,依然残留有某种连续性。为弥补这一不足,可采用MAD法经关键key映射为:

        (a * key + b) mod M ,其中M仍然为素数,a > 0, b > 0,且 a mod M !=0

实际上,除余法也可以看作是MAD法的一种特例(a=1 , b=0).

(3) (伪)随机数法

越是随机、越是没有规律,就越是好的收列函数,按照这一标准,任何一个(伪)随机数发生器,本身即是一个好的散列函数。比如,可直接使用C/C++语言提供的 rand ()函数,将关键码key映射至桶地址:

        rand(key) mod M ,其中rand(key) 为系统定义的第key个(伪)随机数。

这一策略的原理也可理解为,将“设计好散列函数”的任务,转换为“设计好的(伪)随机数发生器”的任务,幸运的是,二者的优化目标几乎是一致的。需特别留意的是,由于不同计算环境所提供的(伪)随机数发生器不尽相同,故在将某一系统中生成的散列表移植到另一系统时,必须格外小心

(4) 其他散列函数:数字分析法、平方取中法、折叠法、异或法为保证散列函数取值落在散列地址合法空间内,通常都需要对散列表长度M再做一次取余运算。

三、冲突及排解

散列表的基本构思可以概括为:开辟物理地址连续的桶数组ht[],借助散列函数hash(),将词条关键码key映射为桶地址hash(key),从而快速地确定待操作词条的物理位置。

然而遗憾的是,无论散列函数设计得如何巧妙,也不可能保证不同的关键码之间互不冲突。因此我们必须事先制定一整套有效的对策,以处理和排解时常发生的冲突。

几种常见的冲突排解方法:

(a) 开散列策略

(1) 多槽位(multiple slots)

最直截了当的一种对策是,将彼此冲突的每一组词条组织为一个小规模的子词典,分别存放于它们共同对应的桶单元中,比如一种简便的方法是,统一将各桶细分为更小的称作槽位 (slot) 的若干单元,每一组槽位可组织为向量或列表。

多槽位法有很多缺陷,1. 绝大多数的槽位通常都处与空i状态。导致装填因子大大降低;2. 很难在事先确定槽位应细分到何种程度,方可保证在任何情况下都够用。

(2) 独立链(separate chaining)

冲突排解的另一策略与多槽位 (multiple slots) 法类似,也令相互冲突的每组词条构成小规模的子词典,只不过采用列表(而非向量) 来实现各子词典。

相对于多槽位法,独立链法可更为灵活地动态调整各子词典的容量,降低空间消耗。但在查找过程中一旦发生冲突,则需要遍历整个列表,导致查找成本的增加,而且系统的缓存功能几乎失效(没有局部性)

(3) 公共溢出区法

公共溢出区 (overflow area) 法的思路很简单,在原散列表之外另设个词典结构D,一旦在插入词条时发生冲突就将该词条转存至D中,就效果而言,D相当于一个存放冲突词条的公共缓冲池,该方法也因此得名。

(b) 闭散列策略(最常用)

尽管就逻辑结构而言,独立链等策略便捷而紧凑,但绝非上策。比如,因需要引入次级关联结构,实现相关算法的代码自身的复杂程度将加大大增加,反过来,因不能保证物理上的关联性,对于稍大规模的词条集,查找过程中将需做更多的I/O操作。

实际上,仅仅依靠基本的散列表结构,且就地排解冲突,反而是更i的选择。也就是说,新词条与已有词条冲突,则只允许在散列表内部为其寻找另一空桶,如此,各桶并非注定只能放特定的一组词条:从理论上讲,每个桶单元都有可能存放任一词,因为散列地址空间对所有词条开放,故这一新的策略亦称开放定址(open addressing) ,亦称闭散列(closed hashing) ,反之称为开散列。

(1) 线性试探法(linear probing)

若发现桶单元 ht[hash(key)] 已被占用,则转而试探桶单元 ht[hash(key) + 1];若ht[hash(key) +1]也被占用,则继续试探ht[hash(key) + 2,...;如此不断直到发现一个可用空桶。当然,为确保桶地址的合法,最后还需统一对M取模。因此准确地,第i次 试探的桶单元应为: 

        ht[ (hash(key) +i) mod M ], i= 1,2,3,....

如此,被试探的桶单元在物理空间上依次连贯,其地址构成等差数列。

懒惰删除:查找链中任何一环的缺失,都会导致后续词条因无法抵达而丢失,表现为有时无法找到实际已存在的词条。因此若采用开放定址策略,则在执行删除操作时,需同时做特别的调整。简明而有效的方法是,为每个桶另设一个标志位 ,指示该桶尽管目前为空,但此前确曾存放过词条具体地,为删除词条,只需将对应的桶标志位置1,该桶虽不存放任何实质的词条,却依然是查找链上的一环,这一方法称为懒惰删除。

(2) 平方试探法(quadratic probing)

在试探过程中若连续发生冲突,则按如下规则确定第j次试探的桶地址:

        (hash(key) +j^2) mod M, j-8, 1, 2,…

各次试探的位置到起始位置的距离,以平方速率增长,该方法因此得名。平方试探法能够有效地缓解聚集现象,它充分利了平方函数的特点-顺着查找链,试探位置的间距将以线性(而不再是1)的速度增长,于是,一旦发生冲突,即可“聪明地"尽快“跳离”关键码聚集的区域。

(3) 其他闭散列法:随机试探法、再散列法。

四、其他需要注意的地方

(1) 装填因子

就对散列表性能及效率的影响而言,装填因子是最为重要的一个因素。实质上,理论分近和实验统计一致表明,只要能将装填子控制在适当范以内,闭散列策略的平均效率,通常都可保持在为理想的水平,比如,一般的建议是保持装填因子小于0.5,这原则也适用于其它的定址策略,比如对独立链法而言,建议的装填因子上限为0.9,当前主流的编程语言大多提供了散列表接口,其内部装填因子的阈值亦多采用与此接近的阈值。

(2) 散列码转换

为扩大散列技术的应用范围,散列函数hash()必须能够将任意类型的关键码key映射为地址空间[0,M)内的一个整数hash(key),以便将其作为秩确定key所对应的散列地址。

所以一般在使用散列函数前,需要进行预处理--设计一个散列函数,提前把各种类型的变量转换成整数。

(a) 对于byte、short、int、char等本身不超过32位整数的数据类型,直接强转成32位整数。

(b) 对于long long、double之类超过32为的整数,将其位数拆分,然后高低位进行运算得到32为整数。

(c) 对于字符串,可采用多项式散列码的策略,可取常数x>=2,对于一个字符串“abcd”,取其对应的散列码为:

        a*x^3+b*X^2+c*X^1+d

五、散列表的实现(基于线性试探法)

这里实现的散列表基于线性试探法,包含entry类(描述词条),dictionary(描述字典对象的常用操作),以及hashtable类(描述散列表的插入、删除、自适应调整装填因子等操作)。

hashtable接口列表
操作 功能 对象
hashtable(int c = 5) 构造函数,创建一个容量不少于c的散列表 散列表
~hashtable() 手动释放桶数组所占内存空间 散列表
probe4Hit(const K& k) 沿关键码k对应的查找链,找到与词条匹配的桶 散列表
probe4Free(const K& k) 沿关键码k对应的查找链,找到首个可用的空桶 散列表
rehash() 重散列,扩充桶数组容量 散列表
hashCode(char c) 将对应类型的关键码转换为整数 散列表
size() const 返回当前的词条数量 散列表
put(K k, V v) 插入词条(不许重复) 散列表
get(K k) 读取词条 散列表
remove(K k) 删除词条 散列表

 (1) entry.h

#pragma once

//词条类
template struct entry
{
	K key;      //关键码
	V value;    //数据项

	//构造函数
	entry(K k = K(), V v = V()) :key(k), value(v) {}   
	entry(const entry& e) :key(e.key), value(value) {}
	bool operator<(const entry& e);
	bool operator>(const entry& e);
	bool operator==(const entry& e);
	bool operator!=(const entry& e);
};

template bool entry::operator<(const entry& e)
{
	return this->key < e.key;
}

template bool entry::operator>(const entry& e)
{
	return this->key > e.key;
}

template bool entry::operator==(const entry& e)
{
	return this->key == e.key;
}

template bool entry::operator!=(const entry& e)
{
	return this->key != e.key;
}

(2) dictionary.h

#pragma once

//对词条的操作
template struct dictionary
{
	virtual int size() const = 0;   //返回当前词条总数
	virtual bool put(K k, V v) = 0;  //插入词条
	virtual V* get(K k) = 0;       //读取指定词条的数据项
	virtual bool remove(K k) = 0;    //删除词条
};

 (3) hashtable.h

#pragma once
#include"entry.h"
#include"dictionary.h"
#include"bitmap.h"

#define lazilyRemoved(x) (lazyRemoval->test(x))
#define markAsRemoved(x) (lazyRemoval->set(x))

/*hashtable,基于线性试探策略*/
template class hashtable :public dictionary
{
private:
	entry** ht;   //桶数组(数组中存放的是词条的指针,更加灵活)
	int M;   //桶数组容量
	int N;   //词条总数
	bitmap* lazyRemoval;   //懒惰删除标记
	
protected:
	int probe4Hit(const K& k);     //沿关键码k对应的查找链,找到与词条匹配的桶
	int probe4Free(const K& k);    //沿关键码k对应的查找链,找到首个可用的空桶
	void rehash();   //重散列,扩充桶数组容量

public:
	//构造函数
	hashtable(int c = 5);  //创建一个容量不少于c的散列表
	//析构函数
	~hashtable();   //手动释放桶数组所占内存空间

	static size_t hashCode(char c) { return (size_t)c; }   //将对应类型的关键码转换为整数
	static size_t hashCode(int k) { return (size_t)k; }
	static size_t hashCode(long long) { return (size_t)((i >> 32) + (int)i); }
	static size_t hashCode(char s[]);
	int size() const { return N; }  //返回当前的词条数量
	bool put(K k, V v);     //插入词条(不许重复)
	V* get(K k);    //读取词条
	bool remove(K k);  //删除词条
};

template hashtable::hashtable(int c = 5)
{
	//M为首个不小于c的素数
	while (!bitmap::isPrime(c))
		c++;
	M = c;
	N = 0;
	ht = new entry*[M]; //开辟内存
	memset(ht, 0, sizeof(entry*)*M);   //初始化各桶
	lazyRemoval = new bitmap(M);  //创建懒惰删除标记bitmap
}


template hashtable::~hashtable()
{
	for (int i = 0; i < M; i++)   //释放所有词条所占空间
	{
		if (ht[i])
			delete ht[i];
	}
	delete ht;   //释放桶数组
	delete lazyRemoval;  //释放懒惰删除标志位图
}

template static size_t hashtable::hashCode(char s[])
{
	int h = 0;
	for (size_t n = strlen(s), i = 0; i < n; i++)
	{
		h = (h << 5) | (h >> 27);
		h += (int)s[i];
	}
	return (size_t)h;
}


template int hashtable::probe4Hit(const K& k)
{
	int r = hashCode(k) % M;   //计算出关键码k对应的桶(除余法确定)
	while ((ht[r]&&(k!=ht[r]->key))||(!ht[r]&&lazilyRemoved(r)))   
	{
		r = (r + 1) % M;  //沿查找链路线性试探
	}
	return r;
}


template int hashtable::probe4Free(const K& k)
{
	int r = hashCode(k) % M;   //找到起始桶(除余法)
	while (ht[r])    //是空桶就行,不用管懒惰删除标记
		r = (r + 1) % M;
	return r;
}

template bool hashtable::put(K k, V v)
{
	if (ht[probe4Hit(k)]) return false;   //如果已经存在则返回
	int r = probe4Free(k);  //找到空桶
	ht[r] = new entry(k, v);
	++N; 
	if (N * 2 > M)   //若装填因子高于0.5则重散列,扩容
		rehash();  
	return true;
}

template V* hashtable::get(K k)
{
	int r = probe4Hit(k);
		return (ht[r]) ? &(ht[r]->value) : nullptr;
}

template bool hashtable::remove(K k)
{
	int r = probe4Hit(k);
	if (!ht[r])    //无
		return false;
	delete ht[r];
	ht[r] = nullptr;
	markAsRemoved(r);
	N--;
	return true;
}

template void hashtable::rehash()
{
	int old_capacity = M; entry** old_ht = ht;
	M = M * 2;
	while (!bitmap::isPrime(M))   //容量至少加倍
		M++;
	N = 0;
	ht = new entry*[M];   //创建新桶数组
	memset(ht, 0, sizeof(entry*)*M);
	delete lazyRemoval;
	lazyRemoval = new bitmap(M);
	for (int i = 0; i < old_capacity; i++)
	{
		if (old_ht[i])
		{
			put(old_ht[i]->key, old_ht[i]->value);  //转移原桶
		}

	}
	delete old_ht;  //释放原桶数组
}

 

你可能感兴趣的:(数据结构,散列表,hashtable,数据结构)