一:散列(哈希)表的定义
散列表(Hash Table),也称为哈希表,是一种通过哈希函数组织数据,以支持快速插入和搜索的数据结构。哈希表允许通过关键字(Key)进行数据的快速查找、插入和删除操作。其基本原理是通过哈希函数将关键字映射到表中的一个位置来访问记录,以加快查找的速度。这个映射的位置称为哈希地址或散列地址。
二:散列(哈希)表的结构
-
哈希函数:一个将关键字映射到哈希表中一个位置的函数。理想的哈希函数应该能够减少冲突(即不同的关键字映射到同一个位置的情况),以提高哈希表的性能。
-
解决冲突的方法:由于哈希函数可能不是完美的,不同的关键字可能会映射到哈希表的同一个位置,这种情况称为冲突。解决冲突的方法有多种,常见的有开放寻址法和链地址法(也称为拉链法)。
- 开放寻址法:当发生冲突时,寻找哈希表中的下一个空闲位置来存放新数据。
- 链地址法:在哈希表的每个槽位维护一个链表,所有映射到该槽位的关键字都存储在链表中。
-
哈希表:存储数据的实际结构,通常是数组或动态数组(如动态扩容的数组),其大小通常根据预计存储的元素数量来确定,并考虑一定的冗余空间以应对哈希冲突和未来的扩展。
三:哈希函数
1.简单介绍
①在这里,我将哈希函数比喻成一个加工厂,向工厂里放入不同的原材料,就像是向哈希函数中放入不同的类型,无论是整型,浮点型,字符串这些都是“”原材料“”。原材料在工厂里经过相同的工序加工后所形成的新的东西,加工厂里的工序就像是哈希函数内部的算法。这些操作都是为了将原材料转换成一个新的、通常是固定长度的输出值。这个输出值就像是加工厂生产出来的新产品,它具有某种特定的格式和属性。
②不同的原材料(即使它们非常相似)经过相同的工序加工后,很可能会得到完全不同的新产品。这是因为哈希函数的设计目标是尽量减少不同输入产生相同输出的情况(即哈希冲突)。当然,由于输出空间的限制,完全避免哈希冲突是不可能的,但好的哈希函数会尽量减少这种情况的发生。
③加工厂的工序(哈希函数)是单向的,这意味着我们无法直接从新产品(输出值)反推出原材料(输入值)是什么。这就像我们无法仅通过查看一个加工好的产品就确定它最初是由哪些原材料制成的。
④经过上述我们就可以大致确定,我们向哈希函数中放入一个关键字,通过哈希函数,我们会得到特定的映射值(哈希地址),它们都是相对应的。这个哈希地址是唯一的(在理想情况下,或者至少是在哈希表当前大小和哈希函数设计下尽可能唯一的),它代表了关键字在哈希表中的位置。
2.具体实现
由于哈希函数有很多种,不同的哈希函数有不同的应用场景,但是非常好的哈希函数往往伴随着的是相当复杂的设计原理。在这里,只介绍一种很常用并且很实用的方法——取模法。
int Hash(int key, int tableSize) //哈希函数
{
return key % tableSize; //将关键字key对表长tableSize进行取模
}
可以看出,这个哈希函数的设计一目了然,结构很简单 ,但也有其精妙所在,下面我们来看看。
这里是为了做示例,我们将关键字定义为了int类型,因为该类型对于哈希函数的映射关系更加一目了然,这里的表长tableSize是设计者(即编写程序的大家)根据数据的个数来进行设计的。
对于tableSize的大小设计,要考虑很多情况,这个表长相对于数据个数类说,不能特别短,也不能特别长。特别短更容易引发哈希冲突,因为值的存放是根据不同的关键字key通过哈希函数的映射来决定的,如何tableSize的设计很小,哈希冲突的记录将大大增加。而如果太长,又会造成内存空间不必要的浪费。在这里我将tableSize设计为元素数据个数的2倍再+1。例如我们有五个关键字准备输入,此时表长tableSize为5 * 2 + 1,即11。对于表长的设计,我的方法过于浪费空间了,我在此是为示例方便,不要学我。
注意:实际上我们在对表长的确定中要考虑到一个叫负载因子的东西。负载因子大概表现为:负载因子 = 已存储元素个数 / 表长,即负载因子是已存储元素个数与表长的比值。这个比值在(0.7 - 0.8)之间是较为合理的情况,既不容易造成哈希冲突,也不至于浪费内存空间,是相对来说更为均衡的存在。实际上大家在进行表长的选择的时候,需要通过预估数据规模、选择合适的装填因子、使用质数作为表长、实现动态扩容机制以及平衡空间与性能等方法来进行设计与权衡。但在这里我还是将表长设计的尽可能大,这个取决于自身。如果顾及到绝对合理的情况下,在此我对于表长的设计是对空间有一个比较大的浪费的。
3.图像示例
①我们待输入的关键字key有5个,此时表长设计为11.
②依次输入key为:7, 9, 11,17,43。
经过哈希函数7的存储位置为7 % 11 = 7,即存储在下标为7的地方。
其余依次为 9 % 11 = 9, 11 % 11 = 0, 17 % 11 = 6, 43 % 11 = 10。
③经过哈希函数的计算向散列表中进行存储。
到这里我们大概对哈希函数有了一个更深的认识。
四:哈希冲突
1·.简单介绍
哈希冲突是指在哈希表中,两个或更多个不同的键(Key)经过哈希函数计算后,被映射到了同一个哈希桶(即哈希表中的一个基本单元)或哈希地址的情况。这种情况在哈希表的使用中是不可避免的,因为哈希表的长度是有限(或者说是固定的)的,而输入值的范围是无限(或者说是不固定的)的。哈希冲突的发生可能会导致数据丢失或者检索效率下降,因为不同的键被映射到了同一个位置,需要额外的操作来处理这种冲突。图示如下
上述哈希函数的图像示例中,即下图
这里的每一元素数据都找到了唯一的地方进行存储,即每一个关键字都对应了一个哈希地址(在这里为数组的下标) ,并没有造成冲突。那么再考虑下面的情况
我还是将5个元素存储在表长为11的散列表中,但是将这5个元素换为3,14,7,18,29。
则3的存储位置为3 % 11 = 3,即下标为3的地方。
其余依次为14 % 11 = 3,7 % 11 = 7, 18 % 11 = 7, 29 % 11 = 7。
此时存储后的散列表为
很明显,这非常不合适,本来该存储一个数据元素的哈希桶,现在存储了两个甚至是三个的情况,这样的情况就是哈希冲突。
2.※※※解决哈希冲突的方法(重点)
说明:由于解决哈希冲突的方法不少,在这里我只挑两个常用的也是两个比较重要的细讲,即开放定址法与链地址法(拉链法)。其余的方法可稍作了解,等到确实需要的时候在进行学习。
2.1※※※开放定址法
说明:对于开放定址法来说,也行很多的方法,在这里只对常用的线性探测法做详细介绍,二次探测会稍微提一嘴,其余的③④方法了解即可。
①线性探测法
线性探测顾名思义遵循线性思想,更多表现为其沿着一条直线或直线的轨迹去探寻解决问题的方法。
由上述哈希冲突的情况,我们知道了,每一个关键字都必须只能对应一个哈希地址,这样才能通过关键字去找到哈希地址里的值,形成一对一的映射关系。
所以根据线性探测的思想,如果关键字所映射的地址上面已经存储的有数据了,那么我们就像后移动一位,检查该位置上有没有数据,如果没有,存储在这里,如果有,继续向后移动一位进行探测,直到遇到没有存储元素的地方,把数据放进去,这就是线性探测法工作的方法。
完整代码:
#include <iostream>
#include <cstdlib>
#include <cstring>
using namespace std;
typedef struct DATA
{
int key; //这里的关键字key为ID,ID表示学号
char value[20]; //这里的值value为名字name
} DATA, *LPDATA;
typedef struct HASH
{
LPDATA *table; //散列表,注意该散列表是指向DATA结构指针的指针
int divisor; //divisor为除数,即为tableSize表长
int curSize;
} HASH, *LPHASH;
int Hash(int key, int tableSize)
{
return key % tableSize;
}
//创建散列(哈希)表
LPHASH creatHashTable(int tableSize)
{
LPHASH hash = new HASH; //new一块HASH空间给hash,此时hash指向的结构里面有HASH结构的三个元素
hash->divisor = tableSize; //将HASH结构里的divisor设置为表长,以后就不需要再单独得传递一个表长的参数了
hash->curSize = 0; //将散列表中目前的元素个数设置为0
hash->table = new LPDATA[tableSize]; //为hash指针指向的结构HASH里的table分配表长大小的数组来存放数据
for (int i = 0; i != tableSize; ++i)
{
hash->table[i] = nullptr; //将table所分配的数组大小的空间全都设置为空
}
return hash; //返回hash指针,该指针保存了分配的所有空间
}
//查找
int search(LPHASH hash, int key)
{
int pos = Hash(key, hash->divisor); //pos为位置position的缩写,在这里表示哈希地址
int curPos = pos; //创建一个当前变量用来后续的追踪判定
do
{
//该if语句所判定的意思是,如果当前位置(即关键字key所映射的哈希地址)为空或者当前位置里的数据和新插入数据相同的时候,此时可以将该数据插入到该哈希地址上,此时返回这个可以插入的哈希地址curPos。否则即当前位置不为空,且当前位置上的数据与新插入的数据也不同,此时将当前位置向后移动一位进行循环判定,直到找到符合if条件的位置进行返回,如果没有找到,则返回-1表示找不到这样一个位置存储新的数据。
if (hash->table[curPos] == nullptr || hash->table[curPos]->key == key)
{
return curPos;
}
else
curPos = (curPos + 1) % hash->divisor;
} while (curPos != pos);
return -1;
}
//插入
void insert(LPHASH hash)
{
cout << "请输入待插入的数据 :";
DATA data;
cin >> data.key >> data.value;
int pos = search(hash, data.key); //通过search函数找到一个可以插入的位置
//该if控制语句意思为:如果找到的这个可以插入的位置为空,则将数据插入进去,否则的话就是原来位置上的数据与待插入的数据相同,此时else区域块里代码将进行对原先数据的覆盖操作
if (hash->table[pos] == nullptr)
{
hash->table[pos] = new DATA;
memcpy(hash->table[pos], &data, sizeof(DATA));
hash->curSize++;
}
else
{
if (hash->table[pos]->key == data.key)
strcpy(hash->table[pos]->value, data.value);
else
{
cout << "哈希表已满,插入失败!!!" << endl;
return;
}
}
}
//删除数据
void deleteData(LPHASH hash)
{
cout << "请输入待删除学生的学号:";
int key = 0;
cin >> key;
hash->table[search(hash, key)] = nullptr; // 通过search函数找到该关键字所映射的哈希地址,并将这一块指向该空间的指针置空
delete hash->table[search(hash, key)]; // 删除空间上的数据
}
//打印数据
void printTable(LPHASH hash)
{
for (int i = 0; i != hash->divisor; ++i) //遍历哈希表
{
if (hash->table[i] == nullptr) // 如果为存储元素,打印nullptr,否则打印数据
cout << "nullptr" << endl;
else
cout << hash->table[i]->key << " : " << hash->table[i]->value << endl;
}
}
int main()
{
cout << "请输入待插入的数据个数:";
int numbers = 0;
cin >> numbers;
int tableSize = 2 * numbers + 1 ;
LPHASH hash = creatHashTable(tableSize);
for (int i = 0; i != numbers; ++i)
{
insert(hash);
}
printTable(hash);
cout << "请输入学生的学号进行查询:";
int id = 0;
cin >> id;
cout << hash->table[search(hash, id)]->value << endl;
deleteData(hash);
printTable(hash);
return 0;
}
②平方探测法(不进行实现)
该探测方法与线性探测法逻辑相似,只是每次探测的步长以平方的形式存在,例如
线性探测位置的更新为curPos = (curPos + 1)% hash->divisor;
那么平方探测对于探测地址的更新为curPos = (curPos + i ^ 2)% hash->divisor;这里的i为(1,2,3,4,……)依次增加的。
其中值得注意的是,平方探测法要求表长必须某个形式为 4k+3
的质数(其中 k
是整数,也即索要插入的数据元素的个数),这是为了确保探测过程能够遍历散列表中的所有槽位,并且避免探测序列的周期性重复。
其余的操作与线性探测并没有什么不同。
③伪随机探测再散列(不进行实现)
伪随机探测再散列需要一个伪随机数生成器来产生探测步长,实现伪随机探测再散列需要编写相应的代码来处理哈希表的插入、查找和删除等操作。这些操作需要考虑到冲突的处理,即当哈希地址冲突时,如何根据伪随机数生成器来找到下一个可能的哈希地址。该方法有些复杂且不常用,在这里不做介绍。
2.2※※※链地址法(拉链法)
链地址法可以解决任意数量的冲突情况以及其特有的灵活性,是一种较为常用的一种方法。
依旧是输入5个数据,将表长置为11,5个数据分别为3、7、14、18、27。如图
在图中,可以看出拉链法即为数组和链表的结合,数组里不放置数据了,数据全都放到链表中,而数组中则放置指向该链表的指针。
!!!注意:
①若在不进行特定位置的插入时,拉链法的插入有两种方式,分别为头插法与尾插法。头插法即每次插入均插入在链表的表头处,而尾插法的每次插入均会插入在链表的表尾。
②因为链表的头插法在每次插入时均插入头部,所以插入时不需要对链表进行遍历,即每次插入的时间复杂度均为常数项O(1),虽然时间复杂度低,但头插法会改变插入时的顺序,如果对插入时数据的数据有要求,不建议使用头插,否则后续的其他操作(例如按照插入数据的顺序依次输出时)的时间复杂度可能会来到O(n)。而尾插法在插入时需遍历链表,找到最后一个结点进行插入,时间复杂度来到了O(n),如果链表够短还好,若链表足够长,此时尾插法的时间复杂度将难以想象。所以要根据情况选择合适的插入方式。另外,如果链表够长,说明哈希冲突处理的不好,此时更应该注意哈希函数的设计。
③在头插法插入时,新节点总是被插入到链表的头部。这意味着你只需要修改table[pos]指针所指的位置,让它指向新节点,并让新节点的next
指针指向原来的链表头部所指向的位置。由于这个操作只需要修改table[pos]的指针,而不需要遍历链表来找到末尾,因此不需要二级指针。只需要一个指向链表头部的指针(即一级指针)就足够了。
④而在进行尾插法时,新节点需要被插入到链表的末尾。由于链表的末尾是动态变化的(即最后一个节点的next
指针是nullptr
),你需要一个指针来遍历链表直到找到这个末尾节点。但是,当你找到末尾节点并准备将新节点插入时,你还需要能够修改链表中前一个节点的next
指针,以便它指向新节点。此时若改变前一结点next指针的指向,则需要二级指针。
⑤在前面链表文章中的总结处说到,一级指针可以改变它所指向地址上的值,而二级指针可以修改一级指针的值或间接修改一级指针所指向位置的值。如果只需要修改一级指针所指向地址上的值,此时也不需要动用二级指针,一级指针即可做到。问题在于,在尾插法中,需要修改的是一级指针,即指向链表中结点的指针。此时需要修改以及指针的值,就不得不使用二级指针来进行操作。
完整代码:
#include <iostream>
#include <cstring>
using namespace std;
typedef struct Node
{
int key; //学生学号
char value[20]; //学生姓名
Node *next; //指向下一结点的指针
} NODE, *ListNode;
typedef struct Hash
{
ListNode *table; //哈希表
int divisor; //用来存储表长
} HASH, *HashTable;
int Hash(int key, int divisor)
{
return key % divisor;
}
HashTable creatHashTable(int num)
{
HashTable hash = new HASH; // new一块HASH空间给hash,此时hash指向的结构里面有HASH结构的三个元素
hash->divisor = num;
hash->table = new ListNode[num]; //为table分配表长大小的数组空间
for (int i = 0; i != num; ++i)
{
hash->table[i] = nullptr; //将数组里的每一个指针置空
}
return hash;
}
//查找(在这里的查找函数由于示例需要,我令它返回char*,即返回学生名字)
char *search(HashTable hash, int key)
{
int pos = Hash(key, hash->divisor); //通过哈希函数确定哈希地址pos
ListNode current = hash->table[pos]; //table数组里的每一个值都是一个指针,将current指向由pos确定的哈希地址处
while (current != nullptr) //遍历该链表(此链表为)
{
if (current->key == key) //如果所查到的pos地址上的关键字相同,返回值,否则,遍历进行探测
{
return current->value;
}
else
current = current->next;
}
cout << "No data!!!" << endl;
return nullptr;
}
//头插法插入
void frontInsert(HashTable hash, int key, char *value)
{
int pos = Hash(key, hash->divisor); //获取哈希地址
ListNode newNode = new NODE;
newNode->key = key;
strcpy(newNode->value, value);
if(hash->table[pos] == nullptr) //如果插入的是第一个结点,让插入结点的next域置空
{
newNode->next = nullptr;
}
newNode->next = hash->table[pos]; //将第二次插入的结点指向上一次插入的结点,插到上一次插入结点的前面
hash->table[pos] = newNode; //将对应索引的table指针指向新插入结点,完成头插
}
//尾插法插入
void rearInsert(HashTable hash, int key, char *value)
{
int pos = Hash(key, hash->divisor);
ListNode newNode = new Node;
newNode->key = key;
strcpy(newNode->value, value);
newNode->next = nullptr; //至此,结点创建并设置完成
ListNode *tail = &hash->table[pos]; //tail为二级指针,即指向table数组中pos索引所在位置的指针,因为table[pos]处存放的为指向链表的指针,所以应将table[pos]指针的地址赋给tail指针
while(*tail != nullptr) //对tail指针进行解引用,得到table[pos]指针,若该指针为空,则直接指向新结点
tail = &(*tail)->next; //若不为空,则遍历链表,直到遇到链表中的尾结点,并在该尾结点的后面插入新结点,先对tail指针进行解引用,得到指向链表的指针,再对指向链表下一结点的指针进行取地址并赋给二级指针tail
*tail = newNode;
}
void deleteData(HashTable hash, int key)
{
int pos = Hash(key, hash->divisor); //锁定哈希地址
ListNode current = hash->table[pos];
ListNode prev = nullptr;
while (current != nullptr) //遍历哈希地址所对应的链表
{
if (current->key == key) //若关键字相同,进行删除操作
{
if (prev == nullptr) //若prev为空,说明待删除结点为第一个结点
{
hash->table[pos] = current->next; //此时将哈希地址所对应的table指针指向该链表的第二结点
delete current; //并删除第一个结点
}
else //若不是第一个结点,则链接待删除结点的前一结点与后一结点,架空待删除结点
{
prev->next = current->next;
delete current;
}
}
prev = current; //更新prev指针
current = current->next; //更新current指针
}
}
void printHashTable(HashTable hash)
{
for (int i = 0; i != hash->divisor; ++i) //遍历数组
{
if (hash->table[i] == nullptr)
cout << "nullptr!" << endl;
else // 若不为空,遍历数组内指针所指向的链表
{
ListNode current = hash->table[i];
while (current != nullptr)
{
cout << current->key << " : " << current->value << endl;
current = current->next;
}
}
}
}
void destroyHashTable(HashTable hash)
{
for (int i = 0; i != hash->divisor; ++i)
{
ListNode current = hash->table[i];
while (current != nullptr)
{
ListNode next = current->next; //记录待删除销毁结点的下一个结点位置
delete current; //销毁当前结点
current = next; //更新current指针
}
} // 若成功进行遍历,此时所分配的链表结点空间均会被销毁
delete[] hash->table; //此时再销毁存放指向链表的指针数组
delete hash; //最后再销毁hash指针所指向的结点
hash = nullptr; //销毁后将hash指针置空,避免悬挂指针
}
int main()
{
cout << "请输入待输入的数据个数: ";
int numbers = 0;
cin >> numbers;
int tableSize = 2 * numbers + 1;
HashTable hash = new HASH;
hash = creatHashTable(tableSize);
for (int i = 0; i != numbers; ++i)
{
cout << "请输入新的数据:";
int key;
char value[20];
cin >> key >> value;
frontInsert(hash, key, value);
}
printHashTable(hash);
cout << "请输入待删除数据的个数: ";
int n;
cin >> n;
for (int i = 0; i != n; ++i)
{
cout << "请输入待删除学生的学号: ";
int ID;
cin >> ID;
deleteData(hash, ID);
}
printHashTable(hash);
destroyHashTable(hash);
return 0;
}
若故意制造冲突,可验证头插法与尾插法,冲突发生后,会存放在冲突发生的哈希地址上的指针所指向的链表上。
头插法
如下图,若在程序的main()中调用frontInsert()执行,此时会执行头插法插入代码。关键字key3,10,17均引起冲突,此时先插入的3会在最后,而后插入的10和17以及其所对应的值将依次插入到关键字3前面,完成头插。
尾插法
如下图,若在程序的main()中调用rearInsert(),此时会执行尾插法, 后插入的关键字key10,17及其对应的值会依次插入前一次插入的后面,完成尾插。
2.3再散列(不进行实现)
当哈希表的负载因子(装填因子)达到某个预设的阈值时,为了保持哈希表的性能和减少冲突,需要将哈希表中的所有元素重新散列到一个更大的哈希表中。这个过程包括创建一个新的、更大的哈希表,然后遍历原哈希表,将每个元素按照新的哈希函数重新计算其位置,并插入到新的哈希表中。
2.4建立公共溢出区(不进行实现)
当哈希函数计算出的地址与基本表中的某个元素发生冲突时,不采用其他冲突解决方法(如开放定址法或链地址法),而是直接将冲突的元素存入公共溢出区。公共溢出区通常是一个额外的数据结构,如链表数组、动态数组或其他适合存储大量元素的结构。
实现步骤:
- 定义溢出区:首先,需要定义一个足够大的溢出区来存储可能发生的冲突元素。溢出区的大小可以根据哈希表的使用情况和预期冲突率来设定。
- 哈希函数:使用哈希函数对关键字进行计算,得到其在基本表中的位置。
- 冲突检测:检查基本表中该位置是否已被占用。
- 如果未被占用,则直接将该元素插入基本表。
- 如果已被占用,则将该元素插入到公共溢出区。
- 查找操作:在进行查找时,首先根据哈希函数在基本表中查找。如果基本表中不存在该元素,则继续在公共溢出区中查找。
五:总结(必看!!!)
①对于哈希表的操作,操作不少,但是问题在于哪些需要掌握而哪些需要理解,对于相对复杂且不常用的,知道这种方法即可,若后续确实需要用到,在进行相应检索学习即可,否则学了也是会忘。这对于其他的数据结构一样适用。
②哈希表最主要的用途是用来查重和计数,知道其原理能帮助大家更好理解。
③大家加油!!!