4

【简约而不简单:神级代码的小秘密】| 第一章 哈希表

 2 years ago
source link: https://blog.51cto.com/yuniangniang/5454812
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

【简约而不简单:神级代码的小秘密】| 第一章 哈希表

推荐 原创

钰娘娘ynn 2022-07-08 13:58:37 博主文章分类:java ©著作权

文章标签 散列表 数据结构 数据 服务器 红黑树 文章分类 其它 编程语言 阅读数375

1.1 什么是哈希表

        也叫散列表,是以键-值格式存储的数据接口。可以通过键来找到对应的值。

1.1.1 所以,什么是哈希表

        同事里有个叫子豪的精神小伙,门口有一份快递,快递上写着他他的名字。

        “你们这儿有个叫子豪的小伙子吗?”,快递员气喘吁吁的问道。

        “有,在那边,那个穿蓝色衣服的就是。”

        这个过程,其实可以把子豪看作是键,对应的人就是值。这个找人的过程就是哈希查找

1.1.2 哈希键怎么定义

        哈希的键可以理解为一种分类。比如前面找子豪的例子,那可能一个部门不止一个子豪(我们公司有5个,部门有2个)。所以这里“子豪”这个名字,他可以不能一个,是一群人。虽然可能无法准确的找到是谁,但是依靠“子豪”这个名字去找人,依旧非常有效,它把快递员找人的范围从几百个缩减到了5个。假设是个不那么容易重复的名字,立刻就找到是谁了。

        这个键的定义非常关键,它要足够有效,足够明确,重复一致。如此,才能称作“好键”,咳咳咳,谐音梗扣钱!

        1. 足够有效

反例:把公司身高3米以下(不包含)的人分为一类,3米以上人的分为一类。

                吐槽:全公司都是3米以下人,对于找人没有任何帮助呀(#`O′)!!!

                正例:1.5米到1.7米(不包含)分为一类,1.7米以上分为一类

        2. 足够明确

反例:把公司胖的分为一类,瘦的分为一类。

吐槽: o(* ̄▽ ̄*)o,没有标准我怎么知道自己算哪类。

                正例:体重60公斤以内的分为1类,60公斤以上的分为1类。

        3. 重复一致

反例:扔骰子,扔到3或一下的分为一类。

吐槽:怎么排好了重新再来一次,好多人位置变来变去,忽左忽右(⊙﹏⊙)

                正例:按姓的笔画数分类。

        具体地,数字通常使用取余的方式,因为取余可以较为均匀的分配到每个格子之内。而对于字母和符号,可以转为ASCII码或者Unicode码,进一步当作数字处理。计算机的主要处理方式是使用数据存储地址作为哈希取余的方式。

哈希表:把不同的分类放在不同的位置,从而实现快速查找的存储方式。

哈希查找:通过哈希表的分类,查找到对应类别内容的过程。

1.2 哈希表有啥用?

        其实,哈希上学的时候已经学过了。可直到毕业一年后,我也只知道这个有键值对的东西它叫哈希表,一直不会用。所以我觉得非常有必要描述一下,可能很多人会跟我一样。

1.2.1 散列函数

        比如你有个网站,可以查看影视评分。假设黑客截取了你的数据包,发现某个网址可以去查这个评分,恶意的反复访问这个网址,网站的资源就会被占满,最终无法访问,导致网站瘫痪宕机。

        为了预防黑客,可以用散列函数,给数据校验。每次传网址的时候,我们加两个参数,一个时间一个哈希。通过时间+参数配合,获取MD5(MD5 Message-Digest Algorithm一种被广泛使用的密码散列函数)校验。一方面可以防止信息被篡改,另一方面可以为信息定义失效时间,降低黑客恶意占用网络资源的风险。

1.2.2 统计计数

        还记得上学时候班长是怎么选的吗?它大概有几个步骤:

        1. 每位同学为自己喜爱的候选人投下可能会赢的票

        2. 在黑板上,分散的把候选人的名字写下来

        3. A同学读出名字,B同学在对应的名字下画“正”字

【简约而不简单:神级代码的小秘密】| 第一章 哈希表_散列表

         按照不同的键值,把数量进行累计的过程,就叫做哈希计数。我们通过名字找到类别,从而找到对应的人,为对应的人加选票,最终可以知道每个人获取多少票。假设我们不用哈希计数,采用先排序(以快速排序为例),后计数的方式,平均复杂度是O(nlogn)。而使用哈希进行计数,插入时间复杂度为O(1),查找的时间复杂度为O(1),若是冲突较高的情况则为O(logn)。此处不做展开,后续章节将做进一步说明。这个效率大大增加了,大O表达式可能描述的概念不够明确,以10000个数为例。这里的log,统一以2为底进行计算,后续章节不再赘述。

O(nlogn)=10000*sqrt(10000)=10000*100=1000000

O(logn)=sqrt(10000)=100

        可见,使用哈希计数和排序后再计数的效率明显不同,它能大大提高程序的运行效率。

1.2.3 哈希去重

        互联网公司之间要举办一场运动会。

        其中BAT公司参加跑步的有100个人我们知道他们的名字,参加羽毛球比赛的有100个人我们知道他们的名字,参加团建游戏的有100个人我们知道他们的名字,其中,有一些人参加了多项比赛,这三份名单每一份各自对应一份统计参赛人员的名单。

        为了鼓励参赛的选手,运组委为每一位参赛的选手准备了一份经典时尚的小礼品。已知的参赛人员信息,只有三份名单统计表。       

        已知,参赛选手的姓名没有重复,运组委如何快速的统计这份名单,将小礼品分发给每位参赛选手呢?

        这种时候,可以采用哈希表的方式来处理。从 1.1.2 哈希键怎么定义 中我们知道了哈希的键具有重复一致的特性,那么,可以每次拿到一份名单就求出哈希值,把这个名字放在对应的位置,最后统计的数目就是名字的总数。

1.2.4 关键词搜索

        已知一个关键词长度为 m ,使用这个关键词,在一段长度为的文本 n 里进行搜索,需要怎么操作呢?我们当然可以一个个位置去匹配,但是这样做的时间复杂度是O(mn), 非常的耗时。

        那我们怎么办呢?可以用哈希。使用关键词生成一段哈希。然后从头到尾不断通过求出长度为 n 的文本中的长度为 m 的哈希值,与其进行匹配,就可以达成找到字符串的目的。这个时间复杂度是 O(n)。

        附代码,有兴趣的朋友可以看一下:

public boolean longestDupSubstring(String s,String word) {
if(s.length()<word.length())
return false;
Random random = new Random();
// 生成两个进制
int a1 = random.nextInt(75) + 26;
// 生成两个模
int mod1 = random.nextInt(Integer.MAX_VALUE - 1000000007 + 1) + 1000000007;
int n = s.length();
int m = word.length();
//v为哈希键
int v = 0;
for (int i = 0; i < m; ++i) {
v = ((word.charAt(i) - 'a')+a1*v%mod1)%mod1;
}
return check(s,v,m,n,a1,mod1);
}

public boolean check(String s,int v, int m, int n,int a1, int mod1) {
long aL1 = pow(a1, m, mod1);
long h1 = 0;
for (int i = 0; i < m; ++i) {
h1 = (h1 * a1 % mod1 + (s.charAt(i)-'a')) % mod1;
}

//哈希键一致,粗略认为含有关键字
if(h1==v)
return true;
for(int i = m; i < n; i++){
h1 = (h1 * a1-(s.charAt(i-m)-'a')*aL1 + (s.charAt(i)-'a')) % mod1;
//哈希键一致,粗略认为含有关键字
if(h1==v)
return true;
}
return false;
}

public long pow(int a, int m, int mod) {
long ans = 1;
long contribute = a;
while (m > 0) {
if (m % 2 == 1) {
ans = ans * contribute % mod;
if (ans < 0) {
ans += mod;
}
}
contribute = contribute * contribute % mod;
if (contribute < 0) {
contribute += mod;
}
m /= 2;
}
return ans;
}

1.2.5 分布式

         在单一系统中,我们可以选用自增的ID,例如第一个是1,第二个是2...,以最后一个id递增1个的形式,来设计id,这样做的好处是,我们可以很方便的获取到下一个id,可以保证id一定不会发生重复。

        但是在分布式中,这种方式变的行不通了。分布式读者可以暂时理解为多个服务器协作,完成同样的功能。采用分布式的好处,是服务器可以在固定时间内承载更大的访问量。如果我们还是采取自增的方式,那同一时间内,只能有一台服务器获取到ID,不能达到我们期望服务器承受更大访问量的预期。因此,采用哈希键的方式生成ID的做法应运而生。

        常见的有:UUIDsnowflake,此处不进行详细描述。大致的生成方式是,根据时间,主机号等固定参数,生成哈希散列值,由于哈希碰撞的概率非常低,因此可以当做唯一ID来使用。

        除了生成唯一ID的运用,还有像服务器轮询算法,数据库分片查询等方面也有所运用。

1.3 哈希表的副作用

在1.2节,我们阐述了哈希的一些常见用法,可见,哈希表的好处是很多的,那么它有没有什么副作用呢?那当然是有的。

1.3.1 有效信息丢失

        假设我有 1 到 10 个数 [1,2,3,4,5,6,7,8,9,10],进行哈希键计算时,采用 5 的余数的方式。像下面这样:

【简约而不简单:神级代码的小秘密】| 第一章 哈希表_数据_02

        可以看到,再此期间,我们可以使用 1 到 10 这 10 个数字,得到0-4四个余数;但是无法通过0-4 4个余数,还原出 1-10 10个数字,这个过程中,信息出现了丢失。甚至无法通过某个余数 3 ,还原出原本的数字是3还是7还是10。

        那么,通过哈希映射的方式,必然会产生数据丢失吗?答案是否定的,当键和原始数据的数值是一一对应的情况,可以认为除了排序以外,没有产生数据丢失,请看下面这个例子:

[1,2,3,4,5,1,2,3,4,5,1,2,3,4,5,1,2,3,4,5]

        数据有一定的重复,采用 1.2.2 哈希计数 的方式进行统计,我们得到这样的映射关系:

[{1=>4},{2=>4},{3=>4},{4=>4},{5=>4}]

        这个过程,对每个数据的数量进行统计,不但保留了数据,而且压缩了保存空间,节约了内存。

        从前面的分析中,我们可以得到这样一个结论:

        当哈希键与原始数据的键不是一一对应的情况,会产生信息丢失;

        当哈希键与原始数据的键是一一对应的情况,除了原始数据的排列顺序会丢失外,能完整的保留数据,甚至可以达到数据压缩的优化效果。

1.3.2 键重复

        对于同一个键,产生了多条有效信息,这个过程叫做键重复。对于键重复,我们可以怎样处理呢?

1.3.2.1 丢弃法

       在前面的章节 1.2.3 哈希去重 中描述了使用哈希去除重复元素的方式。对于无需保留重复数据的情况,可以直接丢弃,不做处理。

1.3.2.2 替换法

        有些情况,我们只需要最近的数据。例如我们查看短信,微信,QQ等通讯聊天工具时,会给我们一个消息提示列表,每个群组和个人,只保留最近的一条消息。这个过程中,聊天对象就是键,消息就是映射的值,对于同一个聊天对象发送多条数据的情况,新的聊天记录会替换掉旧的聊天记录,这就是替换方式。

1.3.2.3 列表法

        在 1.1.2 哈希键怎么定义 我们提到,哈希键是一种分类方式。有时候使用哈希,是用来分类的。那么,我们可以把哈希的映射值,设置成一个列表,满足对应哈希条件的分到此分类中。

1.3.2.4 降低冲突

       不得不讲讲雪花算法,雪花算法是为了解决多台机器同一毫秒多台机器同时生成 ID ,但是冲突概率很低的一种方式。它采用了:时间戳毫秒数+同步支持 1024 台机器+ 10 位顺序号,每个机器每毫秒同一台机器可以生成 4096 个 ID 不重复,这意味着需要再同一毫秒,对同一台机器访问 4096 次,假设选择机器的方式是通过随机,那么对于同一台机器重复概率为:

        1/(1024*4096)=1/4,194,304        

        不同机器间,同一毫秒重复的概率是:  

        1/(1024*4096*1023)=1/(4,290,772,992)

        再加上一毫秒的限制条件,实际上冲突概率非常低,近似可以认为是不会冲突的。

1.3.3 哈希碰撞

        通过 1.3.1 有效信息丢失 我们知道,当哈希键与原始数据的键不是一一对应的情况,是会产生信息丢失的。不光哈希键会产生重复,哈希键映射的存储哈希的索引位置也会产生重复。键映射的存储哈希的索引位置,产生重复的过程叫做哈希碰撞。处理哈希碰撞的方法有很多,这里只介绍有代表性的两种开放地址法和链地址法,另外介绍一种红黑树法

1.3.3.1 开放地址法

        出现重复,就通过规则,找到下一个没有存储键的地址进行存储。

        优点:处理简单。

        缺点:冲突加剧后,存储和查找键的效率降低,只能存储和给定空间大小一致或者更少的数据。

1.3.3.2 链地址法

        每个索引位置不单单放一个值,而是将数据以链表的形式进行存储。

【简约而不简单:神级代码的小秘密】| 第一章 哈希表_服务器_03

        优点:无需重复查找键值,相对于开放地址法,同样定义的索引空间,可以存储更多的值,仍保证较高的效率。

        缺点:冲突加剧后,查询效率逐步降低。考虑到所有键都存在一个索引位的最坏情况,查询效率会由 O()退化至 O(n)。

 1.3.3.3 红黑树法

        出于降低链地址法带来的高冲突,进而导致查询效率降低冲突的影响,红黑树解决哈希冲突的方式,应运而生。红黑树属于二叉查找数,它的插入,查找,删除效率都是O(1)。在冲突较高的情况,可采用红黑树的方式替换链地址法,从而降低高冲突带来的负面效果。最低效率可由O(n^2)上升到O(logn)。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK