散列表、散列函数、散列冲突、哈希算法

哈希表理论基础

哈希表能解决什么问题呢?一般哈希表用来快速判断一个元素是否出现在集合里。

Word文档中的单词拼写检查功能如何实现

使用过"Word"文档,就会知道一旦在word文档中输入拼写有误或者语法有误的英文单词,文档就会以标红的方式提示"错误"。如下图所示:

  • 首先第一步打开"word选项框,选中校对",对"在word中更正拼写和语法"下特定的功能打上对勾。

在这里插入图片描述

  • 在文档中输入有误的单词。
    在这里插入图片描述
    这种功能是如何实现的呢?需要用到今天介绍的散列表。

散列表

散列思想

  • 散列表用的是"数组支持按照下标随机访问数据的特性",其实散列表是数组的一种扩展,由数组演化而来。
  • 散列表一个重要的组成部分就是"散列函数"。散列表就是一种数值[称之为键|关键字]到另一种数值[称之为哈希值|散列值|Hash值]的映射,根据散列函数把键值或者关键字映射为下标,然后将数据存储在数组中对应下标的位置。
  • 当按照键值查询元素时,用同样的散列函数,将键值转化为数组下标,从对应的数组下标的位置取出数据。

散列函数

散列函数是一个函数。可以把它定义为hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。

散列冲突

再好的散列函数也无法避免散列冲突。因为数组的存储空间有限,会加大散列冲突的概率。所以几乎无法找到一个完美的无冲突的散列函数,即使是业界著名的MD5\SHA\CRC等哈希算法,也无法避免这种散列冲突。所以需要通过其它的方式解决这种冲突。

一下是一些解决哈希冲突的方法:

开放寻址法(open addressing)

开放寻址法的核心思想就是,如果出现了散列冲突,就重新探测一个空闲位置,将其插入。

  • 线性探测(Linear Probing)。往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到位置。[如果找到尾部还没有找到合适的位置,就从头部开始查找,直到找到合适的空闲的位置]。
    在这里插入图片描述
  • 从图中可以看出,散列表的大小为10,在元素x插入散列表之前,已经6个元素插入到散列表中。x经过Hash算法之后,被散列到位置下标为7的位置,但是这个位置已经有数据了,所以就产生了冲突。于是就顺序地往后一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是从表头开始
    找,直到找到空闲位置2,其插入到这个位置。
  • 二次探测(Quadratic probing)。二次探测和线性探测很像,线性探测每次探测的步长是1,那么它探测的下标序列就是hash(key)+0,hash(key)+1,…,;二次探测的步长就是原来的平方,探测的下标序列就是hash(key)+02,hash(key)+12,hash(key)+22
  • 双重散列(Double hashing)。使用一组散列函数hash1(key),hash2(key),…先用第一个散列函数,如果计算得到的存储位置已经被占用,就是用第二个散列函数重新计算存储位置,…。
  • 不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下尽可能保证散列表中有一定比例的空闲槽位。用装载因子表示空位的多少。[散列表的装在因子=填入表中的元素个数/散列表的长度]。装载因子越大空闲位置越少,冲突越多,散列表的性能会下降。

散列表查找元素的过程与插入的过程类似。通过散列函数求出要查找元素的键值对应得散列值,比较数组下标尾散列值得元素和要查找得元素,相等则找到,反之则按照顺序依次往后查找。如果遍历数组中的空闲位置,都没有找到说明要查找的元素不再散列表中。

散列表和数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用开放寻址法解决散列冲突的散列表,删除操作稍微有些特别。不能单纯的把要删除的元素设置为空。这是因为在查找的时候,一旦通过线性探测方法,找到一个空闲位置,就可以认定散列表中不存在这个数据。但是如果这个空闲位置是后来删除的,就会导致原来的算法查找失败,本来存储在数据会被认定为不存在。

解决方法:可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测。

开放寻址法存在的问题

  • 当散列表中插入的数据越来越多的时候,散列冲突发生的可能性就越来越大,空闲位置越来越少,线性探测的时间就会越来越久。极端情况下,可能需要探测整个散列表,最坏的时间复杂度为O(n)。同理删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据

链表法(chaining)

  • 链表法是一种更加常用的散列冲突解决方法,相比于开放寻址法,它要简单很多。
  • 在散列表中,每个桶(bucket)或者槽(slot)会对应一条链表,所有散列值相同的元素都会被放到相同槽位对应的链表中。

在这里插入图片描述

	当插入的时候,只需要通过散列函数计算出对应的散列槽位,
	将其插入到对应的链表中即可,所以插入的时间复杂度为O(1)
	[插入槽位对应链表的方式为头插法,所以时间复杂度为O(1)]。

解决开头的问题

有了前面的知识储备,看看"Word文档中单词拼写功能是如何实现的"?

常用的英文单词有20万个左右,假设单词的平均长度为10个字母,平均一个单词占用10个字节的内存空间,这样一来20万个英文单词大约占2MB的存储空间,这个大小完全可以放在内存里面。所以可以利用散列表存储整个英文单词词典。

当用户输入某个英文单词时,拿用户输入的单词去散列表查找。如果找到,则说明拼写正确;没有查找到,则说明可能拼写有误。

这里面的散列函数可以这样设计:
将单词中的每个字母的"ASCII码值"进位相加,然后再跟散列表的大小求余、取模、作为散列值。比如,英文单词word,转化出来的散列值就是:

hash(“word”)=((‘w’-‘a’)*263+(‘o’-‘a’)*262+(‘r’-‘a’)*26+(‘d’-‘a’))/len(hashtable)


如何打造一个工业级水平的散列表

散列表的查找效率并不能笼统地说成是O(1)。它根散列函数,装载因子,散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降。

散列表碰撞攻击
在极端情况下,有些恶意的攻击者,可能通过精心构造数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果使用的是基于链表的冲突解决方法,这个时候,散列表就会退化为链表,查询时间复杂度从O(1)退化为O(n)。
如果散列表中有10万个数据,退化后的散列表查询的效率就下降了10万倍。更直接点说,如果之前运行100次查询只需要0.1秒,那现在就需要1万秒。这样就有可能因为查询操作消耗大量CPU或者线程资源,导致系统无法响应其它请求,从而达到拒绝服务攻击(DOS)的目的。这也就是"散列表碰撞攻击的原理"。

如何设计散列函数

散列函数设计的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能。如何设计一个好的散列函数呢?

  • 散列函数的设计不能太复杂。过于复杂的散列函数会消耗很多计算时间,也就间接影响到散列表的性能。
  • 散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。
  • 综合考虑各种因素。比如,关键字的长度、特点、分布、散列表的大小等。

一些常用的散列函数的设计方法

  • 数据分析法
  • 直接寻址法
  • 平方取中法
  • 折叠法
  • 随机数法

装载因子过大怎么办?

装载因子越大,说明散列表的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会变得很慢。
对于没有频繁插入和删除的静态数据集合来说,很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为数据都是已知的。
对于动态散列表来说,数据集合是频繁变动的,事先无法预估加入的数据的个数以及特点,所以无法提前申请一个足够大的散列表。随着数据的慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。

动态扩容

  • 可以仿照"数组"的动态扩容,进行散列表的动态扩容。
  • 针对散列表,当装载因子过大时,可以进行动态扩容,重新申请一个更大的散列表,空间大小为旧散列表的两倍,之后将数据迁移到新的散列表中。
  • 针对数组的扩容,数据迁移操作比较简单。但是,针对散列表的扩容,数据迁移操作很复杂,因为散列表的大小变了,数据的存储位置也变了,所以需要重新通过散列函数计算每个数据的存储位置。
  • 插入一个数据,最好情况下,不需要扩容,最好时间复杂度为O(1)。最坏情况下,散列表装载因子过高,启动扩容,需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度为O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是O(1)。
  • 实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果对空间消耗非常敏感,可以在装载因子小于某个值之后,启动动态缩容。如果更加在意执行效率,能够容忍多消耗一点内存空间,就可以不用费劲缩容。

当散列表的装载因子超过某个阈值时,需要进行扩容。装载因子阈值要设置适当,太大导致冲突过多;如果太小,会导致内存浪费严重。装载因子的阈值设置要权衡时间,空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求不高,可以增加负载因子的值,甚至可以大于1。

如何避免低效扩容

举一个极端的例子,如果散列表当前大小为1GB,想要扩容为原来的两倍大小,那就需要对1GB的数据重新计算哈希值,并且从原来的散列表迁移到新的散列表。如果业务代码直接服务于用户,尽管大部分情况下,插入一个数据的操作很快,但是极个别非常慢的插入操作,也会让用户崩溃。这个时候,"一次性扩容"的机制就很不合适。

为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在写操作的过程中,分批完成。当装载因子达到阈值之后,只申请新空间不进行数据迁移的操作,当有新的数据要插入的时候,将新数据插入到新的散列表中,并且从老的散列表中拿出一部分数据放入到新的散列表中。这样一来,经过多次插入操作之后,老的散列表中的数据就被完全迁移到新的散列表中。分批迁移使得插入操作相比于一次性迁移效率更快。

这期间的查询操作如何完成?对于查询操作,新老散列表中的数据都要考虑到,先从旧的[或者新的]散列表中查找,没有的话,再从新的[或者旧的]散列表中查找。
通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,避免一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度为O(1)。

如何选择冲突解决方法

有两种主要的散列冲突的解决办法"开放寻址法"和"拉链法"。

开放寻址法
散列表中的数据都存储在数组中,可以有效地利用CPU缓存加快查询速度。缺点就是:删除数据的时候比较麻烦,需要特殊标记已经删除的数据。而且开放寻址法所有的数据都存储在一个数组中,比起链表法来说,冲突的可能性更大。所以使用开放寻址法解决冲突的散列表,装载因子上限不能太大,这也导致这种方法比链表法更浪费内存。当数据量比较小,装载因子比较小的时候选择开放寻址法解决散列冲突比较合适。
拉链法
链表法对内存的利用率比开放寻址要高。因为链表节点可以在需要的时候创建,并不需要像开放寻址那样事先申请好。
链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的情况。接近1时,就可能会有大量的散列冲突,大致大量的探测,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成10,也就是链表的长度变长了而已,虽然查找效率有所下降。但是比起顺序查找还是快很多。

链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。链表中的节点是零散的分布在内存中的,无法有效使用CPU的缓存,对于执行效率有一定的影响。
如果存储的是大对象,链表中指针的消耗就可以忽略不计,

实际上,可以对链表法稍加改造,可以实现一个更加高效的散列表。那就是,将链表法中的链表改造成其它更高效的动态数据结构,比如跳表、红黑树。这样即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化的散列表的查找时间也只不过是O(logn)。这样也就有效避免了前面讲到了散列碰撞攻击。

如何设计一个工业级的散列表

什么才算是一个工业级的散列表?结合前面的知识,应该满足以下的要求:

  • 支持快速的查询、插入、删除操作
  • 内存占用合理,不能过多浪费空间;
  • 性能稳定,极端情况下,散列表的性能也不能退化到无法接受的情况。

如何设计?

  • 设计一个合适的散列函数。尽可能让散列后的值随即且均匀分布,这样会尽可能减少散列冲突。
  • 设置适当的装载因子阈值
  • 设计一个合适的散列冲突解决方法

算法题

有效的字母异位词

https://leetcode.cn/problems/valid-anagram/description/


思路:

  • 首先明确一下,字母异位词的定义:若s和t中每个字符出现的次数都相同,则称s和t互为字母异位词。
  • 根据定义可以得出,判断s和t是否是字母异位词的前提是要先分别统计出s和t中每个字符出现的次数,这个可以借助哈希表来完成。
  • 统计完之后,在比较哈希表中每个字符出现的次数是否相同。相同则为字母异位词,反之则不是。
  • [由于字符的本质是ASCII值,所以可以利用数组来实现哈希表的功能]
  • 时间复杂度为O(n),空间复杂度为O(1)。
class Solution {
public:
    bool isAnagram(string s, string t) {
        int ss[26]={0},tt[26]={0};
        for(const char&c:s) ++ss[c-'a'];
        for(const char&c:t) ++tt[c-'a'];
        for(int i=0;i<26;++i) if(ss[i]!=tt[i]) return false;
        return true;
    }
};
func isAnagram(s string, t string) bool {
    ss,tt:=[26]int{0},[26]int{}
    for _,c:=range s {
        ss[c-'a']++
    }
    for _,c:=range t {
        tt[c-'a']++
    }
    for i:=0;i<26;i++{
        if ss[i]!=tt[i]{
            return false
        }
    }
    return true
}

两个数组的交集

https://leetcode.cn/problems/intersection-of-two-arrays/description/


思路:

  • 首先要明确的是,什么样的元素是两个数组的交集部分:如果num既出现在数组1中也出现在数组2中,那么num是两个数组的交际部分。
  • 如何判断num是否在两个数组中都出现。可以先将一个数组中的元素存入集合中,在顺序遍历另外一个数组依次判断这个数组中的元素是否在集合中出现过,如果出现过则是交集部分否则则不是。
  • [集合是一种特殊的哈希表,键值对应的哈希值为空的哈希表]
  • 时间复杂度为O(n),空间复杂度为O(n)。
class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        unordered_set<int>se;
        vector<int>ans;
        for(const int&num:nums1){
            se.insert(num);
        }
        for(const int &num:nums2){
            auto it = se.find(num);
            if(it!=se.end()){
                ans.push_back(num);
                se.erase(it);
            }
        }
        return ans;
    }
};
func intersection(nums1 []int, nums2 []int) []int {
    se:=map[int]struct{}{}
    ans:=make([]int,0)
    for _,num:=range nums1{
        se[num]=struct{}{}
    }
    for _,num:=range nums2{
        if _,ok:=se[num];ok{
            ans=append(ans,num)
            delete(se,num)
        }
    }
    return ans
}
  • 有一个需要注意的点就是,顺序遍历另外一个数组的时候如果num出现在集合中,需要将集合中对应的元素移除,这样的做的目的是为了防止第二个数组中有重复的元素。

赎金信

https://leetcode.cn/problems/ransom-note/description/


思路:

  • 判断ransomNote能否由magazine里面的字符构成,需要先统计magazine中字符对应的个数。
  • 统计好之后,顺序遍历ransomNote中的字符,并判断是否都能在magazine中找到,如果可以的话就能满足条件,不能的话则不能满足条件。
  • 用哈希表统计。时间复杂度为O(n),空间复杂度为O(n)。
class Solution {
public:
    bool canConstruct(string ransomNote, string magazine) {
       int char_count[26]={0};
       for(const char&c:magazine){
           ++char_count[c-'a'];
       }
       for(const char&c:ransomNote){
           if(--char_count[c-'a']<0) return false;
       }
       return true;
    }
};
func canConstruct(ransomNote string, magazine string) bool {
    cc:=[26]int{}
    for _,c:=range magazine{
        cc[c-'a']++
    }
    for _,c:=range ransomNote{
        if cc[c-'a']--;cc[c-'a']<0{
            return false
        }
    }
    return true
}

快乐数

https://leetcode.cn/problems/happy-number/description/


思路:

  • 首先需要一个辅助函数:计算一个数值的各个位数字的平方之和
  • 将一个数字变为每个位置的数字的平方和得到新的数字,不断这个过程如果能得到1则说明这是一个快乐数,反之这个过程不断循环也即是得到的新的数字在前面出现过又开启一段新的循环,则说明这个原始数字不是快乐数。
  • 如何判断新得到的数字之前已经出现过。需要借助哈希表,可以将计算得到的数字存储在集合中,每次新计算的数字如果出现在集合中则开启了新的循环表明原始数字不是快乐数。
  • 时间复杂度为O(nm),空间复杂度为O(m)。[n表示平均每个数字的长度,m表示数字的个数]
class Solution {
public:
    bool isHappy(int n) {
        unordered_set<int>se;
        while(n!=1){
            if(se.count(n)) return false;
             se.insert(n);
             n=square(n);
        }
        return true;
    }
private:
    int square(int num){
        int sum=0;
        while(num){
            int te=num%10;
            sum+=te*te;
            num/=10;
        }
        return sum;
    }
};
func isHappy(n int) bool {
    var square func(int)int
    square=func(n int)int{
        sum:=0
        for n!=0{
            te:=n%10
            sum+=te*te
            n/=10
        }
        return sum
    }
    se:=map[int]struct{}{}
    for n!=1{
        if _,ok:=se[n];ok{
            return false
        }
        se[n]=struct{}{}
        n=square(n)
    }
    return true
}

两数之和

https://leetcode.cn/problems/two-sum/description/


思路:

  • 暴力循环。双层循环,外层循环指向满足条件的第一个数字,内层循环指向满足条件的第二个数字。时间复杂度为O(n2),空间复杂度为O(1)。
  • 哈希表。顺序遍历给定整数数组,对于每一个整数先从哈希表中寻找是否有满足两个数值之和满足条件的整数,有的话直接返回,没有的话将当前整数加入到哈希表中。这样做可以避免同一个元素重复出现在答案中。时间复杂度为O(n),空间复杂度为O(n)。
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        unordered_map<int,int>ma;
        for(int i=0;i<nums.size();++i){
            if(ma.count(target-nums[i])){
                return vector<int>{ma[target-nums[i]],i};
            }
            ma.emplace(nums[i],i);
        }
        return {};
    }
};
func twoSum(nums []int, target int) []int {
    ma:=map[int]int{}
    for i,_:=range nums{
        if _,ok:=ma[target-nums[i]];ok{
            return []int{ma[target-nums[i]],i}
        }
        ma[nums[i]]=i
    }
    return []int{}
}

四数相加II

https://leetcode.cn/problems/4sum-ii/description/


思路:

  • 暴力循环。时间复杂度为O(n4),空间复杂度为O(1)。
  • 哈希表。将四数相加简化为两数相加,首先用哈希表统计其中两个数组相加和对应的次数,得到两个和与出现次数对应的哈希表。之后遍历其中一个哈希表,寻找另一个哈希表中相加得0的数值,将两个数值出现的次数相乘即可得出一共得组合个数。时间复杂度为O(n2),空间复杂度为O(1)。
class Solution {
public:
    int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
        unordered_map<int,int>ma1,ma2;
        for (int i=0;i<nums1.size();++i){
            for(int j=0;j<nums2.size();++j){
                ++ma1[nums1[i]+nums2[j]];
            }
        }
        for (int i=0;i<nums3.size();++i){
            for(int j=0;j<nums4.size();++j){
                ++ma2[nums3[i]+nums4[j]];
            }
        }
        int sum=0;
        for (auto it=ma1.begin();it!=ma1.end();++it){
            sum+=it->second*ma2[-it->first];
        }
        return sum;
    }
};
func fourSumCount(nums1 []int, nums2 []int, nums3 []int, nums4 []int) int {
    ma1,ma2:=map[int]int{},map[int]int{}
    for _,n1:=range nums1{
        for _,n2:=range nums2{
            ma1[n1+n2]++
        }
    }
    for _,n3:=range nums3{
        for _,n4:=range nums4{
            ma2[n3+n4]++
        }
    }
    ans:=0
    for k,v:=range ma1{
        ans+=v*ma2[-k]
    }
    return ans
}

哈希算法

什么是哈希算法?就是将任意长度的二进制值串映射为固定长度的二进制值串,这个映射规则就是哈希算法。

通过原始数据映射之后的到的二进制值串就是哈希值。
一个优秀的哈希算法需要满足以下的要求:

  • 从哈希值不能反向推导出原始数据。(所以哈希算法也叫单向算法)。
  • 对输入数据非常敏感,哪怕是原始数据只修改了一个bit,最后得到的哈希值也不大相同。
  • 散列冲突的概率要小,对于不同的原始数据,哈希值相同的概率应该非常小。
  • 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

一些著名的哈希算法有:MD5[哈希值是128位的bit长度],SHA

接下来看看哈希算法的应用

安全加密

  • 最常用于加密的哈希算法是MD5(Message-Digest Alogrithm 信息摘要算法)和SHA(Secure Hash Algorithm,安全散列算法);除此之外还有,DES(Data Encryption Standard数据加密标准)、AES(Advanced Encryption Standard高级加密标准)
  • 对于加密的哈希算法,有两点格外重要。第一点是很难根据哈希值反向推导出原始数据;第二点是散列冲突的概率要很小。
  • 没有绝对安全的加密算法。越复杂、越难破解的加密算法,需要计算的时间也越长。

唯一标识

如果要在海量的图库中,搜索一张图是否存在,我们不能单纯地用图片的元信息(比如图片名称)来比对,因为有可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那我们该如何搜索呢?

我们知道,任何文件在计算中都可以表示成二进制码串,所以,比较笨的办法就是,拿要查找的图片的二进制码串与图库中所有图片的二进制码串一一比对。如果相同,则说明图片在图库中存在。但是,每个图片小则几十KB、大则几MB,转化成二进制是一个非常长的串,比对起来非常耗时。有没有比较快的方法呢?

我们可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以从图片的二进制码串开头取100个字节,从中间取100个字节,从最后再取100个字节,然后将这300个字节放到一块,通过哈希算法(比如MD5),得到一个哈希字符串,用它作为图片的唯一标识。通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。

如果还想继续提高效率,我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当要查看某个图片是不是在图库中的时候,我们先通过哈希算法对这个图片取唯一标识,然后在散列表中查找是否存在这个唯一标识。
如果不存在,那就说明这个图片不在图库中;如果存在,我们再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样。如果一样,就说明已经存在;如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片。[双重检验]

数据校验

电驴这样的BT下载软件你肯定用过吧?我们知道,BT下载的原理是基于P2P协议的。我们从多个机器上并行下载一个2GB的电影,这个电影文件可能会被分割成很多文件块(比如可以分成100块,每块大约20MB)。等所有的文件块都下载完成之后,再组装成一个完整的电影文件就行了。
我们知道,网络传输是不安全的,下载的文件块有可能是被宿主机器恶意修改过的,又或者下载过程中出现了错误,所以下载的文件块可能不是完整的。如果我们没有能力检测这种恶意修改或者文件下载出错,就会导致最终合并后的电影无法观看,甚至导致电脑中毒。

现在的问题是,如何来校验文件块的安全。
具体的BT协议很复杂,校验方法也有很多,我来说其中的一种思路。
我们通过哈希算法,对100个文件块分别取哈希值,并且保存在种子文件中。我们在前面讲过,哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。

散列函数

前面讲了很多哈希算法的应用,实际上,散列函数也是哈希算法的一种应用。
散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相对哈希算法的其他应用,散列函数对于散列
算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。

不仅如此,散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。

如何防止用户密码脱库
我们可以通过哈希算法,对用户密码进行加密之后再存储,不过最好选择相对安全的加密算法,比如SHA等(因为MD5已经号称被破解了)。不过仅仅这样加密之后存储就万事大吉了吗?

字典攻击你听说过吗?如果用户信息被“脱库”,黑客虽然拿到是加密之后的密文,但可以通过“猜”的方式来破解密码,这是因为,有些用户的密码太简单。比如很多人习惯用00000、123456这样的简单数字组合做密码,很容易就被猜中。

那我们就需要维护一个常用密码的字典表,把字典中的每个密码用哈希算法计算哈希值,然后拿哈希值跟脱库后的密文比对。如果相同,基本上就可以认为,这个加密之后的密码对应的明文就是字典中的这个密码。(注意,这里说是的是“基本上可以认为”,因为根据我们前面的学习,哈希算法存在散列冲突,也有可能出现,尽管密文一样,但是明文并不一样的情况。)

针对字典攻击,我们可以引入一个盐(salt)[一种随机值],跟用户的密码组合在一起,增加密码的复杂度。我们拿组合之后的字符串来做哈希算法加密,将它存储到数据库中,进一步增加破解的难度。不过安全和攻击是一种博弈关系,不存在绝对的安全。所有的安全措施,只是增加攻击的成本而已。

负载均衡

负载均衡算法有很多,比如轮询,随机,加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务上。

最直接的方法就是,维护一张映射关系表,这张表的内容就是客户端IP地址或者会话ID与服务器编号的映射关系。客户端发出去的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。这种方法简单直观,但也存在一些缺点:

  • 如果客户端很多,映射表可能会很大,比较浪费内存空间。
  • 客户端下线,上线,服务器扩容、缩容都会导致映射失效。

通过哈希算法可以完美地解决上述地问题,对客户端IP地址或者会话ID计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。这样就可以把同一个IP过来的请求,都路由到同一个后端服务器上。

但是服务器的列表大小会发生变化,这个时候也需要重新计算,会导致之前的映射全部失效。有一个更好的解决方案就是"一致性哈希算法"。

数据分片

如何统计"搜索关键词"的出现次数?
假如有1T的日志文件,这里面记录了用户的搜索关键词,想要快速统计出每个关键词被搜索的次数,该如何解决?

存在的难点:搜索日志很大,没办法放到一台机器的内存。第二个难点:如果只用一台机器来处理这么巨大的数据,处理时间很长。
针对以上两个难点,逐一解决

  • 先对数据进行分片,然后采用多台机器处理的方式,提高处理速度。
  • 从搜索记录的日志中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟n取模,最终得到的值,就是应该被分配到的机器编号。这样一来,哈希值相同的搜索关键词就被分配到同一个机器上。也即是相同的关键词也会被分配到同一个机器上。
  • 每个机器会分别计算出关键词出现的次数。最后合并起来就是最终的结果。

如何快速判断图片是否在图库中
可以给每个图片取一个唯一标识(或者信息摘要),构建散列表。
假设现在图库中有1亿张图片,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而1亿张图片构建散列表显然超过了单台机器的内存上限。

同样可以对数据进行分片,然后采用多机处理。准备n台机器,每台机器只维护一部分图片对应的散列表。每次从图库中读取一个图片,计算唯一标识,然后与机器个数n求余,得到的值就是应该要分配的机器编号,将这个图片的唯一标识和路径发往对应的机器构建散列表。

当判断一个图是否在图库中的时候,通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数n求余。找到应该要分配的机器,然后在机器的散列表中查找。

接下来估算一下,1亿张图片构建散列表需要多少台机器。
散列表中每个数据单元包含两个信息,哈希值和图片文件的路径。假设通过MD5计算哈希值,长度就是128比特,也就是16字节。文件路径长度的上限就是256字节,可以假设平均长度是128字节。如果用链表法解决冲突,还需要存储指针,指针只占用8字节。所以散列表中每个数据单元占用128+16+8=152字节。
假设一套机器的内存大小为2GB,散列表的装载因子是0.75,那一台机器可以给大约2GB*0.75/152 ~~ 1000万张图片构建散列表。对1亿张图片构建散列表,需要大约十几台机器。

实际上,针对海量数据处理的问题,都可以采用多机分布式处理。借助分片的思路,可以突破单机最大内存,CPU等资源的限制。

分布式存储

现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。我们有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上。

如何决定将哪个数据存储在哪台机器上?可以借助分片的思想,通过哈希算法对数据取哈希值,然后对机器的个数求余,这个最终值就是应该存储缓存值的机器编号。

但是如果数据增多,原来的10个机器已经无法承受了,就需要扩容。所有的数据都需要重新计算哈希值,然后重新搬移到正确的机器上。这样相当于,缓存的数据一下子都失效了。所有的数据请求就会全都打到数据库,就会造成缓存雪崩。

实际上,一致性哈希算法可以解决上述的问题,加入或者减少机器只需要做少量的数据迁移工作。

  • 31
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值