11.1-哈希表与布隆过滤器(数据结构基础篇)

17 阅读18分钟

哈希表解决了什么问题

哈希表主要利用数组索引查找的高效来解决了快速索引数据的问题。

我们知道,数组给出下标x,要查找x中存储的值是相当快的,其时间复杂度是O(1)。但由于数组的索引是整形,而我们实际开发工作中可能需要用于索引的数据类型可能是字符串、或者其他的自定义类型,没办法直接使用数组索引的方式快速查找。那么,有没有一种方式,能让我们使用任意数据类型的数据作为索引,让我们实现一个查找复杂度为O(1)的数据结构呢?那么,此时,我们就可以把我们的任意数据映射到某个数组的下标中,我们要查找某个数据时,就相当于从数组下标对应的空间中取数据。我们说的将任意数据映射成为数组下标的操作一般被称为哈希函数,也就是我们哈希表的核心。

哈希操作: 高维空间向低维空间的一种映射,低维空间的大小可能远小于高维空间的大小

设计

哈希表是一个设计感极强的结构。我们要设计一个哈希表,首先需要确定哈希函数怎么设计,不同的数据类型,哈希函数的设计和实现方式不一样,即使是相同的数据类型,哈希函数也有也有不同的设计方式和实现细节。而设计完哈希函数后,我们又要设计如何在出现哈希碰撞问题时解决哈希碰撞,用什么方式解决等等。

哈希函数

哈希函数就是将我们的任意数据映射成为数组下标的一个方法。但是使用这个方法生成的下标,可能会存在哈希冲突(两个不同的数据映射到了同一个数组下标),我们就需要去解决哈希冲突。

哈希冲突(哈希碰撞)

上面说了,哈希表的哈希操作就是高维空间向低维空间的一种映射。举个例子,我们的到电影院看电影,电影院的座位就可以看做是低维空间,而我们的人群就是高维空间,人群的数量肯定是远大于电影院座位的数量的,这样,当我们去买票时,就不可避免可能两个人要坐同一个位置的情况,这种情况在哈希表中叫做哈希冲突/碰撞

哈希冲突/碰撞是不可能完全避免的,就像人类社会无法完全禁止偷窃行为一样,因此,当我们面对哈希冲突问题事,千万不要尝试着自己能不能设计出某种算法或数据结构,完全避免呢?即使你真的设计出来了,这还是哈希表吗?但是,虽然我们不能完全禁止,但我们可以想办法降低偷窃行为发生的概率,我们可以用法律手段对偷窃者施以惩戒,从而降低偷窃事件的发生。同样的,我们的哈希冲突/碰撞虽然无法完全避免,我们可以在发生冲突时,想办法解决这个冲突。还是上面的例子,当两个人的座位被安排到同一个位置时,我们可以安排一些临时座位,让后来者到临时座位就坐,从而解决冲突的问题,当然,决绝冲突的方式有很多种,下面会给大家介绍4中解决哈希冲突的方法。

# 以下为哈希冲突的一种模拟,[x]代表原始数据,(x)代表根据哈希函数生成的下标,假设我们的数组空间长度为9,*代表数组该位为空
arr = [*,*,*,*,*,*,*,*,*]

# 使用哈希函数产生下标映射
[16] -> 哈希函数处理(val % arr.length) -> 16 % 9 -> (7)
# 将数据放到数组的第7位
arr = [*,*,*,*,*,*,*,16,*]

# 假设再来了一个数字7
[7] -> 哈希函数处理(val % arr.length) -> 7 % 9 -> (7)
# 产生的映射下标依然是7,这样就会出现7和16需要放在统一个数组下标中的情况,这就是哈希冲突/碰撞
arr = [*,*,*,*,*,*,*,(16 7),*]

解决哈希冲突的方法

1. 开放定址法

在已经计算出来的下标idx1的基础上,通过某些计算规则,在计算出一个下标idx2用来存储。

例如开放定址法中有一种方式叫做线性探测法。他的计算规则很简单,在我们发生冲突的下标idx1的基础上加一,及idx2 = idx1 + 1

# 使用开放定址法中的线性探测法解决哈希冲突

# ...如上示例
# 假设再来了一个数字7
[7] -> 哈希函数处理(val % arr.length) -> 7 % 9 -> (7)
# 此时发现7这个索引下已经被占用了,那我们就直接往后找一位
(7) -> 线性探测法计算规则(+1) -> 7+1 -> 8
# 因此产生了一个新的索引位置
arr = [*,*,*,*,*,*,*,16,7]

上面说的只是开放定制发的其中一种情况,开放定制方的计算规则其实是很灵活的,可以使用各种计算规则打到目的,除了上述的线性探测法之外,还有比如说二次再散列法,第一次冲突加上1^2,第二次冲突加上2^2...,第n次冲突加上n^2

// 使用BKDRHash+开放定址法:平方探测法解决冲突的哈希表设计
class HashTable {
    // 哈希表中存储元素数量
    count=0;
    // 哈希表存储数据的数组
    data;
    // 用来记录数据有没有存储过的数组
    flag;
    // 定义一个素数种子
    static seed = 31;
    constructor(size=10){
        this.data = new Array(size);
        this.flag = new Array(size);
        // *用于调试时打印辅助显示的数据,代表数组该为为空,与程序实现逻辑无关,可忽略
        this.data.fill('*');
        this.flag.fill(0);
    }
    insert(key) {
        // 计算字符串的哈希值
        let idx = this.__hashFn(key);
        // 处理哈希冲突
        idx = this.__reCalcFn(idx, key);
        // 如果当前位置没有存储过元素
        if(!this.flag[idx]) {
            this.data[idx] = key;
            // 标记当前下标已经有数据了
            this.flag[idx] = 1;
            // 元素计数器加1
            this.count += 1;
            // 判断装填因子是否超过0.75时对哈希表进行扩容
            if(this.count/this.data.length > .75) {
                // 以下两行为调试相关代码,不影响逻辑
                console.log('\n扩容前哈希表:');
                this.output();
                // 进行扩容
                this.__expand();
            }
            console.log(`data[ ${key}\t=>\t${idx} ]\t=\t${key}`);
        }
        
    }
    find(key) {
        // 计算字符串的哈希值
        let idx = this.__hashFn(key);
        // 处理哈希冲突
        idx = this.__reCalcFn(idx, key);
        return this.data[idx];
    }

    output(){
        const arr = this.data;
        console.log('\n');
        console.log('哈希表数据存储区存储内容:\n');
        console.table(arr);
        console.log('\n');
    }
    // 哈希表扩容
    __expand() {
        // 定义一个容量是原先哈希表2倍的新哈希表,把旧哈希表的数据搬过去
        let n = this.data.length * 2;
        const hashTable = new HashTable(n);
        console.log('--------------------以下为扩容后操作-------------------------------')
        for(let i = 0; i < this.data.length; i++) {
            if(!this.flag[i]) continue;
            hashTable.insert(this.data[i]);
        }
        // 用新哈希表覆盖原先的哈希表
        this.data = hashTable.data;
        this.count = hashTable.count;
        this.flag = hashTable.flag;
    }
    // 哈希函数
    __hashFn(s) {
        // 使用BKDRHash映射字符串的数组下标
        let hash = 0;
        for(let i=0;i<s.length;i++) {
            const char  = s[i];
            hash = hash * HashTable.seed + char.charCodeAt(0);
        }
        // 一定要有这个求模操作,不然产生的数组下标会很大,超出数组容量范围
        hash %= this.data.length;
        return hash;
    }
    // 解决冲突
    __reCalcFn(idx, key) {
        // 当前索引已经存过值时进入循环
        let base = 1;
        while(this.flag[idx] && this.data[idx] !== key) {
            // 使用开发定址法:平方探测法
            idx += base * base;
            base += 1;
            idx %= this.data.length
        }
        return idx;
    }

}

let hash = new HashTable(5);
hash.insert("name");
hash.insert("name2");
hash.insert("name3");
hash.insert("name4");
hash.insert("name5");
hash.insert("name6");
hash.insert("name7");
hash.output();
console.log('查找name3的值:', hash.find('name3'));
console.log('查找name4的值:', hash.find('name4'));
console.log('查找name6的值:', hash.find('name6'));
console.log('查找name7的值:', hash.find('name7'));
// 使用开放定址法:平方探测法解决冲突的输出结果

// data[ name      =>      2 ]     =       name
// data[ name2     =>      3 ]     =       name2
// data[ name3     =>      4 ]     =       name3

// 扩容前哈希表:


// 哈希表数据存储区存储内容:

// ┌─────────┬─────────┐
// │ (index) │ Values  │
// ├─────────┼─────────┤
// │    0    │ 'name4' │
// │    1    │   '*'   │
// │    2    │ 'name'  │
// │    3    │ 'name2' │
// │    4    │ 'name3' │
// └─────────┴─────────┘


// --------------------以下为扩容后操作-------------------------------
// data[ name4     =>      9 ]     =       name4
// data[ name      =>      7 ]     =       name
// data[ name2     =>      8 ]     =       name2
// data[ name3     =>      3 ]     =       name3
// data[ name4     =>      0 ]     =       name4
// data[ name5     =>      0 ]     =       name5
// data[ name6     =>      1 ]     =       name6
// data[ name7     =>      2 ]     =       name7


// 哈希表数据存储区存储内容:

// ┌─────────┬─────────┐
// │ (index) │ Values  │
// ├─────────┼─────────┤
// │    0    │ 'name5' │
// │    1    │ 'name6' │
// │    2    │ 'name7' │
// │    3    │ 'name3' │
// │    4    │   '*'   │
// │    5    │   '*'   │
// │    6    │   '*'   │
// │    7    │ 'name'  │
// │    8    │ 'name2' │
// │    9    │ 'name4' │
// └─────────┴─────────┘


// 查找name3的值: name3
// 查找name4的值: name4
// 查找name6的值: name6
// 查找name7的值: name7

2. 再哈希法

使用再哈希法解决哈希冲突的哈希表,可能存在多个哈希函数,我们先用第一个哈希函数先算出来一个索引idx1,如果这个索引发生了冲突,那我们就用第二个哈希函数再计算一次索引idx2...。

这种方法有种治标不治本的感觉,因为我们有可能设计的所有的哈希函数产生的索引都冲突了,最终还是会出现异常,因此不建议单独使用,一般这种方法都是配合其他的处理冲突的方法一起使用,在使用再哈希法无法解决哈希冲突时,需要有其他的方法兜底,确保哈希表的正常功能。

# 使用再哈希法解决哈希冲突

# ...如上示例
# 假设再来了一个数字7
[7] -> 哈希函数1处理(val % arr.length) -> 7 % 9 -> (7)
# 此时发现7这个索引下已经被占用了,通过第二个哈希函数再次进行哈希运算
(7) -> 哈希函数2处理(val % (arr.length>>1)) -> 7 % (9 >> 1) -> 7 % 4 -> 3
# 因此产生了一个新的索引位置
arr = [*,*,*,7,*,*,*,16,*]

PS: 再哈希法实际上只是多设计几种哈希函数,实现逻辑没有什么区别,无非是冲突时再计算一次哈希,因此就不再做代码演示了

3. 建立公共溢出区

我们可以使用红黑树设计一个公共溢出缓存区,然后将发生冲突的数据放入到公共溢出缓存区当中,在查找时,如果在哈希表中找不到,就去公共溢出区中查找。那么为啥要使用红黑树而不是其他的数据结构呢?其实就是为了提升查找的性能,使用红黑树查找的事件复杂度为O(logn)

# 使用建立公共溢出区解决哈希冲突

# ...如上示例
# 假设再来了一个数字7
[7] -> 哈希函数1处理(val % arr.length) -> 7 % 9 -> (7)
# 此时发现7这个索引下已经被占用了,通过第二个哈希函数再次进行哈希运算
红黑树公共溢出区 tmp = [7]
# 因此产生了一个新的索引位置
arr = [*,*,*,7,*,*,*,16,*]
tmp = [7]

在我们大部分的语言中,Set的实现底层就是红黑树,因此,下面我们使用Set作为公共溢出缓冲区实现一个哈希表

// 使用BKDRHash + 建立公共溢出区法解决哈希冲突的哈希表设计
class HashTable {
    // 哈希表中存储元素数量
    count=0;
    // 哈希表存储数据的数组
    data;
    // 用来记录数据有没有存储过的数组
    flag;
    // 公共溢出缓冲区
    buff = new Set();
    // 定义一个素数种子
    static seed = 31;
    constructor(size=10){
        this.data = new Array(size);
        this.flag = new Array(size);
        this.data.fill('*');
        this.flag.fill(0);
    }
    insert(key) {
        // 计算字符串的哈希值
        let idx = this.__hashFn(key);
        // 公共溢出区法无需处理索引解决哈希冲突
        // idx = this.__reCalcFn(idx, key);
        // 如果当前位置没有存储过元素
        if(!this.flag[idx]) {
            this.data[idx] = key;
            // 标记当前下标已经有数据了
            this.flag[idx] = 1;
            // 元素计数器加1
            this.count += 1;
            // 判断装填因子是否超过0.75时对哈希表进行扩容
            if(this.count/this.data.length > .75) {
                console.log('\n扩容前哈希表:');
                this.output();
                this.__expand();
            }
            console.log(`data[ ${key}\t=>\t${idx} ]\t=\t${key}`);
        } else if (this.data[idx] !== key){
            // 如果当前插入的位置已经有值的时候,将待插入的值放入到公共溢出缓存区
            this.buff.add(key);
        }
        
    }
    find(key) {
        // 计算字符串的哈希值
        let idx = this.__hashFn(key);
        // 公共溢出区法无需处理索引解决哈希冲突
        // idx = this.__reCalcFn(idx, key);
        // 如果当前索引不存在值,则return null
        if(!this.flag[idx]) return null;
        // 如果存在值,先到哈希表中看看有没有我们要找的值
        if(this.data[idx] === key) return this.data[idx];
        // 如果不存在,就去公共溢出区中查找
        if(this.buff.has(key)) return key;
        // 如果公共溢出区也没有找到,就直接返回null
        return null;
    }

    output(){
        const arr = this.data;
        console.log('\n');
        console.log('哈希表数据存储区存储内容:\n');
        console.table(arr);
        console.log('\n');
    }
    // 哈希表扩容
    __expand() {
        let n = this.data.length * 2;
        const hashTable = new HashTable(n);
        console.log('--------------------以下为扩容后操作-------------------------------')
        for(let i = 0; i < this.data.length; i++) {
            if(!this.flag[i]) continue;
            hashTable.insert(this.data[i]);
        }
        // 再将公共溢出区中的元素插入到新的哈希表中
        this.buff.forEach(key => hashTable.insert(key));
        this.data = hashTable.data;
        this.count = hashTable.count;
        this.flag = hashTable.flag;
    }
    // 哈希函数
    __hashFn(s) {
        // 使用BKDRHash映射字符串的数组下标
        let hash = 0;
        for(let i=0;i<s.length;i++) {
            const char  = s[i];
            hash = hash * HashTable.seed + char.charCodeAt(0);
        }
        hash %= this.data.length;
        return hash;
    }
    // 解决冲突
    // __reCalcFn(idx, key) {
    //     // 当当前索引已经存过值时进入循环
    //     let base = 1;
    //     while(this.flag[idx] && this.data[idx] !== key) {
    //         // 使用开发定址法:平方探测法
    //         idx += base * base;
    //         base += 1;
    //         idx %= this.data.length
    //     }
    //     return idx;
    // }

}

let hash = new HashTable(5);
hash.insert("name");
hash.insert("name2");
hash.insert("name3");
hash.insert("name4");
hash.insert("name5");
hash.insert("name6");
hash.insert("name7");
hash.output();
console.log('查找name3的值:', hash.find('name3'));
console.log('查找name4的值:', hash.find('name4'));
console.log('查找name6的值:', hash.find('name6'));
console.log('查找name7的值:', hash.find('name7'));
// 使用建立公共溢出区法解决冲突的输出结果

// data[ name      =>      2 ]     =       name
// data[ name3     =>      3 ]     =       name3
// data[ name4     =>      4 ]     =       name4

// 扩容前哈希表:


// 哈希表数据存储区存储内容:

// ┌─────────┬─────────┐
// │ (index) │ Values  │
// ├─────────┼─────────┤
// │    0    │ 'name5' │
// │    1    │   '*'   │
// │    2    │ 'name'  │
// │    3    │ 'name3' │
// │    4    │ 'name4' │
// └─────────┴─────────┘


// --------------------以下为扩容后操作-------------------------------
// data[ name5     =>      0 ]     =       name5
// data[ name      =>      7 ]     =       name
// data[ name3     =>      8 ]     =       name3
// data[ name4     =>      9 ]     =       name4
// data[ name5     =>      0 ]     =       name5
// data[ name6     =>      1 ]     =       name6
// data[ name7     =>      2 ]     =       name7


// 哈希表数据存储区存储内容:

// ┌─────────┬─────────┐
// │ (index) │ Values  │
// ├─────────┼─────────┤
// │    0    │ 'name5' │
// │    1    │ 'name6' │
// │    2    │ 'name7' │
// │    3    │   '*'   │
// │    4    │   '*'   │
// │    5    │   '*'   │
// │    6    │   '*'   │
// │    7    │ 'name'  │
// │    8    │ 'name3' │
// │    9    │ 'name4' │
// └─────────┴─────────┘


// 查找name3的值: name3
// 查找name4的值: name4
// 查找name6的值: name6
// 查找name7的值: name7

4. 链式地址法(拉练法,推荐使用此方法)

拉练法就是利用了链表可以随意高效增加新节点的特点,在我们的哈希表中存储的不是真实的数据,而是我们链表的一个头指针(注意,头指针不存放数据),如果有两个数据发生碰撞,都想放到同一个索引下面时,我们可以让这两个数据直接挂到数组索引对应的链表的节点中去。在查找时,我们通过哈希函数计算出了数组索引,然后顺着数组索引存的链表头结点依次往下查找就可以了。

拉练法图示

// 使用BKDRHash + 拉练法解决哈希冲突的哈希表设计

// 定义一个简单的链表节点
class LinkNode {
    data;
    next;
    constructor(data, next=null) {
        if(data) this.data = data;
        if(next) this.next = next;
    }
    insert(node) {
        node.next = this.next;
        this.next = node;
    }
    output() {
        let p = this;
        const tmp = [];
        while(p) {
            tmp.push(p.data);
            p = p.next;
        }
        console.log('当前链表:', tmp.join(' -> '));
    }
    toString() {
        const tmp = [];
        let p = this;
        while(p) {
            p.data!=='*' &&tmp.push(p.data);
            p = p.next;
        }
        return tmp.join('|');
    }
}

class HashTable {
    // 哈希表中存储元素数量
    count=0;
    // 哈希表存储数据的数组
    data;
    // 定义一个素数种子
    static seed = 31;
    constructor(size=10){
        this.data = new Array(size);
        // 将哈希表输出存储区的每一个位置都放一个链表头结点上去,这个头结点不存数据
        for(let i=0;i<size;i++){
            this.data[i] = new LinkNode("*");
        }
    }
    insert(key) {
        // 计算字符串的哈希值
        let idx = this.__hashFn(key);
        // 如果当前位置没有存储过元素,则将数据存储到下标对应的链表的后面
        let p = this.data[idx];
        let head = p;
        // 找到链表最后一个节点并且最后一个节点的数据不能是当前要插入的值,否则就重复插入了
        while(p.next && p.next.data !== key) p = p.next;
        // 找到最后一个节点,在他后面插入新的链表节点
        if(p) {
            p.insert(new LinkNode(key));
            console.log(`data[ ${idx} ]\t=\t${key}`);
            head.output();
            // 插入节点后,计数器加1
            this.count += 1;
            // 由于使用拉练法不存在哈希表空间不够的情况,因为可以一直往链表后面加新节点
            // 但如果链表节点过多时,有可能影响查找效率,因此我们才去一个这种方案
            // 当平均每个链表的长度小于3时,其实我们要找到一个值的效率并不差,不需要扩容,只有当大于3时,我们再扩容哈希表
            if(this.count / this.data.length > 3) this.__expand();
            
        }
    }
    find(key) {
        // 计算字符串的哈希值
        let idx = this.__hashFn(key);
        // 由于链表的头结点不存储值,因此我们从找到索引位置链表头结点的下一个节点开始寻找
        let p = this.data[idx].next;
        // 查找目标值
        while(p && p.data !== key) p = p.next;
        // 如果此时p为空,说明要查找的数据不存在,返回null
        if(!p) return null;
        // 否则返回待查找值
        return p.data;
    }

    output(){
        const arr = this.data;
        console.log('\n');
        console.log('哈希表数据存储区存储内容:\n');
        console.table(arr.map(item=>item.toString()));
    }
    // 哈希表扩容
    __expand() {
        let n = this.data.length * 2;
        const hashTable = new HashTable(n);
        console.log('--------------------以下为扩容后操作-------------------------------')
        for(let i = 0; i < this.data.length; i++) {
            // 将数组每一个索引中存储的链表复制到新的哈希表中
            let p = this.data[i].next;
            while(p) {
                hashTable.insert(p.data);
                p = p.next;
            }
        }
        this.data = hashTable.data;
        this.count = hashTable.count;

    }
    // 哈希函数
    __hashFn(s) {
        // 使用BKDRHash映射字符串的数组下标
        let hash = 0;
        for(let i=0;i<s.length;i++) {
            const char  = s[i];
            hash = hash * HashTable.seed + char.charCodeAt(0);
        }
        hash %= this.data.length;
        return hash;
    }
    // 解决冲突
    // __reCalcFn(idx, key) {
    //     // 当当前索引已经存过值时进入循环
    //     let base = 1;
    //     while(this.flag[idx] && this.data[idx] !== key) {
    //         // 使用开发定址法:平方探测法
    //         idx += base * base;
    //         base += 1;
    //         idx %= this.data.length
    //     }
    //     return idx;
    // }

}

let hash = new HashTable(5);
hash.insert("name");
hash.insert("name2");
hash.insert("name3");
hash.insert("name4");
hash.insert("name5");
hash.insert("name6");
hash.insert("name7");
hash.output();
console.log('查找name3的值:', hash.find('name3'));
console.log('查找name4的值:', hash.find('name4'));
console.log('查找name6的值:', hash.find('name6'));
console.log('查找name7的值:', hash.find('name7'));
// 使用简历公共溢出区法解决冲突的输出结果

// data[ 2 ]       =       name
// 当前链表: * -> name
// data[ 2 ]       =       name2
// 当前链表: * -> name -> name2
// data[ 3 ]       =       name3
// 当前链表: * -> name3
// data[ 4 ]       =       name4
// 当前链表: * -> name4
// data[ 0 ]       =       name5
// 当前链表: * -> name5
// data[ 1 ]       =       name6
// 当前链表: * -> name6
// data[ 2 ]       =       name7
// 当前链表: * -> name -> name2 -> name7


// 哈希表数据存储区存储内容:

// ┌─────────┬────────────────────┐
// │ (index) │       Values       │
// ├─────────┼────────────────────┤
// │    0    │      'name5'       │
// │    1    │      'name6'       │
// │    2    │ 'name|name2|name7' │
// │    3    │      'name3'       │
// │    4    │      'name4'       │
// └─────────┴────────────────────┘
// 查找name3的值: name3
// 查找name4的值: name4
// 查找name6的值: name6
// 查找name7的值: name7

装填因子

由于我们的哈希表大小是固定的,比如我们哈希表的大小是10,如果你放11个元素进去,肯定就会撑爆,因此就引申出来一个叫做装填因子的概念。

装填因子 = 储存元素的个数 / 哈希表总容量

一般当装填因子>0.75时,我们就认为哈希表快满了,我们就要想办法进行扩容了。

哈希表扩容

当我们装填因子大于0.75时,我们需要对哈希表进行扩容,其实就是新建一个原哈希表两倍的哈希表,然后把原哈希表的数据倒腾过去,然后再将新哈希表覆盖原哈希表即可,具体代码实现在在上面解决哈希冲突部分的代码演示中已经给出来,这里就不再赘述。

再此插一句,可能有些同学会说,每次扩容都这么简单粗暴的直接把原哈希表的数据倒腾到新哈希表,效率会不会很低呀?其实不会的,我们来想想,假如我们经过了多轮的扩容操作,最终哈希表大小为n,那么我们历史扩容操作从原哈希表倒腾到新哈希表的次数为:

# 由于每次我们都将哈希表扩容一倍,那么最后一次操作我们最多需要将n/2个元素倒腾到新哈希表中(实际是没有达到n/2的,因为我们再装填因子等于0.75的时候就开始扩容了,所以永远不可能达到n/2,这边是为了方便分析,所以取n/2)
n/2 + n/4 + n/8 + .....  =>  ≈ n
# 就算我们每次操作都取最大值,最终加起来,最大也就只有n次操作,因此,我们扩容操作的事件复杂度是O(n)的,效率并不低

而哈希表整体扩容的时间复杂度是O(n),但我们在分析哈希表时间复杂度的时候,一般是分析均摊复杂度,即将操作时间均摊到每一个元素身上。而我们扩容操作的均摊复杂度O(1)的。

传统哈希表与布隆过滤器(传统哈希表的变种)

  • 传统哈希表储存空间与元素数量有关
  • 布隆过滤器储存空间与元素数量无关

布隆过滤器出现的意义(传统哈希表不能解决端的问题)

当我们实现一个爬虫去爬取网络上的资源的时候,通常会将已经爬取过的url标记一下,下次再遇到相同的url时,就无需重复爬取。这个操作传统方式都直接使用哈希表进行存储。但是,由于传统哈希表的存储空间与元素数量正相关,又由于近现代网页动态化程度极大,网页中的url数量及其庞大,甚至有可能你要完成一次爬取任务,需要几TB的存储空间用来存储爬取过的网页,会造成相当大的储存资源占用。此时,工程师们就极度渴望一种存储空间与元素数量无关的类似的数据结构来帮我们解决这个问题。

布隆过滤器的特点

布隆过滤器与传统的哈希表一样,有一个存储空间,但是布隆过滤器的这个存储空间不是用来存储数据的,而是用来存储二进制标记用的。布隆过滤器有一组哈希函数(假如这组哈希函数有3个),同一个数据经过这组哈希函数的处理之后,就得到了三个数组下标,那么,我们就直接将数组中对应下标的数据标记为1。然后,当我们判断时,必须这三个位置的数字都为1才表示数据有可能存在(没办法完全保证),否则只要有一个是0就代表数据不存在。

根据上面的特点,我们总结一下:**布隆过滤器无需存储具体的某个值,并且它只能判断某个是否不存在,而不能百分百确定某个数据存在,因此,布隆过滤器有一定的误判率。**这样的一个特点,特别适合刚刚说的爬虫爬取的场景,我不需要知道这个网址确切的信息,你只要告诉我,这个网址我没有爬取过,那我就去爬取就得了。

使用场景

  • 大数据量场景,如上面说的规模极大的爬虫程序
  • 对于信息安全有要求的场景:传统哈希表因为需要将原始数据存储到哈希表中,一旦哈希表被人盗取,信息就直接泄露了。但如果使用布隆过滤器,因为其中存储的是二进制的标记,并未存储真实数据,即使被盗取也不会造成数据泄密,适合一些对信息安全要求比较高的程序。

JS中的哈希表

上面讲了那么多哈希表的基础知识和扩展,接下来再看看,在我们的js中,我们经常会用到的数据结构里,都有哪些底层是哈希表的。

  • JS对象(JSON): 我们最常用的json和js对象其实其底层就是哈希表,这也是为什么我们可以快速地通过属性查找目标值的原因

  • Map:一个 Map的键可以是任意值,包括函数、对象或任意基本类型。

  • WeakMap:WeakMap的键必须是一个对象,这个对象时一个弱引用的关系,这个数据类型就是为了解决Map类型的键一直引用某个对象导致无法被释放,而造成内存泄露的问题。使用WeakMap时,一旦键所引用的对象被销毁,那我们的数据也会从WeakMap中被移除,这个在Vue3的源码中,实现数据响应化时,就是用了WeakMap来存储响应化后和原始数据间的关系。

    > let obje = {name: 'kiner'};let map = new WeakMap();
    > map.set(obje, '哈哈哈');
    > map.get(obje)
    > "哈哈哈"
    > obje = null
    > map.get(obje)
    > undefined
    

哈希思想在字符串匹配中的技巧

单模匹配

在一长串的字符串(文本串)中,查找其中某一个子串(模式串)是否存在的一类问题被称为单模匹配问题

一般的暴力破解方式是让模式串的起点跟文本串的每一位尝试进行对齐匹配,如果某一位没有匹配上,就进行下一位的匹配。这种方式无疑是比较低效的。现在我们就来看一下如何实现更高效的基于哈希的单模匹配算法。

基于哈希的单模匹配算法

首先,先将我们的模式串用哈希函数先求出一个哈希值,然后再看一下文本串中的每一段(每段的长度为模式串的长度)的哈希是否与模式串的哈希相同,如果找到了相同的地方,由于存在哈希碰撞的可能,因此,哈希相同不代表子串跟模式串就完全匹配,我们还需要使用暴力破解法确定这一串字串是否与模式串相同。

效率分析

假设文本串的长度为n,模式串的长度为m,假设我们生成的哈希值有p种可能性。

  • 暴力匹配算法时间复杂度:O(n*m)
  • 基于哈希的单模匹配算法由于这种方法下,只有发生了哈希碰撞时才会进行暴力匹配,因此我们进行暴力匹配的概率是n/p,而我们暴力匹配总共要匹配m次,再加上刚开始我们计算哈希值的时候扫描了一遍文本串,因此时间复杂度:O(n+n/p*m)

从上面的对比可以发现,当我们计算出来的哈希值越随机,其种类越多,我们的单模匹配算法的效率就会越高,而当p大到接近于n时,p和n就可以互相约掉,最终时间复杂度可以达到O(n+m)

哈希算法的设计

分析过了单模匹配算法给程序带来的效率提升之后,再来看看我们要怎么去设计这个哈希函数呢?这个需要了解滑动窗口的算法技巧。

那么,什么是滑动窗口呢?

滑动窗口图示

根据上面的图示,其实我们哈希算法的设计中,最重要的一部分应该是找到一个方法,能够让我们快速根据h1计算得到h2。而根据上面图示,我们不难看出,其实移动前和移动后,*位置的字符其实是没有变化的,变化的只有符号@和&,我们要做的是从上一次的哈希值中,减去@移除带给哈希值的影响,再加上&给哈希值带来的影响就可以了。

代码演示

// 这边的哈希函数直接使用我们待查找字符的ASCII之和作为hash值,
// 这样后续就不需要重复累加所有字符的ASCII码计算哈希值了,只需要减去头部加上尾部的ASCII就行
function hashFn(str) {
    let res = 0;
    for(let char of str) {
        res+=char.charCodeAt(0);
    }
    return res;
}
function findSubStr(str, subStr){
    // 子串哈希值
    const childHash = hashFn(subStr);
    const childLen = subStr.length;
    // 初始计算一次str中第一个长度为模式串长度子串的哈希值
    let h1 = hashFn(str.substr(0, childLen));
    // 用于暴力遍历当前子串是否与模式串匹配
    function doMatch(start=0,end){
        for(let i=start;i<end;i++) {
            if(str[i]!==subStr[i-start]) return false;
        }
        return true;
    }
    // 如果哈希值冲突并且子串与模式串匹配则直接返回目标子串
    if(h1 === childHash) {
        if(doMatch(0, childLen)) {
            return str.substr(0, childLen);
        }
    }
    // 循环子串直至文本串长度-模式串长度+1的位置
    for(let i=1;i<str.length - childLen + 1;i++) {
        // 根据滑动窗口原理,通过上一个哈希值快速计算本次一个哈希值
        // h2 = h1 - 上一次窗口第一个字符的ASCII + 本次窗口最后一个字符的ASCII
        const h2 = h1 - str[i-1].charCodeAt(0) + str[i + childLen - 1].charCodeAt(0);
        // 覆盖上一个哈希值,方便下一轮的计算
        h1 = h2;
        // 比较本次哈希值相等并且保理匹配通过则返回目标子串
        if(h2===childHash) {
            if(doMatch(i, i+childLen)) {
                return str.substr(i, childLen);
            }
        }
        
    }
    // 循环结束还没找到则返回false
    return false;
}

console.log(findSubStr('fdasdfsdfdsagdfghjkdgh', 'dasd'));
console.log(findSubStr('fdasdfsdfdsagdfghjkdgh', 'jkd'));
console.log(findSubStr('fdasdfsdfdsagdfghjkdgh', 'ghc'));
console.log(findSubStr('fdasdfsdfdsagdfghjkdgh', 'fff'));
console.log(findSubStr('fdasdfsdfdsagdfghjkdgh', 'sagdf'));
console.log(findSubStr('fdasdfsdfdsagdfghjkdgh', 'fdasdfsdfdsagdfghjkdgh'));

扩展:哈希链表

我们之前在学习链表的时候,有讲过哈希链表这样一个即能兼顾哈希表快速索引的优点,又能保留链表高效新增、删除、移动元素的优点的一个优秀数据结构。而哈希链表就是将我们的双向链表哈希表结合的一个新产物。哈希链表LRU缓存淘汰算法的基础,在我们的Vue3就是用了LRU缓存淘汰算法优化单文件组件的编译流程,避免系统中单文件组件过多导致内存“爆炸”。[Vue3中使用的LRU缓存淘汰算法