算法之哈希表/JAVA

1.3 哈希表

什么是哈希表,哈希表是一种类似于数组的数据结构,它能通过Key的值直接找到对应的Value。而哈希表与数组的不同之处在于数组只能通过下标直接访问数据,且下标只能为整型(int),而哈希表则可以将任意数据类型作为Key。

使用哈希表分为两部,第一步是用哈希函数将被查找的Key转化为数组的索引,当然理想情况是不同的键转换成不同的索引,但是这在现实生活中是很难实现的,所以第二步就是将两个不同的键对应同一个Key的问题解决掉,学名叫处理碰撞冲突。最常用的处理碰撞冲突的方式是拉链法和线性探测法。

1.3.1 hashCode与equals

在学习这两种处理碰撞冲突之前我们需要深入了解一下hashCode和equals。

在Java的语法规定中,所有的数据类型都要继承一个能够返回32位整数的hashCode()方法,可以将hashCode()的返回值看成在内存中的地址(实际上是在内存中的虚拟地址经过转换得到的整数,不同的虚拟机和环境得到的结果不一致),而equals比较的是值是否相等(除了基本类型)。那么hashCode和equals有什么关系呢?

  • 两个对象的hashCode比较的结果相等,equals比较的结果不一定相等
  • 两个对象的equals比较的结果相等,hashCode比较的结果不一定相等
  • 两个对象的hashCode比较的结果不相等,equals比较的结果一定不相等
  • 两个对象的equals比较的结果不相等,hashCode比较的结果不一定不相等

想要理解hashCode必须要先了解集合,在Java中集合分为两种List和Set,前者是有序,后者则不允许重复,那么Set如何保证不允许重复呢?首先第一点肯定是进行equals比较就可以得出结果是否相等,但是如果一个Set里面已经有一万个元素,储存第一万零一个元素时就可能要进行一万次比较,这是一个非常大的运算量,大大降低速度,但是如果我们进行hashCode比较,如果hashCode相等,则进行equals比较,如果hashCode不想等,则直接存入,这样可以将万次的比较降低至几次甚至一次,提高效率。

下面我们来讨论下hashCode的两个特点:

  • 相等的对象必须具有相等的hashCode。
  • 如果两个对象的hashCode相同,它们并不一定相同。

首先是第一个,假设有两个对象A和B,A与B相等,假如他们的hashCode不想等,那么在hashMap中通过hashCode计算出来的值一定不同,那么A和B则均可存入hashMap,这显然与Set的定义不符。

其次是第二个,还是对象A和B,假设它们hashCode相等,值不相同,则通过hashCode计算出来的值虽然相同,但是仍然需要存入hashMap中,而如何将hashCode相同的但值不同的对象存入就是我们需要通过处理碰撞冲突来解决的问题。

1.3.2 拉链法处理碰撞冲突

假设我们有一个大小为M的数组存储N个数据,我们通过哈希函数将Key转换成数组下标,当出现碰撞冲突时我们用拉链法来处理,那么拉链法是如何进行处理的?

其实拉链法就是将每一个数组元素都指向一个链表,当出现碰撞冲突时就增加一个节点,然后将数据存入,这样在查找数据时,我们首先直接通过下标找到value所在的链表,再遍历链表找到对应的元素,时间复杂度同样保持在O(N)。

**如何选择数组M的大小?**基于拉链法实现哈希表时,我们需要选择一个合适大小的M来保证既不会因为链表太长而浪费时间,也不会因为空链表浪费内存,经过大量的实践,科学家们提出将链表的平均长度保持在2~8是最合适的。

如何删除元素?与链表删除元素相同。

下面将展示基于拉链法的哈希表。

首先实现一个顺序查找的链表

/**
 * @author linxi
 * @function 基于链表的无序查找
 * @project 算法
 * @package 查找.顺序查找
 * @date 2020/7/20-11:47 下午
 */
public class SequentialSearchST<Key, Value> {
    //链表首结点
    private Node first;
    //链表
    private class Node{
        Key key;
        Value value;
        Node next;

        public Node(Key key, Value value, Node next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    //根据key查找value
    public Value get(Key key){
        for (Node x = first; x != null; x = x.next){
            if (key.equals(x.key)){
                //已找到
                return x.value;
            }
        }
        return null;
    }

    //根据key存入value,如果value已存在则更新
    public void put(Key key, Value value){
        for (Node x = first; x != null; x = x.next){
            if (key.equals(x.key)){
                //已找到
                x.value = value;
                return;
            }
        }
        first = new Node(key, value, first);
    }
}

实现基于拉链法的哈希表

/**
 * @author linxi
 * @function 基于拉链法的哈希表
 * @project 算法
 * @package 查找.哈希表
 * @date 2020/7/20-10:31 下午
 */
public class SeparateChainingHashST<Key, Value> {
    //键值对的个数
    private int N;
    //数组大小
    private int M;
    //存放链表对象的数组
    //SequentialSearchST为以链表为基础的顺序查找类
    private SequentialSearchST<Key, Value>[] st;

    /**
     * 为数组的每一个单元创建一条链表
     * @param M
     */
    public SeparateChainingHashST(int M) {
        this.M = M;
        st = (SequentialSearchST<Key, Value>[]) new SequentialSearchST[M];
        for (int i = 0; i < M; i++) {
            st[i] = new SequentialSearchST<>();
        }
    }


    /**
     * 计算哈希值
     * @param key
     * @return
     */
    private int hash(Key key){
        //将hashCode的第一位置0,保证其为正数,然后模M,保证计算的置在0~M-1之间
        return (key.hashCode() & 0x7fffffff) % M;
    }

    /**
     * 返回数据
     * @param key
     * @return
     */
    public Value get(Key key){
        return (Value) st[hash(key)].get(key);
    }

    /**
     * 放置数据
     * @param key
     * @param value
     */
    public void put(Key key, Value value){
        st[hash(key)].put(key, value);
    }
    
}

1.3.3 基于线性探测法的哈希表

线性探测法与拉链法不同的地方在于线性探测法是用数组中的空位解决碰撞冲突,当碰撞发生时,我们直接检查哈希表的下一个位置,如果为空则插入,不为空则继续查找下一个,直到遇到为空的位置。

**如何选择数组M的大小?**经过大量的实践,科学家们提出将储存数据的位置在整个数组的1/8~1/2是最合适的,所以当数据不断插入时,我们需要使用resize调整数组的大小,实现这个方法首先会创建一个新的数组,然后将原数组中的key和value重新插入。下面展示调整数组大小的方法:

/**
     * 调整数组大小
     * @param cap
     */
private void resize(int cap){
  LinearProbingHashST<Key, Value> t;
  t = new LinearProbingHashST<Key, Value>(cap);
  for (int i = 0; i < M; i++) {
    if (keys[i] != null){
      t.put(keys[i], values[i]);
    }
  }
  keys = t.keys;
  values = t.values;
  M = t.M;
}

删除当涉及到删除时,我们不能直接将key对应的位置置null,假如有5个相同key的数据存储在哈希表中,如果将第三个数据直接置空,则会导致第四第五和数据用于无法被找到,这是万万不可的,所以想删除一个元素则需要将其右侧所有的键重新插入哈希表,下面给出删除方法的代码:

/**
     * 删除数据
     * @param key
     */
public void delete(Key key){
  //不存在key
  if (get(key)  == null){
    return;
  }

  int i = hash(key);

  //查找值的位置并置空
  while (!key.equals(keys[i])){
    i =  (i + 1) % M;
  }
  keys[i] = null;
  values[i] = null;

  //将删除的元素的后面相连的元素都重新插入到哈希表中
  i =  (i + 1) % M;
  while (keys[i] !=  null){
    Key key1 = keys[i];
    Value value1 = values[i];
    keys[i] = null;
    values[i] = null;
    N--;
    put(key1, value1);
    i =  (i + 1) % M;
  }

  N--;

  if (N > 0 && N == M/8){
    resize(M/2);
  }

}

下面将展示哈希表的完整实现

/**
 * @author linxi
 * @function 基于线性探查法的哈希表
 * @project 算法
 * @package 查找.哈希表
 * @date 2020/7/21-12:32 上午
 */
public class LinearProbingHashST<Key, Value> {
    //键值对的个数
    private int N;
    //数组大小
    private int M;
    //键
    private Key[] keys;
    //值
    private Value[] values;

    public LinearProbingHashST(int cap) {
        this.M = cap;
        keys = (Key[]) new Object[M];
        values = (Value[]) new Object[M];
    }



    /**
     * 计算哈希值
     * @param key
     * @return
     */
    private int hash(Key key){
        //将hashCode的第一位置0,保证其为正数,然后模M,保证计算的置在0~M-1之间
        return (key.hashCode() & 0x7fffffff) % M;
    }

    /**
     * 调整数组大小
     * @param cap
     */
    private void resize(int cap){
        LinearProbingHashST<Key, Value> t;
        t = new LinearProbingHashST<Key, Value>(cap);
        for (int i = 0; i < M; i++) {
            if (keys[i] != null){
                t.put(keys[i], values[i]);
            }
        }
        keys = t.keys;
        values = t.values;
        M = t.M;
    }

    /**
     * 存入数据
     * @param key
     * @param value
     */
    public void put(Key key, Value value){
        //保证数据占数组的1/2~1/8
        if (N >=  M/2) {
            resize(2*M);
        }

        int i;
        //i = (i + 1) % M保证i在数组内
        for (i = hash(key); keys[i] != null; i = (i + 1) % M) {
            if (keys[i].equals(key)){
                values[i] = value;
                return;
            }
        }
        keys[i] = key;
        values[i] = value;
        N++;
    }

    /**
     * 获取数据
     * @param key
     * @return
     */
    public Value get(Key  key){
        for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
            if (keys[i].equals(key)){
                return values[i];
            }
        }
        return null;
    }

    /**
     * 删除数据
     * @param key
     */
    public void delete(Key key){
        //不存在key
        if (get(key)  == null){
            return;
        }

        int i = hash(key);

        //查找值的位置并置空
        while (!key.equals(keys[i])){
            i =  (i + 1) % M;
        }
        keys[i] = null;
        values[i] = null;

        //将删除的元素的后面相连的元素都重新插入到哈希表中
        i =  (i + 1) % M;
        while (keys[i] !=  null){
            Key key1 = keys[i];
            Value value1 = values[i];
            keys[i] = null;
            values[i] = null;
            N--;
            put(key1, value1);
            i =  (i + 1) % M;
        }

        N--;

        if (N > 0 && N == M/8){
            resize(M/2);
        }

    }
}

1.3.4 哈希表与其他算法的比较

算法最坏情况下的成本(查找)最坏情况下的成本(插入)平均情况下的成本(查找)平均情况下的成本(插入)是否高效支持有序性相关操作内存使用(字节)
顺序查找(无序链表)NNN/2N48N
二分查找(有序数组)lgN2NlgNN16N
二叉查找树NN1.39lgN1.39lgN64N
2-3查找树(红黑树 )2lgN2lgN1.00lgN1.00lgN64N
拉链法(链表数组)<lgN<lgNN/(2M)N/M48N+32M
线性探测法(并行数组)clgNclgN<1.5<2.532N~128N
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

高压锅码农777

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值