概述
Dynamic programming,简称DP,动态规划,基础算法之一,维基百科的解释:
是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
基本思想:给定问题,需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。子问题非常相似,因此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用,如斐波那契数列问题。
对比分治法
动态规划与分治法对比:
- 相同点:都是通过子问题组合求解原问题
- 不同点:分治法将问题划分为不相交的子问题,求解再合并。动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题,此时如果用分治法就会出现重复计算求解。为了避免重复,动态规划对子问题只求解一次,将其保存在表格中,从而无需每求解一个子子问题时重复计算。
数组维度
动态规划,什么时候使用一维数组,什么时候使用二维数据,即矩阵,取决于问题的状态和转移关系。
使用一维数组的场景:
- 状态只与一个变量有关:如果问题的状态只与一个变量有关,或可通过压缩状态减少维度,则可使用一维数组。如,经典的背包问题
- 状态可以被压缩:有些问题可以通过压缩状态来减少空间复杂度。例如,在某些二维DP问题中,我们只需要前一行的结果,可以使用两个一维数组来交替更新,或使用一个一维数组从后向前更新
- 滚动数组优化:当状态转移只依赖于前一状态或固定的几个前一状态,可以使用滚动数组(Rolling Array)技巧,将二维数组降维成一维数组,从而节省空间
使用二维数组的场景:
- 状态与两个变量有关:如果问题的状态与两个变量有关,并且状态转移依赖于这两个变量的值,那么使用二维数组是更自然的选择。如,编辑距离问题。
- 需要记录子问题的多种状态:当问题需要记录多个状态,并且这些状态之间有复杂的依赖关系时,使用二维数组可以清晰地表示这些依赖关系
- 多阶段决策:有些问题需要记录不同阶段的决策状态,用二维数组可以帮助记录每个阶段的状态
变量个数
在考虑使用动态规划思想来求解问题时,通常还需要考虑设置一个还是两个递增(或递减)变量。没有定论,如果一个变量解决不了,则需要考虑使用两个变量。一般而言:
- 单序列问题:只涉及一个序列的最优子结构,如:最长递增子序列、打家劫舍等问题
- 双序列问题:涉及两个序列的最优子结构时,如:背包问题、网格问题、分苹果问题
怎么分析只需要设置一个变量(处理一层for循环),还是必须使用两个变量?有时候还是挺简单,就看题目涉及到几个数组。
但是像下面的单词拆分
问题,算法入门者(包括我寄几),有时还是挺迷茫的。
秤砝码问题,看起来是两个数组,却要使用3个变量。
只能通过多练多写来固化思维,固化记忆,毕竟面试时谁能不紧张呢?一紧张就脑子一片空白。所以,一定要平时多训练。
经典题目
斐波那契
非常经典的问题,解法有好几个,包括:递归法、动态规划法、通项公式法、查表法(打表法)
递归法
直接给出实现代码:
public static int fib1(int n) {
if (n <= 1) {
return n;
}
return fib1(n-1) + fib1(n-2);
}
递归法的问题在于重复计算,即计算结果不能复用。
对于不同的输入$n$,计算$f(n)$到底需要多少次递归调用呢?可以改进上面的算法,引入一个计数器。
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];
}
爬楼梯
实际上就是斐波那契数列问题。简单。
打家劫舍
给定一个非负整数数组,不能取相邻的两个数,求能从数组里取到的所有数的和的最大值。
简单,直接给出代码:
/**
* 打家劫舍:给定非负整数数组,不能取相邻的两个数,求最大值
*/
public static int rob(int[] nums) {
if (nums.length < 2) {
return nums[0];
}
if (nums.length < 3) {
return Math.max(nums[0], nums[1]);
}
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < nums.length; i++) {
// 唯一核心代码行
dp[i] = Math.max(dp[i - 1], (nums[i] + dp[i - 2]));
}
return dp[nums.length - 1];
}
走棋盘
也叫走迷宫问题,给定一个$n*m$的棋盘格子,沿着格子边线走,不能走回头路。从左上角走到右下角,总共有多少种走法?
分析:首先需要明确的是二维矩阵的大小,对于上面这个题目的描述,实际上不是走格子,而是走交叉点。$nm$的棋盘格子,有$(n+1)(m+1)$个交叉点。入门难度的题目。
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];
}
背包问题
非常经典的动态规划入门题,有很多变种,例如$0-1$背包问题、完全背包问题、多重背包问题等。虽说是入门题,可是感觉比走棋盘等要难一些,难在写出状态转移方程。
其中$0-1$背包问题:给定一个背包的最大容量$W$,以及$n$个物品,每个物品有一个重量$w[i]$和价值$v[i]$。求解如何选择物品使得在不超过背包容量的情况下,背包中的总价值最大。
定义$dp[i][j]$表示前$i$个物品在背包容量为$j$时的最大价值,然后需要对这个二维数据进行初始化处理,$dp[0][j] = 0$表示当没有物品时,无论背包容量是多少,最大价值都是0。
然后需要分析状态转移。对于每个物品,有两个选择:
- 不放入背包:$dp[i][j] = dp[i-1][j]$
- 放入背包:$dp[i][j] = dp[i-1][j-w[i-1]] + v[i-1]$(前提是$j≥w[i−1]$)
从这两个选择里取较大者,所以状态转移方程为:$dp[i][j]=max(dp[i−1][j],dp[i−1][j−w[i−1]]+v[i−1])$。
给出代码实现:
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];
}
上面提到一维数组和二维数据(矩阵),并且上面这个解法是矩阵,那能不能使用一维数组来解决呢?
可以的,在计算$dp[i][j]$时,只需要用到上一行$dp[i-1][*]$的值,因此可以使用滚动数组进行优化,将二维数组降为一维数组。
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];
}
分苹果
一个很经典的问题:把$m$个苹果放在$n$个盘子,允许盘子置空,问共有多少种不同的分法?
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;
}
单词拆分
给定字符串s,单词(字符串)列表wordDict,判断s能否由wordDict组成,单词可重复使用。
示例:
- 输入s = "applepenapple",wordDict = ["apple", "pen"],输出true
- 输入s = "catdogs",wordDict = ["cat", "dog"],输出false
分析:一维dp数组应该可以解决问题;dp数组长度是字符串s的长度;dp数组的类型是boolean;dp[i]表示wordDict里的某一个单词是否能完美匹配字符串s的第i个字符(s.charAt(i)
);完美的定义是前前后后几个字符刚好组成wordDict里的单词;
源码如下:
/**
* 单词拆分:给定字符串s,单词(字符串)列表wordDict,判断s能否由wordDict组成,单词可重复使用
*/
public static boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordSet = new HashSet<>(wordDict);
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true; // 空字符串可以被拆分
for (int i = 1; i <= s.length(); i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && wordSet.contains(s.substring(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[s.length()];
}
零钱兑换
给定不重复的正整数数组coins表示不同面额的硬币,给定一个整数cost,各种硬币的数量无上限。求使得取出的硬币总价值等于cost的最少的硬币数量。
示例:
- 输入coins = [1],cost = 0,输出0
- 输入coins = [2],cost = 5,输出-1
- 输入coins = [2,1],cost = 5,输出3
分析:一维dp数组应该可以解决问题。dp数组的长度是什么?dp数组的类型是什么?dp数组的第一个元素,即dp[0]是什么?状态转移方程是什么?此题还是很有难度的。
秤砝码
秤砝码问题是一个经典的组合问题,给定一组砝码及其数量,问用这些砝码可以称出多少种不同的重量。
假设有$n$种不同重量的砝码,每种砝码的重量分别为$w_1,w_2,...,w_n$,每种砝码的数量分别为$c_1,c_2,...,c_n$。问可以称出的不同重量的种类数。
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;
}
最长公共子串
查找两个字符串$a,b$中的最长公共子串,如果有多个相同长度的子串,返回第一个即可。
分析:
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$单位的钢管,求一种最佳的切割方案,使得收益最大。
分析:n
可能小于10,共有$2^{n−1}$种分割方式(存在重复的情况),解决方法有递归、动态规划。
另外,动态规划还有两种等价的实现方法:
- 带备忘的自顶向下法:递归 + 记忆化。自顶向下法是从问题的最终状态开始,逐步递归地解决子问题,并将子问题的结果存储(记忆化)以避免重复计算。这种方法通常使用递归和一个缓存(如数组或哈希表)来存储已经计算过的结果。
- 自底向上法:迭代。自底向上法是从最小的子问题开始,逐步解决较大的子问题,直到解决整个问题。所有子问题的解会存储在一个数组中,这样每次计算都能直接引用之前计算过的结果
自底向上法
一般情况下,我们通常使用自底向上法求解动态规划类问题。定义一个数组$dp$,其中$dp[j]$表示长度为$j$的钢条的最大收益。状态转移方程:$$dp[j]=max(dp[j],p[i]+dp[j-i])$$,其中$1<=i<=j$。
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;
}
对比
同一个问题往往有好几个不同的解法,这些解法的思想可以相同也可以不相同。如何对不同的解法进行对比和评价呢?
一般情况下,可以从时间复杂度、空间复杂度、实现复杂度(代码易读性,即是否简单易懂)三个方面来进行对比。
此处亦不例外:
- 时间复杂度:
- 自顶向下法:由于递归和记忆化,时间复杂度为$O(n^2)$
- 自底向上法:使用两个嵌套循环,时间复杂度也为$O(n^2)$
- 空间复杂度:
- 自顶向下法:需要额外的递归栈空间,最坏情况下递归深度为$n$
- 自底向上法:只需一个长度为$n+1$的数组,空间复杂度为$O(n)$
- 实现复杂度:
- 自顶向下法:递归实现较为直观,但需要额外处理记忆化
- 自底向上法:迭代实现相对直接,不需要递归
记录切割方案
上面的代码只是返回最大收益,如果还想知道具体的切割方案,则需要略微加以修改和拓展。
@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问题:在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
分析:给定的数字序列(数组)肯定不是排好序的,数字可正可负。
最优二分搜索树
也叫最优二叉搜索树问题。最优指的是搜索成本最低,如何衡量搜索成本,则引出代价函数的概念。代价函数有多种,其中一种是比较次数。