散列表
查找的本质
已知对象找位置
- 有序安排对象:全序、半序
- 直接“算出”对象位置:散列
散列查找
散列查找法的两项基本工作:
- 计算位置:构造散列函数确定关键词存储位置
- 解决冲突:应用某种策略解决多个关键词位置相同的问题
- 时间复杂度几乎是常量: O(1) ,即查找时间与问题规模无关
抽象数据类型定义
- 类型名称:符号表(SymbolTable)
- 数据对象集:符号表是“名字(Name)— 属性(Attribute)”对的集合
- 操作集:
Table∈SymbolTable,Name∈NameType,Attr∈AttributeType
SymbolTable InitializeTable(int TableSize)
:创建一个长度为TableSize
的符号表Boolean IsIn(SymbolTable Table, NameType Name)
:查找特定的名字Name
是否在符号表Table
中AttributeType Find(SymbolTable Table, NameType Name)
:获取Table
中指定名字Name
对应的属性SymbolTable Modefy(SymbolTable Table, NameType Name, AttributeType Attr)
:将Table
中指定名字Name
的属性修改为Attr
SymbolTable Insert(SymbolTable Table, NameType Name, AttributeType Attr)
:向Table
中插入一个新名字Name
及其属性Attr
SymbolTable Delete(SymbolTable Table, NameType Name)
:从Table
中删除一个名字Name
及其属性
散列表的基本术语
- 装填因子(Loading Factor):设散列表空间大小为 m ,填入表中元素的个数是 n ,则称 a=n/m 为散列表的装填因子
散列(Hashing)的基本思想
- 以关键字 key 为自变量,通过一个确定的函数 h (散列函数),计算出对应的函数值 h(key) ,作为数据对象的存储地址
- 可能不同的关键字会映射到同一个散列地址上,即
h(key1)=h(key2),key1≠key2
,称为“冲突(Collision)”
- 需要某种冲突解决策略
散列函数的构造方法
一个“好”的散列函数一般应考虑下列两个因素:
- 计算简单,以便提高转换速度
- 关键词对应的地址空间分布均匀,以尽量减少冲突
数字关键词的散列函数构造
直接定址法
取关键词的某个线性函数值为散列地址,即 h(key)=a∗key+b (a、b为常数)
例
散列函数: h(key)=key−1990
除留取余法
散列函数为: h(key)=key mod p
例
散列函数: h(key)=key % 17
- p=TableSize=17
- 一般, p 取素数
数字分析法
分析数字关键字在各位上的变化情况,取比较随机的位作为散列地址
例1
取11位手机号码key的后四位作为地址,散列函数为: h(key)=atoi(key+7)
例2
关键词 key 是18位的身份证号码:
散列函数:
- h1(key)=(key[6]−′0′)∗104+(key[10]−′0′)∗103+(key[14]−′0′)∗102+(key[16]−′0′)∗10+(key[17]−′0′)
-
h(key)=
- h1(key)∗10+10 ,当 key[18]=′x′ 时
- h1(key)∗10+key[18]−′0′ ,当 key[18]=′0′→′9′ 时
折叠法
把关键词分割成位数相同的几个部分,然后叠加
例
散列函数: h(56793542)=391
平方取中法
把关键词进行平方计算以后,取中间的几位作为散列值
例
散列函数: h(56793542)=641
字符关键词的散列函数构造
简单的散列函数 —— ASCII码加和法
对字符型关键词 key 定义散列函数: h(key)=(∑key[i]) mod TableSize
冲突严重,如: {a3,b2,c1} 以及 {eat,tea}
简单的改进 —— 前3个字符移位法
散列函数: h(key)=(key[0]∗272+key[1]∗27+key[2]) mod TableSize
- 仍然冲突,如 {string,street,strong,...}
- 空间浪费: 3000/263≈30%
好的散列函数 —— 移位法
涉及关键词所有 n 个字符,并且分布得很好,散列函数: h(key)=(∑n−1i=0key[n−i−1]∗32i) mod TableSize
实现
Index Hash(const char *Key, int TableSize) {
unsigned int h = 0; // 散列函数值,初始化为0
while (*Key != '\0') // 移位映射
h = (h << 5) + *Key++; // 左移5位表示乘上32
return h % TableSize;
}
冲突处理方法
常用处理冲突的思路:
- 换个位置:开放地址法
- 同一位置的冲突对象组织在一起:链地址法
开放地址法(Open Addressing)
原理
一旦产生冲突(该地址已有其他元素),就按某种规则去寻找另一空地址
- 若发生了第 i 次冲突,试探的下一地址将增加 di ,基本公式是: hi(key)=(h(key)+di) mod TableSize , 1≤i≤TableSize
-
di
决定了不同的解决方案
- 线性探测: di=i
- 平方探测: di=±i2
- 双散列: di=i∗h2(key)
散列表查找性能分析
- 成功平均查找长度(ASLs):查找表中关键词的平均查找次数(其冲突次数加1)
- 不成功平均查找长度(ASLu):不在散列表中关键词的平均查找次数(不成功)
- 一般方法:将不在散列表中的关键词分若干类,如根据 h(key) 值分类
线性探测法(Linear Probing)
以增量序列 1,2,...,(TableSize−1) 循环试探下一个存储地址
例
设关键词序列为 {47,7,29,11,9,84,54,20,30} ,用线性探测法处理冲突,列出依次插入后的散列表,并估算查找性能
- 散列表长 TableSize=13 (装填因子 a=9/13≈0.69 )
- 散列函数为: h(key)=key mod 11
插入后的散列表
注:元素会在冲突频繁的地方聚集起来,称为聚集现象
查找性能分析
- ASLs=(1+7+1+1+2+1+4+1+4)/9=23/9≈2.56
- ASLu=(3+2+1+2+1+1+1+9+8+7+6)//11=41/11≈3.73
平方探测法(Quadratic Probing)
又称二次探测,以增量序列 12,−12,22,−22,...,q2,−q2 且 q≤⌊TableSize/2⌋ 循环试探下一个存储地址
例
设关键词序列为 {47,7,29,11,9,84,54,20,30} ,用平方探测法处理冲突,列出依次插入后的散列表,并估算 ASLs
- 散列表表长 TableSize=11
- 散列函数为: h(key)=key mod 11
插入后的散列表
查找性能分析
ASLs=(1+1+2+1+1+3+1+4+4)/9=18/9=2
平方探测空间查找问题
存在散列表空间,通过平方探测无法获取到
例
解决方案
有定理显示:如果散列表长度 TableSize 是某个 4k+3 ( k 是正整数)形式的素数时,平方探测法就可以探查到整个散列表空间
双散列探测法(Double Hashing)
di 为 i∗h2(key) , h2(key) 是另一个散列函数,探测序列成: h2(key),2h2(key),3h2(key),...
- 对于任意的 key : h2(key)≠0
- 探测序列还应该保证所有的散列存储单元都应该能够被探测到
- 有良好效果的探测序列: h2(key)=p−(key mod p) , p<TableSize , p 、 TablseSize 都是素数
再散列(ReHashing)
- 当散列表元素太多(即装填因子
a
太大)时,查找效率会下降
- 实用最大装填因子一般取 0.5≤a≤0.85
- 当装填因子过大时,解决的方法是加倍扩大散列表,这个过程叫“再散列”
开放地址法实现
创建开放地址法的散列表
#define MAXTABLESIZE 100000 /* 允许开辟的最大散列表长度 */
typedef int ElementType; /* 关键词类型用整型 */
typedef int Index; /* 散列地址类型 */
typedef Index Position; /* 数据所在位置与散列地址是同一类型 */
/* 散列单元状态类型,分别对应:有合法元素、空单元、有已删除元素 */
typedef enum { Legitimate, Empty, Deleted } EntryType;
typedef struct HashEntry Cell; /* 散列表单元类型 */
struct HashEntry {
ElementType Data; /* 存放元素 */
EntryType Info; /* 单元状态 */
};
typedef struct TblNode *HashTable; /* 散列表类型 */
struct TblNode { /* 散列表结点定义 */
int TableSize; /* 表的最大长度 */
Cell *Cells; /* 存放散列单元数据的数组 */
};
int NextPrime( int N ) { /* 返回大于N且不超过MAXTABLESIZE的最小素数 */
int i, p = (N%2)? N+2 : N+1; /*从大于N的下一个奇数开始 */
while( p <= MAXTABLESIZE ) {
for( i=(int)sqrt(p); i>2; i-- )
if ( !(p%i) ) break; /* p不是素数 */
if ( i==2 ) break; /* for正常结束,说明p是素数 */
else p += 2; /* 否则试探下一个奇数 */
}
return p;
}
HashTable CreateTable( int TableSize ) {
HashTable H;
int i;
H = (HashTable)malloc(sizeof(struct TblNode));
/* 保证散列表最大长度是素数 */
H->TableSize = NextPrime(TableSize);
/* 声明单元数组 */
H->Cells = (Cell *)malloc(H->TableSize*sizeof(Cell));
/* 初始化单元状态为“空单元” */
for( i=0; i<H->TableSize; i++ )
H->Cells[i].Info = Empty;
return H;
}
注:在开发地址散列表中,删除操作要很小心。通常只能“懒惰删除”,即需要增加一个“删除标记(Deleted)”,而不是真正的删除它。以便查找是不会“断链”。其空间可以在下次插入时重用
平方探测法的查找与插入
Position Find( HashTable H, ElementType Key ) {
Position CurrentPos, NewPos;
int CNum = 0; /* 记录冲突次数 */
NewPos = CurrentPos = Hash( Key, H->TableSize ); /* 初始散列位置 */
/* 当该位置的单元非空,并且不是要找的元素时,发生冲突 */
while( H->Cells[NewPos].Info!=Empty && H->Cells[NewPos].Data!=Key ) {
/* 字符串类型的关键词需要 strcmp 函数!! */
/* 统计1次冲突,并判断奇偶次 */
if( ++CNum%2 ){ /* 奇数次冲突 */
NewPos = CurrentPos + (CNum+1)*(CNum+1)/4; /* 增量为+[(CNum+1)/2]^2 */
if ( NewPos >= H->TableSize )
NewPos = NewPos % H->TableSize; /* 调整为合法地址 */
}
else { /* 偶数次冲突 */
NewPos = CurrentPos - CNum*CNum/4; /* 增量为-(CNum/2)^2 */
while( NewPos < 0 )
NewPos += H->TableSize; /* 调整为合法地址 */
}
}
return NewPos; /* 此时NewPos或者是Key的位置,或者是一个空单元的位置(表示找不到)*/
}
bool Insert( HashTable H, ElementType Key ) {
Position Pos = Find( H, Key ); /* 先检查Key是否已经存在 */
if( H->Cells[Pos].Info != Legitimate ) { /* 如果这个单元没有被占,说明Key可以插入在此 */
H->Cells[Pos].Info = Legitimate;
H->Cells[Pos].Data = Key;
/*字符串类型的关键词需要 strcpy 函数!! */
return true;
}
else {
printf("键值已存在");
return false;
}
}
分离链接法(Separate Chaining)
原理
将相应位置上冲突的所有关键词存储在同一个单链表中
例
设关键词序列为 {47,7,29,11,16,92,22,8,3,50,37,89,94,21} ,用分离链接法处理冲突,列出依次插入后的散列表,并估算 ASLs
- 散列函数为: h(key)=key mod 11
插入后的散列表
- 表中有9个结点只需1次查找
- 5个结点需要2次查找
- 查找成功的平均查找次数: ASLs=(9+5∗2)/14=19/14≈1.36
实现
#define KEYLENGTH 15 /* 关键词字符串的最大长度 */
typedef char ElementType[KEYLENGTH+1]; /* 关键词类型用字符串 */
typedef int Index; /* 散列地址类型 */
/******** 以下是单链表的定义 ********/
typedef struct LNode *PtrToLNode;
struct LNode {
ElementType Data;
PtrToLNode Next;
};
typedef PtrToLNode Position;
typedef PtrToLNode List;
/******** 以上是单链表的定义 ********/
typedef struct TblNode *HashTable; /* 散列表类型 */
struct TblNode { /* 散列表结点定义 */
int TableSize; /* 表的最大长度 */
List Heads; /* 指向链表头结点的数组 */
};
HashTable CreateTable( int TableSize )
{
HashTable H;
int i;
H = (HashTable)malloc(sizeof(struct TblNode));
/* 保证散列表最大长度是素数,具体见代码5.3 */
H->TableSize = NextPrime(TableSize);
/* 以下分配链表头结点数组 */
H->Heads = (List)malloc(H->TableSize*sizeof(struct LNode));
/* 初始化表头结点 */
for( i=0; i<H->TableSize; i++ ) {
H->Heads[i].Data[0] = '\0';
H->Heads[i].Next = NULL;
}
return H;
}
Position Find( HashTable H, ElementType Key )
{
Position P;
Index Pos;
Pos = Hash( Key, H->TableSize ); /* 初始散列位置 */
P = H->Heads[Pos].Next; /* 从该链表的第1个结点开始 */
/* 当未到表尾,并且Key未找到时 */
while( P && strcmp(P->Data, Key) )
P = P->Next;
return P; /* 此时P或者指向找到的结点,或者为NULL */
}
bool Insert( HashTable H, ElementType Key )
{
Position P, NewCell;
Index Pos;
P = Find( H, Key );
if ( !P ) { /* 关键词未找到,可以插入 */
NewCell = (Position)malloc(sizeof(struct LNode));
strcpy(NewCell->Data, Key);
Pos = Hash( Key, H->TableSize ); /* 初始散列位置 */
/* 将NewCell插入为H->Heads[Pos]链表的第1个结点 */
NewCell->Next = H->Heads[Pos].Next;
H->Heads[Pos].Next = NewCell;
return true;
}
else { /* 关键词已存在 */
printf("键值已存在");
return false;
}
}
void DestroyTable( HashTable H )
{
int i;
Position P, Tmp;
/* 释放每个链表的结点 */
for( i=0; i<H->TableSize; i++ ) {
P = H->Heads[i].Next;
while( P ) {
Tmp = P->Next;
free( P );
P = Tmp;
}
}
free( H->Heads ); /* 释放头结点数组 */
free( H ); /* 释放散列表结点 */
}
散列表的性能分析
- 平均查找长度(ASL)用来度量散列表查找效率:成功、不成功
- 关键词的比较次数,取决于产生冲突的多少,影响产生冲突多少有以下三个因素:
- 散列函数是否均匀
- 处理冲突的方法
- 散列表的装填因子 a
线性探测法的查找性能
可以证明,线性探测法的期望探测次数满足下列公式:
当 a=0.5 时
- 插入成功和不成功查找的期望 ASLu=0.5∗(1+1/(1−0.5)2)=2.5 次
- 成功查找的期望 ASLs=0.5∗(1+1/(1−0.5))=1.5 次
注:当采用线性探测冲突解决策略时,非空且有空闲空间的散列表中无论有多少元素,不成功情况下的期望查找次数总是大于成功情况下的期望查找次数
例
a=9/13=0.69 ,于是
- 期望 ASLu=0.5∗(1+1/(1−0.69)2)=5.70 次
- 期望 ASLs=0.5∗(1+1/(1−0.69))=2.11 次(实际计算 ASLs=2.56 )
平方探测法和双散列探测法的查找性能
可以证明,平方探测法和双散列探测法的探测次数满足下列公式:
当 a=0.5 时
- 插入成功和不成功查找的期望 ASLu=1/(1−0.5)=2 次
- 成功查找的期望 ASLs=−1/0.5∗ln(1−0.5)≈1.39 次
例
a=9/11=0.82 ,于是
- 期望 ASLu=1/(1−0.82)≈5.56 次
- 期望 ASLs=−1/0.82∗ln(1−0.82)≈2.09 次(实际计算 ASLs=2 )
期望探测次数与装填因子 a 的关系
- 当装填因子 a<0.5 的时候,各种探测法的期望探测次数都不大,也比较接近
- 随着 a 的增大,线性探测法的期望探测次数增加比较快,不成功查找和插入操作的期望探测次数比成功查找的期望探测次数要大
- 合理的最大装入因子 a 应该不超过 0.85
分离链接法的查找性能
所有地址链表的平均长度定义成装填因子 a , a 有可能超过 1 ,其期望探测次数P为:
当 a=1 时
- 插入成功和不成功查找的期望 ASLu=1+e−1=1.37 次
- 成功查找的期望 ASLs=1+1/2=1.5 次
例
a=14/11≈1.27 ,于是
- 期望 ASLu=1.27+e1.27≈1.55 次
- 期望 ASLs=1+1.27/2≈1.64 次(实际计算 ASLs=1.36 )
散列查找性能总结
- 优点:选择合适的 h(key) ,散列法的查找效率期望是常数 O(1) ,它几乎与关键字的空间大小 n 无关。也适用于关键字直接比较计算量大的问题
- 它是以较小的 a 为前提,因此散列方法是一个以空间换时间
- 缺点:散列方法的存储对关键字是随机的,不便于顺序查找关键字,也不适合于范围查找,或最大值最小值查找
开发地址法
- 优点:散列表是一个数组,存储效率高,随机查找
- 缺点:散列表有“聚集”现象
分离链接法
- 散列表是顺序存储和链式存储的结合,链表部分的存储效率和查找效率都比较低
- 优点:关键字删除不需要“懒惰删除”法,从而没有存储“垃圾”
- 缺点:太小的 a 可能导致空间浪费,大的 a 又将付出更多的时间代价。不均匀的链表长度导致时间效率的严重下降