左神算法初级班五 笔记

哈希函数引入:

简言之,哈希函数就是提供一套规则,将我们给定的输入映射到一个输出。经典的哈希函数输入域无穷大;输出域有穷尽;对于相同的输入,能保证哈希后的值不变;会发生两个不同的值哈希为同一个输出的情况;在样本量足够大的情况下,输入域的数据n会被均分到输出域m,每一个输出域涵盖的输入域约等于n/m。

哈希函数的构造技巧:

当我们有一个哈希函数时,假设我们哈希后的值为16个数位,我们取前8位为h1,后8位为h2,于是可以构造出哈希数列:

                                                                                     h_{i}=h_{1}+i*h_{2}

究其原因,是因为对任意的数来说,所有的数位之间都是独立的。让数据散乱无规则分布的技巧一般还是用结合素数的方法,例如我们可以把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=\tfrac{-(n*lnp)}{(ln2)^2}

m:比特空间大小

n:样本量

p:预期失误率

哈希函数个数:

                                                                           k=\tfrac{ln2*m}{n}=\tfrac{0.7*m}{n}

当m和k向上取整后,实际的失误率为:

                                                                                (1-e^{\tfrac{-n*k}{m}})^{k}

一致性哈希

背景引入:经典的服务器扛压接口是在前端通过哈希值进行数据分流,映射到不同机器上进行处理。在大样本的条件下,多台机器的负载都是接近均衡的。但是这种结构如果需要增加或减少参与处理的机器数,需要很高昂的数据迁移代价,需要将所有的信息取出后重新哈希%新规模再重新存储到各台机器上,前端修改哈希函数,才能供查询修改等。

问题剖析:问题在于%n运算虽然是让每台机器每隔n就取一个值,但是却导致了机器存储的数据在很大程度上存在离散性。我们可以去掉%,此时假设hash的映射输出域为[0,2^{64}],并且将首位相连组成环状结构。此时我们假设有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。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值