2.9 以最小插入次数构造回文串
2.9.1 思路分析
- 解读题意:首先这道题从直观上可以在两个字符的中间插入任意一个字符,我们直接暴力的话想着是枚举所有可能插入的情况,然后检查它是不是回文,这是最暴力的做法,但它的时间复杂度肯定暴增,所以想想就好了,那么来看看题意有什么样的特点?
需要穷举
、需要剪枝
,这就是需要用到动态规划的思路 - 动态规划三步走
- 定义dp数组,对于题目,给的字符串只有一个,但是我们要检索的是一个区间内的字符串,也就是
s[i...j]
之间的数值,那么我们就需要定义一个二维的dp数组,定义是对于字符串s[i…j],最少需要进行dp[i][j]
次插入才能变成回文串 - base-case:当
i==j
的时候,dp[i][j] = 0
,因为当i==j
的时候,本身就是一个回文串,就不需要任何的插入操作了 - 写出状态转移方程
- 假设现在得到了
dp[i+1][j-1]
的值,能否设法通过dp[i+1][j-1]
来得到dp[i][j]
的值呢? - 目前已经通过插入操作将
s[(i+1)...(j-1)]
变成了回文串,怎么样才能判定s[i...j]
是回文串呢? - 关键是
s[i]和s[j]
这两个字符- 如果
s[i] == s[j]
:那么就不需要进行任何的插入,只需要知道如何把s[i+1...j+1]
变成回文串即可 - 如果
s[i] != s[j]
:如果我们执行两次插入操作,肯定可以使得其变为回文串,但是不一定是最少的step1
:做选择,先将s[i...j-1]
或者s[i+1...j]
变成回文串,做选择的标准是,谁变成回文串的代价小,就选谁,然而,如果s[i+1...j]
和s[i...j-1]
都不是回文串,都至少需要插入一个字符才能编程回文,那么选择哪个都一样的step2
:根据step1将s[i...j]
变成回文,如果在步骤一种选择把s[i+1...j]
变成回文串,那么在s[i+1...j]
右边插入(指的是在s[i+1]的右边插入一个字符),同理,如果在步骤一种选择把s[i...j-1]
变成回文串,在s[i...j-1]
左边插入一个字符s[j]
一定可以将s[i...j]
变成回文- 解释:来看看
s[i+1...j]
,如果这时候s[i]和s[j]对不上,那么插入这个操作是不是要使得s[i]和s[j]对得上?那么我们做插入,就是强塞给这个字符串一个字符,让它s[i]和s[j]一模一样,那么如果我们决定要修改s[i+1...j]
,那么我们就在原本的i+1的位置的右边插入一个字符,这样就是不是一样了?
- 如果
- 假设现在得到了
- 返回值,要求的是整个字符串,由dp数组的定义,可知最终应当返回
dp[0][s.length()-1]
- 定义dp数组,对于题目,给的字符串只有一个,但是我们要检索的是一个区间内的字符串,也就是
2.9.2 AC代码
public int minInsertions(String s) {
//1.定义dp数组,是一个二维的数组dp[i][j]:s[i...j]变成回文串所需要的最小次数
int[][] dp = new int[s.length()+5][s.length()+5];
//2.写出base-case
//当i=j的时候,指针指向一个字符,这时候的本身就是一个回文串,不需要插入,等于0
for(int i = 0;i<s.length();i++){
dp[i][i] = 0;
}
//3.写状态转移
//回文类型的状态转移不是传统的顺序遍历方式
//遍历方式如何来确定呢?
//我们来观察DP-table,dp-table被初始化为具有斜对角0的表
//那么我们行就应当从倒数第二行开始
//也就是i = n-2的地方开始
//j的话我们根据回文串的特点,它要扩散,那么我们就让它向后走一格,直到s.length()-1为止就好了
for(int i = s.length()-2;i>=0;i--){
for(int j = i+1;j<s.length();j++){
if(s.charAt(i) == s.charAt(j)){
dp[i][j] = dp[i+1][j-1];
}else{
dp[i][j] = Math.min(dp[i][j-1],dp[i+1][j])+1;//最多是2,最少是1,因为都不是回文串
}
}
}
return dp[0][s.length()-1];
}
2.10 正则表达式(动态规划解法)
2.10.1 思路分析
- 首先明确
.能够匹配任意字符
,*可以让*之前的那个字符重复任意次数
- 关于
.
还是非常好实现的,遇到了之后直接让检测指针++ - 关键在于
*
该如何实现?一旦遇到了*
通配符,前面的那个字符可以选择重复一次,也可以重复多次,也可以一次都不出现 - s串和p串相互匹配的过程大致是,两个指针i和j分别在s串和p串上进行扫描,如果最终两个指针都能移动到字符串的末尾,那么就匹配成功,否则失败
- 分类讨论:当
p[j+1]
为*
通配符时s[i] == p[j]
p[j]
有可能匹配多个字符,比如说s="aaa",p="a*"
,这时候p[0]
会通过*
匹配3个字符a
p[i]
也有可能匹配0个字符,比如说s="aa",p="a*aa"
,由于后面的字符可以匹配s,所以这时候p[0]
只能够匹配一次
s[i] != p[j]
p[j]
只能匹配0次,然后看下一个字符是否能与s[i]
匹配,比如s="aa",p="b*aa"
,此时p[0]
只能匹配0次
2.10.2 AC代码
- 我们初步写出代码
//关于通配符
if(s.charAt(i) == p.charAt(j) || p.charAt(j) == '.'){
//匹配到啦
if(j<p.size()-1 && p[j+1] == '*'){//防止越界
//有*通配符,可以匹配0次或者多次
}else{
//无*通配符,那么直接双指针步进
i++;j++;
}
}else{
//不匹配
if(j<p.size()-1 && p[j+1] == '*'){
//有*通配符,只能匹配0次
}else{
//无*通配符,匹配无法进行下去
return false;
}
}
- 那么我们看一下,这个过程,是不是一个在做
选择
的过程?我们改造这道题的解法,就是解析这道题中出现的状态
和选择
状态
:指针i和j步进过程中产生的变化选择
:p[j]
选择匹配多少个字符?
- 根据状态,可以设计一个
dp
函数
boolean dp(String s,int i,String p,int j);
//dp函数是dp数组之母
//如果dp(s,i,p,j)=true,那么就表示s[i...]可以匹配p[j...]
//如果dp(s,i,p,j)=false,那么就表示s[i...]无法匹配p[j...]
//根据这个定义,我们想要的答案就是i=0,j=0时,dp函数的结果
boolean dp(String s,int i,String p,int j){
//首先我们来处理匹配的这种情形
if(s.charAt(i) == p.charAt(j) || p.charAt(j) == '.'){
if(j<p.length()-1 && p.charAt(j+1) == '*'){
//通配符匹配0次或者n次
return dp(s,i,p,j+2) //所谓j+2,就是吧x*看作为一个整体,因为都匹配不上了,我就直接向后走两格
|| dp(s,i+1,p,j);//所谓i+1,就是把x*看作为一个整体,因为这时候我s[i]匹配上了,我就直接i向后走一格,继续匹配
}else{
return dp(s,i+1,p,j+1);//未出现特殊字符的匹配情况,直接双指针步进
}
}else{
//不匹配
if(j<p.length()-1 && p.charAt(j+1) == '*'){
//就是匹配0次了
return dp(s,i,p,j+2);//有通配符出现,直接j+2
}else{
return false;
}
}
}
//bug!这个函数主要是用来体现逻辑的,还没有做base-case来收敛递归
- 写出
base-case:
当j==p.size()
时,按照dp
函数的定义,意味着模式串p
都被匹配完了,那么应该看看文本串s
匹配到哪里了,如果文本串也匹配完毕,那么就证明匹配成功了 - 当
i==s.size()
的时候,意味着s串就被全部匹配完了,只要这时候,p串剩下的东西能够匹配空串,那么就说明匹配成功 - 我们补全代码,同时注意到递归函数,我们发现i,j+2,i+1,j+2这四种下标存在着重叠子问题,我们使用一个备忘录来优化算法
class Solution {
private Map<String,Boolean> map;
public boolean isMatch(String s, String p) {
map = new HashMap<>();
return dp(s,0,p,0);
}
boolean dp(String s,int i,String p,int j){
if(j==p.length()){
return i == s.length();
}
if(i==s.length()){
//查看是否能够匹配空串即可
//如果能够匹配空串,那么就一定是一个字符和*成对出现的
if((p.length()-j)%2 == 1 ){
return false;
}
for(;j+1<p.length();j+=2){
if(p.charAt(j+1)!='*'){
return false;
}
}
return true;
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(i);
stringBuilder.append(",");
stringBuilder.append(j);
Boolean res = map.get(stringBuilder.toString());
//System.out.println(stringBuilder.toString()+","+res);
if(res != null){
return res;
}
boolean resNow;
//首先我们来处理匹配的这种情形
if(s.charAt(i) == p.charAt(j) || p.charAt(j) == '.'){
if(j<p.length()-1 && p.charAt(j+1) == '*'){
//通配符匹配0次或者n次
resNow = dp(s,i,p,j+2) || dp(s,i+1,p,j);
}else{
resNow = dp(s,i+1,p,j+1);//双指针步进
}
}else{
//不匹配
if(j<p.length()-1 && p.charAt(j+1) == '*'){
//就是匹配0次了
resNow = dp(s,i,p,j+2);//p指针
}else{
resNow = false;
}
}
map.put(stringBuilder.toString(),resNow);
return resNow;
}
}
2.11 四键键盘(不同定义产生不同的解法)
2.11.1 思路分析
-
假设你有一个特殊的检票,上面只有四个键,它们分别是
- A键,在屏幕上显示一个A
- Ctrl-A:选中整个屏幕
- Ctrl-C:将选中的区域复制到缓冲区
- Ctrl-V:将缓冲区的内容输出到光标所在的屏幕位置
-
现在要求你只能进行N次操作,请你计算屏幕上最多能够显示多少个A?
-
我们看到这个问题,其实就想到,其实这是个可以穷举的问题,我有N个位置,每个位置可以放4个中的其中一种操作,于是总情况数就是(4n)种,穷举出能够得到所有情况,然后求极值,这是最暴力的做法,时间复杂度到达了O(4n),绝对会超时
-
于是我们思考,穷举走不通,那么我们就剪枝呗,假如说我走到某一步,发现这时候
基于之前的计算结果,这一次的选择可以被确定
,从而推出最终的答案,一想,这就是动态规划 -
动态规划三步走
状态
:找状态的关键就是要抓到底是什么量在变化?- 第一个状态,剩余的按键次数,用n来表示
- 第二个状态,当前屏幕上字符A的数量,我们用a_num来表示
- 第三个状态,剪切板中字符A的数量,我们用copy来表示
选择
:选择明确明显,就是选择哪一种操作的问题base-case
:当n=0的时候,这时候,我们问题的答案就是a_num的数量
-
状态表示
dp(n-1,a_num+1,copy);//可操作次数-1,按下A
dp(n-1,a_num+copy,copy);//可操作次数-1,C-V粘贴
dp(n-2,a_num,a_num);//可操作次数-2,复制
2.11.2 AC代码
import org.junit.Test;
import java.util.HashMap;
import java.util.Map;
public class FourKey {
private Map<String, Integer> map = new HashMap<>();
int dp(int n,int aNum,int copy){
if(n<=0){
return aNum;
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(n+",");
stringBuilder.append(aNum+",");
stringBuilder.append(copy);
if(map.get(stringBuilder.toString())!=null){
return map.get(stringBuilder.toString());
}
int op1 = dp(n-1,aNum+1,copy);//按下A键
int op2 = dp(n-1,aNum+copy,copy);//进行粘贴
int op3 = dp(n-2,aNum,aNum);
map.put(stringBuilder.toString(),Math.max(Math.max(op1,op2),op3));
return map.get(stringBuilder.toString());
}
int maxA(int n){
return dp(n,0,0);
}
@Test
public void test(){
System.out.println(maxA(3));
System.out.println(maxA(7));
}
}
/*初步代码*/
- 这是dp函数的写法,我们想要改成为数组的话,那么就需要确定dp数组的定义,但是第一步就遇到了困难,就是数组到底应该开多大?我们知道第一个维度是好确定的,但是后面两个应该开多少?我们不知道,甚至到Int的极限最大值也是可能的,这将造成极大的空间浪费。于是我们设想,能否改造状态?因为目前的状态不适合用来做dp数组
- 最值问题,动态规划,那就少不了贪心算法,我们用贪心的想法来考虑这个问题
- 说到贪心,那就避免不了计算代价,要用尽可能小的代价,来获取尽可能大的收益,代价就是操作次数,收益就是A的个数
- 当字符数量N比较小的时候,我们一直按A的代价和带来的收益是比组合操作的代价带来的收益要高的
- 当字符数量N比较大的时候,我们用组合操作的代价带来的收益是要比较高的
- 但是代价和收益之间的平衡比较难以看出,我们使用穷举的办法来解决,直接暴力比较
- 而且为了避免"浪费",我们的最后一次操作,一定是按A或者是将剪切板的字母贴下来
- 最优序列必然是
AAAAA
、CA-CC-CV
的有限次组合
int[] dp = new int[n+1];
//定义dp[i]表示i次操作之后最多能显示多少个A
for(int i = 0;i<=n;i++){
dp[i] = max(这次按A键,这次按C-V键);
dp[i]=dp[i-1]+1;//按A键,比上一次操作多一个A
}
int maxA(int n){
int[] dp = new int[n+1];
dp[0]=0;
for(int i = 1;i<=n;i++){
dp[i] = dp[i-1]+1;
for(int j=2;j<i;j++){
dp[i]=Math.max(dp[i],dp[j-2]*(i-j+1));//一共有i-j次复制粘贴,+1是包括自己
}
}
return dp[n];
}
2.12 高楼扔鸡蛋
2.12.1 思路分析
-
有若干层高的楼的若干个鸡蛋,让你计算出最少的尝试次数,找到鸡蛋恰好摔不碎的那层楼。
-
这道题的题目还是很复杂的,首先给出N层楼高,K个鸡蛋,给出限制条件,最少的扔鸡蛋次数,求出临界楼层高度
-
这三个变量是互相牵制的,其中最少扔鸡蛋次数是临界条件,从而能够求出临界楼层的高度
-
假设面前有一栋从
1到N
的N层
楼房,给你K个鸡蛋
,K至少为1
,现在确定这栋楼存在楼层0<=F<=N
,在这层楼将鸡蛋丢下去,鸡蛋恰好没摔碎(高于F的楼层都会碎,低于F的楼层都不会碎
),在最坏的情况下,至少要扔几次鸡蛋,
才能确定这个楼层F? -
什么叫
最坏情况下,至少要扔几次鸡蛋?
- 比如现在先不管鸡蛋个数的限制,有7层楼,最简单的方式就是线性扫描,我在1楼扔一次,在2楼扔一次,最后再去三楼,最坏情况下就是我试到最高的那一层鸡蛋也没碎,也就是扔了7次鸡蛋,
鸡蛋破碎一定发生在搜索区间穷尽的时候
- 所谓至少就是使用尽可能少的实验次数来进行操作
- 比如现在先不管鸡蛋个数的限制,有7层楼,最简单的方式就是线性扫描,我在1楼扔一次,在2楼扔一次,最后再去三楼,最坏情况下就是我试到最高的那一层鸡蛋也没碎,也就是扔了7次鸡蛋,
-
那么这道题下,我们可以很明显感觉到,有着穷举的思路,也就是从某一层楼开始,以某个步长开始做实验,步长可变,开始的楼层可变,然后不断的搜索,直到我们建模出来的图穷尽为止,这是最暴力的做法,时间复杂度绝对是爆的,因为步长是0n,开始楼层选择也是1n。但是每次迭代的时候,是不是有剪枝的可能呢,我们能否能够通过之前计算出来的结果,来推出最后的结果呢?
-
因此本题可以采用动态规划的思路来做,分三步走
-
状态,就是会发生变化的量,在本题中就是当前拥有的鸡蛋个数k和还需要测试的楼层数N
-
选择,其实就是去选择哪层楼扔鸡蛋
-
选择是应该有依据的,也就是产生代价和收益的过程,我们扔下鸡蛋:
-
如果鸡蛋碎了,那么鸡蛋的个数K应该要减一,搜索的楼层区间应该要从
[1...N]变为[1..i-1]
,共i-1层楼
如果鸡蛋没碎,那么鸡蛋的个数K不变,搜索的楼层区间应该从
[1...N]
变为[i+1…N],共N-i层楼
-
因为要求的是最坏情况下扔鸡蛋的次数,所以鸡蛋在第i层楼碎没碎,取决于哪种情况的结果更大,怎么理解呢?也就是说我必须要确定这个楼层数,我必须要穷举完所有的可能性,因此做实验的次数是要最多的,否则无法确定
-
-
2.12.2 dp函数函数代码
- 首先根据我们的分析,先写出基本的代码
//当前状态为k个鸡蛋,面对N层楼
//返回这个状态下的最优结果(函数返回扔鸡蛋的次数)
int dp(int k,int n){
int res;
for(int i =1;i<=n;i++){
res = min(res,这次选择在第i层扔鸡蛋)
}
return res;
}
- 定义
base-case
:对于这道题而言,当递归到鸡蛋数为1的时候,需要线性搜索当前区间的所有值,当递归到楼层数为1或者0的时候,
import org.junit.Test;
import java.util.HashMap;
import java.util.Map;
public class SuperEggDropByDpFunc {
private Map<String,Integer> memo;
private int dp(int k,int n){
//写出base-case
if(k==1){
return n;
}
if(n==0 || n==1){
return 0;
}
String key = k+","+n;
Integer num = memo.get(key);
if(num!=null){
return num;
}
int res = Integer.MAX_VALUE;
for(int i = 1;i<=n;i++){
res = Math.min(res,Math.max(dp(k,n-i),dp(k-1,i-1))+1);//+1是因为用了一次扔鸡蛋的次数
}
memo.put(key,res);
return res;
}
public int superEggDrop(int K, int N) {
memo = new HashMap<>();
return dp(K,N);
}
@Test
public void test(){
System.out.println(superEggDrop(1,2));
System.out.println(superEggDrop(2,6));
System.out.println(superEggDrop(3,14));
}
}
- 上述的代码交上去,发现TLE了,我们来分析一下时间复杂度
- 首先dp函数中有一个for循环,所以函数本身的复杂度就是O(N),子问题个数就是不同状态组合的总数,显然就是两个状态的乘积,也就是O(KN),所以算法的时间复杂度就是O(KN^2),空间复杂度是O(KN)
- 这个问题有更好的方法来解的,首先我们就可以修改代码中的for循环,把其中一个N拿出来化为O(logN)
2.13 高楼扔鸡蛋优化
2.13.1 二分搜索优化
-
首选根据
dp(K,N)
数组的定义(给你K
个鸡蛋面对N
层楼的时候,最少需要扔几次),很容易知道当K固定的时候,这个函数随着N的增加一定是单调递增的,楼层测试的次数是一定会增加的 -
但我们注意
dp(k-1,i-1)
和dp(k,n-i)
两个函数,其中i是从1到N
单调递增的,如果固定K和N,把这两个函数看作关于i的函数,前者随着i的增加是单调递增的,而后者随着i的增加应该是单调递减的。- 可以根据楼层数与鸡蛋数之间的关系来确定
- 每次进行dp,都相当于从第0层开始dp
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4DJL3jmS-1658767022825)(.\image\887_fig1.jpg)]
-
这时候求二者的较大值,再求这些最大值之中的最小值,其实就是求这两条直线的交点,
-
其实这让我联想到了二分法求一元二次方程的根,也就是求山谷值,我们直接上二分法来快速寻找这个交点
class Solution {
private Map<String,Integer> memo;
private int dp(int k,int n){
if(k==1){
return n;
}
if(n==0){
return 0;
}
String key =k+","+n;
if(memo.get(key)!=null){
return memo.get(key);
}
int res = Integer.MAX_VALUE;
int lo = 1;
int hi = n;
while(lo <= hi){
int mid = (lo+hi)/2;
int broken = dp(k-1,mid-1);
int notBroken = dp(k,n-mid);
if(broken > notBroken){
hi = mid -1;
res = Math.min(res,broken+1);
}else{
lo = mid +1 ;
res = Math.min(res,notBroken+1);
}
}
memo.put(key,res);
return res;
}
public int superEggDrop(int K, int N) {
memo = new HashMap<>();
return dp(K,N);
}
}
2.13.2 重新定义状态转移
- 在上述方法中,设立的dp函数的含义是
int dp(int k,int n)
{
return res;
}
//当前状态为k个鸡蛋,面对n层楼
//返回这个状态下最少的扔鸡蛋次数
- 我们将该函数换成dp数组
dp[k][n] = m
//当前状态为k个鸡蛋,面对n层楼
//这个状态下最少的扔鸡蛋次数为m
- 现在,稍微修改dp数组的定义,确定当前的鸡蛋个数和最多允许的扔鸡蛋次数,就能够确定F的最高楼层层数
dp[k][m] = n;
//当前有k个鸡蛋,最多可以尝试扔m次
//在这个状态下,最坏情况下最多能确切测试移动n层的楼
//比叡说dp[1][7]=7表示
//现在有1个鸡蛋,允许你扔7次
//这个状态下最多给你7层楼
//使得你可以确定从楼层F扔鸡蛋而恰好摔不碎
- 在这种情况下,我们发现我们想要求的扔鸡蛋次数m变成了状态,而不是结果,那么可以这样处理
- 题目给了k个鸡蛋和n层楼,求的是最坏情况下最少的测试次数m
- while()循环结束的条件是
dp[k][m] == N
- 也就是给k个鸡蛋,测试m次,最坏情况下最多能测试N层楼
- 在这种情况下,无论在哪层楼扔鸡蛋,鸡蛋只可能摔碎或者没摔碎,碎了的话就测楼下,没碎的话就测楼上
- 无论上楼还是下楼,总的楼层数=楼上楼层数+楼下楼层数+1
dp[k][m] = dp[k][m-1] + dp[k-1][m-1]+1
int superEggDrop(int k,int n){
int m = 0;
while(dp[k][m]<N){
m++;
//实施状态转移
}
return m;
}
class Solution {
public int superEggDrop(int k, int n) {
//m最多不会超过n次(线性扫描情况)
int[][] dp = new int[k+1][n+1];
//base-case
//dp[0][...]=0
//dp[...][0]=0
int m = 0;
while(dp[k][m]<n){
m++;
for(int j = 1;j<=k;j++){
dp[j][m] = dp[j][m-1]+dp[j-1][m-1]+1;
}
}
return m;
}
}