如果把我们的字典看成一个大线性表的话, 也没有任何问题, 不过随着字典的扩大, 我们会发现, 这和现实中为了寻找一个词语的解释, 一页一页翻字典没有任何区别.
也就是说, 我们的字典看上去叫字典, 也实现了字典的接口, 但是在内部, 没有实现一本字典应该有的索引功能.
因此, 我们会想, 如果有一个索引函数, 我把Key放进去, 运算出来的结果就是一个索引, 然后直接操作索引位置的对象就可以了, 这才是完备的字典.
这个想法确实是对的, 有这么一种函数, 就是散列函数. 当然, 也许工作的不想想象的那样完美, 但可以大大的提高查找效率.
- 散列表实现的字典
- 散列函数与散列码的计算
- 下标压缩
- 解决冲突
- 装填因子
- 拉链法实现散列表
- 添加长度变更功能
散列表实现的字典
对于我们的字典, 散列函数的核心思想就是: 根据传入的对象, 计算得到对象在数组中的元素下标.
所以这个函数的本质, 就是将一组事物, 映射到一排整数上去.
如果每个事物都可以映射到不同的整数上去, 这个就叫做完美散列函数. 将一个事物通过散列函数计算得到的整数称为散列码.
完美散列函数的一大问题是, 如果这组事物很多, 则需要的散列表空间也非常大, 至少在编号方面非常大.
所以在很多时候, 可能还需要把散列码压缩到数据结构可实际使用的空间中去, 比如一个对象的散列码计算得到-10, 就不能直接用这个散列码来操作, 还需要为-10这个散列码寻找在数组中的对应位置.
此外, 在计算散列码的时候(或者说直到计算出最终下标)的过程中, 有些不同的对象映射到同一个下标, 这个叫做冲突.
这里先不讨论如何解决冲突, 而是肯定有一个冲突解决方案来做这个工作.
这样我们就初步整理出来了使用散列表的几个核心要素:
- 散列函数和散列码
- 散列码换算成下标
- 冲突解决方案
Java中的基类Object有一个方法叫做hashCode(), 就是散列函数, 计算得到一个int类型的整数, 就是散列码. 不过, 如果不覆盖这个方法, 默认的实现是返回这个对象所在的内存地址当做散列码.
默认的内存地址不实用, 因为当指针指向另外一个对象的时候, 内存地址相同, 但对象发生了变化, 因此很难通过散列码来判断对象的相等性.
Java对此有一些严格的规定, 即判断相等性equals()和hashCode()的方法, 在编写自己的类时候尤其要注意:
- 如果一个类重写了equals(),也必须重写hashCode()
- 如果两个对象equals()的结果是true, 则hashCode()的结果也必须相等
- 一个对象不管调用多少次hashCode(), 只要对象未改变, 结果都一样
散列函数
由上边分析可见, 散列函数非常重要, 好的散列函数可以大大减少之后压缩下标, 解决冲突的难度.
来看一下Java中是如何计算散列函数的.
字符串散列函数
字符串散列函数的Horner方法是, 反复乘再加, 不好说但是一看代码就明白. 根据这个方法, 可以选择一个常数, 来编写一个散列函数:
public static int hashString(String s) {
//所选择的常数
int A = 31;
//初始hash码
int hash = 0;
for (int i = 0; i < s.length(); i++) {
//每次用常数乘以hash码,再加上当前字符的unicode值
hash = hash * A + s.charAt(i);
}
return hash;
}
这里我们的例子选择了31, 这是因为Java的String类的hashCode()方法选择的就是31. 所以我们的散列函数计算出来的结果和Java是一样的. 不信可以试试.
基本类型的散列函数
对于长度不超过int的整数类型 byte short char int, 直接可以将其转换为int就得到散列码, 数字的唯一性保证了散列码的唯一性.
float类型很有意思, 由于其内部就是一个32位的二进制数表示, 因此可以直接将其转换成 32位 相同规格的int即可, 注意这和转换类型可不同.
需要使用Float.floatToRawIntBits(a)
来将其转换成原样的二进制码对应的int, 这也保证了唯一性.
对于long和Double,我们马上就可以判定, 如果将散列码扩展到64位, 则可以用同样的方式保证唯一性.
不幸的是散列码是int类型,因此需要压缩一下, 采用的方法是将64位的前32位和后32位进行异或运算, 得到32位长度的散列码. 写成方法就是:
public static int hash64BitDate(long s) {
return (int) ((s >> 32) ^ s);
}
对于Double类型, 则要稍微做点变更,不能直接移位, 而要先转换成原始的二进制对应的long:
public static int hash64BitDate(double s) {
return hash64BitDate(Double.doubleToLongBits(s));
}
上边的这些函数, 手工计算结果和调用包装类的hashCode方法,结果是一样的.
对于普通对象的散列码, 则需要在编写对象的时候想一个方法来进行编写, 一般可以使用对象中各个数据域的散列码来进行计算.
还一个常见的操作是, 因为计算散列码需要时间, 因此可以设置一个布尔变量来表示散列码是否已经被计算出来, 如果对象的数据发生变化, 则重新计算散列码, 否则直接返回已经计算好的散列码即可.
下标压缩
散列码都是int类型的值, 给了我们一个启示, 就是可以将其压缩到一个数字序列中. 只要选定一个素数, 然后取散列码除以该素数的余数, 就可以将散列码映射到 0 - (素数-1) 范围内.
很容易想到, 如果选择其他数字或者偶数, 则出现重复下标的概率大大提高, 所以n要是奇数, 最好的选择就是某个素数了.
知道了散列码如何计算, 以及如何将散列码压缩至下标, 剩下就需要解决一个问题, 就是两个不同的散列码压缩到同一个下标的时候, 该如何处理. 所以就来研究如何解决冲突.
解决冲突
这里我们先跳过线性探查, 而是专注于拉链法 - 即需要改变字典的内部数据结构.
所谓改变结构, 也就是让线性表的每一个位置, 都可以来存入多个元素, 这只需要把线性表的每一个元素, 也改成一个存放Entry的数据结构.
这个数据结构可以是之前用过的数组, 线性表, 向量等等都可以, 只要支持查找和替换操作, 一般来说, 使用链表组成的线性表会比较方便, 因为不会占用额外空间.
所以实际上, 通过散列表, 有效的减少了操作一条长链的可能性, 将每个查找, 都对应到一条相比整个字典短的多的链条上, 这样查找效率就会大大提高.
这样, 散列表的内部结构, 就变成了一个线性表数组, 这是很有意思的. 看起来已经可以写一个散列表了.
装填因子
这里我们依然关心拉链法. 装填因子 load factor, 就是散列表中已经存储的总项数 / 散列表中的位置数.
对于拉链法来说, 散列表的位置数并不是指总的位置数, 因为链表是可伸缩的, 而是指将散列码要压缩到的范围. 所以对于拉链法来说, 可能存了100个数字, 但是依然每次只有0-16这17个下标,所以没有上限.
然而, 装填因子却影响着查找效率. 拉链法下, 很显然, 设装填因子=A, 则实际上A是线性表数组每一项里边的线性表中的平均元素个数.
根据我们的算法, 对象会被一个常数级别的运算计算出散列码, 然后被常数级别的运算计算出下标. 之后影响性能的就是在A个元素中的查找能力. 先看第一个情况:
查找不成功
查找不成功意味着最后没有找到任何一个键和给出的键对应. 很显然, 在两个常数操作之后, 面对的平均情形就是一个长度为A的链表, 只有检查完全部节点之后, 才能得出查找不成功的结论, 所以查找不成功的次数是A
.
查找成功
查找成功到确定下标之前, 也是常数操作, 之后面临和查找不成功一样的境地, 即一个长度为A的链表, 很显然, 要么第一个就查到, 即花费1, 要么最后一个才查到, 因此平均时间是1 + A/2
.
这样, 在给定的线性表大小和放入元素的个数之后(也就是装填因子), 就可以计算出来查找所需的时间的粗略关系.
经过计算, 从A=0.1开始, 随着不断增大装填因子, (也就是线性表装的东西越来越多), 不成功查找所需的时间的增加量会大于成功查找的增加量. 一般将装填因子维持在1以内比较好, 如果超过, 则应该将线性表数组扩展到下一个素数.
有了这些理论, 就可以来写出一个散列表了. 当然, 散列函数可能还是需要我们自行设计. 但是这里可以先以最常见的字符串散列表为例.
拉链法实现散列表
根据上边的需求, 自己先来编写一个简单的拉链法字典, 先搭建一个基础设施:
public class ZipDictionary<K, V> implements Dictionary<K, V> {
private MyLinkedList<Entry<K, V>>[] dictionary;
private static int DEFAULT_CAPACITY = 17;
private int numberOfEntries;
@SuppressWarnings("unchecked")
public ZipDictionary() {
dictionary = new MyLinkedList[DEFAULT_CAPACITY];
}
private class Entry<K, V> {
K key;
V value;
Entry(K key, V value) {
this.key = key;
this.value = value;
}
@Override
public String toString() {
return "Entry{" +
"key=" + key +
", value=" + value +
'}';
}
@Override
@SuppressWarnings("unchecked")
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Entry<?, ?> entry = (Entry<?, ?>) o;
return key.equals(entry.key);
}
@Override
public int hashCode() {
return key.hashCode();
}
}
}
基础设施包括了一个数组, 每一个元素都是一个链表实现的线性表. 还有老朋友Entry. 下边一个一个来编写各个方法.
首先是核心的add()方法.
@Override
public void add(K key, V value) {
if (key == null || value == null) {
throw new RuntimeException("不允许添加null.");
}
Entry<K, V> newEntry = new Entry<>(key, value);
//关键步骤, 根据键的散列码压缩下标
int index = Math.abs(newEntry.hashCode() % dictionary.length);
//如果为空就先创建线性表, 然后添加
if (dictionary[index] == null) {
dictionary[index] = new MyLinkedList<>();
dictionary[index].add(newEntry);
} else {
//如果不为空, 需要先探查有没有这个东西
int result = dictionary[index].getPosition(newEntry);
//如果没找到, 就直接添加, 可以考虑添加在头部, 这样下一次查找会比较快
if (result == -1) {
dictionary[index].add(0, newEntry);
} else {
//如果找到了, 就替换指定位置的元素
dictionary[index].replace(result, newEntry);
}
}
}
接下来是remove()方法. 逻辑依然是先计算散列码再查找:
@Override
@SuppressWarnings("unchecked")
public V remove(K key) {
int index = Math.abs(key.hashCode() % dictionary.length);
System.out.println("键的散列码是: " + index);
Entry<K, V> target = new Entry<>(key, (V) new Object());
if (dictionary[index] == null) {
return null;
} else {
int result = dictionary[index].getPosition(target);
System.out.println("具体链表的索引是: " + result);
if (result == -1) {
return null;
} else {
V value = dictionary[index].getEntry(result).value;
dictionary[index].remove(result);
return value;
}
}
}
然后是通过键获取值, 如出一辙:
@Override
@SuppressWarnings("unchecked")
public V getValue(K key) {
int index = Math.abs(key.hashCode() % dictionary.length);
Entry<K, V> target = new Entry<>(key, (V) new Object());
if (dictionary[index] == null) {
return null;
} else {
int result = dictionary[index].getPosition(target);
if (result == -1) {
return null;
} else {
return dictionary[index].getEntry(result).value;
}
}
}
之后是contains(), 还是一样的操作:
@Override
@SuppressWarnings("unchecked")
public boolean contains(K key) {
int index = Math.abs(key.hashCode() % dictionary.length);
Entry<K, V> target = new Entry<>(key, (V) new Object());
if (dictionary[index] == null) {
return false;
} else {
return dictionary[index].getPosition(target) != -1;
}
}
最后是几个辅助函数, 编写都依赖于每个链表:
@Override
public boolean isEmpty() {
int sum = 0;
for (MyLinkedList<Entry<K, V>> entries : dictionary) {
if (entries != null) {
sum = sum + entries.getLength();
}
}
return sum == 0;
}
@Override
public int getSize() {
int sum = 0;
for (MyLinkedList<Entry<K, V>> entries : dictionary) {
if (entries != null) {
sum = sum + entries.getLength();
}
}
return sum;
}
@Override
public void clear() {
Arrays.fill(dictionary, null);
}
现在只剩下迭代器了. 迭代器我能想到的一个方法就是遍历一遍然后组装一个数组, 让迭代器去使用. 单独控制一个数量i也可以, 但是每次要一个一个往前走, 似乎写起来非常麻烦. 这里我就组装了一个数组然后返回一个迭代器对象, 先看键迭代器:
@Override
public Iterator<K> getKeyIterator() {
return new KeyIterator();
}
private class KeyIterator implements Iterator<K>{
private K[] array;
private int index;
private int number;
@SuppressWarnings("unchecked")
KeyIterator() {
this.index = 0;
this.number = getSize();
array = (K[]) new Object[number];
int i = 0;
//这里实际上把当前所有Key都装进了新创建的数组, 然后实际上是在这个数组中迭代
for (MyLinkedList<Entry<K, V>> list : dictionary) {
if (list != null) {
for (Entry<K, V> entry : list) {
array[i] = entry.key;
i++;
}
}
}
}
@Override
public boolean hasNext() {
return index != number;
}
@Override
public K next() {
K result = array[index];
index++;
return result;
}
}
会编写键的迭代器了, 值的也一样:
@Override
public Iterator<V> getValueIterator() {
return new ValueIterator();
}
private class ValueIterator implements Iterator<V>{
private V[] array;
private int index;
private int number;
@SuppressWarnings("unchecked")
ValueIterator() {
this.index = 0;
this.number = getSize();
array = (V[]) new Object[number];
int i = 0;
for (MyLinkedList<Entry<K, V>> list : dictionary) {
if (list != null) {
for (Entry<K, V> entry : list) {
array[i] = entry.value;
i++;
}
}
}
}
@Override
public boolean hasNext() {
return index != number;
}
@Override
public V next() {
V result = array[index];
index++;
return result;
}
}
这样一个固定长度的字典基本上就写好了. 不过这里没有编写长度发生改变的方法.
添加长度变更功能
长度发生改变的话, 就需要在每次add方法之前, 判断装填因子是不是超过一定的数量, 也就是用getSize()/dictionary.length
, 如果大于一个数字, 就要扩充到下一个素数的位置,
还会需要一些辅助的素数行列来控制大小和上限, 这里对原来的类进行一些修改, 在add方法添加元素之前, 增加一个扩大数组的方法.
这里需要修改一下整个类的一些设施, 将原来的那个静态变量DEFAULT_CAPACITY去掉, 记录当前数组的长度(其实这个变量也可以省略, 不过为了简明, 就写出来),然后就可以来编写辅助域和方法, 用于在add()添加元素之前扩大数组长度:
public class ZipDictionary<K, V> implements Dictionary<K, V> {
private int capacity = 17;
private static int[] primeNumbers = new int[]{37, 79, 151, 307, 617, 1237, 2477, 4957, 10007};
@Override
public void add(K key, V value) {
if (key == null || value == null) {
throw new RuntimeException("不允许添加null.");
}
checkSize();
Entry<K, V> newEntry = new Entry<>(key, value);
int index = Math.abs(newEntry.hashCode() % dictionary.length);
if (dictionary[index] == null) {
dictionary[index] = new MyLinkedList<>();
dictionary[index].add(newEntry);
} else {
int result = dictionary[index].getPosition(newEntry);
if (result == -1) {
dictionary[index].add(0, newEntry);
} else {
dictionary[index].replace(result, newEntry);
}
}
}
@SuppressWarnings("unchecked")
private void checkSize() {
double loadFactor = (double) getSize() / dictionary.length;
System.out.println("当前的装填因子是: " + loadFactor);
if (loadFactor > 0.7) {
for (int primeNumber : primeNumbers) {
if (primeNumber > capacity) {
capacity = primeNumber;
System.out.println("将容量增大到: " + capacity);
//用一个临时引用保存原来的内部数据结构
MyLinkedList<Entry<K, V>>[] temp = dictionary;
//然后创建一个长度扩大的数组作为新字典的内部数据结构
dictionary = new MyLinkedList[capacity];
//由于是散列, 不能直接复制, 所以只好一个一个把原来的散列表的内容装载到新的散列表中.
loadDataToNewDictionary(temp);
break;
}
}
}
}
private void loadDataToNewDictionary(MyLinkedList<Entry<K, V>>[] array) {
for (MyLinkedList<Entry<K, V>> list : array) {
if (list != null) {
for (Entry<K, V> entry : list) {
add(entry.key, entry.value);
}
}
}
}
}
checkSize()
方法检测当前的装填因子是不是已经大于0.7, 只要大于, 就在素数表中搜索下一个大于capactiy的素数, 并将capacity设置成这个素数.
然后创建一个引用指向原来的内部链表数组, 将dictionary的引用替换成新的capacity大小的空白数组, 然后使用一个辅助方法, 将原来链表数组中的所有元素, 重新添加到新的散列表中.
这里可不能像之前线性表一样重新复制, 因为散列表压缩之后的下标会变化. 这个要特别注意. 上限就是10007, 即数组最大也就扩大到10007长度, 之后便不再扩大散列表了.
经过了一下午, 终于完整的写出了拉链法还能够扩大的散列表了, 在扩大散列表的时候, 是一个O(n)的操作. 在正常取散列表的时候, 假设装填因子是0.7, 说明平均每个位置只有0.7个元素, 由于散列过程可以看做是常数, 所以散列表的性能相比线性表,可就非常快了.
所以最后来写一个小实验, 比一下究竟两者性能差多少.
public static void main(String[] args) {
Random random = new Random();
ZipDictionary<String, String> dictionary1 = new ZipDictionary<>();
for (int i = 0; i < 10000; i++) {
dictionary1.add(String.valueOf(i), String.valueOf(i * 3));
}
LinearDictionary<String, String> dictionary2 = new LinearDictionary<>();
for (int i = 0; i < 10000; i++) {
dictionary2.add(String.valueOf(i), String.valueOf(i * 3));
}
String[] indexNumbers = new String[1024*1024];
for (int i = 0; i < indexNumbers.length; i++) {
indexNumbers[i] = String.valueOf(random.nextInt(10000));
}
long start = System.currentTimeMillis();
for (int i = 0; i < indexNumbers.length; i++) {
dictionary1.getValue(indexNumbers[i]);
}
long end = System.currentTimeMillis();
System.out.println("拉链法字典查找时间是: " + (end - start));
start = System.currentTimeMillis();
for (int i = 0; i < indexNumbers.length; i++) {
dictionary2.getValue(indexNumbers[i]);
}
end = System.currentTimeMillis();
System.out.println("线性表字典查找时间是: " + (end - start));
}
测试首先给长度10000的拉链法字典和线性表字典各装入10000个键值对, 键就是0-9999的字符串, 值是键乘以3的字符串.
然后准备了长度为1M的键数组, 放了所有随机生成的键.
之后让两个程序挨个搜索键数组, 比较完成这1M次数的时间.
运行了几次如下:
拉链法字典查找时间是: 82
线性表字典查找时间是: 28842
拉链法字典查找时间是: 80
线性表字典查找时间是: 28567
拉链法字典查找时间是: 86
线性表字典查找时间是: 29866
可以看到, 性能差距在350倍左右. 这确实是一个巨大的进步.