集训DAY7之线性dp与前缀优化/stl优化

集训DAY7之线性DP与前缀优化/STL优化

目录

DP的概念与思想核心
DP的题目类型
线性DP详解
DP的优化策略
后记

DP的概念与思想核心

DP的定义

DP也就是 动态规划(Dynamic Programming) 是求解决策过程最优化的过程
动态规划主要用于求解以时间划分阶段的动态过程的优化问题

DP的基本思想

动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中我们常常需要在多个可行解中寻找最优解,其基本思想就是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解,其实从这些说法你是否看出,它与分治有异曲同工之妙。通过保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。

DP的优势

本身优势

动态规划通过将问题分解为重叠子问题,并存储子问题的解以避免重复计算,显著提高了效率,以至于动态规划将时间复杂度从指数级降为多项式级,这主要是由于动态规划本质是一种空间换时间的策略
只有经过比较才能有优势接下来我们将DP与分治还有暴力进行对比

相较于分治法

分治法虽然也将问题分解为子问题,但子问题通常独立且无重叠,这导致大量重复计算,从而TLE等,分治会在复杂问题达到指数级

相较于暴力法

暴力法就不用多解释了,暴力法枚举所有可能解,适用于问题规模极小的情况。但对于复杂问题(如背包问题或最短路径),其时间复杂度可能达到阶乘或指数级,实际中无法应用。

DP的局限性

动态规划(Dynamic Programming, DP)虽然是一种强大的算法设计方法,但在实际应用中存在一些局限性。以下是与其他算法相比,动态规划的主要缺点:

问题必须具有最优子结构

动态规划要求问题可以分解为相互重叠的子问题,且子问题的最优解能组合成原问题的最优解。如果问题不具备这种性质(例如某些非优化问题或子问题相互独立的情况),动态规划无法直接应用。

状态空间爆炸

动态规划的性能高度依赖状态空间的大小。如果问题涉及多个维度或高复杂度参数,状态数量可能呈指数级增长(例如背包问题中物品数量和容量较大时),导致计算和存储成本过高。

难以设计状态转移方程

某些问题的状态转移关系复杂,难以用明确的数学表达式描述。相比之下,贪心算法或启发式方法可能更直观,尽管不一定得到全局最优解。

存储开销大

动态规划通常需要存储大量中间状态(如二维表格),可能占用过多内存。相比之下,分治法或回溯法可能通过递归或剪枝减少存储需求。

DP的题目类型

额我在想这个栏目的时候思考了一下因为DP的题目类型……实属有点多这边列举点吧
线性DP,背包DP,区间DP,树形DP,状态压缩DP,数位DP,概率/期望DP
由于太多了下面先详解线性DP吧

线性DP详解

线性 DP 是动态规划中最基础、最常见的一类问题,其核心特点是状态的转移具有线性顺序(通常按照数组下标、序列顺序等一维或二维的线性结构进行递推)。它广泛应用于解决与序列、区间、路径等相关的优化问题,如最长递增子序列、编辑距离、背包问题等。

一、线性DP的核心思想
  1. 状态定义:将问题拆解为若干个子问题,用dp[i](一维)或dp[i][j](二维)表示第i个(或第ij个)元素对应的子问题的最优解。
  2. 状态转移方程:根据子问题之间的关系,推导出dp[i]如何由前面的dp[0...i-1](或其他相关状态)计算得到。
  3. 边界条件:初始化最小子问题的解(如dp[0]的取值)。
  4. 线性递推:按照从左到右、从小到大的线性顺序依次计算每个状态,确保计算当前状态时,所需的前置状态已被求解。
二、一维线性DP

一维线性DP的状态仅依赖于一个维度(如序列的位置),典型问题包括最长递增子序列、爬楼梯等。

1. 爬楼梯问题
  • 问题:每次可爬1或2阶台阶,求爬到第n阶的总方法数。
  • 状态定义dp[i]表示爬到第i阶的方法数。
  • 转移方程dp[i] = dp[i-1] + dp[i-2](最后一步爬1阶或2阶)。
  • 边界条件dp[1] = 1dp[2] = 2
2. 最长递增子序列(LIS)
  • 问题:在无序数组中,找到最长的严格递增子序列的长度(子序列可不连续)。
  • 状态定义dp[i]表示以第i个元素结尾的最长递增子序列长度。
  • 转移方程
    对于所有j < inums[j] < nums[i]dp[i] = max(dp[i], dp[j] + 1)
  • 边界条件dp[i] = 1(每个元素自身是长度为1的子序列)。
  • 示例
    数组[10,9,2,5,3,7]dp数组计算过程为:
    dp[0]=1dp[1]=1dp[2]=1dp[3]=2(2→5),dp[4]=2(2→3),dp[5]=3(2→3→7),最终LIS长度为3。
三、二维线性DP

二维线性DP的状态依赖于两个维度(如两个序列的位置、区间的左右端点),典型问题包括最长公共子序列、编辑距离、区间DP等。

1. 最长公共子序列(LCS)
  • 问题:给定两个字符串,求最长公共子序列的长度(子序列可不连续)。
  • 状态定义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] = 0dp[i][0] = 0(空字符串的LCS为0)。
2. 编辑距离(Levenshtein距离)
  • 问题:将字符串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(删除si个字符),dp[0][j] = j(插入j个字符到s)。
3. 区间DP:最长回文子序列
  • 问题:求字符串中最长回文子序列的长度(子序列可不连续)。
  • 状态定义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 > jdp[i][j] = 0
  • 计算顺序:区间长度从小到大(先算长度1,再算长度2,直到整个字符串)。
四、线性DP的优化技巧
  1. 空间优化
    若状态dp[i]仅依赖于dp[i-1]dp[i-2](如爬楼梯),可使用变量替代数组,将空间复杂度从O(n)降至O(1)

  2. 时间优化
    例如LIS问题,朴素解法时间复杂度为O(n2),可通过二分查找优化为O(n log n)(维护一个贪心序列,记录当前长度下的最小尾元素)。

  3. 滚动数组
    二维DP中,若dp[i][j]仅依赖于dp[i-1][j]dp[i][j-1](如LCS),可使用一维数组滚动更新,空间复杂度从O(nm)降至O(min(n,m))

五、线性DP的应用场景总结
问题类型 典型例子 状态维度 核心思路
单序列优化 最长递增子序列、打家劫舍 一维 依赖前序元素的最优解
双序列匹配 LCS、编辑距离 二维 对比两个序列的字符匹配关系
区间优化 最长回文子序列、矩阵链乘 二维 按区间长度递增顺序递推
路径计数/最值 不同路径、最小路径和 二维 累加或取min/max路径上的状态

DP优化策略

  1. 空间优化:滚动数组、变量替代(状态压缩至常数空间)
  2. 时间优化:
    • 单调队列/栈优化(处理区间最值依赖)
    • 前缀和/差分优化(快速计算区间和)
    • 矩阵快速幂优化(线性递推关系)
    • 斜率优化(处理线性方程形式的转移)
    • 决策单调性优化(转移决策具有单调性)
    • 状态压缩(针对集合类问题,用位运算表示状态)
  3. 其他:维度削减(合并或删除冗余状态维度)

这边详解前缀优化与STL优化

详解前缀优化与STL优化

前缀优化与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个元素的最小值
}
二、STL优化:借助高效数据结构简化逻辑

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_boundupper_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)查询 有序数组

后记

漆黑的夜唯有你我漫步于代码天空之下,坚持前行方能抵达银河

你可能感兴趣的:(c++,开发语言,数据结构,算法)