动态规划:单调减子序列

本文介绍了一种动态规划算法,用于解决给定整数序列中找到最长单调递减子序列的长度及其数量。算法从序列末尾开始处理,利用multimap存储以每个数为起始的子序列长度和下标,通过比较更新最长子序列信息。文中通过实例详细解释了算法思路,并讨论了题目要求变化时的算法调整。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一个算法,题目如下:

        从一个由N个整数排列组成的整数序列中,自左向右不连续的选出一组整数,可以组成一个单调减小的子序列(如从{68 69 54 64 68 64 70 67 78 62 98 87}中我们可以选取出{69 68 64 62}这个子序列;当然,这里还有很多其他符合条件的子序列)。给定整数序列的长度和整数序列中依次的值,请你求出这个整数序列中“最长的单调减小的子序列的长度”以及“不同但长度都是最长得单调减小的子序列的数量”。

       输入第1行为一个整数N,表示输入的整数序列的长度(1≤N≤50000)。输入第2行包括由空格分隔的N个整数(每个整数都在32位长整型范围内)。
输出包括一行,为两个数字,分别为针对给定的整数序列求出的“最长的单调减小的子序列的长度”以及“值不同但长度都是最长得单调减小的子序列的数量”
样例输入
12
68 69 54 64 68 64 70 67 78 62 98 87
样例输出
4 2

分析:

        求序列中的最长单调递增或递减序列长度的动态规划算法网上到处都是,基本思想就是把之前不同长度的最长子序列的最大/小最后一个数给记下来,然后依次的更新后面的数,更新时采用二分查找可以提高效率,缩小复杂度。可以自己先想一下怎么实现,不要直接去看代码,会被搞得云里雾绕的,一些文章写得不是很清晰,代码质量也一般,所以我一般都是先想一下自己会怎么实现,然后再看别人的讲解,代码其次,因为有时候看别人代码,“就跟被强x了似的”(别人说的)。

       但本题目同时要求出最长子序列的数量,这就不一样了,比如这个序列,4 5 2 1,最长递减子序列长度是3,但4 2 1和5 2 1都是最长递减子序列,个数为2.再比如2 1 2 1最长递减子序列长度是2,虽有两个2 1子序列,但只能算1个,因为子序列相同。

       因为一开始没有理解题意,认为2 1 2 1这种情况只要选取子序列时的位置不同就算一个子序列,那么此时就会算有3个最长子序列,虽然内容相同,都是2 1,但选取的元素的位置不同,耗费了大量时间,把代码搞得很复杂,当然出题人也可以这么设计题目。本题中题目意思经测试不是这个意思,2 1 2 1只能算有1个长度为2的内容为2 1的最长子序列。

       现在我们知道了题意,就可以找几个例子来设计一下算法。因为求递减子序列,我的想法是从最后一位进行处理,每次处理时找出以本数为起始时的最长子序列长度及个数,然后不断的往序列前面处理,直到序列处理完毕,然后统计出最长的子序列个数。关键算法在于找出以本数为起始时的最长子序列长度及内容不同最长子序列个数,根据动态规划的思想,将以每个数为起始的长度、下标记录,然后处理序列前面的数时,拿这个数与之前记录中的每个下标位置的数进行比较,求出若能构成最长的子序列,若构成最长子序列的个数不是1个,那么有几个就记录几个长度和下标,此处我用了multimap<int,int>来进行存储长度和下标键值对,因为multimap允许重复键存在。

       找个特殊一点的例子进行举例分析,比如4 3 2 5 3 2这个序列,从后往前处理,最后一位为2,最长子序列长度位1,存储长度1、数组下标5;那么插入<1,5>;然后是3,根据存储内容,插入一个<2,4>,此时multimap为:<1,5> <2,4>;然后是5,插入<3,3>,此时multimap为:<1,5> <2,4> <3,3>;接下来处理2,因为之前有了2这个长度为1的子序列,且与之前的2之间不存在比2小的数,所以没有产生更长的子序列,所以这里不必记录,multimap不变;然后是3,同样可以查出之前有3存在,且与之前的2之间multimap中没有长度为1的子序列小的数,所以也不记录,multimap不变;然后是4,可

<think>我们被要求回答关于最长不下降子序列(Longest Non-decreasing Subsequence, LNDS)的动态规划(DP)和记忆化搜索(DFS)实现方法。根据引用内容,我们可以总结出以下信息: 1. 动态规划是解决最长不下降子序列问题的经典方法,通常有两种实现方式:自底向上的递推(DP)和自顶向下的记忆化搜索(DFS+Memoization)。 2. 引用[3]提到了最长不下降子序列的优化解法($O(N \log N)$)以及如何输出序列,但用户问题主要关注动态规划和记忆化搜索,因此我们重点放在这两种方法上,优化方法可以简要提及。 3. 引用[4]和[5]分别给出了最长上升子序列(LIS)和最长下降子序列(LDS)的动态规划代码,我们可以参考其思路,但注意问题是最长不下降(即非严格递增,允许相等)。 ### 问题分析 最长不下降子序列问题:给定一个序列,找到最长的子序列,使得这个子序列非严格递增(即对于子序列中的任意两个相邻元素,有$a_i \leq a_j$,其中$i<j$)。 ### 方法一:动态规划(递推) 设$dp[i]$表示以第$i$个元素结尾的最长不下降子序列的长度。 状态转移方程: $$ dp[i] = \max_{\substack{1 \leq j < i \\ a[j] \leq a[i]}} \{ dp[j] \} + 1 $$ 如果不存在这样的$j$,则$dp[i]=1$(即自身作为一个子序列)。 最后,整个序列的最长不下降子序列长度为$\max_{1\leq i \leq n} dp[i]$。 时间复杂度:$O(n^2)$,因为对于每个$i$,需要遍历$i$之前的所有元素。 ### 方法二:记忆化搜索(DFS+Memoization) 记忆化搜索是动态规划的一种自顶向下的实现方式。我们使用递归函数`dfs(i)`来计算以第$i$个元素结尾的最长不下降子序列的长度。为了避免重复计算,使用一个数组`memo`来保存已经计算过的结果。 递归函数定义: - `dfs(i)`:返回以第`i`个元素结尾的最长不下降子序列的长度。 递归过程: - 对于每个`i`,遍历`j`从`0`到`i-1`,如果`a[j] <= a[i]`,则`dfs(i) = max(dfs(i), dfs(j)+1)`。 - 如果没有任何`j`满足条件,则`dfs(i)=1`。 同时,使用`memo`数组记录已经计算过的`dfs(i)`,避免重复递归。 ### 代码实现 下面分别给出动态规划(递推)和记忆化搜索的代码实现(使用C++,因为引用中的代码是C++)。 #### 动态规划(递推)代码 ```cpp #include <iostream> #include <algorithm> using namespace std; int main() { int n; cin >> n; int a[n], dp[n]; for (int i = 0; i < n; i++) { cin >> a[i]; } int maxLen = 0; for (int i = 0; i < n; i++) { dp[i] = 1; // 初始化,至少包含自己 for (int j = 0; j < i; j++) { if (a[j] <= a[i]) { // 不下降:允许相等 dp[i] = max(dp[i], dp[j] + 1); } } maxLen = max(maxLen, dp[i]); } cout << maxLen << endl; return 0; } ``` #### 记忆化搜索(DFS+Memoization)代码 ```cpp #include <iostream> #include <algorithm> #include <cstring> using namespace std; const int MAX_N = 1000; int a[MAX_N]; int memo[MAX_N]; // memo[i]存储以第i个元素结尾的最长不下降子序列长度,初始为-1表示未计算 int dfs(int i) { if (memo[i] != -1) return memo[i]; int res = 1; // 至少包含自身 for (int j = 0; j < i; j++) { if (a[j] <= a[i]) { res = max(res, dfs(j) + 1); } } memo[i] = res; return res; } int main() { int n; cin >> n; for (int i = 0; i < n; i++) { cin >> a[i]; } memset(memo, -1, sizeof(memo)); int maxLen = 0; for (int i = 0; i < n; i++) { maxLen = max(maxLen, dfs(i)); } cout << maxLen << endl; return 0; } ``` ### 优化方法($O(N \log N)$) 引用[3]提到了利用二分查找和调性优化到$O(N \log N)$的方法。我们维护一个数组`d`,其中`d[i]`表示长度为`i`的最长不下降子序列的最后一个元素的最小可能值(注意,这里是最小可能值,这样后续可以扩展更多元素)。由于`d`数组是调不减的,我们可以使用二分查找来更新。 具体步骤: 1. 初始化一个数组`d`,长度为0(表示没有元素)。 2. 遍历原数组每个元素`a[i]`: - 在`d`数组中查找第一个大于`a[i]`的位置(如果是严格上升则用`lower_bound`,但这里不下降可以用`upper_bound`来允许相等?注意:不下降序列允许相等,所以应该用`upper_bound`?实际上,我们通常用`lower_bound`来查找第一个大于等于`a[i]`的位置,但为了维护不下降,我们允许相等,所以这里使用`upper_bound`来查找第一个大于`a[i]`的位置,然后替换该位置上的值。但具体实现时,对于不下降序列,我们通常使用非递减的`d`数组,并利用`lower_bound`找到第一个大于等于`a[i]`的位置,然后替换。但注意,这样得到的是最长不下降子序列,但长度正确,序列不一定实际存在,但长度正确。 - 更准确地说,我们维护的`d`数组是调不减的。对于当前元素`a[i]`,我们使用`upper_bound`查找第一个大于`a[i]`的位置(因为允许相等,所以相等时我们可以放在相同长度的位置,但为了尽可能延长序列,我们希望`d`数组中的值尽可能小,所以当遇到相等的值时,我们也可以替换,因为不会影响后面的扩展)。实际上,标准做法是: 如果`a[i]`大于等于`d`的最后一个元素,则加入`d`(长度+1); 否则,在`d`中找到第一个大于`a[i]`的位置(用`upper_bound`),然后替换为`a[i]`。 这样,数组`d`的长度就是最长不下降子序列的长度。 代码示例: ```cpp #include <iostream> #include <algorithm> #include <vector> using namespace std; int main() { int n; cin >> n; vector<int> a(n); for (int i = 0; i < n; i++) { cin >> a[i]; } vector<int> d; // d 将存储最长不下降子序列的最后一个元素的最小可能值 for (int i = 0; i < n; i++) { // 找到第一个大于 a[i] 的位置 auto it = upper_bound(d.begin(), d.end(), a[i]); if (it == d.end()) { // 如果没找到大于a[i]的元素,说明a[i]可以接在d后面(因为d中所有元素都<=a[i]) d.push_back(a[i]); } else { // 替换第一个大于a[i]的位置上的值为a[i] *it = a[i]; } } cout << d.size() << endl; return 0; } ``` 注意:这里使用`upper_bound`(找第一个大于`a[i]`的位置)是因为我们允许相等,所以当有相等元素时,我们希望替换掉大于`a[i]`的第一个元素,这样可以保证`d`数组仍然是非递减的。如果使用`lower_bound`(找第一个大于等于`a[i]`的位置),那么当遇到相等元素时,我们就会替换掉相等的元素,这样也是可以的,但是替换相等元素没有意义(因为相等元素不影响长度),所以两种方法都可以,但通常使用`upper_bound`。 但是,对于最长不下降子序列,更常见的做法是使用`lower_bound`来维护一个非递减的序列,但注意替换条件。实际上,我们也可以这样: ```cpp auto it = lower_bound(d.begin(), d.end(), a[i]+1); // 第一个大于a[i]的位置 ``` 或者直接使用`upper_bound(d.begin(), d.end(), a[i])`,因为`upper_bound`就是找大于`a[i]`的第一个位置。 ### 输出序列 引用[3]提到了如何输出序列。在$O(N^2)$的DP方法中,我们可以记录前驱节点。具体做法: - 在计算`dp[i]`时,记录使`dp[i]`取得最大值的`j`(即前驱节点)。 - 最后,从整个序列中`dp[i]`最大的位置开始,回溯前驱节点,即可得到序列。 在$O(N \log N)$方法中,输出序列较为复杂,但引用[3]给出了一种方法:在更新`d`数组的同时,记录每个位置对应的原数组中的元素,并记录每个元素的前驱。但通常,我们只要求长度,所以输出序列不是必须的。 ### 总结 - 动态规划(递推)和记忆化搜索都可以解决最长不下降子序列问题,时间复杂度为$O(n^2)$。 - 优化方法使用二分查找和贪心思想,将时间复杂度降为$O(n \log n)$,但只用于求长度,序列输出较复杂。 - 记忆化搜索本质上是动态规划的自顶向下实现,避免了手动设计递推顺序,但会有递归开销。 ### 相关问题 根据用户的问题,我们生成了以下相关问题: 1. 最长不下降子序列问题中,动态规划方法和记忆化搜索方法在时间复杂度和空间复杂度上有何异同? 2. 如何输出最长不下降子序列的具体元素(而不仅仅是长度)? 3. 最长不下降子序列的$O(n \log n)$算法是如何利用二分查找和贪心思想的? 4. 动态规划求解最长不下降子序列时,如何优化空间复杂度? 5. 在记忆化搜索中,如何避免递归深度过大导致的栈溢出问题?</think>### 最长不下降子序列动态规划与记忆化搜索实现 #### 一、问题定义 给定序列 $\{a_1, a_2, \dots, a_n\}$,求最长子序列长度,满足 $\forall i<j,\ a_i \leq a_j$(非严格递增)。 #### 二、动态规划(递推)实现 **状态定义**: $dp[i]$ 表示以 $a_i$ 结尾的最长不下降子序列长度。 **状态转移方程**: $$ dp[i] = \max_{\substack{1 \leq j < i \\ a_j \leq a_i}} \{ dp[j] \} + 1 $$ 若不存在满足条件的 $j$,则 $dp[i] = 1$。 **最终结果**: $$ \max_{1 \leq i \leq n} dp[i] $$ **代码实现**: ```cpp #include <iostream> #include <algorithm> using namespace std; int main() { int n; cin >> n; int a[n], dp[n]; for (int i = 0; i < n; i++) cin >> a[i]; int maxLen = 0; for (int i = 0; i < n; i++) { dp[i] = 1; // 初始化(至少包含自身) for (int j = 0; j < i; j++) { if (a[j] <= a[i]) { // 允许相等 dp[i] = max(dp[i], dp[j] + 1); } } maxLen = max(maxLen, dp[i]); } cout << maxLen << endl; return 0; } ``` **时间复杂度**:$O(n^2)$,**空间复杂度**:$O(n)$[^1][^5]。 --- #### 三、记忆化搜索(DFS + Memoization)实现 **核心思想**: - 自顶向下递归,通过`memo`数组避免重复计算。 - `dfs(i)` 返回以 $a_i$ 结尾的最长长度。 **递归方程**: $$ dfs(i) = \max_{\substack{0 \leq j < i \\ a_j \leq a_i}} \{ dfs(j) + 1 \} $$ **代码实现**: ```cpp #include <iostream> #include <cstring> #include <algorithm> using namespace std; const int MAX_N = 1010; int a[MAX_N], memo[MAX_N]; // memo[i] 缓存dfs(i)的结果 int dfs(int i) { if (memo[i] != -1) return memo[i]; // 已计算则直接返回 int res = 1; // 最小长度为1(自身) for (int j = 0; j < i; j++) { if (a[j] <= a[i]) { res = max(res, dfs(j) + 1); // 递归子问题 } } memo[i] = res; // 记录结果 return res; } int main() { int n; cin >> n; for (int i = 0; i < n; i++) cin >> a[i]; memset(memo, -1, sizeof(memo)); // 初始化为未计算 int maxLen = 0; for (int i = 0; i < n; i++) maxLen = max(maxLen, dfs(i)); // 对每个位置调用dfs cout << maxLen << endl; return 0; } ``` **时间复杂度**:$O(n^2)$(每个状态计算一次),**空间复杂度**:$O(n)$[^1][^2][^4]。 --- #### 四、两种方法对比 | **特性** | 动态规划(递推) | 记忆化搜索 | |------------------|------------------------|----------------------| | **实现方向** | 自底向上(迭代) | 自顶向下(递归) | | **计算顺序** | 显式顺序(从前往后) | 隐式顺序(递归栈) | | **代码复杂度** | 较低 | 较高(需处理递归) | | **适用场景** | 依赖关系简 | 依赖关系复杂 | | **空间优化** | 可滚动数组优化 | 难优化 | > **关键区别**:动态规划显式定义计算顺序,记忆化搜索通过递归隐式求解,本质都是对状态空间的遍历[^1][^2]。 --- #### 五、优化方法($O(n \log n)$) 通过维护调数组 + 二分查找: ```cpp #include <iostream> #include <vector> #include <algorithm> using namespace std; int main() { int n; cin >> n; vector<int> a(n), d; // d: 存储长度为i的子序列的最小末尾值 for (int i = 0; i < n; i++) cin >> a[i]; for (int i = 0; i < n; i++) { // 找到d中第一个 > a[i] 的位置 auto it = upper_bound(d.begin(), d.end(), a[i]); if (it == d.end()) d.push_back(a[i]); // 可扩展序列长度 else *it = a[i]; // 优化d数组的值 } cout << d.size() << endl; // 输出最长长度 return 0; } ``` **原理**:`d` 数组始终保持有序,通过二分查找快速定位插入位置[^3]。 --- ### 相关问题 1. **动态规划与分治法的本质区别是什么?** > 动态规划要求子问题重叠且有最优子结构,通过记忆化避免重复计算;分治法子问题独立无重叠[^2]。 2. **如何输出最长不下降子序列的具体元素(而不仅是长度)?** > 需在DP过程中记录前驱节点,最终回溯输出完整序列[^3]。 3. **$O(n \log n)$ 算法为何能正确求解?其数学依据是什么?** > 依赖于贪心思想:维护的 `d` 数组具有调性,替换操作保证后续可扩展性[^3]。 4. **记忆化搜索在哪些场景下优于递推动态规划?** > 当状态转移依赖复杂拓扑序(如树形DP)时,记忆化更易实现[^1][^4]。 5. **如何将最长不下降子序列问题扩展为三维(如时间序列分析)?** > 需增加状态维度(如 $dp[i][j]$),但时间复杂度升至 $O(n^2)$ 或更高。 [^1]: 动态规划入门思路: dfs暴力 --> 记忆化搜索 --> 递推DP。 [^2]: 动态规划通过记忆化存储子问题解,减少重复计算。 [^3]: 利用调性二分查找可将复杂度优化至 $O(n \log n)$。 [^4]: 记忆化搜索本质是递归形式的动态规划。 [^5]: 动态规划通过两层循环实现状态转移。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值