散列表也称为哈希表,是我们一种重要的数据结构,是一种用于以常数平均时间执行插入、删除和查找的技术。维基百科上对散列表的定义如下:
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表
意思是散列表是一个存放记录的数组,只不过我们进行操作数据的时候是按照一个算法(即散列函数f(x))计算出这个数据在数组中的位置,其中x被称为键(也被称为关键字),数组中的位置也被我们称为槽。
下面我们介绍几种常用的散列函数,在介绍之间我们先说明为什么要更好的散列函数
对于一个函数f(x),输入的x不同可以得到相同的结果。比如: f(x) = x mod 5,当x = 2 和x = 7时我们得到的函数值都为2。在散列表中,对于不同的关键字通过散列函数得到相同的值的情况我们称之为冲突,一个好的散列函数可以帮我们尽可能地减少冲突发生。
下面介绍几种常用的散列函数
除法散列法(除留余数法)
通过取key除以m的余数,将关键字key映射到m槽的某一个
即 散列函数为h(key) = key mod m
m 通常要求为一个素数,一般取散列表的长度
乘法散列法
a用关键字key乘上常数A,并提取k·A 的小数部分,用m乘以这个值,再向下取整。
即 散列函数为h(key) = floor(m·(k·A mod 1))
A要求在(0,1)之间
全域散列法
给定一组散列函数,随机地选择一个作为散列函数
直接定址法
取关键字或关键字地某个线性函数作为散列地址
比如: f(x) = ax 、 f(x) = ax + b等
常用的散列函数还有数字分析法、平方取中法、折叠法、随机数法等。
前面我们介绍了冲突的概念,即在散列表中,对于不同的关键字通过散列函数得到相同的值的情况我们称之为冲突,一个好的散列函数只能尽可能减少冲突地发生,而不能处理冲突。从散列表地定义中我们发现,可以从两个角度来处理冲突
一、从散列表的槽中来解决冲突,把槽当作一个容器,将所有发生冲突的元素放到这个容器中,可以把槽想象成一个桶;
二、通过一定的算法,将发生冲突的关键字放到下一个空槽中。
从这两个角度我们衍生出两种方法处理冲突,分别是链接法和开放寻址法,或者结合两种方法从而衍生处第三种处理方法完全散列(也称完美散列)。下面将逐一介绍
在链接法中,把散列到同一槽中的所有元素都放在一个链表中,即把槽当作一个容器,容器是由链表构成的。当然,在编程实现的时候我们将链表的引用放到槽中,通过索引来操作链表。下图是我们根据链接法抽象出来的一个结构图
通过链接法得到的散列表的性能取决于很多因素,如散列函数,关键字等,一个差的散列函数可能会将所有的元素散列到同一个槽中,这样无异于我们直接使用链表,这也是出现的最坏情况,在最坏情况下查找的时间复杂度为O(n)。当然,如果我们将链表改成双向链表,可以大大提高性能,查找、插入、删除操作最坏情况下也只需要O(1)的时间。在本文的最后将会给出使用双向链表的链接法得到的散列表的实现。
在开放寻址法中,所有的元素都存放在散列表中。也就是说,所有的元素都直接存储在槽中,槽中不再设立容器,不像链接法,这里既没有链表,也没有元素存放在散列表外。因此在开放寻址法中,散列表可能会被填满,以至于不能插入任何元素。当散列表被填满时,我们会进行再散列操作(再散列类即将构造一个新的更大的散列表,然后将旧散列表中的数据搬移到新的散列表中,将新散列表当作我们操作的散列表),这将在后面讲解。使用开放寻址法得到的散列表也称为探测散列表
为了使用开放寻址法插入一个元素,需要连续地检查散列表(也称为探查),直到找到一个空槽来放置待插入地关键字为止。如下图所示
在开放寻址法中,有三种技术常用来计算探查序列,分别是线性探查、二次探查和双重探查。
在上面的举例中我们使用的就是线性探查,即当发生冲突的时候,探查下一个空槽使用的是一个线性函数得到的空槽。使用这种方法会产生聚集(一次聚集),如上图我们还需要插入一个13,本应该插入到索引为3的位置,但是发现该位置被占用了,所以我们不得不寻找下一个空槽来放置元素,这无疑增加了计算量。
二次探查即使用开放寻址法发生冲突的时候,探查下一个空槽使用的是一个二次函数得到的空槽位置。比较流行的就是使用 f ( i ) = i 2 f(i) =i^2 f(i)=i2 其中i从0开始,没有找到空槽就i++,直到找到空槽为止。二次探查相对于线性探查好,但是也会产生聚集(二次聚集)。
双重散列即使用开放寻址法发生冲突的时候,探查下一个空槽使用的是两个组合的散列函数得到的空槽为止。双重散列是用于开放寻址法的最好方法之一,双重散列采用如下形式的散列函数
h ( k , i ) = ( h 1 ( k ) + i h 2 ( k ) ) m o d h(k,i)=(h1(k)+ih2(k))mod h(k,i)=(h1(k)+ih2(k))mod m
其中h1和h2均称为辅助散列函数
完全散列能保证在最坏情况用0(1)次访问完成。
实现完美散列的一种方案可以采用两级的散列方法来设计,在每级上都使用全域散列。即类似于散列表中嵌套散列表,第一层散列表采用链接法,槽中的容器也使用散列表(第二层散列表),第二层散列表使用开放寻址法,探测使用双重散列探测,使用这种方式能够大大提高性能。
对于使用平方探测的开放定址散列法,如果散列表填的太满,那么操作的运行时间将开始消耗过长,且插入操作可能失败。这可能发生在有太多的移动和插入混合的场合。此时,一种解决方法是建立另外一个大约两倍大的表,扫描整个原始散列表,计算每个元素的新散列值并将其插入到新表中,整个过程叫做再散列,后面我们给出的实现中也有再散列的应用。
使用链接法构造的散列表实现
import java.util.LinkedList;
import java.util.List;
// 分离直链法实现hash表
public class SeparateChainingHashTable {
private static final int DEFAULT_TABLE_SIZE = 101;// 表的默认大小
private List[] 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<>();
}
}
/*
* 插入
* 1. 找到插入的槽位
* 2. 将该元素插到槽位中存储的容器linkedList的第一个位置
* 整个操作可以在线性时间内完成
*/
public void insert(T element) {
List whichList = theLists[myhash(element)];
if(!whichList.contains(element)) {
whichList.add(element);
if(++currentSize > theLists.length)
rehash();
}
}
/*
* 删除元素
* 1. 找到需要删除元素的槽位
* 2. 从该槽位中删除这个元素
* 删除算法的时间复杂度取决于槽位中以及存在的元素
*/
public void remove(T element) {
List whichList = theLists[myhash(element)];
if(whichList.contains(element)) {
whichList.remove(element);
currentSize--;
}
}
public boolean contains(T element) {
List whichList = theLists[myhash(element)];
return whichList.contains(element);
}
/*
* 将散列表置空
*/
public void makeEmpty() {
for (int i = 0; i < theLists.length; i++) {
theLists[i].clear();
}
currentSize = 0;
}
/*
* 再散列
* 将散列表扩大为当前散列表两倍散列表的最小素数长度
* 然后将旧散列表中的数据放到新散列表中
*/
private void rehash() {
List[] oldLists = theLists;
theLists = new List[nextPrime(2*theLists.length)];
for(int j = 0 ; j < theLists.length;j++) {
theLists[j] = new LinkedList<>(); // 为每一个槽创建容器
}
currentSize = 0;
for(int i = 0 ; i < oldLists.length;i++) {
for(T item:oldLists[i]) {
insert(item);
}
}
}
/*
* 这里采用的是除法散列法来设计hash函数,要求length为素数,这样适合适合计算
* 1. 获取element的hash值
* 2. 将获取到的hashVal和hash表的长度进行进求模运算
* 3. 如果hashVal<0
* 则加上哈希表的长度以免数组越界
*/
private int myhash(T element) {
int hashVal = element.hashCode();
hashVal %= theLists.length;
if (hashVal < 0)
hashVal += theLists.length;
return hashVal;
}
/*
* 寻找比n大的最小素数
* 素数的性质: 除了1和本身外不存在其他可以整除的数
* 推论: 除2外,能被2整除的数都不是素数
*/
private static int nextPrime(int n) {
if(n % 2 == 0) n++;
while(!isPrime(n)) n += 2; // 直接从n开始寻找,优化
return n;
}
/*
* 判断一个数是不是素数,注意1不是素数,2是最小的素数
*/
private static boolean isPrime(int n) {
if(n == 3 || n == 2) return true;
if(n % 2 == 0 || n == 1) return false;
for(int i = 3; i * i < n ; i +=2)
if(n%i == 0) return false;
return true;
}
}
使用开放寻址法实现的散列表,探测使用线性探测
public class QuadriticProbingHashTable {
private static final int DEFAULT_TABLE_SIZE = 11;
private HashEntry[] array;
private int currentSize;
public QuadriticProbingHashTable() {
this(DEFAULT_TABLE_SIZE);
}
public QuadriticProbingHashTable(int size) {
allocateArray(size);
makeEmpty();
}
public boolean contains(T element) {
int currentPos = findPos(element);
return isActive(currentPos);
}
/*
* 插入元素,如果元素总量超过了array的长度,则进行再散列
*/
public void insert(T element) {
int currentPos = findPos(element);
if(isActive(currentPos)) return;
array[currentPos] = new HashEntry<>(element,true);
if(currentSize > array.length/2) rehash();
}
/*
* 再散列
*/
private void rehash() {
HashEntry[] oldArray = array;
allocateArray(nextPrime(2*oldArray.length));
currentSize = 0;
for(int i = 0 ; i < oldArray.length;i++) {
if(oldArray[i] != null && oldArray[i].isActive)
insert(oldArray[i].element);
}
}
/*
* 删除指定元素,将其标记为true
*/
public void remove(T element) {
int currentPos = findPos(element);
if(isActive(currentPos))
array[currentPos].isActive = false;
}
/*
* 查询指定元素的索引
* 使用线性探查
*/
private int findPos(T element) {
int offset = 1;
int currentPos = myhash(element);
while(array[currentPos] !=null && !array[currentPos].element.equals(element)) {
currentPos += offset;
offset += 2;
if(currentPos >= array.length) {
currentPos -= array.length;
}
}
return currentPos;
}
private boolean isActive(int currentPos) {//判断指定为止的元素是否被删除或是否有元素
return array[currentPos] != null && array[currentPos].isActive;
}
private void allocateArray(int arraySize) {// 构造一个新的散列表
array = new HashEntry[nextPrime(arraySize)];
}
public void makeEmpty() {
currentSize = 0;
for(int i = 0; i < array.length ; i++) {
array[i] = null;
}
}
/*
* 这里采用的是除法散列法来设计hash函数,要求length为素数,这样适合适合计算
* 1. 获取element的hash值
* 2. 将获取到的hashVal和hash表的长度进行进求模运算
* 3. 如果hashVal<0
* 则加上哈希表的长度以免数组越界
*/
private int myhash(T element) {
int hashVal = element.hashCode();
hashVal %= array.length;
if (hashVal < 0)
hashVal += array.length;
return hashVal;
}
/*
* 寻找比n大的最小素数
* 素数的性质: 除了1和本身外不存在其他可以整除的数
* 推论: 除2外,能被2整除的数都不是素数
*/
private static int nextPrime(int n) {
if(n % 2 == 0) n++;
while(!isPrime(n)) n += 2; // 直接从n开始寻找,优化
return n;
}
/*
* 判断一个数是不是素数,注意1不是素数,2是最小的素数
*/
private static boolean isPrime(int n) {
if(n == 3 || n == 2) return true;
if(n % 2 == 0 || n == 1) return false;
for(int i = 3; i * i < n ; i +=2)
if(n%i == 0) return false;
return true;
}
private static class HashEntry{ // 储存值
public T element;
public boolean isActive;
public HashEntry(T element) {
this(element,true);
}
public HashEntry(T element,boolean flag) {
this.element = element;
isActive = flag;
}
}
}
以上为散列表的一些基础知识,还存在其他扩展的散列表,如布谷鸟散列、跳房子散列等。在java的类库中,HashSet和HashMap的数据结构也都是散列表。在java中,字符串经常被当作为HashMap的键,在进行操作时会经常使用到String的HashCode,每次计算会耗费大量时间。为了避免昂贵的重新计算,在String类中做了一个重要的优化,每个String对象内部都存储他的hashcode值,该值初始化为0,但若hashcode被调用,这个值就会被记住,这个技巧叫做闪存散列代码。以下是Sting的部分源码展示
/** Cache the hash code for the string */
private int hash; // Default to 0
/**
* Returns a hash code for this string. The hash code for a
* {@code String} object is computed as
*
* s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
*
* using {@code int} arithmetic, where {@code s[i]} is the
* ith character of the string, {@code n} is the length of
* the string, and {@code ^} indicates exponentiation.
* (The hash value of the empty string is zero.)
*
* @return a hash code value for this object.
*/
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}