HASH 链与“航空公司VIP客户查询”题解

禁止码迷,布布扣,豌豆代理,码农教程,爱码网等第三方爬虫网站爬取!

哈希链

HASH 表

如果有一种查找方法,我们能一下子就知道其存储位置,并能进行快速地访问,那么时间复杂度就是 O(1),这样查找的效率就太高了。能够实现这种效果的查找成为散列,所谓散列技术就是数据保存的存储位置和关键字之间存在一个映射关系,我们称之为散列函数,这使得关键字可以和存储位置直接对应起来。通过散列技术将数据存储起来的所建成的存储结构称之为哈希表(Hash table)。

链地址法

链地址法建成的哈希表,很像树结构中的孩子表示法。它的存储手法是把具有相同散列地址的记录放在同一个单链表中,称之为同义词链表。由此可见哈希链是一种非常保险的做法,你也不用担心找不找得到空闲地址的问题了,只不过你需要承担单链表操作的开销。

这是一种安全的冲突处理方法,因此很有必要谈细一些。此时我们也用 ASL 度量一下现有的散列表。

ASL成功 = (1 × 5 + 2) / 6 = 7/6
ASL失败 = (1 × 4 + 2) / 8 = 3/4

结构体定义

HASH 链的结构体定义类似于邻接表,需要一个数组来表示主表,然后通过指针域指向散列地址相同的元素单链表。因此我们需要 2 个结构体,分别来表示这 2 部分。

单链表

typedef struct Node
{
    KeyType key;      //关键字域
    InfoType data;      //其他数据域
    struct Node* next;
}LinkList,*List;      //单链表

HASH 表

typedef struct HashList
{
    long long length;      //HASH 表规模
    List* list;      //HASH 表主体
}*Hash;      //HASH 表

建立 HASH 链

这个函数将初始化一个空的哈希链,并且返回其地址。

Hash createHash(int length)
{
    Hash H = new HashList;      //给哈希表申请空间

    H->length = length;
    H->list = new List[MAXSIZE];      //给每个单链表申请空间
    for (int i = 0; i < H->length; i++)      //初始化
    {
        H->list[i] = new Node;
        H->list[i]->next = NULL;
    }
    return H;
}

查找操作

对于 HASH 表而言,查找操作是其他操作的基础,因此就先来看看查找操作。这个操作本质上是对单链表的操作,通过散列函数得到散列地址之后,就去遍历对应的单链表进行查找即可。

List findKey(Hash H, KeyType key)
{
    int idx;      //存储计算散列函数得到的地址
    List ptr;      //指向 HASH 表对应地址的头结点

    idx = hash_Function(key, H->length);      //计算散列地址
    ptr = H->list[idx]->next;      //ptr 指向头结点
    while (ptr != NULL && ptr->key == key)
    {
        ptr = ptr->next;      //遍历顺序表,知道查找成功或失败
    }
    return ptr;
}

插入操作

插入操作需要借助查找操作实现,首先先查找一下 HASH 链中关键字是否已经存在。

bool Insert(Hash H,KeyType key, InfoType data)
{
    int idx;      //存储计算散列函数得到的地址
    List ptr;      //保存结点查找结果
    List new_node;      //新的空结点

    idx = hash_Function(key, H->length);      //计算散列地址
    ptr = findKey(H, key);      //查找关键字 key
    if (ptr == NULL)      //没找到
    {
        new_node = new Node;      //初始化新结点
        new_node->key = key;
        new_node->data = data;
        new_node->next = H->list[idx]->next;      //头插法插入
        H->list[idx]->next = new_node;
        return true;
    }
    return false;
}

实例:航空公司VIP客户查询

情景需求

输入样例

4 500
330106199010080419 499
110108198403100012 15000
120104195510156021 800
330106199010080419 1
4
120104195510156021
110108198403100012
330106199010080419
33010619901008041x

输出样例

800
15000
1000
No Info

实现思路

由于里程数和客户身份证是一一对应的,因此我们想到使用哈希链来存储数据。此时就面临着 2 个问题,首先是哈希链的建立,这个并不难,只需要把上述函数的数据类型进行修改,然后封装好调用就行。
第二个问题就是哈希函数的设计,由于身份证编码作为数字而言非常大,因此直接排除直接定值法。分析一下身份证号,发现在前面的部分比较容易出现相同的数字,这就说明如果用前面几位数字来构造的话,得到的散列地址不会太分散。所以我们选择差别较大的后面几位来实现,实践证明直接的除留余数法,取身份证号后 5 位来构造才能够使数据尽量分散,当然如果用其它方法来处理会有变化。这么分析下来,主体的框架已经有了。
下面来讲一下这里对数据的需求,此处对数据提出了 2 个需求。首先是若输入的里程数小于最低里程,那就要对输入的里程数进行修正。第二是若出现了重复的身份证号,那就是需要对数据进行更新,也需要查找到存储地址把里程数修正。
最后提一下这道题对性能的需求,此处对性能提出了时间上的需求。如果使用 C++ 关键字 cout 和 cin 来操作的话,就会出现超时问题。这个问题深究起来也不难理解,因为这 2 个关键字是对输入和输出的强化,可以适应包括 STL 库容器的输入输出,也自带清理缓冲区等性能。由于这种操作的功能更强大,因此就需要更多的时间来进行附加功能的实现,以及健壮性的处理,因此会耗费更多的时间。因此这里要使用 C 语言的 scanf() 和 printf() 函数来进行输入和输出,来满足时间上的需求。这一点我也有在另一篇博客——C++面向过程编程中提及过。

主函数

伪代码

代码实现

int main()
{
    char a_id[20];      //单个乘客的身份证号
    int a_mileage;      //单个记录的里程
    int min_mileage;      //最短里程
    int count,fre;
    Hash H;
    List ptr;      //接收查找结果

    scanf("%d%d", &count, &min_mileage);
    H = createHash(count);
    for (int i = 0; i < count; i++)
    {
        scanf("%s%d", a_id, &a_mileage);
        if (a_mileage < min_mileage)      //修正里程数
        {
            a_mileage = min_mileage;
        }
        Insert(H, a_id, a_mileage);
    }
    scanf("%d", &fre);
    for (int i = 0; i < fre; i++)
    {
        scanf("%s", a_id);
        ptr = findID(H, a_id);      //根据身份证查找里程
        if (ptr == NULL)
        {
            printf("No Info\n");
        }
        else
        {
            printf("%d\n", ptr->mileage);
        }
    }
    return 0;
}

散列函数

long long hash_Function(char key[], long long length)
{
    long long value = 0;

    for (int i = 13; i < 18; i++)      //提取身份证后 5 位
    {
        if (key[i] == 'x')
        {
            value = value * 10 + 10;
        }
        else
        {
            value = value * 10 + key[i] - '0';
        }
    }
    return value % length;      //除留余数法
}

调试遇到的问题


Q1:短程优惠数据没有处理。
A1:没有仔细看题目,忽略了“低于K公里的航班也按K公里累积”这一限制条件。在输入数据时,添加一个分支结构进行修正即可。
Q2:错误信息没有处理。
A2:这个主要来源于出现重复的身份证,此时我原来的写法仍然添加的新结点,这样就让数据无端增生了。出现出现重复的身份证时需要根据里程数进行累计,也就是我们没有很好地分析题意。修改的地方在插入函数,若结点已经存在就更新数据。
Q3:在数据处理层面超时。
A3:这个是在哈希函数的构建中,一开始我构造的函数的发散性很弱,无法很好地将散列地址分散开来。经过和同学的讨论,可以对身份证后 5 位,按照位数逐项做除留余数法,就可以满足这个需求。
Q4:在性能方面出现超时。
A4:找不到原因啊,后来想起了老师的提示,就注意到了 cout 和 cin 的效率问题。因为这 2 个关键字是对输入和输出的强化,可以适应包括 STL 库容器的输入输出,也自带清理缓冲区等性能。由于这种操作的功能更强大,因此就需要更多的时间来进行附加功能的实现,以及健壮性的处理,因此会耗费更多的时间。因此这里要使用 C 语言的 scanf() 和 printf() 函数来进行输入和输出,来满足时间上的需求。
Q5:scanf 对 string 类容器的处理能力有限。
A5:我很想吐槽这一点,因为我原来是使用 string 类来写的,由于身份证号有 “X” 字母,用 STL 来操作比较方便。但是 scanf() 无法直接输入到 string 类容器,这就逼得我把所有的容器全部改成 char[] 字符数组,那就是我要修改所有的函数接口和调用 <string.h> 库的函数来做,醉了。

技巧总结

  1. 哈希链的建立及其基础操作,由于这道个情景我使用哈希链来实现,因此如果这个结构建立失败,后面的任何操作都不用谈。因此在这里也可以熟练哈希链的操作,其实本质上就是链表的操作加上查找的知识。
  2. 散列函数的设计,对于散列函数来说,需要把散列地址尽量分散开。此时就需要对数据进行分析,例如这里的身份证,如果使用直接定址法显然不现实,由于前面几位数字比较有规律,因此用前面几位来构造的话无法很好地分散开。而选取随机性较大的后面几位数字来构造,就能够尽量把地址分散开来。
  3. 对于除留余数法而言,这种写法的健壮性很强。因为这种做法可以结合其他的手法,例如折叠法、平方取中法,也可以有按位进行操作。
  4. 性能方面,cout 和 cin 功能更强大,但是效率上还是 scanf() 和 printf() 函数会更快一些。如果对效率有需求的话,也只能换一种方式来处理。
  5. 虽然后面我被迫把 string 全部改为了字符数组,但是还是熟悉了 assign()compare() 方法,分别有复制和比较的功能。

参考资料

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社
C++面向过程编程
C++ string类(C++字符串)完全攻略

posted @ 2020-05-28 17:21  乌漆WhiteMoon  阅读(757)  评论(0编辑  收藏  举报