数据结构与算法--跳表、hash

2,083 阅读8分钟

引子

​ 我们之前学了线性表中的数组和链表,它们之间各有优势和缺点,且链表和数组的优缺点正好互补。那么我们可不可以用一种新的数据结构来代替链表和数组,使其继承它们各自的优点呢?

​ 而接下来所讲的跳表、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算法还有 MD5SHACRC等。

哈希冲突

​ 由上边的哈希函数我们可以看出,可能多个不同的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需要具备以下几点:

  1. hash函数不能太复杂(减少计算时间),尽量均匀分布

  2. 对于频繁插入和删除的数据集合: 随着数据增加到一定阈值,重新申请更大的hash表,并将原有数据重新hash到新的hash表中。

    该操作耗费时间巨多,有两种解决办法:

    (1)均摊代价,将扩容操作分批完成(具体流程就是只申请空间,当有新数据要插入时,我们将新数据插入新hash表中,并从老hash表中拿出一个数据重新hash放入到新hash表,直到全部搬移到新hash表为止)

    (2)采用一致性hash

  3. 对于小数据量,hash冲突采用开放定址法

  4. 对于大数据量,hash冲突采用链地址法,当某hash值冲突过多时,可将链表改为红黑树、跳表等数据结构,来降低复杂度

  5. 设置合适的初始值和装载因子阈值,来减少动态扩容的次数

hash应用

安全加密

​ 最常用于加密的哈希算法是MD5SHA。没有绝对安全的加密,越复杂的加密算法需要计算的时间也越长。

​ 对于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)

指针prev、next用于串联双向链表,指针hnext用于串联散列表(hash表)

​ 增:通过hash表判断数据是否存在,若不存在添加到双向链表尾部,并更新prev、next、hnext

​ 删:通过hash表找到数据所在节点,然后让前驱后继相连,让前面的hnext连接下个节点

​ 查:通过hash表找到对应数据

扩展

​ 上述做法可实现O1复杂度添加、按键值删除、按键值查找,若想实现按分值区间查找数据、按分值大小排序,则需把双向链表改为跳表(以权值排序构建跳表,再以键值构建hash表)