当前位置: 首页 > news >正文

数据结构学习笔记 4-2 哈希表与布隆过滤器 与 LeetCode真题(Java)

喜欢该类型文章可以给博主点个关注,博主会持续输出此类型的文章,知识点很全面,再加上LeetCode的真题练习,每一个LeetCode题解我都写了详细注释,比较适合新手入门数据结构与算法,后续也会更新进阶的文章。
课件参考—开课吧《门徒计划》

4-2 哈希表与布隆过滤器

哈希表基础知识

f u n c ( k e y ) = v a l u e func(key) = value func(key)=value

image-20220807111503379

下标为 0 − 8 0-8 08,假设要存的值 v a l = 16 val=16 val=16,那么怎么能将它不越界的存到这几个下标中呢?我们可以进行取余操作, 16   %   9 = 7 16\ \%\ 9=7 16 % 9=7

image-20220807112148229

可以把哈希表理解为升级版的数组,哈希表不单单可以存储数字,它的下标可以是任意类型,最终对应的值也可以是任意类型。

Map<String, Integer> map = new HashMap<>(); // key => value

哈希函数

哈希函数是一个很随意化的东西,你只要能把传入的任意字符换换成对应的数字 它就可以作为哈希函数。

  • 计算出来的值一定是正数(作为下标)
  • k e y 1 = k e y 2 key1=key2 key1=key2 => h a s h 1 = h a s h 2 hash1=hash2 hash1=hash2
  • k e y 1 ≠ k e y 2 key1≠key2 key1=key2 => h a s h 1 ≠ h a s h 2 hash1≠hash2 hash1=hash2(目前所有的哈希函数都无法实现,因为会存在哈希冲突,而我们需要学习怎么解决哈希冲突)

查找效率为 O ( 1 ) O(1) O(1)

哈希冲突

此时还要存一个值 7 7 7,那么 7   %   9 = 7 7\ \%\ 9=7 7 % 9=7,此时会跟 16 16 16 存到同样的位置,造成了哈希冲突

image-20220807113555576

哈希冲突处理方法

image-20220807113702482

1. 开放定址法(线性探测法)

最粗暴最简单的方法,假设当前下标已经有了元素,那么直接将它存入该下标的下一个位置,假如下一个位置也有元素,那么再继续往下,直到找到某个下标位置为空:

image-20220807114050427

但是如果这个哈希表中的元素已经很多了,那么每次查找就不可能是 O ( 1 ) O(1) O(1) 了,最坏情况下会退化成 O ( N ) O(N) O(N)

我们可以试着增加步长,以指数的方式增加下标:

image-20220807115510368

开放定址法也存在着缺点,假设通过开放定址法存入三个 7 7 7,那么下标位置肯定会依次存放在 7 、 8 、 9 7、8、9 789,但如果我们把下标为 8 8 8 对应的元素删除,那么此时会找不到下标为 9 9 9 的元素,因为当 8 8 8 这个位置没有元素,会误认为这个哈希表中并未出现过哈希冲突,所以就不会再往下遍历,遍历不到下标为 9 9 9 的位置。

如何解决?可以在已删除元素的下标添加一个 d e l e t e d deleted deleted 标记,表明这个下标位置的元素被删除过。

image-20220807173834466

开放定址法局限性比较大。

但如果数据量小,存储元素的字节较小,开放定址法是一个不错的选择。

开放定址法代码实现

/*
  开放定址法
 */
public class Hash1 {
    public static void main(String[] args) {
        int op;
        String s; // 数字跟字符串做映射
        HashTable h = new HashTable(100);
        Scanner sc = new Scanner(System.in);
        while (true) {
            op = sc.nextInt();
            s = sc.next();
            switch (op) {
                case 1: 
                    h.insert(s); break;
                case 2:
                    System.out.println("find " + s + " : " + h.find(s));
            }
        }
    }
}

class HashTable {

    int cnt;
    String[] data;
    boolean[] flags;

    public HashTable(int n) {
        data = new String[n];
        flags = new boolean[n];
        cnt = 0;
    }

    public void insert(String s) {
        int ind = hash_func(s) % data.length; // get hash code
        recalc_ind(ind, s); // 如果出现冲突 重新计算下标 使用线性探测法
        if (!flags[ind]) {
            data[ind] = s;
            flags[ind] = true;
            cnt++;
            if (cnt * 100 > data.length * 75) { // 如果存的值大于75% 则进行扩容
                expand(); // 扩容函数
            }
        }
    }

    private void expand() {
        // 如何处理哈希搬运(从旧数组搬运到扩容的新数组中) 同样也是redis主键实现的方式
        /*
          当整个哈希函数需要扩容时 我们先把扩容的新数组创建出来
          当有insert操作时 直接插入到新数组中 当有find操作时 先从小(旧)数组中找
          如果找到 则将小数组的值拷贝到新数组中 再把小数组找到的值删掉 以此类推 小数组中的元素会越来越少 最后小数组会逐渐消失
          相当于延迟操作的效果 对于整个程序 把集中式的密集计算 变为了时间长度跨越较大的 离散式的计算 减少负载
         */
        int n = data.length * 2; // 扩容2倍
        HashTable h = new HashTable(n);
        for (int i = 0; i < data.length; i++) {
            if (!flags[i]) continue;
            h.insert(data[i]);
        }
    }

    public boolean find(String s) {
        int ind = hash_func(s) % data.length; // get hash code
        recalc_ind(ind, s); // 如果出现冲突 重新计算下标 使用线性探测法
        return flags[ind];
    }

    // 把传入的字符串转换成数字
    private int hash_func(String s) {
        int seed = 131, hash = 0;
        for (int i = 0; i < s.length(); i++) {
             /*
               比较常见的字符串哈希
               我们乘的这个seed:131可以不是一个固定值 只要是一个素数就可以
               使用哈希函数 每次迭代都乘上一个素数 那么最终算出来的值 很大的概率是平均的(数学公式推导证明出来的)
             */
            hash = hash * seed + s.charAt(i);
        }
        return hash & 0x7fffffff; // 保证哈希值一定是正数
    }

    // 重新计算 处理冲突 线性探测法
    private void recalc_ind(int ind, String s) {
        int t = 1;
        while (flags[ind] && !data[ind].equals(s)) {
            ind += t * t; // 指数倍递增
            t++;
            ind %= data.length;
        }
    }
}

image-20220808181127131

2. 再哈希法

顾名思义,一个哈希函数可能会造成哈希冲突,那么我们用一组哈希函数 来避免冲突:

image-20220807175212093

但其实再哈希法局限性更大,可能一组哈希函数还是会造成冲突。

再哈希法单独拿出来没什么用,需要跟布隆过滤器结合一起使用。

3. 建立公共溢出区

之前我们的 16 16 16 7 7 7 出现了冲突,这时我们可以开辟一个溢出缓冲区,把冲突的数字丢进去,再查找 先在哈希数组中找,找不到再从缓冲区中找:

image-20220807175518254

这个缓冲区可以是任意一种合适的数据结构:堆、红黑树、B树、链表(就是下面的拉链法)等。

建立公共溢出区代码实现

/*
  建立公共溢出区
 */
public class Hash2 {
    public static void main(String[] args) {
        int op;
        String s; // 数字跟字符串做映射
        HashTable h = new HashTable(100);
        Scanner sc = new Scanner(System.in);
        while (true) {
            op = sc.nextInt();
            s = sc.next();
            switch (op) {
                case 1:
                    h.insert(s); break;
                case 2:
                    System.out.println("find " + s + " : " + h.find(s));
            }
        }
    }
}

class HashTable {

    int cnt;
    String[] data;
    boolean[] flags;
    Set<String> buff; // 使用set集合作为缓冲区

    public HashTable(int n) {
        data = new String[n];
        flags = new boolean[n];
        cnt = 0;
    }

    public void insert(String s) {
        int ind = hash_func(s) % data.length; // get hash code
        if (!flags[ind]) {
            data[ind] = s;
            flags[ind] = true;
            cnt++;
            if (cnt * 100 > data.length * 75) { // 如果存的值大于75% 则进行扩容
                expand(); // 扩容函数
            }
        } else if (!data[ind].equals(s)) {
            buff.add(s); // 只要找不到 就放在缓冲区中
        }
    }

    private void expand() {
        // 如何处理哈希搬运(从旧数组搬运到扩容的新数组中) 同样也是redis主键实现的方式
        /*
          当整个哈希函数需要扩容时 我们先把扩容的新数组创建出来
          当有insert操作时 直接插入到新数组中 当有find操作时 先从小(旧)数组中找
          如果找到 则将小数组的值拷贝到新数组中 再把小数组找到的值删掉 以此类推 小数组中的元素会越来越少 最后小数组会逐渐消失
          相当于延迟操作的效果 对于整个程序 把集中式的密集计算 变为了时间长度跨越较大的 离散式的计算 减少负载
         */
        int n = data.length * 2; // 扩容2倍
        HashTable h = new HashTable(n);
        for (int i = 0; i < data.length; i++) {
            if (!flags[i]) continue;
            h.insert(data[i]);
        }
        for (String s : buff) {
            h.insert(s); // 把buff中的所有元素存入新数组中
        }
    }

    public boolean find(String s) {
        int ind = hash_func(s) % data.length; // get hash code
        if (!flags[ind]) return false;
        if (data[ind].equals(s)) return true;
        return buff.contains(s); // 当到了这一行 说明元素肯定存入了缓冲区中
    }

    // 把传入的字符串转换成数字
    private int hash_func(String s) {
        int seed = 131, hash = 0;
        for (int i = 0; i < s.length(); i++) {
             /*
               比较常见的字符串哈希
               我们乘的这个seed:131可以不是一个固定值 只要是一个素数就可以
               使用哈希函数 每次迭代都乘上一个素数 那么最终算出来的值 很大的概率是平均的(数学公式推导证明出来的)
             */
            hash = hash * seed + s.charAt(i);
        }
        return hash & 0x7fffffff; // 保证哈希值一定是正数
    }
}

image-20220808185236110

4. 链式地址法(拉链法)

拉链法是最常见的处理哈希冲突方式。

每一个数组下标存的不是具体的结点值,而是一个链表,哈希值相同的元素统统放到对应数组下标的链表中:

image-20220807175730426

链表有什么缺点,链式地址法就有什么缺点。

HashMap解决哈希冲突使用的就是拉链法

拉链法代码实现

/*
  拉链法
 */
public class Hash3 {
    public static void main(String[] args) {
        int op;
        String s; // 数字跟字符串做映射
        HashTable h = new HashTable(100);
        Scanner sc = new Scanner(System.in);
        while (true) {
            op = sc.nextInt();
            s = sc.next();
            switch (op) {
                case 1:
                    h.insert(s); break;
                case 2:
                    System.out.println("find " + s + " : " + h.find(s));
            }
        }
    }
}

class HashTable {

    int cnt;
    Node[] data;

    public HashTable(int n) {
        data = new Node[n];
        Arrays.fill(data, new Node()); // 初始化
        cnt = 0;
    }

    public void insert(String s) {
        int ind = hash_func(s) % data.length; // get hash code
        Node p = data[ind];
        // 不为空 且每一个节点都不等于s
        while (p.next != null && !p.next.data.equals(s)) p = p.next;
        if (p.next == null) {
            p.insert(new Node(s));
            cnt++;
            if (cnt * 3 > data.length)
                expand();
        }
    }

    private void expand() {
        int n = data.length * 2;
        HashTable h = new HashTable(n);
        for (int i = 0; i < data.length; i++) {
            Node p = data[i].next;
            while (p != null) {
                h.insert(p.data);
                p = p.next;
            }
        }
    }

    public boolean find(String s) {
        int ind = hash_func(s) % data.length; // get hash code
        Node p = data[ind];
        while (p.next != null && !p.next.data.equals(s)) p = p.next;
        return p.next != null; // 找到了则不为空
    }

    // 把传入的字符串转换成数字
    private int hash_func(String s) {
        int seed = 131, hash = 0;
        for (int i = 0; i < s.length(); i++) {
            hash = hash * seed + s.charAt(i);
        }
        return hash & 0x7fffffff;
    }
}

// 链表
class Node {

    String data;
    Node next;

    public Node() {
    }

    public Node(String data) {
        this.data = data;
    }

    public void insert(Node node) {
        node.next = this.next;
        this.next = node;
    }
}

布隆过滤器

只是简单的进行讲解,并无法讲的很彻底,我自认为理解的也不是很好。

image-20220827181402544

布隆过滤器启动后,元素数量是多少就是多少,几乎不会对存储空间进行扩容或缩容。

布隆过滤器是一个跟数学强相关的东西,在设计布隆过滤器时会通过一系列复杂的数学公式,来把传入的数据规模的数量、变化程度通过公式计算后,算出布隆过滤器最终的值,这个值也就是布隆过滤器启动时初始化的值。

假设有一个 k e y 1 key1 key1,把这个 k e y 1 key1 key1 同时传入这三个哈希函数中,这三个哈希函数的实现都各有不同,最终算出的值也不同,三个函数映射出三个位置来,把这三个位置都置为 1 1 1

image-20220827191855388

采用了布隆冲突器之后,产生冲突的概率就小了很多。

假设此时又传入了一个 k e y 2 key2 key2,通过三个哈希函数计算后,结果这三个值跟计算后 k e y 1 key1 key1 的三个值一样,发生了冲突,那怎么办呢?

布隆过滤器没有办法解决冲突。

问题没法解决,那我们就不让这个问题发生,想象一个场景:“过滤器”,布隆过滤器只起到一个作用:过滤

我只需要知道它对应的值是 1 1 1 还是 0 0 0,从而将它过滤。

布隆过滤器的底层就是二进制的 b i t m a p bitmap bitmap 位图,全部由二进制位组成的数组。

当某个 k e y key key 通过三个哈希函数计算后的位置,有一个位置是 0 0 0,则一定不会被过滤,三个位置如果全是 1 1 1,则有可能被过滤,为了确认这个可能性,我们可以用其他的方式确认一下(在线查询、查询数据库),即使慢一点也没什么关系,毕竟过滤的一定是少数,这些时间损耗都是可以接受得了的。

布隆过滤器 = b i t m a p + h a s h _ f u n c ( ) + 等一系列策略 布隆过滤器 = bitmap+hash\_func()+等一系列策略 布隆过滤器=bitmap+hash_func()+等一系列策略

布隆过滤器的应用非常广泛,比如微信小游戏的封禁功能就是使用的布隆过滤器。

LeetCode真题

经典面试题—哈希结构封装

LeetCode705. 设计哈希集合

难度:easy

模板题,用拉链法实现。

LeetCode题解:代码实现


LeetCode706. 设计哈希映射

难度:easy

新东西:void put(int key, int value) 向 HashMap 插入一个键值对 (key, value) 。如果 key 已经存在于映射中,则更新其对应的值 value

之前我们只存了key,现在要存键值对(key, value)

同样基于拉链法实现。

LeetCode题解:代码实现

经典面试题—哈希结构使用

LeetCode535. TinyURL 的加密与解密

难度:mid

long String <=> short String

怎么写加密算法没有限制,但要保证可以加密和解密。

我们就按照题中的加密串4e9iAk来模拟,有大写、小写字母,数字, 0 − 9 ,   a − z ,   A − Z 0-9,\ a-z,\ A-Z 09, az, AZ,总计 62 62 62 位;

通过哈希表存储键值对,key:加密后的字符串 => val:加密前的字符串,从而进行解密还原。

image-20220827212835360

LeetCode题解:代码实现


LeetCode187. 重复的DNA序列

难度:mid

这道题我们可以暴力一点,利用滑动窗口的思想来做,因为题中给的条件:返回所有在 DNA 分子中出现不止一次的 长度为 10 的序列(子字符串)。所以我们可以遍历整个串,把所有长度为 10 10 10 的子串存入哈希表中,再通过计数 就可以判断子串出现的次数了。

LeetCode题解:代码实现


LeetCode318. 最大单词长度乘积

难度:mid

题意很好理解,求两个不含有公共字母的字符串长度相乘的最大值。

朴素思想就是所有单词两两相乘做一个遍历,但是在乘之前需要判断这两个单词是否有公共字母,当然也可以逐一对比,但如何能快速的判断呢?

我们可以借助布隆过滤器的思想,利用二进制标记表示每个单词,两个单词进行与运算后结果为 0 0 0,则说明这两个字母一定没有公共字母。

LeetCode题解:代码实现


LeetCode面试题 16.25. LRU 缓存

难度:mid

简单实现一个LRU算法,之前在链表篇简单提到过,在操作系统中也有LRU(最近最久未使用)算法,选择最久未使用的元素,将它淘汰。

就比如示例1,缓存容量为 2 2 2 ,也就是只能存放 2 2 2 个元素,执行了cache.put(1, 1)cache.put(2, 2)之后,缓存中已经满了,此时又执行了cache.get(1),使用过了一次 1 1 1 这个元素,当下一步执行cache.put(3, 3),显然最久未使用的元素的 k e y key key 2 2 2,所以密钥 2 2 2 作废。

image-20220828151055905

这种维护每个元素的优先级,天然符合链表这个数据结构,队尾元素是最近使用的元素,队首元素是最久未使用的元素。

首先维护一个链表,当遇到 p u t ( ) put() put() 操作直接将元素插入到链表尾部,当遇到 g e t ( ) get() get() 操作,若有元素,则将元素更新到链表尾部;但这样可能需要遍历整个链表才可以找到该元素,如何快速的实现更新呢?我们可以在链表的基础上维护一个哈希表,存储每一个元素的地址,这样就可以快速更改链表的指向,实现更新操作。

这道题考察的就是数据结构的封装能力:

NodeList
   ↑
HashList
   ↑
  LRU

哈希表 + 链表:把数据在哈希表中存一份,在链表中存一份,当获取数据时从哈希表中查,插入时就在链表中写入,同时更新到哈希表中。

LeetCode题解:代码实现

总结

其实哈希表这个结构,就是一个升级版的数组,存储的是键值对 k e y = > v a l u e key=>value key=>value,给定一个 k e y key key,通过哈希函数计算 将 v a l u e value value 放到指定位置,一般哈希表都是用来快速判断一个元素是否出现集合里,实现 O ( 1 ) O(1) O(1) 的查找效率。

了解过java的HashMap就应该知道 HashMap解决哈希冲突就是使用的拉链法,在jdk1.8之后 链表长度超过8会转换为红黑树。

LRU缓存使用的哈希表+链表,在java中也有对应的实现类:LinkedHashMap,我之后也会阅读这个类的源码。

相关文章:

  • JAVA基础之动态代理
  • 轻量级神经网络算法系列文章-MobileNet v3
  • 聚苯乙烯负载酸性离子液体(P[Vim-PS][HSO4])|活性炭(AC)负载酸性离子液体[Hmim-BS][HSO4]齐岳
  • 视频流PS打包方式详解
  • BIM从业者的焦虑和困惑,你遇到了吗?
  • 携职教育:2022年初级会计考试证书领取流程及所需材料
  • iOS App怎么上架到苹果TestFlight?
  • 自动控制原理6.2---常用校正装置及其特性
  • Android——常用定时器
  • 堆排序-Head Sort
  • 【C++】wav文件解析(兼容性强)
  • 免费查题接口搭建
  • 多目标优化算法|用于全局和工程设计优化的多目标原子轨道搜索 (MOAOS)算法(Matlab代码实现)
  • [C++]:for循环for(int num : nums)
  • 3年测试经验,去面试连25K都拿不到了吗?现在测试这么坑?
  • 《Javascript数据结构和算法》笔记-「字典和散列表」
  • 【翻译】babel对TC39装饰器草案的实现
  • Java IO学习笔记一
  • Java的Interrupt与线程中断
  • k8s如何管理Pod
  • SpiderData 2019年2月13日 DApp数据排行榜
  • vue-cli3搭建项目
  • 前端工程化(Gulp、Webpack)-webpack
  • 前端学习笔记之原型——一张图说明`prototype`和`__proto__`的区别
  • 扫描识别控件Dynamic Web TWAIN v12.2发布,改进SSL证书
  • ​linux启动进程的方式
  • ​ssh免密码登录设置及问题总结
  • #if #elif #endif
  • #Linux(帮助手册)
  • (BFS)hdoj2377-Bus Pass
  • (PWM呼吸灯)合泰开发板HT66F2390-----点灯大师
  • (附源码)小程序儿童艺术培训机构教育管理小程序 毕业设计 201740
  • (规划)24届春招和25届暑假实习路线准备规划
  • (机器学习-深度学习快速入门)第一章第一节:Python环境和数据分析
  • (三)c52学习之旅-点亮LED灯
  • (原創) 如何解决make kernel时『clock skew detected』的warning? (OS) (Linux)
  • (最优化理论与方法)第二章最优化所需基础知识-第三节:重要凸集举例
  • *Algs4-1.5.25随机网格的倍率测试-(未读懂题)
  • .jks文件(JAVA KeyStore)
  • .NET Framework 的 bug?try-catch-when 中如果 when 语句抛出异常,程序将彻底崩溃
  • .net MySql
  • .NET Standard 支持的 .NET Framework 和 .NET Core
  • .NET 材料检测系统崩溃分析
  • .NET 回调、接口回调、 委托
  • .net 重复调用webservice_Java RMI 远程调用详解,优劣势说明
  • @JsonSerialize注解的使用
  • @RequestBody与@ResponseBody的使用
  • [ 常用工具篇 ] AntSword 蚁剑安装及使用详解
  • [2024最新教程]地表最强AGI:Claude 3注册账号/登录账号/访问方法,小白教程包教包会
  • [Android] 修改设备访问权限
  • [Android]使用Retrofit进行网络请求
  • [C++基础]-初识模板
  • [codevs 1515]跳 【解题报告】
  • [HOW TO]如何在iPhone应用程序中发送邮件
  • [iOS]如何删除工程里面用cocoapods导入的第三方库