暴力递归到动态规划
介绍递归和动态规划
暴力递归:
- 把问题转化为规模缩小了的同类问题的子问题;
- 有明确的不需要继续进行递归的条件(base case);
- 有当得到了子问题的结果之后 的决策过程;
- 不记录每一个子问题的解。
动态规划:
- 从暴力递归中来
- 将每一个子问题的解记录下来,避免重复计算
- 把暴力递归的过程,抽象成了状态表达
- 并且存在化简状态表达,使其更加简洁的可能。
动态规划的前提:无后效性尝试
- 列出可变参数组合(表)
- base case填简单状态
- 最终状态
- 普遍位置如何依赖其他位置
- 根据第4步的依赖顺序逆着从简单到复杂(填表)
题目一:求n!的结果
/**
* 求n!的阶乘
*/
public class Factorial {
public static void main(String[] args){
int n = 5;
System.out.println(getFactorial1(n));
System.out.println(getFactorial2(n));
}
/**
* 非递归形式
* @param n
* @return
*/
private static long getFactorial2(int n) {
long result = 1L;
for (int i = 1; i <= n ; i++) {
result*=i;
}
return result;
}
/**
* 递归形式
* @param n
* @return
*/
private static long getFactorial1(int n) {
if (n==1){
return 1L;
}
return (long)n*getFactorial1(n-1);
}
}
题目二:汉诺塔问题
打印n层汉诺塔从最左边移动到最右边的全部过程
题目:给定一个整数n,代表汉诺塔游戏中从小到大放置的n个圆盘,假设开始时所有的圆盘都放在左边的柱子上,想按照汉诺塔游戏的要求把所有的圆盘都移到右边的柱子上。实现函数打印最优移动轨迹。
例如:
n=1时,打印:
move from left to right
n=2时,打印:
move from left to mid
move from left to right
move from mid to right
进阶题目:给定一个整型数组arr,其中只含有1,2和3,代表所有圆盘目前的状态,1代表左柱,2代表中柱,3代表右柱,arr[i]的值代表第i+1个圆盘的位置。比如,arr=[3,3,2,1],代表第1个圆盘在右石柱上、第2个圆盘在右石柱上、第3个圆盘在中柱上,第4个圆盘在左柱上。如果arr代表的状态是最优移动轨迹过程中出现的状态,返回arr这种状态是最优移动轨迹中的第几个状态。如果arr代表的状态不是最优移动轨迹过程中出现的状态,则返回-1。
例如:
arr=[1,1]。两个圆盘目前都在左柱上,也就是初始状态,所以返回0.
arr=[2,1]。第一个圆盘在中柱上、第二个圆盘也在中柱上,这个状态是2个圆盘的汉诺塔游戏中最优移动轨迹的第1步,所以返回1.
arr=[3,3]。第一个圆盘在右柱上、第二个圆盘也在右柱上,这个状态是2个圆盘的汉诺塔游戏中最优移动轨迹的第3步,所以返回3.
arr=[2,2].第一个圆盘在中柱上,第二个圆盘在中柱上,这个状态是2个圆盘的汉诺塔游戏中最优移动轨迹从来不会出现的状态,所以返回-1.
进阶题目要求:如果arr的长度为N,请实现时间复杂度为O(N)、额外空间复杂度为O(1)的方法。
解答:
原问题:假设有from柱子、mid柱子和to柱子,都在from的圆盘1~i完全移动到to,最优过程为:
步骤1:圆盘1~i-1从from移动到mid
步骤2:单独把圆盘i从from移动到to。
步骤3:把圆盘1~i-1从mid移动到to.如果圆盘只有1个,直接把这个圆盘从from移动到to即可。
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
int n = sc.nextInt();
hanio(n);
}
sc.close();
}
private static void hanio(int n) {
if (n > 0) {
hanioCore(n, "left", "right", "mid");
}
}
private static void hanioCore(int n, String from, String to, String help) {
if (n == 1) {
System.out.println("move " + 1 + " from " + from + " to " + to);
} else {
hanioCore(n - 1, from, help, to);
System.out.println("move " + n + " from " + from + " to " + to);
hanioCore(n - 1, help, to, from);
}
}
进阶题目。首先求都在from柱子上的圆盘1~i,如果都移动到to上的最少步骤数,假设为S(i)。根据上面的步骤,S(i)=步骤1的步骤总数+1+步骤3的步骤总数=S(i-1)+1+S(i-1),S(1)=1.所以S(i)+1=2(S(i-1)+1),S(1)+1=2。根据等比数列求和公式得到,所以。
对于数组arr来说,arr[N-1]表示最大圆盘N在哪个柱子上,情况有以下三种:
- 圆盘N在左柱上,说明步骤1或者没有完成,或者已经完成,需要考察圆盘1~N-1的状况
- 圆盘N在右柱上,说明步骤2已经完成,起码走完了步。步骤2也已经完成,起码又走完了1步,所以当前状况起码是最优步骤的步,剩下的步骤怎么确定还得继续考察圆盘1~N-1的状况
- 圆盘N在中柱上,这是不可能的,最后步骤不可能让圆盘N处于中柱上,直接返回-1
所以整个过程可以总结为:对圆盘1~i来说,如果目标从from到to,那么情况有三种:
- 圆盘i在from上,需要继续考察圆盘1~i-1的状况,圆盘1~i-1的目标从from到mid
- 圆盘i在to上,说明起码走完了步,剩下的步骤怎么确定还得继续考察圆盘的状况,圆盘1~i-1的目标为从mid到to。
- 圆盘i在mid上,直接返回-1.
/**
* step1是递归函数,递归最多调用N次,并且每步的递归函数再调用递归函数的次数最多一次。
* step1方法的时间复杂度为O(N).
* 因为递归函数需要函数栈的关系,step1放啊的额外空间复杂度为O(N).
*
* @param arr
* @return
*/
public static int step1(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
return process(arr, arr.length - 1, 1, 2, 3);
}
private static int process(int[] arr, int i, int from, int mid, int to) {
if (i == -1) {
return 0;
}
if (arr[i] != from && arr[i] != to) {
return -1;
}
if (arr[i] == from) {
return process(arr, i - 1, from, to, mid);
} else {
int rest = process(arr, i - 1, mid, from, to);
if (rest == -1) {
return -1;
}
return (1 << i) + rest;
}
}
将整个过程改成非递归的方法。
public static int step2(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
int from = 1;
int mid = 2;
int to = 3;
int i = arr.length - 1;
int res = 0;
int tmp = 0;
while (i >= 0) {
if (arr[i] != from && arr[i] != to) {
return -1;
}
if (arr[i] == to) {
res += 1 << i;
tmp = from;
from = mid;
} else {
tmp = to;
to = mid;
}
mid = tmp;
i--;
}
return res;
}
题目三:打印一个字符串的全部子序列,包括空字符串
解答:首先需要明确的是:子序列!=子串
最长公共子串要求在原字符串中是连续的,而子序列只需要保持相对顺序一致,并不要求连续。
例如:“abc”
从位置0开始,有两种决策:1,要;2,不要
向后走,每个位置同样两种决策,递归
递归结束就是走到了字符串最后
代码:
import java.util.Scanner;
/**
* 打印全部子串
*/
public class AllSub {
public static void main(String[] args){
Scanner sc =new Scanner(System.in);
while (sc.hasNext()){
String str = sc.nextLine();
process(str.toCharArray(),0,"");
}
sc.close();
}
private static void process(char[] chars, int i, String result) {
if (i==chars.length){
System.out.println(result);
return;
}else {
//不要下标为i+1的字符
process(chars,i+1,result);
//要第i+1个字符
process(chars,i+1,result+chars[i]);
}
}
}
题目四:打印一个字符串的全部排列
题目描述:输入一个字符串,打印这个字符串中字符的全排列。
例如:
输入:abc
输出:abc acb bac bca cab cba
思路:将求字符串的全排列分解为两步:
第一步:确定第一个位置的字符,就是第一个位置与后边的所有字符进行交换。
第二步:对除了第一个位置的后边所有位置的字符进行相同处理;直至剩下一个字符,打印;
进阶:打印一个字符串的全部排列,要求不要出现重复的排列
代码:
import java.util.HashSet;
import java.util.Scanner;
/**
* 输出一个字符串的全排列
*/
public class AllPermutations {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
String str = sc.nextLine();
System.out.println("==未去重==");
printAllPermutations1(str);
System.out.println("==去重==");
printAllPermutations2(str);
System.out.println("==结束==");
}
sc.close();
}
/**
* 将字符串转换为字符数字,并从第0个位置的字符开始
*
* @param str
*/
private static void printAllPermutations1(String str) {
char[] chars = str.toCharArray();
process1(chars, 0);
}
/**
* 以chars数组的i号位置作为交换点,向后交换
* 在交换过程中没有考虑字符重复的情况
*
* @param chars
* @param i
*/
private static void process1(char[] chars, int i) {
//递归出口,即只剩一个字符,无法交换,打印输出
if (i == chars.length) {
System.out.println(String.valueOf(chars));
}
for (int j = i; j < chars.length; j++) {
//依次交换i与它后面的每一个字符
swap(chars, i, j);
process1(chars, i + 1);
}
}
private static void printAllPermutations2(String str) {
char[] chars = str.toCharArray();
process2(chars, 0);
}
/**
* 使用HashSet对字符进行去重
*
* @param chars
* @param i
*/
private static void process2(char[] chars, int i) {
if (i == chars.length) {
System.out.println(String.valueOf(chars));
}
HashSet<Character> set = new HashSet<>();
for (int j = i; j < chars.length; j++) {
if (!set.contains(chars[j])) {
set.add(chars[j]);
swap(chars, i, j);
process2(chars, i + 1);
}
}
}
private static void swap(char[] chars, int i, int j) {
char temp = chars[i];
chars[i] = chars[j];
chars[j] = temp;
}
}
题目五:母牛每年生一只母牛,新出生的母牛成长三年后也能每年生一只母牛,假设母牛不会死。求N年后,母牛的数量。
思考:N=6,第1年1头成熟母牛记为a;第2年a生了新的小母牛,记为b,总数为2;第3年a生了新的小母牛,记为c,总牛数为3;第4年a生了新的小母牛,记为d,总牛数为4。第5年b成熟了,a和b分别生了新的小母牛,总牛数为6;第6年c也成熟了,a、b、c分别生了新的小母牛,总牛数记为9,返回9.
所以第N-1年的牛会毫无损失地活到第N年。同时所有成熟的牛都会生1头新的牛,那么成熟牛的数量如何估计?就是第N-3年的所有牛,到第N年肯定都是成熟的牛,其间出生的牛肯定都没有成熟。所以C(n)=C(n-1)+C(n-3)。初始项为C(1)==1,C(2)==2,C(3)==3。
要求:请实现时间复杂度为O(logN)的解法
public class Fibonaqie {
/**
* 求矩阵m的n次方
*
* @param m
* @param p
* @return
*/
public static int[][] matrixPower(int[][] m, int p) {
int[][] res = new int[m.length][m[0].length];
//先把res设置为单位矩阵,相当于整数中的1
for (int i = 0; i < res.length; i++) {
res[i][i] = 1;
}
int[][] tmp = m;
for (; p != 0; p >>= 1) {
if ((p & 1) != 0) {
res = muliMatrix(res, tmp);
}
tmp = muliMatrix(tmp, tmp);
}
return res;
}
private static int[][] muliMatrix(int[][] m1, int[][] m2) {
int[][] res = new int[m1.length][m2[0].length];
for (int i = 0; i < m1.length; i++) {
for (int j = 0; j < m2[0].length; j++) {
for (int k = 0; k < m2.length; k++) {
//矩阵的乘法 ,某一行的元素与某一列的元素的乘积之和
res[i][j] += m1[i][k] * m2[k][j];
}
}
}
return res;
}
/**
* 母牛数量问题
* 解法1,时间复杂度为O(2^n)
*
* @param n
* @return
*/
public static int c1(int n) {
if (n < 1) {
return 0;
}
if (n == 1 || n == 2 || n == 3) {
return n;
}
return c1(n - 1) + c1(n - 3);
}
/**
* 解法2:时间复杂度为O(N)
*
* @param n
* @return
*/
public static int c2(int n) {
if (n < 1) {
return 0;
}
if (n == 1 || n == 2 || n == 3) {
return n;
}
int res = 3;
int pre = 2;
int prepre = 1;
int tmp1 = 0;
int tmp2 = 0;
for (int i = 4; i <= n; i++) {
tmp1 = res;
tmp2 = pre;
res = res + prepre;
pre = tmp1;
prepre = tmp2;
}
return res;
}
/**
* 时间复杂度为O(logN)
* @param n
* @return
*/
public static int c3(int n) {
if (n < 1) {
return 0;
}
if (n == 1 || n == 2 || n == 3) {
return n;
}
int[][] base = {{1, 1, 0}, {0, 0, 1}, {1, 0, 0}};
int[][] res = matrixPower(base, n - 3);
return 3 * res[0][0] + 2 * res[1][0] + res[2][0];
}
}
备注:如果递归式严格符合,那么它就是一个i阶的递归式,必然有与i*i的状态矩阵有关的矩阵乘法的表达。一律可以用加速矩阵乘法的动态规划将时间复杂度降为O(logN)。
题目六:给你一个栈,请你逆序这个栈,不能申请额外的数据结构,只能使用递归函数。如何实现?
题目描述:一个栈依次压入1、2、3、4、5,那么从栈顶到栈底分别为5、4、3、2、1。将这个栈转置后,从栈顶到栈底为1、2、3、4、5,也就是实现栈中元素的逆序,但是只能用递归函数来实现,不能用其他数据结构。
思路:设计两个递归函数:
递归函数1:将栈stack的栈底元素返回并删除
递归函数2:逆序一个栈。
代码:
import java.util.Stack;
/**
* 逆序一个栈,不使用其他数据结构
*/
public class ReverseStackUsingRecursive {
/**
* 递归函数2:逆序一个栈
* @param stack
*/
public static void reverse(Stack<Integer> stack){
if (stack.isEmpty()){
return;
}
int i = getAndRemoveLastElement(stack);
reverse(stack);
stack.push(i);
}
/**
* 递归函数1,将栈的栈底元素返回并删除
* @param stack
* @return
*/
public static int getAndRemoveLastElement(Stack<Integer> stack){
int result = stack.pop();
if (stack.isEmpty()){
return result;
}else {
int last = getAndRemoveLastElement(stack);
stack.push(result);
return last;
}
}
/**
* 依次压入1、2、3、4、5.
* @param args
*/
public static void main(String[] args){
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.push(4);
stack.push(5);
}
}
题目七:给你一个二维数组,二维数组中的每个数都是整数,要求从左上角走到右下角,每一步只能向右或向下。沿途经过的数字要累加起来。返回最小的路径和。
/**
* 题目七,返回最小路径和
*/
public class Code_07 {
public static int minPath(int[][] matrix) {
return process(matrix, 0, 0);
}
private static int process(int[][] matrix, int i, int j) {
if (i == matrix.length - 1 && j == matrix[0].length - 1) {
return matrix[i][j];
}
//i和j起码有一个没到终止位置
if (i == matrix.length - 1) {
return matrix[i][j] + process(matrix, i, j + 1);
}
if (j == matrix[0].length - 1) {
return matrix[i][j] + process(matrix, i + 1, j);
}
return matrix[i][j] + Math.min(process(matrix, i + 1, j), process(matrix, i, j + 1));
}
}
优化:使用缓存,记录已经计算过的数值,不再暴力展开,将时间复杂度从降到
//代码优化,傻缓存
static HashMap<String, Integer> cache = new HashMap<>();
private static int process2(int[][] matrix, int i, int j) {
int result = 0;
if (i == matrix.length - 1 && j == matrix[0].length - 1) {
result = matrix[i][j];
} else if (i == matrix.length - 1) {
int next = 0;
String nextKey = String.valueOf(i) + "_" + String.valueOf(j + 1);
if (cache.containsKey(nextKey)) {
next = cache.get(nextKey);
} else {
next = process2(matrix, i, j + 1);
}
result = matrix[i][j] + next;
} else if (j == matrix[0].length - 1) {
int next = 0;
String nextKey = String.valueOf(i + 1) + "_" + String.valueOf(j);
if (cache.containsKey(nextKey)) {
next = cache.get(nextKey);
} else {
next = process2(matrix, i + 1, j);
}
return matrix[i][j] + next;
} else {
int downNext = 0;
String downNextKey = String.valueOf(i + 1) + "_" + String.valueOf(j);
if (cache.containsKey(downNextKey)) {
downNext = cache.get(downNextKey);
} else {
downNext = process2(matrix, i + 1, j);
}
int rightNext = 0;
String rightNextKey = String.valueOf(i) + "_" + String.valueOf(j + 1);
if (cache.containsKey(rightNextKey)) {
rightNext = cache.get(rightNextKey);
} else {
rightNext = process2(matrix, i, j + 1);
}
result = matrix[i][j] + Math.min(downNext, rightNext);
}
String key = String.valueOf(i) + "_" + String.valueOf(j);
cache.put(key, result);
return result;
}
题目八:给你一个数组arr,和一个整数aim。如果可以任意选择arr中的数字,能不能累加得到aim,返回true或者false。
/**
* 给你一个数组arr,和一个整数aim。如果可以任意选择arr中的数字,能不能累加得到aim,返回true或者false。
*/
public class Code_08 {
public static boolean money1(int[] arr, int aim) {
return process1(arr, 0, 0, aim);
}
/**
* 从第i位置开始,判断是否要当前数字,如果要当前数字,累加和是否等于aim,如果不要当前数字,累加和是否等于aim。
*
* @param arr
* @param i
* @param sum
* @param aim
* @return
*/
public static boolean process1(int[] arr, int i, int sum, int aim) {
if (i == arr.length) {
return sum == aim;
}
return (process1(arr, i + 1, sum, aim) || process1(arr, i + 1, sum + arr[i], aim));
// if (sum == aim) {
// return true;
// }
// if (i == arr.length) {
// return false;
// }
// return (process1(arr, i + 1, sum, aim) || process1(arr, i + 1, sum + arr[i], aim));
}
/**
* 动态规划版本
*
* @param arr
* @param i
* @param sum
* @param aim
* @return
*/
public static boolean money2(int[] arr, int aim) {
boolean[][] dp = new boolean[arr.length + 1][aim + 1];
for (int i = 0; i < dp.length; i++) {
dp[i][aim] = true;
}
for (int i = arr.length - 1; i >= 0; i--) {
for (int j = aim - 1; j >= 0; j--) {
dp[i][j] = dp[i + 1][j];
if (j + arr[i] <= aim) {
dp[i][j] = dp[i][j] || dp[i + 1][j + arr[i]];
}
}
}
return dp[0][0];
}
public static void main(String[] args) {
int[] arr = {1, 4, 8};
int aim = 12;
System.out.println(money1(arr, aim));
System.out.println(money2(arr, aim));
}
}
题目九:
给定数组arr,arr中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim代表要找的钱数,求组成aim的最少货币数。
例如:
arr = [5,2,3],aim=20
4张5元可以组成20元,其他的找钱方案都要使用更多的货币,所以返回4.
arr = [5,2,3],aim=0;
不用任何货币就可以组成0元,返回0.
arr=[3,5],aim=2
根本无法组成2元,钱不能找开的情况下默认返回-1。
解答:
原问题的经典动态规划方法。如果arr的长度为N,生成行数为N,列数为aim+1的动态规划表dp。dp[i][j]的含义是,在可以任意使用arr[0..i]货币的情况下,组成j所需的最小张数。根据这个定义,dp[i][j]的值按如下方式计算:
- dp[0...N-1][0]的值(即dp矩阵中第一列的值)表示找的钱数为0时需要的最小张数,钱数为0时,完全不需要任何货币,所以全设为0即可。
- dp[0][0...aim]的值(即dp矩阵中第一行的值)表示只能使用arr[0]货币的情况下,找某个钱数的最小张数。比如,arr[0]=2,那么能找开的钱数为2,4,6,8...所以令dp[0][2]=1,dp[0][4]=2,dp[0][6]=3...第一行其他位置所代表的的钱数一律找不开,所以一律设为32位整数的最大值,我们把这个值记为max。
- 剩下的位置依次从左到右,再从上到下计算。假设计算到位置(i,j),dp[i][j]的值可能来自下面的情况:
- 完全不使用当前货币arr[i]情况下的最少张数,即dp[i-1][j]的值。
- 只使用1张当前货币arr[i]情况下的最少张数,即dp[i-1][j-arr[i]]+1.
- 只使用2张当前货币arr[i]情况下的最少张数,即dp[i-1][j-a*arr[i]]+2
- 只使用3张当前货币arr[i]情况下的最少张数,即dp[i-1][j-3*arr[i]]+3
所有的情况下,最终取张数最小的。所以:
dp[i][j] = min{dp[i-1][j-k*arr[i]]+k(0<=k)}
=>dp[i][j] = min{dp[]i-1[]j,min{dp[i-1][j-x*arr[i]]+x(1<=x)}}
=>dp[i][j] = min{dp[i-1][j],min{dp[]i-1][j-arr[i]-y*arr[i]]+y+1(o<=y)}}
又有min{dp[i-1][j-arr[i]-y*arr[i]]+y*arr[i]+y(0<=y)}=>dp[i][j-arr[i]],所以,最终有:dp[i][j] = min{dp[i-1][j],dp[i][j-arr[i]]+1}。如果j-arr[i]<0,即发生越界了,说明arr[i]太大,用一张都会超过钱数j,令dp[i][j]=dp[i-1][j]即可。具体过程参见如下代码,整个过程的时间复杂度和额外空间复杂度都是O(N*aim),N为arr的长度。
代码:
public int minCoins1(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
int n = arr.length;
int max = Integer.MAX_VALUE;
int[][] dp = new int[n][aim + 1];
for (int j = 1; j <= aim; j++) {
dp[0][j] = max;
if (j - arr[0] >= 0 && dp[0][j - arr[0]] != max) {
dp[0][j] = dp[0][j - arr[0]] + 1;
}
}
int left = 0;
for (int i = 1; i < n; i++) {
for (int j = 1; j <= aim; j++) {
left = max;
if (j - arr[i] >= 0 && dp[i][j - arr[i]] != max) {
left = dp[i][j - arr[i]] + i;
}
dp[i][j] = Math.min(left, dp[i - 1][j]);
}
}
return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
}
原问题在动态规划基础上的空间压缩方法。选择生成一个长度为aim+1的动态规划一维数组,然后按行来更新dp即可。之所以不选按列更新,是因为根据dp[i][j] = min{dp[i-1][j],dp[i][j-arr[i]]+1}可知,位置(i,j)依赖位置(i-1,j),即往上跳一下,也依赖位置(i,j-arr[i]),即往左跳arr[i]一下的位置,所以按行更新只需要1个一维数组,按列更新需要一个一维数组个数就arr中货币的最大值有关,如最大的货币为a,说明最差情况下要向左侧跳a下,相应地,就要准备a个一维数字不断地滚动复用,这样实现起来很麻烦,所以不采用按列更新的方式。空间压缩之后时间复杂度为O(N×aim),额外空间复杂度为O(aim)。
代码:
public int minCoins2(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
int n = arr.length;
int max = Integer.MAX_VALUE;
int[] dp = new int[aim + 1];
for (int j = 1; j <= aim; j++) {
dp[j] = max;
if (j - arr[0] >= 0 && dp[j - arr[0]] != max) {
dp[j] = dp[j - arr[0]] + 1;
}
}
int left = 0;
for (int i = 1; i < n; i++) {
for (int j = 1; j <= aim; j++) {
left = max;
if (j - arr[i] >= 0 && dp[j - arr[i]] != max) {
left = dp[j - arr[i]] + 1;
}
dp[j] = Math.min(left, dp[j]);
}
}
return dp[aim] != max ? dp[aim] : -1;
}
补充:
题目描述:
给定数组arr,arr中所有的值都是正数,每个值仅代表一张钱的面值,再给定一个正数aim代表要找的钱数,求组成aim的最少货币数。
例如:
arr=[5,2,3],aim=20.
无法组成20元,默认返回-1.
arr=[5,2,5,3],aim = 10
5元的货币有2张,可以组成10元,且该方案所需张数最少,返回2.
arr=[5,2,5,3],aim=0
不用任何货币就可以组成0元,返回0。
解法:如果arr的长度为N,生成行数为N、列数为aim+1的动态规划表dp。dp[i][j]的含义是,在可以任意使用arr[0...i]货币的情况下(每个值仅代表一张货币),组成j所需的最小张数。根据这个定义,dp[i][j]的值按如下方式计算:
1.dp[0...N-1][0]的值(即dp矩阵中第一列的值)表示找的钱数为0时需要的最少张数。钱数为0时完全不需要任何货币,所以全设为0即可。
2.dp[0][0...aim]的值(即dp矩阵中第一行的值)表示只能使用一张arr[0]货币的情况下,找某个钱数的最小张数。比如arr[0]=2,那么能找开的钱数仅为2,所以令dp[0][2]=1。因为只有一张钱,所以其他位置所代表的钱数一律找不开,一律设为32位整数的最大值。
3.剩下的位置依次从左到右,再从上到下计算。假设计算到位置(i,j),dp[i][j]的值可能来自下面两种情况。
1)dp[i-1][j]的值代表在可以任意使用arr[0...i-1]货币的情况下,组成j所需的最小张数。可以任意使用arr[0...j]货币的情况当然包括不使用这一面值为arr[i]的货币,而只任意的使用arr[0...i-1]货币的情况,所以dp[i][j]的值可能等于dp[i-1][j]。
2)因为arr[i]只有一张不能重复使用,所以我们考虑dp[i-1][j-arr[i]]的值,这个值代表在可以任意使用arr[0...i-1]货币的情况下,组成j-arr[i]所需的最小张数。从钱数为j-arr[i]到钱数j,只用再加上当前的这张arr[i]即可。所以dp[i][j]的值可能等于dp[i-1][j-arr[i]]+1
4.如果dp[i-1][j-arr[i]]中i-arr[i]<0,也就是位置越界了,说明arr[j]太大了,只用一张都会超过钱数j,令dp[i][j] = dp[i-1][j]即可。否则dp[i][j] = min{dp[i-1][j],dp[i-1][j-arr[i]]+1}。
整个霍城的时间复杂度与额外空间复杂度都为O(N*aim),N为arr的长度。
public int minCoins3(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
int n = arr.length;
int max = Integer.MAX_VALUE;
int[][] dp = new int[n][aim + 1];
for (int j = 1; j <= aim; j++) {
dp[0][j] = max;
}
if (arr[0] <= aim) {
dp[0][arr[0]] = 1;
}
int leftup = 0;//左上角某个位置的值
for (int i = 1; i < n; i++) {
for (int j = 1; j <= aim; j++) {
leftup = max;
if (j - arr[i] >= 0 && dp[i - 1][j - arr[i]] != max) {
leftup = dp[i - 1][j - arr[i]] + 1;
}
dp[i][j] = Math.min(leftup, dp[i - 1][j]);
}
}
return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
}
进阶问题在动态规划基础上的空间压缩方法。选择生成一个长度为aim+1的动态规划一维数组dp,然后按行来更新dp即可。空间压缩后的时间复杂度为O(N*aim),额外空间复杂度为O(aim)。
public int minCoins4(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1;
}
int n = arr.length;
int max = Integer.MAX_VALUE;
int[] dp = new int[aim + 1];
for (int j = 1; j <= aim; j++) {
dp[j] = max;
}
if (arr[0] <= aim) {
dp[arr[0]] = 1;
}
int leftup = 0;//左上角某个位置的值
for (int i = 1; i < n; i++) {
for (int j = aim; j > 0; j--) {
leftup = max;
if (j - arr[i] >= 0 && dp[j - arr[i]] != max) {
leftup = dp[j - arr[i]] + 1;
}
dp[j] = Math.min(leftup, dp[j]);
}
}
return dp[aim] != max ? dp[aim] : -1;
}
测试:
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
while (sc.hasNext()){
String[] str = sc.nextLine().split(",");
int[] arr = new int[str.length];
for (int i = 0; i < str.length; i++) {
arr[i] = Integer.parseInt(str[i]);
}
int aim = Integer.parseInt(sc.nextLine());
System.out.println(minCoins1(arr,aim));
System.out.println(minCoins2(arr,aim));
System.out.println(minCoins3(arr,aim));
System.out.println(minCoins4(arr,aim));
}
sc.close();
}
题目十:换钱的方法数
题目:给定数组arr,arr中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim代表要找的钱数,求换钱有多少种方法。
例如:
arr=[5,10,25,1],aim=0
组成0元的方法只有1中,就是所有面值的货币都不用,所以返回-1;
arr=[5,10,25,1],aim=15
组成15元的方法有6种,分别为3张5元,1张10元+1张5元,1张10元+5张1元,10张1元+1张5元,2张5元+5张1元和15张1元,所以返回6.
arr=[3,5],aim=2
任何方法都无法组成2元,所以返回0
解法:
首先用暴力递归的方法,如果arr=[5,10,25,1],aim=1000,分析过程如下:
- 用0张5元的货币,让[10,25,1]组成剩下的1000,最终的方法数记为res1.
- 用1张5元的货币,让[10,25,1]组成剩下的995,最终的方法数记为res2.
- 用2张5元的货币,让[10,25,1]组成剩下的990,最终的方法数记为res3.
- ...
- 用200张5元的货币,让[10,25,1]组成剩下的0,最终的方法数记为res201.
那么res1+res2+...+res201的值就是总的方法数。
代码:
/**
* 如果用arr[index...N-1]这些面值的钱组成aim,返回总的方法数
* 暴力递归方法,时间复杂度与arr中钱的面值有关,最差情况下为O(aim^N)。
* @param arr
* @param index
* @param aim
* @return
*/
private static int process1(int[] arr, int index, int aim) {
int res = 0;
if (index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
//用arr[index]货币i张,剩下aim-arr[index]*i。
for (int i = 0; arr[index] * i <= aim; i++) {
res += process1(arr, index + 1, aim - arr[index] * i);
}
}
return res;
}
记忆化搜索的优化方式。process1(arr,index,aim)中arr是始终不变的,变化的只有index和aim,所以可以用p(index,aim)表示一个递归过程。重复计算之所以大量发生,是因为每一个递归过程的结果都没记下来,所以下次还要重复去求。所以可以事先准备好一个map,每计算完一个递归过程,都将结果记录到map中。当下次进行同样的递归过程之前,先在map中查询这个递归过程是否已经计算过。
/**
* 记忆搜索优化,准备全局变量map,记录已经计算过的递归过程的结果
* map是一张二维表,map[i][j]表示递归过程p(i,j)的返回值。另外有一些特别值,map[i][j]==0表示递归过程p(i,j)从来没有计算过。
* map[i][j]==-1表示递归过程p(i,j)计算过,但是返回是0.
* 如果map[i][j]的值既不等于0,也不等于-1,记为a,则表示递归过程p(i,j)的返回值为a
* 记忆化搜索的时间复杂度为O(N*aim^2)
* @param arr
* @param aim
* @return
*/
private static int coin2(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int[][] map = new int[arr.length + 1][aim + 1];
return process2(arr, 0, aim, map);
}
private static int process2(int[] arr, int index, int aim, int[][] map) {
int res = 0;
if (index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
int mapValue = 0;
for (int i = 0; arr[index] * i <= aim; i++) {
mapValue = map[index + 1][aim - arr[index] * i];
if (mapValue != 0) {
res += mapValue == -1 ? 0 : mapValue;
} else {
res += process2(arr, index + 1, aim - arr[index] * i, map);
}
}
}
map[index][aim] = res == 0 ? -1 : res;
return res;
}
动态规划方法。生成生成行数为N,列数为aim+1的矩阵dp,dp[i][j]的含义是在使用arr[0...i]货币的情况下,组成钱数j有多少种方法。dp[i][j]的值求法如下:
1.对于矩阵dp第一列的值dp[...][0],表示组成钱数为0的方法数,很明显是1种,也就是不使用任何货币,所以dp的第一列的值统一设置为1.
2.对于矩阵dp的第一行的值dp[0][...],表示只能使用arr[0]这一种货币的情况下,组成钱的方法数,比如,arr[0]=5时,能组成的钱数只有0,5,10,15...所以,令dp[0][k*arr[0]]=1(0<=k*arr[0]<=aim,k为非负整数)
3.除第一行和第一列的其他位置,记为位置(i,j)。dp[i,j]的值是以下几个值的累加。
- 完全不用arr[i]货币,只用arr[0...i-1]货币时,方法数为dp[i-1][j];
- 用1张arr[i]货币,剩下的钱用arr[0...i-1]货币组成时,方法数为dp[i-1][j-arr[i]];
- 用2张arr[i]货币,剩下的钱用arr[0...i-1]货币组成时,方法数为dp[i-1][j-2*arr[i]];
- ...
- 用k张arr[i]货币,剩下的钱用arr[0...i-1]货币组成时,方法数为dp[i-1][j-k*arr[i]],j-k*arr[i]>=0,k为非负整数.
4.最终dp[N-1][aim]的值就是最终结果。
最差情况下,对于位置(i,j)来说,求解dp[i][j]的计算过程需要枚举dp[i-1][0...j]上的所有值,dp一共有N*aim个位置,所以总体的时间复杂度为O(N*aim^2)。
/**
* 动态规划方法
* 生成生成行数为N,列数为aim+1的矩阵dp,dp[i][j]的含义是在使用arr[0...i]货币的情况下,组成钱数j有多少种方法。
* 时间复杂度为O(N*aim^2)。
*
* @param arr
* @param aim
* @return
*/
private static int coin3(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int[][] dp = new int[arr.length][aim + 1];
for (int i = 0; i < arr.length; i++) {
dp[i][0] = 1;
}
for (int j = 1; arr[0] * j <= aim; j++) {
dp[0][arr[0] * j] = 1;
}
int num = 0;
for (int i = 1; i < arr.length; i++) {
for (int j = 1; j <= aim; j++) {
num = 0;
for (int k = 0; j - arr[i] * k >= 0; k++) {
num += dp[i - 1][j - arr[i] * k];
}
dp[i][j] = num;
}
}
return dp[arr.length - 1][aim];
}
记忆化搜索的方法就是不关注到达某一个递归过程的路径,只是单纯地对计算过的递归过程进行记录,避免重复的递归过程,而动态规划的方法则是规定好每一个递归过程的计算顺序,依次进行计算,后计算的过程严格依赖前面计算的过程。两者都是空间换时间的方法,也都有枚举的过程,区别就在于动态规划规定了计算顺序,而记忆搜索不用规定。所以记忆搜索方法的时间复杂度也是O(N*aim^2)。两者各有优缺点,如果对暴力递归过程简单地优化为记忆搜索的方法,递归函数依然在使用,这在工程上的开销较大。而动态规划方法严格规定了计算顺序,可以将递归计算编程顺序计算,这是动态规划方法具有的优势。
进一步优化。在动态规划第三步中,第1种情况的方法数为dp[i-1][j],而第2种情况一直到第k种情况的方法数累加值其实就是dp[i][j-arr[i]]。所以步骤3可以简化为dp[i][j] = dp[i-1][j]+dp[i][j-arr[i]]。一下省去了枚举的过程,时间复杂度也减少至O(n*aim)。代码如下:
/**
* 时间复杂度度为O(N*aim)
*
* @param arr
* @param aim
* @return
*/
public static int coin4(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int[][] dp = new int[arr.length][aim + 1];
for (int i = 0; i < arr.length; i++) {
dp[i][0] = 1;
}
for (int i = 1; arr[0] * i <= aim; i++) {
dp[0][arr[0] * i] = 1;
}
for (int i = 1; i < arr.length; i++) {
for (int j = 1; j <= aim; j++) {
dp[i][j] = dp[i - 1][j];
dp[i][j] += j - arr[i] >= 0 ? dp[i][j - arr[i]] : 0;
}
}
return dp[arr.length - 1][aim];
}
时间复杂度为O(n*aim)的动态规划方法再结合空间压缩的技巧。代码就可以进一步压缩为:
/**
* 空间压缩
* @param arr
* @param aim
* @return
*/
public static int coin5(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int[] dp = new int[aim + 1];
for (int j = 0; j * arr[0] <= aim; j++) {
dp[arr[0] * j] = 1;
}
for (int i = 1; i < arr.length; i++) {
for (int j = 1; j <= aim; j++) {
dp[j] += j - arr[i] >= 0 ? dp[j - arr[i]] : 0;
}
}
return dp[aim];
}
题目十一:
给定两个数组w和v,两个数组长度相等,w[i]表示第i件商品的终点,v[i]表示第i件商品的价值。再给定一个整数bag,要求你挑选商品的重量加起来一定不能超过bag,返回满足这个条件下,你能获得的最大价值。
/**
* 背包问题
*/
public class Knapsack {
public static int maxValue1(int[] c, int[] p, int bag) {
return process1(c, p, 0, 0, bag);
}
private static int process1(int[] weights, int[] values, int i, int alreadyweight, int bag) {
if (alreadyweight > bag) {
return 0;
}
if (i == weights.length) {
return 0;
}
return Math.max(
process1(weights, values, i + 1, alreadyweight, bag), //需要第i号商品
//不需要i号商品
values[i] + process1(weights, values, i + 1, alreadyweight + weights[i], bag));
}
public static int maxValue2(int[] c, int[] p, int bag) {
int[][] dp = new int[c.length + 1][bag + 1];
for (int i = c.length - 1; i >= 0; i--) {
for (int j = bag; j >= 0; j--) {
dp[i][j] = dp[i + 1][j];
if (j + c[i] <= bag) {
dp[i][j] = Math.max(dp[i][j], p[i] + dp[i + 1][j + c[i]]);
}
}
}
return dp[0][0];
}
public static void main(String[] args) {
int[] c = {3, 2, 4, 7};
int[] p = {5, 6, 3, 19};
int bag = 11;
System.out.println(maxValue1(c, p, bag));
System.out.println(maxValue2(c, p, bag));
}
}