散列表的实现常称为 散列(hashing),是一种用于常数平均时间执行插入、删除和查找的技术。
5.1 基本思想
理想的散列表数据结构只不过是包含一些项的具有固定大小的数组。
把表的大小记作TableSize,并将其理解为散列数据结构的一部分而不仅仅是浮动于全局的某个变量。将每个键映射到从0到TableSize-1的这个范围中的某个数,并且将其放到合适得单元中,这个映射就称为散列函数。
冲突:当两个键散列到同一个值得时候,应该做什么以及如何确定散列表的大小。
5.2 散列函数
如果输入的键为整数,一般的合理方法直接返回“Key mod TableSize”,此时需要保证表的大小为素数。
通常输入的键是字符串,在这种情况下,散列函数需要:把字符串中的ASCⅡ码值加起来。如下所示
//一个简单的散列函数
int hash(const string & key, int tableSize)
{
int hashVal = 0;
for(int i =0; i < key.length() ; i++)
hashVal += key[i];
return hashVal % tableSize;
}
存在的问题:如果TableSize = 10007 (素数),假设键最多8个字符,ASCⅡ最大127,所以散列函数只能在0-1016之间取值,其中1016 = 127 *8,这不是一种均匀的分配。
散列函数形式多样,主要的剩余问题就是解决冲突,主要介绍最简单的两种:分离链接法和开放定址法
5.3 分离链接法
做法:将散列到同一个值的所有元素保留在一个链表里。散列表结构存储一个链表数组。
//分离链接散列表的类架构
template <typename HashedObj>
class HashTable
{
public:
explicit HashTable(int size = 101 );
bool contains(const HashedObj & x)const;
void makeEmpty();
void insert(const HashedObj & x);
void remove(const HashedObj & x);
private:
vector<list<HashedObj> > theLists;
int currentSize;
void rehash();
int myhash(const HashedObj & x)const;
};
int hash(const string & key);
int hash(int key);
在架构实现的时候,注意定义vector变量时,两个>之间要有一个空格,因为在c++里面<<也是一个操作符。
//myhash成员函数:将结果分配到一个合适得数组索引中
int myhash(const HashedObj & x)const
{
int hashVal = hash(x);
hashVal %= theLists.size();
if(hashVal < 0)
hashVal += theLists.size();
return hashVal;
}
装填因子λ:散列表中的元素个数与散列表大小的比值,
分离连接散列法的一般法则是使得表的大小尽量与预料的元素个数差不多,也就是λ≈1.
5.4 不使用链表的散列表
另一个解决冲突的方法:当冲突发生时尝试选择另一个单元,直到找到空的单元。更正式的,单元h0(x), h1(x), h2(x)…依次进行试选,其中hi(x) = (hash(x) + f(i)) mod TableSize, 并且f(0) = 0。函数f 是冲突解决函数。
一般来说,不适用分离链接法的散列表,其装填因子λ低于0.5.称这样的表为探测散列表。
5.4.1 线性探测
在线性探测中,f是i的线性函数,一般情况下f(i) = i。相当于逐个探测每个单元来查找出空单元。
举例,将诸键{89,18,49,58,69}插入到一个散列表,按照取10余的方法和线性探测,过程如下
- 将89插入到第九个位置。
- 将18插入到第八个位置。
- 49,和89冲突,然后放到下一个位置,轮回到0
- 58,和18冲突,检查第九个位置,发现有89,继续检查第零个发现有49,继续检查第一个没有,放下
- 69,和89冲突,检查第零,一个都有东西,然后放到了第二个位置。
|位置| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|-------------------------------------------------|
|元素|49|58|69|空 |空 |空 |空 |空 |18|89
在这里有一个问题,对于元素69的处理,即使只要有空位置就能找到,但是花费比较多的时间。更为糟糕的是,占据的单元会开始形成一些区块(例如8,9,0,1,2聚堆出现元素),这种结果称为一次聚焦。
5.4.2 平方探测
消除线性探测中一次聚集问题的冲突解决方法。流行的选择为f(i)=i2.
与刚才的例子一样,插入变成
|位置| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|-------------------------------------------------|
|元素|49|空 |空 |58|69|空 |空 |空 |18|89
注:58和18冲突,而18的下一个又是89,所以元素=2,相隔22=4个位置
定理5-1:如果使用平方探测,且表的大小是素数,那么当表至少有一半是空的时候,总能插入一个新的元素。
部分实现代码如下:(重点体会思路)
//使用探测策略的散列表的类接口,包括嵌套的HashEntry类
template <typename HashedObj>
class HashTable
{
public:
explicit HashTable (int size = 101 );
bool contains(const HashedObj & x) const;
void makeEmpty();
bool insert(const HashedObj & x) const;
bool remove(const HashedObj & x) const;
enum EntryType {ACTIVE, EMPTY, DELETED};
private:
struct HashEntry
{
HashedObj element;
EntryType info;
HashEntry(const HashedObj & e = HashedObj (), EntryType i = EMPTY):element(e), info(i){}
};
vector<HashEntry> array;
int currentSize;
bool isActive(int currentPos) const;
int findPos(const HashedObj & x)const;
void rehash();
int myhash(const HashedObj & x)const;
};
//初始化平方探测散列表的例程
explicit HashTable (int size = 101 ):array(nextPrime(size))
{
makeEmpty();
}
void makeEmpty()
{
currentSize = 0;
for(int i =0 ;i < array.size(); i ++)
array[i].info = EMPTY;
}
//平方探测法的contains例程,f(i) = f(i- 1) + 2i - 1
bool contains(const HashedObj & x)const
{return isActive(findPos(x));}
int findPos(const HashedObj & x)const
{
int offset = 1;
int currentPos = myhash(x);
while(array[currentPos].info != EMPTY && array[currentPos].element != x)
{
currentPos += offset;
offset +=2;
if(currentPos >=array.size())
currentPos -= array.size();
}
return currentPos;
}
bool isActive(int cuurentPos) const
{ return array[currentPos].info = ACTIVE ; }
//平方探测的insert和remove
bool insert(const HashedObj &x)
{
int currentPos = findPos(x);
if(isActive(currentPos))
return false;
array[currentPos] = HashEntry(x, ACTIVE);
if(++curentSize > array.size() /2)
rehash();
return true;
}
bool remove(const HashedObj &x)
{
int currentPos = findPos(x);
if(!isActive(currentPos))
return false;
array[currentPos].info = DELETED;
return true;
}
平方探测排除了一次聚集,但是散列到同一位置上的哪些元素将探测出同样的备选单元,这成为二次聚集。
5.4.3 双散列
一种流行的选择是f(i) = i * hash2(x),例如选择hash2(x) = R - (x mod R),其中R为不大于TableSize的素数。
在这里我们选择R= 7,相同的例子插入。
|位置| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|-------------------------------------------------|
|元素|69|空 |空 |58|空 |空 |49|空 |18|89
5.5 再散列
使用平方探测的开放地址散列法,如果表的元素填的太慢,操作的运行时间过长。解决办法就是建立另外一个大约两倍大的表(而且使用一个相关的新散列函数),扫描整个原始散列表,计算每个(未删除的)元素的新散列值并将其插入到新表中。
例如,元素13,15,24,6插入大小为7的线性探测散列表中,散列函数是h(x) = x mod 7,插入后得到的散列表为
|位置| 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|元素| 6 |15|空|24|空 |空 |13|
如果将23插入,应该在位置2,此时超过70%的单元是满的,所以建立一个新的表,大小为17(因为17是原来表两倍大的第一个素数),重新计算h(x) = x mod 17。然后插入到新表中
|位置| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |10|11|12|13|14|15|16|
|元素|空 |空 |空|空|空 |空 |6|23|24| 空|空|空|空 |13|空|15|空|
整个操作称为再散列
小结
散列常见的应用:
- 编译器使用散列表跟踪代码中声明的变量,这种数据结构叫做符号表。
- 适用于任何其结点有实名而不是数字名的图论问题。
- 在游戏编制的程序中,通过简单的移动变换避免昂贵的复杂计算,这种特点叫做置换表。
- 在线拼写检查程序,可以预先将这个词典进行散列。