引入:
哈希表是一种数据结构,它可以提供快速的插入操作和查找操作。哈希表有非常多的优点,不论哈希表中有多少数据,插入和删除数据只需要接近常量的时间,即O(1)时间级。
哈希表同样叶存在这一些缺点:它是基于数组的,数组创建后难以扩展。某些哈希表被基本填满时,性能下降的非常严重,所以我们在使用哈希表之前一定要清楚表中要存储多少数据。
同样,也没有一种简便的方法可以以任何一种顺序遍历表中的数据。如果需要遍历,那就只能选择其他的数据结构。
哈希表:
首先我们需要明白哈希表的插入删除查找的时间将近是常量的原因,我们可以这样去想其实这也是因为我们在操作哈希表的时候,其实就是在操作数组,相当于我们在操作数组的时候已经知道了我们要操作的数据的下标值。那个速度可想而知的快。
现在我们就要想一下怎么将我们key-value对中的key转化为数组的下标:
1. 把单词转化为数组下标
在我们平时的计算机应用中,有很多的编码表合一使用,其中一种是ASCII编码,其中a是97,b是98,以此类推,知道122代表z。然而ASCII码从0到255。英文字母中有26个字母,所以可以设计出一种自己编码的方案,比如a是1,b是2,c是3,以此类推,直到26代表z。还要把空格代表0,所以有27个字符。但是如何把单个字母的数字组合成代表整个单词的数字呢?
a. 把数字相加
把每个单词的各个字母用上面的数字代替后相加求和。我们假设每个单词有10个字母,那么字典的第一个单词a的编码就是0+0+0+0+0+0+0+0+0+1 = 1,那么最后一个单词就是zzzzzzzzzz,所有字符的编码和为10个26相加为260,从某种角度来说,这个方案就只能保存260个单词,肯定有问题。
b. 幂的连乘
上面的那种方案没有足够的空间来存储我们想要存储的东西,所以我们决定把存储的空间加大一点,我们采用的是,把单词分解为字母组合,把字母分解为他们的数字代码,乘以适当的27的幂,然后将结果相加,这样应该可以增加我们想要的空间,但是实际情况是这样的。最长的单词zzzzzzzzzz将转化26*279+26*278+26*277+26*276+26*275+26*274+26*273+26*272+26*271+26*270 我们会发现这个结果大的可怕,所以两种方案好像都走了一个极端。
第一种方案产生的数组的下标太少,第二种方案产生的数组下标太多。
哈希化
我们现在就需要一种方法,把巨大的数组下标压缩一下。
有一种简单的方法是使用取余运算符,假设我们现在有0-199的数字,我们需要将其压缩为0-9的数字。我们就可以让原来的数字对10求余,我们就会得到 0-9 的数字。这就是一种哈希函数,它把一个大范围的数字哈希化为一个小范围的数字,这个小的范围对应着数组的下标。使用哈希函数向数组插入数据之后,这个数组就称为哈希表。
在原始的范围中,每个数字代表这一个潜在的数据项,但是他们之间只有很少一部分代表真实数据。哈希函数把这个巨大的整数范围转换成小的多的数组下标范围。
冲突
一个key经过哈希化之后会出现相同值的情况。这种情况就叫做冲突。出现了这样的问题,肯定是要解决的,目前在我们面前就有两种可行的方案:
方案1:当发生冲突的时候,通过系统的方法找到数组的下一个空位,并把这个单词添加进去。这个方法叫做开放地址法。
方案2:创建一个存放单词链表的数组,数组内不直接存储单词。当发生冲突时,新的数据项直接接到这个数组下标所指的链表中。这种方法叫做,链地址法。
开放地址法:
顾名思义就是把地址开发出来,供给数据进行存储,我们不再局限于只把数据存在哈希值对应的数组下标中,我们可以存在周围。只要下面还有位置,就可以存,取的时候也是一样,先在对应的位置找,找不到就在周围找,直到周围没有数据为止,删除也是同理。开放地址法中有包含着其它的子方法:
线性探测:
// 数据项类
public class DataItem {
private int iData;
public DataItem(int i) {iData = i;
public int getKey() {return iData;}
}
// hash
public class hash{
private DataItem[] hashArray; // 存储数据的数组
private int arraySize; // 数组的大小
private DataItem nonItem; // 没有数据的数据项
public hash(int size) {
arraySize = size;
hashArray = new DataItem[arraySize];
nonItem = new DataItem(-1); // 没有数据则让其数据项为-1
}
// 显示函数
public void display() {
System.out.print("Table: ");
for (int i=0; i<arraySize; i++) {
if (hashArray[i] != null)
System.out.print(hashArray[i].getKey() + " ");
else
System.out.print("** ");
}
System.out.println();
}
// 哈希化函数
public int hashFunc(int key) {
return key%arraySize; // 返回的是哈希化后的哈希值
}
// 插入数据
public void insert(DataItem item) {
int key = item.getKey(); // 获取数据项的key
int hashVal = hashFunc(key); // 获取key的哈希值
while (hashArray[hashVal] != null && hashArray[hashVal] != -1) { // 位置被占用了
hashVal++; // 向下走一个单位
hashVal = hashVal%arraySize; // 重新获取哈希值
}
// 直到找到对应的位置
hashArray[hashVal] = item; // 插入数据
}
// 删除数据
public DataItem delete(int key) {
int hashVal = hashFunc(key);
while (hashArray[hashVal] != null) {
if (hashArray[hashVal].getKey() == key) { // 找到要删除的数据
DataItem temp = hashArray[hashVal];
hashArrayp[hashVal] = nonItem;
return temp; // 找到并返回
}
hashVal++;
hashVal = hashVal%arraySize;
}
return null; // 没找到
}
// 寻找数据
public DataItem find(int key) {
int hashVal = hashFunc(key);
while (hashArray[hashVal] != null) {
if (hashArray[hashVal].getKey() == key) {
return hashArray[hashVal];
}
hashVal ++;
hashVal = hashVal%arraySize;
}
return null;
}
}
但是上述的方式存在很大的隐患,就是数据可能在某个地方堆积起来导致哈希表的效率大幅下降,为了解决这种隐患可以采取另外一种方案,那就是二次探索,就是在二次探索的过程中判断数据有没有发生聚集的情况。
二次探测:
已经填入哈希表中数据和表长的比率就叫做装填因子,有10000个单位的哈希表填入6667个数据后,它的装填因子就是2/3。
当装填因子不大时,聚集分布得比较连贯。二次探测,顾名思义就是在线性探测得基础上,线性探测每次探测得步长为1,而二次探测得步长为12,22,32……,它得初衷是探测较远得单元。
其中也会出现问题:虽然二次探测消除了线性探测中得聚集问题(原始聚集),但是,它有产生了新的问题,“二次聚集”,因为它的步长其实也是固定的,若哈希值相同的元素太多,同样会产生聚集问题。
为了解决线性探测和二次探测的原始聚集和二次聚集问题,提出了另外一种方案:“再哈希法”
再哈希法:
解决问题的思路:产生一种依赖关键字的探测序列,而不是每个关键字都一样,那么不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。
实现方法:用不同的哈希函数将关键字再做一遍哈希化,用这个结果作为步长。
第二个哈希函数一般具有如下特点:1. 和第一个哈希函数不相同 2. 不能输出0
代码来了:
class HashTable{
private DataItem[] hashArray; // 数据项存储数组
private int arraySize;
private DataItem nonItem;
HashTable(int size) {
arraySize = size;
hashArray = new DataItem[arraySize];
nonItem = new DataItem(-1);
}
// 遍历显示数组中的所有项目
public void displayTable() {
System.out.print("Table:");
for (int i=0; i<arraySize; i++) {
if(hashArray[i] != null)
System.out.print(hashArray[j].getKey() + " ");
else
System.out.print("** ");
}
System.out.println();
}
// 哈希函数1
public int hashFunc1(int key) {
return key % arraySize;
}
// 再哈希法哈希函数
public int hashFunc2(int key) {
return 5 - key % 5;
}
// 插入
public void insert(int key, DataItem item) {
int hashVal = hashFunc1(key); // 获取哈希值
int stepSize = hashFunc2(key); // 获取步长
while (hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) { // 当前的哈希值下有对应的值 或者还没有删掉
hashVal += stepSize; // 首先给之前的哈希值加上由key计算的来的步长
hashVal %= arraySize; // 再获取一次哈希值
}
hashArray[hashVal] = item; // 如果找到灭有数据的位置 直接插入数据
}
// 删除数据
public DataItem delete(int key) {
int hashVal = hashFunc1(key); // 获取哈希值
int stepSize = hashFunc2(key); // 获取其唯一的步长(根据其key值计算而来)
while (hashArray[hashVal] != null) { // 找到了改数据项
if (hashArray[hashVal].getKey() == key) {
DataItem temp = hashArray[hashVal]; // 备份改数据项
hashArray[hashVal] = nonItem; // 给改数据项的数据置空
return temp; // 返回数据项
}
// 若找不到 且没有遇到空的位置 利用再哈希法继续寻找
hashVal += stepSize;
hashVal %= arraySize;
}
// 若遇到空的位置
return null;
}
// 查找数据项
public DataItem find(int key) {
int hashVal = hashFunc1(key); // 获取哈希值
int stepSize = hashFunc2(key);
while (hashArray[hashVal] != null) { // 如果找到的数据项不为空
if (hashArray[hashVal].getKey() == key) // 判断是否为要找的数据项
return hashArray[hashVal]; // 是则返回该数据项
// 不是则继续寻找
hashVal += stepSize;
hashVal %= arraySize;
}
// 若遇到空的位置
return null;
}
}
链地址法:
数据项并不存储在哈希值映射的数组中,而是存储在数组某一项对应的链表中,这样这要遇到哈希冲突的问题,直接去对应的链表里插入、寻找、删除就行。
直接上代码:
public class Link {
private int iData;
public Link next;
public Link(int it) {iData = it;}
public int getKey() {return iData;}
public void displayLink() { System.out.print(iData + " "); }
}
// 链表类
public class SortedList {
private Link first;
public SortedList() {first = null;}
// 插入数据
public void insert(Link theLink) {
int key = theLink.getKey();
Link previous = null;
Link current = first;
while(current != null && key > current.getKey()) {
previous = current;
current = current.next;
}
if (previous == null)
first = theLink;
else
previous.next = theLink;
theLink.next = current;
}
// 删除数据
public void delete(int key) {
Link previous = null;
Link current = first;
while (current != null && key != current.getKey()) {
previous = current;
current = current.next;
}
if (previous == null)
first = first.next;
else
previous.next = current.next;
}
// 寻找数据
public Link find(int key) {
Link current = first;
while (current != null && current.getKey() <= key) {
if (current.getKey() == key)
return current;
current = current.next;
}
return null;
}
// 打印链表
public void displayList() {
System.out.print("List (first --> last): ");
Link current = first;
while (current != null) {
current.displayLink();
current = current.next;
}
System.out.println();
}
}
// 使用链地址法的哈希表类
public class hashTable {
// 新建一个链表数组
private SortedList[] hashArray;
private int arraySize;
public hashTable(int size) {
arraySize = size;
hashArray = new SortedList[arraySize]; // 初始化链表数组
for (int j=0; j<arraySize; j++) {
hashArray[j] = new SortedList(); // 给数组的每个位置都新建一个链表
}
}
// 打印每个位置上的每一条链表
public void displayTable() {
for (int j=0; j<arraySize; j++) {
System.out.print(j + ", ");
hashArray[j] .displayList();
}
}
// 哈希函数
public int hashFunc(int key) {
return key % arraySize;
}
// 将数据存进哈希值对应的链表中
public void insert(Link theLink) {
int key = theLink.getKey();
int hashVal = hashFunc(key); // 获取哈希值
hashArray[hashVal].insert(theLink); // 直接利用哈希值找到对应的链表进行数据的插入
}
// 删除
public void delete(int key) {
int hashVal = hashFunc(key); // 先找到数据在哪个链表中
hashArray[hashVal].delete(key); // 在链表中删除对应的数据
}
// 寻找数据项
public Link find(int key) {
int hashVal = hashFunc(key); // 找到数据项应该存在的位置
Link theLink = hashArray[hashVal].find(key); // 直接在对应的链表中查找数据
return theLink; // 法妞数据
}
}
其实链地址法的代码实现起来不难,只不过是代码量增加了而已,因为要在里面维护一个链表类,然后再将向正常的哈希表中的增删查改、对应位置上对链表的增删查改。
开放地址法和链地址法的比较:
如果使用开放地址法,对于小型的哈希表,再哈希法似乎比二次探测的效果好。但是有一个情况例外,就是内存充足,并且哈希表一经创建,就不再改变其容量,在这种情况下,线性探测相对容易实现,并且,如果装填因子低于0.5,几乎没有什么性能的下降。
如果再哈希表创建的时候,要填入的项数未知,链地址法要好过开发地址法。如果用开发地址法,随着装填因子变大,性能会下降很快,但是使用链地址法,性能只能线性的下降。
当两者都可选的时候,选择链地址法。它需要使用链表类,但回报是增加比预期更多的数据项,不会导致性能快速的下降。