如何判断是否适用动态规划?
动态规划(DP)是解决具有特定结构问题的强大工具,但并不是所有问题都适用。要判断一个问题是否适用动态规划,我们需要考虑以下几个核心要素:
1. 核心判断依据:
-
求方案数:如果问题本质上是在求解“方案数”或“最优解”,并且问题中的子问题是重叠的,动态规划通常是首选方法。
例如,计算某个目标金额的不同组合数(例如背包问题),或者在给定条件下分配资源等,都会适用动态规划。 -
子问题重叠:判断问题中是否存在子问题重叠。动态规划之所以高效,是因为它通过缓存已计算过的子问题的结果,避免了重复计算。例如,斐波那契数列就是一个经典的例子,它的每个子问题(如
fib(n-1)和fib(n-2))会在多个地方被重复调用,这正是动态规划的应用场景。 -
最优子结构:是否能够利用前面计算的状态推导出后面的状态?动态规划依赖于最优子结构,意味着通过解决子问题,能逐步构造出大问题的最优解。最优子结构是动态规划的一个重要特征。例如,在背包问题中,最大价值是由各个物品的最优选择状态推导出来的。
2. 动态规划 vs 回溯 vs BFS:
-
动态规划:适用于求解最优解或者方案数的问题,尤其是具有重叠子问题和最优子结构的情况。例如,考虑一串数字的组合情况,动态规划能够通过前面的计算结果来减少重复计算,优化效率。
-
回溯:适用于需要找到所有解或者排列组合的情况,通常递归进行,探索所有可能的解空间。例如,在解决排列问题或选择问题时,回溯通过不断尝试每种选择并撤销来寻找所有可能的解。
-
BFS(广度优先搜索):适用于寻找最短路径的情况。BFS通过逐层扩展节点的方式,保证找到最短路径。例如,图中的最短路径问题可以通过BFS来求解,保证每次遍历到最小的步数。
动态规划的解题步骤
动态规划问题的解决过程通常包括以下几个步骤,下面将结合具体例子进行说明。
1. 确定 dp 数组及下标含义
-
状态的定义:我们首先要明确问题中“状态”是什么意思。例如,在斐波那契数列问题中,状态
dp[i]表示第i项的值,目标是通过递推计算出最终结果。 -
如何推导子问题的解:在背包问题中,
dp[i]表示在容量为i的背包中,能装下的最大价值。通过前面已知的背包容量和物品的选择情况,我们可以推导出当前背包容量的最优解。
2. 确定状态转移方程
状态转移方程是动态规划的核心,它定义了如何通过已知状态推导出未知状态。
-
例如,斐波那契数列:
dp[i] = dp[i-1] + dp[i-2]。从前两个数推导出当前数值。 -
凑零钱问题:定义状态
dp[i]表示凑成金额i所需的最少硬币数。状态转移方程为:dp[i] = min(dp[i], dp[i - coin] + 1),表示选择一个硬币后,更新当前金额的最优解。
3. 初始化 dp 数组
-
边界条件:例如,在斐波那契数列中,
dp[0] = 0和dp[1] = 1,这些是基本的初始值,必须先给定才能递推出后续值。 -
在背包问题中,
dp[0] = 0表示一个容量为0的背包最大价值为0。
4. 确定遍历顺序
- 动态规划的遍历顺序依赖于问题的特性。例如:
- 在凑零钱问题中,我们通常从小到大遍历目标金额,因为每个子问题的解依赖于更小的子问题。
- 对于分割问题,比如找到数组中两个不重叠子数组之和最大的,可以使用Kandane算法来从左往右找到最大子数组,再从右往左找到最大子数组,然后通过动态规划找到最大值。
5. 举例推导 dp 数组
-
斐波那契数列:
int fib(int n) { if (n <= 1) return n; vector<int> dp(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]; } -
背包问题:
int knapsack(int W, vector<int>& wt, vector<int>& val, int n) { vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0)); for (int i = 1; i <= n; ++i) { for (int w = 1; w <= W; ++w) { if (wt[i-1] <= w) { dp[i][w] = max(dp[i-1][w], dp[i-1][w-wt[i-1]] + val[i-1]); } else { dp[i][w] = dp[i-1][w]; } } } return dp[n][W]; }
动态规划的适用场景
动态规划非常适用于以下几种类型的问题:
1. 最优化问题
这类问题的目标是找到一种方案,使得某个指标(如成本、时间、利润等)达到最优。例如:
- 最短路径问题:如在图中从起点到终点的最短路径,典型的动态规划应用。
- 最大利润问题:例如股票买卖问题,如何通过动态规划来找到最佳的买卖时机。
2. 组合计数问题
这类问题通常涉及到在某些限制条件下,计算有多少种不同的方案。例如:
- 背包问题:在一定容量下选择不同物品,使得总价值最大化。
- 凑零钱问题:求解给定硬币面额和目标金额时,如何选择硬币组合。
3. 序列问题
需要在序列或字符串中找到最优解或符合某种条件的子序列。例如:
- 最长公共子序列(LCS):给定两个字符串,找到最长的公共子序列。
- 最长递增子序列(LIS):找到一个数组的最长递增子序列。
4. 划分问题
将一个集合分割成若干部分,使每部分满足某种条件并且最优化。例如:
- 分割问题:将一个数组分成两部分,使得两部分的和最接近。
5. 博弈与决策问题
这类问题涉及到在一定规则下进行决策,求出最优策略。例如:
- 最优策略:在棋类游戏中如何通过动态规划来确定每一步的最优决策。
典型问题
1. 斐波那契数列
斐波那契数列的计算可以通过动态规划来优化。通过存储中间计算结果,避免了重复计算。
斐波那契数列:状态=数列序号n;没有选择;dp长度为n+1的一维数组
2. 背包问题
在背包问题中,我们通过动态规划来找出在有限容量下可以获得的最大价值。
3. 最长公共子序列问题
给定两个字符串,要求找到它们的最长公共子序列。使用动态规划逐步构造解,并通过状态转移方程推导出最优解。
使用动态规划
动规五部曲如下:
-
确定dp数组(dp table)以及下标的含义
dp[i]:包括下标i(以nums[i]为结尾)的最大连续子序列和为dp[i]。
-
确定递推公式
dp[i]只有两个方向可以推出来:
- dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
- nums[i],即:从头开始计算当前连续子序列和
一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);
-
dp数组如何初始化
从递推公式可以看出来dp[i]是依赖于dp[i - 1]的状态,dp[0]就是递推公式的基础。
dp[0]应该是多少呢?
根据dp[i]的定义,很明显dp[0]应为nums[0]即dp[0] = nums[0]。
-
确定遍历顺序
递推公式中dp[i]依赖于dp[i - 1]的状态,需要从前向后遍历。
-
举例推导dp数组
以示例一为例,输入:nums = [-2,1,-3,4,-1,2,1,-5,4],对应的dp状态如下:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return 0;
}
vector<int> dp(n);
dp[0] = nums[0];
int res = dp[0];
for (int i = 1; i < n; i++) {
dp[i] = max(nums[i] + dp[i - 1], nums[i]);
res = max(dp[i], res);
}
return res;
}
};
题目:翻转增益的最大子数组和
问题描述
给定一个数组,目标是找到两个不重叠的子数组,使它们的和最大。你可以选择一个子数组进行翻转,翻转后的数组中,两个子数组的和加起来得到的最大值就是答案。我们需要返回通过翻转操作后,可能获得的最大子数组和。
例如,考虑数组 1, 2, 3, -1, 4,我们可以选择翻转子数组 -1, 4 得到 1, 2, 3, 4, -1,或翻转 1, 2, 3, -1 得到 -1, 3, 2, 1, 4,在这两种情况下,最大的子数组和都是 10。
输入
N:数组的长度。data_array:一个长度为N的整数数组。
输出
- 返回翻转操作后可能获得的最大子数组和。
示例
样例1:
输入:
N = 5, data_array = [1, 2, 3, -1, 4]输出:
10
样例2:
输入:
N = 4, data_array = [-3, -1, -2, 3]输出:
3
样例3:
输入:
N = 3, data_array = [-1, -2, -3]输出:
-1
样例4:
输入:
N = 6, data_array = [-5, -9, 6, 7, -6, 2]输出:
15
样例5:
输入:
N = 7, data_array = [-8, -1, -2, -3, 4, -5, 6]输出:
10
解题思路
暴力解法
暴力解法的思路是:
- 首先计算原数组的最大子数组和,使用 Kadane 算法。
- 然后枚举所有可能的翻转区间
[i, j],对每个子区间进行翻转,并计算翻转后数组的最大子数组和。 - 更新全局最大值,返回结果。
步骤:
- 使用 Kadane 算法计算原数组的最大子数组和。
- 对于每个可能的子数组
[i, j],翻转该子数组并计算最大子数组和。 - 返回最大值。
暴力解法的时间复杂度为 ,其中:
Kadane算法的时间复杂度为 ,- 枚举翻转区间的时间复杂度为 。
#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>
// 求最大子数组和的工具函数
int kadane(const std::vector<int>& arr) {
int max_sum = INT_MIN, current_sum = 0;
for (int num : arr) {
current_sum = std::max(num, current_sum + num);
max_sum = std::max(max_sum, current_sum);
}
return max_sum;
}
// 主函数
int solution(int N, std::vector<int>& data_array) {
// 求原始数组的最大子数组和
int max_sum = kadane(data_array);
// 枚举翻转每一个子数组
for (int i = 0; i < N; ++i) {
for (int j = i; j < N; ++j) {
// 翻转子数组 [i, j]
std::vector<int> flipped_array = data_array;
std::reverse(flipped_array.begin() + i, flipped_array.begin() + j + 1);
// 计算翻转后的最大子数组和
int flipped_sum = kadane(flipped_array);
// 更新全局最大值
max_sum = std::max(max_sum, flipped_sum);
}
}
return max_sum;
}
int main() {
// 测试用例
std::vector<int> data1 = {1, 2, 3, -1, 4};
std::vector<int> data2 = {-3, -1, -2, 3};
std::vector<int> data3 = {-1, -2, -3};
std::vector<int> data4 = {-5, -9, 6, 7, -6, 2};
std::vector<int> data5 = {-8, -1, -2, -3, 4, -5, 6};
std::cout << solution(5, data1) << std::endl; // 输出: 10
std::cout << solution(4, data2) << std::endl; // 输出: 3
std::cout << solution(3, data3) << std::endl; // 输出: -1
std::cout << solution(6, data4) << std::endl; // 输出: 15
std::cout << solution(7, data5) << std::endl; // 输出: 10
return 0;
}
动态规划优化
为了优化暴力解法,我们可以使用动态规划,通过预处理最大子数组和来加速计算。
优化思路:
- 使用 Kadane 算法分别计算从左到右和从右到左的最大子数组和,存储在
leftMax和rightMax数组中。 - 然后遍历所有可能的分割点,利用
leftMax[i-1]和rightMax[i]计算两个区间的和,更新最大值。
这种方法的时间复杂度为 ,因为我们只需要两次遍历数组一次计算 leftMax 和 rightMax,一次遍历计算最终的最大和。
#include <iostream>
#include <vector>
#include <climits>
int Kadane(const std::vector<int>& nums) {
int max_normal_sum = INT_MIN, current_sum = 0;
for (int num : nums) {
current_sum = std::max(num, current_sum + num);
max_normal_sum = std::max(max_normal_sum, current_sum);
}
return max_normal_sum;
}
int maxTwoSubArraySum(const std::vector<int>& nums) {
int n = nums.size();
if (n < 2) return INT_MIN;
// Step 1: Compute leftMax
std::vector<int> leftMax(n, 0);
int currentSum = 0, maxSum = INT_MIN;
for (int i = 0; i < n; ++i) {
currentSum = std::max(nums[i], currentSum + nums[i]);
maxSum = std::max(maxSum, currentSum);
leftMax[i] = maxSum;
}
// Step 2: Compute rightMax
std::vector<int> rightMax(n, 0);
currentSum = 0, maxSum = INT_MIN;
for (int i = n - 1; i >= 0; --i) {
currentSum = std::max(nums[i], currentSum + nums[i]);
maxSum = std::max(maxSum, currentSum);
rightMax[i] = maxSum;
}
// Step 3: Find the maximum sum of two subarrays
int result = INT_MIN;
for (int i = 0; i < n - 1; ++i) {
result = std::max(result, leftMax[i] + rightMax[i + 1]);
}
return result;
}
int solution(int N, const std::vector<int>& data_array) {
if (N == 0) {
return 0;
}
int max_normal_sum = Kadane(data_array);
if (max_normal_sum < 0) {
return max_normal_sum;
}
return max(max_normal_sum, maxTwoSubArraySum(data_array));
}
int main() {
std::cout << (solution(5, {1, 2, 3, -1, 4}) == 10) << std::endl;
std::cout << (solution(4, {-3, -1, -2, 3}) == 3) << std::endl;
std::cout << (solution(3, {-1, -2, -3}) == -1) << std::endl;
std::cout << (solution(6, {-5, -9, 6, 7, -6, 2}) == 15) << std::endl;
std::cout << (solution(7, {-8, -1, -2, -3, 4, -5, 6}) == 10) << std::endl;
return 0;
}
这道题通过翻转子数组并计算最大子数组和,要求我们找到通过翻转所能得到的最大结果。暴力解法采用了枚举所有子数组
总结
动态规划是解决最优化问题、组合计数问题、序列问题等的一种强大工具。通过识别问题是否具有最优子结构和重叠子问题特征,我们可以判断是否适用动态规划。掌握动态规划的解题步骤,理解状态转移方程,并结合具体问题进行实践,能够帮助我们高效地解决复杂的计算问题。