设计一个信息管理系统,你需要知道这些

电子说

1.3w人已加入

描述

近日周立功教授公开了数年的心血之作《程序设计与数据结构》,电子版已无偿性分享到电子工程师与高校群体下载,经周立功教授授权,特对本书内容进行连载。

>>>> 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;
打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分