《算法与数据结构 C语言描述》第六章 集合与字典
集合与字典是两种常用的数据结构,应用非常广泛
字典是关联的集合。集合主要考虑集合之间的并、交和差操作,字典主要关心其元素的检索、插入和删除
6.1 集合及其抽象数据类型
集合是数学中最基本的概念,也是一种基本数据结构
6.1.1 基本概念
集合是一些互不相同元素的无序汇集。这些元素称为该集合的成员。集合的大小指集合中所包含的所愿的个数
不包含任何元素的集合称为空集。
6.1.2 主要运算
并集:两个集合的所有元素形成的集合。
交集:两个集合的同时存在的元素形成的集合
子集:一个集合是另一个集合的子集,如果该集合的所有元素都出现在另一个集合中,另一个集合则是该集合的超集
相等:两盒集合互为子集,则这两个集合相等
6.1.3 抽象数据类型
集合的抽象数据类型如下:
ADT Set is
Operations
// 创建一个空的集合
Set createEmptySet(void)
// 求x是否为集合的成员
int member(Set A, DataType x)
// 插入x作为集合的成员
int insert(Set A, DataType x)
// 删除成员
int delete(Set A, DataType x);
// 并集
int intersection(Set A, Set B, Set C);
// 差集
int difference(Set A, Set B, Set C);
// 判断A是否为B的子集
int subSet(Set A, Set B);
end ADT Set
6.2 集合的实现
6.2.1 集合的位向量表示
存储结构
位向量是一种每个元素都是二进制位(0/1)的数组。当表示的集合存在某个不太大的公共超集时,采用位向量方式来表示这种集合往往十分有效
在位向量里面
位向量的存储结构如下:
typedef struct {
int size; // 字符数组的长度
char *array; // 位向量空间,每一数组元素保存8位
} BitSet;
位向量表示集合时,所占用空间的大小与公共超集的大小n成正比,而与要表示的集合大小无关。
算法的实现
BitSet *createEmptySet(int n) {
int i;
BitSet *s = (BitSet *) malloc(sizeof(BitSet));
if (s != NULL) {
s->size = (n +7) / 8;
s->array = (char *) malloc(s->size * sizeof(char));
if (s->array != NULL) {
for (i = 0; i <s->szie; i++) {
s->array[i] = '\0';
}
return s;
}
}
return NULL;
}
将整数index的元素插入集合:
int insert(BitSet *s, int index) {
if (index >= 0 && index >> 3 < s->size) {
s->array[index >> 3] |= (1 << (index & 07));
return 1;
}
return 0;
}
将整数index 的元素从集合中删除:
int delete(BitSet *s, int index) {
if (index >= 0 && index >> 3 < s->size) {
s->array[index >> 3] &= ~(1 << (index & 07));
return 1;
}
return 0;
}
判断是否为属于集合
int member(BitSet *s, int index) {
if (index >= 0 && index >> 3 < s->size
&& (s->array[index >> 3] & (1 << (index & 07)))) {
return 1;
}
}
集合的并:
int union(BitSet *s0, BitSet *s1, BitSet *s2) {
int i;
// 判断位向量长度是否相同,不相同无法比较
if (s->size != s1->size || s2->size != s1->size) {
return 0;s
}
for (i = 0; i < s1->size; i++) {
s2->array[i] = s0->array[i] | s1->array[i];
}
return 1;
}
集合与集合的交:
int intersection(BitSet *s0, BitSet *s1, BitSet *s2) {
int i;
if (s0->size != s1->size || s2->size != s1->size) {
return 0;
}
for (i = 0; i < s1->size; i++) {
s2->array[i] = s0->array[i] & s1->array[i];
}
return 1;
}
集合与集合的差:
int difference(BitSet *s0, BitSet *s1, BitSet *s2) {
int i;
if (s0->size != s1->size || s2->size != s1->size) {
return 0;
}
for (i = 0; i < s1->size; i++) {
s2->array[i] = s0->array[i] & ~ s1->array[i];
}
return 1;
}
6.2.2 集合的单链表表示
存储结构
用链表凡是来表示集合时,链表中的每个结点表示集合中的一个元素。与单链表的结点有点类似,不同之处在于,线性表的单链表中,link字段表示线性表元素之间的逻辑后继关系。
结构体如下:
typedef struct Node {
DataType info;
struct Node *link;
} Node;
typedef Node *LinkSet;
算法实现
集合的交集:
int intersectionLink(LinkSet s0, LinkSet s1, LinkSet s2) {
Node *x;
if (s0 == NULL || s1 = NULL || s2 == NULL) {
printf("no head node error");
return 0;
}
s2->link = NULL;
s0 = s0->link;
s1 = s1->link;
while (s0 != NULL && s1 != NULL) {
if (s0->info > s1->info) {
s1 = s1->link;
} else if (s0->info > s1->info) {
s0 = s0->link;
} else if (s0->info == s1->info) {
x = (Node *) malloc(sizeof(Node));
if (x == NULL) {
printf("out of space");
return 0;
}
x->info = s0->info;
x->link = NULL;
s2->link = x;
// 指针往后移
s0 = s0->link;
s1 = s1->link;
s2 = s2->link;
}
}
return 1;
}
集合赋值
int assignLink(LinkSet s0, LinkSet s1) {
Node *x;
if (s0 == NULL || s1 == NULL) {
printf("no head node error");
return 0;
}
s0->link = NULL;
s1 = s1->link;
while (s1 != NULL) {
x = (Node *) malloc(sizeof(Node));
if (x == NULL) {
pintf("out of space");
return 0;
}
x->info = s1->info;
x->link = NULL;
s0->link = x;
s1 =s1->link;
s0 = s0->link;
}
return 1;
}
有序链表表示的集合中的插入操作:
int insertLink(LinkSet s0, DataType x) {
Node *temp;
if (s0 == NULL) {
printf("no head node error");
return 0;
}
temp = (Node *)malloc(sizeof(Node));
while (s0->link != NULL) {
if(s0->link->info == x) {
printf("data already exit");
return 1;
} else if (s0->link->info < x) {
s0 = s0->link;
} else if (s0->link->info > x) {
temp->info = x;
temp->info = s0->link;
s0->link = temp;
return 1;
}
}
// 到最后也没有找到,直接插入到最后
if (s0->link == NULL) {
temp->info = x;
temp->link = s0->link;
s0->link = temp;
return 1;
}
}
6.3 字典及其抽象数据类型
6.3.1 基本概念
字典是一种集合,其中每个元素由两部分组成,分别称为关键码和属性(也成为值)。这种包含关键码和属性的二元组称作关键(Association)。
字典关心的最主要的是检索。
字典分为静态字典和动态字典
静态字典选择存储方法主要考虑检索效率以及空间利用效率。
动态字典主要存储方法的选择不仅要考虑存储效率和检索效率,还要考虑字典元素的插入、删除运算是否简便。
衡量一个字典检索算法效率的主要标准是检索过程中对关键码的平均比较次数,即平均检索长度ASL(Average Search Length),定义为:
ASL(n) = ∑pici, i = 1 to n
其中,n是字典中元素的个数,pi是查找第i个元素的概率,若不特别声明,一般假设每个元素的检索概率相同,即pi = 1/n,ci是找到第i个元素的比较次数。
6.3.2 抽象数据类型
字典的抽象数据结构如下:
ADT Dictionary is
Operations
// 创建一个空字典
Dictionary createEmptyDictionary(void)
// 在字典dic中检索关键码为key的关联的位置p
int search(Dictionary dic, DicElememt ele)
// 删除关键码为key的关联
int delete(Dictionary dic, KeyType key)
end ADT Disctionary
6.4 字典的顺序表示
6.4.1 存储结构
从逻辑结构出发,字典中的元素是无序的,但为了实现的方便,可以把字典中的元素按关键码的大小组织成一个顺序表。
顺序表的定义如下:
typedef int KeyType;
typedef int DataType;
typedef struct {
KeyType key;
DataType value;
} DicElement;
typedef struct {
int MAXNUM;
int n;
DicElement *element;
} SeqDictionary;
6.4.2 算法的实现
顺序检索算法:
int seqSearch(SeqDictionary *dic, KeyType key, int *position) {
int i;
for (i = 0; i < dic->n; i++) {
if (dic->element[i].key == key) {
*position = i;
return 1;
}
}
*position = dic->n;
return 0;
}
检索平均检索长度为:
ASL = 1 x P1 + 2 x P2 + ... + n x Pn
假设每个元素的检索概率相等,即Pi = 1/n,则平均检索长度为:
ASL = ∑pici = ∑(i/n) = (n + 1) / 2;
因此,成功检索的平均比较次数约为字典长度的一半。
6.4.3 有序顺序表与二分法检索
另一种提高检索效率的方法是将字典元素按关键码递增(递减)的顺序排列,这种按照关键码大小排序的顺序表称作有序顺序表。
基本思想
二分法检索又称折半检索,是一种效率较高的检索方法,使用这种方法检索时,要求字典的元素在顺序表中按关键码排序。
二分法检索算法
int binarySearch(SeqDictionary *dic, KeyType key, int *position) {
int low, mid, high;
low = 0;
high = dic->n-1;
while (low <= high) {
mid = (low + high) / 2;
if (dic->element[mid].key == key) {
*position = mid;
return 1;
} else if (dic->element[mid].key > key) {
high = mid - 1;
} else {
low = mid + 1;
}
}
*position = low;
return 0;
}
6.5 字典的散列表示
6.5.1 基本概念
散列法(hashing)又称杂凑法或者关键码-地址转换法。选择一个从关键码到地址的映射函数h(称为散列函数),对于每个关键码为key的元素,计算出h(key),期望把对应的元素存储到h(key)指示的地址(称为散列地址)上。如果两个不相等的关键码key1 和 key2,用散列函数h计算得到相同的散列地址,这种现象叫作碰撞。发生碰撞的两个(或多个)关键码称为同义词(相对于函数h而言),为了实现散列法必须解决同义词的存储问题。
6.5.2 散列函数
散列函数也称为杂凑函数。要提高散列表的检索效率,散列函数的选取是关键。但是由于不同的字典具有不同的关键码集合,所以不存在一种普遍使用的最佳散列函数。构造散列函数,需要保证计算出的地址尽可能均匀地分布在希望的地址空间中。同时为了提高关键码到地址的转换速度,散列函数应该尽可能简单。由于要求关键码能够唯一标识元素,所以通常定义在一个很大的表示空间上。常用的散列函数如下:
数字分析法
关键码位数比基本去的地址码位数多,这是可以对关键码的各位进行分析,丢掉分布不均匀的位,留下均匀地位作为地址。
例如,对下列关键码集合设计散列函数。关键码是9位的。地址码只有3位,需要经过数字分析去掉6位:
key | h(key) |
---|---|
000 3194 26 | 326 |
000 7183 09 | 709 |
000 6294 43 | 643 |
000 7586 15 | 715 |
000 9196 97 | 997 |
000 3103 29 | 329 |
分析这6个关键码,前三位都是0,不均匀,应该丢掉。第五位4个1,不均匀,第六、七位也不太均匀,都应该丢掉。因此修改第四、八、九位作为地址。
数字分析法的缺点是散列函数过分依赖于关键码集合,因此通常适合于静态的字典。
折叠法
如果关键码的位数比地址码位数多,且各位分布较均匀,不适合于数字分析法,则可以考虑折叠法。折叠法将关键码从某些地方断开,分为几部分,其中最大部分的长度等于地址码的长度,再加上其余部分,舍弃进位。
折叠法例子
中平方法
先求出关键码的平方,然后取中间几位作为地址
基数转换法
把关键码看作是另一个进制的表示,然后再转换成原来进制的数,最后用数字分析法取其中几位作为散列地址。一般转换基数大于原基数,且两个基数最好互素。
除余法
选择一个适当的正整数P,用P去除关键码,余数作为散列地址,即 h(key) = key % P。这个方法的关键是选取适当的P。如果P为偶数,则总是把奇数的关键码转换为奇数地址,把偶数的关键码转换为偶数地址。但一般不这么取。一般P为不大于基本区域长度m的最大素数比较好。
对于动态字典的关键码,没有规律出现的情况,经常采用除余法。
6.5.3 碰撞的处理
设给定一个元素,其关键码为key,首先使用选择的散列函数h,计算出散列地址h(key)。当以 h(key) 为地址的空间未被占用时,如果执行插入,则元素存入该存储空间;如果执行检索,则说明检索失败。当以h(key)为地址的空间已经存放了元素,如果执行插入,则表明发生碰撞,需要根据处理碰撞方法去处理。如果执行检索,则还需要检查已经存放的元素是否需要检索的元素,如果不是,还不能肯定检索失败,还需要根据处理碰撞的方法去检索它的同义词中是否存在检索的元素。
散列法步进要选择适当的散列函数,还要解决碰撞问题,即如何处理同义词。通常有两种方法 —— 开地址法和拉链法。
开地址法
在基本区域形成一个同义词的探查序列,沿着探查序列逐个查找,知道找到查找的元素或碰到一个未被占用的地址为止。若检索元素,则需要碰到空的地址单元后,才能说明表中没有待查的元素(检索失败)。
采用开地址法解决碰撞,是在基本区域内存放同义词,负载因子必须小于1,否则一定有元素无处存放。负载因此越小,碰撞可能性越小,查找速度越快,当然空间开销越大。
形成探查序列的方法有多种,下面先介绍线性探索法。
线性探索法
产生探查序列最简单的方法是线性探索法,即将基本存储区域看做一个循环表。若在地址为d = h(key) 的单元发生碰撞,则依次探查下述地址单元:
d+1, d+2, ..., m-1, 0, 1, ..., d-1 m为基本存储区域的长度
直到找到一个空单元或者查找到关键码为key的元素为止。如果从单元d开始探查,查找一遍后,又回到地址d,则表示在基本存储区域已经溢出。
线性探索法的例子如下:
线性探索法例子
存储结构:
为了实现开地址法解决碰撞的散列表,可以设计下面的Hash Dictionary 类型:
typedef int KeyType;
typedef int DataType;
typedef struct {
KeyType key; // 字典元素的关键码字段
DataType value; // 字典元素的属性字段
} DicElement;
typedef struct {
int m; // 基本区域长度
DicElement *element;
} HashDictionary;
其中m为基本区域长度。DicElement是字典中元素类型。在存储结构定义中,没有引入MAXNUM的成员,原因是这种散列结构在开始创建时,需要充分估计它需要的基本区域大小。也就是说m的值是不能再插入和删除过程中随便修改的。
算法
- 创建空散列表
HashDictionary *createEmptyDictionary(int m) {
HashDictionary *phd = (HashDictionary *) malloc(sizeof(HashDictionary) * m);
if (phd != NULL) {
phd->element = (DicElement *) malloc(sizeof(DicElement) * m);
if (phd->element) {
phd->m = m;
for (int i = 0; i < m; i ++) {
phd->element[i] = 0;
}
return phd;
}
}
printf("Out of space!\n");
return NULL;
}
- 散列表的检索算法 —— 用线性探查法解决碰撞
int linearSearch(HashDictionary *phash, KeyType key, int *position) {
int d, inc;
d = hash(key); // d为散列地址,散列函数为 hash(key)
for (inc =0; inc < phash->m; inc++) {
if (phash->element[d].key == key) {
*position = d;
return 1;
} else if (phash->element[d].key == 0) {
*position = d;
return 0;
}
d = (d + 1) % phash->m;
}
*position = -1; // 散列表溢出
return -1;
}
在插入算法中,首先调用检索法,如果检索失败,在根据提供的位置信息将元素ele插入。
- 散列表的插入算法 —— 用线性探查法解决碰撞
int lineatInsert(HashDictionary * phash, DicElement ele) {
int position;
int result = lineatSearch(phash, ele.key, &position);
if (result == 1) {
printf("Find\n");
} else if (position != -1) {
phash->element[position] = ele; // 插入结点
} else {
return 0;
}
return 1;
}
注意,由于散列函数的选择和碰撞的处理都与基本区域的大小密切相关,所以如果在散列表溢出时,简单扩大基本区域的大小,直接把原来的散列表中数据复制到新的散列空间,将会破坏散列表中原来同义词的关系。因此,在创建一个空散列表是,应该估计到表中最大的元素个数
关于堆积的问题
在上例中,h(32) = 6, h(5) = 5, 32 和5 不是同义词,但是在解决 5 与 18 的碰撞时,5已经存入element[6],是的在插入32时,32与5本来不冲突的两个非同义词之间发生了碰撞。用线性探查法解决碰撞时,两个同义词子表结合再一起的现象称为堆积。即出现不是同义词的结点处在同一个探查序列中,增加了探查序列的长度。
为了减少堆积的产生,可以改进线性探查法,使探查序列跳跃式地分布在表中,下面介绍的双散列函数法可以提供参考。
根本地减少堆积现象的方法是适当增加基本区域的空间(即减少负载因子),因为如果把负载因子设计为1,那么插入最后一个元素时,可能要把原来已经存储在表中的元素探索一遍后,才能找到最后的位置,这样检索它就需要O(n)的时间。
关于结点的删除
需要注意的是,用开地址法构造散列表,删除结点时,不能简单地将要删除的结点空间置为初始状态0,因为各种开地址法中,为0的关键码检索是失败的依据,这样删除一个结点会破坏已经建立的同义词链接关系,从而印象其他结点的检索。因此,只能在被删除结点上做标记,不能真正删除。这样,做了许多次删除后,形式上散列表是满的,但实际上却很空,为了重新利用这些已经删除的结点空间,一种改进方法是,插入结点是利用做了删除标记的地址空间。
双散列函数法
双散列函数法要选择两个散列函数h1 和 h2,它们均以关键码为自变量, h1 产生一个 0 ~ m-1之间的数作为散列地址。h2产生一个1到m-1之间的并和m互素的数作为对地址的增量。要求产生的数与m互素是为了使得到的存储地址探查序列能够分布在整个基本区域内。
拉链法
设基本区域长度为m,使用拉链发需要建立m条链表,所有散列地址相同的元素存放在同一条链表中,如果某个地址中没有存放任何元素,则对应的链表为空链表。设给定字典元素的关键码为key,首先根据散列函数h计算出h(key),即确定是在第h(key)条链表中,然后在该链表中进行插入、删除及检索操作。如下图所示:
用拉链法解决碰撞
6.5.4 散列文件
与内存不同,对外存储器上的数据存取不能按字(字节)进行。要读外存上的数据,首先要把外存上一个物理记录(又称为页块)读到内存的缓冲区。然后在缓冲区中找到需要的逻辑记录进行处理。写的过程相反。
散列文件是一种存储在外存的大型字典的存储结构,它把前面介绍的散列表的思想用于外存之上。通过散列函数作用到记录的关键码,来确定记录在外存的存储地址,在处理同义词时,常常使用适合外存分块存取的拉链法。
常见的构造散列文件的方法 —— 按桶散列
基本思想
按桶散列的文件由若干桶和一个桶目录表构成。
一个桶由0个或多个外存的页块通过指针的链接构成。散列函数值相同的记录存放在同一个桶里。桶目录表是一些指针的序列,通常存储在内存中,每个指针指向一个桶的首页块。如下图所示:
按桶散列的文件
与内存散列表不同的是,内存拉链法的每个结点存储一个同义词,而按桶散列中每个结点是一个页块。
文件的操作
检索: 要查找关键码值为key的记录,首先计算h(key)。设h(key) = i,查阅桶目录表的第i项,得到第i个桶的首页块,在块上进行顺序查找。
插入: 要插入关键码为key的记录,先按上述方法进行查找。找到时,说明本次插入是错误的或多余的。若找不到,则此时读到内存中的页块恰好是新记录应插入的桶的最后一块。在这个页块的空位置上填入新记录,并写回外存。
桶数的选择和扩充
在设计按桶散列时,桶的多少要适当。太少时使桶中的页块较多,存取时增大访外次数。太多时造成浪费。桶的数目可以按下述经验的方法设定: 使桶数与文件所能填满的页块数大致相同。
散列文件在初建时,往往难以准确地估计其大小。随着文件的不断插入,可能发现各个桶中的页块太多了,检索效率严重下降。此时可以对桶数来一次扩充。