概念
散列是一种用于以常数平均时间,执行插入、删除、查找的技术。
散列中要查找的数据项叫做关键字(key)。
映射:如果表的大小是tableSize,将每个关键词与0-tableSize-1的下标一一对应起来的过程叫做映射。
图中 dave—>0 john->3 phil->4 这种关系称为映射。
散列函数:f(关键词)=下标,这样的f 叫做散列函数。
如果两个关键词映射到同一个下标,这叫做冲突。
散列函数
字符串的ASCII码
public static int hash(String key,int tableSize){
int hashVal = 0;
for(int i=0;i<key.length();i++){
hashVal += key.charAt(i);
}
return hashVal%tableSize;
}
优点:计算简单
缺点:如果表很大,函数将不会很好的分配关键字。如果tableSize = 10 007,设字符串最长8个字符,字符ASCII码最大127,因此散列函数只能假设值在0-1016(=127*8)之间,分布不均匀。
27进制算法
假设每个字符串至少有3个字符,tableSize = 10 007。如果前三位字符随机(从26个字母和空格中随机选择),那么有 263=17576 种可能,与表的大小相近,会很均匀,但是实际中不是随机的,3个字符的不同组合有2 851种,最多只能占表的28%。
public static int hash(String key,int tableSize){
return (key.charAt(0)+27*key.charAt(1)+27^2*key.charAt(2))%tableSize;
}
所有字符参与计算
使用所有字符参数hansh计算。计算公式: sumkeysize−1i=0Key[keysize−i−1]∗37i 根据Horner法则,将式子转为 hk=((k2)∗37+k1)∗37+k0 如果关键字key=”hello”, ‘h’* 374+′e′∗373+′l′∗372+′l′∗371+′o′∗370 。
public static int hash(String key, int tableSize) {
int hashVal = 0;
for (int i = 0; i < key.length(); i++) {
hashVal = hashVal*37+key.charAt(i);
}
hashVal = hashVal % tableSize;
if(hashVal<0){
hashVal = hashVal+tableSize;
}
return hashVal;
}
优点:一般可以分布得很好。
缺点:如果key很长,计算量很大。
说实话没看出比上一个方法好在哪里了。
类型
要散列的对象不仅可以是String,任何实现了equals和hashCode函数的对象都可以散列。
冲突解决
冲突解决有几种:分离链接法和开放定址法
分离链接法
分离链接法是将散列到同一个值的所有元素保留到一个表中。
缺点:因为这些表是双向列表,浪费空间。
装填因子
λ=元素个数/表的大小
没看明白:一次插入操作、不成功的查找操作耗时
λ
,一次成功的查找大约 XXX。
开放定址法
开放定址法是当遇到冲突的单元时,尝试下一个单元,直到成功。
hi(x)=(hash(x)+f(i)modtableSize
i是正在尝试的数组下标。
根据 f(i)的不同分为线性探测法、平方探测法、双散列。
线性探测
逐个探测可用单元。
例如f(i) = i,hash(x)=x%tableSize。在一个tableSize=10的数组nums中。
插入89,nums[9]=89;
插入18,nums[8] = 18;
插入49,hash(49)=9,nums[9]已经有值,则尝试i=1,
h1(49)=(9+1)mod10=0
,nums[0]=49;
插入58,hash(58)=8,nums[8]已经有值,尝试i=1,
h1(48)=(8+1)mod10=9
,nums[9]已经有值;尝试i=2,
h2(48)=(8+2)mod10=0
,nums[0]已经有值;尝试i=3,
h3(48)=(8+3)mod10=1
,nums[1]=58.
一次聚集:即使表相对较空,占据的单元会开始形成一些区块。散列到区块中的任何关键字需要多次试选才能插入。
一次插入或者不成功的查找,探测次数为
12(1+1(1−λ)2)
,一次成功的查找探测次数:
12(1+1(1−λ))
如果
λ=0.5
,平均操作插入需要2.5次探测,查找需要1.5次探测。速度还是挺快的。(与分离链接法比较也挺浪费空间的)
λ
越大,需要的探测次数越多,
λ
越小,空间浪费越严重。
平方探测
与上面相似,只是 f(i)=i^2。
定理:如果使用平方探测,表的大小是素数,那么当表至少有一半是空的时候,总是能够一次插入一个新的元素。
散列到相同位置的元素将探测相同的备选单元,这叫做二次聚集。
双散列
双散列是指在发生冲突之后,再次使用散列函数计算散列值。 fi=i∗hash2(x) 是一种常用的选择。 hash2 需要保证所有元素都能被探测到。列表的大小一定要是素数。否则备用单元可能被提前用完。
双散列在理论上很有吸引力,在实践中平方探测速度可能更快,用的更广泛。
再散列
使用平方探测的开放地址散列法,如果散列表填的太满,操作的运行时间将开始耗时过长。这时一种解决方法是建一个原表约两倍大小的表,使用一个新的散列函数,扫描原始表,重新计算散列值,插入新表。这个操作叫做再散列。
什么时候再散列有几种做法。第一种,只要表填满到一半。第二种,插入操作失败的时候。第三种,当装填因子达到一定值的时候进行。
可扩散列
当数据量大到装载不到内存中的时候,需要将数据存放在硬盘。那么算法的时间将主要取决于多少次磁盘操作。假设每个时刻有N条记录要存储,N随时间而变化。最多可以把M条记录放在一个磁盘区块。M=4.
B树具有深度O(
logM/2N
) ,随着M增加,深度降低。如果M非常大,N=1,那将是最理想的情况,但是这样分支系数太高,以至于为了确定数据存在哪个分支上需要花费大量的操作。如果确定分支有规律可循,那么速度将大大提升。
假设每条数据由几个6比特整数组成。每个树包含4个链,它们由这些数据的前两个比特确定在哪个分支存储,每个树叶有M=4个数据。
用D代表根节点使用的比特数,目录中的项数=
2D
。
dL
为所有树叶L所有元素共有的最高位的比特位数。不同的树叶
dL
不同。上图中恰好,每个树叶的
dL=2
。
如果现在要插入 100 100,它将插入到第三片树叶中,但是第三片树叶已经满了,这时候需要分裂第三片树叶,分裂后的树叶由前三个比特确定分支。这时候D=3。
分裂后目录数=
23
。第三片、第四片树叶的
dL=3
。其他树叶由两个相邻的目录所指。
如果现在插入000 000 妈妈第一片树叶分裂。
需要注意的细节:1 如果插入的数字引起元素有多余D+1位相同,就会引起多次分裂。例如在上图插入111 010,111 011,这样就需要D=5才可以。2 关键字重复怎么处理。3 如果有>M个重复元素怎么处理。
树叶的期望个数= N/Mlog2e 。平均树叶满的程度为ln2=0.69。这和B树相同。目录的期望大小D=O( N1+1/M/M )。
应用
1 编译器使用散列跟踪源代码中声明的变量。
2 应用在图论中。图论中节点的名字一般为有顺序的字母。
3 应用在游戏程序中。
4 在线拼写检查程序。
5 字符串A=’12314123’,求’123’在A中出现的次数。希望以O(n)的时间完成。使用f(‘123’)=
1∗102+2∗10+3=123
映射关系。在A中就是查找f(‘123’)=?,f(‘234’)=?…如果A的长度为n,字符串长度为m,那么会产生n-m+1个子串。每个子串计算f(子串)需要m次,那么复杂度是(n-m+1)*m,是O(nm)。要想简化,这里使用滑窗的思想。
m1=
1∗102
m2=
2∗10
m3=m2+3(3是A串第3位) ,与子串的值比较=(‘123’与’123’比较)
m4=
m3∗10
m5=m4+1(1是A串第4位),与子串值比较=(‘231’与’123’比较)
m6=
m5∗10
m7=m6+4(4是A串第5位),与子串值比较=(‘314’与’123’比较)
这样每次计算f(子串)只需要一次乘法,一次加法,变为O(1),整个时间复杂度变为O(n)。