目录
动态规划算法是一种在数学、计算机科学和经济学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划算法常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划算法的基本思想
动态规划算法的基本思想是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。动态规划算法背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
动态规划算法的性质
动态规划算法通常需要满足以下三个性质:
- 最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
- 子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。
- 无后效性。即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
动态规划算法的步骤
动态规划算法一般可以分为以下四个步骤:
- 划分阶段。按照问题的特征,把问题分为若干阶段。注意:划分后的阶段一定是有序的或者可排序的。
- 确定状态和状态变量。将问题发展到各个阶段时所处的各种不同的客观情况表现出来。状态的选择要满足无后续性。
- 确定决策并写出状态转移方程:状态转移就是根据上一阶段的决策和状态来导出本阶段的状态。根据相邻两个阶段状态之间的联系来确定决策方法和状态转移方程。
- 边界条件:状态转移方程是一个递推式,因此需要找到递推终止的条件
动态规划算法的例子
为了更好地理解动态规划算法的思想和应用,我们来看几个经典的例子。
例1:斐波那契数列
斐波那契数列是一个非常简单的递归问题,它的定义如下:
如果我们直接按照定义来实现,我们会发现存在大量的重复计算,比如计算 F(5) 时,需要计算 F(4) 和 F(3),而计算 F(4) 时,又需要计算 F(3) 和 F(2),以此类推。这样的时间复杂度是指数级的,非常低效。
为了避免重复计算,我们可以用一个数组来存储已经计算过的结果,这样每次计算前先查看是否已经存在,如果存在就直接返回,否则就按照递推公式计算并存储。这样的时间复杂度是线性的,空间复杂度也是线性的。
// C++ 代码
int fib(int n) {
// 创建一个数组来存储结果
vector<int> dp(n+1, -1);
// 初始化边界条件
dp[0] = 0;
dp[1] = 1;
// 定义一个辅助函数来递归求解
int helper(int n) {
// 如果已经计算过,直接返回
if (dp[n] != -1) return dp[n];
// 否则按照递推公式计算并存储
dp[n] = helper(n-1) + helper(n-2);
return dp[n];
}
// 调用辅助函数
return helper(n);
}
我们还可以进一步优化空间复杂度,因为我们只需要存储最近的两个结果就可以推导出下一个结果,所以我们可以用两个变量来代替数组,这样的空间复杂度是常数的。
// C++ 代码
int fib(int n) {
// 初始化边界条件
if (n == 0) return 0;
if (n == 1) return 1;
// 定义两个变量来存储最近的两个结果
int a = 0; // F(0)
int b = 1; // F(1)
int c; // F(n)
// 循环计算 F(n)
for (int i = 2; i <= n; i++) {
// 按照递推公式更新 c
c = a + b;
// 更新 a 和 b
a = b;
b = c;
}
// 返回结果
return c;
}
例2:最长上升子序列
最长上升子序列(Longest Increasing Subsequence)是一个经典的动态规划问题,它的描述如下:
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18] 输出: 4 解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
为了求解最长上升子序列的长度,我们可以定义一个数组 dp,其中 dp[i] 表示以 nums[i] 结尾的最长上升子序列的长度。那么,我们的目标就是求出 dp 数组中的最大值。
那么,如何求解 dp[i] 呢?我们可以考虑 nums[i] 前面的所有元素 nums[j](0 <= j < i),如果 nums[j] < nums[i],那么 nums[i] 可以接在 nums[j] 后面形成一个更长的上升子序列,而这个子序列的长度就是 dp[j] + 1。因此,我们可以遍历所有的 j,找出满足条件的最大的 dp[j] + 1,作为 dp[i] 的值。即:
最后,我们遍历一遍 dp 数组,找出其中的最大值,就是最长上升子序列的长度。
// C++ 代码
int lengthOfLIS(vector<int>& nums) {
// 获取数组长度
int n = nums.size();
// 特判
if (n == 0) return 0;
// 创建 dp 数组
vector<int> dp(n, 1);
// 初始化最大值为 1
int maxLen = 1;
// 遍历数组
for (int i = 1; i < n; i++) {
// 遍历前面的元素
for (int j = 0; j < i; j++) {
// 如果满足上升条件
if (nums[j] < nums[i]) {
// 更新 dp[i]
dp[i] = max(dp[i], dp[j] + 1);
}
}
// 更新最大值
maxLen = max(maxLen, dp[i]);
}
// 返回结果
return maxLen;
}
这个算法的时间复杂度是 O(n^2),空间复杂度是 O(n)。如果想进一步优化时间复杂度,可以使用二分查找的方法,将时间复杂度降低到 O(nlogn)。
例3:最大子数组和
最大子数组和(Maximum Subarray Sum)是一个经典的动态规划问题,它的描述如下:
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4] 输出: 6 解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
解题思路:
为了求解最大子数组和,我们可以定义一个数组 dp,其中 dp[i] 表示以 nums[i] 结尾的最大子数组和。那么,我们的目标就是求出 dp 数组中的最大值。
那么,如何求解 dp[i] 呢?我们可以考虑 nums[i] 前面的最大子数组和 dp[i-1],如果 dp[i-1] > 0,那么 nums[i] 可以接在 dp[i-1] 后面形成一个更大的子数组,而这个子数组的和就是 dp[i-1] + nums[i]。如果 dp[i-1] <= 0,那么 nums[i] 就不需要接在 dp[i-1] 后面,因为这样会使得子数组的和变小,所以这时候最大子数组就是 nums[i] 本身,即 dp[i] = nums[i]。因此,我们可以得到递推公式:
dp[i]=max(dp[i−1]+nums[i],nums[i])
最后,我们遍历一遍 dp 数组,找出其中的最大值,就是最大子数组和。
// C++ 代码
int maxSubArray(vector<int>& nums) {
// 获取数组长度
int n = nums.size();
// 特判
if (n == 0) return 0;
// 创建 dp 数组
vector<int> dp(n);
// 初始化第一个元素
dp[0] = nums[0];
// 初始化最大值为第一个元素
int maxSum = nums[0];
// 遍历数组
for (int i = 1; i < n; i++) {
// 按照递推公式更新 dp[i]
dp[i] = max(dp[i-1] + nums[i], nums[i]);
// 更新最大值
maxSum = max(maxSum, dp[i]);
}
// 返回结果
return maxSum;
}
这个算法的时间复杂度是 O(n),空间复杂度是 O(n)。如果想进一步优化空间复杂度,可以使用一个变量来代替 dp 数组,因为我们只需要存储前一个状态的值就可以推导出当前状态的值,这样的空间复杂度是 O(1)。
// C++ 代码
int maxSubArray(vector<int>& nums) {
// 获取数组长度
int n = nums.size();
// 特判
if (n == 0) return 0;
// 定义一个变量来存储前一个状态的值
int pre = nums[0];
// 初始化最大值为第一个元素
int maxSum = nums[0];
// 遍历数组
for (int i = 1; i < n; i++) {
// 按照递推公式更新当前状态的值
pre = max(pre + nums[i], nums[i]);
// 更新最大值
maxSum = max(maxSum, pre);
}
// 返回结果
return maxSum;
}
例4:最长公共子序列
最长公共子序列(Longest Common Subsequence)是一个经典的动态规划问题,它的描述如下:
给定两个序列 X=<x 1 ,x 2 ,x 3 ,…,x m >和Y=<y 1 ,y 2 ,y 3 ,…,y n >,找出X和Y的最大长度公共子序列。
示例:
输入: X = “ABCBDAB”, Y = “BDCABA” 输出: 4 解释: 最长公共子序列是 “BCBA”,长度为 4。
解题思路:
为了求解最长公共子序列,我们可以定义一个二维数组 dp,其中 dp[i] [j] 表示 X 的前 i 个元素和 Y 的前 j 个元素的最长公共子序列的长度。那么,我们的目标就是求出 dp[m] [n] 的值。
那么,如何求解 dp[i] [j] 呢?我们可以分析 X[i] 和 Y[j] 的关系,如果它们相等,那么它们就可以构成一个公共子序列,而这个公共子序列的长度就是 X 的前 i-1 个元素和 Y 的前 j-1 个元素的最长公共子序列的长度加一,即 dp[i] [j] = dp[i-1] [j-1] + 1。如果它们不相等,那么它们就不能构成一个公共子序列,而这时候最长公共子序列的长度就是 X 的前 i-1 个元素和 Y 的前 j 个元素的最长公共子序列的长度与 X 的前 i 个元素和 Y 的前 j-1 个元素的最长公共子序列的长度中的较大者,即 dp[i] [j] = max(dp[i-1] [j], dp[i] [j-1])。因此,我们可以得到递推公式:
最后,我们从 dp[m] [n] 开始回溯,根据递推公式判断每个元素是否属于最长公共子序列,从而得到最长公共子序列。
// C++ 代码
int longestCommonSubsequence(string X, string Y) {
// 获取两个字符串的长度
int m = X.size();
int n = Y.size();
// 创建 dp 数组
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
// 初始化第一行和第一列为零
for (int i = 0; i <= m; i++) {
dp[i][0] = 0;
}
for (int j = 0; j <= n; j++) {
dp[0][j] = 0;
}
// 遍历两个字符串
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 按照递推公式更新 dp[i][j]
if (X[i - 1] == Y[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 返回结果
return dp[m][n];
}
这个算法的时间复杂度是 O(mn),空间复杂度是 O(mn)。如果想进一步优化空间复杂度,可以使用两个一维数组来代替二维数组,因为我们只需要存储当前行和上一行的值就可以推导出下一行的值,这样的空间复杂度是 O(n)。
// C++ 代码
int longestCommonSubsequence(string X, string Y) {
// 获取两个字符串的长度
int m = X.size();
int n = Y.size();
// 创建两个一维数组
vector<int> pre(n + 1);
vector<int> cur(n + 1);
// 初始化第一行为零
for (int j = 0; j <= n; j++) {
pre[j] = 0;
}
// 遍历两个字符串
for (int i = 1; i <= m; i++) {
// 初始化第一列为零
cur[0] = 0;
for (int j = 1; j <= n; j++) {
// 按照递推公式更新 cur[j]
if (X[i - 1] == Y[j - 1]) {
cur[j] = pre[j - 1] + 1;
} else {
cur[j] = max(pre[j], cur[j - 1]);
}
}
// 将当前行赋值给上一行
pre = cur;
}
// 返回结果
return cur[n];
}