哈希表(一)
哈希表是一种数据结构,它可以提供快速的插入和删除操作。无论哈希表有多少数据,插入、删除只需要接近常量的时间,即 O(1) 的时间级。明显比树还快,树的操作通常需要O(N)的时间级。
缺点:它是基于数组的,数组创建之后难以维护。某些哈希表被基本填满时,性能下降非常严重。而且也没有提供一种方法可以以任何一种顺序(例如从大到小)遍历表中数据项。
若需把单词当做key(数组下标)获取value(数据),可以把单词分解成字母组合,把字母转化为它们的数字代码(a-1,b-2,c-3……z-26,空格-27),每个数字乘以相应的27(因为字母有27种可能,包括空格)的幂,然后结果相加,就可以每个单词对应一个独一无二的数字。
例如 cats 转换数字:3*273 + 1*272 + 20*271 + 19*270 = 60337
这种方案会使得数组的长度太大,而且只有很少的一部分下标是有数据的。
哈希化
arrayIndex = hugeNumber % arraySize,这是一种哈希函数,它把一个大范围的数字哈希(转化)成一个小数字的范围。
使用取余操作符(%),把巨大的整数范围转换为两倍于要存储内容的数组下标范围。下面是哈希函数的例子:
arraySize = wordNumber * 2;
arrayIndex = hugeNumber % arraySize;
期待的数组应该有这样的特点:平均起来,每两个数组单元,就有一个数值,有些单元没有数值,但有些单元可能有多个数值。
冲突
把巨大的数字空间压缩为较小的数字空间,必然要付出代价,即不能保证每个单词都映射到数组的空白单元。假设在数组中需要插入单词zoo,哈希化之后得到它的下标,发现该单元已经有了其它另一个不同的单词,这个情况叫做“冲突”。
解决方案1 - 开放地址法
前面已经提过指定的数组大小两倍于需要存储的数据量,因此还有一半单元是空白的。当发生冲突的时候,通过系统的方法找到数组的一个空位,并把单词放进去,而不再用哈希函数得到的数组下标,这种方法叫做“开放地址法”。
解决方案2 - 链地址法
创建一个存放单词链表的数组,数组内不直接存储单词,这样,但发生冲突的时候,新的数据项直接接到这个数组下标所指的链表当中。这种方法叫做“链地址法”。
开放地址法
寻找数组的其它位置有三种方法:线性探测、二次探测、再哈希法。
1)线性探测
线性查找空白单元,如果 21 是要插入数据的位置,它已经被占用了。那么就使用 22 ,然后是 23 ,以此类推,数组下标一直递增,直到找到空位为止。
插入(insert)
当数据项的数目占哈希表的一半,或最多三分之二时,哈希表的性能是最好的。可以看出已填充的单元分布不均匀,有时一串空白单元,有时有一串已填充的单元。
在哈希表中,一串连续的已填充单元叫做“填充序列”。增加越来越多的数据项时,填充序列变得越来越长,这叫做“聚集”。
删除(Delete)
在哈希表中,查找算法是以哈希化的关键字开始,沿着数组一个一个寻找,如果在寻找到关键字之前遇到一个空白单元,说明查找失败。
delete不是简单地把某个单元的数据项变为空白(null),因为在一个填充序列中间有个空白,查找算法就会中途放弃查找。因此需要一个有特殊关键字的数据项代替要被delete的数据项。标记数据项不存在。
public class DataItem { private int i; public DataItem(int i) { this.i = i; } public int getKey() { return i; } public void printf() { System.out.println("data -> " + i); } }
public class HashTable { private DataItem[] itemArray; private int arraySize; private DataItem nonItem; // for deleted items public HashTable(int size) { this.arraySize = size; itemArray = new DataItem[arraySize]; nonItem = new DataItem(-1); } public void display() { for (DataItem data : itemArray) { if (data != null) { data.printf(); } } } public int hashFuc(int key) { return key % arraySize; } public void insert(DataItem item) { int key = item.getKey(); int hashVal = hashFuc(key); DataItem tItem; while ((tItem = itemArray[hashVal]) != null && tItem.getKey() != -1) { if (tItem.getKey() == key) { itemArray[hashVal] = item; return; } hashVal++; // go to next cell hashVal %= arraySize; // wraparound if necessary } itemArray[hashVal] = item; } public DataItem delete(int key) { int hashVal = hashFuc(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { itemArray[hashVal] = nonItem; return item; } hashVal++; hashVal %= arraySize; } return null; } public DataItem find(int key) { int hashVal = hashFuc(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { return item; } hashVal++; hashVal %= arraySize; } return null; } }
public static void main(String[] args) { HashTable t = new HashTable(10); t.insert(new DataItem(39)); t.insert(new DataItem(51)); t.insert(new DataItem(23)); t.insert(new DataItem(25)); t.insert(new DataItem(23)); t.insert(new DataItem(10)); t.insert(new DataItem(9)); t.delete(25); t.insert(new DataItem(79)); t.insert(new DataItem(81)); t.display(); }
打印结果:
data -> 10
data -> 51
data -> 9
data -> 23
data -> 79
data -> 81
data -> 39
扩展数组
当哈希表太满,需要扩展数组。只能创建一个新的更大的数组,然后把旧的数组所有数据项插入到新的数组。由于哈希函数是根据数组的大小计算数据项的位置,所以不能简单把一个数据项插入新的数组,需要按顺序遍历旧的数组,然后调用 insert()向新的数组插入每个数据项。这叫做“重新哈希化”。
扩展后的数组容量通常是原来的两倍,实际上数组的容量应该是一个质数,所以新的数组要比两倍容量多一点。
好的HASH函数需要把原始数据均匀地分布到HASH数组里,比如大部分是偶数,这时候如果HASH数组容量是偶数,容易使原始数据HASH后不会均匀分布:
2 4 6 8 10 12这6个数,如果对 6 取余 得到 2 4 0 2 4 0 只会得到3种HASH值,冲突会很多。如果对 7 取余 得到 2 4 6 1 3 5 得到6种HASH值,没有冲突。
同样地,如果数据都是3的倍数,而HASH数组容量是3的倍数,HASH后也容易有冲突,用一个质数则会减少冲突的概率,更分散。
以下是求质数的代码:
private int getPrime(int min) { for (int j = min;; j++) { if (isPrime(j)) { return j; } } } private boolean isPrime(int num) { for (int j = 2; j * j <= num; j++) { if (num % j == 0) { return false; } } return true; }
3)二次探测
在线性探测中会发生聚集,一旦聚集形成,它会越来越大,哈希化后的落在聚集范围内的数据项都要一步步移动,性能越差。
装填因子:已填入哈希表的数据项和表长的比率叫做装填因子。loadFactor = nItems / arraySize ;
二次探测是防止聚集的产生,思想是探测相隔较远的单元,而不是相邻的单元。
步骤是步数的平方:假设哈希表中原始下标是x,那么线性探测是:x+1,x+2,x+3……;而在二次探测中,探测过程是:x+12,x+22,x+32……。
二次探测消除了在线性探测产生的聚集问题,这种聚集问题叫做“原始聚集”。然而二次探测产生了另外一种更细的聚集问题。之所以会发生,是因为所有映射到同一个位置的关键字在寻找空位时,探测的单元都是一样的(步长总是固定的,都是:1、4、9、16、25、36……)。
4)再哈希法
为了消除原始聚集和二次聚集,可使用另一种方法:再哈希法。现在需要的一种方法产生一种依赖关键字的探测序列,而不是每个关键字都一样,那么,不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。
方法是把关键字用不同的哈希函数再做一遍哈希化,用这个结果作为步长。对于指定的关键字,步长在整个探测是不变的,不过不同关键字使用不同步长。
经验说明,第二哈希函数必须具备以下条件:
- 与第一个哈希函数不同
- 不能输入0(否则没有步长,每次探测都原地踏步,死循环。)
stepSize = constant - (key % constant),其中 constant 是质数,且小于数组容量。例如:stepSize = 5 - key % 5 ;
public class HashTable2 { private DataItem[] itemArray; private int arraySize; private DataItem nonItem; // for deleted items public HashTable2(int size) { this.arraySize = size; itemArray = new DataItem[arraySize]; nonItem = new DataItem(-1); } public void display() { for (DataItem data : itemArray) { if (data != null) { data.printf(); } } } public int hashFuc1(int key) { return key % arraySize; } public int hashFuc2(int key) { /* * non-zero, less than array size, different from hashFuc1. array size * must be relatively prime to 5, 4, 3, 2 */ return 5 - key % 5; } public void insert(DataItem item) { int key = item.getKey(); int hashVal = hashFuc1(key); int stepSize = hashFuc2(key); DataItem tItem; while ((tItem = itemArray[hashVal]) != null && tItem.getKey() != -1) { if (tItem.getKey() == key) { itemArray[hashVal] = item; return; } hashVal += stepSize; // add the step hashVal %= arraySize; // wraparound if necessary } itemArray[hashVal] = item; } public DataItem delete(int key) { int hashVal = hashFuc1(key); int stepSize = hashFuc2(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { itemArray[hashVal] = nonItem; return item; } hashVal += stepSize; hashVal %= arraySize; } return null; } public DataItem find(int key) { int hashVal = hashFuc1(key); int stepSize = hashFuc2(key); DataItem item; while ((item = itemArray[hashVal]) != null) { // until empty cell if (item.getKey() == key) { return item; } hashVal += stepSize; hashVal %= arraySize; } return null; } }
表的容量必须是一个质数
再哈希法要求表的容量是一个质数。为什么会有这个限制,假设表的容量不是质数,表长是15(坐标 0 - 14),有一个特别关键字映射到0,步长为5,探测序列为0、5、10、0、5……,一直循环下去,算法只会尝试这三个单元,不可能找到其它空白单元,算法崩溃。
如果数组容量是13,即一个质数,那么探测序列会访问到所有单元。即0、5、10、2、7、12、4、9、1、6、11、3,一直下去,只要表中有一个空位,就可以探测到它。用质数作为数组容量使得任何数想整除它是不可能的,因此探测序列最终会检查到所有单元。