散列的Java实现
0. 前言
我是一名数据结构与算法的初学者,为了巩固知识点,与更多的IT朋友们交流,我将在CSDN社区发布数据结构与算法系列的学习总结。根据学习进度,内容大概1-2周一更,欢迎大家对相关知识点进行校正和补充,共同进步,谢谢~
1. 概念
这一章将介绍散列的基本知识以及一些核心思想。高亮部分为重要知识点或需要掌握的概念。1.1节介绍什么是散列表,1.2-1.4节介绍散列表的核心思想:散列函数、解决冲突、再散列。
1.1 什么是散列表
散列表是一种以常数平均时间执行增、删、查的技术,但不支持排序。
散列表其实是包含一些项的具有固定大小的数组大小为TableSize,如下表。向数组中添加元素时,将项映射到 [0,TableSize)中的某个数index,然后把这个元素添加到数组中相应的位置。这就是散列表的基本想法,接下来就要解决如何映射的问题。
角标 | 项 |
---|---|
0 | 张三 18 |
1 | |
2 | 王五 18 |
3 | 赵六 18 |
… | … |
TableSize-1 |
1.2 散列函数h(x)
1.1节介绍了散列的基本工作原理,在这一节将解决映射的问题,给定一个项,我们可以根据这个项中的某个关键字进行映射,散列函数将关键字转换成散列表数组的角标。
- 当关键字Key为整数时,令散列函数h(x) = Key % TableSize,这种方法最为简单,但是需要考虑的是,若表的大小是10,而项的关键字个位都是0时,所有的项都被映射到0这个位置,就发生了冲突,为了避免冲突,最好的方法是将TableSize设置为质数,这样冲突就会减少,但还不能完全避免,比如TableSize = 11,而关键字是11的倍数。本文将在1.3中介绍解决冲突的方法,在第2章中给出散列的java代码实现。
- 当关键字是字符串时,散列函数见Code1:
//Code1 关键字是字符串时的散列函数 //一个良好的散列函数,根据Horner法则计算 public int hash(String key, int tableSize){ int hasVal = 0; for (int i = 0; i < key.length(); i++) { hasVal = 37 * hasVal + key.charAt(i); } hasVal %= tableSize; if(hasVal < 0)//计算出来的hasVal可能会溢出,产生负数,因此需要进行判断 hasVal += tableSize; return hasVal; }
- 使用对象进行散列:散列函数并不是一成不变的,只是有些散列函数能把关键字均匀的分配,不易造成冲突,而有些散列函数映射出来的结果很差。其实Object的hashCode()方法可以为我们获取对象的哈希值,我们可以在此基础上写散列函数,需要时,也可以覆盖hashCode()方法。
//基于对象哈希值的散列函数 public int hash(Object obj, int tableSize){ int hasVal = obj.hashCode(); hasVal %= tableSize; if(hasVal < 0) hasVal += tableSize; return hasVal; }
1.2 解决冲突
本节解释发生冲突时如何解决冲突,但本节并不能解决全部的冲突,这个小问题留在1.3中解决。
将一个新的项存入散列表的某个位置时,这个位置可能已经被其他项占据,这称之为冲突。解决冲突的方法有几种,最简单的是分离链接法和开放定址法,而开放定址法又分为线性探测、平方探测以及双散列。根据方法的不同,我们有不同的散列实现,见第2章。以下介绍这些方法的基本原理和基本概念。
- 分离链接法-分离散列表
将散列到同一个值的所有元素保存到一个表(LinkedList)中,这样便解决了冲突。散列表数组存储的元素为LinkedList链表,而每个linkedList储存冲突的项。比如向链表中插入2时发现3占据了这个位置,但是没关系,用链表把3和2串起来一起放到这个位置即可。将散列到同一个值的所有元素保存到一个表(LinkedList)中,这样便解决了冲突。散列表数组存储的元素为LinkedList链表,而每个linkedList储存冲突的项。比如向链表中插入2时发现3占据了这个位置,但是没关系,用链表把3和2串起来一起放到这个位置即可。
执行查找时,使用散列函数来确定需要遍历哪个链表;执行插入时,检查对应链表中是否存在这个元素,若不存在,则在链表的前端插入,这么做的原因是:新插入的元素最有可能不久后又被访问。
角标 | 数组存储的链表 |
---|---|
0 | linkedList0 2->3->4->1 |
1 | linkedList1 5->6->7->8 |
2 | linkedList2 12->10->11->9 |
3 | linkedList3 13->14->16->15 |
… | … |
TableSize-1 | linkedList… …->…->…->… |
- 开放定址法-探测散列表
当储存元素发生冲突时,这种方法尝试另外的单元h(x),直到找出空单元位置。根据1.2节的散列函数hash(x)构造新的散列函数散列函数hi(x)
hi(x) = ( hash( x ) + f( i ) ) % tableSize
通过试探h0(x)、h1(x)、h2(x)……这些单元,找到空位。hash(x)是1.2节的散列函数,f(i)函数的作用是解决冲突,hi(x)是新的散列函数。
根据f(i)的不同,开放定址法可分为线性探测法、平方探测法、双散列法。
总之,开放地址法的思想是,当发生冲突时,我们从冲突发生的位置按照一定的规则不断试探其他单元,直到找到空单元时把元素存入。但这样的努力并不能解决所有冲突,有时可能永远也找不到空位,这样的探测可能是失效的,对于这个问题,1.3给出解决办法。
1.3 再散列
考虑一种情况,当表的大小固定而存储的元素数不断增多时,对于分散链接法,链表长度将不断增加,导致查找和删除时运行时间的增加(因为要遍历链表);而对于开放定址法,元素不断增多时,可能导致元素找不到空位,从而引发死循环,造成致命错误。再散列指当元素数目过多时,增大散列表TableSize的大小。
因此,分离链接法通过在散列降低删除和查找操作的复杂度;开放定址法通过再散列降低运算复杂度,并解决元素找不到空位这种致命的错误,这回应了1.2节中遗留的问题。
那么元素过多怎么定义,具体什么时候需要进行再散列呢?
定义散列表的装填因子R为散列表中元素个数与该表大小的比,对于分离连接法,一般使得R = 1,如果 R > 1 则进行再散列;对于开放地址法,R > 0.5 的时候进行再散列。
相关定理表明,如果使用平方探测,且表大小是质数时,那么当表至少有一半是空的时候,总能插入一个新的元素。这解决了开放地址法中平方探测法的致命错误。为了减少冲突以及解决致命错误,我们要把散列表的大小设置为质数。
2. 散列的实现(Java代码)
本章使用java语言实现分离链接法以及开放定址中的平方探测法,线性探测法以及双散列仅做简单介绍。
2.1 分离链接法散列表的Java实现
import java.util.LinkedList;
import java.util.List;
/**
* 分离链接法实现散列表
* 表中维护着一个数组array、元素个数currentSize
* 定义了散列表默认大小
*/
public class MySeparateChainingHashTable<T> {
/**
* 默认构造方法
*/
public MySeparateChainingHashTable(){this(DEFAULT_TABLE_SIZE); }
/**
* 创建一个具有指定大小的散列表
* @param tableSize 指定散列表的大小
*/
public MySeparateChainingHashTable(int tableSize){
array = new LinkedList[nextPrime(tableSize)];
for (int i = 0; i < array.length; i++) {
array[i] = new LinkedList<T>();
}
currentSize = 0;
}
/**
* 将表置空
*/
public void makeEmpty(){
for(List list : array){
list.clear();
}
currentSize = 0;
}
/**
* 存储一个元素,若元素存在,则无需存储
* 若元素不存再则存储该元素
* 若装填因子>1则进行再散列
* @param t 要存储的元素
*/
public void insert(T t){
List<T> list = array[myhash(t)]; //找到对应的链表
if(!list.contains(t)){
list.add(t);
//若装填因子大于1,则进行再散列操作
if(++currentSize > array.length)
rehash();
}
}
/**
* 判断散列表中是否存在元素t
* @param t 要查找的元素
* @return 是否存在该元素
*/
public boolean contains(T t){
List<T> list = array[myhash(t)];
return list.contains(t);
}
/**
* 从散列表中删除元素t
* @param t 想要删除的元素
*/
public void remove(T t){
if(contains(t)){
List<T> list = array[myhash(t)];
list.remove(t);
currentSize--;
}
}
/**
* 判断散列表是否为空
* @return
*/
public boolean isEmpty(){
return size() == 0;
}
/**
* 获取散列表大小
* @return
*/
public int size(){
return currentSize;
}
/*
再散列,防止装填因子过大而增加运算复杂度
*/
private void rehash() {
List<T>[] oldArray = array;
array = new LinkedList[nextPrime(oldArray.length * 2)];
for (int i = 0; i < array.length; i++) {
array[i] = new LinkedList<T>();
}
currentSize =0;
for (List<T> list : oldArray){
for(T e : list){
insert(e);
}
}
}
/*
散列函数,获取元素t对应散列表的数组角标
*/
private int myhash(T t) {
int hashVal = t.hashCode();
hashVal %= array.length;
if (hashVal < 0)
hashVal += array.length;
return hashVal;
}
/*
获取不小于tableSize的最小质数
*/
private int nextPrime(int tableSize) {
if(isPrime(tableSize)) return tableSize;
while(isPrime(tableSize))
tableSize++;
return tableSize;
}
/*
判断一个数是否为质数
*/
private boolean isPrime(int num) {
if(num < 2) return false;
for (int i = 2; i < num; i++) {
if (num % i == 0) return false;
}
return true;
}
//常量
private static final int DEFAULT_TABLE_SIZE = 11;
//维护的字段
private List<T>[] array;
private int currentSize;
}
下面对这个散列表进行一个简单的加载测试:
public static void main(String[] args){
MySeparateChainingHashTable t = new MySeparateChainingHashTable<String>();
t.insert("aaa");
t.insert("bbb");
t.insert("ccc");
System.out.println(t.contains("aaa")); //true
System.out.println(t.contains("ddd")); //false
t.remove("aaa");
System.out.println(t.contains("aaa")); //false
t.insert("ddd");
System.out.println(t.contains("ddd")); //true
System.out.println(t.size()); //3
System.out.println(t.isEmpty()); //false
t.makeEmpty();
System.out.println(t.isEmpty()); //true
System.out.println(t.contains("ddd")); //false
//加载测试
for(int i =0; i < 1000; i++){
t.insert("元素"+i);
}
System.out.println(t.contains("元素999")); //true
System.out.println(t.size()); //1000
t.makeEmpty();
System.out.println(t.contains("元素999")); //false
}
测试成功,证明散列表是可用的。
2.2 线性探测法简介
线性探测法是开放地址法的一种,只是规定其解决冲突的函数 f(i) = i。线性探测法容易造成表中元素的聚集,导致元素无法均匀的存入表中,增加了运算的复杂度(需要很多次探测才能解决冲突)。因此不推荐这种方法。
2.3 平方探测法散列表的Java实现
平方探测法解决了线性探测法的聚焦问题。平方探测法的f(i) = i2。值得强调的是,开放地址法的删除操作是懒惰删除。如果我们真的删除了某个元素,那么对跳过这个元素的其他元素执行contians()方法时将捕获到null,contains方法失效。我们写一个内部类HashEntry来保存元素信息。
/**
* 开放地址法——平方探测实现散列表
* 维护一个HashEntry<T>[]数组
* 维护currentSize:当前元素个数
* 定义了散列表的默认大小DEFAULT_TABLE_SIZE
* @param <T>
*/
public class MyQuadraticProbingHashTable<T> {
/**
* 默认构造初始化,创建默认大小(11)的散列表
*/
public MyQuadraticProbingHashTable(){
this(DEFAULT_TABLE_SIZE);
}
/**
* 创建指定大小的散列表
* @param tableSize 接收一个散列表的大小
*/
public MyQuadraticProbingHashTable(int tableSize){
allocate(tableSize);
}
/**
* 把元素插入到散列表中,若元素已存在,则什么也不做
* 若装填因子过大则执行再散列
* @param t 接收一个元素
*/
public void insert(T t){
int currentPost = findPos(t);
if (!isActive(currentPost)){
array[currentPost] = new HashEntry<T>(t,true);
currentSize++;
if (currentSize > array.length / 2)
rehash();
}
}
/**
* 判断散列表中是否包含元素t
* @return
*/
public boolean contains(T t){
return isActive(findPos(t));
}
/**
* 将元素t从散列表中删除
* @param t
*/
public void remove(T t){
if (contains(t)){
array[findPos(t)].isActive = false;
currentSize--;
}
}
/**
* 获得散列表中元素个数
* @return
*/
public int size(){
return currentSize;
}
/**
* 判断散列表是否为空
* @return
*/
public boolean isEmpty(){
return size() ==0;
}
/**
* 将散列表置空
*/
public void makeEmpty(){
allocate(array.length);
currentSize = 0;
}
/*
当装填因子 > 0.5时进行再散列,避免运算复杂度过大,避免出现致命的错误。
*/
private void rehash() {
HashEntry<T>[] oldArray = array;
allocate(nextPrime(2 * oldArray.length));
currentSize = 0;
for(HashEntry<T> entry : oldArray){
if (entry != null && entry.isActive)
insert(entry.eletment);
}
}
/*
判断指定位置是否存在active的元素
*/
private boolean isActive(int currentPost){
return (array[currentPost] != null && array[currentPost].isActive);
}
/*
返回元素t所在的位置,若散列表中没有元素t,则返回t可以添加的位置
这个函数其实就是再平方法的散列函数
*/
private int findPos(T t) {
int currentPos = myhash(t);
int offset = 1;
while (array[currentPos] != null &&
!array[currentPos].eletment.equals(t)){
currentPos += offset;
offset +=2;
if(currentPos >= array.length)
currentPos -= array.length;
}
return currentPos;
}
/*
散列函数,获取元素t对应散列表的数组角标
*/
private int myhash(T t) {
int hashVal = t.hashCode();
hashVal %= array.length;
if (hashVal < 0)
hashVal += array.length;
return hashVal;
}
/*
为数组HashEntry[]初始化
*/
private void allocate(int size) {
array = new HashEntry[nextPrime(size)];
}
/*
获取不小于tableSize的最小质数
*/
private int nextPrime(int tableSize) {
if(isPrime(tableSize)) return tableSize;
while(isPrime(tableSize))
tableSize++;
return tableSize;
}
/*
判断一个数是否为质数
*/
private boolean isPrime(int num) {
if(num < 2) return false;
for (int i = 2; i < num; i++) {
if (num % i == 0) return false;
}
return true;
}
/*
私有类,保存元素以及元素的状态,便于惰性删除
*/
private static class HashEntry<T>{
public T eletment;
public boolean isActive;
public HashEntry(T t){
this(t, true);
}
public HashEntry(T t, boolean isActive){
eletment = t;
this.isActive = isActive;
}
}
private static final int DEFAULT_TABLE_SIZE = 11;
//维护的私有变量
private HashEntry<T>[] array;
private int currentSize;
}
下面对这个散列表进行一个简单的加载测试:
public static void main(String[] args){
MyQuadraticProbingHashTable<String> t = new MyQuadraticProbingHashTable<String>();
t.insert("aaa");
t.insert("bbb");
t.insert("ccc");
System.out.println(t.contains("aaa")); //true
System.out.println(t.contains("ddd")); //false
t.remove("aaa");
System.out.println(t.contains("aaa")); //false
t.insert("ddd");
System.out.println(t.contains("ddd")); //true
System.out.println(t.size()); //3
System.out.println(t.isEmpty()); //false
t.makeEmpty();
System.out.println(t.isEmpty()); //true
System.out.println(t.contains("ddd")); //false
//加载测试
for(int i =0; i < 1000; i++){
t.insert("元素"+i);
}
System.out.println(t.contains("元素999")); //true
System.out.println(t.size()); //1000
t.makeEmpty();
System.out.println(t.contains("元素999")); //false
}
测试通过!!
2.3 分离链表法与平方探测法的简要对比
- 很容易看出,分离链表法引入了链表数据结构LinkedList,而平方探测法没有用到多余的数据结构;
- 分离链表法要分配链表的地址,这回消耗一定的资源
- 平方探测法实现的散列表比分离链表法大(装填因子的选择不同)
2.4 双散列法简介
双散列不提供代码实现,双散列即令冲突的函数f(i) = i*hash2(x)。双散列使用到了两个散列函数,hash2(x)的选择是非常重要的。
2.5 平方探测法与双散列法的对比
双散列需要用到第二个散列函数,这对于字符串这样的关键字,散列函数计算起来相当耗时。因此在实践中平方散列可能更快。