左程云算法笔记总结-基础提升篇

提升01(哈希)

认识哈希函数

  1. 哈希函数的输入一般需要是无穷尽的,没有限制;输出可以有一定的范围,比如MD5加密后产生的字符串可以有2的32次方-1种,用十六进制表示需要16个字符。
  2. 相同的输入对应相同的输出,不具有随机性
  3. 因为输出域是有限的,输入域很大,说明有几率出现不同的输入有相同的输出,哈希碰撞。
  4. 离散性:即使输入值非常相似,但是输出值的分布非常随机。均匀性、离散性:把所有输入值输入到哈希表中,用一个圆圈框这些值,每次找到的值得个数基本相同。

现有40亿个int型数字需要统计词频,找出出现次数最多的。先想到储存在哈希表中统计次品,但是如果一次性把所有值存进哈希表,一组key-value需要8个字节,总共需要的内存至少32G,会爆内存。

解决方法:先根据哈希函数把这些数字前前后后分配到100个文件里,(因为数据量大,所以每个文件里所有数字种类基本可以保证都出现过,是均分的),每次在内存里只处理一个文件,处理完第一个文件规模的数字再处理第二个文件规模的数字,这样始终只会占用一个文件的大小,0.32G,然后处理完这100个数字,会得到每个文件中的最大值,100个最大值,然后在这100个最大值选出最大的。就是最大出现词频。

哈希表实现

  1. 哈希表初始是数组+链表,当链表节点太多超过8个,会把链表变成红黑树。
  2. 对于哈希表中储存n个数,扩容代价均摊到每个数上时间复杂度是 N*logN / N = O(logN),当n不是特别大的时候,logN相当于logK,所以在使用上近似于O(1)
  3. 当存储过多是,jvm会对哈希表进行离线扩容,也就是新建一个哈希表,把数据挪过去,挪过去之后使用新建的哈希表

例题

设计RandomPool结构 leetcode 705

【题目】 设计一种结构,在该结构中有如下三个功能:

insert(key):将某个key加入到该结构,做到不重复加入

delete(key):将原本在结构中的某个key移除

getRandom(): 等概率随机返回结构中的任何一个key。

【要求】 Insert、delete和getRandom方法的时间复杂度都是O(1)

解题思路:这一题不是让我们从0创造一个哈希表。这个结构包括两个哈希表,其中一个<key,value>,另一个<index,key>,还包括一个size,每插入一个key,则第一个map插入key和value,另一个插入index(根据size++)和key,相对应。等概率随机返回时,计算出一个index值,根据这个index值查出来key值,然后在第一张表中删除这个key。删除后还需要保证之后的random不会随机到一个空值,因为如果之前有0~25个连续的值,删除22后,下次随机到22时这就是个空值,如果太多这种空值,会加大时间复杂度,所以这里让当前map最后一个值挪到被删除位置填补这个空洞,这样随机的时候就不会随机到空值。

布隆过滤器

hashset和hashmap的区别就是有没有value,其他都相同。布隆过滤器结构相当于一个set,只有add和check功能,没有删除功能,布隆过滤器可以用来把100亿级的url加入黑名单,不让用户访问这些违法链接。布隆过滤器可以做到非常省内存,但是由于本身的设计问题导致会低概率产生误报,比如对于一个url黑名单来说,可能会把未列入黑名单的url误报成黑url,但是绝对不会把黑名单里的url误报成白url,”宁可错杀一千 不可放过一个“,而且经过一定优化,这些误报会非常少,到达十几万分之一。

设计布隆过滤器:需要一个bitmap,也就是以bit为单位的数组,怎么实现这个结构呢,我们用int数组,int数组每一个数据是4字节,也就是32bit,如果数组长度是10,那么就可以代表320个bit。看下代码:

int[] arr = new int[10];//32bit * 10 -> 320 bits
//arr[0] int 0 ~31
//arr[1] int 32~63
//arr[2] int 64~95
int i = 178;//想取得178位的状态
int numIndex = 178 / 32;//得到178位所在int数组的数组下标
int bitIndex = 178 % 32;//取得178所在的int元素中第几位,注意,在二进制位中,从右到左才是由低到高,所以bit在第几位要从右数
int s = (    (arr[numIndex] >> (bitIndex))    & 1)//把178位所在数组上的int数字右移bitIndex位,注意,int数字在二进制下从右到左是从低位到高位。然后和1做与运算,如果这一bit位是1那s就是1,如果是0,那s就是0;

//1、把178位的状态改为1
arr[numIndex]  = arr[numIndex] | (1 << (bitIndex));//把1左移bitIndex位后和数组元素进行或运算,得到的结果相对于原int元素的二进制形态,bit位上的数字变为1。

//2、把178位的状态改为0
arr[numIndex]  = arr[numIndex] & (~  (1 << bitIndex)  );//1左移bitIndex位后再取反,得到只有bitIndex位上是0的二进制数字(其他都是1),然后和arr[numIndex]与运算,得到和原来的int数字相比,178位的bit变为0

//3、把178位的状态拿出来
i = 178;
//bit 0 1
int bit=(arr[i/32] >> (i%32)) & 1;

那布隆过滤器就是一个大bitmap,长度为m的bitmap时间占用空间是m/8字节。

布隆过滤器通过对每一个数据进行k次hash运算,每次运算的结果对应的位置都要设置为1,把k个位置设置为1,然后在查询数据的时候,再次进行这k次hash运算,查看是不是这k个位置都是1,如果是,那就相当于这个数据在bitmap里。这里就可以看到一些缺陷,如果某个白名单url计算出来的哈希值和某个黑名单的哈希值相同,那么就会误把白url当成黑url。

设布隆过滤器的bitmap大小为m,哈希函数的个数为k。

当m过小的时候,很容易几下子就把所有位置都设置为1了,那就很容易误判了。

当k过小的时候,(其实进行哈希运算就像采集指纹,当采集指纹越多那么证明是本人的概率越大,如果k过小,那就很容易让不同的数据算出来相同的几个哈希值,就容易把白url误判成白url)。

但是k也不能过大,如果过大,那么计算一个数据就会产生很多个1,把很多格子描黑,这样也容易误判。

总结:失误率随着bit数组增大而减小,随着k值增大先减小后增大。

设n代表样本量,p代表失误率。

根据这两个值计算需要m的大小(bit数组大小,也就是内存)和哈希函数的个数k。

公式:

在这里插入图片描述

以上计算的都是理论值,但实际上使用如果能申请更大的内存空间,那么失误率会更低,问一下面试官能不能申请更大的内存,如果能,那么计算实际失误率的公式就是:

”k真“的计算根据上个公式,把“m真”代入即可

在这里插入图片描述

一致性哈希原理

对于分布式存储,不同机器上存储不同对象的数据,我们使用哈希函数建立从数据到服务器之间的映射关系。

背景:使用简单的哈希函数情况下,假设开始是3台机器,数据经过哈希函数平均分配到3台机器上,但是如果要水平增加一台机器,则需要重新分配所有数据到4台机器上,这会造成大量的数据迁移,导致网络通信压力的剧增,严重情况,还可能导致数据库宕机。

一致性hash算法可以保证当机器增加或者减少时,节点之间的数据迁移只限于两个节点之间,不会造成全局的网络问题。

一致性哈希1.0:需要一个哈希环,然后把机器平均安置在3个节点,然后根据哈希函数分配数据,环上的数据归属于顺时针方向的下一个机器。如果需要新增一个节点,那么只需要在新增节点的前后重新调整数据存放位置即可。但是存在两个问题,一,新增一个节点后,会造成负载不均衡。二,当开始时机器不够多的时候,哈希函数并不能保证每台机器分到的数据均分。

在这里插入图片描述

一致性哈希2.0: 虚拟节点技术。比如原来有3台机器,我们直接让每台机器复制出1000份他自己,让这些虚拟节点也就是虚拟机器去抢环,这样就能做到每台机器抢到的环上的数据是均分的,因为每台机器及其复制品足够多。并且可以根据机器性能,复制不同数量的节点,如果性能够强,给他分配2000个虚拟节点,弱一点的分配1000个虚拟节点,这样就能让能者多劳合理分配。新增一台机器时候,也是复制他自己1000份,并且数据迁移不多。

岛问题

【题目】 一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右四个位置相连,如果有一片1连在一起,这个部分叫做一个岛,求一个矩阵中有多少个岛?

【举例】 001010 111010 100100 000000 这个矩阵中有三个岛

【进阶】 如何设计一个并行算法解决这个问题

基础做法:递归+遍历。遍历所有节点,如果遇到1的数字,就把结果+1,并且把自身和相邻的所有1设置为2,代表已经遍历过。直到所有节点都被遍历过。时间复杂度是O(N*M),不是因为每个节点最多只会被遍历4次,分别是上下左右四个邻居和自己对比,还是n级别的。代码见:Code03_Islands

进阶做法:

设计并行算法:不过我们做题和面试一般都是单cpu单内存的算法,并行算法只要求在面试的时候把过程说出来即可。

这个算法需要知道并查集。

假设把矩阵分成左右两半,让两个cpu从左右两侧进行统计,每个初始节点都放在一个集合里,然后进行感染,并且记录边界位置都是被哪个节点感染的,最后合并结果,在合并结果时,如果左右两侧节点都被感染,那么就合并相应的初始节点集合,最后合并完成,剩余集合的数量就是岛的数量。

在这里插入图片描述

并查集

背景:在k算法里,开始时为每个数据创建一个集合,并且需要两个方法,一个是isSameSet来判断是否是同一个集合,另一个是union来合并两个集合。但是基础数据结构里有两个,一个是hashmap来作为集合来实现这两个方法时,isSameSet这个方法只需要判断两个集合是不是同一个map就行,时间复杂度时O(1),但是union时间复杂度时O(N),需要把另一个map里的数据移动到前一个里去。另一个是链表,链表在合并时候只需要合并头和另一个尾,是O(1),但是查询需要遍历是O(N)。那么有没有一种结构让两种方法时间复杂度都接近O(1)。

并查集:对于每一个元素开始时都独自存在一个集合,每一个元素都要向上指向他的父亲,那么开始时他的父亲就是他自己。

对于isSameSet方法,需要判断每个元素向上指,也就是调用findHead方法,直到向上指为空,那么就找到了当前集合的代表节点,如果两个节点所在集合的代表节点相同,那么就说明两个节点在相同集合里。注意,代表节点需要储存当前集合的大小,这个需要在合并集合时用得到。

对于union方法,需要找到当前集合的代表节点,然后根据代表节点储存的集合size的数据,判断出小的集合,小的集合的代表节点需要指向大集合的代表节点,这样就对两个集合进行了合并。

对于上面提到的findHead方法,向上看有没有父亲,如果有那就把当前节点替换为父亲,如果当前节点的父亲等于他自己,那就相当于知道了当前集合的代表节点,(因为初始时,每个节点的父亲指针指向他自己,如果过程中没有指向大集合,那么始终他就是指向自己)。这样就找到了代表节点和储存的集合大小。

在findHead过程中,需要对并查集结构进行一些优化,因为在向上指的过程中,如果路程过长,那么每次findHead就会走那么长的路,所以在这个过程中需要压缩路径以减低后来的时间复杂度。怎么压缩?遍历到一个节点时,就把这个节点放到栈里,或者其他数据结构,然后找到代表节点时,依次弹出每一个节点,让这些节点的父亲指向代表节点,这样就把遍历到的所有节点下一次findHead的时间复杂度降到了O(1) 。虽然优化过程比较费时间,但是经过优化的findHead方法平均到每次调用,时间复杂度相当于O(1)。

代码:

package class01;

import java.util.HashMap;
import java.util.List;
import java.util.Stack;

public class Code04_UnionFind {
     
     

	public static class Element<V> {
     
     //这个结构就是对数据包一层,让他有了地址,方便下边的表示,避免出现根据数字来判断两个元素是否相等而误判的情况
		public V value;

		public Element(V value) {
     
     
			this.value = value;
		}

	}

	public static class UnionFindSet<V> {
     
     //并查集
		public HashMap<V, Element<V>> elementMap;//保存对应的包装元素,方便用户查询
		public HashMap<Element<V>, Element<V>> fatherMap;//保存父元素
		public HashMap<Element<V>, Integer> rankMap;//只有代表节点需要使用(也就是每个集合的头节点),用来保存集合的大小

		public UnionFindSet(List<V> list) {
     
     
			elementMap = new HashMap<>();
			fatherMap = new HashMap<>();
			rankMap = new HashMap<>();
			for (V value : list) {
     
     //设置每个元素的包装、父亲为自己,集合大小为1
				Element<V> element = new Element<V>(value);
				elementMap.put(value, element);
				fatherMap.put(element, element);
				rankMap.put(element, 1);
			}
		}

		private Element<V> findHead(Element<V> element) {
     
     
			Stack<Element<V>> path = new Stack<>();//栈来储存路程中遍历到的节点
			while (element != fatherMap.get(element)) {
     
     //向上遍历直到代表结点
				path.push(element);//压栈
				element = fatherMap.get(element);
			}
			while (!path.isEmpty()) {
     
     //把栈里的节点父亲设置为代表结点
				fatherMap.put(path.pop(), element);
			}
			return element;//返回代表结点
		}

		public boolean isSameSet(V a, V b) {
     
     
			if (elementMap.containsKey(a) && elementMap.containsKey(b)) {
     
     //看是否存在这两个元素,如果不存在直接返回
				return findHead(elementMap.get(a)) == findHead(elementMap.get(b));//看代表结点是否相同来判断是否属于同一个集合
			}
			return false;
		}

		public void union(V a, V b) {
     
     
			if (elementMap.containsKey(a) && elementMap.containsKey(b)) {
     
     //如果两个元素不存在直接什么都不做
				Element<V> aF = findHead(elementMap.get(a));
				Element<V> bF = findHead(elementMap.get(b));
				if (aF != bF) {
     
     //不是同一个集合前提下
					Element<V> big = rankMap.get(aF) >= rankMap.get(bF) ? aF : bF;//判断大小集合
					Element<V> small = big == aF ? bF : aF;
					fatherMap.put(small, big);//设置小集合的代表结点指向大集合的代表结点
					rankMap.put(big, rankMap.get(aF) + rankMap.get(bF));//更新集合大小
					rankMap.remove(small);//删除小集合大小
				}
			}
		}

	}

}

提升02(有序表)

有序表

是指在哈希表的基础上对元素默认按照key进行排序,可自定义排序规则。所有操作都是O(logN)级别的

有序表分AVL树、SB树(size-balance)、红黑树、跳表(SkipList)。四种实现方式性能几乎没有差别。其中AVL、SB、红黑树三个分为一类,都类似平衡二叉搜索树,只是平衡条件不同;跳表分为一类。前三种结构的差别只在于判断平衡的条件不同,比赛一般选择SB树,因为改写难度较低。

平衡搜索树:符合平衡条件的(比如左右子树高度差不超过1)树,对于一个节点,左子树上的值永远小于自己,右子树上的值永远大于自己。如果有重复值,一般把他们放在同一个位置。

搜索二叉树

删除操作:记录遍历时候的前一个节点,(1)如果要删除值左右孩子为空,即叶子结点,直接删除即可。(2)如果左右孩子不双全,删除他后,直接让唯一孩子代替他。(3)假如删除节点的左右子树双全,那就让左子树最右节点或右子树最左边节点,代替他。如果右子树的最左孩子有右子树,那么需要把这个右子树给他的父节点后才能替换删除值。

修改:先删除后添加

在这里插入图片描述

搜索二叉树,具有左右旋操作,AVL这类考虑的是怎么用这两个操作。

左旋和右旋

对某个节点进行操作,左旋是把该节点倒向左边,让右子节点作为根节点,并且把右子节点的左子树转移给这个节点。右旋同理。左旋和右旋可以让树更平衡

在这里插入图片描述

AVL树

AVL树的增删改查在节点操作这个方面上和搜索二叉树没有任何不同。但是在加入一个节点时候,会在加入之后向上查一遍是否有平衡性。没有平衡性就左旋或者右旋。删除节点也一样,不过开始查询平衡性的位置是替代节点的上一个节点,往上开始查询。

具体怎么查询平衡性有没有被破坏?

LL型:当前节点左孩子的左边过长导致平衡性被破坏,对当前节点进行右旋即可

RR型:右孩子的右边过长导致平衡性被破坏,对当前节点进行左旋

LR型:左孩子的右孩子过长,先让左孩子左旋,即让左孩子的右孩子移动到左孩子的位置上,然后让右孩子成为整棵树的头,即让Z右旋
在这里插入图片描述

RL型:右孩子的左孩子过长,想方设法让C成为头部,先让右孩子左旋,再让左孩子的左孩子移动到头部,即让Z右旋

AVL、SB、红黑树增删改查的方式一样,唯一的区别是判断违规条件不一样

SB树

size-blance平衡性:
每棵子树的大小,不小于其兄弟的子树大小
既每棵叔叔树的大小,不小于其任何侄子树的大小

在这里插入图片描述

如图,要保证B大于等于F树和G树,也就是兄弟树B和C的差距不能超过(两倍+1)

平衡性被破坏:

LL:当前节点的左孩子的左孩子比右孩子大。让当前节点递归:(1)右旋(2)m(T)(3)m(L) 。m方法:谁的孩子发生变化就递归谁,让它继续进行右旋操作

LR:当前节点的左孩子的右孩子比右孩子大

红黑树

平衡条件:

(1)每一个节点不是红就是黑

(2)头节点和叶子结点(最底层的null节点)

(3)任何两个红节点不相邻

(4)从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点

从头结点到叶子结点假设全是黑的路径是7,红黑相间的路径是14,保证到任何一个叶子结点的路径差距不超过两倍

跳表

有三个属性,key、value、指向下级的指针列表。key需要是可以比较的。

默认节点:默认有一个指针,并且key最小,而且只有它才能扩充指针

当新加一个节点时,新增节点根据筛筛子确定自己有多少个指针,确定后,

(1)如果新增节点的指针个数比默认节点多,默认节点就扩充节点到相同个数的指针。然后从默认节点最高层开始,找到本层刚刚大于新加入节点值的节点,如果没有就直接把这一层的指针指向新节点。然后依次向下面的层级,找到小于等于新加入值的最右节点,然后让他的指针指向新节点。循环直到最下面层级。然后默认节点的所有指针指向这个新节点

(2)新增节点的指针个数小于等于默认指针的指针数时,从最高层开始找到小于等于新增值最右节点。然后判断当前层级是不是和新增值某个层级重合,如果没有就什么都不做,然后去下面层级,如果遇到了重合指针,就把找到的"最右节点"的指针指向新增节点,继续往下直到最下面一层。

跳表的精妙之处就是可以跳过部分值,找到新增节点应处的位置,比如下面图中招70所在位置,从最高层找小于等于70最右的,找到了20,那就从20开始看,因为70没有5层节点,那就从20的第五层开始往下看,来到第四层,查找小于等于70的最右节点,找到了50,继续从50的第四层向下找,来到了第三层。(每向下一层就跳过一部分值),第三层没有找到新的小于等于70的值,那就往第二层,发现70有第一层,那就把第一层挂上70,再往下到零层,也挂上70。

复杂度估算:第0层有 N 个节点,第1层有 N/2个节点 第2层有 N/4个节点 。。。。这样每次查找都能跳过当前几乎一半的值,相当于进行一个满二叉树搜索。时间复杂度O(logN)

在这里插入图片描述

提升03(字符串)

子串是有序的,子序列是无序的

KMP

题目:字符串str1和str2,str1是否包含str2,如果包含返回str2在str1中开始的位置。

基础做法是让str2和str1每个字符作为起点依次对比,这样的时间复杂度是O(N*M)。而KMP算法就是在这个基础上进行了一些加速,总体思路是不变的。

在进行加速之前,我们需要算出来str2每个字符对应的一个信息,这个信息和他自己没关系,而是和他前边的字符串有关系,这个信息:他前边字符串的“最大前缀和后缀相等”的字符串“长度”。假设字符k前面的字符串是“abbsabb”,那么这个信息就是3,因为前缀abb和后缀abb是最大的相等的部分了。而且相等部分可以重叠,比如“baabbaabb”这个字符串的信息是5,“baabb”。但是最大长度不可以等于字符串总长度,比如“aaaa”,这个信息不能是4,也就是前缀和后缀最大长度不能等于字符串。

得到str2中每个字符的信息后,我们就可以进行加速。

在这里插入图片描述

如图,假设str1中从i开始,“X”前的内容和str2中“Y”前的内容都相等,但到了“x”和“y”就不相等了,假设y的信息是3,那么前面字符串的前缀等于他的后缀,这时候我们就可以让“x”直接开始和str2的前缀的下一个字符对比。为什么?(1)前缀和后缀相等,后缀部分和str1的相对应部分也相等,那么前缀部分就等于str1的后缀对应部分,相当于“x”前的后缀对应部分一定和str2的前缀部分相等,所以可以跳过前缀对比。(2)为什么图中i+1j的部分不需要对比?也就是说为什么在i+1j之间开始对比的话,不存在和str2相等的情况?可以假设从k开始对比存在相等的情况,因为k到x这部分距离的字符和str2里的对应部分相等,又因为假设的k开始和str2[0]开始后面相等,所以相当于找到了一个更大的前缀后缀相等长度,但是我们的信息已经是最大的了,这个想法导致结果违背之前的信息,是不成立的,所以i~j之间不可能存在相等的情况。

如何求下标为i的信息?

我们要求i的信息就要先看i-1的信息,也就是0~i-2这一段的前缀后缀,如果前缀的后面一个字符k等于i-1的值,那么i的信息就是i-1信息+1;如果不等,那就看k的信息的前缀的后面一个字符m,如果m等于i-1,那么i的信息就是k的信息+1,如果还不相等那就如此循环直到找到和i-1相等的某个数字。

在这里插入图片描述

代码:

public static int getIndexOf(String s, String m) {
    
    
		if (s == null || m == null || m.length() < 1 || s.length() < m.length()) {
    
    
			return -1;
		}
		char[] str1 = s.toCharArray();
		char[] str2 = m.toCharArray();
		int i1 = 0;
		int i2 = 0;
		int[] next = getNextArray(str2);//获得信息数组 O(M)
		while (i1 < str1.length && i2 < str2.length) {
    
    //比对 直到str1到结尾或者str2到结尾 O(N)
			if (str1[i1] == str2[i2]) {
    
    //当前位置相等,比对下一位
				i1++;
				i2++;
			} else if (next[i2] == -1) {
    
    //str2指针的位置到了str2的0位置,那就说明当前以str1的指针位置为开头没有和str2相等的字符串,那么就要把str1指针向后移
				i1++;
			} else {
    
    //当前出现不相等的情况,把str2的指针向前移,移动到当前指针前字符串的前缀的后面一位。如果一直移动,直到移动到0位置,那就执行else if的那一种情况了。
				i2 = next[i2];
			}
		}
		return i2 == str2.length ? i1 - i2 : -1;//如果i2到达了结尾,那么说明存在相等部分,就把i1的位置减去i2大小,就是str1中str2开头的位置
	}

	public static int[] getNextArray(char[] ms) {
    
    
		if (ms.length == 1) {
    
    
			return new int[] {
    
     -1 };
		}
		int[] next = new int[ms.length];
		next[0] = -1;//数组0位置是-1,1位置是0,固定的。因为0位置前面没有东西,1位置前之有1个,但是不能等于字符串总长度
		next[1] = 0;
		int i = 2;
		int cn = 0;//代表前面数字的前缀的下一个数字k的下标
		while (i < next.length) {
    
    
			if (ms[i - 1] == ms[cn]) {
    
    //前一个数字等于前一个数字求出的前缀的后一个数字,则当前数字的信息是前一个数字信息加1
				next[i++] = ++cn;
			} else if (cn > 0) {
    
    //不等的情况下,还没走到0时,那就往前,看前缀字符串的前缀是不是存在上一个情况
				cn = next[cn];
			} else {
    
    //不等的情况下,走到了0还是没有相同数字,那么当前数字的信息就是0
				next[i++] = 0;
			}
		}
		return next;
	}

时间复杂度:因为求数组信息需要O(M),然后比对字符串需要O(N),但是N>M,所以时间复杂度是O(N)

Manacher算法

题目:字符串str中,最长回文子串的长度如何求解?

如何做到时间复杂度O(N)完成

经典做法:

在这里插入图片描述

对每一个字符,向左右两边扩,进行对比。为了处理奇偶情况,在字符中间都新增一个字符,比如“#”,这样就可以把奇偶情况相同对待,不会漏结果。时间复杂度是O(N2),比如都是1的一个字符串,每个字符串都要和所有字符串进行对比,就是n2。

Manacher算法:也是在经典做法上做一些加速。

前置知识:

回文半径:当前字符左右扩出来的回文字符串长度的一半

回文直径:当前字符左右扩出来的回文字符串的长度

回文半径数组:开辟辅助数组记录每个字符的回文半径

int R :之前扩的所有位置中所到达的最右回文右边界

int C :取得最右时候的回文字符串的中心点,R和C是一起变化的

算法思路:先对数组进行"#"处理,然后设置R=-1,C=-1。然后用i代表当前位置。每次遍历到一个字符,判断i处字符在R的哪边,(一)如果在R的右边,也就是不在最右回文字符串内,那就暴力对比i左右两边的数,求出来他的最大回文字符串,并且更新R。(二)如果在R的左边,也就是在上一次求出回文字符串内,那这种就分为3种情况。这三种情况主要看与i对应的i’的位置,i’是i以c为中心的对称点。

(1)当i’的回文字符串的左边界大于R关于C的对称点L时,i的回文字符串的长度等于i’的回文字符串长度,因为第一,i和i’是对称的,再加上回文字符串也是对称的,所以他们左右字符串都是对称的。(i’的回文长度是之前已经求出来的)

(2)当i’的回文字符串的左边界小于R关于C的对称点L时,i的回文字符串的长度等于R-i的长度。那为什么不能再向左一点,再大一点呢?因为L左边的某点关于C的对称点在R的右边的一个点不对称。

(3)当i’的回文字符串左边界等于R关于C的对称点L时,i的回文长度至少是“R-i”这一部分长度,那么R右边的位置属不属于回文还是要单独暴力判断。

代码:

public static char[] manacherString(String str) {
     
     //在字符两边添加"#"
		char[] charArr = str.toCharArray();
		char[] res = new char[str.length() * 2 + 1];
		int index = 0;
		for (int i = 0; i != res.length; i++) {
     
     
			res[i] = (i & 1) == 0 ? '#' : charArr[index++];//奇数位填#
		}
		return res;
	}

	public static int maxLcpsLength(String str) {
     
     
		if (str == null || str.length() == 0) {
     
     
			return 0;
		}
		char[] charArr = manacherString(str);					
		int[] pArr = new int[charArr.length];//回文半径数组
		int C = -1;
		int R = -1;//为了代码处理方便,此时的R代表右边界的再往右一个字符
		int max = Integer.MIN_VALUE;
		for (int i = 0; i != charArr.length; i++) {
     
     //遍历每个字符
			pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - i) : 1; //设置i位置的回文字符串至少是多少,当i>R时,先设置为1,至少为自身。当i小于等于右边界(因为此时R代表右边界右边的值)时,在R-i到i'回文长度中取最小值
			while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
     
     //在上一步的基础上进行暴力查询,相当于对四种情况都进行了暴力,不过i大于R和i<R两种情况本身不用暴力查询,这里也只用查询一次,其他两种情况i'<L和i==R本身就需要暴力
				if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
					pArr[i]++;
				else {
     
     
					break;
				}
			}
			if (i + pArr[i] > R) {
     
     //看看向右扩后是不是比R大,如果大,那就更新R
				R = i + pArr[i];
				C = i;
			}
			max = Math.max(max, pArr[i]);//更新max
		}
		return max - 1;//max代表带"#"的字符串的最大回文半径,最大回文半径减1就是不带"#"的字符串的回文直径,也就是最大回文字符串的长度
	}

提升04(滑动窗口、单调栈)

滑动窗口

就是一个双端队列,一般认为双端队列最左边是头,最右边是尾,左右端口都可以向右滑。数据可以从左右两端进出。

java里双端队列也是用LinkedList,Deque deque = new LinkedList()。头部插入:addFirst(E e) 尾部插入:addLast(E e) 是否包含:contains(Object o) 返回(不删除)第一个元素(头部):element()或getFirst() peekFirst() 返回(不删除)最后一个元素(尾部):getLast() peekLast() 返回并删除第一个元素: poll() pollFirst() removeFirst() 返回并删除最后一个元素:pollLast() removeLast()

而普通队列的声明是:Queue queue = new LinkedList()。

此外,除了Stack类,LinkedList也可以声明为堆栈使用(Stack过时): Deque deque = new LinkedList()

在控制滑动窗口内数字由大到小的题目中,右端每向右滑动一次,就判断之前队列里最右端的值是不是小于当前滑到的值,如果小于就让最右端的值弹出,如果还小于当前值,那就再弹出直到遇到大于自己的值,然后从右边入队。然后最左端永远是当前窗口里的最大值,获取最大值可以从左端弹出第一个值。

leetcode 3 利用了滑动窗口 求无重复字符的最长子串

单调栈

题目:找出数组中每一个数字中,离他最近的比他大的值?

先假设数组中没有重复的值:

维护一个栈,从栈底到栈顶依次是从大到小的顺序(存放栈里的是下标)。如果当前压栈的数小于栈顶,那就直接把他压栈,这样就能维护顺序;遇到大于栈顶的元素的时候,把栈顶弹出去,在弹出的时候,就可以算出来弹出的值的结果,此时栈顶元素的右边离他最近的值就是当前要压栈的值,左边离他最近的值就是栈里他下面的值;然后看下一个栈顶是不是也是小于当前值,如果小于,继续循环,直到栈顶元素大于当前值。当把所有值全部压到栈里后,如果栈里还有值,需要对栈进行最后一次处理。此时栈里每一个数字都没有比他大的值,每一个数字离他最近的比他大的值都是他下面的值。

在这里插入图片描述

在考虑有重复值的情况:在压栈是遇到和自己相等的值的时候,把自己和栈顶合在一起,同一个位置存储两个下标,然后在发现当前值比这两个值大的时候,他们的右边近的最大值就是当前值,左边最大的就是他们两个下面的那个值。

提升05(树形DP、Morris遍历)

树形dp套路

树形dp套路使用前提: 如果题目求解目标是S规则,则求解流程可以定成以每一个节点为头节点的子树在S规则下的每一个答案,并且最终答案一定在其中

树形dp套路第一步: 以某个节点X为头节点的子树中,分析答案有哪些可能性,并且这种分析是以X的左子树、X的右子树和X整棵树的角度来考虑可能性的

树形dp套路第二步: 根据第一步的可能性分析,列出所有需要的信息

树形dp套路第三步: 合并第二步的信息,对左树和右树提出同样的要求,并写出信息结构

树形dp套路第四步: 设计递归函数,递归函数是处理以X为头节点的情况下的答案。包括设计递归的basecase,默认直接得到左树和右树的所有信息,以及把可能性做整合,并且要返回第三步的信息结构这四个小步骤

题目:叉树节点间的最大距离问题

从二叉树的节点a出发,可以向上或者向下走,但沿途的节点只能经过一次,到达节点b时路径上的节点个数叫作a到b的距离,那么二叉树任何两个节点之间都有距离,求整棵树上的最大距离

算法思路:对于任意一个节点,以它为根的整个树的最大路径可能经过它或不经过它两种情况,那就可以根据这个条件来划分情况。(1)当不包含它的时候,他以它为根的树的最大距离等于左子树最大距离或者右子树最大距离

(2)当包含它的时候,距离等于左子树最左边到它,然后到右子树最右的距离,也就等于左子树高度+右子树高度+1。

然后从下到上对每一个节点,拿到左子树和右子树信息,判断这3种情况下哪一个距离最大,然后返回Info类。Info类包含两个属性,最大距离和高度。当节点是整棵树的根的时候,算出来的最大距离就是整棵树的最大距离。记得要设置好Base Case,返回高度为0,最大距离为0的Info类。

代码:


	public static class Node {
     
     
		public int value;//在本题没什么用,路径每经过一个节点就+1
		public Node left;
		public Node right;

		public Node(int data) {
     
     
			this.value = data;
		}
	}

	public static class ReturnType{
     
     
		public int maxDistance;
		public int h;//height
		
		public ReturnType(int m, int h) {
     
     
			this.maxDistance = m;;
			this.h = h;
		}
	}
	
	public static ReturnType process(Node head) {
     
     
		if(head == null) {
     
     
			return new ReturnType(0,0);
		}
		ReturnType leftReturnType = process(head.left);
		ReturnType rightReturnType = process(head.right);
		int includeHeadDistance = leftReturnType.h + 1 + rightReturnType.h;
		int p1 = leftReturnType.maxDistance;
		int p2 = rightReturnType.maxDistance;
		int resultDistance = Math.max(Math.max(p1, p2), includeHeadDistance);//求三种情况的最大距离
		int hitself  = Math.max(leftReturnType.h, leftReturnType.h) + 1;//求高度
		return new ReturnType(resultDistance, hitself);
	}

Morris遍历

一种遍历二叉树的方式,并且时间复杂度O(N),额外空间复杂度O(1)

通过利用原树中大量空闲指针的方式,达到节省空间的目的

Morris遍历细节

假设来到当前节点cur,开始时cur来到头节点位置

1)如果cur没有左孩子,cur向右移动(cur = cur.right)

2)如果cur有左孩子,找到左子树上最右的节点mostRight:

a.如果mostRight的右指针指向空,让其指向cur, 然后cur向左移动(cur = cur.left)

b.如果mostRight的右指针指向cur,让其指向null, 然后cur向右移动(cur = cur.right)

3)cur为空时遍历停止

Morris遍历的实质

建立一种机制,对于没有左子树的节点只到达一次,对于有左子树的节点会到达两次

morris遍历时间复杂度:O(N)

Morris先序中序后序遍历

先序遍历:如果一个节点只能到达他一次,到达他时候直接打印;如果一个节点能到达两次,第一次直接打印。

	public static void morrisPre(Node head) {
    
    
		if (head == null) {
    
    
			return;
		}
		Node cur1 = head;
		Node cur2 = null;
		while (cur1 != null) {
    
    
			cur2 = cur1.left;
			if (cur2 != null) {
    
    
				while (cur2.right != null && cur2.right != cur1) {
    
    
					cur2 = cur2.right;
				}
				if (cur2.right == null) {
    
    //第一次来到cur,打印
					cur2.right = cur1;
					System.out.print(cur1.value + " ");
					cur1 = cur1.left;
					continue;
				} else {
    
    
					cur2.right = null;
				}
			} else {
    
    //如果没有左子树,直接打印
				System.out.print(cur1.value + " ");
			}
			cur1 = cur1.right;
		}
		System.out.println();
	}

中序遍历:如果一个节点只能到达他一次,到达他时候直接打印;如果一个节点能到达两次,第二次时打印。

public static void morrisIn(Node head) {
    
    
		if (head == null) {
    
    
			return;
		}
		Node cur1 = head;
		Node cur2 = null;
		while (cur1 != null) {
    
    
			cur2 = cur1.left;
			if (cur2 != null) {
    
    
				while (cur2.right != null && cur2.right != cur1) {
    
    
					cur2 = cur2.right;
				}
				if (cur2.right == null) {
    
    
					cur2.right = cur1;
					cur1 = cur1.left;
					continue;
				} else {
    
    
					cur2.right = null;
				}
			}
			System.out.print(cur1.value + " ");//没有左子树时直接打印;有左子树时,执行完上边的if,如果第一次来到cur直接continue,执行不到此处;第二次来到cur后,没有continue,执行本处打印
			cur1 = cur1.right;
		}
	}

后序遍历:对于只能到达一次的节点,不打印;对于能到达两次的节点,第二次到达的时候,打印其左子树右边界;最后单独逆序打印整棵树的右边界。逆序打印也需要时间复杂度O(1),反转链表后打印,然后还原链表。

怎么选择递归遍历还是Morris遍历?

递归遍历和Morris遍历二叉树,当结果需要第三次遍历来整合结果,遍历方法就是最优解。否则Morris是最优解

提升06(大数据题目/资源限制题)

解题思路:

这种问题需要问清面试官条件.be clearly

1)哈希函数可以把数据按照种类均匀分流 (万能方式)

2)布隆过滤器用于集合的建立与查询,并可以节省大量空间

3)一致性哈希解决数据服务器的负载管理问题

4)利用并查集结构做岛问题的并行计算

本节内容:

5)位图解决某一范围上数字的出现情况,并可以节省大量空间

6)利用分段统计思想、并进一步节省大量空间

7)利用堆、外排序来做多个处理单元的结果合并

位图解决某一范围上数字的出现情况,并可以节省大量空间

题目:

32位无符号整数的范围是0~4,294,967,295,现在有一个正好包含40亿个无符号整数的文件,所以在整个范围中必然存在没出现过的数。可以使用最多1GB的内存,怎么找到所有未出现过的数?

数字大小是0~2的32次方-1,个数是40亿个,当申请2的32次方除以8个字节,即一次性申请500MB即可统计所有数字出现过没有。

【进阶】 内存限制为 3KB,但是只用找到一个没出现过的数即可

3KB可以存储3000/4≈512个int无符号整数。那就生成一个512大小的int型数组,这个数组上每一位表示范围大小为8388608的这些数字一共出现了多少次,512个8388608合起来就是40亿,这就是范围统计,每一位上的数字只要对应统计范围里数字出现一次就加1。

对于x数字,假设他是8088609那么他除以8088608得出来1,那就让512大小的数组的下标为1的数字加1。

进进阶:假设只能申请几个变量,那怎么统计哪些数字没出现过?

二分 0-2的32-1,统计所有的数,看哪边不满;比如0~2的32次方左边不满,那就再把左边二分,然后再统计一遍,看哪边不满;循环直到找到没有出现过的数字

在这里插入图片描述

有一个包含100亿个URL的大文件,假设每个URL占用64B,请找出其中所有重复的URL

可以用布隆过滤器,不过需要承受一定失误率

【补充】 某搜索公司一天的用户搜索词汇是海量的(百亿数据量),请设计一种求出每天热门Top100 词汇的可行办法

把数据分成小文件,每个小文件维护一个大根堆,最后在所有文件大根堆中每次都选出最大的,选出100个就行了

题目:32位无符号整数的范围是0~4294967295,现在有40亿个无符号整数,可以使用最多1GB的 内存,找出所有出现了两次的数。

哈希函数分流,或者用位图,并且两位表示一个信息,00表示出现0次,01出现1次,10出现2次。一个数需要2bit表示,2的32次方乘2然后除以8得到字节数,小于1G可以表示出来。

进阶:可以使用最多3KB的内存,怎么找到这40亿个整数的中位数?

范围统计,每个循环,进行词频统计出来小于中位数的范围上总个数,然后减去这些中位数前面的数,然后下次循环再分为相等份数,从减去数后面再开始统计。

提升07(动态规划)

暴力递归到动态规划

动态规划:递归->记忆化搜索(缓存)->严格表结构(直接画表)->精致版动态规划–斜率优化等–>更多高深优化

线性动态规划、背包动态规划、区间动态规划、树形动态规划…

动态规划就是暴力尝试减少重复计算的技巧整而已

这种技巧就是一个大型套路 先写出用尝试的思路解决问题的递归函数,而不用操心时间复杂度

这个过程是无可替代的,没有套路的,只能依靠个人智慧,或者足够多的经验

但是怎么把尝试的版本,优化成动态规划,是有固定套路的,大体步骤如下

1)找到什么可变参数可以代表一个递归状态,也就是哪些参数一旦确定,返回值就确定了,参数尽可能的少,且不易变化

2)把可变参数的所有组合映射成一张表,有 1 个可变参数就是一维表,2 个可变参数就是二维表,…

3)最终答案要的是表中的哪个位置,在表中标出

4)根据递归过程的 base case,把这张表的最简单、不需要依赖其他位置的那些位置填好 值

5)根据递归过程非base case的部分,也就是分析表中的普遍位置需要怎么计算得到,那么这张表的填写顺序也就确定了

6)填好表,返回最终答案在表中位置的值。这就是严格表结构

动态规划总结:

先想好“试法”,然后写递归过程,然后可以选择记忆化搜索,减少重复计算,也可以直接进行严格表结构动态规划。严格表结构动态规划就是画表,先根据base case确定已经确定的位置,然后判断普遍位置依赖哪些已知位置,根据已经求得的格子 有序的依次计算出所有格子。最后某些题目可以进行斜率优化等,例如当前位置所要求的结果可以根据同一行中或者同一列前面已经求得的结果,再加上当前依赖就可以直接算出来结果,不需要重复计算依赖值。不过斜率优化包括很多内容,有关数学的问题。尽量优化步骤,减少不必要的计算。

  1. 注意想递归的时候不要过于在意整体递归流程,只要想好base case和子问题就好,其他的直接递归求得。
  2. 三维的参数列表是立体的,先考虑好最底层的数据情况,然后依次向上计算其他层的情况,本质还是二维,只不过是依赖下一层的结果,计算多个二维层级。

Arrays.fill()以某个数填满数组

猜你喜欢

转载自blog.csdn.net/m0_63323097/article/details/129910375