引子
我们之前学了线性表中的数组和链表,它们之间各有优势和缺点,且链表和数组的优缺点正好互补。那么我们可不可以用一种新的数据结构来代替链表和数组,使其继承它们各自的优点呢?
而接下来所讲的跳表、hash表,都是结合了数组和链表优点而诞生的
跳表
跳表 可以支持快速的插入、删除、查找操作。它在单链表的基础上, 对链表建立多级索引,进而提高查询效率(空间换时间),类似于基于链表的“二分查找”
就如上图,想要查询单链表12所在位置,可以通过 壹 -> 伍 -> 玖 -> 九 -> 十一 -> 11 -> 12来找到(数据量越大效果越明显)
复杂度
时间复杂度:
查询:O(log(n))
插入:O(log(n))
删除:O(log(n))
空间复杂度:2/N + 4/N + ... + 1 = N - 1(如果原始链表中存储很大的对象, 而索引结点只需要存储关键值和几个指针,那索引占用的额外空间就可以忽略了)
针对插入、删除导致的跳表退化
当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡 。
跳表是通过随机函数来维护平衡性,随机函数决定将结点插入到哪几级索引中。比如随机函数生成 K,那我们就将这个结点添加到原始链表和1~K 级索引中。
Hash
hash由hash函数和hash表组成。
hash表也叫散列表, 可根据key值通过某种映射关系而直接访问的数据结构 ,它支持按照下标随机访问的特性 ,实质上是数组的一种扩展 。
hash函数就是上述的映射关系, 它可以把字符串,数字等等映射为一个无符号整型,以便快速找到存储该元素信息的位置。
哈希函数的映射关系有很多,比较常用的有一下几种:
(1)直接定址法
一般形式为f(x) = a * x + b,a,b为自定义常数,比如:
unsigned val = 0;
for (int i = 0; i < strlen(s); i++) {
val = val * 33 + s[i];
}
(2)平方取中法
先求关键值的平方值,通过平方扩大差异,而后取中间数位作为最终存储地址,比如:
unsigned val = 0;
for (int i = 0; i < strlen(s); i++) {
val = val * 33 + s[i];
}
val = val * val / p % q; //p,q为常数
(3)除留余数法
一般形式为:f(x) = x % p,其中p为不超过表长的质数(可以减少地址重复)
(4)随机数法
一般形式为:f(x)=random(x)
除此之外,业界著名的hash算法还有 MD5、SHA、CRC等。
哈希冲突
由上边的哈希函数我们可以看出,可能多个不同的key值通过哈希函数最后映射得到的值为同一个数,这也就是哈希冲突。再优秀的哈希算法也无法避免哈希冲突(因为数组的存储空间有限)
对于哈希冲突,我们可以通过以下办法来解决:
开放定址法:
如果遇到哈希冲突,我们就找hash表中剩余的空间,然后将其插入(比如从当前hash位置开始依次往后查找直到空闲位置处,将值插入)
再hash法:
如果遇到hash冲突,再用第二个hash函数进行hash,依次类推,直到找到空闲位置。
公共区法:
开辟一个公共区,所有hash冲突的值都放到公共区内。
链地址法:
上述三种方法都有一个局限性,就是当表的内容已经填满时就无法进行插入,而该方法可以解决这个问题。这也是哈希表最常用的方法。
该方法如果遇到哈希冲突,他就会在原地址新建一个空间,然后以链表节点的形式插入到该空间。
复杂度
hash的插入、删除、查询操作,不可以笼统看作O(1)。,因为它跟hash函数、装载因子、hash冲突等都有关系。如果hash函数设计得不好,或者装载因子过高,都可能导致hash冲突发生的概率升高,导致效率下降。
简易代码实现
//以链地址法为例
//哈希函数
unsigned int hash(char *key) {
unsigned int val = 0;
for (int i = 0; i < strlen(key); i++) {
val = val * 31 + key[i];
}
return (val ^ (val >>> 16)) & (MAXN -1);
}
//定义节点
struct Node {
char *key;
char *val;
Node *next;
}
//定义哈希表
struct Hash {
Node *head[MAXN];
int len;
Hash() {
for (int i = 0; i < MAXN; i++) {
this -> head[i] = NULL;
}
this -> len = 0;
}
}
//查
Node* query(Hash *h, char *key) {
unsigned inx = hash(key);
Node *p = h -> head[inx];
while(p != NULL) {
if(!strcmp(p -> key, key)) {
return p;
}
p = p -> next;
}
return NULL;
}
//增或改
void insert(Hash *h, char *key, char *val) {
Node *p = query(h, key);
if (p == NULL) {
unsigned inx = hash(key);
p = (Node *)malloc(sizeof(Node));
p -> key = key;
p -> next = h -> head[inx];
head[inx] = p;
}
p -> val = val;
}
//删
bool del(Hash *h, char *key) {
Node *p = query(h, key);
if (p == NULL) return false;
unsigned inx = hash(key);
if (!strcmp(h -> head[inx] -> key, key)) {
h -> head[inx] = p -> next;
free(p);
return true;
}
Node *q = h -> head[inx];
while(q -> next != NULL) {
if (!strcmp(q -> next -> key, key)) {
q -> next = p -> next;
free(p);
}
q = q -> next;
}
return true;
}
工业级hash
一些恶意攻击,可以使所有数据经过hash函数后得到相同hash值。对于链地址法,这时hash表就会退化为链表,查询的时间复杂度就从 O(1) 退化为 O(n)。
而工业级hash,需要避免在hash冲突情况下性能急剧下降,并且能抵抗恶意攻击。
工业级hash需要具备以下几点:
-
hash函数不能太复杂(减少计算时间),尽量均匀分布
-
对于频繁插入和删除的数据集合: 随着数据增加到一定阈值,重新申请更大的hash表,并将原有数据重新hash到新的hash表中。
该操作耗费时间巨多,有两种解决办法:
(1)均摊代价,将扩容操作分批完成(具体流程就是只申请空间,当有新数据要插入时,我们将新数据插入新hash表中,并从老hash表中拿出一个数据重新hash放入到新hash表,直到全部搬移到新hash表为止)
(2)采用一致性hash
-
对于小数据量,hash冲突采用开放定址法
-
对于大数据量,hash冲突采用链地址法,当某hash值冲突过多时,可将链表改为红黑树、跳表等数据结构,来降低复杂度
-
设置合适的初始值和装载因子阈值,来减少动态扩容的次数
hash应用
安全加密
最常用于加密的哈希算法是MD5和SHA。没有绝对安全的加密,越复杂的加密算法需要计算的时间也越长。
对于hash加密,常见的破解方式是字典攻击,就是黑客维护一个常用密码字典,然后用不同hash算法得到不同hash值,同拿到的加密后的密文一一对比,若相同则认为是通过该种hash算法进行加密。
针对字典攻击,开发者可以通过后台增加一段随机数来跟用户密码组合,增加密码的复杂性。
区块链
区块链由区间链接而成,每个区块分为块头和块体,块头保存块体和上一个块头的hash值。因此只要区块链上任意一个区块被修改,后面所有区块保存的hash值都不对了。
由于区块链采用SHA256哈希算法非常耗时,因此要篡改一个区块就必须重新计算该区块后所有区块的哈希值,短时间内几乎做不到。
唯一标识
比如hash一张图片的二进制,来判断是否存在该图片(耗时,优化思路是随机取部分位置二进制码hash对比)
数据校验
在网络中,传输前hash数据,传输后再hash进行比对,防止数据被恶意修改或丢包。
负载均衡
对客户端ip地址或者id进行hash,然后把hash值与服务器列表的大小进行取模,得到的值作为被路由到的服务器编号。缺点是扩/缩容时需重新hash,在分布式存储中容易造成缓存雪崩,此时可考虑使用一致性hash(每个机器负责一个hash区间,每次扩容拆分一台机器的负责区间,每次缩容合并一台机器的负责区间)
hash表+双向链表
通过hash表+双向链表来实现链表的增、删、改、查都是O(1)复杂度,可用于LRU缓存淘汰算法(时间复杂度由On降为O1)
增:通过hash表判断数据是否存在,若不存在添加到双向链表尾部,并更新prev、next、hnext
删:通过hash表找到数据所在节点,然后让前驱后继相连,让前面的hnext连接下个节点
查:通过hash表找到对应数据
扩展
上述做法可实现O1复杂度添加、按键值删除、按键值查找,若想实现按分值区间查找数据、按分值大小排序,则需把双向链表改为跳表(以权值排序构建跳表,再以键值构建hash表)