一、概述
什么是散列表?散列表也被称为 hash表,它是一种用于以 常数平均时间执行插入、删除和查找的技术。其通过 关键字(key)来实现对数据的直接访问。关键字和数据的映射关系称为 散列函数又称 hash函数,记做 hash(x),我们把散列表的大小记做 tableSize。理想的散列表: 关键字被映射到tableSize个单元内,并且任意两个不同的关键字被映射到不同的单元 。事实上单元的数量有限,而关键字实际上是用不完的。 因此要找到一个散列函数,该函数要在单元之间均匀的分配关键字,这就是散列的基本想法。剩下的还有一个问题就是:当两个关键字散列到同一个单元的时候 ( 这叫做 冲突),应该如何解决这种冲突?二、散列函数
如果输入的关键字是整数,则一般合理的方法就是直接返回key%tableSize ( 即hash(x) = x%tableSize ),但如果关键字不是整数,比如说字符串那么以上的散列函数就不适合了,好的解决办法就是将字符串中字符的ASCII 码值加起来生成一个整数,然后在使用以上的散列函数,看过String源码的朋友应该注意到String类提供了一个方法叫做hashCode(),下面我们来看下这段源码
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
String
类是以字符的形式将数据存储到内存里面。以上计算hashCode的方法就是取出每个字符按照位置乘上31再加上各自的
ASCII 码值,得到一个
int 型的整数,我们可以用这种方式将
String类型的关键字转换成整数关键字,这样就可以用
hash(x) = x%tableSize 这个散列函数,不仅
String类提供了
hashCode方法,而且
Object类也提供了
hashCode方法,
Object
类是所有类的父类
,也就是说
java里面所有的对象都有
hashCode方法,无论关键字是什么类型都可以用
hash(x) = x%tableSize
这个散列函数。我们在设计散列表的时候要
尽可能保证表大小为素数,
这样就可以保证关键字的分配均匀。如下图所示,当
tableSize=10,关键字的个位数始终为0,那么散列函数就不能均匀的分布,当我们将散列表的大小设计为11(素数),可以看到关键字的分布情况要好的多。
三、冲突解决策略
上面提到两个关键字被散列到同一个单元,我们必须找到一种策略,该策略可以很好的解决这种冲突 。第一种解决冲突的方法通常叫做 分离链接法,其做法是将散列到同一个单元的所有元素保留到一个表中,如下图 该图展示了散列表大小为10,处理冲突的表为单链表, 考虑到散列表的性能,我们可以得到“ 用于处理冲突的链表不能无限大 ”这种结论。因为我们很容易推测出查找一个元素所耗费的时间是 计算散列函数值的常数时间加上遍历链表所用的时间,用复杂度表示即为 O(1)+O(N) ,从中可以看出 N越大复杂度越高,耗时也就越长。我们记散列表的 填装因子(r)为散列表中 元 素个数 (n)对该 表大小(tableSize)的比。(即 r=n/tableSzie)从中很容易看出,如果填装因子越大,相对的链表就越大,复杂度越高,最好的办法是 让填装因子始终小于 1,所以我们在设计的时候要考虑到将散列表扩容。第二种解决冲突的方法可以用线性探测法。当产生冲突的时候该方法会相继探测逐个单元以查找出一个空单元,然后将该冲突值放入这个空的单元里。例如我们在tableSize=10的散列表相继插入【56,89,43,76,23,36】
上图表示使用线性探测法插入76,23,36时,解决冲突的示意图,我们可以看到当插入76时会产生冲突,该单元已经被56所占据,于是76会探测下一个单元,如果下个单元为空就将76放进去,如果不为空,就继续探测直到找到一个空的单元为止。只要表足够大,总能找到一个空单元,但是这样花费的时间是相当多的。三、代码实现
接下来我们选择其中一种解决冲突的方法进行代码实现,两种方法相比较,我们选择 分离链接法来进行代码实现。 (Java7的HashSet和HashMap是用分离链接散列实现的) 首先创建SeparateChainingHashTable类,定义好成员变量,添加构造器,根据上面的分析,我们应当保证散列表的大小始终为素数。我们定义了一个默认的容量为11,还定义一个有参构造器通过方法nextPrime(),将capacity转换为大于capacity的最小素数,如下代码public class SeparateChainingHashTable { private List[] entries; private int currentSize; // 默认散列表的容量 private static final int DEFAULT_CAPACITY = 11; SeparateChainingHashTable() { this(DEFAULT_CAPACITY); } SeparateChainingHashTable(int capacity) { this.entries = new List[nextPrime(capacity)]; }}
然后我们开始添加add()方法,
在
添加add()方法之前,我们应该考虑:如果添加一个已经存在的元素该如何操作?我们这里就先按照无法重复添加相同元素的逻辑来处理,如下代码,给出了add()方法和contains()方法,在进行添加操作的时候,先判断要添加的元素是否已经存在,存在则不做任何操作,不存在则添加
/** * 添加元素 * * @param t */ public void add(T t) { if (contains(t)) return; // 获取关键字 int key = getKey(t); if (entries[key] == null) entries[key] = new LinkedList<>(); entries[key].add(t); if (++currentSize >= entries.length) { // 扩容 rehash(); } } /** * 判断是否包含元素 * * @param t * @return */ public boolean contains(T t) { List entry = entries[getKey(t)]; if (entry == null) return false; return entry.contains(t); }
上面add()方法里面有一个rehash()方法,该方法就是保证散列表的填装因子始终小于1,以确保该散列表的操作效率,以下代码就是该散列的扩容机制
/** * 对当前散列表进行扩容 */ private void rehash() { List[] oldEntries = this.entries; int oldSize = this.entries.length; this.entries = new List[nextPrime(oldSize << 1)]; for (List oldEntry : oldEntries) { if (oldEntry == null) continue; for (T t : oldEntry) { add(t); } } currentSize = 0; }
最后我们再编写删除remove()方法,同样如果要删除的元素不在散列表里,则不作任何操作,如下代码所示
/** * 删除元素 * * @param t */ public void remove(T t) { if (!contains(t)) return; List entry = entries[getKey(t)]; entry.remove(t); currentSize--; }
代码编写到这里,已经基本完成,我们选用的链表是
LinkedList,没有用自己手写的单链表,(之前的
java基础数据结构之链表中已经编写过单链表,感兴趣的读者朋友可以自己将
LinkedList替换成上文提到的单链表)
四、小结
本章主要帮助大家梳理了散列的基本思想,介绍了常规的hash函数,以及解决hash冲突的几种方法,编写了分离链接法实现的散列表。当然散列的实现不止文章中说到的几种方法,例如:布谷鸟散列、跳房子散列等等,关于这些散列感兴趣的朋友可以去找资料详细了解一下。(如果有必要,后续我们会对这两种散列的实现进行分享)
实际工作中我们也经常接触散列这种数据结构,例如标准库中的HashMap、HashTable,在分布式系统中我们经常听到的一致性Hash算法等等,都涉及到了散列,由此可见hash的重要性。
感兴趣的读者朋友可以 关注本公众号,和我们一起学习探究。
本人因所学有限,如有错误之处,望请各位指正!