概述
Dynamic programming,简称DP,动态规划,基础算法之一,维基百科的解释:
是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
基本思想:给定问题,需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。子问题非常相似,因此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用,如斐波那契数列问题。
对比分治法
动态规划与分治法对比:
- 相同点:都是通过子问题组合求解原问题
- 不同点:分治法将问题划分为不相交的子问题,求解再合并。动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题,此时如果用分治法就会出现重复计算求解。为了避免重复,动态规划对子问题只求解一次,将其保存在表格中,从而无需每求解一个子子问题时重复计算。
数组维度
动态规划,什么时候使用一维数组,什么时候使用二维数据,即矩阵,取决于问题的状态和转移关系。
使用一维数组的场景:
- 状态只与一个变量有关:如果问题的状态只与一个变量有关,或可通过压缩状态减少维度,则可使用一维数组。如,经典的背包问题
- 状态可以被压缩:有些问题可以通过压缩状态来减少空间复杂度。例如,在某些二维DP问题中,我们只需要前一行的结果,可以使用两个一维数组来交替更新,或使用一个一维数组从后向前更新
- 滚动数组优化:当状态转移只依赖于前一状态或固定的几个前一状态,可以使用滚动数组(Rolling Array)技巧,将二维数组降维成一维数组,从而节省空间
使用二维数组的场景:
- 状态与两个变量有关:如果问题的状态与两个变量有关,并且状态转移依赖于这两个变量的值,那么使用二维数组是更自然的选择。如,编辑距离问题。
- 需要记录子问题的多种状态:当问题需要记录多个状态,并且这些状态之间有复杂的依赖关系时,使用二维数组可以清晰地表示这些依赖关系
- 多阶段决策:有些问题需要记录不同阶段的决策状态,用二维数组可以帮助记录每个阶段的状态
经典题目
斐波那契
非常经典的问题,解法有好几个,包括:递归法、动态规划法、通项公式法、查表法(打表法)
递归法
直接给出实现代码:
public static int fib1(int n) {
if (n <= 1) {
return n;
}
return fib1(n-1) + fib1(n-2);
}
递归法的问题在于重复计算,即计算结果不能复用。
对于不同的输入,计算到底需要多少次递归调用呢?可以改进上面的算法,引入一个计数器。
private static int count = 0;
public static int fib1(int n) {
count++;
if (n <= 1) {
return n;
}
return fib1(n-1) + fib1(n-2);
}
public static void main(String[] args) {
int n = 15;
System.out.println("输入:" + n + ",计算结果:" + fib1(n) + ",计算(递归)次数:" + count);
}
一些打印输出:
输入:15,计算结果:610,计算(递归)次数:1973
输入:25,计算结果:75025,计算(递归)次数:242785
还是很恐怖的。
动态规划
直接给出实现代码:
public int fib2(int n) {
if (n <= 1) {
return n;
}
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
背包问题
非常经典的动态规划入门题,有很多变种,例如背包问题、完全背包问题、多重背包问题等。
其中背包问题:给定一个背包的最大容量,以及个物品,每个物品有一个重量和价值。求解如何选择物品使得在不超过背包容量的情况下,背包中的总价值最大。
定义表示前个物品在背包容量为时的最大价值,然后需要对这个二维数据进行初始化处理,表示当没有物品时,无论背包容量是多少,最大价值都是0。
然后需要分析状态转移。对于每个物品,有两个选择:
- 不放入背包:
- 放入背包:(前提是)
从这两个选择里取较大者,所以状态转移方程为:。
给出代码实现:
public static int knapsack(int[] weights, int[] values, int W) {
int n = weights.length;
int[][] dp = new int[n + 1][W + 1];
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= W; j++) {
if (j < weights[i - 1]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
}
}
}
return dp[n][W];
}
上面提到一维数组和二维数据(矩阵),并且上面这个解法是矩阵,那能不能使用一维数组来解决呢?
可以的,在计算时,只需要用到上一行的值,因此可以使用滚动数组进行优化,将二维数组降为一维数组。
public static int knapsack1(int[] weights, int[] values, int W) {
int n = weights.length;
int[] dp = new int[W + 1];
for (int i = 1; i <= n; i++) {
for (int j = W; j >= weights[i - 1]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i - 1]] + values[i - 1]);
}
}
return dp[W];
}
走棋盘
也叫走迷宫问题,给定一个的棋盘格子,沿着格子边线走,不能走回头路。从左上角走到右下角,总共有多少种走法?
分析:首先需要明确的是二维矩阵的大小,对于上面这个题目的描述,实际上不是走格子,而是走交叉点。的棋盘格子,有个交叉点。入门难度的题目。
public static int getNum(int n, int m) {
int[][] dp = new int[n + 1][m + 1];
// 初始化边界数据
for (int i = 0; i <= n; i++) {
dp[i][0] = 1;
}
for (int i = 0; i <= m; i++) {
dp[0][i] = 1;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[n][m];
}
分苹果
一个很经典的问题:把个苹果放在个盘子,允许盘子置空,问共有多少种不同的分法?
public static int countWays(int m, int n) {
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= n; i++) {
dp[0][i] = 1; // 0个苹果放在任何数量的盘子中都有一种方法
}
for (int i = 1; i <= m; i++) {
dp[i][0] = 0; // 非0个苹果放在0个盘子中没有方法
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (i < j) {
dp[i][j] = dp[i][i];
} else {
dp[i][j] = dp[i][j - 1] + dp[i - j][j];
}
}
}
return dp[m][n];
}
连续子数组最大和
给定一个整型数组,数组元素有正有负。数组中连续的多(包括一)个整数组成一个子数组。求所有子数组的和的最大值。
分析:这个题目也可以通过动态规划来求解。
public static int maxSubArray1(int[] nums) {
int[] dp = new int[nums.length];
dp[0] = nums[0];
int max = dp[0];
for (int i = 1; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
max = Math.max(max, dp[i]);
}
return max;
}
秤砝码
秤砝码问题是一个经典的组合问题,给定一组砝码及其数量,问用这些砝码可以称出多少种不同的重量。
假设有种不同重量的砝码,每种砝码的重量分别为,每种砝码的数量分别为。问可以称出的不同重量的种类数。
public static int countDistinctWeights(int[] weights, int[] counts) {
int maxWeight = 0;
for (int i = 0; i < weights.length; i++) {
maxWeight += weights[i] * counts[i];
}
// 布尔数组dp,dp[j]表示是否可以称出重量为j的砝码,从dp[0]开始,然后逐步增加每种砝码的重量,更新dp数组
boolean[] dp = new boolean[maxWeight + 1];
dp[0] = true; // 可以称出重量为0
for (int i = 0; i < weights.length; i++) {
for (int j = maxWeight; j >= 0; j--) {
if (dp[j]) {
for (int k = 1; k <= counts[i] && j + k * weights[i] <= maxWeight; k++) {
dp[j + k * weights[i]] = true;
}
}
}
}
int res = 0;
for (boolean b : dp) {
if (b) {
res++;
}
}
return res;
}
最长公共子串
查找两个字符串中的最长公共子串,如果有多个相同长度的子串,返回第一个即可。
分析:
public static String longestCommonSubstring(String a, String b) {
// dp[i][j]表示字符串a的前i个字符和字符串b的前j个字符的最长公共子串长度
int[][] dp = new int[a.length() + 1][b.length() + 1];
int maxLength = 0;
int endIndex = 0;
for (int i = 1; i <= a.length(); i++) {
for (int j = 1; j <= b.length(); j++) {
if (a.charAt(i - 1) == b.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
if (dp[i][j] > maxLength) {
maxLength = dp[i][j];
endIndex = i - 1;
}
}
}
}
if (maxLength == 0) {
return "";
}
return a.substring(endIndex - maxLength + 1, endIndex + 1);
}
切割钢条
也叫切割钢管问题:给定钢管长度和对应的价钱如下表,
给你一根长度为单位的钢管,求一种最佳的切割方案,使得收益最大。
分析:n
可能小于10,共有种分割方式(存在重复的情况),解决方法有递归、动态规划。
另外,动态规划还有两种等价的实现方法:
- 带备忘的自顶向下法:递归 + 记忆化。自顶向下法是从问题的最终状态开始,逐步递归地解决子问题,并将子问题的结果存储(记忆化)以避免重复计算。这种方法通常使用递归和一个缓存(如数组或哈希表)来存储已经计算过的结果。
- 自底向上法:迭代。自底向上法是从最小的子问题开始,逐步解决较大的子问题,直到解决整个问题。所有子问题的解会存储在一个数组中,这样每次计算都能直接引用之前计算过的结果
自底向上法
一般情况下,我们通常使用自底向上法求解动态规划类问题。定义一个数组,其中表示长度为的钢条的最大收益。状态转移方程:,其中。
public static void main(String[] args) {
int[] prices = {0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30}; // 长度为i的钢条价格,索引从0开始
int n = 8; // 钢条长度
System.out.println("Maximum profit: " + rodCut(prices, n));
}
public static int rodCut(int[] prices, int n) {
int[] dp = new int[n + 1]; // dp[i]表示长度为i的钢条的最大收益
for (int j = 1; j <= n; j++) {
for (int i = 1; i <= j; i++) {
dp[j] = Math.max(dp[j], prices[i] + dp[j - i]);
}
}
return dp[n];
}
自顶向下法
需要额外引入一个Hash表结构:
public static void main(String[] args) {
int[] prices = {0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30};
int n = 8;
Map<Integer, Integer> memo = new HashMap<>();
System.out.println("Maximum profit: " + rodCut(prices, n, memo));
}
public static int rodCut(int[] prices, int n, Map<Integer, Integer> memo) {
if (n == 0) {
return 0;
}
if (memo.containsKey(n)) {
return memo.get(n);
}
int maxProfit = Integer.MIN_VALUE;
for (int i = 1; i <= n; i++) {
int currentProfit = prices[i] + rodCut(prices, n - i, memo);
maxProfit = Math.max(maxProfit, currentProfit);
}
memo.put(n, maxProfit);
return maxProfit;
}
对比
同一个问题往往有好几个不同的解法,这些解法的思想可以相同也可以不相同。如何对不同的解法进行对比和评价呢?
一般情况下,可以从时间复杂度、空间复杂度、实现复杂度(代码易读性,即是否简单易懂)三个方面来进行对比。
此处亦不例外:
- 时间复杂度:
- 自顶向下法:由于递归和记忆化,时间复杂度为
- 自底向上法:使用两个嵌套循环,时间复杂度也为
- 空间复杂度:
- 自顶向下法:需要额外的递归栈空间,最坏情况下递归深度为
- 自底向上法:只需一个长度为的数组,空间复杂度为
- 实现复杂度:
- 自顶向下法:递归实现较为直观,但需要额外处理记忆化
- 自底向上法:迭代实现相对直接,不需要递归
记录切割方案
上面的代码只是返回最大收益,如果还想知道具体的切割方案,则需要略微加以修改和拓展。
@Data
@AllArgsConstructor
private static class Result {
private int maxProfit;
/**
* 记录数组索引信息
*/
private List<Integer> cuts;
}
public static Result rodCutWithDetail(int[] prices, int n) {
int[] dp = new int[n + 1]; // dp[i]表示长度为i的钢条的最大收益
int[] cuts = new int[n + 1]; // cuts[i]表示长度为i的钢条的第一次切割长度
for (int j = 1; j <= n; j++) {
for (int i = 1; i <= j; i++) {
if (dp[j] < prices[i] + dp[j - i]) {
dp[j] = prices[i] + dp[j - i];
cuts[j] = i;
}
}
}
List<Integer> cutList = new ArrayList<>();
while (n > 0) {
cutList.add(cuts[n]);
n -= cuts[n];
}
return new Result(dp[dp.length - 1], cutList);
}
最长不下降子序列
Longest Increasing Subsequence,LIS问题:在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
分析:给定的数字序列(数组)肯定不是排好序的,数字可正可负。
最优二分搜索树
也叫最优二叉搜索树问题。最优指的是搜索成本最低,如何衡量搜索成本,则引出代价函数的概念。代价函数有多种,其中一种是比较次数。
矩阵链相乘
Floyd最短路
凸多边形三角剖分
参考