本文内容基于《数据结构与算法分析 Java语言描述》第三版,冯舜玺等译。
散列表的实现叫做散列。散列是一种用于以常数平均时间执行插入、删除和查找的技术。
1. 一般想法
理想的散列表数据结构只不过是一个包含一些项的具有固定大小的数组。通常查找是对项的某个部分进行的,这部分叫做关键字。
每个关键字被映射到从0到TableSize - 1这个范围中的某个数,并且被放到适当的单元中,这个映射叫做散列函数,理想情况下它应该计算起来简单,并且应该保证任何两个不同的关键字映射到不同的单元。
2. 散列函数
如果输入的关键字是整数,则一般合理的方法就是直接返回key % TableSize,除非key碰巧具有某些不合乎需要的性质,比如关键字都是以0为个位且表的大小是10。另外,最好保证表的大小是素数,当输入的关键字是随机整数时,散列函数不仅计算起来简单而且关键字的分配也很均匀。
通常,关键字是字符串,一个好的散列函数:
public static int hash(String key, int tableSize) {
int hashVal = 0;
for (int i = 0; i < key.length(); i++) {
hashVal = 37 * hashVal + key.charAt(i);
}
hashVal %= tableSize;
if (hashVal < 0) {
hashVal += tableSize;
}
return hashVal;
}
如果当一个元素被插入时与一个已经插入的元素散列到相同的值,那么就产生一个冲突,这个冲突需要消除。
3. 分离链接法
其做法是将散列到同一个值的所有元素保留到一个表中。
为执行一次查找,使用散列函数来确定究竟遍历哪个链表,然后再在被确定的链表中执行一次查找。
为执行insert,先检查响应的链表看看该元素是否已经处在适当的位置,如果这个元素是个新元素,那么将它插入到链表的前端(新插入的元素最有可能不久又被访问)。
public class SeparateChainingHashTable<AnyType> {
private static final int DEFAULT_TABLE_SIZE = 101;
private List<AnyType>[] theLists;
private int currentSize;
public SeparateChainingHashTable() {
this(DEFAULT_TABLE_SIZE);
}
public SeparateChainingHashTable(int size) {
theLists = new LinkedList[nextPrime(size)];
for (int i = 0; i < theLists.length; i++) {
theLists[i] = new LinkedList<>();
}
}
public void insert(AnyType x) {
List<AnyType> whichList = theLists[myhash(x)];
if (!whichList.contains(x)) {
whichList.add(x);
if (++currentSize > theLists.length) {
rehash();
}
}
}
public void remove(AnyType x) {
List<AnyType> whichList = theLists[myhash(x)];
if (whichList.contains(x)) {
whichList.remove(x);
currentSize--;
}
}
public boolean contains(AnyType x) {
List<AnyType> whichList = theLists[myhash(x)];
return whichList.contains(x);
}
public void makeEmpty() {
for (int i = 0; i < theLists.length; i++) {
theLists[i].clear();
}
currentSize = 0;
}
/**
* 求至少等于n的质数
* @param n
* @return
*/
private static int nextPrime(int n) {
if (n % 2 == 0) {
n++;
}
for (; !isPrime(n); n += 2) {
}
return n;
}
/**
* 判断一个数是否是质数
* @param n
* @return
*/
private static boolean isPrime(int n) {
if (n == 2 || n == 3) {
return true;
}
if (n == 1 || n % 2 == 0) {
return false;
}
for (int i = 3; i * i <= n; i += 2) {
if (n % i == 0) {
return false;
}
}
return true;
}
/**
* 使得表的大小与预料的元素个数大致相等
*/
private void rehash() {
List<AnyType>[] oldLists = theLists;
theLists = new List[nextPrime(2 * theLists.length)];
for (int i = 0; i < theLists.length; i++) {
theLists[i] = new LinkedList<>();
}
currentSize = 0;
for (int i = 0; i < oldLists.length; i++) {
for (AnyType item : oldLists[i]) {
insert(item);
}
}
}
/**
* 散列函数
* @param x
* @return
*/
private int myhash(AnyType x) {
int hashVal = x.hashCode();
hashVal %= theLists.length;
if (hashVal < 0) {
hashVal += theLists.length;
}
return hashVal;
}
}
分离链接散列算法的缺点是使用链表,由于给新单元分配地址需要时间,这就导致算法的速度有些减慢,同时算法实际上还要求对第二种数据结构的实现。
4. 开放定址法
不用链表解决冲突的方法是尝试另外一些单元,直到找到空的单元为止。因为所有的数据都要置入表内,所以这种解决方案所需要的表要比分离链接散列的表大。
- 线性探测法
- 平方探测法
- 双散列
5. 再散列
如果散列表太慢,则需要建立一个新的表。
再散列可以有多种方法实现:
- 只要表满到一半就再散列;
- 只有当插入失败时才再散列;
- 当散列表达到某一个装填因子时进行再散列。
装填因子为散列表中元素的个数对该表大小的比。
分离链接散列法的一般法则是使得表的大小与预料的元素个数大致相等,即装填因子约等于1。
private void rehash() {
List<AnyType>[] oldLists = theLists;
theLists = new List[nextPrime(2 * theLists.length)];
for (int i = 0; i < theLists.length; i++) {
theLists[i] = new LinkedList<>();
}
currentSize = 0;
for (int i = 0; i < oldLists.length; i++) {
for (AnyType item : oldLists[i]) {
insert(item);
}
}
}
6. 标准库中的散列表
标准库包括Set和Map的散列表的实现,即HashSet类和HashMap类。HashSet中的关键字必须提供equals方法和hashCode方法。同行是用分离链接散列实现的。
HashMap的性能常常优于TreeMap的性能。
在Java中,能够被合理地插入到一个HashSet中去或是所谓关键字被插入到HashMap中去的那些库类型已经被定义了equals和hashCode方法。特别是String类有一个hashCode方法,每个String对象内部都存储它的hashCode值,初始为0,若hashCode方法被调用,这个值就会被记住,大大节省计算hashCode的时间。