散列表(by Separate chaining)
本文不打算扣散列表的细节,只打算整理散列表的思路,和提供一种非常基础的实现方式——分离链接法(Separate chaining,国内教材或有其他叫法)。
本贴基于《数据结构与算法分析(Mark Allen Weiss)》、什么是散列表、哈希表(散列表)原理详解 。
后面请自行学习:java.util.Hashtable( t是小写!jdk1.0的遗留问题),java.util.HashSet,java.util.HashMap的源码。
引言
高校里的学号大多十余位,比如2030 1234 0023,前4位为入学年份,中间4位为专业年级,后面为该专业年级下的一个序号,现在有个需求是给一个班的同学建立一个成绩单可以快速查询每位同学的成绩。我们自然的做法就是不要学号前面的一连串的数字,只要最后2位或3位的数字来表征学生。那么,要查找学号为2030 1234 0023的同学的成绩时,只要直接访问表下标为23的数据即可。这就能够在 O ( 1 ) O(1) O(1)时间复杂度内完成成绩查找。
抽象
把上面的思想抽象出来。
1:每个人对应了一个学号,这其实是建立了他到一个数字的映射,更具有可行性的说法是,通过一个函数,建立某个key(键值,不妨就理解为钥匙、密钥)到一个整数的映射,这个函数被称为hashCode函数;
2:保留学号的后几位,这是一个数到另一个数(存放数据的数组的下标)的映射,这个函数被称为hash函数、散列函数。
说明:
1. 散列函数一种常用处理是让“学号”对数组的长度length 取模。比如这班的学号是2030 1234 0067
→
\to
→ 2030 1234 0131,用一个length为100的数组来储存,刚好存放在下标为67
→
\to
→ 99和0
→
\to
→ 31的位置上。根据分布的不同,它应该不是最优的,但它确实极其简单的优点,而且速度也很快。
2. 编程中hash函数中会调用hashCode函数,因为需要hashCode函数提供第一个数“学号”。如果是非泛型的实现,hashCode函数可以不用单独提出来,直接写在hash函数里即可,即这时候的hash函数包含了2个映射。如果是非泛型的实现,那么hashCode函数由使用散列表存储的类本身提供,数据结构hashTable<T>本身只调用、不提供具体的hashCode函数。
3. 一般的,这个数组的大小一般选择素数(e.g. 考虑情况10 20 30 40模10{0,0,0,0}和模7{3,6,2,5}),当输入的关键字是随机整数时,表的大小是素数使得散列函数计算起来简单而且关键字的分配也很均匀。同时hash函数的选取有很多的方式,本身也极具技巧性,可用于加密等。当然hash函数的设计和种种技巧不在本贴的考虑之中。
4. 这种转换是一种压缩映射,也就是,散列值的空间通常 远小于 输入的空间。
5. 不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。
6. 当使用哈希表进行查询的时候,就是再次使用hash函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。
散列表
理想的散列表(哈希表)是一个 包含关键字的、具有固定大小的 数组,它能够以常数时间
O
(
1
)
O(1)
O(1) 执行 ①插入,②删除 和 ③查找 操作。原理就是:
w
h
a
t
e
v
e
r
→
n
→
i
n
d
e
x
{\rm whatever}\to n\to index
whatever→n→index,
其中,
w
h
a
t
e
v
e
r
{\rm whatever}
whatever 为任意类型,
n
n
n为一个整数,
i
n
d
e
x
index
index为最后存放的下标(暂未考虑冲突的情况)。散列函数为从任意类型到数组下标的映射(不一定包含
w
h
a
t
e
v
e
r
↦
n
whatever\mapsto n
whatever↦n的hashCode函数的具体实现)。
也即,散列表(Hash table,哈希表)是根据键值(Key,不过本贴不涉及Map相关)而直接进行访问的数据结构。它通过把key映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表,记录的存储位置 = f ( w h a t e v e r ) =f(\rm{whatever}) =f(whatever)。
(1) 从hash值不可以反向推导出原始的数据;
(2) 输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的数据值;
(3) 执行效率要高,长文本也能快速计算出hash值;
(4) 冲突概率小。
数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。那有没有一种 寻址容易、插入删除也容易 的数据结构?有,hashTable就是。
但,那些需要元素间任何排序信息的树操作将得不到有效的支持。因此,诸 如findMin、findMax以及以线性时间将排过序的整个表进行打印 的操作都是散列表所不支持的。
某种角度而言,Hash就是将任意长度是数据映射到有限长度的域上。
应用
1、加密。Hash主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128位的编码,这些编码值叫做Hash值。比如参加数学建模时会先提交一个hash码(截至前提交文件全国那么多队伍接收不过来),后面再提交文件。后面若对文件有任何修改,则再提交hash码就会不一样,就会被判定为作弊。
2、查找。哈希表,又称为散列,是一种更加快捷的查找技术。我们之前的查找,都是这样一种思路:集合中拿出来一个元素,看看是否与我们要找的相等,如果不等,缩小范围,继续查找。而哈希表是完全另外一种思路:当我知道key值以后,我就可以直接计算出这个元素在集合中的位置,不再需要一次又一次的查找。
3、海量数据处理。Hash表在海量数据处理中有着广泛应用。
冲突
一个数组容量是有限的,hash函数的效果本身也受到key分布的某种隐性的影响,如果两个字符串在哈希表中对应的位置相同怎么办?
解决冲突的方法有几种,最简单的两种:分离链接法,开放定址法。本贴只分析分离链接法(separate chaining):将散列到同一个值的所有元素保存到一个表中。
分离链接法
该方法下的散列表存储的是一个链表数组。比如,为执行一次查找,使用散列函数来确定究竟遍历哪个链表,然后再在被确定的链表中执行一次查找。
就像二叉树只对那些是 Comparable的对象工作一样,这里的散列表只对遵守确定协议的那些对象工作,在 Java中即要求这样的对象必须提供 适当的equals方法 和 返回一个int型的hashCode方法。
类架构如下:
import myList.MyLinkedList;
public class MyHashTable<T> {
private static final int DEFAULT_TABLE_SIZE = 11;
private MyLinkedList<T>[] theLists;
private int currentSize;
public MyHashTable() {}// of the first constructor
public MyHashTable(int size) {}// of the second constructor
public void makeEmpty() {}// of makeEmpty()
// *****************************************************************************************
private static boolean isPrime(int n) {}// of isPrime(int)
// return a prime that is equal or greater than the parameter.
private static int nextPrime(int n) {}// of nextPrime(int)
private int myHash(T x) {}// myHash(T)
private void reHash() {}// of rehash()
// *****************************************************************************************
public boolean contains(T x) {}// of contains(T)
public void insert(T x) {}// of insert(T)
public void remove(T x) {}// of remove(T)
// *****************************************************************************************
public static void main(String[] args) {}// of main
}// of class MyHashTable
完整代码:
package myHash;
import myList.MyLinkedList;
/**
* HashTable implemented by Separate Chaining Method
*/
public class MyHashTable<T> {
private static final int DEFAULT_TABLE_SIZE = 3;
private MyLinkedList<T>[] theLists;
private int currentSize;
public MyHashTable() {
this(DEFAULT_TABLE_SIZE);
}// of the first constructor
public MyHashTable(int size) {
theLists = new MyLinkedList[nextPrime(size)];
for (int i = 0; i < theLists.length; i++) {
theLists[i] = new MyLinkedList<>();
} // of for i
}// of the second constructor
public void makeEmpty() {
for (int i = 0; i < theLists.length; i++) {
theLists[i].clear();
} // of for i
currentSize = 0;
}// of makeEmpty()
// *****************************************************************************************
private static boolean isPrime(int n) {
if (n < 2) {
return false;
} else if (n > 2) {
if (n % 2 == 0) {
return false;
} else {
double sqrtN = Math.sqrt(n);
for (int i = 3; i <= sqrtN; i += 2) {
if (n % i == 0) {
return false;
} // of if
} // of for i
} // of if-else
} // of if-elseIf
return true;
}// of isPrime(int)
// return a prime that is equal or greater than the parameter.
private static int nextPrime(int n) {
if (n < 2) {
return 2;
} else if (isPrime(n)) {
return n;
} // of if
while (!isPrime(++n)) {
}
return n;
}// of nextPrime(int)
private int myHash(T x) {
int hashVal = x.hashCode();
hashVal %= theLists.length;
if (hashVal < 0) {
hashVal += theLists.length;
} // of if
return hashVal;
}// myHash(T)
private void reHash() {
MyLinkedList<T>[] oldLists = theLists;
// create a new double-sized empty table
theLists = new MyLinkedList[nextPrime(2 * theLists.length)];
for (int i = 0; i < theLists.length; i++) {
theLists[i] = new MyLinkedList<>();
} // of for i
// copy table over
currentSize = 0;
for (int i = 0; i < oldLists.length; i++) {
for (T item : oldLists[i]) {
insert(item);
} // of for item
} // of for i
}// of rehash()
// *****************************************************************************************
public boolean contains(T x) {
MyLinkedList<T> whichList = theLists[myHash(x)];
return whichList.contain(x);
}// of contains(T)
public void insert(T x) {
MyLinkedList<T> whichList = theLists[myHash(x)];
if (!whichList.contain(x)) {
whichList.add(x);
if (++currentSize > theLists.length) {
reHash();
} // of inner if
} // of outer if
}// of insert(T)
public void remove(T x) {
MyLinkedList<T> whichList = theLists[myHash(x)];
if (whichList.contain(x)) {
whichList.remove(x);
currentSize--;
} // of if
}// of remove(T)
// *****************************************************************************************
public static void main(String[] args) {
MyHashTable<String> myTable = new MyHashTable<>();
myTable.insert("I");
System.out.println(myTable.contains("I"));
myTable.remove("I");
System.out.println(myTable.contains("I"));
System.out.println("***************************");
myTable.insert("a");
System.out.println(myTable.currentSize);
myTable.insert("a");
System.out.println(myTable.currentSize);
System.out.println("***************************");
myTable.insert("b");
myTable.insert("c");
System.out.println(myTable.currentSize);
System.out.println(myTable.theLists.length);
System.out.println("***************************");
myTable.insert("d");
System.out.println(myTable.currentSize);
System.out.println(myTable.theLists.length);
}// of main
}// of class MyHashTable
测试结果:
true
false
***************************
1
1
***************************
3
3
***************************
4
7
Java中HashTable和HashMap的区别:
定义: 散列表的装填因子
λ
\lambda
λ(load factor)为散列表的元素个数对比该表大小的比。
链表的平均长度即为
λ
\lambda
λ,执行一次查找的所需的工作量是 计算散列函数所需的常数时间 加上 遍历链表 所需的时间。在一次不成功的查找中要考察的节点数平均为
λ
\lambda
λ,一次成功的查找则需要遍历大约
1
+
λ
/
2
1+\lambda/2
1+λ/2个链。因此散列表的实际大小并不重要,而装填因子才是重要的。分离链表的一般法则是,使表的大小与预料的元素个数大致相等,即使
λ
≈
1
\lambda \approx 1
λ≈1。
再次指出,使表的大小为素数以保证一个好的分布,这很有必要。
此外,关于复杂性分析,知道插入、删除、查找的平均花销期望都是 O ( 1 ) O(1) O(1)就够了,对于最坏情况下的表现,不是自己的研究课题就别管了。
最后,标准库中包含了Set和Map的散列表的实现,即HashSet类和HashMap类等,HashSet和HashMap都可由分离链接散列实现。
一个糟糕的HashMap
下面给一个用HashTable实现的,非常粗略的、有明显bug的、只是表示原理的HashMap。简单来说,HashTable的元素换做一个二元键值对的类:Entry(条目)即可。
package myHash;
public class MyHashMap<KeyType, ValueType> {
private static class Entry<KeyType, ValueType> {
final KeyType key;
ValueType value;
protected Entry(KeyType key, ValueType value) {
this.key = key;
this.value = value;
}// of Entry(KeyType, ValueType)
public boolean equals(Object o) {
if (!(o instanceof MyHashMap.Entry)) {
return false;
} // of if
MyHashMap.Entry<?, ?> e = (MyHashMap.Entry<?, ?>) o; // 通配符?可表示任意数据类型,加上通配符防止类型擦除
return (key == null ? e.getKey() == null : key.equals(e.getKey()))
&& (value == null ? e.getValue() == null : value.equals(e.getValue()));
}// of equals(Object)
public KeyType getKey() {
return key;
}// of getKey()
public ValueType getValue() {
return value;
}// of getValue()
public int hashCode() {
return (key == null ? 0 : key.hashCode()); // ^ (value == null ? 0 : value.hashCode())
}// of hashCode()
}// of static class Entry<KeyType, ValueType>
private MyHashTable<Entry<KeyType, ValueType>> items;
// *****************************************************************************************
public MyHashMap() {
items = new MyHashTable<>();
}// of the first constructor
public void makeEmpty() {
items.makeEmpty();
}// of makeEmpty()
// *****************************************************************************************
public void put(KeyType key, ValueType val) {
//显然这里应该判断:若key为空;若键值均相同则不变;若只有键相同则替换;若键不同则下面的insert……
items.insert(new Entry<>(key, val));
}// put(KeyType, ValueType)
//唉,耍赖了,实在不想写了……
//ValueType should not be null, which indicates not found.
public ValueType get(KeyType key) {
Entry<KeyType, ValueType> tempEntry = new Entry<>(key, null);
return items.getItem(tempEntry.hashCode()).value;
}// of get(KeyType)
public ValueType remove(KeyType key) {
ValueType temp = get(key);
items.remove(new Entry<>(key, null));
return temp;
}// of remove(KeyType)
public boolean isEmpty() {
return items.isEmpty();
}// of isEmpty()
// *****************************************************************************************
public static void main(String[] args) {
MyHashMap<String,Integer> myMap = new MyHashMap<>();
myMap.put("I",1);
System.out.println(myMap.isEmpty());
System.out.println(myMap.get("I"));
myMap.put("II",2);
System.out.println(myMap.get("II"));
myMap.put("III",3);
System.out.println(myMap.get("III"));
myMap.put("a",-1);
System.out.println(myMap.get("a"));
myMap.put("a",-2);
System.out.println(myMap.get("a"));
myMap.remove("a");
System.out.println(myMap.get("a"));
}// of main
}// of class MyHashMap<KeyType, ValueType>
结果:
false
1
2
3
-1
-2
-2
标准库中HashMap的常用操作
put(key, value):向HashMap这个关于Entry的数组中加入一个Entry(key, value)
containsKey(key):返回是否包含key的boolean值
keySet():给出HashMap所有key的一个集,进一步可以得到KeySet().size()等
toString():直接输出所有Entry(条目,键值对)
HashMap的遍历,用keySet()做for each循环或使用迭代器:
HashMap<KeyType, ValueType> myMap = new HashMap<>();
for (KeyType k : myMap.keySet()) {
ValueType tempValue = myMap.get(k);
// To Do
}