cover

散列表篇

在之前,我们已经学习了多种查找数据的方式,比如最简单的,如果数据量不大的情况下,我们可以直接通过顺序查找的方式在集合中搜索我们想要的元素;当数据量较大时,我们可以使用二分搜索来快速找到我们想要的数据,不过需要要求数据按照顺序排列,并且不允许中途对集合进行修改。

在学习完树形结构篇之后,我们可以利用二叉查找树来建立一个便于我们查找的树形结构,甚至可以将其优化为平衡二叉树或是红黑树来进一步提升稳定性。在最后我们还了解了B树和B+树,得益于它们的巧妙设计,我们可以以尽可能少的时间快速找到我们需要的元素,大大提升程序的运行效率。

这些都能够极大地帮助我们查找数据,而散列表,则是我们查找系列内容的最后一块重要知识。

散列查找

我们之前认识的查找算法,最快可以达到对数阶 $O(logN)$,那么我们能否追求极致,让查找性能突破到常数阶呢?这里就要介绍到我们的散列(也可以叫哈希 Hash)它采用直接寻址的方式,在理想情况下,查找的时间复杂度可以达到常数阶 $O(1)$。

散列(Hashing)通过散列函数(哈希函数)将要参与检索的数据与散列值(哈希值)关联起来,生成一种便于搜索的数据结构,我们称其为散列表(哈希表),也就是说,现在我们需要将一堆数据保存起来,这些数据会通过哈希函数进行计算,得到与其对应的哈希值,当我们下次需要查找这些数据时,只需要再次计算哈希值就能快速找到对应的元素了:

image-20220818214145347

当然,如果一脸懵逼没关系,我们从哈希函数开始慢慢介绍。

散列函数

散列函数也叫哈希函数,哈希函数可以对一个目标计算出其对应的哈希值,并且,只要是同一个目标,无论计算多少次,得到的哈希值都是一样的结果,不同的目标计算出的结果介乎都不同。哈希函数在现实生活中应用十分广泛,比如很多下载网站都提供下载文件的MD5码校验,可以用来判别文件是否完整,哈希函数多种多样,目前应用最为广泛的是SHA-1和MD5,比如我们在下载IDEA之后,会看到有一个验证文件SHA-256校验和的选项,我们可以点进去看看:

image-20220818214908458

点进去之后,得到:

1
e54a026da11d05d9bb0172f4ef936ba2366f985b5424e7eecf9e9341804d65bf *ideaIU-2022.2.1.dmg

这一串由数字和小写字母随意组合的一个字符串,就是安装包文件通过哈希算法计算得到的结果,那么这个东西有什么用呢?我们的网络可能有时候会出现卡顿的情况,导致我们下载的文件可能会出现不完整的情况,因为哈希函数对同一个文件计算得到的结果是一样的,我们可以在本地使用同样的哈希函数去计算下载文件的哈希值,如果与官方一致,那么就说明是同一个文件,如果不一致,那么说明文件在传输过程中出现了损坏。

可见,哈希函数在这些地方就显得非常实用,在我们的生活中起了很大的作用,它也可以用于布隆过滤器和负载均衡等场景,这里不多做介绍了。

散列表

前面我们介绍了散列函数,我们知道可以通过散列函数计算一个目标的哈希值,那么这个哈希值计算出来有什么用呢,对我们的程序设计有什么意义呢?我们可以利用哈希值的特性,设计一张全新的表结构,这种表结构是专为哈希设立的,我们称其为哈希表(散列表)

image-20220818220944783

我们可以将这些元素保存到哈希表中,而保存的位置则与其对应的哈希值有关,哈希值是通过哈希函数计算得到的,我们只需要将对应元素的关键字(一般是整数)提供给哈希函数就可以进行计算了,一般比较简单的哈希函数就是取模操作,哈希表长度是多少(长度最好是一个素数),模就是多少:

image-20220819170355221

比如现在我们需要插入一个新的元素(关键字为17)到哈希表中:

image-20220819171430332

插入的位置为计算出来的哈希值,比如上面是8,那么就在下标位置8插入元素,同样的,我们继续插入27:

image-20220819210336314

这样,我们就可以将多种多样的数据保存到哈希表中了,注意保存的数据是无序的,因为我们也不清楚计算完哈希值最后会放到哪个位置。那么如果现在我们想要从哈希表中查找数据呢?比如我们现在需要查找哈希表中是否有14这个元素:

image-20220819211656628

同样的,直接去看哈希值对应位置上看看有没有这个元素,如果没有,那么就说明哈希表中没有这个元素。可以看到,哈希表在查找时只需要进行一次哈希函数计算就能直接找到对应元素的存储位置,效率极高。

我们可以通过代码来实现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#define SIZE 9

typedef struct Element { //这里用一个Element将值包装一下
int key; //这里元素设定为int
} * E;

typedef struct HashTable{ //这里把数组封装为一个哈希表
E * table;
} * HashTable;

int hash(int key){ //哈希函数
return key % SIZE;
}

void init(HashTable hashTable){ //初始化函数
hashTable->table = malloc(sizeof(struct Element) * SIZE);
for (int i = 0; i < SIZE; ++i)
hashTable->table[i] = NULL;
}

void insert(HashTable hashTable, E element){ //插入操作,为了方便就不考虑装满的情况了
int hashCode = hash(element->key); //首先计算元素的哈希值
hashTable->table[hashCode] = element; //对号入座
}

_Bool find(HashTable hashTable, int key){
int hashCode = hash(key); //首先计算元素的哈希值
if(!hashTable->table[hashCode]) return 0; //如果为NULL那就说明没有
return hashTable->table[hashCode]->key == key; //如果有,直接看是不是就完事
}

E create(int key){ //创建一个新的元素
E e = malloc(sizeof(struct Element));
e->key = key;
return e;
}

int main() {
struct HashTable hashTable;
init(&hashTable);
insert(&hashTable, create(10));
insert(&hashTable, create(7));
insert(&hashTable, create(13));
insert(&hashTable, create(29));

printf("%d\n", find(&hashTable, 1));
printf("%d\n", find(&hashTable, 13));
}

这样,我们就实现了一个简单的哈希表和哈希函数,通过哈希表,我们可以将数据的查找时间复杂度提升到常数阶。

哈希冲突

前面我介绍了哈希函数,通过哈希函数计算得到一个目标的哈希值,但是在某些情况下,哈希值可能会出现相同的情况:

image-20220819215004653

比如现在同时插入14和23这两个元素,他们两个计算出来的哈希值是一样的,都需要在5号下标位置插入,这时就出现了打架的情况,那么到底是把哪一个放进去呢?这种情况,我们称为哈希碰撞(哈希冲突)

这种问题是很严重的,因为哈希函数的设计不同,难免会出现这种情况,这种情况是不可避免的,我们只能通过使用更加高级的哈希函数来尽可能避免这种情况,但是无法完全避免。当然,如果要完全解决这种问题,我们还需要去寻找更好的方法。

线性探测法

既然有可能出现哈希值重复的情况,那么我们可以选择退让,不去进行争抢(忍一时风平浪静,退一步海阔天空)我们可以去找找哈希表中相邻的位置上有没有为空的,只要哈希表没装满,那么我们肯定是可以找到位置装下这个元素的,这种类型的解决方案我们统称为线性探测法,开放定址法包含,线性探测法、平方探测法、双散列法等,这里我们以线性探测法为例。

既然第一次发生了哈希冲突,那么我们就继续去找下一个空位:
$$
h_i(key) = (h(key) + d_i)\space % \space TableSize
$$
其中 $d_i$ 是随着哈希冲突次数增加随之增加的量,比如上面出现了一次哈希冲突,那么我就将其变成1表示发生了一次哈希冲突,然后我们可以继续去寻找下一个位置:

image-20220820112822005

出现哈希冲突时,$d_i$自增,继续寻找下一个空位:

image-20220820113020326

再次计算哈希值,成功得到对应的位置,注意 $d_i$ 默认为0,这样我们就可以解决冲突的情况了。

我们来通过代码实际使用一下,这里需要调整一下插入和查找操作的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void insert(HashTable hashTable, E element){   //插入操作,注意没考虑满的情况,各位小伙伴可以自己实现一下
int hashCode = hash(element->key), count = 0;
while (hashTable->table[hashCode]) { //如果发现哈希冲突,那么需要继续寻找
hashCode = hash(element->key + ++count);
}
hashTable->table[hashCode] = element; //对号入座
}

_Bool find(HashTable hashTable, int key){
int hashCode = hash(key), count = 0; //首先计算元素的哈希值
const int startIndex = hashCode; //记录一下起始位置,要是转一圈回来了得停
do {
if(hashTable->table[hashCode]->key == key) return 1; //如果找到就返回1
hashCode = hash(key + ++count);
} while (startIndex != hashCode && hashTable->table[hashCode]); //没找到继续找
return 0;
}

这样当出现哈希冲突时,会自动寻找补位插入:

1
2
3
4
5
6
7
8
9
10
11
int main() {
struct HashTable hashTable;
init(&hashTable);
for (int i = 0; i < 9; ++i) {
insert(&hashTable, create(i * 9));
}

for (int i = 0; i < 9; ++i) {
printf("%d ", hashTable.table[i]->key);
}
}

当然,如果采用这种方案删除会比较麻烦,因为有些元素可能是通过线性探测补到其他位置上的,如果删除元素,那么很有可能会影响到前面的查找操作:

image-20220820211324957

此时删除关键字为45的元素,会出现截断的情况,当下次查找时,会出现严重问题:

image-20220820214945139

可以看到,删除一个元素可能会导致原有的结构意外截断,无法正确找到对应的元素,所以,我们在删除元素时,为了防止出现这种截断的情况,我们需要对这个位置进行标记,表示之前有过元素,但是被删除了,当我们在查找时,如果发现曾经有过元素,依然需要继续向后寻找:

image-20220820215613368

代码实现有点麻烦,这里就不编写代码了。

当然除了直接向后进行探测之外,我们也可以采用二次探测再散列法处理哈希冲突,因为有些时候可能刚好后面没有空位了,但是前面有,如果按照之前的方法,我们得转一圈回来才能找到对应的位置,实在是有点浪费时间,所以说我们可以左右开弓,同时向两个方向去寻找。

它的查找增量序列为:$1^2$、$-1^2$、$2^2$、$-2^2$、…、$q^2$、$-q^2$,其中$q <= \lfloor {TableSize\div2} \rfloor$,比如现在我们要向下面的哈希表中插入数据,现在插入关键字为24的元素,发现冲突了:

image-20220821214600725

那么此时就需要进行处理了,这里我们采用上面的方式,先去寻找 $1^2$ 位置:

image-20220821214751809

我们接着来插入:

image-20220821215445041

实际上我们发现和之前是一样的,只要冲突就一直往下找就完事,只不过现在是左右横跳着找,这样可以进一步提升利用率。

链地址法

实际上常见的哈希冲突解决方案是链地址法,当出现哈希冲突时,我们依然将其保存在对应的位置上,我们可以将其连接为一个链表的形式:

image-20230814161152299

当表中元素变多时,差不多就变成了这样,我们一般将其横过来看:

image-20230814161208801

通过结合链表的形式,哈希冲突问题就可以得到解决了,但是同时也会出现一定的查找开销,因为现在有了链表,我们得挨个往后看才能找到,当链表变得很长时,查找效率也会变低,此时我们可以考虑结合其他的数据结构来提升效率。比如当链表长度达到8时,自动转换为一棵平衡二叉树或是红黑树,这样就可以在一定程度上缓解查找的压力了。

我们来编写代码尝试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define SIZE 9

typedef struct ListNode { //结点定义
int key;
struct ListNode * next;
} * Node;

typedef struct HashTable{ //哈希表
struct ListNode * table; //这个数组专门保存头结点
} * HashTable;

void init(HashTable hashTable){
hashTable->table = malloc(sizeof(struct ListNode) * SIZE);
for (int i = 0; i < SIZE; ++i) {
hashTable->table[i].key = -1; //将头结点key置为-1,next指向NULL
hashTable->table[i].next = NULL;
}
}

int main(){
struct HashTable table; //创建哈希表
init(&table);
}

接着是编写对应的插入操作,插入后直接往链表后面丢就完事了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int hash(int key){   //哈希函数
return key % SIZE;
}

Node createNode(int key){ //创建结点专用函数
Node node = malloc(sizeof(struct ListNode));
node->key = key;
node->next = NULL;
return node;
}

void insert(HashTable hashTable, int key){
int hashCode = hash(key);
Node head = hashTable->table + hashCode; //先计算哈希值,找到位置后直接往链表后面插入结点就完事了
while (head->next) head = head->next;
head->next = createNode(key); //插入新的结点
}

同样的,查找的话也是直接找到对应位置,看看链表里面有没有就行:

1
2
3
4
5
6
7
_Bool find(HashTable hashTable, int key){
int hashCode = hash(key);
Node head = hashTable->table + hashCode;
while (head->next && head->key != key) //直到最后或是找到为止
head = head->next;
return head->key == key; //直接返回是否找到
}

我们来测试一下吧:

1
2
3
4
5
6
7
8
9
10
11
12
int main(){
struct HashTable table;
init(&table);

insert(&table, 10);
insert(&table, 19);
insert(&table, 20);

printf("%d\n", find(&table, 20));
printf("%d\n", find(&table, 17));
printf("%d\n", find(&table, 19));
}

实际上这种方案代码写起来也会更简单,使用也更方便一些。

散列表习题:

  1. 下面关于哈希查找的说法,正确的是( )

    A 哈希函数构造的越复杂越好,因为这样随机性好,冲突小

    B 除留余数法是所有哈希函数中最好的

    C 不存在特别好与坏的哈希函数,要视情况而定

    D 越简单的哈希函数越容易出现冲突,是最坏的

    首先,衡量哈希函数好坏并没有一个确切的标准,而是需要根据具体情况而定,并不一定复杂的哈希函数就好,因为会带来时间上的损失。其实我们的生活中很多东西都像这样,没有好坏之分,只有适不适合的说法,所以说选择C选项

  2. 设有一组记录的关键字为{19,14,23,1,68,20,84,27,55,11,10,79},用链地址法构造散列表,散列函数为H(key)=key MOD 13,散列地址为1的链中有( )个记录。

    A 1 B 2 C 3 D 4

    这种咱们得画图才知道了,答案是D

  3. 设哈希表长为14,哈希函数是H(key)=key%11,表中已有数据的关键字为15,38,61,84共四个,现要将关键字为49的元素加到表中,用二次探测再散列解决冲突,则放入的位置是( )

    A 8 B 3 C 5 D 9

    咱们先把这个表给画出来吧,答案是D

  4. 选取哈希函数 H(key)=(key x 3)%11 用线性探测散列法和二次探测再散列法分别处理冲突。试在0~10的散列地址空间中,对关键字序列(22,41,53,46,30,13,1,67)构建哈希表,并求等概率情况下查找成功的平均查找长度。

    其中平均查找长度(ASL)就是表中每一个元素需要查找次数之和的平均值,我们注意在插入元素时顺便记录计算次数即可,如果是链地址法,那么直接看层数就行,ASL =(第一层结点数量+第二层结点数量+第三层结点数量)/ 非头结点总数

算法实战

(简单)两数之和

本题来自LeetCode:1.两数之和(整个力扣的第一题)

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。

你可以按任意顺序返回答案。

示例 1:

输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。

示例 2:

输入:nums = [3,2,4], target = 6
输出:[1,2]

示例 3:

输入:nums = [3,3], target = 6
输出:[0,1]

这道题很简单,实际上使用暴力枚举是可以完成的,我们只需要让每个数去寻找一个与其匹配的数即可,所以说直接循环就完事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int * result(int i, int j, int * returnSize){
*returnSize = 2;
int * result = malloc(sizeof(int) * 2);
result[0] = i;
result[1] = j;
return result;
}

int* twoSum(int* nums, int numsSize, int target, int* returnSize){
for (int i = 0; i < numsSize; ++i) {
for (int j = 0; j < numsSize; ++j) {
if(j == i) continue;
if(nums[i] + nums[j] == target)
return result(i, j, returnSize); //找到匹配就直接返回完事
}
}
return NULL; //无视即可,因为不可能
}

但是这样效率实在是太低了,可以看到我们的程序运行时间都好几百毫秒了,能不能优化一下呢?我们正好学习了散列表,是否可以利用一下散列表来帮助我们完成?

因为每当我们遍历一个数时,实际上就是去寻找与其匹配的数是否存在,我们可以每遍历一个数都将其存放到散列表中,当下次遇到与其相匹配的数时,只要能够从散列表中找到这个数,那么就可以直接完成匹配了,这样就只需要遍历一次即可完成。比如:

[2,7,11,15] ,targert = 9

第一次先将2放入散列表,接着往后看7,现在目标值时9,那么只需要去寻找 9 - 7 这个数,看看散列表中有没有即可,此时散列表中正好有2,所以说直接返回即可。

我们来尝试编写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#define SIZE 128

typedef int K;
typedef int V;

typedef struct LNode { //结点定义需要稍微修改一下,因为除了存关键字还需要存一下下标
K key;
V value;
struct LNode * next;
} * Node;

typedef struct HashTable{ //哈希表
struct LNode * table; //这个数组专门保存头结点
} * HashTable;

void init(HashTable hashTable){
hashTable->table = malloc(sizeof(struct LNode) * SIZE);
for (int i = 0; i < SIZE; ++i) {
hashTable->table[i].key = -1; //将头结点key置为-1,value也变成-1,next指向NULL
hashTable->table[i].value = -1;
hashTable->table[i].next = NULL;
}
}

int hash(unsigned int key){ //因为哈希表用的数组,要是遇到负数的key,肯定不行,咱先给它把符号扬了再算
return key % SIZE;
}

Node create(K key, V value){ //创建结点,跟之前差不多
Node node = malloc(sizeof(struct LNode));
node->key = key;
node->value = value;
node->next = NULL;
return node;
}

void insert(HashTable hashTable, K key, V value){
int hashCode = hash(key);
Node head = hashTable->table + hashCode;
while (head->next) head = head->next;
head->next = create(key, value); //这里同时保存关键字和对应的下标
}

Node find(HashTable hashTable, K key){
int hashCode = hash(key);
Node head = hashTable->table + hashCode; //直接定位到对应位置
while (head->next && head->next->key != key) //直接看有没有下一个结点,并且下一个结点不是key
head = head->next; //继续往后找
return head->next; //出来之后要么到头了下一个是NULL,要么就是找到了,直接返回
}

哈希表编写完成后,我们就可以使用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int * result(int i, int j, int * returnSize){   //跟上面一样
*returnSize = 2;
int * result = malloc(sizeof(int) * 2);
result[0] = i;
result[1] = j;
return result;
}

int* twoSum(int* nums, int numsSize, int target, int* returnSize){
struct HashTable table; //初始化哈希表
init(&table);
for (int i = 0; i < numsSize; ++i) { //挨个遍历
Node node = find(&table, target - nums[i]); //直接去哈希表里面寻找匹配的,如果有直接结束,没有就丢把当前的key丢进哈希表,之后如果遇到与其匹配的另一半,那么就直接成功了
if(node != NULL) return result(i, node->value, returnSize);
insert(&table, nums[i], i);
}
return NULL; //无视就好
}

我们再次提交代码,时间直接来到了个位数:

image-20220821122010425

采用哈希表,就是一种空间换时间的策略,在大多数情况下,我们也更推荐使用这种方案。