哈希函数引入:
简言之,哈希函数就是提供一套规则,将我们给定的输入映射到一个输出。经典的哈希函数输入域无穷大;输出域有穷尽;对于相同的输入,能保证哈希后的值不变;会发生两个不同的值哈希为同一个输出的情况;在样本量足够大的情况下,输入域的数据n会被均分到输出域m,每一个输出域涵盖的输入域约等于n/m。
哈希函数的构造技巧:
当我们有一个哈希函数时,假设我们哈希后的值为16个数位,我们取前8位为h1,后8位为h2,于是可以构造出哈希数列:
究其原因,是因为对任意的数来说,所有的数位之间都是独立的。让数据散乱无规则分布的技巧一般还是用结合素数的方法,例如我们可以把i替换成第i个素数,也可以在数位之间进行异或运算。
JDK中关于HashMap的一些知识:
在JDK1.6之前,当HashMap发生冲突的时候,采用拉链法将冲突节点串起来解决冲突。在1.6之后,当链长度达到了8,底层就会把链表构建为红黑树。当链表长度小于等于6,又会从红黑树退化为链表。哈希表每一次扩容都增长两倍,每一次扩容需要重新计算所有元素的哈希值并且重新插值,假设最终哈希表长度为N,则需要扩容logN次,每次扩容代价为N,则总的扩容代价为NlogN,平摊下来的复杂度为O(NlogN/N)=O(logN)。
另外,我们还可以采用离线扩容的方式,在发现可能需要扩容的时候在后台扩容,前台继续使用旧结构,待扩容完成后改变引用即可。当然这并不是JVM里对哈希表的扩容实现。
哈希表的工程应用:
1.数据分流:在面对分布式系统的时候,假设要对很大的数据量进行处理,我们需要将数据分流到多台服务器上处理,但是如果我们是人工设定规律分流,那么势必要花费代价表明数据流向,而实际上可以通过hashcode+%将数据进行分流,这样只要一次运算就能直到数据对应的服务器。
例题:有一个100T的文件和1000台机器,每一行存储一个字符串,现在我们要求求出所有重复的字符串。
我们通过每个字符串的hashcode%1000得到分流去向对应的机器,于是每个机器上的字符串种类量接近均衡。也就是说如果原本一共有m种字符串,那么经过hash分流后,每台机器上差不多有m/1000种。
设计RandomPool结构
【题目】 设计一种结构,在该结构中有如下三个功能: insert(key):将某个key加入到该结构,做到不重复加入。 delete(key):将原本在结构中的某个key移除。 getRandom(): 等概率随机返回结构中的任何一个key。
【要求】 Insert、delete和getRandom方法的时间复杂度都是 O(1)
思路:要在O(1)的时间复杂度内做到不重复加入和删除,所以我们需要提供一个快速查询元素的方法或者结构,这就在暗示我们使用哈希表结构。那么现在的问题是如何等概率随机返回任何一个key。我们可以使用双哈希表结构,将每个元素和它加入结构的时间戳联系起来【时间戳变相等于含有元素的个数,对应的索引从0-n-1】,我们建立<元素,时间戳>和<时间戳,元素>这样两种对应关系的两个哈希表,于是随机等概率返回就可以直接Math.random()*n拿到时间戳,再查表即可,insert需要同时维护两张表,那么最难解决的就是删除元素了,其实我们可以把表的最后一个元素填到删除后产生的空位上,将时间戳回溯-1。
import java.util.HashMap;
public class RandomPool<V> {
HashMap<V,Integer> KeyToIndex;
HashMap<Integer,V> IndexToKey;
int size=0;
RandomPool(){
KeyToIndex=new HashMap<V, Integer>();
IndexToKey=new HashMap<Integer, V>();
}
public void insert(V value){
KeyToIndex.put(value,size);
IndexToKey.put(size,value);
++size;
}
public V getRandom(){
return IndexToKey.get(Math.random()*size);
}
public void delete(V value){
if(KeyToIndex.containsKey(value)){
int deleteIndex=KeyToIndex.get(value);//拿到要删除的元素对应的索引
int lastIndex=--size;//左后一个元素的索引 填补后 长度减一
V lastValue=IndexToKey.get(lastIndex);
IndexToKey.put(deleteIndex,lastValue);
KeyToIndex.put(lastValue,deleteIndex);
IndexToKey.remove(lastIndex);
KeyToIndex.remove(value);
}
}
}
认识布隆过滤器
用于查询一个元素是否属于一个集合,是在hash的一般数组存储开销过大的解决方式。
注:布隆过滤器存在失误率。
以左神给的例子来说吧:
需求:现有一个黑名单,包括100亿个url,假设每个url占用空间为64字节,查询一个url是否在黑名单内,O(1)时间复杂度。
O(1)时间复杂度:就是在暗示查询用Hash。
我们算一下空间成本,忽略掉指针域,只看数据本身,也需要100亿*64=6400亿字节~640G。(っ°Д°;)っ Are you kidding me?
就算可以使用hash分流给多台机器处理,也对设备的要求过高。然而对于大空间开销的问题,要联想到bit,因为最小的数据类型char都有8bit呢,所以bit数组一定可以降低开销。
但是bit一位只能表示0和1怎么办呢(°Д°)。。。。。。
嗯,直接进入正题吧 ╮(╯Д╰)╭,技术不够,文字来凑。
0、1,bool逻辑值,我们可以用0、1表示某个hash值对应的url是否存在。
嗯,冲突咋办(っ°Д°;)っ。。。。。。那就多用几个hash。。。。。。
我们有一组hash函数:hash1、hash2、、、、、、hashk,对一个url,我们分别进行k次hash,将结果对应位抹为1,这样,只只有在一个url对应的k个hash值对应的位都为1时才认为是黑名单里的url,这样失误率下跌了哎,┑( ̄▽  ̄)┍。
左神用的是int类型的数组,用一个int表示32位bit,设hashIndex为hash后的结果,那么抹黑的方式为:
public class BolongFilter {
public static void main(String[] args) {
int[] arr=new int[1000];//可以表示32000个bit
int hashcode=30000;
int intIndex=hashcode/32;
int bitIndex=hashcode%32;
arr[intIndex]|=(1<<bitIndex);
}
}
布隆过滤器的失误率公式:
m:比特空间大小
n:样本量
p:预期失误率
哈希函数个数:
当m和k向上取整后,实际的失误率为:
一致性哈希
背景引入:经典的服务器扛压接口是在前端通过哈希值进行数据分流,映射到不同机器上进行处理。在大样本的条件下,多台机器的负载都是接近均衡的。但是这种结构如果需要增加或减少参与处理的机器数,需要很高昂的数据迁移代价,需要将所有的信息取出后重新哈希%新规模再重新存储到各台机器上,前端修改哈希函数,才能供查询修改等。
问题剖析:问题在于%n运算虽然是让每台机器每隔n就取一个值,但是却导致了机器存储的数据在很大程度上存在离散性。我们可以去掉%,此时假设hash的映射输出域为,并且将首位相连组成环状结构。此时我们假设有3台机器,用三台机器均分这个环【给三台机器各自分配一个哈希值,例如直接使用机器ip映射之类】,每个哈希映射值由顺时针找到的第一个机器负责。所以对于不同的哈希值h,我们可以直接在机器的哈希值数组上二分[m1,m2,m3](假设m1<m2<m3),如果h>m3,则h交由m1负责,其余情况只需要找到第一个满足>=h的数即可。用下图表示我们的新结构:
假设此时我们在[m1,m2]之间加入一台新机器,原本[m1,m2]之间的数据是由m2负责的,当加入m4之后,我们需要把[m1,m4]之部分原本属于m1的丢给m4,数据迁移代价相比原来全部重新映射低了很多,迁移后的结构如下:
新的问题:在机器数量不够的情况下,机器哈希值在整个哈希域上的离散程度可能很高,导致了部分机器的负载很大,机器之间没有达到负载均衡【哈希函数均匀性的性质无法体现】;即便一开始我们强制让机器均分环,随着不断加减机器,也会变得不均匀,因此需要引入虚拟节点技术。
虚拟节点技术:我们使用很多个虚拟节点去均分环,而给机器分配虚拟节点。以左神的例子说明:我们给每台物理机器分配1000个虚拟节点,用一张路由表来维护机器和虚拟节点的关系:
m1:m1-1、m1-2、m1-3......m1-1000
m2:m2-1、m2-2、m2-3......m2-1000
m3:m3-1、m3-2、m3-3......m3-1000
由于虚拟节点数量很多,因此虚拟节点几乎可以均分整个哈希域,而每台机器占有的虚拟节点数量相同,因此每台机器的负载也均衡。注意:并不一定要分配给物理机器的虚拟节点都是连续且紧密挨着的,因为这样会退化成上面的情况,虚拟节点没有太多意义。而当我们需要增加机器m4的时候,我们给他分配1000个新的虚拟节点,让虚拟节点之间去负责数据的迁移。减去机器也是在虚拟节点上完成数据迁移。
并查集:
1.快速查询两个元素是否属于一个集合 2.把两个集合合并
并查集模板:
public class UnionSet {
int[] F=new int[100010];
int Find(int x){//寻找根
if(x==F[x])return x;
return F[x]=Find(F[x]);//路径压缩
}
void Union(int A,int B){
A=Find(A);
B=Find(B);
F[A]=B;//合并集合
}
public void init(int n){
for(int i=0;i<=n;++i)F[i]=i;//初始化时 每个点自成集合
}
}
我们可以增加一个size[i]用来统计以i为根的元素的个数。不过需要注意有一些信息的维护当引入路径压缩后会失效。如果只需要每个连通块的根信息,就可以使用。
岛屿问题
一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右 四个位置相连,如果有一片1连在一起,这个部分叫做一个岛,求一个矩阵中有多少个岛?
举例:
0 0 1 0 1 0
1 1 1 0 1 0
1 0 0 1 0 0
0 0 0 0 0 0
这个矩阵中有三个岛。
思路:标记法,实际上我们可以从一个没有被打过标记的并且是岛的点出发,遍历周围可达的所有点,并打上和源点一致的标记,统计数量+1。
public class LandCount {
public static int CountLands(int[][] m){
if(m==null || m[0]==null)return 0;
int N=m.length;
int M=m[0].length;
int count=0;
for(int i=0;i<N;++i){
for(int j=0;j<M;++j){
if(m[i][j]==1){
infect(m,i,j,N,M);
++count;
}
}
}
return count;
}
public static void infect(int[][] m,int i,int j,int N,int M){
if(i<0||i>=N||j<0||j>=M||m[i][j]!=1)return;
m[i][j]=2;
infect(m,i-1,j,N,M);
infect(m,i,j-1,N,M);
infect(m,i+1,j,N,M);
infect(m,i,j+1,N,M);
}
public static void main(String[] args) {
int[][] m1 = { { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 1, 1, 1, 0, 1, 1, 1, 0 },
{ 0, 1, 1, 1, 0, 0, 0, 1, 0 },
{ 0, 1, 1, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 1, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0, 0 }, };
System.out.println(CountLands(m1));
int[][] m2 = { { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 1, 1, 1, 1, 1, 1, 1, 0 },
{ 0, 1, 1, 1, 0, 0, 0, 1, 0 },
{ 0, 1, 1, 0, 0, 0, 1, 1, 0 },
{ 0, 0, 0, 0, 0, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 1, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0, 0 }, };
System.out.println(CountLands(m2));
}
}
然而如果矩阵很大,我们就不得不切割矩阵运算后,再考虑合并结果,更新答案了。
我们以切割为两份为例:
我们可以在两台机器上利用标记法统计小部分的并查集数量,上图为左边2,右边2,然而在合并过程过,如果两个部分的并查集可以合并,那么每合并一次,答案就应该少掉1。合并过程中我们只需要用到边缘信息。我们把边缘信息单独抽出来,并且属于不同部分的边缘编上不同的编号,于是有下图:
当两个非0节点即有编号且编号不同的点相遇的时候,我们直到左右有小集合要合并,此时4-1=3,并且统一编号或者增加一个标明[L1,R1]对的数据,然后左右指针向下移动:
上一步我们已经知道L1和R1有连接,因此这一步不再产生影响。重复此过程,直到边界处理完。最终结果是L1和R1合并一次,L2和R1合并一次,于是答案变为4-2=2。