散列在数据结构算法中占据独特而重要地位。此类方法以最基本的向量作为底层支撑结构,通过适当的散列函,在词条的关键码与向量单元的秩之间建立起映射关系,理论分析和实验统计均表明,只要散列散列函数以及冲突排解策略设计得当,散列技术可在期望的常数时间内实现词典的所有接口。
尤为重要的是,散列技术完全摒弃了基于关键码大小比较式的设计思路,其算法性能不受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(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; //释放原桶数组
}