算法套路学习笔记(第二章) 动态规划系列 2.9-2.13

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]

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或者是将剪切板的字母贴下来
    • 最优序列必然是AAAAACA-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到NN层楼房,给你K个鸡蛋K至少为1,现在确定这栋楼存在楼层0<=F<=N,在这层楼将鸡蛋丢下去,鸡蛋恰好没摔碎(高于F的楼层都会碎,低于F的楼层都不会碎),在最坏的情况下,至少要扔几次鸡蛋,才能确定这个楼层F?

  • 什么叫最坏情况下,至少要扔几次鸡蛋?

    • 比如现在先不管鸡蛋个数的限制,有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;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值