什么暴力递归可以继续优化?
- 有重复调用同一个子问题的解,这种递归可以优化。
- 如果每一个子问题都是不同的解,无法优化也不用优化。
暴力递归和动态规划的关系
- 某一个暴力递归,有解的重复调用,就可以把这个暴力递归优化成动态规划,任何动态规划问题都一定对应着某一个有解的重复调用的暴力递归但不是所有的暴力递归,都一定对应着动态规划。
面试题和动态规划的关系
- 解决一个问题,可能有很多尝试方法,可能在很多尝试方法中,又有若干个尝试方法有动态规划的方式,一个问题可能有若干种动态规划的解法。
如何找到某个问题的动态规划方式?
1)设计暴力递归:重要原则+4种常见尝试模型!重点!
2)分析有没有重复解:套路解决
3)用记忆化搜索 -> 用严格表结构实现动态规划:套路解决
4)看看能否继续优化:套路解决
面试中设计暴力递归过程的原则
1)每一个可变参数的类型,一定不要比int类型更加复杂
2)原则1)可以违反,让类型突破到一维线性结构,那必须是单一可变参数
3)如果发现原则1)被违反,但不违反原则2),只需要做到记忆化搜索即可
4)可变参数的个数,能少则少
知道了面试中设计暴力递归过程的原则,然后呢?
一定要逼自己找到不违反原则情况下的暴力尝试!
如果你找到的暴力尝试,不符合原则,马上舍弃!找新的!
如果某个题目突破了设计原则,一定极难极难,面试中出现概率低于5%!
常见的4种尝试模型
1)从左往右的尝试模型
2)范围上的尝试模型
3)多样本位置全对应的尝试模型
4)寻找业务限制的尝试模型
如何分析有没有重复解
列出调用过程,可以只列出前几层
有没有重复解,一看便知
暴力递归到动态规划的套路
1)你已经有了一个不违反原则的暴力递归,而且的确存在解的重复调用
2)找到哪些参数的变化会影响返回值,对每一个列出变化范围
3)参数间的所有的组合数量,意味着表大小
4)记忆化搜索的方法就是傻缓存,非常容易得到
5)规定好严格表的大小,分析位置的依赖顺序,然后从基础填写到最终解
6)对于有枚举行为的决策过程,进一步优化
动态规划的进一步优化
1)空间压缩
2)状态化简
3)四边形不等式
其他优化技巧略
例题
- 假设有排成一行的N个位置,记为1~N,N 一定大于或等于 2,开始时机器人在其中的M位置上(M 一定是 1~N 中的一个),如果机器人来到1位置,那么下一步只能往右来到2位置;如果机器人来到N位置,那么下一步只能往左来到 N-1 位置;如果机器人来到中间位置,那么下一步可以往左走或者往右走;规定机器人必须走 K 步,最终能来到P位置(P也是1~N中的一个)的方法有多少种?给定四个参数 N、M、K、P,返回方法数。
public class PathNum {
// N:有N个位置,从1开始
// M:起始位置
// K:必须走K步
// P:目标点P
public static int pathNum(int N,int M,int K,int P){
if(N <= 0 || M < 0 || M > N || P < 0 || P > N ){
return 0;
}
return walkNum(N,M,K,P);
}
// N 个位置
// cur当前所在位置
// rest还剩余的步数
// P 目标点
// 函数代表此时机器人在cur点,还剩下rest步,需要到P点,还要多少种方式
private static int walkNum(int N,int cur,int rest,int P){
if(rest == 0){
// 如果剩余步数为0,则当前位置时P则有一种方法,否则不是有效路径
return cur == P ? 1 : 0;
}
// 如果当前点来到起始点,则只能向右走来到点2
if(cur == 1){
return walkNum(N,2,rest - 1,P);
}
// 如果当前点来到终点则只能向左走来到N-1
if(cur == N){
return walkNum(N,N - 1,rest - 1,P);
}
int res = 0;
// cur在普遍点既可以向左,又可以向右
res = walkNum(N,cur + 1,rest - 1,P);
res += walkNum(N,cur - 1,rest - 1,P);
return res;
}
// 缓存法
public static int pathNum2(int N,int M,int K,int P){
if(N <= 0 || M < 0 || M > N || P < 0 || P > N ){
return 0;
}
// 定义一个二维数组存已经计算过的值,横坐标是当前位置,纵坐标是剩余步数
int[][] dp = new int[N + 1][K + 1];
for(int i = 0;i <= N;i++){
for (int j = 0; j <= K; j++) {
dp[i][j] = -1;
}
}
return walkNum2(N,M,K,P,dp);
}
private static int walkNum2(int N,int cur,int rest,int P,int[][] dp){
if(dp[cur][rest] != -1){
return dp[cur][rest];
}
if(rest == 0){
// 如果剩余步数为0,则当前位置时P则有一种方法,否则不是有效路径
dp[cur][rest] = cur == P ? 1 : 0;
return dp[cur][rest] ;
}
// 如果当前点来到起始点,则只能向右走来到点2
if(cur == 1){
dp[cur][rest] = walkNum2(N,2,rest - 1,P,dp);
return dp[cur][rest];
}
// 如果当前点来到终点则只能向左走来到N-1
if(cur == N){
dp[cur][rest] = walkNum2(N,N - 1,rest - 1,P,dp);
return dp[cur][rest];
}
int res = 0;
// cur在普遍点既可以向左,又可以向右
res = walkNum2(N,cur + 1,rest - 1,P,dp);
res += walkNum2(N,cur - 1,rest - 1,P,dp);
dp[cur][rest] = res;
return dp[cur][rest];
}
private static int walkNum3(int N,int M,int K,int P){
int[][] dp = new int[N + 1][K + 1];
dp[P][0] = 1;
for(int i = 1;i <= K;i++){
for (int j = 1; j <= N; j++) {
if(j > 1 && j < N ){
dp[j][i] = dp[j - 1][i-1] + dp[j + 1][i-1];
}else if(j == 1){
dp[j][i] = dp[j + 1][i-1];
}else if(j == N){
dp[j][i] = dp[j - 1][i-1];
}
}
}
return dp[M][K];
}
public static void main(String[] args) {
System.out.println(pathNum(7, 3, 3, 4));
System.out.println(pathNum2(7, 3, 3, 4));
System.out.println(walkNum3(7, 3, 3, 4));
}
}
- 定数组arr,arr中所有的值都为正数且不重复每个值代表一种面值的货币,每种面值的货币可以使用任意张再给定一个整数 aim,代表要找的钱数求组成 aim 的方法数。
/**
* 给定数组arr,arr中所有的值都为正数且不重复
* 每个值代表一种面值的货币,每种面值的货币可以使用任意张
* 再给定一个整数 aim,代表要找的钱数
* 求组成 aim 的方法数
*/
public class FindMoney {
// 暴力递归
public static int findMoney(int[] arr,int aim){
if(arr == null || arr.length == 0 || aim <=0){
return 0;
}
return process(arr,0,aim);
}
// index 当前使用的index位置的金额
// rest 还有rest这么多钱没有找
// 函数意义:当前使用index位置及以后的金额,张数不限,筹够rest这么多钱的方法数返回
private static int process(int[] arr,int index,int rest){
// 调函数的时候已经保证rest 不可能小于0,所以这里可以去掉
// if(rest < 0){
// // 如果已经没有剩余钱数可以找,且是负数,则前面所选的方案都不正确
// return 0;
// }
if(index == arr.length){
// 如果所有面值的面额都已经选了,如果刚好剩余钱数等于0,那么有一种方法,否则没有
return rest == 0 ? 1 : 0;
}
// 金额既没有选完,且剩余钱数也不等于0
// 普遍位置,index 可以选择0张或者一张二张。。。。
int res = 0;
for(int num = 0;num*arr[index] <= rest;num++){
// 当前位置可以选择任意张数,但是不能超过剩余的钱数
// 当前位置决定好后交给后续过程继续
res += process(arr,index + 1,rest - num*arr[index]);
}
return res;
}
// 记忆化搜索
public static int findMoney2(int[] arr,int aim){
if(arr == null || arr.length == 0 || aim <=0){
return 0;
}
int N = arr.length;
int[][] dp = new int[N+1][aim+1];
for (int i = 0; i <= N; i++) {
for (int j = 0; j <= aim; j++) {
dp[i][j] = -1;
}
}
return process2(arr,0,aim,dp);
}
// 记忆化搜索
private static int process2(int[] arr,int index,int rest,int[][] dp){
if(dp[index][rest] != -1){
return dp[index][rest];
}
if(index == arr.length){
dp[index][rest] = rest == 0 ? 1 : 0;
return dp[index][rest];
}
int res = 0;
for(int num = 0;num*arr[index] <= rest;num++){
// 当前位置可以选择任意张数,但是不能超过剩余的钱数
// 当前位置决定好后交给后续过程继续
res += process(arr,index + 1,rest - num*arr[index]);
}
dp[index][rest] = res;
return res;
}
// 动态规划
public static int findMoney3(int[] arr,int aim){
if(arr == null || arr.length == 0 || aim <=0){
return 0;
}
int N = arr.length;
// 定义dp表
int[][] dp = new int[N+1][aim+1];
// 第N行的rest == 0 ? 1 : 0;
dp[N][0] = 1;
// 普遍位置都只依赖下一行,每一行从左往又填就可以了
for(int index = N-1;index >=0;index--){
for(int rest = 0;rest <= aim;rest++){
int res = 0;
for(int num = 0;num*arr[index] <= rest;num++){
// 当前位置可以选择任意张数,但是不能超过剩余的钱数
// 当前位置决定好后交给后续过程继续
res += dp[index + 1][rest - num*arr[index]];
}
dp[index][rest] = res;
}
}
return dp[0][aim];
}
// 优化动态规划
private static int findMoney4(int[] arr,int aim){
if(arr == null || arr.length == 0 || aim <=0){
return 0;
}
int N = arr.length;
// 定义dp表
int[][] dp = new int[N+1][aim+1];
// 第N行的rest == 0 ? 1 : 0;
dp[N][0] = 1;
// 可以发现方法三的最里层循环其实有枚举行为是可以省略掉的每一个普遍位置的值
// 依赖他当前位置减去此时面值金额的位置加上此时他下方的值
for(int index = N-1;index >=0;index--){
for(int rest = 0;rest <= aim;rest++){
int res = 0;
if(rest - arr[index] >=0){
res = dp[index][rest - arr[index]];
}
dp[index][rest] = res + dp[index+1][rest];
}
}
return dp[0][aim];
}
public static void main(String[] args) {
int[] arr = new int[]{4,2,3,5,10,20};
System.out.println(findMoney(arr,100));
System.out.println(findMoney2(arr,100));
System.out.println(findMoney3(arr,100));
System.out.println(findMoney4(arr,100));
}
}
- 给定一个字符串str,给定一个字符串类型的数组arr。
arr里的每一个字符串,代表一张贴纸,你可以把单个字符剪开使用,目的是拼出str来。
返回需要至少多少张贴纸可以完成这个任务。
例子:str= "babac",arr = {"ba","c","abcd"}
至少需要两张贴纸"ba"和"abcd",因为使用这两张贴纸,把每一个字符单独剪开,含有2个a、2个b、1个c。是可以拼出str的。所以返回2。
class Solution {
public int minStickers(String[] arr, String str) {
if(str == null || arr == null || arr.length == 0){
return 0;
}
// 获取每一张贴纸字符的个数
// 申请一个二维数组,每一行代表一个字符,26个列代表26个小写字母
int N = arr.length;
int[][] stickers = new int[N][26];
char[] chars = null;
for (int i = 0; i < N; i++) {
chars = arr[i].toCharArray();
for(int j = 0;j < chars.length;j++){
stickers[i][chars[j] - 'a'] ++;
}
}
Map dp = new HashMap<>();
dp.put("",0);
return process(str,stickers,dp);
}
// 字符串rest , 字符数组arr,返回需要的字符个数
private int process(String rest,int[][] stickers,Map dp){
if(dp.containsKey(rest)){
return dp.get(rest);
}
int[] aim = new int[26];
// 统计剩余字符串每个字符的个数
char[] chars = rest.toCharArray();
for (int i = 0; i < chars.length; i++) {
aim[chars[i] - 'a'] ++;
}
// 用当前的贴纸
int res = Integer.MAX_VALUE;
int temp = 0;
for(int i = 0;i < stickers.length;i++){
if(stickers[i][chars[0] - 'a'] == 0){
// 如果当前贴纸不包含目标的第一个字符则跳过
continue;
}
StringBuffer nextStr = new StringBuffer();
for (int j = 0; j < aim.length; j++) {
if(aim[j] > 0){
for (int k = 0; k < Math.max(0,aim[j] - stickers[i][j]); k++) {
nextStr.append((char)(j+'a'));
}
}
}
temp = process(nextStr.toString(),stickers,dp);
if(temp != -1){
res = Math.min(res,temp+1);
}
}
dp.put(rest,res == Integer.MAX_VALUE ? -1 : res);
return dp.get(rest);
}
}
- 两个字符串的最长公共子序列问题
/**
* 两个字符串的最长公共子序列问题
* 多样本位置全对应,这里是两个样本一个样本左行一个样本做列
*/
public class MaxCommonSubSqu {
public static int maxCommonSubSqu(String str1,String str2){
if(str1 == null || str2 == null){
return 0;
}
//定义dp表
int[][] dp = new int[str1.length()][str2.length()];
// 填写第一行
char[] c1 = str1.toCharArray();
char[] c2 = str2.toCharArray();
for(int j = 0;j < c2.length;j++){
dp[0][j] = c1[0] == c2[j] ? 1 : (j - 1 >= 0) ? Math.max(0,dp[0][j-1]):0;
}
for(int i = 0;i < c2.length;i++){
dp[i][0] = c1[i] == c2[0] ? 1 : (i - 1 >= 0) ? Math.max(0,dp[0][i-1]):0;
}
// 四种可能
// 1 最长子序列以 i j 结尾 2 以i结尾不以j结尾 3 以j结尾不以i结尾 4 以i j 结尾
for(int i = 1;i < c1.length;i++){
for (int j = 1;j < c2.length;j++){
dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]);
if(c1[i] == c2[j]){
// 以i j 结尾
// 不用比较 i-1 j-1 因为在求上一个点的时候 i-1 j-1这个位置已经比较过了
dp[i][j] = Math.max(dp[i][j],dp[i - 1][j - 1] + 1);
}
}
}
return dp[c1.length-1][c2.length-1];
}
public static void main(String[] args) {
String str1 = "abcbgh";
String str2 = "acj";
System.out.println(maxCommonSubSqu(str1, str2));
}
}
- 给定一个数组,代表每个人喝完咖啡准备刷杯子的时间
只有一台咖啡机,一次只能洗一个杯子,时间耗费a,洗完才能洗下一杯
每个咖啡杯也可以自己挥发干净,时间耗费b,咖啡杯可以并行挥发
返回让所有咖啡杯变干净的最早完成时间
三个参数:int[] arr、int a、int b
/**
* 给定一个数组,代表每个人喝完咖啡准备刷杯子的时间
* 只有一台咖啡机,一次只能洗一个杯子,时间耗费a,洗完才能洗下一杯
* 每个咖啡杯也可以自己挥发干净,时间耗费b,咖啡杯可以并行挥发
* 返回让所有咖啡杯变干净的最早完成时间
* 三个参数:int[] arr、int a、int b
*/
public class WashCup {
public static int washCup(int[] arr,int a,int b){
if(arr == null || arr.length == 0){
return -1;
}
if(a >= b){
// 全部挥发
return arr[arr.length - 1] + b;
}
// 当前来到0号喝咖啡,机器0点时间点可用,返回洗干净咖啡杯的最早完成时间。
return process(arr,a,b,0,0);
}
// 暴力递归
// 来到某一个喝完咖啡是,可以选择机洗,也可以选择挥发,从左向右的尝试模型,但是洗杯子的时间需要业务分析
// index 当前来到某个人喝完咖啡,选择清洗方式,
// washTime 选择洗咖啡,咖啡机什么时间可用
private static int process(int[] arr,int a,int b,int index,int washTime){
// 如果已经来到了最后一杯咖啡时
if(index == arr.length - 1){
// 挥发 和 机洗选最小
return Math.min(Math.max(washTime,arr[index])+a,arr[index]+b);
}
// 机洗
// 当前咖啡杯机器洗完来到的时间点
int wash = Math.max(washTime,arr[index]) + a;
// 后续杯子递归取洗
int res = process(arr, a, b, index + 1, wash);
// 决定最后时间的是看当前时间和后续洗杯子的时间那个最晚,所以取其中最大的
int p1 = Math.max(wash,res);
// 挥发
// 挥发的咖啡机可用时间不变
int hui = arr[index] + b;
// 后续杯子清洗时间
int res2 = process(arr, a, b, index + 1, washTime);
int p2 = Math.max(hui,res2);
return Math.min(p1,p2);
}
// 动态规划(根据暴力递归改)
public static int washCup2(int[] arr,int a,int b){
if(arr == null || arr.length == 0){
return -1;
}
if(a >= b){
// 全部挥发
return arr[arr.length - 1] + b;
}
int N = arr.length;
// washTime 的取值范围是多少? 所有杯子都有咖啡机洗
int washTime = 0;
for (int i = 0;i < N;i++){
washTime = Math.max(arr[i],washTime) + a;
}
// 定义dp表
int[][] dp = new int[N][washTime+1];
// 先填最后一行
for(int col = 0; col <= washTime;col++){
dp[N-1][col] = Math.min(Math.max(col,arr[N - 1])+a,arr[N - 1]+b);
}
for (int i = N-2; i >= 0; i--) {
for (int j = 0; j <= washTime; j++) {
int wash = Math.max(j,arr[i]) + a;
// 由于wash会变化,需要判断是否越界
int res = 0;
if(wash <= washTime){
res = dp[i + 1][wash];
}
int p1 = Math.max(wash,res);
int hui = arr[i] + b;
int res2 = dp[i + 1][j];
int p2 = Math.max(hui,res2);
dp[i][j] = Math.min(p1,p2);
}
}
return dp[0][0];
}
public static void main(String[] args) {
int[] arr = new int[]{0,5,6,7,8,9,10,13,15,17,19,20};
int a = 3;
int b = 10;
System.out.println(washCup(arr,a,b));
System.out.println(washCup2(arr,a,b));
}
}