数据结构-哈希表

发布于:2025-04-13 ⋅ 阅读:(16) ⋅ 点赞:(0)


概念

哈希表也是为了搞笑查找设计的,查找的时间复杂度为O(1),插入的时间复杂度也为O(1)
有一系列手段,把要表示的元素转换到数组上,这样的转换过程,称为哈希函数
把一大串数据,通过一系列数学变换,映射到一个较小的数组中,比如把一亿的数据保存到数组int [ ] count=new int[10000]中,像这样用来表示映射关系的数组就称为 哈希表(HashMap)

冲突-概念

如果要存储的是任意整数,范围是42亿个,但是创建的保存数据的数组范围是10000个,此时必然会出现重复,
像这样两个不同的数据,映射到同一个数组下标上的情况,叫做 哈希冲突

举例:
对key来说,一个常见的hash函数的设计方式就是直接求余数
假设key的范围是 0-42亿
哈希函数:key%1w
此时 10001,20001,30001就会映射到一个下标上(下标为1)

冲突-避免

由于我们哈希表底层数组的容量往往是⼩于实际要存储的关键字的数量的,这就导致⼀个问题,冲突的发⽣是必然的,但我们能做的应该是尽量的降低冲突率

冲突-避免-哈希函数设计

哈希函数的设计有很多种方法,比较常用的是 除留余数法
方法:对键除以一个余数(取模%),得到的余数作为数组下标

针对整数来说,就可以使用除留余数
如果是针对小数,可以先转化成整数(扩大倍数),再除留余数
针对字符串,也可以转换成较大的正整数,然后再进行求余
字符串转换:字符串每个部分都是一个字符,而字符本质上就是用数字表示的

针对数字设计的哈希函数,核心就是把不同类型的数字 先转换成一个较大的正整数,然后再进行求余,得到数组下标

冲突-解决-闭散列

问题:
在这里插入图片描述

解决方案(闭散列):
当10001占用了下标为1的位置,那么20001就往数组后面找,直到找到空位置,就插入进去,30001同理,继续往后找
如果再来一个10002,此时就继续往后找空闲位置

像这样的过程叫做“线性探测”:挨着一个一个的往后找空闲位置
还可以“二次探测”:按照1,4,9,16的长度跳着往后找

在实际开发中,“开散列”比闭散列更有价值

冲突-解决-开散列/哈希桶

使用 开散列的方法,能确保哈希函数计算出来的下标是准确位置
方法:数组的每一个元素都是“链表节点”,在有冲突的位置使用一个链表,保存“冲突”的元素

计算 哈希函数,还是O(1)
如果进行 查找/ 删除 ,可能会遍历链表,此时时间复杂度仍为O(1)
原因:可以通过一些手段,限制链表的最大长度(虽然是链表,但是长度很短,可控 [自定义的常数] )
这个最大长度,可以参考负载因子设计

冲突-避免-负载因子调节

设计 负载因子= 整个哈希表保存的元素个数 / 数组长度
负载因子的大小就相当于链表的平均长度

在实践中,可以根据 负载因子 设计 阈值
随着哈希表中元素个数的增加,使得负载因子增加,当负载因子达到阈值,就自动触发 扩容 操作 ,这样数组长度增加,负载因子又回到平均水平,这样就保证了链表平均长度不会变很大

对于Java标准库的HashMap 来说,还有一些额外的优化手段
负载因子达阈值触发 扩容 本身就有一定成本(会涉及大量的元素拷贝)
还可能触发一些极端情况(如整体链表平均长度不是很长,在阈值之下。但是个别的链表长度特别长【小概率】)
在这里插入图片描述
为了避免这样的情况,Java的 HashMap 会在元素往链表上插入的时候做一个判定,判定如果当前链表的长度达到了一定的值了,直接把链表转成红黑树(增加效率)
在这里插入图片描述
问:上述 HashMap 中,负载因子达到多少,要出发扩容?链表长度达到多少转成红黑树?
HashMap 标准库中,要求0.75 就要触发扩容

冲突-解决

哈希表 本质上是利用数组下标操作比较高效的特点 实现的一个数据结构
通过哈希函数 把要保存的内容映射到数组下标上,如果出现冲突,再通过开散列的方式来解决冲突

引入负载因子 限制链表的平均长度,保证最终的复杂度仍然是O(1)级别

哈希表代码模拟实现

  private static class Node {
        private int key;
        private int value;
        Node next;


        public Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }


    private Node[]  array;
    private int size;   // 当前的数据个数

    public int HashCode(int key){//根据key得到数组下标
        return key%array.length;
    }

    //添加 或者 如果键相同,就修改值
    public void put(int key, int value) {
        //先判断这个键有没有存在
        int index=HashCode(key);
        Node head=array[index];
        for (Node cur=head;cur!=null;cur=cur.next){
            if(cur.key==key){
                cur.value=value;
            }
        }
        //如果没有重复key,就添加,用头插的方式
        Node newNode=new Node(key, value);
        newNode.next=head;
        array[index]=newNode;
        size++;
        //扩容
        if((double) size/array.length>0.75){
            resize();
        }
    }

    private void resize() {//扩容操作
        Node[] newArray=new Node[array.length*2];//新数组的长度
        for (int i=0;i<array.length;i++){//遍历整个数组
            for(Node cur=array[i];cur!=null;cur=cur.next){//针对每个下标的链表转移
                Node newNode=new Node(cur.key,cur.value);//获取新值
                int newIndex=cur.key%newArray.length;//获取新下标
                //插入新数组(头插)
                newNode.next=newArray[newIndex];
                newArray[newIndex]=newNode;
            }
        }
        array=newArray;
    }

    //根据键获取值
    public int get(int key) {
        int index=HashCode(key);
        for (Node cur=array[index];cur!=null;cur=cur.next){
            if(cur.key==key){
                return cur.value;
            }
        }
        //没找到就返回-1
        return -1;
    }

    //删除键值对
    public void remove(int key){
        int index=key%array.length;
        if(array[index]==null){//该键值对不存在
            return;
        }
        if(array[index].key==key){//下标的第一个元素就是该key,直接删除
            array[index]=array[index].next;
            return;
        }
        //接下来是一般情况,即键值对在链表中,删除是把前一个节点的指向指到该键值对的后面
        Node prev=array[index];
        Node cur=array[index].next;
        while (cur!=null){
            if(cur.key==key){
                prev.next=cur.next;
                size--;//删除成功
                return;
            }
            //如果没找到就继续往后遍历
            prev=cur;
            cur=cur.next;
        }
    }

什么时候用TreeMap,什么时候用HashMap?
绝大多数情况下,都是用HashMap,但是如果追求有序的内容,就用 TreeMap
TreeMap 使用迭代器/ foreach 进行中序遍历,得到的结果就是有序的