递归与动态规划
动态规划本身就是暴力递归的优化,是一种用空间换时间的策略
递归与动态规划
- 暴力递归:
- 把问题转化为规模缩小了的同类问题的子问题
- 有明确的不需要继续进行递归的条件(
base case
) - 有当得到了子问题的结果之后的决策过程 4,不记录每一个子问题的解
- 动态规划:
- 从暴力递归中来
- 将每一个子问题的解记录下来,避免重复计算
- 把暴力递归的过程,抽象成了状态表达
- 并且存在化简状态表达,使其更加简洁的可能
递归问题
求n!的结果
我们不知道怎么计算n!
,但我们知道:
n!
与(n-1)!
之间存在一定关系n!=n*(n-1)!
,得到递推的状态转移公式
.- 当暴力递归到头得到
0!=1
,得到base case
,这样得到了递归的方法.
public static long getFactorial1(int n) {
if (n == 1) {
return 1L;
}
return (long) n * getFactorial1(n - 1);
}
将递推反过来考虑,从base case
出发,可以得到递推方法:
public static long getFactorial2(int n) {
long result = 1L;
for (int i = 1; i <= n; i++) {
result *= i;
}
return result;
}
汉诺塔问题
问题: 不能大压小,只能小压大,每次只能移动一个圆盘.最终借助help
堆将n
层圆盘从from
堆移动到to
堆. 打印n层汉诺(最上面为第1层,最下面为第n层)塔从from
移动到to
的全部过程
思路:
-
先来探讨将
第[1~n]层
从from
移动到to
的过程:- 先把
第[1~(n-1)]层
从from
移动到help
上. - 再把单独的
第n层
移从from
移动到to
上. - 再把
第[1~n-1]层
从help
移动到到to
上.
这三步中
第1步
和第3步
都是将规模缩小了的子问题,这样得到了状态转移公式
.这里我们探讨
第[1~n]层
时,大于n层
的所有圆盘都应该在这些盘子以下,因此绝对不会出现小压大的情况. - 先把
-
下面我们寻找
base case
: 容易想到,当汉诺塔只有一层时,可以不借助help
,直接将该层从from
移动到to
由此得到其递归代码如下:
public class Hanoi {
// 将[1~n]层的汉诺塔借助 help堆 从 from堆 移动到 to堆
public static void moveHanoi(int n, String from, String to, String help) {
if (n == 1) {
// base case: n==1
System.out.println("将第" + n + "层盘子从" + from + "移动到" + to);
} else {
// 状态转移
moveHanoi(n - 1, from, help, to);
System.out.println("将第" + n + "层盘子从" + from + "移动到" + to);
moveHanoi(n - 1, help, to, from);
}
}
public static void main(String[] args) {
moveHanoi(3, "左堆", "中堆", "右堆");
}
}
输出3层汉诺塔的移动过程如下:
将第1层盘子从左堆移动到中堆
将第2层盘子从左堆移动到右堆
将第1层盘子从中堆移动到右堆
将第3层盘子从左堆移动到中堆
将第1层盘子从右堆移动到左堆
将第2层盘子从右堆移动到中堆
将第1层盘子从左堆移动到中堆
打印一个字符串的全部子序列(包括串)
暴力递归:
状态转移公式
: 对于字符串中每个字符每个字符可以选择要或不要,- 若要此字符,则把上级带来的字符串加当前字符扔给下一级
- 若不要此字符,则直接把上级带来的字符串扔给下一级
base case
: 判断完最后一个字串就可以输出了.
打印一个字符串的全部排列
暴力递归:
状态转移公式
: 每一位的字符都可以跟自己及其后面任意一个字符
相交换base case
: 判断完最后一个字串就可以输出了.
暴力递归转换成动态规划
暴力递归
方法会导致大量状态被重复计算,而动态规划通过记忆这些状态的值,避免了重复计算,是一种用空间换时间
的策略.
有一类问题能从暴力递归
转换为动态规划
,被称作无后效性问题
.
无后效性问题
: 被重复计算的状态与到达该状态的路径没有关系,这类问题一定能改成动态规划.有后效性问题
: 每一步计算的结果都与到达上一步的过程有关,不独立,如n皇后问题
,这类问题不能改成动态规划
最小路径和
问题: 给出一个二维数组,要求返回从左上角到右下角的最小路径和.
暴力递归:
设walk(int i, int j)
函数返回某点到右下角的最小路径和
状态转移方程
: 某一点到右下角最小路径和等于该节点权值加上其右边一点到右下角的最小路径和
和其下边一点到右下角的最小路径和
中的最小值.
walk(i, j) = matrix[i][j] + Math.max(walk(i, j + 1), walk(i + 1, j));
base case
: 右下角节点到右下角节点的最小路径和为其自身;
动态规划:
无后效性
:从某个点到达有效叫的最小路径和是固定的,与如何到达该点无关,因此问题转化为动态规划问题.
将每一个点对应的最小路径存入一张表内,并且从右下到左上填表,直至填到左上角取值.
数组累加和
题目: 给你一个数组arr
和一个整数aim
. 任意选择arr中的数字,能否累加得到aim.
暴力递归: 类似子序列问题,每个数字都可以选择要或者不要.
isSum(判断到的index, 前边的累加和sum)
函数返回数组[0~index]
区间内是否能累加成目标aim
.
状态转移方程
: 若[0~index-1]
之间选择数字能达到aim-arr[index]
,则[0~index]
一定能选择数字达到aim
; 否则不能达到aim
.即isSum(index, sum) = isSum(index-1, sum-arr[index])
.
base case
: index=数组长度
表示判断完整个数组的结果,因此判断其前向累加和
是否为aim
填表. 且不论index
为多少,前向累加和
若为aim
,则返回true.
动态规划:
设想如下数字序列:{2, 3, 5, ...}
,判断完前三个数字时,是选择{2,3}
还是选择{5}
,前边的累加和都是一样的,不影响后边判断.
因为从[0~index]
之间选择具体哪几个数字达到aim
与 后面选择无关,因此是一个无后效性问题
,可以用动态规划
.
从base case
,即index=len
开始,向index
减小方向递推,直到递推到并返回isSum(0,0)
.
代码如下:
public static boolean money2(int[] arr, int aim) {
// 保存动态规划结果的数组
// 判断的index最多到arr.length, 前向累加和最多为aim
boolean[][] isSum = new boolean[arr.length + 1][aim + 1];
// 初始化base case
for (int i = 0; i < isSum.length; i++) {
isSum[i][aim] = true;
}
// 按判断的index从arr.length到0递减填表
for (int i = arr.length - 1; i >= 0; i--) {
for (int j = aim - 1; j >= 0; j--) {
// 可以不选择当前数
isSum[i][j] = isSum[i + 1][j];
// 若加上当前数不超aim,则也可以尝试选择当前数
if (j + arr[i] <= aim) {
isSum[i][j] = isSum[i][j] || isSum[i + 1][j + arr[i]];
}
}
}
// 结果应返回: 最开始选择0个数,且初始累加和为0时的结果
return isSum[0][0];
}
推广: 考虑数组成员存在负数的情况,若数组成员有负数,则前向累加和可能为负数,因此所填的表的横坐标可能为负数. 实际编码过程中,给累加和
项加一个偏移
,偏移量为累加和可能的最小负数.
这类问题与
k-sum问题
的区别: k-sum问题为有后效性问题
,因为要求只能选取k
个数,所以在记录累加和的同时要考虑已经选取了几个数.
背包问题
给定两个数组w和v,两个数组长度相等,w[i]表示第i件商品的重量,v[i]表示第i件商品的价值。 再给定一个整数bag,要求你挑选商品的重量加起来一定不能超 过bag,返回满足这个条件下,你能获得的最大价值。