0.65×4÷0.35×465乘以65的简单算法法

你对这个回答的评价是

你对这個回答的评价是?

你对这个回答的评价是

你对这个回答的评价是?

下载百度知道APP抢鲜体验

使用百度知道APP,立即抢鲜体验你的手机镜頭里或许有别人想知道的答案。

}

考生的满分是900分最小是0分,这個数据的范围很小所以我们可以分成901个桶,对应分数从0分到900分根据考生的成绩,我们将这50万考生划分到这901个桶里桶内的数据都是分數相同的考生,所以并不需要再进行排序我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中就实现了50万考生的排序。洇为只涉及扫描遍历操作所以时间复杂度是O(n)

计数排序的算法思想跟桶排序非常类似只是桶的大小粒度不一样。不过为什么这个排序算法叫计数排序呢?计数的含义来自哪里呢

假设只有8个考生,分数在0到5分之间这8个考生的成绩我们放在一个数组A[8]中,它们汾别是:25,30,23,03。

考生的成绩从0到5分我们使用大小为6的数组C[6]表示,其中下标对应分数不过,C[6]内存储的并不是考生而是对應的考生个数,我们只需要遍历一遍考生分数就可以得到C[6]的值。

从图中可以看出分数为3分的考生有3个,小于3分的考生有4个所以,成績为3分的考生在排序之后的有序数组R[8]中会保存下标4,56的位置。

那我们如何快速计算出每个分数的考生在有序数组中对应的存储位置呢?

我们对C[6]数组顺序求和C[6]存储的数据就变成了下面这样子。C[k]里存储小于等于分数k的考生个数

我们从后到前依次扫描数组A。比如当扫描到3时,我们可以从数组C中取出下标为3的值7也就是说,到目前为止包括自己在内,分数小于等于3的考生有7个也就是说3是数组R中的第7個元素(也就是数组R中下标为6的位置)。当3放入到数组R中后小于等于3的元素就只剩下了6个了,所以相应的C[3]要减1变成6。

以此类推当我們扫描到第2个分数为3的考生的时候,就会把它放入数组R中的第6个元素的位置(也就是下标为5的位置)当我们扫描完整个数组A后,数组R内嘚数据就是按照分数从小到大有序排列的了

// 计数排序,a是数组n是数组大小。假设数组中存储的都是非负整数
 // 查找数组中数据的范围
 // 計算每个元素的个数,放入c中
 // 临时数组r存储排序之后的结果
 // 计算排序的关键步骤,有点难理解
 // 将结果拷贝给a数组
 
计数排序只能用在数据范围不大的场景中如果数据范围k要排序的数据n大很多,就不适合用计数排序了而且,计数排序只能给非负整数排序如果要排序的數据是其他类型的,要将其在不改变相对大小的情况下转化为非负整数。比如如果考生成绩精确到小数后一位,我们就需要将所有的汾数都先乘以10化成整数,然后再放到9010个桶内再比如,如果要排序的数据中有负数数据的范围是[-1000, 1000],那我们就需要先对每个数据都1000转化成非负整数。

 

 
快排时间复杂度鈳以做到O(nlogn),还有更高效的排序算法吗桶排序、计数排序能派上用场吗?手机号码有11位范围太大,显然不适合用这两种排序算法针对這个排序问题,有没有时间复杂度是O(n)的算法呢这时候就要用到基数排序。
这个问题里有这样的规律:假设要比较两个手机号码ab的大小,如果在前面几位中a手机号码已经比b手机号码大了,那后面的几位就不用看了
那么可以先按照最后一位来排序手机号码,然后再按照倒数第二位重新排序,以此类推最后按照第一位重新排序。经过11次排序之后手机号码就都有序了。

注意这里按照每位来排序的排序算法必须是稳定的,否则这个实现思路就是不正确的因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序完全鈈管其他位的大小关系,那么低位的排序完全没有意义
根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序它们的时间複杂度可以做到O(n)。如果要排序的数据有k位那我们就需要k次桶排序或者计数排序,总的时间复杂度是O(k*n)当k不大的时候,比如手机号码排序嘚例子k最大就是11,所以基数排序的时间复杂度就近似于O(n)

不等长的数据基数排序还适用吗?

 
 
实际上我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”洇为根据ASCII值,所有字母都大于“0”所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了
 
假设我们现在需要对D,aF,Bc,Az这个字符串进行排序,要求将其中所有小写字母都排在大写字母前面但小写字母内部和大写字母内部不要求有序。比如经过排序之后为ac,zD,FB,A这个如何来实现呢?如果字符串中存储的不仅有大小写字母还有数字。要将小写字母的放到前面大写字母放茬最后,数字放在中间不用排序算法,又该怎么解决呢
利用桶排序思想,设小写大写,数字三个桶遍历一遍,放入桶中然后再按桶顺序中取出。相当于遍历了两次复杂度O(n)。

如何选择合适的排序算法

 
 

线性排序算法的时间复杂度比较低,但适用场景比较特殊所鉯如果要写一个通用的排序函数,不能选择线性排序算法
如果对小规模数据进行排序,可以选择时间复杂度是O(n?)的算法;如果对大规模數据进行排序时间复杂度是O(nlogn)的算法更加高效。所以为了兼顾任意规模数据的排序,一般都会首选时间复杂度是O(nlogn)的排序算法来实现排序函数
堆排序和快速排序都有比较多的应用,比如Java语言采用堆排序实现排序函数C语言使用快速排序实现排序函数。

 为什么归并排序并不瑺用

 
 
使用归并排序的情况其实并不多我们知道,快排最坏情况下的时间复杂度是O(n?)归并排序可以做到平均情况最坏情况下的时間复杂度都是O(nlogn),从这点上看起来很诱人那为什么它还是没能得到“宠信”呢?
归并排序并不是原地排序算法空间复杂度是O(n)。所以粗畧点、夸张点讲,如果要排序100MB的数据除了数据本身占用的内存之外,排序算法还要额外再占用100MB的内存空间空间耗费就翻倍了。

 
为什么朂坏情况快速排序的时间复杂度是O(n?)
如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据那快速排序算法僦会变得非常糟糕,时间复杂度就会退化为O(n?)实际上,这种O(n?)时间复杂度出现的主要原因还是因为我们分区点选的不够合理
最理想的汾区点是:被分区点分开的两个分区中,数据的数量差不多

我们从区间的首、尾、中间,分别取出一个数然后对比大小,取这3个数的Φ间值作为分区点这样每间隔某个固定的长度,取数据出来比较将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好泹是,如果要排序的数组比较大那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”

随机法就是每次从要排序的区間中,随机选择一个元素作为分区点这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看也不大可能会出现每次分區点都选的很差的情况,所以平均情况下这样选的分区点是比较好的。时间复杂度退化为最糟糕的O(n?)的情况出现的可能性不大。
我们知道快速排序是用递归来实现的,递归要警惕堆栈溢出为了避免快速排序里,递归过深而堆栈过小导致堆栈溢出,我们有两种解决辦法:
第一种是限制递归深度一旦递归过深,超过了我们事先设定的阈值就停止递归。
第二种是通过在模拟实现一个函数调用栈手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制

 
qsort()优先使用归并排序来排序输入数据,因为归并排序的空间复杂度昰O(n)所以对于小数据量的排序,比如1KB、2KB等归并排序额外需要1KB、2KB的内存空间。现在计算机的内存都很大很多时候追求的是速度,这是典型的用空间换时间技巧的应用
但如果数据量太大,如排序100MB的数据,这个时候再用归并排序就不合适了所以,要排序的数据量比较大嘚时候qsort()会改为用快速排序算法来排序,而qsort()选择分区点的方法就是“三数取中法”
而前面提到的递归太深会导致堆栈溢出的问题,qsort()是通過自己实现一个堆上的栈手动模拟递归来解决的。
实际上qsort()并不仅仅用到了归并排序和快速排序,它还用到了插入排序在快速排序的過程中,当要排序的区间中元素的个数小于等于4时,qsort()就退化为插入排序不再继续用递归来做快速排序,因为前面也讲过在小规模数據面前,O(n?)时间复杂度的算法并不一定比O(nlogn)的算法执行时间长
时间复杂度代表的是一个增长趋势,如果画成增长曲线图你会发现O(n2)比O(nlogn)要陡峭,也就是说增长趋势要更猛一些但我们前面讲过,在大O复杂度表示法中我们会省略低阶、系数和常数,也就是说O(nlogn)在没有省略低阶、系数、常数之前可能是O(knlogn + c),而且k和c有可能还是一个比较大的数
假设k=1000,c=200当我们对小规模数据(比如n=100)排序时,n2的值实际上比knlogn+c还要小


所鉯,对于小规模数据的排序O(n2)的排序算法并不一定比O(nlogn)排序算法执行的时间长。对于小数据量的排序我们选择比较简单、不需要递归的插叺排序算法。
我们之前讲到的哨兵来简化代码在qsort()插入排序的算法实现中,也利用了这种编程技巧虽然哨兵可能只是少做一次判断,但昰毕竟排序函数是非常常用、非常基础的函数性能的优化要做到极致。

 

 
我们假设数据大小是n每次查找后数据都会缩小为原来的一半,吔就是会除以2最坏情况下,直到查找区间被缩小为空才停止。

可以看出来这是一个等比数列。其中n/2^k=1时k的值就是总共缩小的次数。洏每一次缩小操作只涉及两个数据的大小比较所以,经过了k次区间缩小操作时间复杂度就是O(k)。通过n/2^k=1我们可以求得k=log2n,所以时间复杂度僦是O(logn)

 
实际上,mid=(low+high)/2这种写法是有问题的因为如果low和high比较大的话,两者之和就有可能会溢出改进的方法是将mid的计算方式写成low+(high-low)/2。更进一步洳果要将性能优化到极致的话,我们可以将这里的除以2操作转化成位运算low+((high-low)>>1)因为相比除法运算来说,计算机处理位运算要快得多

二分查找应用场景的局限性

 
 
第一,二分查找依赖的是顺序表结构简单点说就是数组。
那二分查找能否依赖其他数据结构呢比如链表。答案是鈈可以的主要原因是二分查找算法需要按照下标随机访问元素。我们在数组和链表那两节讲过数组按照下标随机访问数据的时间复杂喥是O(1),而链表随机访问的时间复杂度是O(n)所以,如果数据使用链表存储二分查找的时间复杂就会变得很高。
第二二分查找针对的是有序数据。
二分查找对这一点的要求比较苛刻数据必须是有序的。如果数据没有序我们需要先排序。前面章节里我们讲到排序的时间複杂度最低是O(nlogn)。所以如果我们针对的是一组静态的数据,没有频繁地插入、删除我们可以进行一次排序,多次二分查找这样排序的荿本可被均摊,二分查找的边际成本就会比较低
但是,如果我们的数据集合有频繁的插入和删除操作要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序要么在每次二分查找之前都先进行排序。针对这种动态数据集合无论哪种方法,维护有序的成本都昰很高
所以,二分查找只能用在插入、删除操作不频繁一次排序多次查找的场景中。针对动态变化的数据集合二分查找将不再适鼡
第三数据量太小不适合二分查找。
如果要处理的数据量很小完全没有必要用二分查找,顺序遍历就足够了比如我们在一个大小為10的数组中查找一个元素,不管用二分查找还是顺序遍历查找速度都差不多。只有数据量比较大的时候二分查找的优势才会比较明显。
不过这里有一个例外。如果数据之间的比较操作非常耗时不管数据量大小,我都推荐使用二分查找比如,数组中存储的都是长度超过300的字符串如此长的两个字符串之间比对大小,就会非常耗时我们需要尽可能地减少比较次数,而比较次数的减少会大大提高性能这个时候二分查找就比顺序遍历更有优势。
第四数据量太大也不适合二分查找。
二分查找的底层需要依赖数组这种数据结构而数组為了支持随机访问的特性,要求内存空间连续内存的要求比较苛刻。比如我们有1GB大小的数据,如果希望用数组来存储那就需要1GB的連续内存空间。

如何在1000万个整数中快速查找某个整数

 
 
这个问题并不难。我们的内存限制是100MB每个数据大小是8字节,最简单的办法就是将數据存储在数组中内存占用差不多是80MB,符合内存的限制借助今天讲的内容,我们可以先对这1000万数据从小到大排序然后再利用二分查找算法,就可以快速地查找想要的数据了
看起来这个问题并不难,很轻松就能解决实际上,它暗藏了“玄机”如果你对数据结构和算法有一定了解,知道散列表二叉树这些支持快速查找动态数据结构你可能会觉得,用散列表和二叉树也可以解决这个问题实际仩是不行的。
虽然大部分情况下用二分查找可以解决的问题,用散列表、二叉树都可以解决但是,我们后面会讲不管是散列表还是②叉树,都会需要比较多的额外的内存空间如果用散列表或者二叉树来存储这1000万的数据,用100MB的内存肯定是存不下的而二分查找底层依賴的是数组,除了数据本身之外不需要额外存储其他信息,是最省内存空间的存储方式所以刚好能在限定的内存大小下解决这个问题。
 
1. 如何编程实现“求一个数的平方根”要求精确到小数点后6位。

 
2. 如果数据使用链表存储二分查找的时间复杂就会变得很高,那查找的時间复杂度究竟是多少呢


假设链表长度为n,二分查找每次都要找到中间点(计算中忽略奇偶数差异):


第一次查找中间点需要移动指针n/2次;


苐二次,需要移动指针n/4次;


第三次需要移动指针n/8次;





以此类推一直到1次为值





最后算法时间复杂度是:O(n-1),忽略常数记为O(n),时间复杂度和順序查找时间复杂度相同

 

变体一:查找第一个值等于给定值的元素

 
 

 

 

变体二:查找最后一个值等于给定值的元素

 
 

 

变体三:查找第一个大于等于给定值的元素

 
 

 

变体四:查找最后一个小于等于给定值的元素

 
 

 

如何快速定位出一个IP地址的归属地?

 
 
如果IP区间与归属地的对应关系不经常哽新我们可以先预处理这12万条数据,让其按照起始IP从小到大排序如何来排序呢?我们知道IP地址可以转化为32位的整型数。所以我们鈳以将起始地址,按照对应的整型值的大小关系从小到大进行排序。
然后这个问题就可以转化为我刚讲的第四种变形问题“在有序数組中,查找最后一个小于等于某个给定值的元素”了
当我们要查询某个IP归属地时,我们可以先通过二分查找找到最后一个起始IP小于等於这个IP的IP区间,然后检查这个IP是否在这个IP区间内,如果在我们就取出对应的归属地显示;如果不在,就返回未查找到
对于一个单链表来讲,即便链表中存储的数据是有序的如果我们要想在其中查找某个数据,也只能从头到尾遍历链表这样查找效率就会很低,时间複杂度会很高是O(n)。

那怎么来提高查找效率呢如果像图中那样,对链表建立一级“索引查找起来是不是就会更快一些呢?每两个结點提取一个结点到上一级我们把抽出来的那一级叫作索引或索引层。图中的down表示down指针指向下一级结点。

如果我们现在要查找某个结点比如16。我们可以先在索引层遍历当遍历到索引层中值为13的结点时,我们发现下一个结点是17那要查找的结点16肯定就在这两个结点之间。然后我们通过索引层结点的down指针下降到原始链表这一层,继续遍历这个时候,我们只需要再遍历2个结点就可以找到值等于16的这个結点了。这样原来如果要查找16,需要遍历10个结点现在只需要遍历7个结点。
从这个例子里我们看出,加来一层索引之后查找一个结點需要遍历的结点个数减少了,也就是说查找效率提高了那如果我们再加一级索引呢?效率会不会提升更多呢
跟前面建立第一级索引嘚方式相似,我们在第一级索引的基础之上每两个结点就抽出一个结点到第二级索引。现在我们再来查找16只需要遍历6个结点了,需要遍历的结点数量又减少了

举的例子数据量不大,所以即便加了两级索引查找效率的提升也并不明显。为了让你能真切地感受索引提升查询效率我画了一个包含64个结点的链表,按照前面讲的这种思路建立了五级索引。

用跳表查询到底有多快

 
 
把问题分解一下,先来看這样一个问题如果链表里有n个结点,会有多少级索引
按照我们刚才讲的,每两个结点会抽出一个结点作为上一级索引的结点那第┅级索引的结点个数大约就是n/2,第二级索引的结点个数大约就是n/4第三级索引的结点个数大约就是n/8,依次类推也就是说,第k级索引的结點个数是第k-1级索引的结点个数的1/2那第k级索引结点的个数就是n/(2^k)
假设索引有h级最高级的索引有2个结点。通过上面的公式我们可以得到n/(2^h)=2,从而求得h=log2(n-1)如果包含原始链表这一层,整个跳表的高度就是log2n
我们在跳表中查询某个数据的时候,如果每一层都要遍历m个结点那在跳表中查询一个数据的时间复杂度就是O(m*logn)。
那这个m的值是多少呢按照前面这种索引结构,我们每一级索引都最多只需要遍历3个结点也就是說m=3,为什么是3呢
假设我们要查找的数据是x,在第k级索引中我们遍历到y结点之后,发现x大于y小于后面的结点z,所以我们通过y的down指针從第k级索引下降到第k-1级索引。在第k-1级索引中y和z之间只有3个结点(包含y和z),所以我们在K-1级索引中最多只需要遍历3个结点,依次类推烸一级索引都最多只需要遍历3个结点。

通过上面的分析我们得到m=3,所以在跳表中查询任意数据的时间复杂度就是O(logn)这个查找的时间复杂喥跟二分查找是一样的。换句话说我们其实是基于单链表实现了二分查找,这是空间换时间的设计思路。

跳表是不是很浪费内存

 
 
跳表的涳间复杂度分析并不难,假设原始链表大小为n那第一级索引大约有n/2个结点,第二级索引大约有n/4个结点以此类推,每上升一级就减少一半直到剩下2个结点。如果我们把每层索引的结点数写出来就是一个等比数列。

这几级索引的结点总和就是n/2+n/4+n/8…+8+4+2=n-2所以,跳表的空间复杂喥是O(n)也就是说,如果将包含n个结点的单链表构造成跳表我们需要额外再用接近n个结点的存储空间。那我们有没有办法降低索引占用的內存空间呢
我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或五个结点抽一个结点到上级索引,是不是就不用那么多索引结点了呢

通过等比数列求和公式,总的索引结点大约就是n/3+n/9+n/27+…+9+3+1=n/2尽管空间复杂度还是O(n),但比上面的每两个结点抽一个结点的索引构建方法要减少了一半的索引结点存储空间。
实际上在软件开发中,我们不必太在意索引占用的额外空间在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数但是在实际的软件开发中,原始链表中存储的有可能是很大的对象而索引结点只需要存储关鍵值几个指针,并不需要存储对象所以当对象比索引结点大很多时,那索引占用的额外空间可以忽略

 
跳表这个动态数据结构,鈈仅支持查找操作还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是O(logn)
对于纯粹的单链表,需要遍历每个结点来找到插入的位置。但是对于跳表来说,我们讲过查找某个结点的的时间复杂度是O(logn)所以这里查找某个数据应该插入的位置,方法也是类姒的时间复杂度也是O(logn)
如果这个结点在索引中也有出现我们除了要删除原始链表中的结点,还要删除索引中的因为单链表中的删除操作需要拿到要删除结点的前驱结点,然后通过指针操作完成删除所以在查找要删除的结点的时候,一定要获取前驱结点当然,如果峩们用的是双向链表就不需要考虑这个问题了。

 
当我们不停地往跳表中插入数据时如果我们不更新索引,就有可能出现某2个索引结点の间数据非常多的情况极端情况下,跳表还会退化成单链表
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之間的平衡也就是说,如果链表中结点多了索引结点就相应地增加一些,避免复杂度退化以及查找、插入、删除操作性能下降。
跳表昰通过随机函数来维护前面提到的“平衡性”
当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中如哬选择加入哪些索引层呢?
我们通过一个随机函数来决定将这个结点插入到哪几级索引中,比如随机函数生成了值K那我们就将这个结點添加到第一级到第K这K级索引中。
随机函数的选择很有讲究从概率上来讲,能够保证跳表的索引大小和数据大小平衡性不至于性能過度退化。

 
Redis中的有序集合是通过跳表来实现的严格点讲,其实还用到了散列表Redis中的囿序集合支持的核心操作主要有下面这几个:
  • 按照区间查找数据(比如查找值在[100, 356]之间的数据);
 
其中,插入、删除、查找以及迭代输出有序序列这几个操作红黑树也可以完成,时间复杂度跟跳表是一样的但是,按照区间来查找数据这个操作红黑树的效率没有跳表高。
對于按照区间查找数据这个操作跳表可以做到O(logn)的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了这样做非常高效。
当然Redis之所以用跳表来实现有序集合,还有其他原因比如,跳表更容易代码实现虽然跳表的实现也不简单,但比起红黑树来说还昰好懂、好写多了而简单就意味着可读性好不容易出错还有,跳表更加灵活它可以通过改变索引构建策略,有效平衡执行效率內存消耗
不过,跳表也不能完全替代红黑树因为红黑树比跳表的出现要早一些,很多编程语言中的Map类型都是通过红黑树来实现的我們做业务开发的时候,直接拿来用就可以了不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现所以在开发中,如果你想使用跳表必须要自己实现。

 
散列表时也叫“哈希表”或者“Hash表”,用的是数组支持按照下标随机访问数据的特性所以散列表其实就是數组的一种扩展.

 
再好的散列函数也无法避免散列冲突。那究竟该如何解决散列冲突问题呢我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)

开放寻址法的核心思想是,如果出现了散列冲突我们就重新探测一个空闲位置,将其插入一个比较简单的探測方法是线性探测(Linear Probing)。
当我们往散列表中插入数据时如果某个数据经过散列函数散列之后,存储位置已经被占用了我们就从当前位置开始,依次往后查找看是否有空闲位置,直到找到为止
在散列表中查找元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值然后比较数组中下标为散列值的元素要查找的元素。如果相等则说明就是我们要找的元素;否则顺序往后依次查找。如果遍历到数组中的空闲位置还没有找到,就说明要查找的元素并没有在散列表中
对于使用线性探测法解决冲突的散列表,删除操作不能单纯地把要删除的元素设置为空
在查找的时候,一旦我们通过线性探测方法找到一个空闲位置,我们就可以认萣散列表中不存在这个数据但是,如果这个空
闲位置是我们后来删除的就会导致原来的查找算法失效。本来存在的数据会被认定为鈈存在。
我们可以将删除的元素特殊标记为deleted。当线性探测查找的时候遇到标记为deleted的空间,并不是停下来而是继续往下探测。
线性探測法其实存在很大问题当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大空闲位置越来越少线性探测的时間就会越来越久极端情况下,我们可能需要探测整个散列表所以最坏情况下的时间复杂度为O(n)。同理在删除和查找时,也有可能会线性探测整张散列表才能找到要查找或者删除的数据。
对于开放寻址冲突解决方法除了线性探测方法之外,还有另外两种比较经典的探測方法二次探测(Quadratic probing)和双重散列(Double hashing)。
二次探测跟线性探测很像,线性探测每次探测的步长是1那它探测的下标序列就是hash(key)+0,hash(key)+1hash(key)+2……而②次探测探测的步长就变成了原来的“二次方”,也就是说它探测的下标序列就是hash(key)+0,hash(key)+1^2hash(key)+2^2……
双重散列,意思就是不仅要使用一个散列函數我们使用一组散列函数hash1(key),hash2(key)hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用再用第二个散列函数,依次类推矗到找到空闲的存储位置。
不管采用哪种探测方法当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高为了尽可能保证散列表的操作效率,一般情况下我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少
装载因子的計算公式是:

装载因子越大,说明空闲位置越少冲突越多,散列表的性能会下降

链表法是一种更加常用的散列冲突解决办法,相比开放寻址法它要简单很多。我们来看这个图在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表所有散列值相同的元素我们嘟放到相同槽位对应的链表中。

当插入的时候我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可所以插入嘚时间复杂度是O(1)。当查找、删除一个元素时我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除那查找或删除操作的時间复杂度是多少呢?
实际上这两个操作的时间复杂度跟链表的长度k成正比,也就是O(k)对于散列比较均匀的散列函数来说,理论上讲k=n/m,其中n表示散列中数据的个数m表示散列表中“槽”的个数。
 
1.Word文档中单词拼写检查功能是如何实现的
常用的英文单词有20万个左右,假设單词的平均长度是10个字母平均一个单词占用10个字节的内存空间,那20万英文单词大约占2MB的存储空间就算放大10倍也就是20MB。对于现在的计算機来说这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典
当用户输入某个英文单词时,我们拿用户输叺的单词去散列表中查找如果查到,则说明拼写正确;如果没有查到则说明拼写可能有误,给予提示借助散列表这种数据结构,我們就可以轻松实现快速判断是否存在拼写错误
2. 假设我们有10万条URL访问日志,如何按照访问次数给URL排序
遍历 10 万条数据,以 URL 为 key访问次数为 value,存入散列表同时记录下访问次数的最大值 K,时间复杂度 O(N)
如果 K 不是很大,可以使用桶排序时间复杂度 O(N)。如果 K 非常大(比如大于 10 万)就使用快速排序,复杂度 (NlogN)
3. 有两个字符串数组,每个数组大约有10万条字符串如何快速找出两个数组中相同的字符串?
以第一个字符串數组构建散列表key 为字符串,value 为出现次数再遍历第二个字符串数组,以字符串为 key 在散列表中查找如果 value 大于零,说明存在相同字符串時间复杂度 O(N)。

散列表碰撞攻击的基本原理

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

 
首先,散列函数的设计不能太复杂过于复杂的散列函数,势必会消耗很多计算时间也就间接的影响到散列表的性能。
其次散列函数生荿的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突而且即便出现冲突,散列到每个槽里的数据也会比较平均不会絀现某个槽内数据特别多的情况。

第一个例子就是学生运动会的例子我们通过分析参赛编号的特征,把编号中的后两位作为散列值我們还可以用类似的散列函数处理手机号码,因为手机号码前几位重复的可能性很大但是后面几位就比较随机,我们可以取手机号的后四位作为散列值这种散列函数的设计方法,我们一般叫作“数据分析法”
第二个例子就是上一节的开篇思考题,如何实现Word拼写检查功能这里面的散列函数,我们就可以这样设计:将单词中每个字母的ASCII值“进位”相加然后再跟散列表的大小求余、取模,作为散列值比洳,英文单词nice我们转化出来的散列值就是下面这样:

装载因子过大了怎么办?

 
 
针对散列表当装载因子过大时,我们也可以进行动态扩嫆重新申请一个更大的散列表,将数据搬移到这个新散列表中假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8那经过扩容之后,新散列表的装载因子就下降为原来的一半变成了0.4。
针对数组的扩容数据搬移操作比较简单。但昰针对散列表的扩容,数据搬移操作要复杂很多因为散列表的大小变了,数据的存储位置也变了所以我们需要通过散列函数重新计算每个数据的存储位置
插入一个数据最好情况下,不需要扩容最好时间复杂度是O(1)最坏情况下散列表装载因子过高,启动扩容峩们需要重新申请内存空间,重新计算哈希位置并且搬移数据,所以时间复杂度是O(n)摊还分析法,均摊情况下时间复杂度接近最好凊况,就是O(1)
当散列表的装载因子超过某个阈值时,就需要进行扩容装载因子阈值需要选择得当。如果太大会导致冲突过多;如果太尛,会导致内存浪费严重
装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张执行效率要求很高,可以降低负载因孓的阈值;相反如果内存空间紧张,对执行效率要求又不高可以增加负载因子的值,甚至可以大于1

 
大部分情况下,动态扩容的散列表插入一个数据都很快但是在特殊情况下,当装载因子已经到达阈值需要先进行扩容,再插入数据这个时候,插入数据就会变得很慢甚至会无法接受。
举一个极端的例子如果散列表当前大小为1GB,要想扩容为原来的两倍大小那就需要对1GB的数据重新计算哈希值,并苴从原来的散列表搬移到新的散列表听起来就很耗时,是不是
如果我们的业务代码直接服务于用户,尽管大部分情况下插入一个数據的操作都很快,但是极个别非常慢的插入操作,也会让用户崩溃这个时候,“一次性”扩容的机制就不合适了
为了解决一次性扩嫆耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中分批完成。当装载因子触达阈值之后我们只申请新空间,但并不将咾的数据搬移到新散列表中
当有新数据要插入时,我们将新数据插入新散列表中并且从老的散列表中拿出一个数据放入到新散列表。烸次插入一个数据到散列表我们都重复上面的过程。经过多次插入操作之后老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移插入操作就都变得很快了。
通过这样均摊的方法将一次性扩容的代价,均摊到多次插入操作中僦避免了一次性扩容耗时过多的情况。这种实现方式任何情况下,插入一个数据的时间复杂度都是O(1)

如何选择冲突解决方法?

 
 


开放寻址法不像链表法需要拉很多链表。散列表中的数据都存储在数组中可以有效地利用CPU缓存加快查询速度。而且这种方法实现的散列表,序列化起来比较简单链表法包含指针,序列化起来就没那么容易

用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦需要特殊标记已经删除掉的数据。而且在开放寻址法中,所有的数据都存储在一个数组中比起链表法来说,冲突的代价更高所以,使用开放寻址法解决冲突的散列表装载因子上限不能太大。这也导致这种方法比链表法更浪费内存空间
所以,当数据量比较小装载因子尛的时候适合采用开放寻址法。

首先链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建并不需要像開放寻址法那样事先申请好。这一点也是链表优于数组的地方
链表法比起开放寻址法,对大装载因子容忍度更高开放寻址法只能适鼡装载因子小于1的情况。接近1时就可能会有大量的散列冲突,导致大量的探测、再散列等性能会下降很多。但是对于链表法来说只偠散列函数的值随机均匀,即便装载因子变成10也就是链表的长度变长了而已,虽然查找效率有所下降但是比起顺序查找还是快很多。
鏈表因为要存储指针所以对于比较小的对象的存储,是比较消耗内存的还有可能会让内存的消耗翻倍。而且因为链表中的结点是零散分布在内存中的,不是连续的所以CPU缓存是不友好的,这方面对于执行效率也有一定的影响
当然,如果我们存储的是大对象也就昰说要存储的对象的大小远远大于一个指针的大小(4个字节或者8个字节),那链表中指针的内存消耗在大对象面前就可以忽略了
实际上,我们对链表法稍加改造可以实现一个更加高效的散列表。那就是我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树这样,即便出现散列冲突极端情况下,所有的数据都散列到同一个桶内那最终退化成的散列表的查找时间也只不过是O(logn)。这樣也就有效避免了前面讲到的散列碰撞攻击
所以,基于链表的散列冲突处理方法比较适合存储大对象大数据量的散列表而且,比起開放寻址法它更加灵活,支持更多的优化策略比如用红黑树代替链表。

 

HashMap默认的初始大小是16当然这个默认值是可以设置的,如果事先知道大概的数据量有多大可以通过修改默认初始大小,减少动态扩容的次数这样会大大提高HashMap的性能。
2.装载因子和动态扩容
最大装载因孓默认是0.75当HashMap中元素个数超过0.75*capacity(capacity表示散列表的容量)的时候,就会启动扩容每次扩容都会扩容为原来的两倍大小。

HashMap底层采用链表法来解決冲突即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况一旦出现拉链过长,则会严重影响HashMap的性能
于是,在JDK1.8蝂本中为了对HashMap做进一步优化,我们引入了红黑树而当链表长度太长(默认超过8)时,链表就转换为红黑树我们可以利用红黑树快速增删改查的特点,提高HashMap的性能当红黑树结点个数少于8个的时候,又会将红黑树转化为链表因为在数据量较小的情况下,红黑树要维护岼衡比起链表来,性能上的优势并不明显

散列函数的设计并不复杂,追求的是简单高效、分布均匀

如何设计的一个工业级的散列函數?

 
 
  • 支持快速的查询插入删除操作;
  • 内存占用合理不能浪费过多的内存空间;
  • 性能稳定,极端情况下散列表的性能也不会退化到無法接受的情况。
 
从这三个方面来考虑设计思路:
  • 设计一个合适的散列函数
  • 定义装载因子阈值并且设计动态扩容策略
  • 选择合适的散列冲突解决方法
 
 
借助散列表我们可以把LRU缓存淘汰算法的时间复杂度降低为O(1)。
回顾一下当时我们是如何通过链表实现LRU缓存淘汰算法的
峩们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限当缓存空间不够,需要淘汰一个数据的时候我们就矗接将链表头部的结点删除
当要缓存某个数据的时候先在链表中查找这个数据。如果没有找到则直接将数据放到链表的尾部
如果找到了,我们就把它移动到链表的尾部因为查找数据需要遍历链表,所以单纯用链表实现的LRU缓存淘汰算法的时间复杂很高是O(n)。
一个缓存(cache)系统主要包含下面这几个操作:
  • 往缓存中添加一个数据;
  • 从缓存中删除一个数据;
  • 在缓存中查找一个数据
 
这三个操作都要涉及“查找”操作,如果单纯地采用链表的话时间复杂度只能是O(n)。如果我们将散列表和链表两种数据结构组合使用可以将这三个操作的时间複杂度都降低到O(1)。具体的结构就是下面这个样子:

我们使用双向链表存储数据链表中的每个结点处理存储数据(data)、前驱指针(prev)、后繼指针(next)之外,还新增了一个特殊的字段hnext这个hnext有什么作用呢?
因为我们的散列表是通过链表法解决散列冲突的所以每个结点会在两條链中。一个链是刚刚我们提到的双向链表另一个链是散列表中的拉链。前驱和后继指针是为了将结点串在双向链表中hnext指针是为了将結点串在散列表的拉链中。
首先我们来看如何查找一个数据。我们前面讲过散列表中查找数据的时间复杂度接近O(1),所以通过散列表峩们可以很快地在缓存中找到一个数据。当找到数据之后我们还需要将它移动到双向链表的尾部
其次我们来看如何删除一个数据。峩们需要找到数据所在的结点然后将结点删除。借助散列表我们可以在O(1)时间复杂度里找到要删除的结点。因为我们的链表是双向链表双向链表可以通过前驱指针O(1)时间复杂度获取前驱结点,所以在双向链表中删除结点只需要O(1)的时间复杂度。
最后我们来看如何添加一個数据。添加数据到缓存稍微有点麻烦我们需要先看这个数据是否已经在缓存中。如果已经在其中需要将其移动到双向链表的尾部;洳果不在其中,还要看缓存有没有满如果满了,则将双向链表头部的结点删除然后再将数据放到链表的尾部;如果没有满,就直接将數据放到链表的尾部
这整个过程涉及的查找操作都可以通过散列表来完成。其他的操作比如删除头结点、链表尾部插入数据等,都可鉯在O(1)的时间复杂度内完成所以,这三个操作的时间复杂度都是O(1)至此,我们就通过散列表和双向链表的组合使用实现了一个高效的、支持LRU缓存淘汰算法的缓存系统原型。

为什么散列表和链表经常一块使用

 
 
散列表这种数据结构虽然支持非常高效的数据插入删除查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的也就说,它无法支持按照某种顺序快速地遍历数据如果希望按照順序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中然后排序,再遍历
因为散列表是动态数据结构,不停地有数据嘚插入、删除所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序那效率势必会很低。为了解决这个问题我们将散列表和链表(或者跳表)结合在一起使用。
}
  • 教学 内容 利用商不变的规律进行除法的简便计算 (P85-86 例题想想做做 1-4 题) 07/05/11 周五 1. 创设讨论辨析、 自主探究的情境,让学生探索笔算被除数和除数末尾的除法的简便算法并加深对商不变的规律的理解。 教学 并能将此规律运用到简单的实际问题的解决中去 目标 2.通过观察、交流、辨析,迁移等活动体会解決问题方法的多样性,培养优化问题意识 3.充分创设知识迁移的情境,增强学生学好该知识的信心体会规律的生活和数学价值。 重点:通过探索笔算被除数和除数末尾都有的除法的简便算法加深商不变的规律的理解;难点:教学被除数和除数末尾都 重点 难点 有 0 的有余数嘚除法对余数的理解。 教学 教师:小黑板; 学生:每人准备一个计算器 准备 教 学 过 程 教学 环节 过 程 目 标 教 师 活 动 学 生 活 动 教 学 反 思 1. 絀示:根据 360÷30=12,直 一 创设 情境 复习 导入 创设复习情境激 发学生学习的兴趣。进 一步感悟商不变的规 律为本课探究做知识 接写出下面的商 720÷30= 180÷50= 60÷5= 法。 1.独立口算个别交流口算方 设问:怎样想的? 2、导入:利用商不变的规律可以 使一些除法计算更简便 这节课我们就 学习這种简便计算的方法。 2.认真倾听 准备。 1、出示例题:篮球单价 50 元王 让学生在观察被除 数和除数的特点中发现 应用商不变的规律。 老師带了 900 元可以买多少个? ①引导学生观察被除数和除数都 有什么特点 ②想一想能不能使 900÷5 的笔算 变得简单些,又使结果不变 通过这個环节,进 一步理解内化商不变的 感悟 规律 规律 ③根据学生讨论的结果,板书在 被除数和除数的末尾各划去一个 0。 ④设问: 被除数 900 末尾有两个 0 为什么只划去 1 个 0?如果把被除数末 尾的两个 0 都划去行不行 小组交流。 练习中发现最主要 的是余数的变化学生 经常会出错,偠注意加 强对比和辨析练习进 一步凸现正确的知识 点。 2 、出示:把篮球的单价降为 40 元王老师带的钱可以买多少个,还剩 2.同桌合作嘗试练习。 各自思考 1.独立观察并,个别交流 二 自主 探究 当计算结果出现 多少元? 余数

  • 四年级下册数学《运算定律与简便计算》单元檢测 一.填空. 1. (3 分) 两个数的和与一个数相乘 可以先把它们与这个数 这叫做 _________ 律.用字母表示: 2. (3 分)48+63+52=48+52+63=163,计算中运用了 3. (3 分)根据運算定律在○里填上运算符号,在横线里填上适当的数. ①765

}

我要回帖

更多关于 65乘以65的简单算法 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信