1. Hash 哈希函数和哈希表
1.1 认识哈希函数和哈希表的实现
1.1.1 哈希函数 hash function
- 普通函数 f(input) -->output。
哈希函数也是f(input) --> output 其中input输入域为无穷∞,output输出域有限。java中输出域是0~2^32 -1。 意味着ouput是8位16进制的数组成(日常听说过的MD5算法,SHA1算法都是哈希函数的模型。)
- in的输入域无穷,比方说可以传入任意长度的字符串。但是在有些工程中也会给输入域规定范围。
- out的输出域相对有限,比如MD5算法的输出域是 [ 0,(2^64) - 1 ];SHA1算法的输出域是 [ 0,(2^128) - 1 ];在Java中规定Hash算法的输出域是 [ 0,(2^32) - 1 ]。可以认为输出域很大,但是一定是有穷尽的。
- 相同的输入,一定返回相同的输出(same in ——> same out)。哈希函数没有随机成分
- 哈希碰撞:不同的输入,有可能返回相同的输出(dif in ——> same out)。因为输出域有限。但概率很小
- 无论有多少个in传入Hash函数,最终输出的out在输出域上都是均匀离散的。从而保证了均匀性和离散性。如果一个Hash函数的离散型和均匀性越好,该Hash函数就越优秀。
- 均匀性:设定多个等规模的out输出域的真子集,使用这些真子集映射到输出域中,会发现每一个真子集中out的数量都是一样的。
- 离散性:in对应的out在输出域中是没有规律的。即使in非常像,out都不会有相似。如果离散性不存在,那么可以通过输入相似的in,让输出域上出现某一个区域的集中,这样均匀性也不会存在。所以均匀性和离散型是同时存在,相辅相成的。
1.1.2 哈希函数应用
原理:假设输入样本为in1,in2,in3...,通过Hash函数得到out1,out2,out3...(假设没有碰撞),然后我们给每一个out添加一个去摸%m的操作,得到m1,m2,m3... 。由于性质五,我们可知out在输出域上是均匀离散的,那么%m之后,可以保证m在 [ 0,m-1 ] 这个范围上也是均匀离散分布的。
题目:假设有一个大文件,该文件中存储了40亿个无符号整数,已知每一个无符号整数的范围是:[ 0,(2^32) - 1 ],转化成10进制是 [ 0,42亿多 ]。如果只有1G的内存,如何得到文件中出现次数最多的那个数?
解析: 这道题,如果使用Java来解,我们通常想到的经典解法是利用Java中的HashMap。Key是Integer类型,表示文件中的数;Value是Integer类型,表示该数出现的次数。然后我们从头到尾遍历该文件,遍历结束后,HashMap中最大Value对应的Key就是出现次数最多的那个数。 但是,该题目只给了1G的内存,那么使用HashMap来做够不够用呢? 已知HashMap中的一条记录有两个空间,包括Key的空间和Value的空间,Key和Value都是int类型都是4个字节,因此HashMap中一条记录最起码需要8个字节。除此之外,HashMap内部存储索引还需要一部分空间,我们假设索引没有占用空间。40亿个数,最差情况是40亿个数都不一样,这样HashMap中就要存储40亿条记录。每一条记录8个字节,40亿条记录一共需要320亿字节,折合32G的内存空间。因此,不能使用Java中提供的HashMap来做本题。
我们发现,HashMap空间的占用,只和Key的种类数有关,在本题中就是和文件中数字的种类数有关,相同种的数多次出现是不会增加HashMap的占用空间的,修改一下对应的Value就可以实现(HashMap的词频压缩)。所以在本题中,我们并不害怕某个数出现很多次,而是害怕有很多个不一样的数字。 解: 将文件中所有的数传入Hash函数,计算出40亿个out,将out依次%100,得到一个0 ~ 99范围的数。创建0 ~ 99号文件,将%100后的out存储到对应号的文件中。假设最坏情况有40亿个不相同的数字,我们也可以保证0 ~ 99号文件中每个文件存储的数字数量和种类分布都是差不多的。 然后,对每一号文件单独使用HashMap进行处理。如果40亿条记录放在一个文件中使用HashMap处理需要32G的内存,而40亿条记录均匀分布在一百个文件中,对其中一个文件使用HashMap处理则仅需要320M的内存。我们一号一号文件进行处理,这样就会保证内存不会被占满。每一号文件在HashMap处理后都会有一个出现次数最多的那个数,同时相同的数肯定只会出现在同一号文件里。因此在所有文件处理完之后,就会得到100个出现次数最多的数,而且100个数都不相同。最后在这100个数中找出出现次数最多的那个数就是最终的答案。
1.2 哈希表
在日常刷题过程中,只涉及到哈希表的使用,知道哈希表的增删改查的性能都是O(1)
1.2.1 哈希表经典结构
哈希表基于Hash函数实现。 假设设置初始区域容量为17,就会有0~16号区域。
Java中最常用的哈希表莫过于HashMap和HashSet了,两者的结构一致,原理一致,唯独区别在于HashMap有伴随数据Value而HashSet没有。本文详解结构时以HashMap为例。 如果要将Key="abc",Value=1存入哈希表,首先哈希表会使用哈希函数对key进行计算得到out,然后out再%17得到一个0~16的数,假设为1。然后哈希表遍历到1号区域,如果1号区域为空,就在1号区域后创建一个单向链表,将Key="abc",Value=1作为一个节点连接到单项链表中。多加几对Key-Value与上述同理,最终构成一个经典的哈希表。
由于Hash函数的性质,我们可以知道哈希表每个区域连接的单项链表基本上都是均匀变长的。 如果我们想通过Key找到对应的Value,就把存储Key-Value的流程再走一遍即可。假如当前Key="abc",那么哈希表先使用哈希函数对Key进行计算得到out,然后out再%17得到1,然后再遍历1号区域的单向链表最终找到对应节点,得到Value=1。
1.2.2 扩容
如果在上面的哈希表中加入大量的Key-Value,假设加入N对Key-Value,那么哈希表遍历的平均长度就是N / 17。如果哈希表容量一直都是17,那么哈希表操作的性能远远达不到O(1)。
因此,我们需要统计哈希表中每一个单向链表的长度,一旦某一个单向链表的长度超过一个阈值,那么就要触发哈希表的扩容机制。
假设每一次扩容都是原来的一倍,那么触发了扩容机制后,区域容量就成了34。原本所有单向链表中的节点都需要被哈希函数重新计算,重新取模,重新挂载到新的哈希表中。原来所有节点由17个区域均分,现在由34个区域均分,因此我们可以将原哈希表中的所有单向链表长度减半。
当扩容后,假设我们需要在哈希表中进行查询操作,哈希函数对Key计算的时间复杂度为O(1),取模的时间复杂度为O(1),遍历单向链表的时间复杂度为O(K)(假设链中由K个节点,如果可以保证单向链表不会过长,那么在单向链表中增删改查的时间复杂度都为O(1))。
扩容复杂度的计算比较复杂,假设我们加入了N对Key-Value且只要单向链表长度超过2就扩容一倍,那最多需要经历logN次扩容。如果单项链表扩容阈值大于2,那么扩容的次数就会小于logN,但是复杂度也是O(logN)级别的,只是常数项不同。每一次扩容完之后,需要把N个节点重新计算哈希值,重新取模,重新挂载到心的哈希表中,综合起来时间复杂度为O(N)。因此,当经历若干次扩容到容纳N对Key-Value时,扩容的时总代价为O(NlogN),单次扩容的代价为O(NlogN / N) 。因此可以认为在哈希表中单次的增删改查平均代价为O(logN)。
虽然哈希函数计算的时间复杂度为O(1),但是实际上常数是比较大的,但是指标为O(1)。
为什么说在使用哈希表时时间复杂度为O(1)呢?
因为我们可以将单向链表扩容的阈值定的很长,假设定为10,遍历10个节点的速度依旧非常快,但是可以极大减少扩容代价。因此O(logN)就变成了一个特别小的常数,可以说逼近于O(1)。
1.2.3 离线扩容技术
该技术像C++这种及时申请内存的语言做不了,但是Java和一些虚拟机托管的语言能够实现。
该技术能够在上述描述的扩容基础上,继续给扩容加速。
假设用户在使用哈希表A,哈希表A中的单向链表已经很长了,虽然操作的时间复杂度仍然可以达到O(1),但是常数比较大。因此用户想给哈希表A扩容。
因为该哈希表A被JVM托管,所以即使用户不用,它也会在内存中一直存在。那么我们就可以在内存别处给哈希表A做扩容生成哈希表B,在扩容过程中是不妨碍用户使用哈希表A的。等扩容成功后,就把哈希表A的指针指向哈希表B,将哈希表A销毁即可。这样就进一步降低了哈希表的扩容代价。
这就是为什么可以说在使用层面上哈希表的时间复杂度为O(1),而在理论上不是,理论上就是O(logN)。
1.2.4 不同语言
哈希表在不同语言中的具体实现有所不同,因为不同语言可能会使用其他数据结构来对哈希表做再次优化。
Java就把哈希表中的单链表改成了红黑树,但是C++还是保持了最经典的哈希表结构。
1.2.5 题目:设计randomPool结构
insert(key):将某个Key加入到该结构,做到不重复加入。 delete(key):将原本在结构中的某个Key移除。 getRandom():等概率随机返回结构中的任何一个Key。
要求是以上三个方法的时间复杂度为O(1)。
分析: 本道题是一个使用哈希表层面的数据结构设计,不会使用到哈希表的原理。
本体采用两个哈希表相互辅助实现RandomPool结构,其中一个哈希表中存储的是 key—index,另一个哈希表中存储的是 index—key。两个表之间通过index实现数据的关联,可以完成双向数据查找的操作。
随机生成数字getrandom,用此数字在index-key中查找,数字是key,返回字符。等概率随机
insert和getRandom操作没有什么特别之处,关键在于delete操作。如果直接删除结构中某一个Key和其对应的Index,那么势必会造成Index的不连续从而产生漏洞。这样在Random时就会出现多次随机生成Index,却击中不了当前存储在哈希表中的Index。因此在删除时,我们会将最后存入哈希表的Key覆盖掉目标Key,复用目标Key的Index,然后物理删除最后存入哈希表的Key。这样逻辑上既删除了目标Key,又不会造成Index的漏洞。
public class RandomPool {
private int size;
private final HashMap<K, Integer> keyIndexMap;
private final HashMap<Integer, K> indexKeyMap;
public RandomPool() {
this.size = 0;
this.keyIndexMap = new HashMap<K, Integer>();
this.ndexKeyMap = new HashMap<Integer, K>();
}
// insert(key):将某个Key加入到该结构,做到不重复加入。
public void insert(K key) {
// 如果Key不存在,执行insert操作
if (!this.keyIndexMap.containsKey(key)) {
this.keyIndexMap.put(key, size);
this.indexKeyMap.put(size ++, key);
}
}
// delete(key):将原本在结构中的某个Key移除。
public void delete(K key) {
// 如果Key存在,执行delete操作
if (this.keyIndexMap.containsKey(key)) {
int deleteIndex = this.keyIndexMap.get(key);
int lastIndex = --this.size;
K lastKey = this.indexKeyMap.get(lastIndex);
// keyIndexMap中最后Key覆盖目标Key
this.keyIndexMap.put(lastKey,deleteIndex);
// indexKeyMap中最后Key覆盖目标Key
this.indexKeyMap.put(deleteIndex, lastKey);
// keyIndexMap中删除目标Key
this.keyIndexMap.remove(key);
// indexKeyMap中删除最后一个Key
this.indexKeyMap.remove(lastIndex);
}
}
// getRandom():等概率随机返回结构中的任何一个Key。
public K getRandom() {
if (this.size == 0) {
return null;
}
int randomIndex = (int) (Math.random() * this.size);//0~size-1
return indexKeyMap.get(randomIndex);
}
}
1.3 布隆过滤器
1.3.1 介绍
布隆过滤器主要作用就是用来解决 "黑名单系统" 或者 "爬虫去重问题" 之类的问题。 我们以黑名单系统举例,比方说,现在有1000万个URL组成的大文件,这些URL都是黑名单,每个URL最大64字节,公司不希望用户在使用我们开发的浏览器服务的时候能够访问它们。
因此,我们需要构建一个黑名单系统,该系统使用一种数据结构能够将大文件中所有URL组织起来,并且能够快速查询。当用户输入一个URL要去访问时,我们能够通过查询该数据结构快速判断该URL是否在黑名单中。 该数据结构没有删除某个黑名单的需求,只有将某个URL加入黑名单和查询某个URL是否在黑名单中这两个重点需求。 经典解决方案就是构建一个HashSet,将1000万个URL全部存入HashSet,总共占用内存空间6亿4000万字节,约5GB,内存的空间开销将会非常大。
如果使用布隆过滤器实现该系统,将会极大减少内存的开销,但是将会出现一定概率的失误。
布隆过滤器专门用来替代内存中存储大量数据的集合,且该集合只有增和查的需求,没有删除的需求。布隆过滤器只占用很小的内存空间,但是会有一定概率出现失误,且失误无法避免。 失误有两种:
-
在黑名单中的URL被判断成白名单。
-
不在黑名单中的URL被判断成黑名单。
布隆过滤器不会出现第一种失误,只可能会有第二种类型的失误。但是布隆过滤器可以通过人为的设计,让第二种类型失误的触发率很低,低到万分之一。
布隆过滤器在工程中是完全能被接收的,即使是极度敏感的黑名单,要求不能误判任何一个黑名单,布隆过滤器也能做到 "即使错杀一百,也不放过一个",更何况错杀的概率很低。
1.3.2 位图
在详解布隆过滤器的实现之前,需要了解位图。位图,又叫 Bit Array 或者 Bit Map。
我们都熟悉Java中的数组,有int类型的数组int[],如果初始容量为100,那么int[]会有0~99个单元空间,每一个空间占4个字节。从概念上来说,位图就是bit类型的数组bit[],和int[]一样,只是bit[]每一个空间只占1bit。我们知道Java中,内存是按照字节计算的,而位图的占用空间是按照bit来计算的,那么我们在Java中如何实现一个位图呢?
使用基础类型数组来拼凑,意思就是可以使用基础类型来保存bit的整体信息,然后通过位运算来操作每一个bit。容量为10的int类型数组可以表示容量为320的bit类型数组。
int[] arr = new int[10]// 32 bit * 10 -> 320bit
//arr[0] int 0~31
//arr[1] int 32~63
现在需要获取第178 bit的信息,如果是1返回整数1,如果是0返回整数0,那么该如何获取?
// 定位第178 bit在int[]中的哪一个单元空间
int numIndex = 178 / 32;
// 定位该单元空间中的哪一个bit
int bitIndex = 178 % 32;
// 获取该bit上的状态
int status = (arr[numIndex] >> bitIndex) & 1;
修改第178 bit的信息,如何修改?
// 将第178bit改成1 并保存
arr[numIndex] = (1 << bitIndex) | arr[numIndex];
// 将第178bit改成0 并保存
arr[numIndex] = (~(1 << bitIndex)) & arr[numIndex];
1.3.3 布隆过滤器
布隆过滤器就是一个大位图,容量为m的bit[],实际占用空间为m/8字节。
往布隆过滤器中添加第一个黑名单URL1,那么URL1会先被哈希函数1计算所得结果out1,再将out1%m,就可以算出一个0~(m-1)的数字,这样就可以对应到bit[]中的某一个单元空间,将该位bit置1;然后URL1再被哈希函数2计算所得结果out2,再将out2%m,又可以对应的bit[]中的某一个单元空间,将该位bit置1 ... 以此类推,假设有K个哈希函数,URL1就会去依次调用K个哈希函数,得到K个out,模完m后对应到bit[]中K个单元空间,将K位bit置1(有可能单元空间有重叠,重叠的bit保持1,也有可能K个单元空间都独立不相同)。当K个哈希函数都调用完,URL1就算存入布隆过滤器中了。
URL2、URL3 ... URL1000万以此类推,每一个URL都需要调用K个哈希函数,得到K个out,模完m后对应到bit[]中K个单元空间,将K位bit置1。
现在有一个URL,需要在布隆过滤器中查找该URL是否是黑名单,那么该URL需要调用K个哈希函数,得到K个out,摸完m后对应到bit[]中K个单元空间,如果所有位bit都是1,那么说明该URL是黑名单;只要有1位bit不是1,那么说明该URL是白名单。 这里有两个参数是不确定的:
- K个哈希函数到底设置多少个?
- 位图容量m到底开多大?
为什么在这个问题中布隆过滤器中不会出现 "黑名单误判成白名单" 这种失误呢?
机制决定的,如果该URL是黑名单,那么在查的时候,可以保证该URL对应的bit位必然全都是1。
为什么在这个问题中布隆过滤器中会出现 "白名单误判成黑名单" 这种失误呢?
如果容量m定的很小,URL数量又很多,那么每一位bit必然会出现大量重叠,最终每一位bit都是1,就必然会出现误判。
什么决定失误率? 最重要的因素就是位图的容量m,如果m很大,那么失误率就能压的很小;如果m很小,失误率就会上升,最高可能高达100%。
哈希函数的个数K是什么地位? K实际上是根据位图容量m和样本量共同决定的。这种机制直观起来理解实际上就是采集指纹,K个哈希函数就相当于在一个指纹样本上采集K个特征点。如果只有1个哈希函数,相当于只采集了一个特征点,失误率就不能控制的很低。如果有3个哈希函数,那么失误率就会比只有1个哈希函数要低一些。但是不能把特征点数定的非常大,如果每一个指纹需要采集100个特征点,即使m设置的很合理,那么在处理非常大量的样本后,每位bit有相当大的概率也都置1了。
m和K的确定流程是什么?
先根据样本量n和失误率P先确定位图容量m,然后再根据样本量n、失误率P和位图容量m计算出一个最合适的哈希函数个数K。
在样本量n确定的情况下,随着位图容量m的增长,失误率P逐渐下降,但是下降速度会越来越缓慢。根据预期的失误率,可以选择一个较为合适的位图容量m = v。
在样本量n和位图容量m都确定的情况下,随着哈希函数个数K的增长,失误率P逐渐下降,但是当K很大时,m也会被逐渐填满,失误率P最终会逐渐上升,最终逼近到1。因此我们能够找到失误率P下降和回升的临界点,该点对应的k值就是最合适的哈希函数个数。
1.3.4 布隆过滤器设计以及参数选择
1.3.4.1 设计判断
什么时候需要布隆过滤器:
-
确定模型,类似于黑名单业务这样的系统,并且系统中没有删除业务。
-
该系统允许有一定的失误,并且有一个明确的预期失误率。
单样本的大小和设计布隆过滤器没有一点关系,不会决定布隆过滤设计的任何细节,例如上述例子中 "每个URL最大占64字节"。只要我设置的哈希函数可以接收64字节的URL作为参数,并能够对其进行计算就可以了。
m是bit长度,m/8才是实际使用空间
公式一和公式二计算出来的是理论位图容量m和理论哈希函数个数K,在实际生产中会做出相应调整。
比如理论位图计算出来容量m=26G,实际上可能会给m‘分配32G,这样实际失误率P’就会比理论失误率P进一步降低。
如果要计算实际失误率P’,需要使用先使用公式一和公式二计算出m和K,再使用公式三:
1.4 一致性哈希
1.4.1 如何组织多台数据服务器
数据服务器通常是指MySQL,Oracle这类的数据库服务器。
如果是经典的Web架构,那么通常只有一台数据服务器,不存在数据组织的问题。如果是分布式架构,那么就会有多台数据服务器,每台服务器存储哪些数据?这就涉及到多台数据服务器数据如何组织的问题。
经典组织方法是: 有三台数据服务器,分别为0号、1号和2号数据服务器,每一台数据服务器都维护着自己的专属数据。
如果现在需要存储 { "name": "John", "pass": "123" } 该条数据,那么应用服务器会先将该条数据的Key(唯一标识) "John" 放入哈希函数中进行计算得到out,再将out%3得到一个0~2的数字,然后存储到集群中对应的数据服务器中去。
如果现在要通过name = "John"查询pass,那么应用服务器也同样会将 "John" 放入哈希函数中计算再模3最终能够确定该数据条目归属于集群中哪一号数据服务器,从而从中取出 "123"。
这种经典的数据服务器组织数据的方式已经能够很好的做到数据在每一台数据服务器上的均匀分配,实现负载均衡。
那么拿数据条目中的什么Key来做这种数据的专属划分呢? 选择条目中种类非常多、不会重复的字段作为Key。比如说ID、用户名等。这样能够让哈希函数更好的做均分。
这种经典组织方法有一个致命的问题,就是增加数据服务器和减少数据服务器数据迁移的代价是全量的。
如果在某一个时刻数据量非常大,数据服务器不够用了,我想扩充数据服务器的数量,那么数据迁移的代价将会非常大,我们需要将原所有数据服务器中的数据提取出来,然后进行哈希计算再取模,重新将数据均匀存储到添加了新数据服务器的集群中。
那么我们就需要想如何不用 "模" 的方式来实现数据的均匀存储,让数据迁移的代价不那么高呢?
一致性哈希
1.4.2 一致性哈希
一致性哈希是用来讨论 "如何组织多台数据服务器" 这类问题。
一致性哈希中是没有 "模" 这个操作的,那么具体是如何完成数据的均匀存储呢? 假设使用MD5算法作为哈希函数,计算出来的哈希值范围是 [ 0,(2^64) - 1 ],我们将整个哈希值域 [ 0,(2^64) - 1 ] 抽象成一个闭环。
假设当前有三台数据服务器,分别为:0号、1号和2号数据服务器。每一台数据服务器的机器信息(IP,Host name,MAC地址)肯定都不一样,只要能够选择一种机器信息能够将每一台数据服务器都区分开,该机器信息就可以作为每一台数据服务器的传入哈希函数的参数。例如使用MAC地址作为区分服务器的机器信息,那么哈希函数就会计算每一台数据服务器的MAC地址,最后得到不同的out,对应到闭环中。
如果现在需要存储 { "name": "John", "pass": "123" } 该条数据,那么应用服务器会先将该条数据的Key(唯一标识) "John" 放入哈希函数中进行计算得到out,out一定能对应到 [ 0,(2^64) - 1 ] 中的某一个位置,然后将该条数据存储到对应位置顺时针距离最近的数据服务器上。
那么该机制如何实现? 在每个应用服务器上维护所有数据服务器通过MD5(MAC)计算得到的哈希值的一个有序排列,同时每个应用服务器还需要记录每一个哈希值对应哪一个数据服务器。上述例子的有序排列就是 [ ( 0 — 10亿),( 1 — 5000亿 ),( 2 — 70000亿 ) ]。
如果现在要通过name = "John"查询pass,那么在接收到该请求的应用服务器中,先将 "John" 放入哈希函数中计算得到4500亿,然后拿着计算出来的4500亿在该排序做二分查找,就能找到排序中大于4500亿且距离最近的一个哈希值为5000亿,因此应用服务器就会去哈希值为5000亿对应的1号数据服务器中检索该数据条目。
如果有数据服务器的哈希值正好等于4500亿,则应用服务器就直接去该数据服务器中检索数据条目,而不需要顺时针再去找数据服务器。
如果哈希函数计算出的哈希值在排序中没有找到大于等于该值的,那么对应的就是有序排列中最小的哈希值对应的那个数据服务器(因为是一个闭环)。
这个机制有什么好处? 如下图哈希环所示,在没有添加新数据服务器时,0号数据服务器存储哈希值落在C区域的数据,1号数据服务器存储哈希值落在A区域的数据,2号数据服务器存储哈希值落在B区域的数据。当集群中添加一个新数据服务器3号,那么仅需要0号数据服务器将C区域的数据迁移到3号数据服务器上即可,大大降低了添加 / 减少数据服务器时数据迁移的代价。
但是,此时还存在两个潜在问题:
- 在数据服务器数量很少时,一开始可能做不到数据的均匀存储。(因为哈希函数的特征是当数据多起来时落在每一段的数据数量差不多,而不是只有三个数据的时候三个数据必须分别落在三个段上)
- 即便在数据服务器很少的情况下可以把哈希环均分,当增加 / 减少一台数据服务器时,马上负载不均衡。(比方说本来三台数据服务器各占哈希环的 1 / 3,当加入一台新的数据服务器时,其中两台还占 1 / 3,但是另外两台却各自占 1 / 6)
如果解决了这两个问题,一致性哈希就非常好用了。 这两个潜在问题可以使用一个技术来解决,虚拟节点技术。
1.4.3 虚拟节点
假设在上述例子的基础上,给0号数据服务器分配1000个虚拟节点(a1,a2,...,a1000),给1号数据服务器也分配1000个虚拟节点(b1,b2,...,b1000),给2号数据服务器也分配1000个虚拟节点(c1,c2,...,c1000)。
不是0号、1号和2号数据服务器去争抢哈希环了,而是这些数据服务器下的虚拟节点去抢环。每个虚拟节点中存储一个代表字符串,通过将字符串传入哈希函数,计算得到的哈希值落在哈希环的对应位置上。
写一个简单的底层逻辑就可以实现虚拟节点之间的数据迁移。例如将a10的数据迁移到b500,通过路由表,a10去0号数据服务器上找数据,然后传输到b500虚拟节点对应的1号数据服务器中。
当前,整个哈希环被3000个虚拟节点所争抢。其中,哈希环中有1000个落点属于0号数据服务器,1000个落点属于1号数据服务器,1000个落点属于2号数据服务器。由于哈希函数的特性,这3000个落点在每一段位置中都分布均衡。因此随便取一段,a、b和c虚拟节点的个数都是差不多的,基本上保持1 / 3属于0号数据服务器,1 / 3属于1号数据服务器,1 / 3属于2号数据服务器。
按照比例去争抢数据,就能解决初始数据存储不均和增加 / 减少数据服务器导致数据存储不均的问题。
当增加一台数据服务器时,那么就为该数据服务器再创建1000个虚拟节点,再将1000个落点打到哈希环中。那么此时哈希环中每台数据服务器都各占1 / 4的落点,此时新数据服务器一定会从0号、1号和2号数据服务器夺取等量的数据到自身中。 当减少一台数据服务器时,该数据服务器也会将自身数据等量的分给其他三台数据服务器,给完之后其他三台数据服务器的数据也能够保持均衡。
1.4.4 管理负载
如果按照比例去争抢数据,那么一致性哈希还有一个妙用,就是可以通过实际机器的状况来管理负载。
比如在上面的例子中,0号数据服务器的性能比1号、2号数据服务器的性能都强。那么我们可以给0号数据服务器分配2000个虚拟节点,给1号、2号数据服务器各分配500个虚拟节点。
当今,各大公司构建的分布式数据库底层使用的都是这个原理,被誉为 "谷歌改变世界技术的三驾马车之一"(GFS、MapReduce和BigTable) 。
2. Disjoint-set 并查集
2.1 并查集 Disjoint-set data structure
2.1.1 总结
并查集是一种能够将集合快速合并的结构
2.1.2 问题引入
假设有5个样本ABCDE,我们将每一种样本单独构成一个集合:{A},{B},{C},{D},{E}。 现在我们需要对外提供两个操作:
第一个是:查询任意两个样本是否属于同一个集合isSameSet(a,b)。 第二个是:将任意两个不是同一个集合的样本所在的集合合并union(a,b)。
其实可以用很多结构来实现上述两个功能。但是想让两个功能运行的很快,很复杂,使用经典的一些结构是无法做到的。
如果使用链表LinkedList来实现,那么union(a,b)很快,能达到O(1);但是isSameSet(a,b)很慢,时间复杂度为O(N),因为需要遍历整个链表。
如果使用哈希表Hash Table来实现,那么isSameSet(a,b)很快,能达到O(1);但是union(a,b)很慢,哈希表之间数据迁移的时间复杂度很高。
我们设计一个什么结构能够让isSameSet(a,b)和union(a,b)时间复杂度都是O(1)?并查集。
2.1.3 并查集原理
使用一种特殊逻辑的图结构来表示并查集。
每一个样本都是图中的一个Node,每一个Node都会有一个指针,在并查集初始化时,每一个Node的指针都会指向节点自身。
isSameSet(a,b)的实现逻辑是:分别找样本a和样本b的代表节点,如果代表节点是同一个节点,则表示样本a和样本b在同一个集合中;如果不是同一个节点,则表示样本a和样本b不在同一个集合中。
代表节点就是一个集合的代表。代表节点找法就是:从当前样本Node的指针指向开始遍历,直到遍历到指针指向自身的节点为止,该节点就是Node的代表节点。
此时,b和c的代表节点都是a。
union(a,b)的实现逻辑是:首先调用isSameSet(a,b)判断样本a和样本b是否在同一个集合中,如果在,则无需做任何操作;如果不在,则可以进行合并。合并的具体实现是:将节点数少的集合的代表节点的指针指向节点数多的集合的代表节点(如果两个集合节点数一样,则指向顺序随意)。
2.1.4 并查集优化
并查集中有一个非常重要的优化,优化的就是根据指针指向进行遍历的过程。
如上图,假设在某一个时刻并查集成了该种结构,假设现在调用isInSameSet(a,g)或者union(a,g)(union底层调用的还是isInSameSet)。那么此时,a和g节点就会根据自身的指针指向开始遍历,直到遍历到指针指向自己的节点位置。a指针指向自己,因此无需遍历;g需要根据指针指向依次遍历f、e和b最终才能找到a。
该优化的操作就是在g —> f —> e —> b —> a的过程中,需要将g遍历的路径 "扁平化"。 扁平化的具体操作就是:将遍历路径上的所有节点的指针指向最后的代表节点,也就是让g、f、b和e节点的指针直接指向a。
在没有优化并查集前,性能的瓶颈很明显。如果并查集的某次操作时间复杂度过高,一定是某一个节点遍历寻找代表节点的单向链表过长导致的,这也是并查集唯一一个需要优化的问题。
"扁平化" 操作能够不断压缩单向链表的长度,且不违反原本的结构。
2.1.5 并查集实现
public class unionFind{
public static class Node<V> {
// 样本
public V value;
public Node(V value) {
this.value = value;
}
}
public static class UnionFindSet<V>{
// 样本——Node 对应表
public HashMap<V, Node<V>> nodeMap;
// Node——父Node 对应表
public HashMap<Node<V>, Node<V>> fatherMap;
// 代表Node——对应集合中Node总数 对应表(只有代表节点才会存入该表,且计算总数时包含代表节点)
public HashMap<Node<V>, Integer> sizeMap;
// 并查集能够使用的前提是要有初始化,并且初始化时,要求样本必须全部注册入并查集(传入List)
public UnionFindSet(List<V> list){
nodeMap = new HashMap<>();
fatherMap = new HashMap<>();
sizeMap = new HashMap<>();
for (V sample : samples) {
// 将每一个样本构建成一个Node
Node<V> node = new Node<V>(sample);
// 将样本与Node一一对应
nodeMap.put(sample, node);
// 初始化时先将每个节点的指针指向自身
fatherMap.put(node, node);
// 初始化时每个Node都构成一个集合,每个Node都是代表Node
sizeMap.put(node, 1);
}
}
// 通过Node找到该Node所属集合的代表节点(扁平优化)
private Node<V> findRepresentativeNode(Node<V> node) {
// 使用一个栈辅助优化
Stack<Node<V>> stack = new Stack<>();
// 直到遍历到指针指向自身的代表节点位置
while (fatherMap.get(node) != node) {
// 将沿途所有Node压栈
stack.push(node);
// node赋值为其父节点
node = fatherMap.get(node);
}
// 进行扁平优化
while (!stack.isEmpty()) {
// 将沿途所有Node的指针指向代表节点
fatherMap.put(stack.pop(), node);
}
// 返回代表节点
return node;
}
// 判断a,b两个样本是否在同一个集合中
public boolean isInSameSet(V a, V b) {
// 确保样本a和b都注册
if (nodeMap.containsKey(a) && nodeMap.containsKey(b)) {
// 判断两个样本对应Node的代表Node是否是同一个
return findRepresentativeNode(nodeMap.get(a)) == findRepresentativeNode(nodeMap.get(b));
}
return false;
}
public void union(V a, V b) {
// 确保样本a和b都注册
if (nodeMap.containsKey(a) && nodeMap.containsKey(b)) {
// 取出他们的代表节点
Node<V> aRNode = findRepresentativeNode(nodeMap.get(a));
Node<V> bRNode = findRepresentativeNode(nodeMap.get(b));
// 如果代表节点不是同一个,则进行集合合并
if (aRNode != bRNode) {
// 判断哪一个集合的节点数多
Node<V> moreNode = sizeMap.get(aRNode) > sizeMap.get(bRNode) ? aRNode : bRNode;
Node<V> lessNode = moreNode == aRNode ? bRNode : aRNode;
// 将集合节点数少的代表节点指向集合节点数多的代表节点
fatherMap.put(lessNode, moreNode);
// 更新合并后集合的节点数
sizeMap.put(moreNode, sizeMap.get(lessNode) + sizeMap.get(moreNode));
// 集合节点数少的代表节点不再是代表节点
sizeMap.remove(lessNode);
}
}
}
}
}
那么如何评估为什么 "扁平化" 的优化能够让并查集操作的时间复杂度变得很低? 证明太复杂,并查集从1964年被发明出来后直到1989年才证明完毕,整整证了25年。要想了解详细证明,请看《算法导论》23章。
我们只说结论:如果有N个样本,当findRepresentativeNode的调用次数已经逼近了O(N)的水平或者再往上的水平之后,单次findRepresentativeNode的平均代价O(1)。也就是说,当样本很大,且findRepresentativeNode的调用次数为有限次,那么可以保证单次调用findRepresentativeNode的复杂度很低;但是如果findRepresentativeNode的调用非常频繁,频繁到逼近样本数,那么调用的越频繁,单次调用findRepresentativeNode的复杂度越低,达到O(1),且常数非常小不超过6。
2.2 岛问题 -- 200 number of islands (medium)
2.2.1 经典解决
经典问题很好解决,首先定义一个感染过程infect,然后遍历矩阵。在遍历过程中只要遇到1,就调用infect将该1上下左右能连的1全部连起来了并且全部置2。直到矩阵遍历结束,此时调用几次infect就有几个岛。 如果使用题目所给矩阵,infect的递归调用树如下: R 代表return
整个过程时间复杂度为:O(NM),其中N为矩阵的宽,M为矩阵的长。 该算法虽然有递归过程,但是时间复杂度并不高,只是一个矩阵的规模。 具体时间复杂度如何估算,我们看矩阵中每一个位置会被访问几次。在整体遍历阶段,每个位置被访问一次。在infect阶段,每个位置最多被它的上下左右各访问了一次。总体来看,每个位置最多会被调用5次,是一个有限次数。因此时间复杂度为O(5NM),取出常数项为O(NM)。
public static int islandProblem(int[][] matrix) {
if (matrix == null || matrix.length == 0) {
return 0;
}
// 调用infect的次数
int count = 0;
for (int i = 0; i < matrix.length; i ++) {
for (int j = 0; j < matrix[i].length; j ++) {
if (matrix[i][j] == 1) {
// 进行感染过程
infect(matrix, i, j);
// 感染次数自增
count ++;
}
}
}
return count;
}
public static void infect(int[][] matrix, int i, int j) {
// Base case 边界过滤器
if (
i < 0 || i >= matrix.length
|| j < 0 || j >= matrix[i].length
|| matrix[i][j] != 1) {
return ;
}
// 将自身置2
matrix[i][j] = 2;
// 将与该位置上下左右相连的一片1全部置2
infect(matrix, i - 1, j);
infect(matrix, i + 1, j);
infect(matrix, i, j - 1);
infect(matrix, i, j + 1);
}
2.2.2 并行算法解决
面试、ACM中遇到大部分题目的默认环境都是单CPU、单内存系统,只关注于算法的逻辑。但是在面试过程中,也会遇到并行算法的题目,这种题目不需要代码实现,只需要阐述清楚过程即可。
明明经典解法的时间复杂度已经控制的非常好了,为什么还要设计一个并行算法来解决该问题?
假设矩阵非常大,如果使用经典解法,只能使用一台机器来解决,这样效率特别慢。如果使用并行算法,那么我们需要给矩阵设计一个分片和合并的策略,这样可以让多台机器并行计算矩阵中岛的数量,效率几何倍提高。
在设计并行算法之前,需要了解并查集这个非常重要的结构,请先看上文。 假设我们先讨论使用两个CPU并行计算加速的情况:
如果我们将矩阵划分给不同CPU进行计算,那么岛的数量可能会上升。例如如下矩阵,我们发现原矩阵中所有的1都是连成一片,因此只有一个岛。如果我们将该矩阵从中间划分成两部分,CPU1去计算左半边矩阵中岛的数量,CPU2去计算右半边矩阵中岛的数量,那么最终CPU1会统计出2个岛,CPU2也会统计出2个岛,总岛数是4。
事实上是只有一个岛的,但是为什么划分矩阵之后会出现多个岛的情况呢?
因为划分的过程破坏了原本的连通性。因此我们还需要设计一个合并矩阵的方案,从而能够计算出正确岛的数量。
合并矩阵的方案是:
在CPU1计算出左半边矩阵中岛的数量后,利用infect过程去收集边界上的感染点的信息(边界指的是划分出来的边界)。在CPU2计算出右半边矩阵中岛的数量后,也利用infect过程去收集边界上的感染点的信息。
如何收集感染点的信息呢?首先需要CPU1记录左半边矩阵中第一个感染初始点A,然后记录所有被A感染的边界点。然后CPU1再记录左半边矩阵中第二个感染初始点B,然后记录所有被B感染的边界点。CPU2记录右半边矩阵中第一个感染初始点C,然后记录所有被C感染的边界点(C点自身也是被感染的边界点)。CPU2再记录右半边矩阵中第二个感染初始点D,然后记录所有被D感染的边界点(D点自身也是被感染的边界点)。
将A、B、C和D注册入并查集并进行初始化。开始各自为一个集合{ A }、{ B }、{ C } 和 { D }。然后看边界相碰的两个点是否是属于同一个集合。例如上图所圈出的里两个点,左边点是B感染的边界点,右边点是C感染的边界点 ,B和C不属于同一个集合,因此B和C的集合需要合并为{ B,C },然后总岛数减一(因为两个边界相碰的点在原矩阵中是连通的)。如果B和C属于同一个集合,说明之前讨论过的联通路径已经包含了此时边界相碰的两个点,总岛数不变。
按照上述操作,将所有边界相碰的情况讨论完,被两个CPU划分的矩阵就合并成功了。此时的总岛数就是原矩阵的岛数。
为什么这种方法快?
经典解决方案是一个CPU从左到右、从上到下遍历矩阵得到结果;现在是使用CPU1和CPU2同时遍历一半矩阵,最终再使用一个CPU3处理一下边界碰撞的点即可。
如果在多个CPU一起并行处理的情况下,我们就可以将矩阵划分成若干块,然后每个CPU先处理自己块的信息,再处理一下边界信息即可(边界信息的处理方式和2个CPU的方式相同)。例如下图中CPU1在处理完自身信息后,还需要处理四条边的边界信息。
2.2.3 总结
扩展问题的解决方案贯彻了MapReduce的思想,MapReduce的整体思想就是将一个算法改成并行计算并能够整合正确结果的过程。其中,Map就是并行处理一个整体的不同块,Reduce就是将不同块的信息整合。 本题的解决方案比经典的Hadoop,Spark中Reduce的方案高级很多,是一种非常强的算法设计。
3. KMP算法
3.1 引入
KMP算法中最著名的应用就是 "求子串问题"。
3.1.1 题目
现在有str1="abcd1234efg" 和str2="1234",如何判断str2是不是str1的子串?
注意,子串必须是连续一段,比如 "1234f" 就不是 "abcd1234efg" 的子串,但是 "1234e" 是。
3.2 暴力解
尝试方法是从左往右尝试str1中每一个字符是否能够配出str2。
暴力解法在一种极端的情况下,时间复杂度会非常高。比如:str1="1111111112",str2="11112"。如果要用暴力解法,则str1从第一个字符1向后配了5次发现配不上,再从第二个字符1向后又配了5次发现配不上,以此类推,直到str1最后一段才配上。如果str1长为N,str2长为M,则时间复杂度为O(NM),相当于str1每走一步就需要遍历整个str2。
如果使用KMP算法解决该问题,则返回类型不是boolean,而是返回子串在str1中第一个字符的下标,如果不包含返回-1。
KMP和暴力解法核心思想是一样的,都是从左往右尝试str1中每一个字符是否能够配出str2,但是KMP有加速。
public static boolean findSubStr(String str1, String str2) {
if (str1 == null || str2 == null || str1.length < str2.length) {
return false;
}
return process(str1.toCharArray(), str2.toCharArray());
}
public static boolean process(char[] c1, char[] c2) {
// 标记默认为false,这样如果str1和str2一个相交字符都没有也会返回false
boolean flag = false;
// 遍历整个str1
for (int i = 0; i < c1.length; i ++) {
// 尝试str1中每一个字符和str2相匹配
if (c1[i] == c2[0]) {
// 只要有一个字符匹配上,我们就期望这次匹配是成功的
flag = true;
// str1和str2挨个字符进行匹配
int k = i + 1;
for (int j = 1; j < c2.length; j ++, k ++) {
// 在匹配过程只要有一个字符不一样匹配就失败,直接终止匹配
if (c1[k] != c2[j]) {
flag = false;
break;
}
}
}
}
return flag;
}
3.3 最大匹配长度
在将KMP算法解决 "求子串问题",我们首先要理解一个概念,就是最长前缀和后缀的匹配长度。
最长前缀和后缀的匹配长度是子串(str2)中每一个字符都需要携带的信息,该信息与对应的字符无关,但是与对应字符的前面所有字符有关。
比如说有一个字符串 "abbabbk",我们想要知道k字符的最长前缀后后缀的匹配长度。 首先我们需要获取k字符前面的字符串 "abbabb",然后求出 "abbabb" 字符串前缀和后缀的最大匹配长度为3,k字符携带的信息就是3。
前缀和后缀都不能取到字符串整体。例:"aaaaak" k的最大匹配长度就是4,因为不能取到字符串整体
如果该字符前面没有字符串,则该字符携带的信息就是 -1。人为规定字符串0位置的字符携带的是 -1,1位置的字符携带的是0。
对Str2 求每个位置的最大匹配长度。例:“aabaabs”
| a | a | b | a | a | b | s |
|---|---|---|---|---|---|---|
| -1 | 0 | 1 | 0 | 1 | 2 | 3 |
3.4 KMP加速过程
使用KMP加速该问题的关键是:str2中每一个字符要携带前缀和后缀的最大匹配长度。 假设str1="......oabbcefabbckbc......",str2="abbcefabbcg......"。str1中显示的首字符o是第 i - 1 位,假设当前str1从第 i 位开始开始匹配str2(前面匹配过程忽略)。
str1的第 i 位之后的字符依次和str2进行字符匹配,在str1匹配到第 i + 10 位时,发现和str2第 10 位的字符匹配不上,从第 i 位开始的匹配操作失败。因此,str1就会放弃第 i 位能匹配子串成功的可能性,匹配指针会从str1的第 i + 10 位跳到第 i + 1位,第 i + 1之后的字符依次再和str2第 0 位字符进行字符匹配(相当于str2向右滑动一位)。以此类推。
KMP
统计str2中每个字符的前后缀最大匹配长度: [ -1,0,0,0,0,0,0,1,2,3,4 ...... ]。
str1的第 i 位之后的字符依次和str2进行字符匹配,在str1匹配到第 i + 10 位时,发现和str2第 10 位的字符匹配不上,从第 i 位开始的匹配操作失败。
根据统计的前后缀最大匹配长度可知,其中前 11 位中最大匹配长度为第 10 位字符对应的4,因此可以确定在str2第 10 位字符g之前最大前缀和最大后缀是 "abbc"。 str1的匹配指针不动,继续和str2的第 4 位(最大前缀的后一位)进行字符匹配。
要想理解KMP的第二步操作到底什么意思?通过查看两次的比较对象就可以得知。
和经典方法一样,在str1第 i 位开始的匹配操作失败之后,str1也不会再去尝试第 i + 1 位能够匹配成功的可能性,但是str1不会去尝试第 i + 2 位能够匹配成功的可能性,而是直接尝试第 i + 6 位能够匹配成功的可能性(相当于str2向右滑动(最大后缀中第一位的下标 - 最大前缀第一位的下标)位),也就是说str1会直接从str1中的str2的最大后缀开始匹配。 但是str1第 i + 6 位和str2第 0 位开始往后4位在上一轮匹配中就确定是重合的了,都是最大前 / 后缀的内容为 "abbc"。为了加速效果更好(常数加速),在实际比较时可以直接跳过重合部分进行开始比较。 因此str1并没有从第 i + 6开始尝试匹配,而是直接从第 i + 11位开始匹配,因为str1的第 i + 6 位到第 i + 9 位和str2的第 0 位到第 3 位是重合的,只有到str1的第 i + 10 位之后才可能会出现匹配失败的情况。 那么现在需要证明一点:为什么从str1的第 i + 1 位到第 i + 5 位开始匹配就一定会匹配失败?
3.5 证明KMP算法正确性
为什么从str1的第 i + 1 位到第 i + 5 位开始匹配就一定会匹配失败?
还是和str2统计的第 0 位到第 10 位字符每个字符的前后缀最大匹配长度这个数组 [ -1,0,0,0,0,0,0,1,2,3,4 ...... ] 有关。
将上述证明问题抽象:证明str1的第 i 位到第 j 位 [ i+1,j-1 ] 中间任何一位 k 开始匹配都一定会失败。
该证明有一个前提条件:从str1的第 i 位到第 (X - 1) 位这一段和从str2的第 0 位到第 (Y - 1) 位这一段完全相等。
使用反证法,假设str1的第 k 位开始匹配,结果匹配成功。
已知,str1无论是从哪一位开始匹配都是从str2的第 0 位开始匹配的,如果匹配成功,意味着从str1的第 k 位开始一直到第 X 位这一段一定和从str2第 0 位开始到第 (X - k) 位这一段完全一致。
因此,从str1的第 k 位开始一直到第 (X -1) 位这一段一定和从str2的第 0 位开始到第 (X - k - 1) 位这一段完全一致。
又因为上一轮顺位匹配时只有str1第 X 位和str2第 Y 位不同,因此从str1的第 k 位到第 (X -1)位这一段一定和从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段一定完全一致。
由等价交换,从str2的第 0 位开始到第 (X - k - 1) 位这一段和从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段一定完全一致。
因为str1的第 k 位小于第 j 位,因此从str1的第 k 位到第 X 位这一段一定比从第 j 位到第 X 位这一段要大。
因此对应的从str2的第 0 位开始到第 (X - k - 1) 位这一段一定比原第 Y 位的最长前缀要长。
因为从str2的第 0 位开始到第 (X - k - 1) 位这一段和从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段等长。
因此对应的从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段一定比原第 Y 位的最长后缀要长。
又因为从str2的第 0 位开始到第 (X - k - 1) 位这一段也相当于是第 Y 位的前缀,从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段也相当于是第 Y 位的后缀。
所以原str2的第 Y 位出现了一个比最长前缀和后缀更长的前缀和后缀,因为已经给出了最长前缀和后缀,所以相互矛盾,假设不成立。
3.6 KMP完整流程
实际上KMP的整个流程就是str2一直右移的过程。
我们抛开KMP常数优化的过程,仔细分析一下KMP加速的本质流程。
KMP为什么很快?拿str1中a~e举例子,使用经典方法需要比较17次,而使用KMP只需要比较4次,如果加上KMP的常数加速,那么在4次比较中将会更快。
其中,①、②、③和④是4次匹配开始时str1和str2的比较位置,实际上代表了KMP对str1中a~e这一段完整的加速流程。
在匹配指针指向str1的①位置开始匹配时,直到发现 e 和 w 匹配不上,因此①位置匹配失败。
然后找出str2中 w 的最长前后缀为:abbsabb。匹配指针指向②位置开始匹配,直到发现 e 和 t 匹配不上,因此②位置匹配失败。
然后找出str2中 t 的最长前后缀为:abb。匹配指针指向③位置开始匹配,直到发现 e 和 s 匹配不上,因此③位置匹配失败。
然后找出str2中 s 的最长前后缀为:无。匹配指针指向④位置开始匹配,发现 e 和 a 匹配不上,因此④位置匹配失败。
str1中a~e已经全部和str2匹配完,任何一个位置开始匹配都匹配不出str2,因此匹配指针指向 e 的后面一位⑤开始匹配,继续循环执行①位置的操作,周而复始,直到str1最后一位结束。
// 返回值是子串在str1中的起始下标
class Solution {
public static int strStr(String str1, String str2) {
if (str1 == null || str2 == null || str1.length() < str2.length()) {
return -1;
}
return process(str1.toCharArray(), str2.toCharArray());
}
public static int process(char[] str1, char[] str2) {
// i1是str1的匹配指针,i2是str2的匹配指针
int i1 = 0;
int i2 = 0;
// 获取str2所有字符的最长前缀和后缀
int[] next = getMaximalPrefixAndSuffix(str2);
// 匹配过程只要i1越界或者i2越界匹配都会终止(i1和i2也可能同时越界)
while (i1 < str1.length && i2 < str2.length) {
// KMP加速过程中只有三种情况
if (str1[i1] == str2[i2]) {
// 对应位置一样,str1和str2并行向后继续匹配
i1 ++;
i2 ++; // 只有匹配字符相等时i2才会往后走
} else if (i2 == 0) { // next[i2] == -1
// 对应位置不一样,但是str2的匹配指针在0位置,说明i2跳到0位置了,意味着str1前面一整段没有位置能和str2匹配成功
// str1匹配指针向后移一位开始下一段与str2的匹配
i1 ++;
} else {
// 对应位置不一样,且str2的匹配指针不在0位置,此时i2需要跳到最长前缀的下一位,进行下一次比较
// i2跳的这个过程就是KMP常数加速的操作
i2 = next[i2];
}
}
// 如果i2越界,那么说明str1中匹配成功了str2,那么就返回str1中子串的首位
// 如果i1越界,那么说明str1中没有任何一位能够与str2匹配成功,返回-1
return i2 == str2.length ? i1 - i2 : -1;
}
// 统计最大前后缀的本质就是确定str2每一位的最大前缀,预估的最大前缀在没有匹配成功时每一轮都会缩小,直到收缩到0,则表示该位置没有最大前缀
public static int[] getMaximalPrefixAndSuffix(char[] str2) {
// 如果str2中只有一个字符,那么一定是next[0]
if (str2.length == 1) {
return new int[] {-1};
}
// 如果不止一个字符,那么手动给next[0]和next[1]赋值
int[] next = new int[str2.length];
next[0] = -1;
next[1] = 0;
// str2的游标,从str2[2]后开始给next填值
int i = 2;
// prefix是c[i]目前最有可能的最长前缀的后一位的下标
//prefix 跟i-1的位置比较
int prefix = 0;
// 当i越界时表示next已经全部填满
while (i < str2.length) {
if (str2[i - 1] == str2[prefix]) {
// str2[i]的前一位和str2[i]当前最有可能的最长前缀的后一位的下标相同,说明最长前缀还能延长,需要包含str2[prefix]
// 同时当前第i位匹配成功
next[i] = prefix + 1;
i++;
prefix++;
} else if (prefix > 0) {
// 如果str2[i]的前一位和str2[i]当前最有可能的最长前缀的后一位的下标不相同,说明最长前缀必须缩小,prefix需要向前跳
// prefix需要跳到c[prefix]最长前缀的后一位
// 当前第i位匹配失败,下一轮继续匹配第i位
prefix = next[prefix];
} else {
// 当prefix跳到第0位时,还和第i位匹配不上,说明str2[i]没有最长前缀,置为0
// 同时当前第i位匹配成功
next[i] = 0;
i++;
}
}
return next;
}
}
精简版(🈚️注释)
class Solution {
public static int strStr(String str1, String str2) {
if (str1 == null || str2 == null || str1.length() < str2.length()) {
return -1;
}
return process(str1.toCharArray(), str2.toCharArray());
}
public static int process(char[] str1, char[] str2) {
int i1 = 0;
int i2 = 0;
int[] next = getMaximalPrefixAndSuffix(str2);
while (i1 < str1.length && i2 < str2.length) {
if (str1[i1] == str2[i2]) {
i1 ++;
i2 ++;
} else if (i2 == 0) {
i1 ++;
} else {
i2 = next[i2];
}
}
return i2 == str2.length ? i1 - i2 : -1;
}
public static int[] getMaximalPrefixAndSuffix(char[] str2) {
if (str2.length == 1) {
return new int[] {-1};
}
int[] next = new int[str2.length];
next[0] = -1;
next[1] = 0;
int i = 2;
int prefix = 0;
while (i < str2.length) {
if (str2[i - 1] == str2[prefix]) {
next[i] = prefix + 1;
i++;
prefix++;
} else if (prefix > 0) {
prefix = next[prefix];
} else {
next[i] = 0;
i++;
}
}
return next;
}
}
3.7 复杂度证明
3.7.1 process 中while 的复杂度
while (i1 < c1.length && i2 < c2.length) {
if (c1[i1] == c2[i2]) {
i1 ++;
i2 ++;
} else if (i2 == 0) {
i1 ++;
} else {
i2 = next[i2];
}
}
通过观察代码,我们可以发现第一个分支中 i1 和 i2 都增大;第二个分支中 i1 增大;第三个分支中 i2 减小。
估计while的复杂度时,我们需要假设两个量,第一个量是 i1,第二个量是 i1 - i2。 假设str1长度为N,那么 i1 和 i1 - i2 的最大值都是N。
我们要看循环中的三个分支分别对这两个量的影响。
- 循环的第一个分支,i1 和 i2 一起增加。因此 i1 增加,i1 - i2 不变。
- 循环的第二个分支,i1 增加。因此 i1 增加,i1 - i2 增加。
- 循环的第三个分支,i2 减少。因此 i1 不变,i1 - i2 增加。 每一次循环只会走一个分支,因此将这两个量的变化范围叠加起来,最大的幅度就是2N(走第二个分支,且都是N)。 三个分支都不会让两个量中任何一个量减少,因此循环发生的次数就和两个量变化范围的叠加绑定在了一起,两个量变化的幅度就是while循环的次数,所以整个while循环的次数不会超过2N。 因此可以证明while的时间复杂度是线性的,为O(N)。
3.7.2 getMaximalPrefixAndSuffix方法的复杂度
public static int[] getMaximalPrefixAndSuffix(char[] str2) {
if (str2.length == 1) {
return new int[] {-1};
}
int[] next = new int[str2.length];
next[0] = -1;
next[1] = 0;
int i = 2;
int prefix = 0;
while (i < str2.length) {
if (str2[i - 1] == str2[prefix]) {
next[i ++] = ++ prefix;
} else if (prefix > 0) {
prefix = next[prefix];
} else {
next[i ++] = 0;
}
}
return next;
}
估计getMaximalPrefixAndSuffix的复杂度时,我们也需要假设两个量,第一个量是 i,第二个量是 i - prefix。
假设str2长度为M,i 的最大值是M,i - prefix的最大值也是M。
- 循环的第一个分支,i 和 prefix 一起增加。因此 i 增加,i - prefix 不变。
- 循环的第二个分支,prefix 减少。因此 i 不变,i - prefix 增加。
- 循环的第三个分支,i 增加。因此 i 增加,i - prefix 增加。
每一次循环只会走一个分支,因此将这两个量的变化范围叠加起来,最大的幅度就是2M(走第三个分支,且都是N)。 因此可以证明getMaximalPrefixAndSuffix的时间复杂度是线性的,为O(M)。
3.7.3 总体时间复杂度
因为M一定小于等于N,所以KMP整体时间复杂度为O(N)。
4. Manacher算法 马拉车
4.1 总结
Manacher算法和KMP算法都是解决字符串相关题目的常见算法原型,但是各自解决的问题却不一样。
Manacher算法一开始是专门用来解决 "字符串中最长回文子串问题" 的,实际上Manacher算法中有一个非常重要的信息,使用它可以解决很多其他问题,这个信息就是:每一个位置的最长回文半径。
4.2 回文
回文通俗的来说就是正着看和反着看内容相同。
回文的定义是存在一个对称轴,左部分是右部分的逆序(右部分是左部分的逆序)。
例如字符串 "abcba" 和 "abba" 都是回文。
4.3 最长回文子串
求最长回文子串实际上就是在一个字符串中,求哪一个子串是回文,并且是最长的。
子串中每一个字符都要求是连续的(如果字符不是连续的则是子序列)。
例如一个字符串 "abc1232de1" 中,最长回文子串是:"232"。
4.4 经典解法: Time O(N^2)
我们可以自己脑补一个方法,比如将字符串中的每一位字符都当作对称轴,然后同时向左右两边开始匹配。这种方法有一个明显的问题,就是如果回文子串的长度是偶数,那么是没有办法检测出来的,因为长度是偶数的回文串实际上对称轴是 "虚" 的,无法实际定位。
假设当前字符串是 "122131221"。如果使用我们原来脑补的方法,则 "1221" 回文子串就不会被检测到。
我们将原来脑补的方法进行改进,在匹配前先对原始字符串进行处理。处理方式是:字符串左右两头加 "#",每两个字符中间加 "#"。处理成:"#1#2#2#1#3#1#2#2#1#"。然后将处理之后的字符串中的每一个字符都当作对称轴,同时向左右两边开始匹配,记录回文子串长度。最后将每个位置统计的回文子串长度除以2(向下取整),对应到原字符串就是以该位置为对称轴的回文子串的长度
使用改进后的方法,无论是奇数个数的回文子串还是偶数个数的回文子串都可以被检测到。
辅助字符是什么都行,不会影响最后的答案。因为无论是以处理后的字符串的哪一个字符作为对称轴向左向右开始匹配,永远都是辅助字符和辅助字符比,真实字符和真实字符比。
public static int plalindrome(String str) {
if (str == null || str.length() == 0) {
return -1;
}
char[] c = str.toCharArray();
char[] newC = new char[c.length * 2 + 1];
newC[0] = '#';
// c的指针
int i = 0;
// newC的指针
int j = 1;
// 使用辅助字符#处理原字符串
while (i < c.length && j < newC.length - 1) {
newC[j ++] = c[i ++];
newC[j ++] = '#';
}
return process(newC);
}
public static int process(char[] str) {
// 回文区域数组
int[] next = new int[str.length];
//构建回文区域数组
for (int i = 0; i < str.length; i ++) {
int j = i - 1;
int k = i + 1;
// 回文区域最少是1,为本身
int count = 1;
// 向两边每个字符进行匹配
while (j >= 0 && k != str.length) {
if (str[j] != str[k]) {
break;
}
j --;
k ++;
count = count + 2;
}
next[i] = count;
}
// 回文区域数组升序排序
Arrays.sort(next);
// 回文区域数组中最大的元素除以2就是最大回文子串长度
return next[next.length - 1] / 2;
}
时间复杂度 我们举一个最坏的例子,原字符串为:"1111111"。经过我们处理后是:"#1#1#1#1#1#1#1#"。
在以处理后的字符串中每一个字符为对称轴同时进行左右匹配时,由于每一次比对都会相等,因此如果当前作为对称轴的字符在左半边,则一定会匹配到最左边界;如果当前作为对称轴的字符在右半边,则一定会匹配到最右边界。
因此,假设原字符串长度为N,那么时间复杂度为:O(N^2)。我们需要对这种经典方法进行优化,从而创造了Manacher算法。
4.5 回文半径和回文直径
- 回文半径:从对称轴开始向左或向右衍生,直到回文区域边界后统计的字符总数。
- 回文直径:从对称轴开始向左向右衍生,直到回文区域边界后统计的字符总数。
4.6 Manacher: Time O(N)
4.6.1 参数设置
- 需要给被辅助字符处理的字符串中的每一个字符计算回文半径,从而构建一个回文半径数组。
- 设置一个变量 R,记录之前匹配回文区域的所有字符中,回文边界达到的最右下标(初值为-1)。R 永远是只增不减的,只要有字符的回文更靠右,下标就会更新给 R。
- 设置一个变量 C,和 R 一起用,记录当前取得最右下标的回文区域的中心点的下标(初值为-1,如果最右下标重合,按照原中心点的下标)。C 也是永远只增不减的,R 更新 C 一定更新,R 不更新 C 一定不更新。
4.6.2 流程
首先需要构建回文半径数组,在构建回文半径数组时,会遇到两种大情况:
4.6.2.1 情况1:当前匹配的字符的位置不在之前匹配的字符的回文区域的最右边界中
该情况无优化,只能从该中心点开始同时向两边暴力扩展匹配,同时计算出该字符的回文半径。
4.6.2.2 情况2:当前匹配的字符的位置在之前匹配的字符的回文区域的最右边界中
如上图当 R=2 时的情况
当第二种大情况出现的时候,一定存在下图表示的通用拓扑结构:
i 为当前匹配的字符的位置,i‘ 是以 C 为对称轴所作的 i 的对称点。C、L 和R 一定都存在。
i 和 C 是不可能重合的,因为 C 表示的是 i 之前字符构建的最长回文子串的中心点。当遍历到 i 位置时,C 一定已经遍历过了。
按照 i’ 的回文区域的状况可以将第二种大情况划分成三种具体的情况,每一种情况都有单独的优化。
4.6.2.2.1 情况2-1:i‘ 的回文区域完全在L~R的内部
此时,i 的回文半径就是 i’ 的回文半径。
在 i 位作与 i‘ 等量的区域 cd。由于整个 LR 是一个以 C 为中心的回文串,因此 ab 和 cd 一定关于 C 对称,从而 cd 一定是 ab 的逆序。而 ab 是回文串,且回文串的逆序也是回文串,因此 cd 最少一定与 ab 等规模。为什么 cd 不能更大?需要证明。
设置 ab 区域前一个字符为 X,后一个字符为 Y;设置 cd 区域前一个字符为 Z,后一个字符为 K。
为什么 i’ 当时没有将自己的回文区域扩的更大?
原因只有一个,就是X != Y。
又因为X和K对称,Y 和 Z 对称,因此 X == K,Y == Z。
所以Z != K,i 的回文区域也无法再扩大。
4.6.2.2.2 情况2-2:i‘ 的回文区域有一部分在 L~R 的外部
L 作 i’ 的对称点 L',R 作 i 的对称点 R'。
L
L‘ 和 RR’ 一定是逆序关系,LL‘ 是回文,因为 LL’ 在 LR的内部,因此 RR’ 一定也是回文,因此 i 的回文区域至少与 LL’ 等规模。为什么 RR’ 不能更大?需要证明。
设置LL‘ 前一个字符为X,LL‘ 后一个字符为Y;RR’ 前一个字符为Z,RR’ 后一个字符为R。
因为 X 和 Y 都在 i‘ 的回文区域中,且关于 i’ 对称,所以X == Y。
又因为 Y 和 Z 都在 C 的回文区域中,且关于 C 对称,所以Y == Z。
为什么当时 C 没有将自己的回文区域扩的更大?
原因只有一个,就是X != K。
所以Z != K,i 的回文区域也无法再扩大。
4.6.2.2.3 情况2-3:i‘ 的回文区域的左边界正好和 L 重合
此时,不能直接得出 i 的回文半径,只能确定回文半径最小就是 iR。能不能更大,需要从 i 位置向外扩展匹配。
但是此时可以设计一个常数加速,因为 iR 是确定的最小回文半径,因此可以直接从 R 开始继续向外扩展匹配。
4.6.3 实现
class Solution {
public boolean checkIfPangram(String sentence) {
}
}
public static int plalindrome(String str) {
if (str == null || str.length() == 0) {
return -1;
}
char[] c = str.toCharArray();
char[] newC = new char[c.length * 2 + 1];
newC[0] = '#';
// c的指针
int i = 0;
// newC的指针
int j = 1;
// 使用辅助字符#处理原字符串
while (i < c.length && j < newC.length - 1) {
newC[j ++] = c[i ++];
newC[j ++] = '#';
}
return process(newC);//1221 -> #1#2#2#1#
}
public static int process(char[] str) {
// 这里的R和定义中有些不同,这里是指i之前字符的回文区域最右边的下标的后一位,也就是说R-1才是回文区域最右边的下标
int R = -1;
// 中心
int C = -1;
// 回文半径数组
int[] next = new int[str.length];
for (int i = 0; i < str.length; i ++) {
// 先确定所有情况中最低回文半径
// 如果i > R,表示第一种大情况,最低回文半径为自身为1
// 如果 i < R,表示第二种大情况:
// 如果是第①种小情况,i'的回文半径会比R-i小,直接可以确定为i'的回文半径
// 如果是第②种小情况,i’的回文半径会比R-i大,直接可以确定为R-i
// 如果是第③种小情况,i’的回文半径和R-i相等,虽然不能确定最终的回文半径,但是可以确定最少就是i'的回文半径或R-i
next[i] = i < R ? Math.min(next[2 * C - i], R - i) : 1;
// 无论是遇到哪一种情况都会尝试着往外扩,因为只有第一种大情况和第二种大情况的第③种小情况需要向外扩,因此如果是第二中大情况的
// 第①种小情况或者第②种小情况虽然也会走向外扩的流程,但是第一次就会失败,从而跳出循环。
// 这是为了省略多个if-else所做的一个统一的流程,优化了代码的长度,并不会影响时间复杂度
while (i + next[i] < str.length && i - next[i] >= 0) {
if (str[i + next[i]] == str[i - next[i]]) {
next[i] ++;
} else {
break;
}
}
// 判断R和C是否需要更新
if (i + next[i] > R) {
// R向右扩
R = i + next[i];
// 此时i就是当前所有字符的回文区域达到最右的区域的中心点
C = i;
}
}
// 给next数组排序,找到最大的回文半径
Arrays.sort(next);
int maxPalindromeRadius = next[str.length - 1];
// 处理串中i位的回文半径长度 - 1 = 原串中以i为对称轴的回文子串长度
return maxPalindromeRadius - 1;
}
4.7 时间复杂度
伪代码
public static int process(char[] str) {
int R = ?;
int C = ?;
int[] next = new int[str.length];
for (int i = 0; i < str.length; i ++) {
if (i在R的外面) {
从i开始向两边暴力扩;R变大
} else { // i在R的里面
if (i'的回文区域完全在L~R中) {
// O(1)的操作
返回i'的回文半径;
} else if (i'的回文区域的左边界在L的左边) {
// O(1)的操作
返回R-i;
} else { // i'的回文区域的左边界与L重合
从R之外的字符开始向外扩,然后确定pArr[i]的答案;
如果第一步扩失败,R不变;否则R变大
}
}
排序获得最长的回文半径处理成原文的回文串长度返回
}
第一种大情况,和第二种大情况的第③种小情况需要向外扩充匹配,因此必定会失败1次。
第二种大情况的第①种小情况和第②种小情况是不需要向外扩充匹配的,因此失败0次。
因此每个位置扩充失败的代价是O(N)。
根据上述伪代码,不看失败,只看成功。假设字符串长度为N,以 i 和 R 作为参考标注:
i 只增不减,R 也是只增不减。
我们将扩的行为和 R 变大绑定到一起,每一次扩,R 都会变大,R变大的总幅度就是扩成功的次数。而扩失败的次数,是可以估计出来一个总的量,就是O(N)。
因此整个时间复杂度为O(N)。
4.8 更多应用
Manacher算法可以解决最大回文子串的问题,但远远不仅于此,通过回文半径数组next的信息可以解决非常多的回文问题。
回文半径数组是Manacher算法的灵魂,所以一定要清楚在每一次使用到Mancher算法时,回文半径数组的求法。
5. Sliding window 滑动窗口
5.1 窗口
如上图,一开始窗口的左边界和右边界都停留在整个数组的最左侧,窗口是空的。
窗口的左边界 L 和右边界 R 只能向右移动,不能向左移动。同时在移动时必须遵循一个原则:左边界 L 一定不能移动到右边界 R 的右边(L 不能超越 R)。 只要不违反原则,L 和 R可以随时向右移动。
当 R 往右移动,表示数组中有若干个元素从窗口的右侧进窗口。 当 L 往右移动,表示数组中有若干个元素从窗口的左侧出窗口。
如果我们将 L 向右移动和 R 向右移动封装成两个接口,让开发人员来调用。这样每一次的调用,就会形成不同的窗口状态。
如果我们需要获得当前窗口状态下的最大值,通常做法是遍历整个窗口,这样代价就不够低。那么能不能通过某一种结构,使用很低的代价就能获得当前窗口状态的最大值或者最小值?
就是窗口内最大值或最小值更新结构。
5.2 窗口最大值更新结构
在任意窗口状态下得到窗口中最大值的结构。
构建一个双端队列,双端队列就是可以从头部进出节点,也可以从尾部进出节点。
双端队列中存储的是数组中元素的下标。为什么不存储元素?是因为下标不仅仅能够表示元素,还能表示元素在数组中的位置,携带的信息更多。
如果是最大值更新结构,那么单调性是从大到小的,也就是需要保证双端队列从头到尾存储的下标对应的元素是从大到小的。
5.2.1 R移动
-
如果双端队列为空,那么 R 新囊括的元素的下标从尾部直接进入双端队列。
-
如果双端队列不为空,则 R 新囊括的元素需要与双端对列尾部的下标所指向的元素进行比较:
- 如果新囊括的元素比双端对列尾部下标所指向的元素小,则直接从尾部进入双端对列。
- 如果新囊括的元素比双端对列尾部下标所指向的元素大或者相等,则将尾部的下标从尾部弹出双端队列,让新囊括的元素与当前尾部下标所指向的元素继续比较。
⚠️:只要从双端队列尾部弹出的下标永远不找回。任何时候双端队列的最大值都是头部存储的元素或者元素代表的值。设计这种规则实际上就是在严格维护双端队列的单调性。
5.2.2 L移动
只需要将 L 新排出的元素对应的下标和双端队列头部的下标进行比较:
- 如果新排出的元素对应的下标和双端队列头部下标一致,则将头部的下标从头部弹出双端队列。
- 如果新排出的元素对应的下标和双端队列头部下标不一致,无需任何操作。 这里匹配的是下标,而不是下标所对应的元素值。
5.3 窗口最小值更新结构
最小值更新结构和最大值更新结构同理。只需要修改单调性,保证双端队列从头到存储的下标对应的元素是从小到大的即可。
5.4 原理
为什么每一个窗口状态的最大值都是当前双端队列头部的值呢? 双端窗口维持的是如果此时不让 R 向右移动而选择让 L 依次向右移动,谁会依次成为最大值这个信息。
假设现在有一个数组 arr = { 6,5,4,3,5,7 },窗口区域是 [ 6,5,4,3 ]。 此时双端队列维持的最大值是 arr[0] = 6。
如果让 R 不动,L 向右移动一位,则 arr[0] 过期,arr[1] 成为了最大值。L 再向右移动一位,则 arr[1] 也过期了,arr[2] 成为了最大值。以此类推。
如果让 L 不动,R 向右移动一位,则 3、2 和 1 都要依次弹出,再让 4 进入。 3、2 和 1 弹出的原因是 arr[4] = arr[1] > arr[2] > arr[3],且 arr[1]、arr[2] 和 arr[3] 又一定比 arr[4] 更早过期,所以 arr[1] 、arr[2] 和 arr[3] 再也没有机会成为最大值了,因此可以直接从双端队列中弹出,让 arr[4] 压入双端队列就足够了。
因此,也可以说双端队列维持的是如果依次过期,谁会依次成为最大值这个信息。
5.5 时间复杂度
当窗口在数组中向右滑动的时候,统计每个元素进出窗口的次数即可。
每一个元素最多进窗口1次,最多出窗口1次。已经出窗口的元素是不会再重新回到窗口的,所以不存在一个元素多次进窗口的情况。 所以当窗口向右滑动的元素是 N 个,双端队列更新的总代价一定是O(N),单次更新的代价就是O(N) / N,因此单次的平均代价就是O(1)。
注意,只是平均代价是O(1),并不代表每一次更新都是O(1)。 比如 arr = { 6,5,4,3,2,1,7 },窗口为 [ 6,5,4,3,2,1 ] 此时 L 动,R 向右移动一位,此时双端队列更新的代价为O(N)。但是 0~5 位置进入窗口双端队列更新代价都是O(1),可以说在某一个时刻,单个元素更新的复杂度可能比较高,但是总的代价平均下来是非常低的。
5.6 实现
public class SlidingWindow {
// 使用LinkedList作为双端队列的基础数据结构
private LinkedList<Integer> queue = new LinkedList<>();
// 窗口左边界
private int left;
// 窗口右边界
private int right;
// 窗口滑动的数组
private int[] arr;
public SlidingWindow(int[] arr) {
this.left = -1;
this.right = -1;
this.arr = arr;
}
// 窗口左边界向右滑动n个单位
public void leftSlide(int n) {
// 如果left和right交相交或者left越界,则滑动失败
if (this.left + n > this.right) {
return ;
}
// left向右滑动n个单位
for (int i = 0; i < n; i ++) {
this.left ++;
// 如果left新排出的下标和队首下标相同,则队首下标出队列
if (left - 1 == queue.getFirst()) {
queue.pollFirst();
}
}
}
// 窗口右边界向右滑动n个单位
public void rightSlide(int n) {
// 如果right越界,则滑动失败
if (this.right + n >= arr.length) {
return ;
}
// right向右滑动n个单位
for (int i = 0; i < n; i ++) {
this.right ++;
// 如果right新囊括的下标对应的元素大于等于队尾下标对应的元素,队尾下标出队列
while (!queue.isEmpty() && arr[right] >= arr[queue.getLast()]) {
queue.pollLast();
}
// right进队列
queue.addLast(this.right);
}
}
// 获取当前窗口内的最大值
public int getMax() {
// 如果当前双向队列为空
if (this.queue.isEmpty()) {
return -1;
}
// 获取对头下标对应的元素
return arr[this.queue.peek()];
}
}
5.7 题目 -- 239. Sliding Window Maximum(hard)
duque -- 双端队列 有一个整型数组 arr 和一个大小为 w 的窗口从数组的最左边滑到最右边,窗口每次向右边滑—个位置。 例如,arr 为 { 4,3,5,4,3,3,6,7 } ,滑动窗口大小为 3 时:
[ 4 3 5 ] 4 3 3 6 7 窗口最大值 = 5
4 [ 3 5 4 ] 3 3 6 7 窗口最大值 = 5
4 3 [ 5 4 3 ] 3 6 7 窗口最大值 = 5
4 3 5 [ 4 3 3 ] 6 7 窗口最大值 = 4
4 3 5 4 [ 3 3 6 ] 7 窗口最大值 = 6
4 3 5 4 3 [ 3 6 7 ] 窗口最大值 = 7
如果数组长度为 n,窗口大小为 w,则一共产生 n-w+1 个窗口的最大值。 请实现一个函数,收集每一个窗口状态下的最大值。 输入:整型数组 arr,窗口大小 w 输出:一个长度为 n-w+1 的整形数组,记录每一个窗口状态下的最大值,如上述 [ 5,5,5,4,6,7 ]。
class Solution {
public int[] maxSlidingWindow(int[] arr, int w) {
if (arr == null || w < 1 ||arr.length < w){
return null;
}
//下标 值从大到小
LinkedList<Integer> qmax = new LinkedList<Integer>();//deque双端队列
int[] res = new int[arr.length - w + 1];
int index = 0;
for(int i = 0; i < arr.length; i++){//窗口R进
//i -> arr[i]
while(!qmax.isEmpty() && arr[qmax.peekLast()] <= arr[i]){
qmax.pollLast();//一直弹,弹到一个数可以进来
}
qmax.addLast(i);//每一步固定让一个过期
if (qmax.peekFirst() == i - w){//i-w 过期的下标
qmax.pollFirst();//过期就弹出
}
if(i >= w - 1){//窗口形成
res[index++] = arr[qmax.peekFirst()];
}
}
return res;
}
}
6. Monotonic Stack 单调栈
6.1 问题
单调栈解决的是这样一个问题,比如说给一个数组arr = { 5,4,6,7,2,3,0,1 },我想知道每一个元素左边比该元素大且离得最近的元素和右边比该元素大且离得最近的元素都是什么。
如果数组有 N 个元素,经典解法就是来到 i 位置,左边遍历直到比 arr[i] 大的元素为止,右边遍历直到比 arr[i] 大的元素为止。确定一个位置的时间复杂度为O(N),确定 N 个位置的时间复杂度就是O(N^2)。
能不能将确定 N 个位置的时间复杂度降到O(N)?单调栈结构
同样,如果使用单调栈能够找到每一个元素左边和右边比该元素大且离得最近的元素,同样也能找到每个元素左边和右边比该元素小且离得最近的元素。
6.2 流程
6.2.1 无重复元素
单调栈本身是支持数组中有重复值的,但是我们为了讲清原理,举得例子中数组是没有重复值的。
首先,准备一个栈。 栈中存储的是数组中元素的下标。为什么不存储元素?是因为下标不仅仅能够表示元素,还能表示元素在数组中的位置,携带的信息更多。
如果要找到数组中每一个元素左右两边比该元素大且离得最近的元素,那么单调栈要保证从栈底到栈顶存储的下标对应的元素是从大到小的。 如果要找到数组中每一个元素左右两边比该元素小且离得最近的元素,那么单调栈要保证从栈底到栈顶存储的下标对应的元素是从小到大的。
本案例只找比该元素大且离得最近的元素。
从头开始遍历数组:
-
如果栈中没有元素,直接将元素的下标压栈。
-
如果栈中有元素,当前元素和栈顶的下标所指向的元素进行比较:
- 当前元素比栈顶的下标所指向的元素小,将当前元素的下标压栈。
- 当前元素比栈顶的下标所指向的元素大,栈顶的下标弹栈,同时记录原栈顶下标对应的元素的信息。原栈顶下标对应的元素左边比该元素大且离得最近的元素就是在栈中原栈顶下标压在下面的相邻下标对应的元素;原栈顶下标对应的元素右边比该元素大且离得最近的元素就是让它的下标弹栈的下标对应的元素。记录完之后,当前元素继续和新栈顶下标对应的元素进行比较。如果栈中只有一个下标,则该下标左边没有比该下标对应的元素大且离得最近的元素,右边正常。
当数组遍历完后,如果栈中还有下标,则进入清算阶段:
- 如果不是最后一个下标,依次弹出栈顶下标,原栈顶下标对应的元素左边比该元素大的且离得最近的元素就是在栈中原栈顶下标压在下面的相邻下标;原栈顶下标对应的元素右边没有比该元素大的且离得最近的元素。
- 是最后一个下标,弹出该下标,该下标对应的元素没有左边比该元素大的且离得最近的元素,也没有右边没有比该元素大的且离得最近的元素。
设计这种规则实际上就是在严格维护单调栈的单调性。
6.2.2 有重复元素
设数组中有重复值,那么单调栈中存储的元素就不能只是一个下标了,可能会存储多个下标,这多个下标对应的数组中的值是一样的。 因此在实现上,我们偏向去使用一个链表来作为单调栈的元素类型,同一个链表中所有下标指向的元素值是一样的。 这种结构可以处理有重复值的数组,也可以处理无重复值的数组,是万能的。
流程上和无重复的大致相同,区别在于:
- 当前元素比栈顶的下标链表所指向的元素大,栈顶的下标链表弹栈,同时记录原栈顶下标链表中每一个下标对应的元素的信息。原栈顶下标链表中每一个下标对应的元素左边比该元素大且离得最近的元素都是在栈中原栈顶下标链表压在下边的相邻下标链表的最后一个下标对应的元素;原栈顶下标链表中每一个下标对应的元素有右边比该元素大且离得最近的元素就是让它的下标链表弹栈的下标链表中的下标对应的元素(此时下标链表中只会有一个元素)。如果栈中只有一个下标链表,则该链表中所有下标左边没有比该下标对应的元素大且离得最近的元素,右边正常。
- 当前元素与栈顶的下标链表所指向的元素相等,将该元素对应的下标连接到栈顶的下标链表的末尾。
6.3 时间复杂度 O(N)
为什么说使用单调栈可以将时间复杂度降低至O(N)?
假设有数组中有 N 个元素,在我们计算出了所有元素的左右边比该元素大或者小且离得最近的元素的整个过程中,无论是使用有重复的模型还是无重复的模型,每一个元素都只进栈一次,出栈一次。
6.4 单调栈的正确性
6.4.1 无重复元素
为什么数组中没有重复值,单调栈可以做到以O(N)的代价找到每个元素左右边比该元素大且离得最近的元素?
假设当前有一个单调栈,栈中有 a 和 b。 现在 c 要压栈,已知 arr[c] > arr[b],因此 b 需要先弹出栈,在 b 弹栈时记录 b 的相关信息。
为什么 arr[b] 左边比 arr[b] 大且离得最近的元素一定是 arr[a] ?
为什么 arr[b] 右边比 arr[b] 大且离得最近的元素一定是 arr[c] ?
证明:arr[b] 右边比 arr[b] 大且离得最近的元素是 arr[c]。 因为是从左往右依次遍历数组的,b 比 c 先进了栈,表示 arr[b] 比 arr[c] 先遍历到,因此 arr[c] 一定在 arr[b] 的右边。 那么我们看 bc 之间,有没有可能有一个 k 使得 arr[k] > arr[b] ?
不可能,如果在 bc 之间有一个 arr[k] > arr[b],那么肯定先遍历到 k,当遍历到 k 的时候,为了保证栈的单调性,一定会将 b 弹栈,根本轮不到 c 来让 b 弹栈。
证明:arr[b] 左边比 arr[b] 大且离得最近的元素是 arr[a]。 因为是从左往右依次遍历数组的,a 比 b 先进了栈,表示 arr[a] 比 arr[b] 先遍历到,因此 arr[a] 一定在 arr[b] 的左边。 那么我们看 a ~ b 之间,有没有可能有一个 k 使得 arr[k] > arr[b]?
此时我们知道 arr[a] > arr[b],arr[k] > arr[b],因此我们需要讨论一下 arr[a] 和 arr[k] 的关系:
如果 arr[k] > arr[a],因为 k 一定比 a 后遍历到,因此在遍历到 k 时一定会让 a 弹栈,等遍历到 b 让 b 压栈时根本碰不到 a,因此这种情况不可能。
如果 arr[k] < arr[a],因为 k 一定比 a 后遍历到,因此在遍历到 k 时 k 一定会压在 a 的上面。因为 arr[k] > arr[b],且 b 比 k 后遍历到,因此等遍历到 b 时,b 压栈时一定会和 a 之间隔一个 k,a 和 b 根本不会相邻,因此这种情况也不可能。
证明的目的就是要说明:使用单调栈这种结构来解决这种问题,每一步的流程和数据都是正确的。
6.4.2 有重复元素
为什么数组中有重复值,单调栈也可以做到以O(N)的代价找到每个元素左右边比该元素大且离得最近的元素?
假设当前有一个单调栈,栈中有 a、b、c、d 和 e。 现在 e 要压栈,已知 arr[e] > arr[d],因此 d 需要先弹出栈,在 d 弹栈时记录 b 的相关信息。
为什么 arr[d] 左边比 arr[d] 大且离得最近的元素一定是 arr[c] ?
为什么 arr[d] 右边比 arr[d] 大且离得最近的元素一定是 arr[e] ?
证明:arr[d] 右边比 arr[d] 大且离得最近的元素是 arr[e]。
与无重复同理。
证明:arr[d] 左边比 arr[d] 大且离得最近的元素是 arr[c]。
因为是从左往右依次遍历数组的,c 比 d 先进了栈,表示 arr[c] 比 arr[d] 先遍历到,因此 arr[c] 一定在 arr[d] 的左边。 那么我们看 c ~ d 之间,有没有可能有一个 k 使得 arr[k] > arr[d]?
此时我们知道 arr[c] > arr[d],arr[k] > arr[d],因此我们需要讨论一下 arr[c] 和 arr[k] 的关系:
如果 arr[k] > arr[c],因为 k 一定比 c 后遍历到,因此在遍历到 k 时一定会让 c 弹栈,等遍历到 d 让 d 压栈时根本碰不到 c,因此这种情况不可能。
如果 arr[k] == arr[c],因为 k 一定比 c 后遍历到,因此在遍历到 k 时一定会让 k 连接到 c 后面,等遍历到 e 让 d 弹栈时,d 就会对 k 收集左边的信息,而不是 c,因此这种情况不可能。
如果 arr[k] < arr[c],因为 k 一定比 c 后遍历到,因此在遍历到 k 时 k 一定会压在 c 的上面。因为 arr[k] > arr[d],且 d 比 k 后遍历到,因此等遍历到 d 时,d 压栈时一定会和 c 的下标链表之间隔一个 k,d 和 c 根本不会相邻,因此这种情况也不可能。
6.5 实现
下面代码实现的是支持有重复,能找到arr[i]左右两边比arr[i]小且离得最近的元素的单调栈。
public class MonotoneStack {
private Stack<LinkedList<Integer>> stack = new Stack<>();
private int[] leftRecord;
private int[] rightRecord;
public MonotoneStack(int[] arr) {
this.leftRecord = new int[arr.length];
this.rightRecord = new int[arr.length];
record(arr);
}
// 构建记录
public void record(int[] arr) {
for (int i = 0; i < arr.length; i ++) {
// 如果栈空或者arr[i]大于栈顶下标链表对应的元素的值,直接压栈
if (stack.isEmpty() || arr[i] > arr[stack.peek().getLast()]) {
LinkedList<Integer> list = new LinkedList<>();
list.addFirst(i);
stack.push(list);
}
// 如果arr[i]等于栈顶下标链表对应的元素的值,连接到栈顶下标链表的末尾
else if (arr[i] == arr[stack.peek().getLast()]) {
stack.peek().addLast(i);
}
// 如果arr[i]小于栈顶下标链表对应的元素的值,栈顶下标链表弹栈
else {
// 直到arr[i]大于等于栈顶下标链表对应的元素值为止
while (arr[i] < arr[stack.peek().getLast()]) {
LinkedList<Integer> list = stack.pop();
// 判断是否是栈底下标链表
if (stack.isEmpty()) {
for (Integer index : list) {
leftRecord[index] = index;
rightRecord[index] = I;
}
break;
}
// 遍历链表
for (Integer index : list) {
// 左边比arr[i]小且离得最近的是原栈顶下标链表下面链表的最后一个下标
leftRecord[index] = stack.peek().getLast();
// 右边比arr[i]小且离得最近的是原栈顶下标链表弹栈的下标
rightRecord[index] = I;
}
}
// 如果栈空或者arr[i]大于栈顶下标链表对应的元素
if (stack.isEmpty() || arr[i] > arr[stack.peek().getLast()]) {
// 压栈
LinkedList<Integer> list = new LinkedList<>();
list.addFirst(i);
stack.push(list);
}
// arr[i]等于栈顶下标链表对应的元素
else {
// 追加栈顶下标链表
stack.peek().addLast(i);
}
}
}
// 清算阶段
while (!stack.isEmpty()) {
LinkedList<Integer> list = stack.pop();
// 弹出的是否是栈底下标链表
if (stack.isEmpty()) {
for (Integer index : list) {
leftRecord[index] = index;
rightRecord[index] = index;
}
break;
}
// 如果不是栈底下标链表
for (Integer index : list) {
// 左边比arr[i]小且离得最近的是原栈顶下标链表下面链表的最后一个下标
leftRecord[index] = stack.peek().getLast();
// 右边没有比arr[i]小且离得最近的元素
rightRecord[index] = index;
}
}
}
// 通过下标获取左边比arr[i]小且离得最近的元素的下标,没有就是自己i
public int getLeftRecord(int i) {
return leftRecord[I];
}
// 通过下标获取右边比arr[i]小且离得最近的元素的下标,没有就是自己i
public int getRightRecord(int i) {
return rightRecord[I];
}
}
6.6 题目
一个正数数组中所有的数累加起来的和乘以这个数组中的最小值得到的积叫做指标A。
一个数组一定有很多子数组(包括自身),那么每个子数组都会有自己的指标A。
给一个数组,求出在这个数组的子数组中,最大的指标A是多少?
对于数组中每一位元素,我们都需要找出:该元素在子串中是最小的元素且长度最长的子串。
对找出的所有子串计算指标A,得到最大的就是最后的答案。
对于第 i 位元素,如何找出最小元素是 arr[i] 且长度最长的子串?单调栈。
单调栈的具体作用就是从 arr[i] 开始向左右两边找比 arr[i] 小且离得最近的元素,从而确定子串的左右边界。
public static int getSum(int[] arr, int i, int j) {
int sum = 0;
for (int k = i; k <= j; k ++) {
sum += arr[k];
}
return sum;
}
/**
* 求第cur位的最大指标A
* @param arr 数组
* @param i 子串的左边界
* @param j 子串的右边界
* @param cur 当前子串中最小值的下标
* @return
*/
public static int getA(int[] arr, int i, int j, int cur) {
int sum = 0;
// arr[cur]不是子串的左边界,也不是右边界
if (i != cur && j != cur) {
sum = getSum(arr, i + 1, j - 1);
}
// arr[cur]是子串的左边界
if (i == cur && j != cur) {
sum = getSum(arr, i, j - 1);
}
// arr[cur]是子串的右边界
if (i != cur && j == cur) {
sum = getSum(arr, i + 1, j);
}
return sum * arr[cur];
}
public static int getMaxA(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
// 最大指标A
int maxA = 0;
// 构建单调队列
MonotoneStack stack = new MonotoneStack(arr);
// 求每一位的最大指标A,比出数组中的最大指标A
for (int i = 0; i < arr.length; i ++) {
// 获取构建成最大指标A的子串的左右边界
int left = stack.getLeftRecord(i);
int right = stack.getRightRecord(i);
// 获取第i位的最大指标
int a = getA(arr, left, right, i);
// 选出arr中的最大指标A
if (maxA < a) {
maxA = a;
}
}
return maxA;
}
7. Morris遍历
7.1 树形dp套路
树形dp套路使用前提: 如果题目求解目标是s规则,则求解流程可以定成以每一个节点为头节点的子树,在s规则下的每一个答案,并且最终答案一定在其中
- 以某一个节点X为头节点的子树中,分析答案有哪些可能性,并且这些分析是以X的左子树,X的右子树和X整棵树的角度来分析可能性的
- 根据第一步的可能性分析,列出所有需要的信息
- 合并第二步的信息,对左树和右树提出同样的要求,并写出信息结构
- 设计递归函数,递归函数是处理以X为头节点的情况下的答案。包括设计递归的basecase,默认直接得到左树和右树的所有信息,以及把可能性做整合,并且要返回第三步的信息结构的这四个小步骤
7.2 题目:二叉树节点间最大距离 -- 543. Diameter of Binary Tree (easy)
分析:
- 情况1:X不参与:左树/右树是最大距离
- 情况2:X参与:左树高度+右树高度 所以(左树最大距离,右树最大距离,左高+右高)三者求max 结构:所有子树返回信息:最大距离+高度
class Solution{
public int diameterOfBinaryTree(TreeNode head){
return process(head).maxDistance;
}
//左右树需要返回的信息
public class Info{
public int maxDistance;
public int height;
public Info(int dis, int h){
maxDistance = dis;
height = h;
}
}
//递归函数:返回以x为头的整棵树,两个信息
public Info process(TreeNode x){
if(x == null){
return new Info(0,0);//basecase,x为空,maxDistance和height都是0
}
Info leftInfo = process(x.left);
Info rightInfo = process(x.right);
//处理信息
int p1 = leftInfo.maxDistance;
int p2 = rightInfo.maxDistance;
int p3 = leftInfo.height + rightInfo.height;
int maxDistance = Math.max(p3,Math.max(p1,p2));
int height = Math.max(leftInfo.height, rightInfo.height) + 1;
return new Info(maxDistance, height);
}
}
7.3 题目: 派对最大快乐值
员工信息定义如下:
class Employee{
public int happy;//该名员工可以带来的快乐值
List<Employee> subordinates;//这名员工的直接下级名单
}
整个公司的人员结构可以看作是一棵标准的多叉树。树的头节点是公司唯一的老板,除老板外,每个员工都有唯一的直接上级,叶节点是没有任何下属的基层员工,除基层员工外,每个员工都有一个或多个直接下级,另外每个员工都有一个快乐值。
这个公司现在要办 party,你可以决定哪些员工来,哪些员工不来。但是要遵循如下的原则:
1.如果某个员工来了,那么这个员工的所有直接下级都不能来。 2.派对的整体快乐值是所有到场员工快乐值的累加。 3.你的目标是让派对的整体快乐值尽量大。
给定一棵多叉树头节点boss,请输出派对的最大快乐值。
- 分析:X参与不参与
设X点,abc为X的直接下属,abc也拥有各自的直接下属
- X参与:X点快乐值 = X值+a整棵树的最大值(不包含a值)+b整棵树的最大值(不包含b值)+c整棵树的最大值(不包含c值) -->因为X参与,abc都不能参与
- X不参与: X值为0 : 0+max(a来整棵树的值,a不来整棵树的值)+max(b来整棵树的值,b不来整棵树的值)+max(c来整棵树的值,c不来整棵树的值) -->因为X不参与,abc可以参与也可以不参与,所以要求abc来或者不来此时整棵树较大的值
所以,递归函数返回值:头节点来时值+头节点不来时值
class Solution{
public int maxHappy(Employee boss){
Info headInfo = process(boss);
return(Math.max(headInfo.comeMaxHappy,headInfo.noMaxHappy));
}
//子树信息
public class Info{
public int ComeMaxHappy;//头节点来时值
public int NoMaxHappy;//头节点不来时值
public Info(int come, int no){
ComeMaxHappy = come;
NoMaxHappy = no;
}
}
//递归
public Info process(Employee x){
if(x.nexts.isEmpty()){//basecase x是基层员工
return new Info(x.happy, 0);//来就有值,不来就0
}
int come = x.happy;//x来的情况下,初始值就是x的值
int no = 0;//x不来的情况下,初始值就是0
for(Empoyee next:x.nexts){
Info nextInfo = process(next);//要来下级员工的子树信息
come += nextInfo.noMaxHappy;//X来,直接下属都只能不来
no += Math.max(nextInfo.comeMaxHappy,nextInfo.noMaxHappy);//x不来,选直接下属来不来值高的
}
return new Info(come,no);
}
}
7.4 Morris遍历 -- 面试
7.4.1 简介
一种遍历二叉树的方式,Time complexity O(N), Space complexity O(1) 通过利用原树中大量空闲指针的方式,达到节省空间的目的
我们不管是用递归方式还是非递归方式遍历二叉树,只能做到时间复杂度为O(N),额外空间复杂度为O(logN),根本做不到额外空间复杂度为O(1)。因为递归方式遍历二叉树的本质是系统帮我们压栈,而非递归方式遍历二叉树的本质是我们自己压栈,遍历二叉树沿途的节点都需要被压倒栈里去。
因为和树遍历有关的题的解法非常多,如果找到了一种比别的遍历方式都好的遍历方式,那么就代表该解法比别的解法都好。
如果在遍历一棵树时严令禁止修改树的结构,那么Morris遍历就用不了。 Morris遍历有另外一个学名叫做:线索二叉树。
在Morris遍历的基础上可以加工出先序遍历、中序遍历和后续遍历。
7.4.2 遍历流程
开始时cur来到根节点位置:
-
如果cur有左孩子,找到左子树上最右的节点mostRight
- 如果mostRight的右指针指向null,让其指向cur,然后cur向左移动(cur = cur.left)
- 如果mostRight的右指针指向cur,让其指向null,然后cur向右移动(cur = cur.right)
-
如果cur没有左孩子,cur向右移动(cur = cur.right)
-
cur为空时遍历停止
在Morris遍历的过程中,如果一个节点有左孩子,一定能访问到两次;如果一个节点没有左孩子,只能访问到一次。
在Morris遍历到一个有左孩子的节点时,能否知道是第几次访问到该节点?
可以,根据该节点左子树最右节点的 right 指针指向来判断。如果 right 指向null,则是第一次访问;如果 right 指向 该节点自身,则是第二次访问。
7.4.3 Morris实质
如下是标准的递归版本的二叉树遍历:
public static void process(Node root) {
if (root == null) {
return ;
}
// 第一次访问该节点
process(root.left);
// 第二次访问该节点
process(root.right);
// 第三次访问该节点
}
二叉树的递归遍历中,是将每一个节点当作root,如果root是null,则返回。否则就去root 的左子树遍历一遍,回到root,再去root的右子树上遍历一遍,再回到root。 这其实是根据系统的自动压栈和弹栈来实现递归函数的调用和返回,从而可以让每一个节点都会被访问三次,构建出二叉树遍历的递归序列。
Morris实际上是递归函数的一种模拟,但是它只能够做到如果一个节点root有左孩子,那么可以在root的左子树遍历一遍后再次回到 root,而不能实现在root的右子树上遍历一边后再次回到root。如果一个节点root没有左孩子,那么只能访问它一次。 这其实是利用底层线索的关系实现的,从而可以让有左孩子的节点被访问两次,没有左孩子的节点被访问一次,构建出Morris序列。
7.4.4 实现
public static void morrisTraversal(Node root) {
if (root == null) {//basecase
return ;
}
Node cur = root;
Node mostRight = null;
while (cur != null) { // 当前节点为空时遍历停止
mostRight = cur.left//mostRight是cur左孩子
if(mostRight != null){//cur有左孩子
while(mostRight.right != null && mostRight.right !=cur){
//停止标准1,右孩子是空
//停止标准2,右孩子被改过,指向当前节点
mostRight = mostRight.right;//mostRight变成了cur左子树上,最右节点
}
if(mostRight.right == null){//第一次来到cur
mostRight.right = cur;
cur = cur.left;
continue;
}else{//mostRight.right == cur 第二次来到cur
mostRight.right = null;
}
}
cur = cur.right;//cur没有左孩子
}
}
7.4.5 复杂度
7.4.5.1 空间复杂度 O(1)
Morris遍历的额外空间复杂度很明显就是O(1),因为由程序中可以得到,我们只准备了cur和mostRight两个变量。
7.4.5.2 时间复杂度 O(N)
对于时间复杂度,我们需要思考一个问题:
在Morris遍历中,每访问一个有左孩子的节点都需要找到该节点左子树的最右节点,这个操作会不会导致整个过程的时间复杂度上升?
我们来计算一下在Morris遍历中,所有需要找的节点找到该节点左子树的最右节点的总代价。
首先看哪些节点会去找该节点的左子树的最右节点,并列出寻找路径:
1 节点遍历了两遍 2,5,11 节点。
2 节点遍历了两遍 4,9 节点。
3 节点遍历了两遍 6,13 节点。
4 节点遍历了两遍 8 节点。
5 节点遍历了两遍 10 节点。
6 节点遍历了两遍 12 节点。
7 节点遍历了两遍 14 节点。
我们发现所有需要找左子树最右节点的节点在找的过程中遍历的节点都是不重复的。 遍历这些节点总共的代价是逼近O(N)的。 也就是说每访问一个有左孩子的节点都需要找到该节点左子树的最右节点的这个操作最大时间复杂度就是O(N)。
7.5 Morris 先序
Morris遍历加工成先序遍历的规则是:
- 如果一个节点只能被访问一次,被访问时打印。
- 如果一个节点能被访问两次,在第一次被访问时打印。
public static void morrisTraversal(Node root) {
if (root == null) {//basecase
return ;
}
Node cur = root;
Node mostRight = null;
while (cur != null) { // 当前节点为空时遍历停止
mostRight = cur.left
if(mostRight != null){//cur有左孩子
while(mostRight.right != null && mostRight.right !=cur){
mostRight = mostRight.right;
}
if(mostRight.right == null){//第一次来到cur
System.out.println(cur.value);//如果一个节点能被访问两次,在第一次被访问时打印。
mostRight.right = cur;
cur = cur.left;
continue;
}else{//mostRight.right == cur 第二次来到cur
mostRight.right = null;
}
}else{//没有左树
System.out.println(cur.value);//如果一个节点只能被访问一次,被访问时打印。
}
cur = cur.right;
}
}
7.6 Morris 中序
- 如果一个节点只能被访问一次,被访问时打印。
- 如果一个节点能被访问两次,在第二次被访问时打印。
public static void morrisTraversal(Node root) {
if (root == null) {//basecase
return ;
}
Node cur = root;
Node mostRight = null;
while (cur != null) { // 当前节点为空时遍历停止
mostRight = cur.left
if(mostRight != null){//cur有左孩子
while(mostRight.right != null && mostRight.right !=cur){
mostRight = mostRight.right;
}
if(mostRight.right == null){//第一次来到cur
mostRight.right = cur;
cur = cur.left;
continue;
}else{//mostRight.right == cur 第二次来到cur
mostRight.right = null;
}
}
//没有左树跳过if,直接打印,并向右移动
//有左树第一次回到自己时,continue,到不了print。第二次print
System.out.println(cur.value);
cur = cur.right;
}
}
7.7 Morris 后序
Morris遍历加工成后序遍历的规则是:
- 如果一个节点能被访问两次,在第二次被访问时自底向上打印该节点左子树的右边界。
- 当所有节点遍历完后,单独自底向上打印整棵树的右边界。
(自底向上即逆序)
在代码实现之前,我们需要解决自底向上打印的问题,并且将自底向上打印的额外空间复杂度控制在O(1)。
// 自底向上打印以root为根的树的右边界
public static void printRightEdge(Node root) {
// 反转右边界并获取右边界最底部节点
Node tail = reverseRightEdge(root);
Node cur = tail;
while (cur != null) {
// 打印
System.out.println(cur.value);
cur = cur.right;
}
// 将右边界反转回去
reverseRightEdge(tail);
}
// 反转以root为根的树的右边界
public static Node reverseRightEdge(Node root) {
Node pre = null;
Node next = null;
while (root != null) {
next = root.right;
root.right = pre;
pre = root;
root = next;
}
return pre;
}
后序遍历
public static void morrisTraversal(Node root) {
if (root == null) {//basecase
return ;
}
Node cur = root;
Node mostRight = null;
while (cur != null) { // 当前节点为空时遍历停止
mostRight = cur.left
if(mostRight != null){//cur有左孩子
while(mostRight.right != null && mostRight.right !=cur){
mostRight = mostRight.right;
}
if(mostRight.right == null){//第一次来到cur
mostRight.right = cur;
cur = cur.left;
continue;
}else{//mostRight.right == cur 第二次来到cur
mostRight.right = null;
printEdge(cur.left);//逆序打印左树右边界
}
}
cur = cur.right;
}
printEdge(root);//遍历完成,逆序打印整棵树右边界
System.out.print();
}
7.8 Morris判断是否为搜索二叉树
中序遍历的序列如果是升序,则该树是搜索二叉树。
这道题使用Morris遍历来解决将会比常规解法节省大量的空间,是本题的最优解。
public static boolean morrisTraversal(Node root) {
if (root == null) {//basecase
return ;
}
Node cur = root;
Node mostRight = null;
int preValue = Integer.MIN_VALUE;
while (cur != null) { // 当前节点为空时遍历停止
mostRight = cur.left
if(mostRight != null){//cur有左孩子
while(mostRight.right != null && mostRight.right !=cur){
mostRight = mostRight.right;
}
if(mostRight.right == null){//第一次来到cur
mostRight.right = cur;
cur = cur.left;
continue;
}else{//mostRight.right == cur 第二次来到cur
mostRight.right = null;
}
}
if(cur.value <= preValue){
return false;
}
preValue = cur.value;
cur = cur.right;
}
return true;
}
7.9 和二叉树递归套路的比较
哪些二叉树的问题是以二叉树的递归套路作为最优解的?哪些二叉树的问题是以Morris遍历作为最优解的?
-
如果你发现你的方法必须做第三次信息的强整合,只能使用二叉树的递归套路,并且二叉树的递归套路就是最优解。
- 第三次信息的强整合指的是:一个节点必须在得到它的左子树的信息和右子树的信息之后,在第三次访问自己时结合左右子树信息和自身信息整合并返回。
-
如果你发现你的方法并不需要做第三次信息的强整合,可以使用二叉树的递归套路,也可以使用Morris遍历,但是Morris遍历是最优解。