电子说
近日周立功教授公开了数年的心血之作《程序设计与数据结构》,电子版已无偿性分享到电子工程师与高校群体下载,经周立功教授授权,特对本书内容进行连载。
>>>> 1.1 哈希表
>>> 1.1.1 问题
假设需要设计一个信息管理系统,用于管理大约一万个学生的相关信息,可以通过学号查找到对应学生的信息,每条学生记录包含学号、姓名、性别、身高、体重等信息。即:
typedef struct _student{
unsigned char id[6]; // 学号(6字节)
char name[10]; // 姓名
char sex; // 性别
float height, weight; // 身高、体重
}student_t;作为信息管理系统,首先要能够存储学生记录,这上万条记录如何存储呢?简单地,可以使用一段连续的内存存储学生记录,比如,使用一个大数组存储,每个数组元素都可以存储一条学生记录:
student_t student_db[10000];
当使用数组存储学生信息时,如何通过学号查找相应的信息呢?如果学号编排是一种非常理想的情况,10000个学生的学号按照 0 ~ 9999顺序排列,则可以直接将学号作为数组的索引值查找相应的数组元素,其存储和查找的效率都非常高。但实际上学号往往不是如此简单编排的,一种常见的编排方法是“年级+专业代码+班级+班级内序号”,比如,6字节学号为0x20, 0x16, 0x44, 0x70, 0x02, 0x39,即:201644700239,表示2016年入学,专业代码为4470(即计算机专业),2班的39号同学。
此时,通过学号查找学生信息的方法也很简单,直接从第一个学生记录开始,顺序遍历各个学生记录,将记录中的学号与期望查找的学生学号相比较,学号相同即查找到了相应学生的信息,详见程序清单3.61。
程序清单3.61 顺序查找范例程序
1 student_t * student_search(unsigned char id[6])
2 {
3 for (int i = 0; i < 10000; i++) {
4 if (memcmp(student_db[i].id, id, 6) == 0) { // 比较
5 return &student_db[i]; // 找到该学生的信息
6 }
7 }
8 return NULL; // 未找到该学生的信息
9 }显然,如果采用顺序查找法,学生记录越多,则查找时需要比较的次数越多,效率也就越低。当学生记录的条数上万时,则可能需要比较上万次才能找到相应的学生信息。
如何以更高的效率实现查找呢?在理想情况下,若将学号作为数组索引存储数据,则查找的效率非常高。既然如此,如果扩大数组容量至学号的最大值加1(以包含学号0),则可以直接以学号作为数组的索引值。由于学号是由6字节组成的,因此数组必须能够容纳248条记录,需要占用多少存储空间呢?就算一条记录只占用一个字节,也需要262144 G存储空间,何况电脑硬盘没这么大!如果只使用其中的10000条记录,则剩下的(248-10000)空间就会造成极大的浪费,显然这种方式是不可取的。
在查找算法中,非常经典高效的算法是“二分法查找”,按10000条记录算,最多也只需要比较14次(log210000)。但使用“二分法查找”的前提是信息必须有序排列,即要求学生记录必须按照学号的顺序存储,这就导致在添加或删除学生信息时,数据库存储的信息需要进行大量的移动操作。比如,数组中已经按照学号从小到大的顺序存储了9999条记录,现在写入第10000条记录,若该记录的学号最小,需要写入到所有记录的前面,这就需要将之前存储的9999条记录全部向后移动一次,以预留出首元素的空间,然后将新的学生记录写入首元素对应的空间中。由此可见,虽然使用这种方法可以提高查找效率,却牺牲了添加信息时的效率。
为了在添加信息时不进行大量的数据移动,能否换一种存储方式呢?比如,使用存储空间不连续的“单向链表”结构,将各个学生记录“链”起来,其示意图详见图3.23。
图3.23 使用单向链表管理学生记录
当使用链表管理学生记录时,实现有序排列只需每次插入新结点时,找到正确的插入位置,无需进行大量数据的移动。由于存储空间不连续,因此无法使用“二分法”查找学生信息,则实现有序排列也没有解决查找效率低下的问题,无论是否有序,查找时都需要从头开始顺序查找。
由此可见,使用“二分法查找”必须牺牲记录写入的效率以实现所有记录有序排列,使得写入记录的效率非常低。虽然基础的“顺序查找”对写入记录的效率完全不影响,但查找效率极为低下。因此,这两种情况都太极端了,要么选择极低的写入效率,要么选择极低的查找效率。何不将二者结合一下,以折中写入的效率和查找的效率呢?比如,将记录“二分”为两部分,使用两个数组来存储:
student_t student_db0[5000];
student_t student_db1[5000];
假设规定,学号小于某值(即201044700239)时,记录存储在student_db0中,反之,记录存储在student_db1中。如此一来,在写入记录时,只需要多一条判断语句,对性能并没太大影响。而在查找时,只要根据学号判断记录在哪一个数组中,即可按照顺序查找的方式查找。此时,查找需要比较的次数就从最大的10000次降低到了5000次。由此可见,通过一个简单的方法,将信息分别存储在两个数组中,就可以明显地提高查找效率。为了继续提高查找的效率,还可以继续分组,比如,分成250组,每组的大小为40:
student_t student_db0[40];
student_t student_db1[40];
……
student_t student_db248[40];
student_t student_db249[40];
显然,采用这种定义方式太繁琐了,由于每个数组的大小是相同的,因此可以直接将存储40个学生记录的数组定义为一个类型:
typedef student_t student_group_t[40];
student_group_t student_db[250];
此时,每个分组的大小为40,从而使得查找记录时,最多只需要比较40次。接下来,需要定义分组规则,以通过学号找到该记录属于哪个组。在定义规则时,应尽可能地使所有记录平均地分布在各个组中,不应该出现一些组存储的记录非常多,而一些组存储的记录非常少的情况。但这并不是一件容易的事情,需要对学号的数据分布进行精确的分析。
如果分成250组,假定学号是均匀分布的,则可以将6字节学号数求和除以250(分组数目)所得的余数(取余法)作为分组的索引,由于写入和查找时,都需要通过学号找到该记录应该属于哪个组,因此可以根据学号分组的依据,编写一个通过学号找到对应分组索引的函数,详见程序清单3.62。
程序清单3.62 通过学号分组范例程序
1 int db_id_to_idx(unsigned char id[6])
2 {
3 int i, sum = 0;
4
5 for (i = 0; i < 6; i++) {
6 sum += id[0];
7 }
8 return sum % 250;
9 }即将分组数为250看作一个大小为250的表格,每个表项可以存储40个学生记录的数组,通过db_id_to_idx()函数找到关键字学号ID对应在该表中的位置。其中,大小为250的表格就是“哈希表”,详见图3.24。db_id_to_idx()函数就是“哈希函数”,哈希函数的结果(分组索引)称之为“哈希值”。
图3.24 哈希
哈希表的核心工作在于哈希函数的选择,将查找的关键字送给哈希函数产生一个哈希值,哈希函数的选择直接决定了记录的分布,必须尽可能地确保所有记录均匀地分布在各个组中。在上面的示例中,每个分组中都定义了大小相同的数组作为记录存储的空间。如果按照分组规则,能够确保恰好均匀地分布在各个分组中,这是最佳的。
而实际上学生记录是会变动的,可能增加或删除,则很难保证按照现在定义的分组规则,保证100%的完全平均。如果每个分组都使用大小相同的数组作为记录存储的空间,则可能会导致部分数组未存满,部分数组却存不下的情况,就会导致部分学生记录无处可存,造成严重的数据管理问题。
由于数组都是提前定义好大小的,动态性能差,而链表的动态性能更好,可以根据需要增加、删除结点,改变链表长度,因此可以使用链表管理学生记录,就算分布不均匀,也只存在链表长度的差异,不会出现数据存储不了的问题,其示意图详见图3.25。
图3.25 链式哈希表
当使用链表管理学生记录时,哈希表每个表项的实际内容就是该组链表的表头。链表头结点的类型slist_head_t(slist.h)的定义如下:
typedef struct _slist_node{
struct _slist_node *p_next; // 向下一个结点的指针
}slist_node_t;
typedef slist_node_t slist_head_t;基于此,在哈希表的每个表项中存储一个slist_head_t类型的链表头结点即可,哈希表的定义如下:
typedef slist_head_t student_group_t;
student_group_t student_db[250];
根据对链式哈希表结构的分析,编写一个基于链式哈希表的信息管理系统,作为示例仅提供增加、删除、查找三种功能。当然,在使用这些功能前,还必须定义一个哈希表对象的类型,以便使用该类型定义具体的哈希表实例,进而使用各个功能接口对该实例进行操作。
>>>> 1.1.2 哈希表的类型
哈希表类型struct _hash_db定义如下:
typedef struct _hash_db hash_db_t;
在结构体中,需要包含哪些哈希表的相关信息呢?链式哈希表的核心是一个slist_head_t类型的数组,其大小与分组数目相关。为了通用,分组数目应由用户根据实际情况确定。slist_head_t类型的数组信息由一个指向数组首地址的slist_head_t*类型的指针和一个指定数组大小的size构成,哈希表结构体类型的定义如下:
struct _hash_db{
slist_head_t *p_head; // 指向数组首地址
unsigned int size; // 数组成员数
};在实际的应用中,信息可以是任意数据类型(void *),其次还需要知道该void *指针指向的记录的长度,比如,学生记录的长度是sizeof(student_t),因此更新哈希表结构体类型的定义如下:
struct _hash_db{
slist_head_t *p_head; // 指向数组首地址
unsigned int size; // 数组成员数
unsigned int value_len; // 一条记录的长度
};在存储或查找记录时,可以通过与关键字(比如,学号ID)比较找到哈希表中的索引值,然后在对应的表项中添加或查找记录。在存储记录时,需要提供关键字和记录;而在查找记录时,仅需提供关键字。由此可见,关键字和记录是两个不同的概念,关键字具有特殊的作用,因此关键字和记录应该分别对待。对于学生信息管理系统来说,其关键字为学号,长度是6字节,记录包含姓名、性别、身高、体重等信息。因此,在学生记录结构体的定义中,将关键字ID分离出来。学生记录的定义如下:
typedef struct _student{
char name[10]; // 姓名
char sex; // 性别
float height, weight; // 身高、体重
}student_t;同理,关键字的长度也是由用户决定的,在存储一条记录时,需要分配内存存储关键字,以便查询时读取该关键字与查询使用的关键字进行比较。因此在哈希表的结构体类型中,需要包含关键字长度信息,更新哈希表结构体类型的定义如下:
struct _hash_db {
slist_head_t *p_head; // 指向数组首地址
unsigned int size; // 数组成员数
unsigned int value_len; // 一条记录的长度
unsigned int key_len; // 关键字的长度
};特别地,在前面的分析中,哈希表最重要的一个概念就是“哈希函数”,哈希函数的作用是通过关键字(如学号ID)得到其对应记录在哈希表中的索引值,哈希函数要尽可能确保记录均分地分布在哈希表的各个表项中。对于不同的数据,用户可能选择不同的哈希函数,因此哈希函数应该由用户指定。基于此,在哈希表结构体中新增一个函数指针,用于指向用户自定义的哈希函数。完整的哈希表结构体类型定义如下(hash_db.h):
typedef unsigned int (*hash_func_t) (const void *key); // 定义哈希函数类型
struct _hash_db {
slist_head_t *p_head; // 指向数组首地址
unsigned int size; // 数组大小
unsigned int value_len; // 一条记录的长度
unsigned int key_len; // 关键字的长度
hash_func_t pfn_hash; // 哈希函数
};在使用哈希表的各个接口函数前,首先需要使用该类型定义一个哈希表实例:
hash_db_t hash;
如果系统中需要使用多张哈希表,则只需要使用该类型定义多个哈希表实例即可:
hash_db_t hash1;
hash_db_t hash2;全部0条评论
快来发表一下你的评论吧 !