DP也就是 动态规划(Dynamic Programming) 是求解决策过程最优化的过程
动态规划主要用于求解以时间划分阶段的动态过程的优化问题
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中我们常常需要在多个可行解中寻找最优解,其基本思想就是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解,其实从这些说法你是否看出,它与分治有异曲同工之妙。通过保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。
动态规划通过将问题分解为重叠子问题,并存储子问题的解以避免重复计算,显著提高了效率,以至于动态规划将时间复杂度从指数级降为多项式级,这主要是由于动态规划本质是一种空间换时间的策略
只有经过比较才能有优势接下来我们将DP与分治还有暴力进行对比
分治法虽然也将问题分解为子问题,但子问题通常独立且无重叠,这导致大量重复计算,从而TLE等,分治会在复杂问题达到指数级
暴力法就不用多解释了,暴力法枚举所有可能解,适用于问题规模极小的情况。但对于复杂问题(如背包问题或最短路径),其时间复杂度可能达到阶乘或指数级,实际中无法应用。
动态规划(Dynamic Programming, DP)虽然是一种强大的算法设计方法,但在实际应用中存在一些局限性。以下是与其他算法相比,动态规划的主要缺点:
动态规划要求问题可以分解为相互重叠的子问题,且子问题的最优解能组合成原问题的最优解。如果问题不具备这种性质(例如某些非优化问题或子问题相互独立的情况),动态规划无法直接应用。
动态规划的性能高度依赖状态空间的大小。如果问题涉及多个维度或高复杂度参数,状态数量可能呈指数级增长(例如背包问题中物品数量和容量较大时),导致计算和存储成本过高。
某些问题的状态转移关系复杂,难以用明确的数学表达式描述。相比之下,贪心算法或启发式方法可能更直观,尽管不一定得到全局最优解。
动态规划通常需要存储大量中间状态(如二维表格),可能占用过多内存。相比之下,分治法或回溯法可能通过递归或剪枝减少存储需求。
额我在想这个栏目的时候思考了一下因为DP的题目类型……实属有点多这边列举点吧
线性DP,背包DP,区间DP,树形DP,状态压缩DP,数位DP,概率/期望DP
由于太多了下面先详解线性DP吧
线性 DP 是动态规划中最基础、最常见的一类问题,其核心特点是状态的转移具有线性顺序(通常按照数组下标、序列顺序等一维或二维的线性结构进行递推)。它广泛应用于解决与序列、区间、路径等相关的优化问题,如最长递增子序列、编辑距离、背包问题等。
dp[i]
(一维)或dp[i][j]
(二维)表示第i
个(或第i
到j
个)元素对应的子问题的最优解。dp[i]
如何由前面的dp[0...i-1]
(或其他相关状态)计算得到。dp[0]
的取值)。一维线性DP的状态仅依赖于一个维度(如序列的位置),典型问题包括最长递增子序列、爬楼梯等。
n
阶的总方法数。dp[i]
表示爬到第i
阶的方法数。dp[i] = dp[i-1] + dp[i-2]
(最后一步爬1阶或2阶)。dp[1] = 1
,dp[2] = 2
。dp[i]
表示以第i
个元素结尾的最长递增子序列长度。j < i
且nums[j] < nums[i]
,dp[i] = max(dp[i], dp[j] + 1)
。dp[i] = 1
(每个元素自身是长度为1的子序列)。[10,9,2,5,3,7]
,dp
数组计算过程为:dp[0]=1
,dp[1]=1
,dp[2]=1
,dp[3]=2
(2→5),dp[4]=2
(2→3),dp[5]=3
(2→3→7),最终LIS长度为3。二维线性DP的状态依赖于两个维度(如两个序列的位置、区间的左右端点),典型问题包括最长公共子序列、编辑距离、区间DP等。
dp[i][j]
表示字符串s1[0..i-1]
与s2[0..j-1]
的LCS长度。s1[i-1] == s2[j-1]
,则dp[i][j] = dp[i-1][j-1] + 1
(当前字符加入LCS)。dp[i][j] = max(dp[i-1][j], dp[i][j-1])
(取删除一个字符后的最优解)。dp[0][j] = 0
,dp[i][0] = 0
(空字符串的LCS为0)。s
转换为t
的最少操作数(操作包括插入、删除、替换一个字符)。dp[i][j]
表示s[0..i-1]
转换为t[0..j-1]
的最少操作数。s[i-1] == t[j-1]
,则dp[i][j] = dp[i-1][j-1]
(无需操作)。dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
(分别对应删除、插入、替换)。dp[i][0] = i
(删除s
的i
个字符),dp[0][j] = j
(插入j
个字符到s
)。dp[i][j]
表示区间s[i..j]
中最长回文子序列的长度。s[i] == s[j]
,则dp[i][j] = dp[i+1][j-1] + 2
(两端字符加入回文)。dp[i][j] = max(dp[i+1][j], dp[i][j-1])
(取去掉一端后的最优解)。dp[i][i] = 1
(单个字符是回文),i > j
时dp[i][j] = 0
。空间优化:
若状态dp[i]
仅依赖于dp[i-1]
和dp[i-2]
(如爬楼梯),可使用变量替代数组,将空间复杂度从O(n)
降至O(1)
。
时间优化:
例如LIS问题,朴素解法时间复杂度为O(n2)
,可通过二分查找优化为O(n log n)
(维护一个贪心序列,记录当前长度下的最小尾元素)。
滚动数组:
二维DP中,若dp[i][j]
仅依赖于dp[i-1][j]
和dp[i][j-1]
(如LCS),可使用一维数组滚动更新,空间复杂度从O(nm)
降至O(min(n,m))
。
问题类型 | 典型例子 | 状态维度 | 核心思路 |
---|---|---|---|
单序列优化 | 最长递增子序列、打家劫舍 | 一维 | 依赖前序元素的最优解 |
双序列匹配 | LCS、编辑距离 | 二维 | 对比两个序列的字符匹配关系 |
区间优化 | 最长回文子序列、矩阵链乘 | 二维 | 按区间长度递增顺序递推 |
路径计数/最值 | 不同路径、最小路径和 | 二维 | 累加或取min/max路径上的状态 |
这边详解前缀优化与STL优化
在动态规划及算法问题中,前缀优化和STL优化是提升效率的重要手段。它们并非孤立存在,而是常与具体场景结合,通过简化计算或利用高效数据结构,让复杂问题的解决变得更轻松。
前缀优化的核心是“预计算”,通过提前处理数组的前缀信息,将原本需要遍历区间的操作转化为直接查表,尤其适合涉及区间和、区间最值的场景。
1. 前缀和:快速计算区间总和
当需要频繁查询数组中某段连续元素的和时,前缀和是最直接的优化方式。比如在“子数组最大和”“分割数组为k个连续子数组”等问题中,直接遍历计算每个区间的和会导致O(n2)的时间复杂度,而用前缀和预处理后,每次查询只需O(1)。
例如,对于数组[1,3,5,7,9]
,其前缀和数组为[0,1,4,9,16,25]
,查询区间[1,3]
(即元素3、5、7)的和时,只需计算prefix[4] - prefix[1] = 16 - 1 = 15
,无需再逐个累加。
关键代码示例:
vector<int> prefix(n + 1, 0);
for (int i = 1; i <= n; ++i) {
prefix[i] = prefix[i - 1] + arr[i - 1]; // 累加前i个元素
}
int sum = prefix[r + 1] - prefix[l]; // 区间[l, r]的和(0-based)
2. 前缀最值:记录区间内的最大/最小值
在一些需要对比“截至当前位置的最大/最小值”的问题中,前缀最值数组能派上用场。比如“股票买卖”问题中,若想知道某天之前的最低股价,无需每次回溯查找,直接通过前缀最小值数组即可获取。
例如,数组[5,3,6,2,7]
的前缀最小值数组为[5,3,3,2,2]
,查询第3天(0-based索引2)之前的最低股价,直接取prefix_min[2] = 3
即可。
关键代码示例:
vector<int> prefix_min(n);
prefix_min[0] = arr[0];
for (int i = 1; i < n; ++i) {
prefix_min[i] = min(prefix_min[i - 1], arr[i]); // 记录前i个元素的最小值
}
C++的STL(标准模板库)封装了多种高效数据结构,能帮我们避开复杂的底层实现,直接利用其特性优化代码效率,尤其适合动态维护集合、快速查询或排序的场景。
1. map/unordered_map:快速映射与查找
当需要存储“键-值”对并快速查询时,这两种容器是首选。map
基于红黑树实现,内部元素有序,查询复杂度O(logn);unordered_map
基于哈希表,查询平均复杂度O(1),适合对速度要求更高的场景。
比如在“两数之和”问题中,用unordered_map
存储已遍历的元素及其索引,遍历到目标元素时,可直接查询是否存在互补的元素,无需嵌套循环。
关键代码示例:
unordered_map<int, int> num_map; // 存储元素值与索引的映射
for (int i = 0; i < nums.size(); ++i) {
int target = 9 - nums[i];
if (num_map.count(target)) { // 查找是否存在互补元素
return {num_map[target], i};
}
num_map[nums[i]] = i; // 存入当前元素
}
2. set/multiset:动态维护有序集合
这两种容器能自动对元素排序,且支持动态插入、删除和查找。set
存储唯一元素,multiset
允许重复元素,适合需要“实时维护有序序列”的场景。
例如,在“数据流中的中位数”问题中,用两个multiset
分别存储较小的一半和较大的一半元素,通过调整元素分布,可快速获取中位数。
关键代码示例:
multiset<int> small, large; // small存储较小一半(最大堆逻辑),large存储较大一半(最小堆逻辑)
// 插入元素时维持平衡
if (small.empty() || num <= *small.rbegin()) {
small.insert(num);
} else {
large.insert(num);
}
// 调整大小,确保small比large多1个或相等
if (small.size() > large.size() + 1) {
int val = *small.rbegin();
small.erase(small.find(val));
large.insert(val);
}
3. 二分查找函数:有序数组中的精准定位
STL的lower_bound
和upper_bound
函数基于二分查找实现,能在有序数组中快速定位元素位置,复杂度O(logn)。在“最长递增子序列”等问题中,用它们替代遍历查找,可将时间复杂度从O(n2)降至O(n logn)。
例如,在有序数组中查找第一个大于等于x的元素:
vector<int> arr = {2, 4, 6, 8};
int x = 5;
auto it = lower_bound(arr.begin(), arr.end(), x); // 指向6(第一个>=5的元素)
int pos = it - arr.begin(); // 位置为2
前缀优化通过“空间换时间”,用预处理数组简化区间查询;STL优化则借助封装好的数据结构,减少手动实现复杂逻辑的成本。实际解题中,两者常结合使用——比如先用前缀和处理区间和,再用map
存储中间结果,从而高效解决问题。掌握这些技巧,能让代码更简洁、运行更快,尤其在处理大规模数据时效果显著。
典型应用场景对比:
优化策略 | 适用问题 | 时间复杂度优化 | 核心数据结构 |
---|---|---|---|
前缀和 | 区间和查询 | O(n)预处理 + O(1)查询 | 一维/二维数组 |
前缀最值 | 区间最值查询 | O(n)预处理 + O(1)查询 | 一维数组 |
map/unordered_map | 快速查找历史状态 | O(logn)/O(1)查询 | 哈希表/红黑树 |
set/multiset | 动态维护有序集合 | O(logn)插入/删除/查询 | 红黑树 |
priority_queue | 贪心策略优化 | O(logn)插入/删除 | 堆 |
二分查找 | 有序数组快速定位 | O(logn)查询 | 有序数组 |
漆黑的夜唯有你我漫步于代码天空之下,坚持前行方能抵达银河