一 斐波那契数列问题的递归和动态规划
【题目】给定整数N,返回斐波那契数列的第N项。
补充问题 1:给定整数 N,代表台阶数,一次可以跨 2个或者 1个台阶,返回有多少种走法。
【举例】N=3,可以三次都跨1个台阶;也可以先跨2个台阶,再跨1个台阶;还可以先跨1个台阶,再跨2个台阶。所以有三种走法,返回3。
补充问题 2:假设农场中成熟的母牛每年只会生 1 头小母牛,并且永远不会死。第一年农场有1只成熟的母牛,从第二年开始,母牛开始生小母牛。每只小母牛3年之后成熟又可以生小母牛。给定整数N,求出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。
【要求】对以上所有的问题,请实现时间复杂度为O(logN)的解法。
斐波那契数列问题
奶牛问题
private int[][] multiMatrix(int[][] m1, int[][] m2) {//矩阵乘法 // TODO Auto-generated method stub 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; } public int f3(int n) { if (n<1) { return 0; } if (n==1||n==2) { return 1; } int [][] base= {{1,1},{1,0}}; int[][] res=matrixPower(base, n-2); return res[0][0]+res[1][0]; } public int c3(int n) { if (n<1) { return 0; } if (n==1||n==2||n==3) { return 3; } int [][] base= {{1,0,1},{0,0,1},{1,0,0}}; int[][] res=matrixPower(base, n-3); return 3*res[0][0]+2*res[1][0]+res[2][0]; }
二 矩阵的最小路径和
给定一个矩阵 m,从左上角开始每次只能向右或者向下走,最后到达右下角的位置,路径上所有的数字累加起来就是路径和,返回所有的路径中最小的路径和。
经典动态规划方法。假设矩阵 m的大小为 M×N,行数为 M,列数为 N。先生成大小和 m一样的矩阵dp,dp[i][j]的值表示从左上角(即(0,0))位置走到(i,j)位置的最小路径和。对m的第一行的所有位置来说,即(0,j)(0≤j<N),从(0,0)位置走到(0,j)位置只能向右走,所以(0,0)位置到(0,j)位置的路径和就是 m[0][0..j]这些值的累加结果。同理,对 m 的第一列的所有位置来说,即(i,0) (0≤i<M),从(0,0)位置走到(i,0)位置只能向下走,所以(0,0)位置到(i,0)位置的路径和就是m[0..i][0]这些值的累加结果。
除第一行和第一列的其他位置(i,j)外,都有左边位置(i-1,j)和上边位置(i,j-1)。从(0,0)到(i,j)的路径必然经过位置(i-1,j)或位置(i,j-1),所以,dp[i][j]=min{dp[i-1][j],dp[i][j-1]}+m[i][j],含义是比较从(0,0)位置开始,经过(i-1,j)位置最终到达(i,j)的最小路径和经过(i,j-1)位置最终到达(i,j)的最小路径之间,哪条路径的路径和更小。那么更小的路径和就是 dp[i][j]的值。
public int minPathSum1(int[][] m) { if (m==null||m.length==0||m[0]==null||m[0].length==0) { return 0; } int row=m.length; int col=m[0].length; int[][] dp=new int[row][col]; dp[0][0]=m[0][0]; for (int i = 1; i < row; i++) { dp[i][0]=dp[i-1][0]+m[i][0]; } for (int j = 0; j < col; j++) { dp[0][j]=dp[0][j-1]+m[0][j]; } for (int i = 1; i < row; i++) { for (int j = 0; j < col; j++) { dp[i][j]=Math.min(dp[i-1][j], dp[i][j-1])+m[i][j]; } } return dp[row-1][col-1]; }
矩阵中一共有 M×N 个位置,每个位置都计算一次从(0,0)位置达到自己的最小路径和,计算的时候只是比较上边位置的最小路径和与左边位置的最小路径和哪个更小,所以时间复杂度为O(M×N),dp矩阵的大小为M×N,所以额外空间复杂度为O(M×N)。动态规划经过空间压缩后的方法。这道题的经典动态规划方法在经过空间压缩之后,时间复杂度依然是O(M×N),但是额外空间复杂度可以从O(M×N)减小至O(min{M,N}),也就是不使用大小为M×N的dp矩阵,而仅仅使用大小为min{M,N}的arr数组。具体过程如下
public int minPathSum2(int[][] m) { if (m==null||m.length==0||m[0]==null||m[0].length==0) { return 0; } int more=Math.max(m.length, m[0].length); int less=Math.min(m.length, m[0].length); boolean rowmore= more==m.length; int[] arr=new int[less]; arr[0]=m[0][0]; for (int i = 1; i < less; i++) { arr[i]=arr[i-1]+(rowmore? m[0][i]:m[i][0]);//先求出到对角线的值 } //数组 arr 中保存的是dp[i][i]中的值,但如果给定的矩阵行数小于列数(M<N),那么就生成长度为M的arr,然后令arr更新成dp矩阵每一列的值,及将arr 中的值保存为 dp[i][N] // 从左向右滚动过去 for (int i = 1; i < more; i++) { arr[0]=arr[0]+(rowmore?m[i][0]:m[0][i]); for (int j = 1; j < arr.length; j++) { arr[j]=Math.min(arr[j-1], arr[j])+(rowmore?m[i][j]:m[j][i]); } } return arr[less-1]; }
三 换钱的最少货币数
给定数组 arr,arr 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim,代表要找的钱数,求组成aim的最少货币数。
方法一:暴力递归
public int minCoins1(int[] arr,int aim) { if (arr==null||arr.length==0||aim<0) { return -1; } return process(arr,0,aim); } private int process(int[] arr, int i, int rest) { if (i==arr.length) { return rest==0?0:-1; } int res=-1; for (int k = 0; k * arr[i]<=rest; k++) { int next=process(arr, i+1, rest-k*arr[i]); if (next!=-1) { res= res==-1?next+k:Math.min(res, next+k); } } return res; }
//方法二:动态规划
public int minCoins(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return -1; // 对于非法输入,返回-1
}
int N = arr.length;
// 创建一个二维数组 dp,dp[i][j] 表示使用前 i 个货币组成金额 j 的最少货币数
int[][] dp = new int[N + 1][aim + 1];
// 初始化最后一行(i=N)为-1,表示使用最后一个货币无法组成任何金额
for (int col = 1; col <= aim; col++) {
dp[N][col] = -1;
}
// 从倒数第二行开始向第一行递推,计算每个单元格的值
for (int i = N - 1; i >= 0; i--) {
for (int rest = 0; rest <= aim; rest++) {
dp[i][rest] = -1; // 先将当前位置的值设为-1,表示无法组成目标金额
// 如果使用下一行的值能够组成当前金额,直接继承下一行的值
if (dp[i + 1][rest] != -1) {
dp[i][rest] = dp[i + 1][rest];
}
// 如果当前面值可以被使用,并且使用当前面值可以组成剩余金额,
// 则更新当前位置的值为使用当前面值和不适用当前面值两种情况中的最小值
if (rest - arr[i] >= 0 && dp[i][rest - arr[i]] != -1) {
if (dp[i][rest] == -1) {
dp[i][rest] = dp[i][rest - arr[i]] + 1;
} else {
dp[i][rest] = Math.min(dp[i][rest], dp[i][rest - arr[i]] + 1);
}
}
}
}
// 返回组成目标金额的最少货币数
return dp[0][aim];
}
这段代码使用动态规划思想,通过填表的方式计算出组成目标金额所需的最少货币数。
minCoins2 方法就是填一张 N×aim 的表,而且因为省掉了枚举过程,所以每个位置的值都在O(1)的时间内得到,该方法时间复杂度为O(N×aim)。
四 机器人达到指定位置方法数
假设有排成一行的N个位置,记为1~N,N一定大于或等于2。开始时机器人在其中的M位置上(M一定是1~N中的一个),机器人可以往左走或者往右走,如果机器人来到1位置,那么下一步只能往右来到2位置;如果机器人来到N位置,那么下一步只能往左来到N-1位置。规定机器人必须走K步,最终能来到P位置(P也一定是1~N中的一个)的方法有多少种。给定四个参数N、M、K、P,返回方法数。
(1)
public int ways1(int N, int M, int K, int P) {
// 检查输入参数的合法性
if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
return 0; // 如果参数不合法,直接返回0
}
// 调用 walk 方法计算机器人在K步内走到位置P的方法数
return walk(N, M, K, P);
}
private int walk(int N, int cur, int rest, int P) {
if (rest == 0) {
return cur == P ? 1 : 0; // 如果已经走完K步,检查是否到达位置P,是则返回1,否则返回0
}
if (cur == 1) {
return walk(N, 2, rest - 1, P); // 如果当前位置是1,则只能往右走到位置2
}
if (cur == N) {
return walk(N, N - 1, rest - 1, P); // 如果当前位置是N,则只能往左走到位置N-1
}
// 否则,计算能够往左或往右走到下一步位置的方法总数
return walk(N, cur + 1, rest - 1, P) + walk(N, cur - 1, rest - 1, P);
}
这段代码使用递归的方式计算机器人在K步内走到位置P的方法数。walk 方法负责计算具体的步数和位置情况,其中使用了递归的方式进行计算。
(2)动态规划
public 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[K + 1][N + 1]; dp[0][P] = 1; for (int i = 1; i <= K; i++) { for (int j = 1; j <= N; j++) { if (j == 1) { dp[i][j] = dp[i - 1][2]; } else if (j == N) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j + 1]; } } } return dp[K][M]; }
(3)动态规划+空间压缩
public int ways3(int N, int M, int K, int P) {
if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
return 0; // 如果参数不合法,直接返回0
}
int[] dp = new int[N + 1]; // 创建一个长度为N+1的数组,用于保存每个位置的方法数
dp[P] = 1; // 初始化目标位置P的方法数为1
for (int i = 1; i <= K; i++) {
int leftUp = dp[1]; // 保存上一行的左上角的值,即位置1的值
for (int j = 1; j <= N; j++) {
int tmp = dp[j]; // 保存当前位置的值
if (j == 1) {
dp[j] = dp[j + 1]; // 如果当前位置是1,只能往右走到位置2
} else if (j == N) {
dp[j] = leftUp; // 如果当前位置是N,只能往左走到位置N-1
} else {
dp[j] = leftUp + dp[j + 1]; // 否则,计算能够往左或往右走到下一步位置的方法总数
}
leftUp = tmp; // 更新左上角的值为上一行的当前位置的值
}
}
return dp[M]; // 返回机器人在K步内走到位置M的方法数
}
这种解法利用动态规划,使用一维数组dp来记录每个位置的方法数。外层循环遍历K步,内层循环遍历每个位置,通过更新数组元素的方式计算每个位置的方法数。最后返回机器人在K步内走到位置M的方法数。
五 换钱的方法数
给定数组 arr,arr 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim,代表要找的钱数,求换钱有多少种方法
(1)暴力解法
public int coins1(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
return process1(arr, 0, aim);
}
private int process1(int[] arr, int index, int aim) {
// TODO Auto-generated method stub
int res = 0;
if (index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
for (int i = 0; arr[index] * i <= aim; i++) {
res += process1(arr, index + 1, aim - arr[index] * i);
}
}
return res;
}
(2) 记忆搜索的方法
public int coins2(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0; // 如果数组为空或目标钱数小于0,直接返回0
}
int[][] map = new int[arr.length + 1][aim + 1]; // 创建一个二维数组,用于保存每个索引和钱数对应的换钱方法数
return process2(arr, 0, aim, map); // 调用递归函数,并返回结果
}
private int process2(int[] arr, int index, int aim, int[][] map) {
int res = 0; // 定义结果变量res
if (index == arr.length) { // 如果递归到达数组末尾
res = aim == 0 ? 1 : 0; // 如果目标钱数为0,说明找到一种换钱方法,将res设为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; // 如果之前计算得到的是-1,说明之前的结果无效,不加入结果res中,否则加入结果res中
} else { // 如果之前没有计算过
res += process2(arr, index + 1, aim - arr[index] * i, map); // 继续递归计算剩余钱数的换钱方法数
}
}
}
map[index][aim] = res == 0 ? -1 : res; // 将计算得到的结果存入map数组中,若结果为0,则设为-1,表示无效结果
return res; // 返回结果
}
段代码使用递归的方式解决换钱问题,并利用二维数组map来记录每个索引和钱数对应的换钱方法数。在递归函数process2
中,首先判断递归是否到达了数组末尾,如果是,判断目标钱数是否为0,然后返回相应的结果。如果未到达数组末尾,则遍历使用当前面值货币的数量,并查看之前是否计算过对应的换钱方法数。如果计算过,将结果加入结果变量res中(若之前的结果为-1,说明无效,则不加入结果中),如果未计算过,则继续递归计算剩余钱数的换钱方法数。最后将计算得到的结果存入map数组中,并返回结果res。
public int coins3(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0; // 如果数组为空或目标钱数小于0,直接返回0
}
int[][] dp = new int[arr.length][aim + 1]; // 创建一个二维数组dp,用于保存每个索引和钱数对应的换钱方法数
for (int i = 0; i < arr.length; i++) {
dp[i][0] = 1; // 当钱数为0时,方法数为1
}
for (int j = 1; arr[0] * j <= aim; j++) {
dp[0][arr[0] * j] = 1; // 第一个面值的货币只能用来凑整数倍的自己,所以设置对应的方法数为1
}
int num = 0; // 用于存储临时的换钱方法数
for (int i = 0; i < arr.length; i++) {
for (int j = 1; j <= aim; j++) {
num = 0; // 初始化临时变量为0
for (int k = 0; j - arr[i] * k >= 0; k++) {
num += dp[i - 1][j - arr[i] * k]; // 计算当前面值货币使用k张时的换钱方法数,并累加到临时变量中
}
dp[i][j] = num; // 将计算得到的结果存入dp数组中
}
}
return dp[arr.length - 1][aim]; // 返回结果
}
这段代码使用动态规划解决换钱问题,并利用二维数组dp来记录每个索引和钱数对应的换钱方法数。首先初始化第一列,即钱数为0时的方法数都为1,因为总钱数为0时只有一种换钱方法,即什么都不换。然后初始化第一行,即第一个面值的货币只能用来凑整数倍的自己,所以设置对应的方法数为1。接下来,遍历数组中的每个面值货币和钱数,并根据状态转移方程计算当前面值货币和钱数对应的换钱方法数。最后返回dp数组的最后一个元素,即最终的结果。
可以优化为
public int coins4(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0; // 如果数组为空或目标钱数小于0,直接返回0
}
int[][] dp = new int[arr.length][aim + 1]; // 创建一个二维数组dp,用于保存每个索引和钱数对应的换钱方法数
for (int i = 0; i < arr.length; i++) {
dp[i][0] = 1; // 当钱数为0时,方法数为1
}
for (int j = 1; arr[0] * j <= aim; j++) {
dp[0][arr[0] * j] = 1; // 第一个面值的货币只能用来凑整数倍的自己,所以设置对应的方法数为1
}
for (int i = 0; i < arr.length; i++) {
for (int j = 1; j <= aim; j++) {
dp[i][j] = dp[i - 1][j]; // 初始化为上一个硬币的情况
if (j - arr[i] >= 0) { // 判断当前面值的硬币是否可以组成j
dp[i][j] += dp[i][j - arr[i]]; // 若可以组成,则加上组成j-arr[i]的情况数
}
}
}
return dp[arr.length - 1][aim]; // 返回结果
}
(4)空间压缩
public int coins5(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0; // 如果数组为空或目标钱数小于0,直接返回0
}
int[] dp = new int[aim + 1]; // 创建一个一维数组dp,用于保存每个钱数对应的换钱方法数
for (int j = 1; j <= aim; j++) {
dp[arr[0] * j] = 1; // 第一个面值的货币只能用来凑整数倍的自己,所以设置对应的方法数为1
}
for (int i = 0; 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]; // 返回结果
}
这段代码使用动态规划解决换钱问题,并利用一维数组dp来记录每个钱数对应的换钱方法数。首先初始化第一个面值的货币的倍数对应的方法数为1。然后遍历数组中的每个面值货币和钱数,并根据状态转移方程计算当前钱数对应的换钱方法数。最后返回dp数组的最后一个元素,即最终的结果。这种方法优化了空间复杂度,只使用一维数组来保存动态规划的结果。