动态规划算法

很早之前就看过算法导论这本书,当时看到动态规划算法的时候就觉得很厉害,然而一直是半懂不懂的状态。一看就会,一做就废。
最近又研究了下,通过本文做一个整理和总结。


先不讲任何理论,直接从问题出发。

斐波拉契数列

斐波拉契数列是数学里非常经典的问题,用算法实现的话,最容易想到的方式是递归。但递归过程中做了很多重复计算。

比如要计算fib(5)的值,则要分别计算fib(4)和fib(3)。而计算fib(4)时又要计算fib(3)和fib(2)的值。其中fib(3)就要重复计算。

如果把每个计算结果保留好,就不用重复计算了,直接从保留的结果中拿对应值就可以,极大节省了效率。

代码如下:

public int getFibNum(int n) {
    // 初始化一个n+1长度的数组,用于存放每一项的结算结果。其中,a[0]使用默认值0
    int[] a = new int[n+1];  
    for (int i = 1; i <= n; i++) {
        if (i <= 2) {
            a[i] = i;
        } else {
            a[i] = a[i-1] + a[i-2];
        }
    }
    return a[n];
}

注意到,这里使用了一个推导公式:fib[i] = fib[i-1] + fib[i-2]

上楼梯

假设有一个n个台阶的楼梯,每次只能走一个台阶或两个台阶。问走到台阶顶部一共有多少种走法?

分析:设想上楼梯的最后一步,可以从倒数第一个台阶上,也可以从倒数第二个台阶上。

设从倒数第一个台阶上的顶部的走法是f(1),倒数第二个台阶上到顶部的走法是f(2), 显然f(1)=1,f(2)=2。

从倒数第三个台阶上到顶部的方式:
先上1步到倒数第二阶,然后上到顶部,有f(2)种上楼方式;或者先上2步到达倒数第一阶,然后上到顶部,有f(1)中上楼方式。

因此从倒数第3阶上到顶部的方式f(3) = f(2) + f(1)。

同理可推论得:f(n) = f(n-1) + f(n-2)。

实际上这还是一个斐波拉契数列。

找零钱

假设有1元,5元,11元的纸币,数量不限。现要通过这些纸币找零n元,最少需要多少张纸币?

找零钱一般通过贪心算法实现,在可选择的范围内始终选择最大面值的零钱,比如人民币找零。

但实际上并不是所有的找零钱都能通过贪心算法实现,这种方式对币值是有要求的,这里不讨论具体条件。

例如题目所述,当找零钱15元时,如果用贪心算法,则是11+1+1+1+1,需要5张。实际上5+5+5是更优解。

对于这种没办法用贪心算法来找零的问题怎么处理呢?

分析:设找零n元的最优解为f(n),假设最后一步挑选一张纸币即可完成找零,最后一张纸币可能为1元,5元,11元。因此有:
f(n) = max(1+f(n-1), 1+f(n-5), 1+f(n-11))。

算法如下:

public static int getNum(int n) {
    // 存储找零钱数量
    int[] m = new int[n+1];
    for (int i = 1; i < n + 1; i++) {
        int a, b = Integer.MAX_VALUE, c = Integer.MAX_VALUE;
        // 最后一步取1元的张数
        a = 1 + m[i - 1];
        // 最后一步取5元的张数
        if (i >= 5) {
            b = 1 + m[i - 5];
        }
        // 最后一步取11元的张数
        if (i >= 11) {
            c = 1 + m[i - 11];
        }
        int min = a;
        if (b < min) {
            min = b;
        }
        if (c < min) {
            min = c;
        }
        m[i] = min;
    }
    return m[n];
}

棋子移动

一个a行b列的棋盘,现有一棋子位于棋盘左上角第一位置,每次只能向右或向下移动一格,则棋子移动到右下角最后一个位置一共有多少种路径?

分析:设棋盘第i行j列的坐标为(i,j),棋子从初始位置移动到坐标(i,j)的路径为f(i,j)。

棋子移动到(i,j)的最后一步有两种方式,即从(i-1,j)移动到(i,j),或者从(i,j-1)移动到(i,j)。

因此可推论得:f(i,j) = f(i-1,j) + f(i,j-1)。

用一个二维数据存储棋子移动到各个位置点的路径数,代码如下:

public static int getPathNum(int a, int b) {
    int[][] m = new int[a][b];
    for (int i = 0; i <= a - 1; i++) {
        for (int j = 0; j <= b - 1; j++) {
            if (i == 0) {
                // 在第一行移动,只有横移这一种方式
                m[0][j] = 1;
            } else if (j == 0) {
                // 在第一列移动,只有竖移这一种方式
                m[i][0] = 1;
            } else {
                m[i][j] = m[i-1][j] + m[i][j-1];
            }
        }
    }
    return m[a-1][b-1];
}

0-1背包

相比于上面的问题,0-1背包稍微复杂一点。问题描述如下:
给定n个重量为W1,W2,…,Wn,价值为V1,V2,…,Vn的物品和容量为C的背包,求这个物品中一个最有价值的子集,使得在满足背包的容量的前提下,包内的总价值最大

分析:设放入第i个物品后,包内的总价值为f(i,C)。

第i个物品可以装入背包,也可以不装入背包。不装情况下价值为f(i-1,C),装入的情况下价值为装入第i个物品之前的最大价值加上第i个物品的价值,也就是f(i-1, C-Wi) + Vi。

从这两个值中取较大的值就是真正的最大价值。

最终递推公式为:f(i,C) = max(f(i-1,C), f(i-1, C-Wi) + Vi)。这个递推公式用离散数学的思想很容易推导。

事实上可以通过逐步推导的方式来证明这个公式。设已有物品:
在这里插入图片描述
下面通过递推的方式来计算,将结果填入表格中:

  • 当背包容量为0时,价值全部为0,即f(i, 0) = 0。
  • 当背包容量为1,允许放入的物品编号为1时,物品1超出容量,因此f(1,1) = 0。
  • 当背包容量为1,允许放入的物品编号为1、2时,物品1超重,放入第二个物品,f(2,1) = 10。
  • 当背包容量为1,允许放入的物品编号为1、2、3时,物品1和3均超重,只能放入2,f(3,1) = 10。
  • 同理f(4,1) = 10。
  • 当背包容量为2,允许放入的物品编号为1时,可以放入,f(1,2) = 12。
  • 当背包容量为2,允许放入的物品编号为1、2时,放入1的价值是12,放入2的价值是10,均放入则超重,所以f(2,2) = 12。
  • 同理f(3,2) = 12, f(4,2) = 15。

  • 结果如下所示:
    在这里插入图片描述

这个计算结果是通过枚举计算来获得的,规律完全符合上述推导公式。当物品数量或者背包容量继续增大时,枚举计算已经不现实了,而通过推导公式则毫无问题。

算法实现如下:

public static int getMaxValue(int[] w, int[] v, int c) {
    int size = w.length;
    int max = 0;
    int[][] dp = new int[size][c+1];
    for (int i = 0; i < size; i++) {
        for (int j = 1; j <= c; j++) {
            if (i == 0) {
                if (w[0] <= j) {
                    // 一个物品,可以放就放,不能放就是默认值0
                    max = v[0];
                }
            } else {
                int a = dp[i-1][j];  // 已放入的i-1个物品的价值
                int b = 0;
                if (j >= w[i]) {
                    b = dp[i-1][j - w[i]] + v[i];
                }
                max = Math.max(a, b);
            }
            dp[i][j] = max;
        }
    }
    return dp[size-1][c];
}

public static void main(String[] args) {
    int[] w = {2, 1, 3, 2};
    int[] v = {12, 10, 8, 15};
    System.out.println(getMaxValue(w, v, 5));
}

连续子数组的最大和

给定一个int数组,数组中的数可以为正或负,输出和最大的子数组的和。
例如[-1, 2, -3, 5, -1, 2],连续子数组[5, -1, 2]和最大,为6。

分析:设以第i个元素结尾的连续子数组,和最大为f(i)。对于每个i(0<=i<n),将f(i)用一个数组存起来。然后取最大的f(i),就是以第i个元素结尾的连续子数组最大的和。

f(i)怎么计算呢?假设数组为[-1, 2],则显然f(0) = -1, f(1) = max(f(0) + 2, 2) = 2。

继续扩展数组为[-1, 2, -3],则f(2) = max(f(1) - 3, -3) = -1。

依次类推,很容易得到f(i) = max(f(i-1) + array[i], array[i])。实现代码如下:

public static int maxSubArray(int[] nums) {
    // m[i]表示第i个元素结果的最大子数组的和
    int[] m = new int[nums.length];
    m[0] = nums[0];
    for (int i = 1; i < nums.length; i++) {
        if (m[i-1] + nums[i] >= nums[i]) {
            m[i] = m[i-1] + nums[i];
        } else {
            m[i] = nums[i];
        }
    }
    int max = m[0];
    for (int i = 1; i < m.length; i++) {
        if (m[i] > max) {
            max = m[i];
        }
    }
    return max;
}

动态规划

分析了以上几个问题之后再来说动态规划的理论。

基本思想
问题的最优解如果可以由子问题的最优解推导得到,则可以先求子问题的最优解,再构造原问题的最优解;若子问题有较多的重复出现,则可以自底向上从最终子问题向原问题逐步求解。

使用条件
使用动态规划的两个条件:存在一个优化子结构;存在重叠子问题。

从上文的几个问题可以看出,每个问题都存在一个推导公式,也就是优化子结构。计算问题的最优解时需要用到子问题的解,存在重叠子问题。符合动态规划的使用条件。

最长回文子串

给定一个字符串,输出其最长回文子串

分析:设f(i,j)=1表示从i到j位置的子串为回文串,则当i-1和j+1位置的字符相同时,则f(i-1,j+1)=1。

状态转移方程为:
当s[i]=s[j],f(i,j)=f(i+1,j-1)。

实现如下:

public static String getLongestSubStr(String s) {
    if (s.length() == 0 || s.length() == 1) {
        return s;
    }
    if (s.length() == 2) {
        return s.charAt(0) == s.charAt(1) ? s : s.substring(0,1);
    }

    int size = s.length();
    int max = 1;  // 最长回文子串长度
    int start = 0;
    char[] chars = s.toCharArray();
    int[][] dp = new int[size][size]; // dp[i][j]表示位置i到j上的子串是否为回文串

    for (int i = 0; i < size; i++) {
        dp[i][i] = 1; // 单字符必为回文串
    }
    // 当s[i] == s[j]时,i到j之间是回文串则必是回文串,即dp[i][j]=dp[i+1][j-1]。
    // 当s[i] != s[j]时,dp[i][j]=0
    // 举例,当s[1]=s[5]时,要判断dp[1][5],只需要知道dp[2][4]的值。而dp[2][4]需要知道dp[1][3]的值。
    for (int j = 1; j < size; j++) {
        for (int i = 0; i < j; i++) {
            if (j - i == 1) {
                if (chars[i] == chars[j]) {
                    dp[i][j] = 1;
                    if (max == 1) {
                        max = 2;
                        start = i;
                    }
                }
            } else {
                if (chars[i] == chars[j]) {
                    dp[i][j] = dp[i+1][j-1];
                    if (dp[i][j] == 1 && j - i + 1 > max) {
                        max = j - i + 1;
                        start = i;
                    }
                }

            }
        }
    }
    return s.substring(start, start+max);
}

最大子序列之和

不选取相邻元素的基础上,选取一个子序列,使其和最大,输出最大的和。

例如大小为3的数组[1,2,3], 不相邻且和最大的子序列为[1,3],和为4。再比如大小为4的数组[3, 4, 6, 4], 不相邻且和最大的子序列为[3,6],和为10。

分析:
设前i个元素组成的最大子序列的和为dp[i],则前i+1个元素组成的最大子序列的和可能为dp[i], 也可能为dp[i-2] + array[i]。

算法实现如下:

/**
 * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
 * 计算
 * @param n int整型 数组的长度
 * @param array int整型一维数组 长度为n的数组
 * @return long长整型
 */
public long subsequence (int n, int[] array) {
    // write code here
    // 前n+1项最大和可能为前n项最大和,也可能为前n-1项中的最大数加上第n+1项数
    int[] dp = new int[n];
    if (n == 1) {
        return array[0];
    } else if (n == 2) {
        return Math.max(array[0], array[1]);
    } else {
        dp[0] = array[0];
        dp[1] = Math.max(array[0], array[1]);
        for (int i = 2; i < n; i++) {
            dp[i] = Math.max(dp[i-1], dp[i-2] + array[i]);
        }
        int max = dp[0];
        for (int i = 1; i < n; i++) {
            if (dp[i] > max) {
                max = dp[i];
            }
        }
        return max;
    }
}

两个数组的最长公共子序列

有两个int数组,长度分别为len1,len2,且 0 < len2 <= len1 <= 10000, 输出两个数组的最长公共子数组的长度。

例如[1,2,3,4,5]和[2,3,4,5,6],最大公共子数组为[2,3,4,5], 长度为4。

分析:

设dp[i][j]表示两个数组中分别以第i个元素和第j个元素结尾的数组的最大公共子数组的长度,如dp[2][1]表示第一个数组的前三个元素数组[1,2,3]和第二个数组的前两个元素数组[2,3]的最大公共子序列的长度,dp[2][1]=2。

则当arr1[i+1] = arr2[j+1]时,dp[i+1][j+1] = 1 + dp[i][j]。状态转移方程找出来之后实现就简单了。

算法实现如下:

public int findLongestCommonStr(String str1,String str2) {
    int len1 = str1.length();
    int len2 = str2.length();
    int max = 0;
    int[][] dp = new int[len1][len2];
    for (int i = 0; i < len1; i++) {
        for (int j = 0; j < len2; j++) {
            if (str1.charAt(i) == str1.charAt(j)) {
                if (i == 0 || j == 0) {
                    dp[i][j] = 1;
                    max = 1;
                } else {
                    dp[i][j] = 1 + dp[i-1][j-1];
                    if (dp[i][j] > max) {
                        max = dp[i][j];
                    }
                }
            }

        }
    }
    return max;
}
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值