13.动态规划

什么暴力递归可以继续优化?

有重复调用同一个子问题的解,这种递归可以优化。如果每一个子问题都是不同的解,无法优化也不用优化。

暴力递归和动态规划的关系

某一个暴力递归,有解的重复调用,就可以把这个暴力递归优化成动态规划。任何动态规划问题,都一定对应着某一个有解的重复调用的暴力递归。但不是所有的暴力递归,都一定对应着动态规划。

面试题和动态规划的关系

解决一个问题,可能有很多尝试方法,可能在很多尝试方法中,又有若干个尝试方法有动态规划的方式。一个问题可能有若干种动态规划的解法。

如何找到某个问题的动态规划方式?

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,返回方法数。

package com.harrison.class13;

public class Code01_RobotWalk {
    
    
	// N:位置为1~N,固定参数
	// cur:当前在cur位置,可变参数
	// rest:还剩rest步可以走 可变参数
	// P:最终目标位置是P,固定参数
	// 该函数的含义:只能在1~N这些位置上移动,当前在cur位置,走完rest步后,停在P位置的方法数
	public static int walk1(int N, int cur, int rest, int P) {
    
    
		// 如果没有剩余步数了,当前的cur位置就是最后的位置
		// 如果最后的位置停在P上,那么之前做的移动是有效的
		// 如果最后的位置没在P上,那么之前的移动是无效的
		if (rest == 0) {
    
    
			return cur == P ? 1 : 0;
		}
		// 如果还有rest步要走,而当前的cur位置在1位置上,那么当前这步只能从1走向2
		// 后续的过程就是,来到2位置上,还剩rest-1步要走
		if (cur == 1) {
    
    
			return walk1(N, 2, rest - 1, P);
		}
		// 如果还有rest步要走,而当前的cur位置在N位置上,那么当前这步只能从N走向N-1
		// 后续的过程就是,来到N-1位置上,还剩rest-1步要走
		if (cur == N) {
    
    
			return walk1(N, N - 1, rest - 1, P);
		}
		// 如果还有rest步要走,而当前的cur位置在中间,那么当前这步可以走向左,也可以走向右
		// 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走
		// 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走
		// 走向左、走向右是截然不同的方法,所以总方法数都要算上
		return walk1(N, cur + 1, rest - 1, P) + walk1(N, cur - 1, rest - 1, P);
	}

	public static int ways1(int N, int M, int K, int P) {
    
    
		// 参数无效直接返回0
		// 位置少于两个 机器人必须至少走1步 机器人不在1~N位置上(越过左界||右界)
		// 最终目标位置不在1~N上
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
    
    
			return 0;
		}
		// 总共N个位置,从M点出发,还剩K步,返回最终能到达P的方法数
		return walk1(N, M, K, P);
	}

	// 把所有cur和rest的组合,返回的结果,加入到缓存里
	public static int walk2(int N, int cur, int rest, int P, int[][] dp) {
    
    
		if (dp[cur][rest] != -1) {
    
    
			return dp[cur][rest];
		}
		if (rest == 0) {
    
    
			dp[cur][rest] = cur == P ? 1 : 0;
			return dp[cur][rest];
		}
		if (cur == 1) {
    
    
			dp[cur][rest] = walk2(N, 2, rest - 1, P, dp);
			return dp[cur][rest];
		}
		if (cur == N) {
    
    
			dp[cur][rest] = walk2(N, N - 1, rest - 1, P, dp);
			return dp[cur][rest];
		}
		dp[cur][rest] = walk2(N, cur - 1, rest - 1, P, dp) + walk2(N, cur + 1, rest - 1, P, dp);
		return dp[cur][rest];
	}

	public static int ways2(int N, int M, int K, int P) {
    
    
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
    
    
			return 0;
		}
		int[][] dp = new int[N + 1][K + 1];
		for (int row = 0; row <= N; row++) {
    
    
			for (int col = 0; col <= K; col++) {
    
    
				dp[row][col] = -1;
			}
		}
		return walk2(N, M, K, P, dp);
	}

	public static void main(String[] args) {
    
    
		System.out.println(ways1(5, 2, 4, 6));
		System.out.println(ways2(5, 2, 4, 6));
	}

}

此题动态规划的体现:暴力递归中有重复计算,给其加缓存,下回遇到同样一个过程,直接从缓存中拿结果

或者有另一个名字——记忆化搜索(动态规划中最糙的一种,不关心状态的依赖)

暴力递归的分析过程抽象出来就是动态规划的转义方程,任何一个动态规划都是由暴力尝试的尝试的那个种子改过来的。只要可变参数是有限几个,三个可变参数就是一张三维表,两个可变参数就是一张二维表,一个可变参数就是一张一维表。只要能试出由可变参数代表的一个暴力递归,就可以改成动态规划

注意:不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归,而暴力递归是跟自然智慧最贴合的,知道怎么拆,所以比编动态转义方程容易。

有些暴力递归改不成动态规划的原因是它没有足够多的重复过程。当然改还是可以改,但是没有必要

题 -> 找到暴力递归的写法(尝试)-> 分析暴力递归过程中是有重复解的(可变参数不讲究组织就是记忆化搜索,记忆化搜索进行精细化组织就是经典的动态规划)

题目二

给定一个数组arr,全是正数且没有重复值,数组元素是人民币面值大小,可以重复取值。常量aim==1000元,要求从数组中取元素使得面值加起来为1000,求有多少种方式?

package com.harrison.class13;

public class Code02_CoinsWay {
    
    
	// arr中都是正数且无重复值,返回组成aim的方法数
	public static int way1(int[] arr, int aim) {
    
    
		if (arr == null || arr.length == 0 || aim < 0) {
    
    
			return 0;
		}
		return process1(arr, 0, aim);
	}

	// 可以自由使用arr[index...]所有的面值,每一种面值都可以使用任意张,
	// 组成rest,有多少种方法
	public static int process1(int[] arr, int index, int rest) {
    
    
//		if (rest < 0) {
    
    
//			return 0;
//		}
		// rest >=0
		if (index == arr.length) {
    
     // 没有货币可以选择了
			return rest == 0 ? 1 : 0;
		}
		// 当前有货币
		int ways = 0;
		for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
    
    
			ways += process1(arr, index + 1, rest - (zhang * arr[index]));
		}
		return ways;
	}

	public static int way2(int[] arr, int aim) {
    
    
		if (arr == null || arr.length == 0 || aim < 0) {
    
    
			return 0;
		}
		int[][] dp=new int[arr.length+1][aim+1];
		// 一开始所有的过程都没有计算 dp[...][...]=-1
		for(int i=0; i<dp.length; i++) {
    
    
			for(int j=0; j<dp[0].length; j++) {
    
    
				dp[i][j]=-1;
			}
		}
		return process2(arr, 0, aim,dp);
	}

	// index和rest的参数组合,是没算过的,dp[index][rest]==-1
	// index和rest的参数组合,是算过的,dp[index][rest] > -1
	public 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 ways = 0;
		for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
    
    
			ways += process2(arr, index + 1, rest - (zhang * arr[index]),dp);
		}
		dp[index][rest]=ways;
		return ways;
	}
	
	public static int way3(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];
		dp[N][0]=1; // dp[N][1...aim] -> 0
		for(int index=N-1; index>=0; index--) {
    
    
			for(int rest=0; rest<=aim; rest++) {
    
    
				int ways = 0;
				for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
    
    
					ways += dp[index + 1][rest - (zhang * arr[index])];
				}
				dp[index][rest]=ways;
			}
		}
		return dp[0][aim];
	}

	public static void main(String[] args) {
    
    
		int[] arr = {
    
     5, 10, 50, 100 };
		int aim = 1000;
		System.out.println(way1(arr, aim));
		System.out.println(way2(arr, aim));
		System.out.println(way3(arr, aim));
	}
}

时间复杂度(背包问题举例)

  • 暴力递归:O(2^N)
  • 记忆化搜索:O(N*bag)
  • 经典动态规划:O(N*bag)

再强调一下,任何动态规划都是从最自然智慧的暴力递归出发弄出来的

题目三

给定一个字符串str,给定一一个字符串类型的数组arr。
arr里的每一个字符串,代表一张贴纸,你可以把单个字符剪开使用,目的是
拼出str来。
返回需要至少多少张贴纸可以完成这个任务。
例子: str= “babac”,arr = {“ba”,“c”,“abcd”}
至少需要两张贴纸"ba"和"abcd",因为使用这两张贴纸,把每一个字符单独剪开,含有2个a、2个b、1个c。是可以拼出str的。所以返回2。

可变参数能少则少,可变参数越少,缓存结构的命中率越高

package com.harrison.class13;

import java.util.HashMap;

public class Code03_StickersToSpellWord {
    
    
	public static int minStickers1(String[] stickers,String target) {
    
    
		int n=stickers.length;
		int [][] map=new int[n][26]; // stickers -> [26] [26] [26]...
		for(int i=0; i<n; i++) {
    
    
			char[] str=stickers[i].toCharArray();
			for(char c:str) {
    
    
				map[i][c-'a']++;
			}
		}
		HashMap<String,Integer> dp=new HashMap<>();
		dp.put("", 0);
		return process1(dp,map,target);
	}
	
	// dp 傻缓存,如果t已经算过了,直接返回dp中的值
	// rest 剩余的目标
	// 0...N中每一个字符串所含字符的词频统计
	// 返回值是-1 map中的贴纸怎么都无法搞定rest
	public static int process1(HashMap<String,Integer> dp,int[][] map,String rest) {
    
    
		if(dp.containsKey(rest)) {
    
    
			return dp.get(rest);
		}
		// ans 搞定rest,使用最少的贴纸数量
		int ans=Integer.MAX_VALUE;
		int n=map.length;// n中贴纸
		int [] tmap=new int[26];// tmap 去替代rest
		char[] target=rest.toCharArray();
		for(char c:target) {
    
    
			tmap[c-'a']++;
		}
		// map -> tmap(目标字符串)
		for(int i=0; i<n; i++) {
    
    
			// 枚举当前第一张贴纸是谁
			// 要求贴纸中至少包含目标字符串中的一个字符才让去试,
			// 否则递归跑不完,导致栈溢出
			if(map[i][target[0]-'a']==0) {
    
    
				continue;
			}
			StringBuilder sb=new StringBuilder();
			// 第i张贴纸  j:枚举a~z字符 
			// 也就是说看看给定的字符串数组能不能把目标字符串中所有的字符给覆盖
			for(int j=0; j<26; j++) {
    
    
				if(tmap[j]>0) {
    
     // j位置这个字符是目标字符串需要的字符
					for(int k=0; k<Math.max(0, tmap[j]-map[i][j]); k++) {
    
    
						sb.append((char)('a'+j));
					}
				}
			}
			String s=sb.toString();
			int tmp=process1(dp,map,s);
			if(tmp!=-1) {
    
    
				ans=Math.min(ans, tmp+1);
			}
		}
		dp.put(rest,ans==Integer.MAX_VALUE?-1:ans);
		return dp.get(rest);
	}
	
	public static void main(String[] args) {
    
    
		String[] arr= {
    
    "aaaa","bbaa","ccddd"};
		String str="abcccccdddddbbbaaaaa";
		System.out.println(minStickers1(arr,str));
	}
}
题目四

两个字符串的最长公共子序列问题

package com.harrison.class13;

public class Code04_LongestCommonSubsequence {
    
    
	public static int lcs(char[] str1, char[] str2) {
    
    
		int[][] dp = new int[str1.length][str2.length];
		dp[0][0] = str1[0] == str2[0] ? 1 : 0;
		for (int i =1; i < str1.length; i++) {
    
    
			dp[i][0] = Math.max(dp[i - 1][0], str1[i] == str2[0] ? 1 : 0);
		}
		for (int j = 1; j < str2.length; j++) {
    
    
			dp[0][j] = Math.max(dp[0][j - 1], str1[0] == str2[j] ? 1 : 0);
		}
		for (int i = 1; i < str1.length; i++) {
    
    
			for (int j = 1; j < str2.length; j++) {
    
    
				dp[i][j] = Math.max(dp[i][j - 1], dp[i-1][j]);
				if (str1[i] == str2[j]) {
    
    
					dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - 1] + 1);
				}
			}
		}
		return dp[str1.length-1][str2.length-1];
	}
	
	public static void main(String[] args) {
    
    
		System.out.println(lcs("ab3dkf2".toCharArray(),
								"akfj33s".toCharArray()));
	}
}
题目五

给定一个数组,代表每个人喝完咖啡准备刷杯子的时间
只有一台咖啡机,一次只能洗一个杯子,时间耗费a,洗完才能洗下一杯
每个咖啡杯也可以自己挥发干净,时间耗费b,咖啡杯可以并行挥发
返回让所有咖啡杯变干净的最早完成时间
三个参数: int[] arr、int a、 int b

package com.harrison.class13;

public class Code05_Coffee {
    
    
	// process(drinks,3,10,0,0)
	// a 洗一杯的时间 固定变量
	// b 自己挥发干净的时间 固定变量
	// drinks 每一个员工喝完的时间 固定变量
	// drinks[0...index-1] 洗好了 
	// drinks[index...] 等待洗
	// washLine 咖啡机何时可用
	// drinks[index...] 都洗干净 返回最早的时间点
	public static int process(int[] drinks,int a,int b,int index,int washLine) {
    
    
		if(index==drinks.length-1) {
    
    
			return Math.min(
						// 喝完最后一杯的时间+洗杯子的时间 和 
					    // 咖啡机可用的时间+洗杯子的时间
					    // 取最大值
						Math.max(drinks[index],washLine)+a,
						// 自己挥发的时间
						drinks[index]+b
					);
		}
		// 剩不止一杯咖啡
		// wash 当前的咖啡杯 洗完的时间点
		int wash=Math.max(drinks[index],washLine)+a;
		// index+1及其往后的杯子变干净的最早时间(不算index)
		int next1=process(drinks,a,b,index+1,wash);
		// 从index及其往后变干净的最早时间
		int p1=Math.max(wash, next1);
		
		// dry 当前的咖啡杯 挥发干净的时间点
		int dry=drinks[index]+b;
		int next2=process(drinks,a,b,index+1,washLine);
		int p2=Math.max(dry, next2);
		return Math.min(p1, p2);
	}
	
	public static int dp(int[] drinks,int a,int b) {
    
    
		int N=drinks.length;
		if(a>=b) {
    
    
			return drinks[N-1]+b;
		}
		// a<b
		// limit 咖啡机什么时候可用
		int limit=0;
		for(int i=0; i<N; i++) {
    
    
			// 所有咖啡杯都遍历完后,limit就是全都洗完的极限
			limit=Math.max(limit, drinks[i])+a;
		}
		int [][] dp=new int[N][limit+1];
		// N-1行 所有的值
		for(int washLine=0; washLine<=limit; washLine++) {
    
    
			dp[N-1][washLine]=Math.min(
									Math.max(drinks[N-1],washLine)+a,
									drinks[N-1]+b);
		}
		// 普遍位置 从下到上、从左到右
		for(int index=N-2; index>=0; index--) {
    
    
			for(int washLine=0; washLine<=limit; washLine++) {
    
    
				
				
				int p1=Integer.MAX_VALUE;
				int wash=Math.max(drinks[index],washLine)+a;
				if(wash<=limit) {
    
    
					p1=Math.max(wash,dp[index+1][wash]);
				}
				int p2=Math.max(drinks[index]+b, dp[index+1][washLine]);
				dp[index][washLine]= Math.min(p1, p2);
			}
		}
		return dp[0][0];
	}
	
	public static void main(String[] args) {
    
    
		int [] arr= {
    
    1,1,5,5,7,10,12,12,12,12,12,12,15};
		int a=3;
		int b=10;
		System.out.println(process(arr,a,b,0,0));
		System.out.println(dp(arr,a,b));
	}
}
最后总结

三条主线

  1. 暴力递归改出来后如何优化
  2. 设计暴力递归过程中,如何知道自己设计的东西靠不靠谱
  3. 根据固定的四个模型,往下编

猜你喜欢

转载自blog.csdn.net/weixin_44337241/article/details/121387967