散列表ADT是一个包含一些项的具有固定大小的数组
散列是一种以常数平均时间O(1)执行插入、删除、查找的技术
JavaCollection中基于散列技术实现了HashSet、HashMap
不支持排序、findMin、findMax等操作
散列表一般是基于HashCode实现散列函数的,故散列表中存储的对象需要hashcode()和equals()方法
散列表ADT是一个包含一些项的具有固定大小的数组
查找操作是通过项的某个部分进行的,这个部分叫做关键字
把散列表的大小记作TableSize
把每个关键字映射从0到TableSize-1这个范围的某个数,并放入数组中对应的单元中的技术叫做散列
其中映射到(0 ~ TableSize-1)的函数叫做散列函数(Hash Function)
这个散列函数在一般情况下就是Key mod TableSize,Key一般下就是对象的HashCode()了
但是散列ADT中存在一个很大的问题就是散列冲突:TableSize必然不能无限大,甚至不能过大,这样必然会有多个关键字映射到同一个值,这样如何存储冲突值就成了一个需要探讨的问题;另外如何能尽可能利用TableSize中的每一个单元,而不是将冲突集中到其中几个单元,剩下的单元利用不充分造成资源浪费
利用散列表需要考虑的问题:
1 如何存储冲突值
2 散列表的大小如何选择
3 散列函数如何计算才能尽可能减小冲突
我们先说明后两个问题:
散列表的大小选择要求尽可能平均分布,举例若散列表大小为10而关键字都以0为个位,那么所有的关键字都会在0位冲突,其他单元又严重浪费,比较好的办法就是:
保证表的大小是素数(素数:除了1和自己不能被整除)(一般取17以上的素数)
原因:
1 如果关键字是随机的,TableSize是多少其实并无所谓,然而在现实中关键字往往有某种规律,例如大量的等差数列,那么公差和模数不互质的时候发生碰撞的概率会变大,而用质数就可以很大程度上回避这个问题。
2 一般地说, 当模数非常大的时候, 取什么数关系不太大, 质数合数都有不错的结果,但是一般情况下模数不会 “足够大” , 这个时候, “所有” 17以上的素数都有不错的结果, 而很多合数也有不错的结果, 但是个别一些合数结果会非常非常差,冲突非常多.。因此为了稳妥起见, 取17以上的素数.
//是否为素数
private boolean isPrime(int num) {
if (num == 2 || num == 3) {
return true;
}
if (num % 6 != 1 && num % 6 != 5) {
return false;
}
for (int i = 5; i * i <= num; i += 6) {
if (num % i == 0 || num % (i + 2) == 0) {
return false;
}
}
return true;
}
//n后面的下一个素数
private int nextPrime(int n) {
boolean state = isPrime(n);
while (!state) {
state = isPrime(++n);
}
return n;
}
一般情况下的散列函数=对象的Key mod TableSize(对象的Key = 对象的HashCode)
public int myHash(T t) {
int hashValue = t.hashCode() % list.length;
if(hashValue < 0) { //考虑溢出为负数
hashValue += list.length;
}
return hashValue;
}
当然对象的HashCode可以依据自身重写
但要记住的是再怎么重写他也是返回的Key,是在对象类中定义的
散列函数却是在实现的散列表中定义的myHash(),哪怕令 myHash = HashCode ,也要在思想上有所区分
HashTable中的容量并不等同于存储元素的个数,散列的作用在于HashTable中只有11个位置,数组中下标0-10,通过散列函数决定这个元素应该放置在数组中哪个下标的单元中,获取的是数组的下标
一个对象的HashCode是表示这个元素是否为新元素的标记,他是对象的Key
一般情况下Set、Map中不存放重复元素(除非有特殊要求),如果HashCode相等表示是一个对象,则不做插入操作
通过散列函数获取存储位置下标,再与该位置中存储元素的hashcode一一对比,若hashcode不相等表示是新元素插入,若hashcode相等表示是重复元素
为了尽可能减小冲突引入通用散列法
H = [(a * key + b) mod p ] mod M
其中1 key为对象HashCode
2 M为散列表TableSize
3 p为大于TableSize的任何素数,一般情况下我们使用梅森数(梅森数:n是一个素数 [2n - 1]也是一个素数)例如:25 - 1 ;231 - 1 ;261 - 1;289 - 1…..,利用梅森数求解mod会十分方便
以梅森数2n - 1 为例,梅森数在乘法和mod上可以利用位移运算简化运算时间
1 乘法 : K * 2n - 1 = K * 2n - K;其中 2n=2 << n(左移n位)
2 mod: K mod 2n - 1 = K/2n + K mod 2n;
其中: K/2n = 2 >> n (右移n位) ,K mod 2n = K & 2n-1 (K 和 2n-1按位与运算 )
4 (a,b)是一对系数,a、b为随机选取的,其中 1<= a <= p-1、 0<= b <= p-1
最后集中讨论第一个问题,如何存储冲突的值
将散列到同一个值得所有元素保留在一个表中(比如存在一个List上),那么用来存储的数组就是一个List数组(List[])
具体实现看GitHub
探测散列表的基本思想:不使用链表而是尝试另外的一些单元,关键字冲突之后将冲突的元素插入到距离冲突位置d的数组位置上
d = f(i) mod TableSize 【 i 是冲突的次数0 1 2 3 ……】
或者说探测散列表散射函数 H = (HashCode + F(i))mod TableSize
依据F(i)的不同分为以下三种:
F(i) = i
如果发生冲突,将放入下一个地址,如果下一个地址依然冲突,则再次下移一个地址,直到存放到空闲地址为止
只要表足够大就一定能找到一个空闲的单元,但是这样产生的问题在于:每次查找空闲单元时都会花费相当多的时间,即使表相对较空,占据的单元由于冲突时下移一位会连在一起构成一次聚集区,这样散列到这个区块的元素要经过多次查找选择才能解决冲突
F(i) = i2
平方探测法可以解决线性探测中的一次聚集问题
当元素在位置 j 发生冲突时,首先在 j+1的位置探测,如果依然冲突则令i=2,在 j+4的位置探测,如果再次冲突 i=3 ,在 j+9的位置探测。。。。直至空闲元素
平方散列表将每次探测间距分隔开,由此解决一次聚集问题
平方散列表实现查找、删除、插入操作,其中注意一点对平方散列表进行删除时,由于冲突元素有可能经过这个被删除元素存放到较下面的位置,如果真的删除这个元素,会影响散列表的结构对后面的探测造成影响,因此在平方散列表中实施的是懒惰删除:不实际删除这个元素,仅仅给一个标记证明是被删除的元素
private class HashEntrySet {
private T data;
private boolean activeIdex;//删除 不存活 false
//我们需要对activeIndex设置set方法
public void setActiveIdex(Boolean activeIdex){
this.activeIdex = activeIdex;
}
public HashEntrySet(T t) {
this.data = t;
this.activeIdex = true;
}
}
遍历时,在散列函数映射的位置开始以i2为步长遍历查找
public int contains(T t) { //获取t的位置
int index = myHash(t);//获取这个数,本该存放的位置,然后按照冲突i=1 i=2 i=3的向后遍历
int i = 1;
while( list[index] != null &&!list[index].data.equals(t)){
//如果查到的list[index]==null则表示不存在这个数,因为如果有这个数这个位置不会为空
//同样如果插入,插入的位置也应该是这个地方
index = index + (2*i-1); // i^2 - (i-1)^2 --> 2i-1
i++;
if(index > list.length) {
index -= list.length;//例如9再向后遍历应该是0,循环遍历
}
}
return index;
}
插入操作就是先查找如果查找到元素且是被删除的,将删除位set false,如果查找不到就新加一个EntrySet
删除操作是查找到元素并把删除位set true
双散列就是步长F(i)不再是基于i的一个固定简单函数,而是一个复杂的散射函数
F(i)=i * hash2(x)
散列表的大小并不是一成不变的,当存储元素达到一定程度(或者叫做填充因子,以及占据的单元/散列表的单元总量),一般情况下填充因子达到0.5时需要扩充散列表数组容量,我们这个过程叫做再散列
在这个过程中要注意:
1 新创建的散列表大小同样也需要是质数,是原表大小两倍之后的第一个质数
2 散列函数=xx mod TableSize,故散列表大小改变之后,散列函数依然会变化,原散列表数组中的元素要经过新的散列函数映射到新的位置
private void expand() {
T[] oldList = (T[])this.list;
this.list = new Object[nextPrime(this.list.length)];
this.size = 0;
for(T t : oldList) {
if(t != null) {
insert(t);
}
}
}
JavaCollection中HashSet和HashMap通常是分离链接散列实现的
就像在最开头说明的,集合中存放的对象必须实现hashcode和equals方法
HashSet、HashMap不支持顺序排序,所以要在依照有序查看这一操作不重要的时候选择这两个类
散列表中费时最多的计算过程就是HashCode方法,然而在String类有一个重要的优化(闪存散列代码):在String内部存储它的HashCode值,该值初始为0,若HashCode函数被调用那么这个值会一直被记住,当调用HashCode第二次计算时直接调用,避免重新计算,String类具有这种机制的原因在于String类是不可改变的,若String发生改变会新生成一个String对象存储新的HashCode值。
一般情况下,HashMap的性能优于TreeMap的性能,但是不一定肯定,在TreeMap和HashMap都可以接受的情况下,使用接口类型Map进行变量的声明,然后将TreeMap的实例变成HashMap的实例并进行计时测试,选择优者。
PPS:TreeMap的实例变成HashMap的实例。。。把元素一个个添加。。直接强制转换是不可能的
以上的散列实现都是在合理的散列函数下,期望插入删除操作的平均花销是O(1),但是仍不排除部分极坏的情况时间复杂度会超出O(1)
但是对于某些应用下,要求即便是最坏情况下也要O(1),这就要求我们进一步缩短花销时间,这就引出了完美散列
完美散列的基本思想:
1 在主散列数组的每个单元中再存放一个散列列表数组(二级散列表),用来存放冲突的值
2 二级散列表大小是对应的主散列表单元中元素的平方(也就是冲突元素数量的平方)
3 每个二级散列表用一个不同的散列函数映射直到没有冲突,可以存放在主散列表单元的元素
4 如果产生冲突的次数过高(多于设定值),主散列表可以重新用一个不同的散列函数重新构建
完美散列有一个前提是假定要存储的N项是已知的,这样我们才方便选择映射函数、构建二级散列表直至没有冲突(静态完美散列:所有Key已知且固定)
涉及到动态插入删除等操作时,我们使用下面两种更新方法:布谷鸟散列、跳房子散列
布谷鸟散列是基于一个定理:将N项放入N个盒子,盒子容量最大期望值为O(logN/log logN),但是如果每次投放时随机选择两个盒子,将该项放入比较空的那一个盒子,则盒子容量最大期望值为O(log logN)
于是产生了布谷鸟散列:维护两个散列表,并且每张表有自己独立的散列函数(两张表的散列函数要明显不同否则在表A的i位置冲突,到表B还是i位置冲突,这样就不行了),可以把每个项分配到每个表的一个位置(存储在两张表中映射的两个位置之一)
布谷鸟散列的特性:一项总会被存储到两张表映射的两个位置之一(好好理解)
布谷鸟散列中一次查找最多需要访问两次表,一次表A,一次表B,而且访问的位置一定是表A通过映射函数A得到的位置i 和 表B通过映射函数B得到的位置 j ,因为上面的特性这个元素一点存储在这两个位置之一
删除的实现也十分简单,找到这个元素直接删除置空,不需要我们在平方探测中使用的懒惰删除,原因还是特性,这个元素在表中本来就是应该存在在这个位置的,没有冲突之说也不会破坏结构,故直接删除
插入操作相对于查找、删除稍微复杂一些
将元素T插入表A的 i 位置
如果无元素直接插入
如果i位置上有元素O,直接替换掉这个元素令A[i]=T
,然后将元素O插入到表B中
元素O通过表B的映射函数B映射到位置j
如果j位置没有元素直接插入
如果j位置上有元素K,直接替换这个元素,令B[j]=O
,然后将元素K插入表A中
。。。。。
直到完成
在这个插入流程中有几个问题:
1 循环替换插入时存在几率:A换掉B,B换掉C,C换掉A这样的死循环,这时说明表A 表B的映射函数差异值还是不够,需要新的散列函数重新散列
2 经实验分析,表的装填因子小于0.5时,发生上述死循环的概率很低
就像我们上面说到的那两个问题,在实现插入操作时除了替换插入,我们还需要注意:
1 当表内的填充因子超过设定值(MAX_LOAD)时,需要扩充表(expand)
2 当替换操作的次数超过设定值(MAX_CHANGE)时,我们认为需要更换散列函数,需要重新生成表A 表B的散列函数并重新散列(rebuild这个过程不改变表的大小,只是用新的散列函数去填充)
3 如果再散列的次数超过限定值(MAX_NEW_COUNT)时,说明按照此刻表的大小很难找到不冲突的散列函数,这时选择将表扩展(expand)
public void insert(T t) {
if(contains(t) != -1){
return;
}
if(this.size >= this.list.length * Max_LOAD) {
expand();
}
//具体的插入
int newCount = 0;//生成新散列的次数
while(true) {
for(int change = 0; change < MAX_CHANGE; change++) { //可以交换MAX_ChanGE次
//查找每一个散列函数 看有没有空位置
for(int i= 0; i < this.functionNumber; i++) {
int index = myHash(t,i);
if(list[index] == null) {
list[index] = t;
this.size++;
return;
}
}
//找不到可用位置,就要替换,这里采用随机替换
int oldIndex = -1;
int newIndex = -1;
int count = 0;//随机生成多少次
while(count ++ < 5 && newIndex == oldIndex){ //随机
newIndex = myHash(t,new Random().nextInt(this.functionNumber));//随机找一个散列
}
//找到交换的newIndex
oldIndex = newIndex;
T tToChange = (T)list[oldIndex];
list[oldIndex] = t;
t = tToChange;
}
//交换MAXCHANGE次依然冲突,根据条件选择是生成新的散列表还是扩充
if(newCount < MAX_NEW_COUNT) { //如果没到5 生成新散列
reBuild();
}else{//否则扩充
newCount = 0;
expand();
}
}
}
跳房子散列是对线性探测算法的改进,线性探测中因为冲突本该插入的位置i,插入 i + d的位置,而跳房子散列的基本思想就是为这个距离d增加一个上界
跳房子散列设置一个数组Hop[n],这个n就是为距离d增加的上界,若插入的位置距离散列位置i上界为4(即可以 i 、 i+1、i+2、i+3)
Hop[ i ]数组即为4位,从左到右第 j 位表示散列为i的元素实际存放在 i + (j - 1)的位置上
Hop[i] | 散列为i的实际存放位置 |
---|---|
0000 | 无散列为i的元素 |
1000 | 存放在位置i |
0100 | 存放在位置i+1 |
0010 | 存放在位置i+2 |
0001 | 存放在位置i+3 |
1100 | 有两个元素散列为i,存放在位置i和位置i+1 |
1110 | 有三个元素散列为i,存放在位置i,位置i+1,位置i+2 |
….. | 等等。。不再列述 |
插入流程:
元素T通过散列列表获取位置 i
经过线性探测获得可以插入的位置 i + d
如果d小于上界,则可以直接插入
如果d不小于上界,则需要对散列表结构调整:
1 从位置 i + d 开始自顶向上查找,遍历Hop[i]最后一位不是1的元(除去0000,因为无元素散列不能带给我们任何咨询),找到可以下移到下面的空单元的元素O(令最后一位尽可能为1,但是不要超过i+d),更新Hop[],如此便可以释放当前的单元空间 j
2 在j的基础上继续向上遍历最后一位不是1的元素,找到可以下移到刚刚释放的单元j上的元素K,更新Hop[],释放元素K的空间
3 如此循环直到释放出一个空间可以存放元素T
可扩散列是用于数据量太大以至于装不进主存的情况。。
[感觉先用不到,占坑吧。。。。]