第四章递归和动态规划(一)

1,斐波那契数列问题的递归和动态规划
补充题目1:
给定整数n,代表台阶数,1次可以跨2个或者1个台阶,返回有多少种走法。
举例:n=3,可以三次都跨一个台阶;也可以先跨2个台阶,再跨一个台阶;还可以先跨1一个台阶,再跨两个台阶。所以有三种方法。
补充题目2:假设母牛每年生1头小母牛,并且永远不会死。第一年有1只成熟的母牛,从第二年开始,母牛开始生小牛。每只小母牛3年之后成熟又可以生小母牛。给定整数n,求出n年后的数量。
举例:n=6,第一年1头母牛记为a;第二年a生了新的小母牛,记为b,总数为2;第三年a生了新的小母牛,记为c,总牛数为3;第4年a生了新的小母牛,记为d,总数为4。第五年b成熟了,a和b分别生了新的小母牛,总数为6;第6年c也成熟了,a、b和c分别生了新的小母牛,总数为9,返回9。
要求:时间复杂度O(logn)。
原问题的解答:
很容易写出暴力递归的解法,时间复杂度为O(2的n次方)。
代码如下:

public static int f1(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return 1;
        }
        return f1(n - 1) + f1(n - 2);
}

O(n)复杂度的方法:

public static int f2(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return 1;
        }
        int res = 1;
        int pre = 1;
        int tmp = 0;
        for (int i = 3; i <= n; i++) {
            tmp = res;
            res = res + pre;
            pre = tmp;
        }
        return res;
}

这没有用递归,斐波那契数列可以根据前两项求出后一项的值。
方法三:O(logn)时间复杂度的方法。
分析:用矩阵乘法的方式可以将时间复杂度降为O(logn)。f(n) = f(n-1) + f(n-2),是一个二阶递推数列,一定可以用矩阵乘法的形式表示,且状态矩阵为2*2的矩阵(这个太难,暂时理解不了)。
补充问题1:台阶只有1个,走法只有一种,有两个方法2种,如果有n级,最后跳上第n级的情况,要么是从n-2级台阶直接跨2级台阶,要么是n-1级跨1级台阶,所以台阶有n级的方法数,为跨到n-2级台阶的方法数加上跨到n-1级台阶的方法数,即s(n) = s(n-1) + s(n-2),初始项s(1) = 1,s(2) = 2,所以类似于斐波那契数列,但是不同的是初始项不同,可以很轻易地写出2的n次方与O(n)的方法,请看下面的s1方法和s2方法。

public static int s1(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return n;
        }
        return s1(n - 1) + s1(n - 2);

    }
public static int s2(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return n;
        }
        int res = 2;
        int pre = 1;
        int tmp = 0;
        for (int i = 3; i <= n; i++) {
            tmp = res;
            res = res + pre;
            pre = tmp;
        }
        return res;
}

以上是2的n次方和O(n)复杂度的方法。
下面讲解O(logn)复杂度的方法,也是求状态矩阵,用矩阵乘法。
补充问题2:所有的牛都不会死,c(n) = c(n-1) + c(n-3)。与斐波那契数列类似,不过是c(n)项依赖于c(n-1)和c(n-3)项的值,而斐波那契数列依赖于f(n-1)和f(n-2)项的值。
c1和c2方法分别是2的n次方和O(n)时间复杂度的方法。

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);
}
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;
}

2,矩阵的最小路径和
题目:给定一个矩阵m,从左上角开始每次只能往下或者往右走,最后到达右下角的位置,路径上的所有数字累加起来就是路径和,返回所有的路径和中最小的路径和。
举例:
矩阵如下:
1 3 5 9
8 1 3 4
5 0 6 1
8 8 4 0
这个矩阵,路径1,3,1,0,6,1,0。和为12,这是最小的路径和。
思路:这是经典的动态规划问题,假设矩阵的大小为m*n,m行n列。先生成大小和m一样的矩阵dp,dp[i][j]的值表示从左上角(即(0,0))位置走到(i,j)位置的最小路径和。对m的第一行所有位置来说,从(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]这些值累计起来的结果。就以题目例子来说,dp第一行和第一列的值如下:
1 4 9 18
9
14
22
生成的dp如下:
1 4 9 18
9 5 8 12
14 5 11 12
22 13 15 12
思路:除了第一行和第一列之外,每一个位置都考虑从左边到达自己的路径和更小还是从上边达到自己的路径和更小,最右下角的值就是整个问题的答案,具体过程请参考如下代码中的minPathSun1方法。

public static 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 = 1; j < col; j++) {
            dp[0][j] = dp[0][j - 1] + m[0][j];
        }
        for (int i = 1; i < row; i++) {
            for (int j = 1; 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数组。具体过程如下:
1),生成长度为4的数组arr,初始时arr={0,0,0,0},我们知道从(0,0)位置到达m中第一行的每个位置,最小路径和就是从(0,0)位置的值开始依次累加的结果,所以依次把arr设置为arr={1,4,9,18},此时arr[j]的值代表从(0,0)位置达到(0,j)位置的最小路径和。
2),步骤1中arr[j]

public static 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]);
        }
        for (int i = 1; i < more; i++) {
            arr[0] = arr[0] + (rowmore ? m[i][0] : m[0][i]);
            for (int j = 1; j < less; j++) {
                arr[j] = Math.min(arr[j - 1], arr[j])
                        + (rowmore ? m[i][j] : m[j][i]);
            }
        }

        return arr[less - 1];

}

扩展:本体压缩空间的方法几乎可以应用到所有需要二维动态规划表的题目中,通过一个数组滚动更新的方式无疑节省了大量的空间。在优化之前,取得某个位置动态规划值得过程是在矩阵中进行两次寻址,优化后,这一过程只需要一次寻址,程序的常数时间也得到了一定程度的加速。但是空间压缩的方法是有局限性的,本体如果改成”打印具有最小路径和的路径”,那么就不能使用空间压缩的方法。如果类似本题这种需要二维表的动态规划题目,最终目的是想求最优解的具体路径,往往需要完整的动态规划表,但如果只是想求最优解的值,则可以使用空间压缩的方法。因为空间压缩的方法是滚动更新的,会覆盖之前求解的值,让求解轨迹变得不可回溯。
3,换钱的最少货币数
题目:给定数组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,arr中所有的值都为正数。每个值仅代表一张钱的面值,再给定一个整数aim代表要找的钱数,求组成aim的最少货币数。
举例:
arr={5,2,3},aim=20。
5、2、3元的钱各有一张,所以无法组成20元,返回-1。
arr={5,2,5,3},aim=10。
5元的货币两张,可以组成10元,所以返回2。
arr={5,2,5,3},aim=15。
所有的货币加起来组成15元,返回4。
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..1][0]的值(即dp矩阵中第一列的值)表示找的钱数为0时需要的最少张数,钱数为0时,完全不需要任何货币,所以全设为0。
2),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。
3),剩下的位置依次从左到右,再从上到下计算。假设计算到位置(i,j),dp[i][j]的值可能来自下面的情况:
a,完全不使用当前货币arr[i]情况下的最少张数,即dp[i-1][j]的值。
b,只使用一张当前货币arr[i]情况下的最少张数,即dp[i-1][j-arr[i]]+1。
c,只使用两张当前货币arr[i]情况下的最少张数,即dp[i-1][j-2*arr[i]]+2。
d,只使用三张当前货币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(0<=y)}}
又有 min{dp[i-1][j - 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]即可。具体过程参看如下代码中的minCoins方法,整个过程的时间复杂度与额外空间复杂度都为O(n*aim),n为arr的长度。

public static 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]] + 1;
                }
                dp[i][j] = Math.min(left, dp[i - 1][j]);
            }
        }
        return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
}

原问题在动态规划基础上的空间压缩方法。参考”矩阵的最小路径和”问题,也就是上题。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值