Java中的散列表——HashSet

散列表,是一种数据结构,通过存储位置与key的映射关系存储数据,实现平均时间复杂度为 O(1)的查找功能。在Java中,每个类因为继承关系,都含有一个 public int hashCode()方法,当我们要将自己实现的类作为散列表中的key时,我们需要自己重写这个函数......

一、查找

我们知道在查找一个线性表中是否contains某个值时,我们需要遍历整个线性表,判断是否存在某个值,时间复杂度为O(n),如Java中的ArrayList和LinkedList:

1.ArrayList



public boolean contains(Object o) {
        return indexOf(o) >= 0;
    }

    public int indexOf(Object o) {
        if (o == null) {
            for (int i = 0; i < size; i++)
                if (elementData[i]==null)
                    return i;
        } else {
        //如果o不是null,遍历数组判断是否有相同的值
            for (int i = 0; i < size; i++)
                if (o.equals(elementData[i]))
                    return i;
        }
        return -1;
    }



2.LinkedList



public boolean contains(Object o) {
        return indexOf(o) != -1;
    }

    public int indexOf(Object o) {
        int index = 0;
        if (o == null) {
            for (Node<E> x = first; x != null; x = x.next) {
                if (x.item == null)
                    return index;
                index++;
            }
        } else {
        //如果o不是null,遍历链表判断是否有相同的值
            for (Node<E> x = first; x != null; x = x.next) {
                if (o.equals(x.item))
                    return index;
                index++;
            }
        }
        return -1;
    }



3.HashSet

这时候散列表就显示出它的作用来了,它就是为快速查找value诞生的。在Java中 HashSet就是散列表的实现, HashSet使用数组和链表来实现散列表,不同 hashcode的value存放在不同数组下标的位置,相同 hashcode的value存放在相同数组下标的链表上的不同位置上。在判断是否 contains某个value时,我们需要传入要查找的value的key,然后:

  • 先查找key是否存在
public boolean contains(Object o) {
        return map.containsKey(o);
    }

    public boolean containsKey(Object key) {
        return getNode(hash(key), key) != null;
    }



  • 再计算key的hashcode
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }



  • 接着根据key的hashcode,找到该hashcode对应的数组下标
first = tab[(n - 1) & hash];



  • 假如恰好该数组下标的位置存放的链表的第一个节点的value就是所要找的value,那么所有查找的时间复杂度都是O(1)
if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;



  • 如果很不幸,散列表出现了冲突,也就是说该数组下标所在的位置存放了很多相同hashcode的不同value,那么还需要遍历链表来判断是否存在需要查找的value,此时查找的时间复杂度就取决于有具有相同hashcode的value的数量。
if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
              //如果出现散列冲突,遍历链表查找
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }



二、hashcode、冲突

上面我们对 HashSet的介绍中,我们提到了几个名词 hashcode冲突,这里我们来解释一下

1.hashcode

我们知道每个类都继承于Object,每个类中都有一个继承来的方法public int hashCode(),我们查看Java文档:

This method is supported for the benefit of hash tables.

也就是说,这个hashcode在散列表的构建中是至关重要的。

我们来看一个例子,在下面这个例子中,我们定义了一个Person类,将Person作为HashSet的泛型参数,并往集合中存了一个ID为12345678的Person。接着,我们调用contains方法查询是否有一个ID为12345678的Person,执行程序后,输出为false。



public class Person {
    private int ID;

    public Person(int ID) {
        this.ID = ID;
    }
}

public class Main {
    public static void main(String[] args) {
        HashSet<Person> people = new HashSet<>();
        people.add(new Person(12345678));
        System.out.println(people.contains(new Person(12345678)));
    }
}

//output: false



显然,这不是我们要的结果。这里出现的错误就是hashCode()方法引起的错误(当然还有equals())。

为什么会出现这种错误呢?

因为在我们的Person类中,Person类自动继承了Object的hashCode()方法生成了hashcode,而基类是使用对象的地址计算hashcode的。所以我们存储在HashSet中的Person实例和我们查询时的Person实例地址是不一样的,自然它们的hashcode也是不一样的。

解决办法

我们重写了Person类的hashCode()方法,并重写了equals()方法,因为我们查看HashSet源码的时候,我们会发现都有一句key.equals(k),HashSet使用equals()方法,判断当前的key是否和散列表中的key一样。



public class Person {
    private int ID;

    public Person(int ID) {
        this.ID = ID;
    }

    @Override
    public int hashCode() {
        return ID;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (!(obj instanceof Person))
            return false;
        return ((Person) obj).ID == this.ID;
    }
}


public class Main {
    public static void main(String[] args) {
        HashSet<Person> people = new HashSet<>();
        people.add(new Person(12345678));
        System.out.println(people.contains(new Person(12345678)));
    }
}

//output:true



当我们完善了hashCode()方法和equals()方法后,我们得到了输出:true。

一些约定

接下来我们就可以了解一些覆写hashCode()方法的一些约定了:

  • 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序地多次执行中,每次执行所返回地整数可以不一致。
  • 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。
  • 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提供散列表的性能。

约定的第三点中提到,即使两个对象根据equals()方法比较是不相等,那么它们的hashcode也有可能是相等的。这就是接下来要讲的冲突。

2.冲突

理解冲突的时候,我们需要了解散列表是如何构建的。在构建散列表的时候,是根据key计算出value存放的地址。在HashSet中是根据hash函数和key计算value存放的数组下标。



//HashSet中使用的hash函数
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
//如果该数组下标所在的地址为null,就实例化一个新的节点存放对象
        //i = (n - 1) & hash计算value存放的数组下标
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);



而我们根据hash函数计算的hashcode,是有可能一样的。当一个散列表中,如果存在相同的hashcode,这种情况就称为冲突

解决冲突

  • 链接表法,把相同hashcode的value都放在一个链表中,HashSet就是采用这种方法解决的。
  • 开放寻址法,不使用链表。如果计算出来的地址已经有了一个value,那么开放寻址法就在当前的hashcode基础上计算出一个为null的地址来存放新的value,该过程称为探查

在上面第一部分关于HashSet的分析中,我们知道散列表查询的性能很大程度上取决于冲突的发生。减少冲突的发生,就需要构建好的hash函数。

三、hash函数

在《算法导论中》,提到了一个好的hash函数的特点:

一个好的散列函数应(近似地)满足简单均匀散列假设:每个关键字都会被等可能地散列到m个槽位中的任何一个,并与其他关键字已散列到哪个槽位无关。

上面的槽位可以理解为数组下标。我们在上面举的Person的例子,使用每个人的ID来构建hashcode,这个就保证了每个key的hashcode都是不一样的,这种使用每个对象独一无二的属性来构建hashcode,也是一种hash方法。接下来我们来简单介绍一些hash函数:

  • 除法散列法。通过取k除以m的余数,将关键字k映射到m个槽中的一个。

假设有以下这些key和value,除法散列法的hash函数为:f(key) = key % 7




java 前端hashmap传参 java hashmap contains_java contains


那么经过散列之后的存储为:

6 % 7 = 6 ,15 % 7 = 1 ,23 % 7 = 2 ,67 % 7 = 4 ,56 % 7 = 0 ,45 % 7 = 3


java 前端hashmap传参 java hashmap contains_java 前端hashmap传参_02


如果我们的key和value,是下面这个表,除法散列法的hash函数还是:f(key) = key % 7


java 前端hashmap传参 java hashmap contains_java遍历object_03


那么我们通过除法散列法得到的数组下标分别是:

6 % 7 = 6 ,16 % 7 = 2 ,24 % 7 = 3 ,66 % 7 = 3 ,55 % 7 = 6 ,45 % 7 = 3

我们发现发生了散列冲突了。这里我们试着用开放寻址法来解决,我们构造的再散列函数是:

F(key) = (f(key) + 1) % 7

解决冲突后的存储为:


java 前端hashmap传参 java hashmap contains_散列表查找失败平均查找长度_04


  • 乘法散列方法。
  • 用关键字k乘以常数A(0 < A < 1),并提取kA的小数部分。
  • 用m乘以这个值,再向下取整。
  • 全域散列方法。随机选择散列函数,使之独立于要存储的关键字。

四、应用

因为HashSet每次在添加数据的时候都会先看集合中原来有没有存储相同的数据,如果有就将新的数据覆盖老的数据,所以可以保证HashSet中的数据是没有重复的。那么我们可以利用这个特性来进行数据去重。

如有错误,望指正