算法与数据结构基础课第十二节笔记

本文深入探讨了如何将暴力递归优化为动态规划,通过多个示例,如机器人走位问题、组合货币问题、字符串拼接问题、咖啡杯洗涤问题和马走棋盘问题,详细解析了优化过程。总结了设计暴力递归的原则,并给出了四种常见的尝试模型,强调了动态规划中记忆化搜索和状态转移方程的重要性。
摘要由CSDN通过智能技术生成

暴力递归转动态规划

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

有重复调用同一个子问题的解,这种递归可以优化

如果每一个子问题都是不同的解,无法优化也不用优化

例1:

假设有排成一行的N个位置,记为1~N,N 一定大于或等于 2
开始时机器人在其中的M位置上(M 一定是 1~N 中的一个)
如果机器人来到1位置,那么下一步只能往右来到2位置;
如果机器人来到N位置,那么下一步只能往左来到 N-1 位置;
如果机器人来到中间位置,那么下一步可以往左走或者往右走;
规定机器人必须走 K 步,最终能来到P位置(P也是1~N中的一个)的方法有多少种
给定四个参数 N、M、K、P,返回方法数。

       public static int ways1(int N, int M, int K, int P) {
		// 参数无效直接返回0
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		// 总共N个位置,从M点出发,还剩K步,返回最终能达到P的方法数
		return walk(N, M, K, P);
	}

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

递归函数的可变参数只有cur,rest那么如果把这两个的所有组合缓存下来,就能减少很多不必要的计算。

一个初步的记忆化搜索的动态规划就完成了

        /**
	 * 把所有cur和rest的组合,返回的结果,加入到缓存里
	 * @param N
	 * @param cur
	 * @param rest
	 * @param P
	 * @return
	 */
	public static int walkCache(int N, int cur, int rest, int P, int[][] dp) {
		// 不是-1,说明之前算过直接返回
		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] = walkCache(N, 2, rest - 1, P, dp);
			return dp[cur][rest];
		}
		if (cur == N) {
			dp[cur][rest] = walkCache(N, N - 1, rest - 1, P, dp);
			return dp[cur][rest];
		}
		dp[cur][rest] = walkCache(N, cur + 1, rest - 1, P, dp) + walkCache(N, cur - 1, rest - 1, P, dp);
		return dp[cur][rest];
	}

参照缓存的过程,画图模拟,可以知道状态的转移方程,就可以写出经典的动态规划了

例1:

给定数组arr,arr中所有的值都为正数且不重复,每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数 aim,代表要找的钱数,求组成 aim 的方法数

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

	// 如果自由使用arr[index...]的面值,组成rest这么多钱,返回方法数 (1 , 6)
	public static int process(int[] arr, int index, int rest) {
		if (index == arr.length) { // 无面值可以选择的时候
			return rest == 0 ? 1 : 0;
		}
		// 有面值的时候
		int ways = 0;
		// arr[index] 当钱面值
		for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
			ways += process(arr, index + 1, rest - zhang * arr[index]);
		}
		return ways;
	}

	/**
	 * 从递归改出的直观的动态规划
	 * @param arr
	 * @param aim
	 * @return
	 */
	public static int ways2(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;
		for (int index = N - 1; index >= 0; i--) { // 大顺序 从下往上
			for (int rest = 0; rest <= aim; rest++) {
				int ways = 0;
				// arr[index] 当钱面值,枚举行为可以优化
				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];
	}

	/**
	 * 最终的动态规划
	 * @param arr
	 * @param aim
	 * @return
	 */
	public static int waysdp(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;
		for (int i = N - 1; i >= 0; i--) { // 大顺序 从下往上
			for (int rest = 0; rest <= aim; rest++) {
				dp[i][rest] = dp[i + 1][rest];
				// 左边的dp[i][rest - arr[i]] 已经包含了除了底下的,所有枚举值
				// 不再重复累加,直接左边的dp[i][rest - arr[i]] 加上下面的 dp[i + 1][rest] 
				if (rest - arr[i] >= 0) {
					dp[i][rest] += dp[i][rest - arr[i]];
				}
			}
		}
		return dp[0][aim];
	}

例2:

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

       // 伪代码
	public static int A (String rest, String[] arr){
		// 看看arr能不能覆盖rest
		return 0;
	}

	public static int minS (String rest, String[] arr){
		if (rest.equals("")){
			return 0;
		}
		// 边界条件需要看看贴纸里有没有rest的字符,没有的话跳过
		// 搞定rest的,第一张贴纸是什么
		int next = 0; // 已经使用了一张,所以初始值是1
		for (String first : arr){
			// rest 里的字符减去 first 里含有的字符 = nextRest 剩余未拼的字符
			// int cur = minS(nextRest,arr);
			// next = Math.min(next,cur);
		}
		return next + 1;
	}

	public static int minStickers1(String[] stickers, String target) {
		int n = stickers.length;
		int[][] map = new int[n][26]; // 保存贴纸字符数量,n是贴纸序号,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中的值
	// t 剩余的目标
	// 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);
		}
		// 以下就是正式递归过程
		int ans = Integer.MAX_VALUE; // 搞定rest使用贴纸的最少数量
		int n = map.length; // n 种贴纸
		int[] tmap = new int[26]; // 与顺序无关,转换为字频
		char[] target = rest.toCharArray();
		for (char c : target) {
			tmap[c - 'a']++;
		}
		// 枚举当前第一张贴纸是谁
		for (int i = 0; i < n; i++) {
			// 相当于当前贴纸至少含有一种target的字符
			// 因为搞定次序无关,可以先选择能搞定 target[0] 的贴纸
			if (map[i][target[0] - 'a'] == 0) {
				continue;
			}
			// 没搞定的字符都存在sb里
			StringBuilder sb = new StringBuilder();
			// j 每种贴纸的字符
			for (int j = 0; j < 26; j++) {
				if (tmap[j] > 0) { // j这个字符是target需要的
					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, 1 + tmp);
			}
		}
		// ans没有被设置过
		dp.put(rest, ans == Integer.MAX_VALUE ? -1 : ans);
		return dp.get(rest);
	}

	// 枚举每一张贴纸使用n张剩余的字符串情况
	public static int minStickers2(String[] stickers, String target) {
		int n = stickers.length;
		int[][] map = new int[n][26];
		for (int i = 0; i < n; i++) {
			char[] str = stickers[i].toCharArray();
			for (char c : str) {
				map[i][c - 'a']++;
			}
		}
		char[] str = target.toCharArray();
		int[] tmap = new int[26];
		for (char c : str) {
			tmap[c - 'a']++;
		}
		HashMap<String, Integer> dp = new HashMap<>();
		int ans = process2(map, 0, tmap, dp);
		return ans;
	}

	public static int process2(int[][] map, int i, int[] tmap, HashMap<String, Integer> dp) {
		StringBuilder keyBuilder = new StringBuilder();
		keyBuilder.append(i + "_");
		for (int asc = 0; asc < 26; asc++) {
			if (tmap[asc] != 0) {
				keyBuilder.append((char) (asc + 'a') + "_" + tmap[asc] + "_");
			}
		}
		String key = keyBuilder.toString();
		if (dp.containsKey(key)) {
			return dp.get(key);
		}
		boolean finish = true;
		for (int asc = 0; asc < 26; asc++) {
			if (tmap[asc] != 0) {
				finish = false;
				break;
			}
		}
		if (finish) {
			dp.put(key, 0);
			return 0;
		}
		if (i == map.length) {
			dp.put(key, -1);
			return -1;
		}
		int maxZhang = 0;
		for (int asc = 0; asc < 26; asc++) {
			if (map[i][asc] != 0 && tmap[asc] != 0) {
				maxZhang = Math.max(maxZhang, (tmap[asc] / map[i][asc]) + (tmap[asc] % map[i][asc] == 0 ? 0 : 1));
			}
		}
		int[] backup = Arrays.copyOf(tmap, tmap.length);
		int min = Integer.MAX_VALUE;
		int next = process2(map, i + 1, tmap, dp);
		tmap = Arrays.copyOf(backup, backup.length);
		if (next != -1) {
			min = next;
		}
		for (int zhang = 1; zhang <= maxZhang; zhang++) {
			for (int asc = 0; asc < 26; asc++) {
				tmap[asc] = Math.max(0, tmap[asc] - (map[i][asc] * zhang));
			}
			next = process2(map, i + 1, tmap, dp);
			tmap = Arrays.copyOf(backup, backup.length);
			if (next != -1) {
				min = Math.min(min, zhang + next);
			}
		}
		int ans = min == Integer.MAX_VALUE ? -1 : min;
		dp.put(key, ans);
		return ans;
	}

可变参数数量越少可以提高缓存命中率,所以第一种比第二种好

面试中设计暴力递归过程的原则

1)每一个可变参数的类型,一定不要比int类型更加复杂

2)原则1)可以违反,让类型突破到一维线性结构,那必须是单一可变参数

3)如果发现原则1)被违反,但不违反原则2),只需要做到记忆化搜索即可

4)可变参数的个数,能少则少

知道了面试中设计暴力递归过程的原则,然后呢?

一定要逼自己找到不违反原则情况下的暴力尝试!

如果你找到的暴力尝试,不符合原则,马上舍弃!找新的!

如果某个题目突破了设计原则,一定极难极难,面试中出现概率低于5%!

常见的4种尝试模型

1)从左往右的尝试模型

2)范围上的尝试模型

3)多样本位置全对应的尝试模型

4)寻找业务限制的尝试模型

暴力递归到动态规划的套路

1)你已经有了一个不违反原则的暴力递归,而且的确存在解的重复调用
2)找到哪些参数的变化会影响返回值,对每一个列出变化范围
3)参数间的所有的组合数量,意味着表大小
4)记忆化搜索的方法就是傻缓存,非常容易得到
5)规定好严格表的大小,分析位置的依赖顺序,然后从基础填写到最终解
6)对于有枚举行为的决策过程,进一步优化

多样本位置全对应的尝试模型

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

以字符串结尾列情况

1、不以str1[0..i] 结尾,不以 str[0..j] 结尾,那么dp[i][j] = dp[i-1][j-1];

2、不以str1[0..i] 结尾,以 str[0..j] 结尾,那么dp[i][j] = dp[i-1][j];

3、以str1[0..i] 结尾,不以 str[0..j] 结尾,那么dp[i][j] = dp[i][j-1];

4、以str1[0..i] 结尾,以 str[0..j] 结尾,str1[i] == str2[j] ,那么dp[i][j] = dp[i-1][j-1] + 1;

       public static int lcse(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 - 1][j], dp[i][j - 1]);
				if (str1[i] == str2[j]) {
					dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - 1] + 1);
				}/*else{
					dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - 1]);
					在做可能性2,3时已经用过可能性1的值做过决策,所以不需要这个else
				}*/
			}
		}
		return dp[str1.length - 1][str2.length - 1];
	}

寻找业务限制的尝试模型

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

       public static class Machine {
		public int timePoint;
		public int workTime;

		public Machine(int t, int w) {
			timePoint = t;
			workTime = w;
		}
	}

	public static class MachineComparator implements Comparator<Machine> {

		@Override
		public int compare(Machine o1, Machine o2) {
			return (o1.timePoint + o1.workTime) - (o2.timePoint + o2.workTime);
		}

	}

	// 方法二,每个人暴力尝试用每一个咖啡机给自己做咖啡,优化成贪心
	public static int minTime2(int[] arr, int n, int a, int b) {
		PriorityQueue<Machine> heap = new PriorityQueue<Machine>(new MachineComparator());
		for (int i = 0; i < arr.length; i++) {
			heap.add(new Machine(0, arr[i]));
		}
		int[] drinks = new int[n];
		for (int i = 0; i < n; i++) {
			Machine cur = heap.poll();
			cur.timePoint += cur.workTime;
			drinks[i] = cur.timePoint;
			heap.add(cur);
		}
		return process(drinks, a, b, 0, 0);
	}


        /**
	 *
	 * @param drinks 每一个员工喝完的时间 固定
	 * @param a 洗一杯,固定时间
	 * @param b 挥发时间,固定
	 * @param index  dirnks[0..index-1] 都有已经洗干净了,不用你操心了
	 *               drinks[index ..] 都想变干净,这是我操心的,washLine 表示洗的机器何时可用
	 *               drinks[index ..] 变干净,最少的时间点返回
	 * @param washLine
	 * @return
	 */
	public static int process(int[] drinks, int a, int b, int index, int washLine) {
		if (index == drinks.length - 1) {
			return Math.min(
					// 喝完和可用时间点最大值+a
					Math.max(washLine, drinks[index]) + a,
					// 喝完就能挥发
					drinks[index] + b);
		}
		// 剩不止一杯咖啡
		// wash是我当前的咖啡杯,洗完的时间
		int wash = Math.max(washLine, drinks[index]) + a;  // 洗完index一杯,结束的时间点
		int next1 = process(drinks, a, b, index + 1, wash); // 让index+1.. 变干净的最早时间
		int p1 = Math.max(wash, next1); // 计算时间点,所以取最大
		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){
		if (a >= b){
			return drinks[drinks.length - 1] + b;
		}
		int n = drinks.length;
		int limit = 0; //咖啡机什么时候可用
		for (int i = 0; i < n; i++){
			limit = Math.max(limit,drinks[i]) + a;
		}
		int[][]dp = new int[n][limit + 1];
		
		for (int washLine = 0; washLine <= limit; washLine ++){
			dp[n-1][washLine] = Math.min(
					// 喝完和可用时间点最大值+a
					Math.max(washLine, drinks[n-1]) + a,
					// 喝完就能挥发
					drinks[n-1] + b);
		}
		
		for (int index = n - 2; index >= 0; index --){
			for (int washLine = 0; washLine <= limit; washLine ++){
				int wash = Math.max(washLine, drinks[index]) + a;
				int p1 = Integer.MAX_VALUE;
				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];
	}

例5:

请同学们自行搜索或者想象一个象棋的棋盘,然后把整个棋盘放入第一象限,棋盘的最左下角是(0,0)位置,那么整个棋盘就是横坐标上9条线、纵坐标上10条线的区域
给你三个 参数 x,y,k返回“马”从(0,0)位置出发,必须走k步,最后落在(x,y)上的方法数有多少种?

        // 10*9
	// 0~9 y
	// 0~8 x
	public static int ways(int a, int b, int step) {
		return f(0, 0, step, a, b);
	}

	// 马在(i,j)位置,还有step步要去跳
	// 返回最终来到(a,b)的方法数
	public static int f(int i, int j, int step, int a, int b) {
		if (i < 0 || i > 9 || j < 0 || j > 8) {
			return 0;
		}
		if (step == 0) {
			return (i == a && j == b) ? 1 : 0;
		}
		return f(i - 2, j + 1, step - 1, a, b) 
				+ f(i - 1, j + 2, step - 1, a, b) 
				+ f(i + 1, j + 2, step - 1, a, b)
				+ f(i + 2, j + 1, step - 1, a, b) 
				+ f(i + 2, j - 1, step - 1, a, b) 
				+ f(i + 1, j - 2, step - 1, a, b)
				+ f(i - 1, j - 2, step - 1, a, b) 
				+ f(i - 2, j - 1, step - 1, a, b);

	}

	// 10*9
	// 0~9 y
	// 0~8 x
	public static int ways1(int a, int b, int step) {
		return f1(a,b,step);
	}

	// 马在(i,j)位置,还有step步要去跳
	// 返回最终来到(a,b)的方法数
	public static int f1(int i, int j, int step) {
		if (i < 0 || i > 9 || j < 0 || j > 8) {
			return 0;
		}
		if (step == 0) {
			return (i == 0 && j == 0) ? 1 : 0;
		}
		return f1(i - 2, j + 1, step - 1)
				+ f1(i - 1, j + 2, step - 1)
				+ f1(i + 1, j + 2, step - 1)
				+ f1(i + 2, j + 1, step - 1)
				+ f1(i + 2, j - 1, step - 1)
				+ f1(i + 1, j - 2, step - 1)
				+ f1(i - 1, j - 2, step - 1)
				+ f1(i - 2, j - 1, step - 1);

	}
	
	
	public static int waysdp(int a, int b, int s) {
		// (i,j,0~ step)
		int[][][] dp = new int[10][9][s+1];
		dp[a][b][0] = 1; // 从ab到00
		// dp[0][0][0] = 1; // 从00到ab
		for(int step = 1 ; step <= s;step++ ) { // 按层来
			for(int i = 0 ; i < 10;i++) {
				for(int j = 0 ; j < 9; j++) {
					dp[i][j][step] = getValue(dp,i - 2, j + 1, step - 1) 
							+ getValue(dp,i - 1, j + 2, step - 1) 
							+ getValue(dp,i + 1, j + 2, step - 1)
							+ getValue(dp,i + 2, j + 1, step - 1) 
							+ getValue(dp,i + 2, j - 1, step - 1) 
							+ getValue(dp,i + 1, j - 2, step - 1)
							+ getValue(dp,i - 1, j - 2, step - 1) 
							+ getValue(dp,i - 2, j - 1, step - 1);
				}
			}
		}
		return dp[0][0][s]; // 从ab到00
		// return dp[a][b][s]; // 从00到ab
	}

	// 在dp表中,得到dp[i][j][step]的值,但如果(i,j)位置越界的话,返回0;
	public static int getValue(int[][][] dp, int i, int j, int step) {
		if (i < 0 || i > 9 || j < 0 || j > 8) {
			return 0;
		}
		return dp[i][j][step];
	}

 

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值