数据结构与算法学习③(Hash hash算法的工程应用 递归 )

Hash

散列表(Hash Table)

概述

散列表(Hash Table)又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性;举例说明散列的思想及原理如下:

场景描述:
全国每年都会举行的马拉松比赛,举办方会给每一个参赛选手分配一个参赛编号,最终进行成绩记录的时候根据参赛编号来记录,然后需要根据参赛编号查询参赛选手的信息;现假设有100个人参加马拉松,编号是1-100,如果要编程实现根据选手的编号迅速找到选手信息?
解决方案:
将这100位参赛选手的信息存入到一个数组中,编号为1的选手信息存放在数组下标为1的位置,编号为2的选手信息存放在数组下标为2的位置…依次同理编号为K的选手信息存放在数组下标为K的位置。因为参赛选手的编号跟数组的下标对应,所以当我们需要查询编号为X的选手信息我们只需要根据数组的下标X取出数组中的元素数据即可。我们知道在数组中查询数据的时间复杂度是O(1),这个查询的效率非常的高。这个例子中就已经用到了散列的思想,选手编号和数组下标一一对应,我们可以用O(1)的时间复杂度来获取选手信息,但该例中蕴含的散列思想还不够明显,下面进行场景升级
场景升级:
假设马拉松大赛主办方对每个选手不采用1-100的自然数对选手进行编号,编号有一定的规则比如:2020ZHBJ001,其中2020代表年份,ZH代表中国,BJ代表北京,001代表原来的编号,那此时的编号2020ZHBJ001不能直接作为数组的下标,此时应该如何实现呢?
解决方案:
思路跟之前的还是一样,虽然2020ZHBJ001不能直接作为数组的下标,但是我们可以通过某种方式将编号2020ZHBJ001转化为数组的下标,然后在对应位置上存储选手信息,获取的时候也是如此,比如:
2020ZHBJ001------------转换为数组下标------------->1
2020ZHBJ002------------转换为数组下标-------------->2
这就是一个非常典型的散列的思想,我们可以将选手编号作为:键(key)用以标识每一个参赛选手,把编号转换为数组下标的方法叫做散列函数(或者哈希函数),通过散列函数对key进行运算得到的值就叫做散列值(或哈希值)如下图所示:

在这里插入图片描述
散列表利用了数组按照下标访问数组元素时间复杂度是O(1)的特性,通过散列函数将键(key)映射为数组下标,然后将数据存储到对应下标的位置上,当我们根据key再次查询数据的时候,同样根据散列函数将key映射为下标,根据下标从数组对应位置上获取数据

散列函数

将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
上面的列子中:选手编号2020ZHBJ001就是key,转换后得到的数组下标就是hashValue,中间的转换过程就是散列函数hash

散列函数的基本要求:
1:散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
2:如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)
3:如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)

前两个要求我们很容易理解,第三个要求看起来没有任何问题,但是在实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不肯能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这也就是我们即将要说到的散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)
在这里插入图片描述
另外数组的存储空间是有限的,当数组快要存满了的时候散列冲突的概率会增加。
如何设计一个企业级的散列函数呢?
散列函数设计的好与坏直接决定了散列冲突发生的概率,也直接决定了散列表的性能,那好的散列函数应该满足一些什么特点呢?
1:散列函数不能太复杂,因为太复杂度势必要消耗很多的时间在计算哈希值上,也会间接影响散列表性能
2:散列函数计算得出的哈希值尽可能的能随机并且均匀的分布,这样能够将散列冲突最小化。
散列函数设计方法实际工作中,需要综合考虑各种因素。这些因素有关键字的长度、特点、分布、还有散列表的大小等。
散列函数各式各样,在此举几个常用的、简单的散列函数的设计方法。
1:直接寻址法:比如我们现在要对0-100岁的人口数字统计表,那么我们对年龄这个关键字key就可以直接用年龄的数字作为地址。此时hash(key) = key。这个时候,我们可以得出这么个哈希函数:hash(0) = 0,hash(1) = 1,…,hash(20)= 20。
在这里插入图片描述
如果我们现在要统计的是1980年后出生年份的人口数,那么我们对出生年份这个关键字可以用年份减去1980来作为地址。此时hash(key) = key-1980
在这里插入图片描述
也就是说,我们可以取关键字key的某个线性函数值为散列地址,即:
hash(key) = a x key + b,其中a,b为常量
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字key的分布情况,适合査找表较小且连续的情况。由于这样的限制,在现实应用中,直接寻址法虽然简单,但却并不常用。
2:除留余数法
除留余数法此方法为最常用的构造散列函数方法。对于散列表长为m的散列函数公式为:hash( key ) = key mod p ( p ≤ m )本方法的关键就在于选择合适的p, p如果选得不好,就可能会容易产生哈希冲突,比如:有12个关键字key,现在我们要针对它设计一个散列表。如果采用除留余数法,那么可以先尝试将散列函数设计为hash(key) = keymod 12的方法。比如29 mod 12 = 5,所以它存储在下标为5的位置

在这里插入图片描述
不过这也是存在冲突的可能的,因为12 = 2×6 = 3×4。如果关键字中有像18(3×6)、30(5×6)、42(7×6)等数字,它们的余数都为6,这就和78所对应的下标位置冲突了。此时如果我们不选用p=12而是选用p=11则结果如下
在这里插入图片描述

使用除留余数法的一个经验是:当P取小于哈希表长的最大质数时,产生的哈希函数较好
3:数字分析法
数字分析法并没有特定的的公式可以寻,比如我们想要把手机号码作为键(key)进行哈希处理,手机号码的前几位重复的可能性非常大,但是一般后四位就比较随机,所以我们可以选取手机号的后四位作为哈希值,这种设计散列函数的方法我们一般叫做数字分析法,数字分析法的核心思想就是从关键字key中提取数字分布比较均匀的若干位作为哈希值,即当关键字的位数很多时,可以通过对关键字的各位进行分析,丢掉分布不均匀的位,作为哈希值它只适合于所有关键字值已知的情况。通过分析分布情况把关键字取值区间转化为一个较小的关键字取值区间
4:平方取中法
这是一种常用的哈希函数构造方法。这个方法是先取关键字的平方,然后根据可使用空间的大小,选取平方数是中间几位为哈希地址
hash(key) = key平方的中间几位
这种方法的原理是通过取平方扩大差别,平方值的中间几位和这个数的每一位都相关,则对不同的关键字得到的哈希函数值不易产生冲突,由此产生的哈希地址也较为均匀。
在这里插入图片描述
5:折叠法:
有时关键码所含的位数很多,采用平方取中法计算太复杂,则可将关键码分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为散列地址,这方法称为折叠法,折叠法可分为两种:
移位叠加:将分割后的几部分低位对齐相加。
边界叠加:从一端沿分割界来回折叠,然后对齐相加。比如关键字为:12320324111220,分为5段,123,203,241,112,20,两种方式如下
在这里插入图片描述
当然了散列函数的设计方法不仅仅只有这些方法,对于这些方法我们无需全部掌握也不需要死记硬背,我们理解其设计原理即可。

散列冲突

前面讲到即使再好的散列函数我们也无法避免不了散列冲突(哈希冲突,哈希碰撞),那如果真的出现了散列冲突我们应该如何来解决散列冲突呢?
两类方法解决散列冲突:开放寻址法链表法开放寻址法
开放寻址法的核心思想是:一旦出现了散列冲突,我们就重新去寻址一个空的散列地址,只要散列表足够大,空的散列地址总能找到,一旦找到就将记录存入该地址。那如何重新寻址一个空的散列地址呢?我们由浅入深依次介绍几种方法。
线性检测
我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

在这里插入图片描述
散列表的大小为7,在元素X插入之前已经有a,b,c,d四个元素插入到散列表中了,元素X经过hash(X)计算之后得到的哈希值为4,但是4这个位置已经有数据了,所以产生了冲突,于是我们需要按照顺序依次向后查找,一直查找到数组的尾部都没有空闲位置了,所以再从头开始查找,直到找到空闲位置下标为1的位置,至此将元素X插入下标为1的位置

如果要从散列表中查找是否存在某个元素,这个过程跟插入类似,先根据散列函数求出要查找元素的key的散列值,然后比较数组中下标为其散列值的元素和要查找的元素,如果相等则表明该元素就是我们想要的元素,如果不等还要继续向后寻找遍历,如果遍历到数组中的空闲位置还没有找到则说明我们要找的元素并不在散列表中。
散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。其中删除操作稍微有点特殊,删除操作不能简单的将要删除的位置设置为空,为什么呢?
从散列表中查找是否存在某个元素一旦在对应hash值下标下的元素不是我们想要的就会继续在散列表中向后遍历,直到找到数组中的空闲位置,但如果这个空闲位置是我们刚刚删除的,那就会中断向后查找的过程,那这样的话查找的算法就会失效,本来应该认定为存在的元素会被认定为不存在,那删除的问题如何解决呢?
我们可以将删除的元素特殊标记为deleted,当线性检测遇到标记deleted的时候并不停下来而是继续向后检测,如下图所示:
在这里插入图片描述
不过可能大家也发现了使用线性检测的方式存在很大的问题,那就是当散列表中的数据越来越多的时候,散列冲突发生的可能性就越来越大,空闲的位置越来越少,那线性检测的时间就会越来越长,在极端情况下我们可能需要遍历整个数组,所以最坏的情况下时间复杂度为O(n)。

因此对于开放寻址解决冲突还有另外两种比较经典的的检测方式:二次检测双重散列
二次检测
所谓的二次检测跟线性检测的原理一样,只不过线性检测每次检测的步长是1,每次检测的下标依次是:hash(key)+0,hash(kjey)+1,hash(key)+2,hash(key)+3…,所谓的二次检测指的是每次检测的步长变为原来的二次方,即每次检测的下标为
在这里插入图片描述
双重散列
所谓的双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)…我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置

总之不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲位置。我们用**装载因子(load factor)**来表示空位的多少。
散列表装载因子的计算公式为:
装载因子 = 散列表中元素的个数 / 散列表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。那如果装载因子过大了怎么办
装载因子过大不仅插入的过程中要多次寻址,查找的过程也会变得很慢。对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出极少冲突的散列函数,因为毕竟之前数据都是已知的。对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估将要加入的数据个数,所以我们也无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。这个时候,我们该如何处理呢?
此时我们需要针对散列表,当装载因子过大时,进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4。针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置
插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)。最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n)。但是这个动态扩容的过程在n次操作中会遇见一次,因此平均下来时间复杂度接近最好情况,就是 O(1)。
当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。
**总结一下,当数据量比较小、装载因子小的时候,适合采用开放寻址法。**这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因
解决散列冲突除了使用这类开放寻址法外还可以使用链表法,这是一种比开放寻址法更加常用的解决散列冲突的方案,接下来我们就来学习使用链表法解决散列冲突。

链表法(拉链)
相比开放寻址法,它要简单很多。我们来看这个图,在散列表中,数组的每个下标位置我们可以称之为“桶(bucket)”或者“槽(slot)”,每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
在这里插入图片描述
插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。
查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢?
实际上,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数,但是基本上或者说平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)。当然了散列表的查询效率并不能笼统地说成是 O(1)。
它跟散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降。在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n),这也就是散列表碰撞攻击的基本原理。

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

复杂度分析

在这里插入图片描述
https://www.bigocheatsheet.com/

工程应用

在实际的工程应用中,高级编程语言都有在哈希表的基础上抽象出具体的实现,并且都已内置给开发者直接使用,在java中使用的最多的就是map和set
Map:key-value键值对,key不重复接口定义:https://docs.oracle.com/javase/8/docs/api/java/util/Map.html
在这里插入图片描述
Map的企业级实现:ConcurrentHashMap, ConcurrentSkipListMap,HashMap,Hashtable,Properties,TreeMap,
课后思考:HashTable和HashMap的区别,各自是怎么实现的?
扩展:其他语言的map有什么操作?

Set:不重复元素的集合
接口定义:https://docs.oracle.com/javase/8/docs/api/java/util/Set.html
在这里插入图片描述
Map的企业级实现:HashSet, LinkedHashSet, TreeSet

面试实战

亚马逊,微软最近面试题,242. 有效的字母异位词

字母异位词:指的是字母相同,但排列不同的字符串
构造哈希表计数器
在这里插入图片描述
用数组来存放每个字母,s,t当做计数器一个加一个减,如果都加减完,整个数组的值都是0,那就相等的.

    public boolean isAnagram(String s, String t) {
          if(s==null||t==null||s.length()!=t.length()){
              return false;
          }
          int[]Hashtable=new int[26];
          for(int i=0;i<s.length();i++){
              Hashtable[s.charAt(i)-'a']++;
              Hashtable[t.charAt(i)-'a']--;
          }
          for(int count:Hashtable){
              if(count!=0){
                  return false;
              }
          }
          return true;
    }

在这里插入图片描述
时间复杂度:O(n),空间复杂度O(1)

腾讯,高盛集团最近面试题,49. 字母异位词分组

https://leetcode-cn.com/problems/group-anagrams/
在这里插入图片描述
使用map来存储同组的异位词,设法让异位词对应相同的key,value部分用一个集合来存储异位词.
key的取值是一个根据26位单词长度生产的字符串,如果一致就肯定是异位词.value是根据异位词来添加的,如果包含加进集合中.

 public List<List<String>> groupAnagrams(String[] strs) {
          if(strs==null||strs.length<1){
              return null;
          }
          Map<String,List<String>>map=new HashMap();
          for(String str:strs){
              String key=genkey(str);
              if(!map.containsKey(key)){
                  map.put(key,new ArrayList());
              }
              map.get(key).add(str);
          }
          return new ArrayList(map.values());
    }

    public String genkey(String str){
        int[]table=new int[26];
        for(int i=0;i<str.length();i++){
            table[str.charAt(i)-'a']++;
        }
        StringBuilder sb=new StringBuilder();
        for(int i=0;i<table.length;i++){
            sb.append("-").append(table[i]);
        }
        return sb.toString();
    }

快慢指针解决链表问题

876链表的中间节点

https://leetcode-cn.com/problems/middle-of-the-linked-list/
在这里插入图片描述
让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。

  public ListNode middleNode(ListNode head) {
          ListNode slow=head;
          ListNode fast=head;
          
          while(fast!=null&&fast.next!=null){
              slow=slow.next;
              fast=fast.next.next;
          }
          return slow;

    }

在这里插入图片描述

剑指 Offer 22. 链表中倒数第k个节点

https://leetcode-cn.com/problems/lian-biao-zhong-dao-shu-di-kge-jie-dian-lcof/
在这里插入图片描述
思路还是使用快慢指针,让快指针先走 k 步,然后快慢指针开始同速前进。这样当快指针走到链表末尾 null时,慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表长度)

 public ListNode getKthFromEnd(ListNode head, int k) {
           ListNode slow,fast;
           slow=fast=head;
           while(k-->0){
              fast=fast.next;
           }
           while(fast!=null){
               slow=slow.next;
               fast=fast.next;
           }
           return slow;
    }
234. 回文链表

https://leetcode-cn.com/problems/palindrome-linked-list/submissions/
在这里插入图片描述
1:快慢指针+反转链表 两头开花
在这里插入图片描述

 public boolean isPalindrome(ListNode head) {
          //定义快慢指针
           ListNode fast,slow;
           fast=slow=head;
            //需要翻转前半部分链表,定义当前要反转的节点以及前面的节点
           ListNode curr,pre;
           curr=null;
           pre=null;
           //快指针走完,慢指针正好在链表中点,并且翻转slow指针
           while(fast!=null&&fast.next!=null){
           //记录当前节点
               curr=slow;
               fast=fast.next.next;
               //slow前进一步
               slow=slow.next;
               //反转链表,并把后指针往前移动一步
               curr.next=pre;
               pre=curr;
           }
           if(fast!=null){
           //如果链表节点为奇数,会移到前面一个位置,需要再往后移动一个位置
               slow=slow.next;
           }
           //接下来就是两头走进行比较了.
           while(slow!=null&&curr!=null){
               if(slow.val!=curr.val){
                   return false;
               }
               slow=slow.next;
               curr=curr.next;
           }
           return true;
    }

2:用
栈是先进后出,可以让链表反过来.压入栈也就是反转的过程,然后比较就好了

   public boolean isPalindrome(ListNode head) {
          if(head==null||head.next==null){
              return true;
          }
          Deque<Integer>stack=new ArrayDeque();
          ListNode curr=head;
          while(head!=null){
              stack.push(head.val);
              head=head.next;
          }
          while(curr!=null){
              if(curr.val!=stack.pop()){
                  return false;
              }
              curr=curr.next;
          }
          return true;
    }

hash算法的工程应用

1、哈希算法

注意:讨论哈希算法并不是去研究哈希算法的原理及如何设计一个哈希算法,更多的是说明哈希算法的特点以及应用场景

1.1、定义及要求

哈希算法又称为摘要算法,它可以将任意数据通过一个函数转换成长度固定的数据串,这个映射转换的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。可见,摘要算法就是通过摘要函数f()任意长度的数据data计算出固定长度的摘要digest,目的是为了发现原始数据是否被人篡改过
摘要算法之所以能指出数据是否被篡改过,就是因为摘要函数是一个单向函数,计算f(data)很容易,但通过digest反推data却非常困难。而且,对原始数据做一个bit的修改,都会导致计算出的摘要完全不同。
那有没有可能两个不同的数据通过某个摘要算法得到了相同的摘要呢?完全有可能!因为任何摘要算法都是把无限多的数据集合映射到一个有限的集合中。这种情况就是我们说的碰撞
我们要想设计出一个优秀的哈希算法并不是很容易,一个优秀的哈希算法一般要满足如下几点要求:

  1. 将任何一条不论长短的信息,计算出唯一的一摘要(哈希值)与它相对应,对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同
  2. 摘要的长度必须固定,散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小
  3. 摘要不可能再被反向破译。也就是说,我们只能把原始的信息转化为摘要,而不可能将摘要反推回去得到原始信息,即哈希算法是单向的
  4. 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值
    这些要求都是比较理论的说法,我们那一种企业常用的哈希算法MD5来说明:现使用MD5对三段数据分别进行哈希求值:
    1.MD5(‘数据结构和算法’) = 31ea1cbbe72095c3ed783574d73d921e
    2.MD5(‘数据结构和算法很好学’)=0fba5153bc8b7bd51b1de100d5b66b0a
    3.MD5(‘数据结构和算法不好学’)=85161186abb0bb20f1ca90edf3843c72

从其结果我们可以看出:MD5的摘要值(哈希值)是固定长度的,是16进制的32位即128 Bit位,无论要哈希的数据有多长,多短,哈希之后的数据长度是固定的,另外哈希值是随机的无规律的,无法根据哈希值反向推算文本信息。
其次2,3表明尽管只有一字之差得到的结果也是千差万别,最后哈希的速度和效率是非常高的,这一点我们可能还体会不到,因为我们哈希的只是很短的一串数据,即便我们哈希的是整个这段文本,用MD5计算哈希值,速度也是非常的快,总之MD5基本满足了我们前面所讲解的这几个要求。

应用场景

数据加密

用于加密数据的最常用的哈希算法是MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和SHA(Secure HashAlgorithm,安全散列算法)
对用于加密的哈希算法来说,有两点格外重要:
第一点:很难根据哈希值反向推导出原始数据,
第二点:散列冲突的概率要很小。
第一点很好理解,加密的目的就是防止原始数据泄露,所以很难通过哈希值反向推导原始数据,这是一个最基本的要求。对于第二点。实际上,不管是什么哈希算法,我们只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的。为什么?
基于组合数学中一个非常基础的理论,抽屉原理:桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,我们会发现至少会有一个抽屉里面至少放两个苹果。这一现象就是我们所说的“抽屉原理”。

掌握这一原理后我们就知道了哈希算法无法做到零冲突,我们只能说设计优良的哈希算法能够最大程度的避免冲突。除此之外,没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。比如SHA-256 比SHA-1 要更复杂、更安全,相应的计算时间就会比较长。密码学界也一直致力于找到一种快速并且很难被破解的哈希算法。我们在实际的开发过程中,也需要权衡破解难度和计算时间,来决定究竟使用哪种加密算法。

唯一ID

比如:如果要在海量的图库中,搜索一张图片是否存在,我们不能单纯地用图片的图片名称来比对,因为有可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那该如何搜索呢?
我们可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以取图片的名称,存储路径,图片的元信息(所有者等信息)或者说使用图片的二进制码信息,然后通过哈希算法(比如 MD5)得到一个哈希字符串,用它作为图片的唯一标识。通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。
如果还想继续提高效率,我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当要查看某个图片是不是在图库中的时候,我们先通过哈希算法对这个图片取唯一标识,然后在散列表中查找是否存在这个唯一标识。如果不存在,那就说明这个图片不在图库中;如果存在,我们再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样。如果一样,就说明已经存在;如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片

密码校验

现在来看一个很常见的业务场景,用户登陆;我们需要保存密码(比如网站用户名和密码),你要考虑如何保护这些密码数据,像下面那样直接将密码写入数据库中是极不安全的,因为任何可以打开数据库的人,都将可以直接看到这些密码。
在这里插入图片描述
解决的办法是将密码加密后再存储进数据库,比较常用的加密方法是使用哈希函数比如MD5,用户登录网站的时候,我们可以检验用户输入密码的哈希值是否与数据库中的哈希值相同。
在这里插入图片描述
由于哈希函数是不可逆的,即使有人打开了数据库,也无法看到用户的密码是多少。
那么存储经过哈希函数加密后的密码是否就是安全的了呢?我们先来看一下几种常见的破解密码的方法:字典破解(Dictionary Attack),暴力破解(Brute Force Attack),这对于这种单向的哈希算法要想破解说白了就是猜密码。字典破解和暴力破解都是效率比较低的破解方式。如果你知道了数据库中密码的哈希值,你就可以采用一种更高效的破解方式,查表法(Lookup Tables)。还有一些方法,比如逆向查表法(Reverse Lookup Tables)、彩虹表(Rainbow Tables)等,都和查表法大同小异。现在我们来看一下查表法的原理。
查表法不像字典破解和暴力破解那样猜密码,它首先将一些比较常用的密码的哈希值算好,然后建立一张表,当然密码越多,这张表就越大。当你知道某个密码的哈希值时,你只需要在你建立好的表中查找该哈希值,如果找到了,你就知道对应的密码了。
那我应该如何应对这种密码破解呢?我们就需要为密码进行加盐处理

密码加盐(Salt)

盐(Salt)是什么?就是一个随机生成的字符串。我们将盐与原始密码连接(concat)在一起(放在前面或后面都可以),然后将concat后的字符串加密。采用这种方式加密密码,查表法就不灵了(因为盐是随机生成的)
在这里插入图片描述
对于盐的生成我们可以使用apache随机字符串工具类,对于哈希算法我们可以采用spring框架中对MD5算法的封装,下面我们提供部分代码

publicstaticvoidmain(String[]args){//生成盐指定盐的长度
String salt=RandomStringUtils.randomAlphanumeric(8);//密码加密加盐
String digestAsHex=DigestUtils.md5DigestAsHex(("123456"+salt).getBytes());

System.out.println("salt:"+salt+"---password:"+digestAsHex);

如果你想演示出效果,须得导入maven坐标

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.5.RELEASE</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>

这样我们使用盐来提高了密码的安全性,但是这样产生的一个问题就是,我们需要一个额外的数据库字段来存储盐,如果盐被人知晓仍然是可以被破解的,接下来我们介绍一个更加安全高级的做法。

BCrypt
在spring-security中提供了一个类BCryptPasswordEncoder,实现了PasswordEncoder接口,其内部使用了BCrypt类中的相关方法,PasswordEncoder接口声明如下

public interface PasswordEncoder{
/***对密码进行加密*/
Stringencode(CharSequencerawPassword);
/***对原始密码和加密后的密码进行匹配*/

booleanmatches(CharSequencerawPassword,StringencodedPassword);}

在BCryptPasswordEncoder中采用SHA-256 +随机盐对密码进行加密,其中SHA系列就是一种哈希算法,这个类中对接口的两个方法进行了实现

(1)加密(encode):使用SHA-256+随机盐把用户输入的密码进行hash处理,得到密码的hash值,然后将其存入数据库中,注意BCryptPasswordEncoder的好处就是我们向数据库中进行存储的时候无需开辟额外的存储字段存储盐,只需存储对密码加密的结果即可,而且我们也不知道盐是多少,这个盐的生成是在BCryptPasswordEncoder的encode方法内部去生成的,并且它会将这个盐散落在最终加密好的结果中,到时候我们进行验证的时候它会自主的从加密串中提取盐,然后进行验证。
(2)密码匹配(matches):用户登录时,密码匹配阶段并没有进行密码解密(因为密码经过Hash处理,是不可逆的),而是使用相同的算法把用户输入的密码进行hash处理,得到密码的hash值跟之前存储在数据库中已经加密的值按照规则去匹配。

public static void main(String[]args){
//创建BCryptPasswordEncoder对象
BCryptPasswordEncoderbCryptPasswordEncoder=newBCryptPasswordEncoder();
//对密码进行加密
String pass=bCryptPasswordEncoder.encode("admin");
//对密码进行验证
boolean matches=bCryptPasswordEncoder.matches("admin",pass);
//输出加密结果及验证结果
System.out.println(pass+"-----"+matches);}

散列函数

前面的章节中讲到,散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。
不仅如此,散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。
除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。

负载均衡

对于负载均衡的算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(sessionsticky)的负载均衡算法呢?
也就是说,在同一个客户端上,一次会话中的所有请求都路由到同一个服务器上。最直接的方法就是,维护一张映射关系表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。这种方法简单直观,但也有几个弊端:
1:如果客户端很多,映射表可能会很大,比较浪费内存空间;
2:客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大
如果借助哈希算法,这些问题都可以非常完美地解决。我们可以通过哈希算法,对客户端 IP 地址或者会话 ID计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。这样,我们就可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。

数据分片

举例说明如下:
需求:现有2T的日志文件,里面记录的是网站每天用户的搜索关键字,我们需要统计出每个关键词被搜索的次数,如何实现?
这里有两个重难点:1:日志数据太大,一台机器的内存不够加载,2:如果只用一台机器来处理的话时间会非常的长

解决方案:对数据进行分片,然后用多台机器并行处理,提高处理速度,比如我们用n台机器并行处理,我们从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配到的机器编号。这样,哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。实际上,这里的处理过程也是 MapReduce 的基本设计思想。
针对这种海量数据的处理问题,我们都可以采用多机分布式处理。借助这种分片的思路,可以突破单机内存、CPU 等资源的限制。

2、面试实战

Two Sum 系列问题在 LeetCode 上有好几道,我们挑出有代表性的几道,看一下这种问题怎么解决。

2.1、1. 两数之和

https://leetcode-cn.com/problems/two-sum/
在这里插入图片描述

注意:数组nums中的数是无序的,但是我们又不能对其排序,因为最终要返回的是其下标,如果重新排序后下标肯定变了。

1.暴力解决,穷举

 public int[] twoSum(int[] nums, int target) {
        int max=0;
        for(int i=0;i<nums.length;i++){
            for(int j=i+1;j<nums.length;j++){
                if(nums[i]+nums[j]==target){
                    return new int[]{i,j};
                }
            }
        }
        return new int[]{};
    }

时间复杂度O(n^2),空间复杂度是O(1)

2.通过哈希表构造缓存,降低时间复杂度

public int[] twoSum(int[] nums, int target) {
        Map<Integer,Integer>map=new HashMap();
        for(int i=0;i<nums.length;i++){
            if(map.containsKey(target-nums[i])){
                return new int[]{map.get(target-nums[i]),i};
            }
            map.put(nums[i],i);
        }
        return new int[]{};
    }

时间复杂度O(N),空间复杂度O(N)

2.2、167. 两数之和 II

https://leetcode-cn.com/problems/two-sum-ii-input-array-is-sorted/
在这里插入图片描述
输入数组元素有序,使用双指针夹逼的思想,因为必然是有顺序的,前面小,后面

public int[] twoSum(int[] numbers, int target) {
            int i=0;
            int j=numbers.length-1;

            while(i<j){
            int sum=numbers[i]+numbers[j];
                if(sum==target){
                    return new int[]{i+1,j+1};
                }else if(sum<target){
                    i++;
                }else{
                    j--;
                }
            }
            return new int[]{};
    }

2.3、15. 三数之和

https://leetcode-cn.com/problems/3sum/
在这里插入图片描述
思路1:使用hash,分解为n-2个两数之和
和上面的暴力解法思想差不多,用set存储不重复的元素,用Arraylist来存储匹配的值,没有就放入缓存中.

 public List<List<Integer>> threeSum(int[] nums) {
        if(nums==null||nums.length<3){
            return Collections.EMPTY_LIST;
        }
        Set<List>set=new HashSet();
        for(int i=0;i<nums.length-2;i++){
            int target=-nums[i];
            Map<Integer,Integer>map=new HashMap();
            for(int j=i+1;j<nums.length;j++){
            if(map.containsKey(target-nums[j])){
                List<Integer>list=new ArrayList();
                list.add(nums[i]);
                list.add(target-nums[j]);
                list.add(nums[j]);
                list.sort(Comparator.naturalOrder());
                set.add(list);
            }else{
                map.put(nums[j],j);
            }
        }
     }
     return new ArrayList(set);
    }

复杂度高:O(n^2)

思路2:对数组排序,然后使用双指针夹逼(左右指针)
排好序后操作.

public List<List<Integer>> threeSum(int[] nums) {
        if(nums==null||nums.length<3){
            return Collections.EMPTY_LIST;
        }
        Arrays.sort(nums);

        List<List<Integer>>result=new ArrayList();
        for(int i=0;i<nums.length-2;i++){
                if(nums[i]>0){
                    break;
                }
                if(i>0&&nums[i]==nums[i-1]){
                    continue;
                }

                int a=nums[i];
                int j=i+1;
                int k=nums.length-1;

               while(j<k){
                   int b=nums[j];
                   int c=nums[k];
                   if(a+b+c==0){
                       List<Integer>list=new ArrayList();
                       list.add(a);
                       list.add(b);
                       list.add(c);
                       result.add(list);
                   
                   while(j<k&&nums[j]==nums[j+1]){
                       j++;
                   }
                   while(j<k&&nums[k]==nums[k-1]){
                       k--;
                   }
                   j++;
                   k--;
                   }else if((a+b+c)<0){
                       j++;
                   }else{
                       k--;
                   }

               }
        }
        return result;
}

总结:
对于 TwoSum 系列问题,一个难点就是给的数组无序。对于一个无序的数组,我们似乎什么技巧也没有,只能暴力穷举所有可能。
一般情况下,我们会首先把数组排序再考虑双指针(左右指针)技巧
TwoSum 启发我们,HashMap 或者HashSet 也可以帮助我们处理无序数组相关的简单问题。有时候双指针不仅是应用在一个数组(或链表)上,还可以应用到多个数组(或链表)上,如下:

2.4、4. 寻找两个正序数组的中位数

https://leetcode-cn.com/problems/median-of-two-sorted-arrays/
在这里插入图片描述
思路:用新数组存储,因为是正序排序,所以进去的数据按大小排序,小的放前面,放完对应的数组下表后移.第一个while比较完后都放入,会有另一个数组没有放完,接下来放进去.最后根据新生成的数组长度做计算即可.

 public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        //构造新的排序数组
        int[] data=new int[nums1.length+nums2.length];
        int k=0;//新数组下表,填充值
        //定义两个指针分别对应两个数组的下表
        int m=0;
        int n=0;
        while(m<nums1.length&&n<nums2.length){
            data[k++]=nums1[m]>nums2[n]?nums2[n++]:nums1[m++];
        }
        while(m<nums1.length){
            data[k++]=nums1[m++];
        }
        while(n<nums2.length){
            data[k++]=nums2[n++];
        }
        int len=data.length;
        if(len%2==0){
            return (data[(len/2)-1]+data[len/2])/2.0;
        }else{
            return data[len/2];
        }
        
    }

2.5、21. 合并两个有序链表

https://leetcode-cn.com/problems/merge-two-sorted-lists/
在这里插入图片描述
放一个哨兵,可以减少判空.小的先放,放完链表后移,最后再看看有没有链表没有结束的,有的话放到后面去.返回哨兵的后一个即可.

 public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        if(l1==null){
            return l2;
        }
        if(l2==null){
            return l1;
        }
        ListNode l3=new ListNode(-1);
        ListNode result=l3;

        while(l1!=null&&l2!=null){
            if(l1.val<l2.val){
                l3.next=l1;
                l1=l1.next;
            }else{
                l3.next=l2;
                l2=l2.next;
            }
            l3=l3.next;
        }
        l3.next=l1==null?l2:l1;
        return result.next;
    }

2.6、23. 合并K个升序链表

https://leetcode-cn.com/problems/merge-k-sorted-lists/
在这里插入图片描述
1:最简单的做法,逐个依次合并(两两合并)
和上面的一样的,只是说变成数组了,多个链表.

 public ListNode mergeKLists(ListNode[] lists) {
        if(lists==null){
            return null;
        }
        ListNode result=null;

        for(int i=0;i<lists.length;i++){
            result=mergeTwoLists(result,lists[i]);
        }
        return result;
    }

    public ListNode mergeTwoLists(ListNode l1,ListNode l2){
        if(l1==null){
            return l2;
        }
        if(l2==null){
            return l1;
        }
        ListNode l3=new ListNode(-1);
        ListNode result=l3;

       while(l1!=null&&l2!=null){
           if(l1.val>l2.val){
               l3.next=l2;
               l2=l2.next;
           }else{
               l3.next=l1;
               l1=l1.next;
           }
           l3=l3.next;
       }
       if(l1!=null){
           l3.next=l1;
       }
       if(l2!=null){
           l3.next=l2;
       }
       return result.next;
    }

2:使用优先级队列,每一次拿出最小的值放入队列中,然后再把该链表放回去.
执行顺序如下:先拿1,拿完后链表往后移动一位,189变成89放回队列链表中,然后是2的链表,2出来变成37,再塞回去
在这里插入图片描述

  public ListNode mergeKLists(ListNode[] lists) {
       if(lists==null||lists.length<1){
           return null;
       }
       //构造优先级队列
       PriorityQueue<ListNode>queue=new PriorityQueue<>(new Comparator<ListNode>(){
           @Override
           public int compare(ListNode o1,ListNode o2){
               if(o1.val<o2.val){
                   return -1;
               }
               if(o1.val>o2.val){
                   return 1;
               }
               return 0;
           }
       });
       //构造哨兵
       ListNode dummy=new ListNode(-1);
       ListNode result=dummy;
       //将各链表头结点加入队列
       for(ListNode head:lists){
           if(head!=null){
               queue.offer(head);
           }
       }
       while(!queue.isEmpty()){
           //依次从队列中获取最小值结点queue.poll();
           dummy.next=queue.poll();
           dummy=dummy.next;
           if(dummy.next!=null){
               queue.offer(dummy.next);
           }
       }
      return result.next;
   }

时间复杂度:O(n*log(k)), n 是所有链表中元素的总和, k 是链表个数。

2.7、3. 无重复字符的最长子串

https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/
在这里插入图片描述
核心思想:维护一个窗口(有点像队列),不断滑动,然后更新答案,下方是滑动窗口问题解决思路的模板
滑动窗口是一种高级的双指针思想

这个算法技巧的时间复杂度是 O(N),比暴力算法要高效得多,一般用于处理字符串字串问题

public int lengthOfLongestSubstring(String s) {
            if(s==null||s.length()<1){
                return 0;
            }
            int left,right;
            left=right=0;
            //用于判断是否有重复字符
            Set<Character>set=new HashSet();
            //定义最大值
            int max=0;
            while(right<s.length()){
                //当窗口需要缩小时
                while(set.contains(s.charAt(right))){
                    set.remove(s.charAt(left));
                    left++;
                }
                set.add(s.charAt(right));
                right++;
                max=Math.max(max,right-left);
            }
            return max;
    }

2.8、76. 最小覆盖子串

https://leetcode-cn.com/problems/minimum-window-substring/
在这里插入图片描述
在这里插入图片描述

  public String minWindow(String s, String t) {
          if(s==null||t==null||s.length()<t.length()){
              return "";
          }
          //构造两个哈希表
          int[]dict=new int[128]; //字典:记录t中所有字符及出现的次数
          int[]window=new int[128];//记录滑动窗口中每个字符出现的次数
          //记录s
          for(int i=0;i<t.length();i++){
              dict[t.charAt(i)]++;
          }
         //定义滑动窗口左右边界指针left,right
         int left,right;
         left=right=0;
         //定义窗口内已有字典中字符的个数
         int count=0;
         int min=s.length()+1;//+1是为了后续判断比这个小,因为有种极端情况是最小窗口就是s的长度
         //定义返回的字符串
         String res="";

         while(right<s.length()){
             char cr=s.charAt(right);
             window[cr]++;
            //判断进入窗口的这个字符是否是t中的
            if(dict[cr]>0&&dict[cr]>=window[cr]){
                //dict[cr]>=window[cr]避免了窗口中进入了过多的重复字符导致误判,比如t="ABC"窗口中进入了"AAA"
                count++;
            }
            right++;
            //当窗口内已完全包含t中所有字符时窗口开始收缩
            while(count==t.length()){
                 //缩小窗口求最小窗口
                 char cl=s.charAt(left);
                 //如果要移除窗口的这个字符时字典中,则窗口不能再收缩了,这是底线
                 if(dict[cl]>0&&dict[cl]>=window[cl]){
                     //dict[cl]>=window[cl]这个地方避免了移出过去的重复字符导致误判,比如t=ABC,窗口中有AABC
                     count--;
                     if(right-left<min){
                         min=right-left;
                         res=s.substring(left,right);
                     }
                 }
                 window[cl]--;
                 left++;
            }
         }
         return res;
    }

递归

1、递归概述

递归(Recursion)是一种非常广泛的算法,与其说递归是一种算法不如说递归是一种编程技巧。在后续数据结构和算法的编码实现过程中我们都要用到递归,比如DFS深度优先搜索,前中后序二叉树的遍历等都需要用到递归的知识,因此搞懂递归非常的重要,否则后续的学习会非常的吃力。

1.1、递归概念

例子1: 1:从前有个山
2:山里有个庙
3:庙里有个和尚讲故事
4:回到1

例2
假设你在一个电影院,你想知道自己坐在哪一排,但是前面人很多,你懒得去数了,于是你问前一排的人"你坐在哪一排?",这样前面的人 (代号 A) 回答你以后,你就知道自己在哪一排了,你只要把 A 的答案加一,
就是自己所在的排了。不料 A 比你还懒,他也不想数,于是他也问他前面的人 B “你坐在哪一排?",这样 A可以用和你一模一样的步骤知道自己所在的排。然后 B 也如法炮制。直到他们这一串人问到了最前面的一排,第一排的人告诉问问题的人"我在第一排"。最后大家就都知道自己在哪一排了。
在这里插入图片描述
综上:
递归在维基百科的官方解释为:递归(Recursion),又名递回
在数学与计算机科学中,是指在函数的定义中使用函数自身的方法
英文Recursion也就是重复发生,再次重现的意思。 而对应的中文翻译 ”递归“ 却表达了两个意思:”递“+”归“。 这两个意思,正是递归思想的精华所在,去的过程叫做递,回来的过程叫做归。

在编程语言中对递归可以简单理解为:方法自己调用自己,只不过每次调用时参数不同而已。

int f(int n) { 
if (n == 1){ 
  return 1; 
}
  return f(n-1) + 1; 
}

1.2、递归特点

与其说是递归的特点,不如说什么样的问题适合用递归来解决,即满足递归的条件
1、可拆解成可重复的子问题(重复子问题)
如果一个问题的解能够拆分成多个子问题的解,拆分之后,子问题和该问题在求解上除了数据规模不一样之外,求解的思路和该问题的求解思路完全相同,也就是说能够找到一种规律,那么这个问题就可以使用递归来求解。
比如电影院中你要知道“自己在哪一排”的问题,可以分解为“前一排的人在哪一排”这样一个子问题,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一样的。这种最近重复子问题的求解思路会形成一种规律,如果将这个规律用数学公式表达出来就是我们所谓的递推公式,基本上所有的递归问题都可以使用递推公式来表示,比如说对于刚刚电影院的例子,我们可以使用递推公式表示出来是这个样子的:

f(n) = f(n-1)+1

2、递归要有出口(终止条件)
把一个问题的解分解为多个子问题的解,把子问题再分解为子子问题,一层一层分解下去,不能存在无限递归,这就需要有终止条件。就比如电影院的例子,第一排的人不需要再继续询问任何人,就知道自己在哪一排,也就是 f(1)=1,这就是递归的终止条件,所以对于电影院的例子,完整的递推公式应该是:

f(n) = f(n-1)+1, 其中f(1)=1

综上所述:写递归代码的关键就是找到如何将一个问题拆分成多个小问题的规律,并且基于此写出递推公式,然后再找到递归终止条件,最后将递推公式和终止条件翻译成代码即可

1.3、递归和循环

从本质上来说:递归类似于循环,只不过是通过函数体调用自己来进行所谓的循环。

计算机语言在刚开始的时候本质上是汇编,它没有所谓的循环嵌套,它更多的是之前有一个函数或者指令在什么地方,程序不断的跳到这个地方去执行,这就是所谓的递归。对于循环本身和递归有异曲同工之妙,这点可以从循环的汇编代码结构上可以看出来,因此递归和循环没有明显的边界。

但是从编程角度上来说递归和循环还是略有区别,递归是有去有回循环是有去无回
举个例子,给你一把钥匙,你站在门前面,问你用这把钥匙能打开几扇门。
递归:你打开面前这扇门,看到屋里面还有一扇门(这门可能跟前面打开的门一样大小,也可能门小了些),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,你继续打开,。。。, 若干次之后,你打开面前一扇门,发现只有一间屋子,没有门了。 你开始原路返回,每走回一间屋子,你数一次,走到入口的时候,你可以回答出你到底用这钥匙开了几扇门。
循环:你打开面前这扇门,看到屋里面还有一扇门,(这门可能跟前面打开的门一样大小,也可能门小了些),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,(前面门如果一样,这门也是一样,第二扇门如果相比第一扇门变小了,这扇门也比第二扇门变小了,你继续打开这扇门,。。。,一直这样走下去。 入口处的人始终等不到你回去告诉他答案。
在这里插入图片描述
但同时递归和循环也是非常像的,循环可以改写成递归,递归未必能改写成循环,有时候我们可以使用迭代循环的方式将递归代码改写为非递归代码。

2、递归的应用

2.1、代码模板

对于递归代码的编写,很多人很容易看懂,但是很难写出来,在此给出递归代码的参考模板
在这里插入图片描述

2.2、实战题目

2.2.1、n的阶乘

计算n的阶乘,n!= n* (n-1) * (n-2) * (n-3) *… * 3 * 2 * 1;
递推公式: f(n) = n * f(n-1),其中n>0且f(1)=1

public int fac(int n){ 
//终止条件
 if ( n <= 1) { 
    return 1; 
 }
   return n* fac(n-1); 
 }

//递归改写成循环
public int fac_loop(int n){
 int res = 1; 
 for (int i=2;i<=n;i++) {
    res = res * i; 
  }
    return res; 
  }

递归写法:从n一直下探到下一层最后走到1,然后依次返回
循环写法:从1开始按照规律(递推公式)循环到n,每次循环都进行计算

2.2.2、剑指 Offer 10- I. 斐波那契数列

https://leetcode-cn.com/problems/fei-bo-na-qi-shu-lie-lcof/

在这里插入图片描述

 public int fib(int n) {
    if(n<2){
        return n;
    }else{
        int x=fib(n-1)%1000000007;
        int y=fib(n-2)%1000000007;
       return (x+y)%1000000007;
    }
  }

简单的测试 用例能通过 ,但是有一些大的测试用力没通过,最终结果未AC,超时
在这里插入图片描述

2.2.3、递归中出现的问题

1、重复计算
对于刚刚的斐波那契数列,我们可以简要的画一下递归整个过程的相关状态节点,以fib(6)为例
在这里插入图片描述
这个过程我们把它叫做画递归的状态树(把递归过程的中间状态画出来),从这个图中我们可以发现,相同颜色的都是重复计算的数,当题目参数n越大,重复的越多,程序执行耗时更长!
这种傻递归的方式,时间复杂度是O(2^n)
递归代码的时间复杂度要根据具体的语境来判断,在能画出递归状态树的情况下比较好判断

如何优化?
使用缓存(哈希)将中间结果缓存起来,避免多余的重复计算
写法1:使用全局参数
就像你在不同梦境中穿越时,总有一双俯视全局的眼镜能看到你在干嘛

//要缓存每一层的中间计算状态,,哈希定义为全局的
  Map<Integer,Integer>hash=new HashMap();
    public int fib(int n) {
    //
    if(n<2){
        hash.put(n,n);
        return n;
    }else{
        if(hash.containsKey(n)){
            return hash.get(n);
        }
        int x=fib(n-1)%1000000007;
        hash.put(n-1,x);
        int y=fib(n-2)%1000000007;
        hash.put(n-2,y);
        int z=(x+y)%1000000007;
        hash.put(n,z);
        return z;
    }
  }

写法2:缓存对象随着递归依次进入到下一层,然后返回的时候可以得到在下一层的改变(类似盗梦空间主角进入下一层时可以携带一些东西,这些东西在下一层发生了改变,返回的时候可以将这种改变带回来),体现在编码上就是函数的参数


 public int fib(int n){
     return fib(n,new HashMap());
 }
    public int fib(int n,Map<Integer,Integer>hash) {
    if(n<2){
        hash.put(n,n);
        return n;
    }else{
        if(hash.containsKey(n)){
            return hash.get(n);
        }
        int x=fib(n-1,hash)%1000000007;
        hash.put(n-1,x);
        int y=fib(n-2,hash)%1000000007;
        hash.put(n-2,y);
        int z=(x+y)%1000000007;
        hash.put(n,z);
        return z;
    }
  }

这种递归方式我们成为记忆化递归,时间复杂度O(n),空间复杂度O(n)

非递归优化版:

public int fib(int n){
   if(n<2){
       return n;
   }
   int first=0;
   int second=1;

   int temp;
   for(int i=2;i<=n;i++){
       temp=second;
       second=(second+first)% 1000000007;
       first=temp;
   }
   return second;
 }

时间复杂度:O(N),空间复杂度O(1)
这种解法也是后续要讲解的动态规划的算法思想

2、堆栈溢出
在这里插入图片描述
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,称为函数调用栈。 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。从代码中我们可以看出,main() 函数调用了 add() 函数,获取计算结果,并且与临时变量 a 相加。为了让大家清晰地看到这个过程对应的函数栈里出栈、入栈的操作,我画了一张图。图中显示的是,在执行到 add() 函数时,函数调用栈的情况。
在这里插入图片描述
通过图我们发现递归代码在执行的过程中,每一次递归的调用都会向函数调用栈中压入临时变量,直到满足递归终止条件在回归的过程中才会依次的将临时变量压出栈,如果递归调用的层次很深,一直在压入栈,我们知道系统栈或者虚拟机栈的空间一般都不大,所以如果一直入栈就会出现堆栈溢出的风险。

递归代码隐含的使用了系统的“栈”

```java
 public int fib(int n) {
    if(n<2){
        return n;
    }else{
        int x=fib(n-1)%1000000007;
        int y=fib(n-2)%1000000007;
       return (x+y)%1000000007;
    }
  }

重要:通过这个我们能推断出递归的空间复杂度,如果递归过程中不额外开辟存储空间的话,递归的空间复杂度就跟递归的深度有关系,每次递归是O(1),如果递归的深度跟数据规模n有关系,那空间复杂度就有可能是O(N)
而递归的时间复杂度则需要看递归的场景。大部分都是**O(n)**的。

那我们在编码的过程中如何避免堆栈溢出呢?

我们可以在代码中限制递归调用的最大深度,比如递归调用超过10000次后就不在继续递归调用了,直接返回或者抛出异常。但是其实这样去做并不能完全的解决问题,因为当前线程能允许的最大递归深度其实跟当前剩余的栈空间大小有关系,事先是不知道有多大的,如果想知道就得实时的去计算剩余的栈空间大小,这样也会影响我们的性能,所以说如果递归的深度不是特别大的话是可以使用这种方式来防止堆栈溢出的,否则的话这种办法也不合
适。

2.2.4、70. 爬楼梯

https://leetcode-cn.com/problems/climbing-stairs/
在这里插入图片描述
递推公式 f(n) = f(n-1) + f(n-2),其中f(1) = 1,f(2) = 2

class Solution {
    HashMap<Integer,Integer>map=new HashMap<>();
    public int climbStairs(int n) {
        return dfs(n);
       
    }
    public int dfs(int n){
        if(n<=2){
            map.put(n,n);
            return n;
        }
        if(map.containsKey(n)){
            return map.get(n);
        }

        int n_1=dfs(n-1);
        int n_2=dfs(n-2);
        int result=n_1+n_2;
        map.put(n,result);
        return result;
    }
}

当然对于斐波拉契数列也有对应的数学解法,需要找到斐波拉契数列的通项公式:
在这里插入图片描述

   public int climbStairs(int n) {
         double sqrt5 = Math.sqrt(5); 
         double fibn = Math.pow((1 + sqrt5) / 2, n + 1) - Math.pow((1 - sqrt5) / 2, n + 1); 
         return (int)(fibn / sqrt5);
    }

动态规划

public int climbStairs(int n) {
        int[] dp = new int[n + 2]; //该数组每个元素的指针对应的是台阶数,元素的值存放的是台阶数对应的方法数
        //dp[0] = 0; //不管怎样,数组下标指针肯定是从0开始的,所以要考虑0.有0个台阶,不需要爬,所以没有方法数(但从斐波那契角度,dp[0]=1)
        dp[1] = 1; //1阶台阶,只有一种方式(1)

        //2阶台阶,有两种方式(1+1, 2), 因为题目设定n是正整数,所以n最小是1,此时如果定义dp的长度是int[n + 1],则length=2
        //而dp[2]实际对应的是第三个元素,超出length了,所以定义new int[n + 2]更合理
        dp[2] = 2;

        //从第三个台阶开始遍历,第三个台阶,是第二个台阶的方法和第一个台阶的方法之和
        //第四个台阶,是第三个台阶和第二个台阶方法之和,依此论推....
        for (int i = 3; i <= n; i++) { //要遍历到第n个台阶,所以指针其实是从0到n,所以dp数组数量比n多1
            dp[i] = dp[i - 1] + dp[i - 2]; //最后到第n个台阶,得到结果后正好遍历完
        }

        return dp[n];
    }
2.2.5、22. 括号生成

https://leetcode-cn.com/problems/generate-parentheses/
在这里插入图片描述
1.先考虑生成所有组合的括号

 public List<String>generateParenthesis(int n){
        ArrayList<String> res = new ArrayList<>();
        recur(1,2*n,"",res);
        return res;
    }
    public void recur(int level,int max_level,String s,List<String>res){
       if (level>max_level){
           res.add(s);
           System.out.println(s);
           return;
       }
       String s1=s+"(";
       String s2=s+")";

       recur(level+1,max_level,s1,res);
       recur(level+1,max_level,s2,res);
    }

    @Test
    public void Test(){
        generateParenthesis(3);
    }

2.然后考虑如何保证是有效的括号
拆分为左括号和右括号+剪枝

  public List<String>generateParenthesis(int n){
        ArrayList<String> res = new ArrayList<>();
        recur(0,0,n,"",res);
        return res;
    }
    public void recur(int left,int right,int n,String s,List<String>res){
        if (left==n&&right==n){
            res.add(s);
            System.out.println(s);
            return;
        }
        if (left<n){
            String s1=s+"(";
            recur(left+1,right,n,s1,res);
        }
        if (right<left){
            String s2=s+")";
            recur(left,right+1,n,s2,res);
        }

    }

    @Test
    public void Test(){
        generateParenthesis(3);
    }

2.2.6、206. 反转链表

https://leetcode-cn.com/problems/reverse-linked-list/

在这里插入图片描述

1:使用递归函数,一直递归到链表的最后一个结点,该结点就是反转后的头结点,记作 ret
2:此后,每次函数在返回的过程中,让当前结点的下一个结点的 next 指针指向当前节点。
3:同时让当前结点的 next指针指向 NULL ,从而实现从链表尾部开始的局部反转
4:当递归函数全部出栈后,链表反转完成。

 public ListNode reverseList(ListNode head) {
           //递归终止条件
          if(head==null||head.next==null){
              return head;
          }
          //处理当前层逻辑
          ListNode node=reverseList(head.next);
          //如何反转当前节点,让当前节点下一个节点的next指针指向自己,自己的next指针为null
          head.next.next=head;
          head.next=null;
          return node;
    }

至此:我们发现一个有意思的现象
递归代码的编写就像是在证明一个命题的过程中将这个命题的结果当作已知条件来用

2.2.7、2. 两数相加

https://leetcode-cn.com/problems/add-two-numbers/
在这里插入图片描述
找重复子问题:每一位都是 l1.val + l2.val +carry;具备重复子问题特征,可以用递归
根据代码模板编写
1:终止条件:l1 == null && l2 == null && carry==0
2:处理当前层逻辑l1.val + l2.val +carry,构造结果的当前节点
3:进入到下一层,并把下一层的返回接到当前节点的next指针上
4:返回当前节点整体来看:递归去的时候计算每层结果值和进位,将进位带到下一层,直到最后;返回的时候依次把各节点连接上

 public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
       return recur(l1,l2,0);
       }
     
    public ListNode recur(ListNode l1,ListNode l2,int carry){
        if(l1==null&&l2==null&&carry==0){
            return null;
        }
        int a=l1==null?0:l1.val;
        int b=l2==null?0:l2.val;
        int v=(a+b+carry)%10; 

        carry=(a+b+carry)/10;//拿到进位值 1或者0
        ListNode node=new ListNode(v);
        node.next=recur(l1==null?null:l1.next,l2==null?null:l2.next,carry);
        return node;
    }

2.3、递归问题的思考要点

要点1:不要人肉递归
要点2:一定要找最近重复子问题(重复子问题决定了可以使用递归,如果没有重复性,那复杂度就是客观存在的)
要点3:学会使用数学归纳法(可以使用数学方法来解决问题)

个算法所需时间由下述递归方程表示, n = 1时 T(n) = 1 , n > 1时 T(n) = 2T(n/2) + n 该算法的时间复杂度是()
A: O(n * log(n))
B: O(n ^ 2)
C: O(n)
D: O(log(n))
题解:
在这里插入图片描述
要点4:做题时按照模板思路来编写代码,减少对递归状态树的依赖,甚至不要(学习初期可以使用)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值