1. 项目概述:从一道国赛真题看“本质上升子序列”
如果你正在备战蓝桥杯国赛,或者对动态规划(DP)中的经典问题“最长上升子序列(LIS)”已经滚瓜烂熟,那么“本质上升子序列”这道题,很可能就是你通往更高分路上的一个关键路标。我第一次在模拟卷上遇到它时,也愣了一下——不就是LIS吗?但仔细一看题目描述和样例,才发现里面藏着不少“坑”和“本质”的区别。这道题经常出现在国赛级别的模拟测试或真题中,考察的不仅仅是你会不会套LIS的模板,更是你对问题本质的理解、对状态定义的精准把握,以及处理去重等边界条件的代码实现能力。简单来说,它要求你计算一个序列中,所有值不同的上升子序列的数量。注意,是“本质不同”,即子序列作为一个序列,其元素构成不同才算不同的序列,而不是位置不同。这直接拔高了题目的难度和思考深度。今天,我就结合多次模拟测试和实战的经验,把这题从里到外拆解清楚,让你不仅会做,更能理解其背后的动态规划思想与优化技巧,从容应对国赛。
2. 问题核心:定义、区别与难点剖析
2.1 什么是“本质上升子序列”?
我们先明确概念。给定一个长度为n的整数序列a[1...n]。
- 普通上升子序列:一个序列
b,如果它是a的子序列(即从a中删除一些元素后得到),并且b中的元素严格单调递增,那么b就是a的一个上升子序列。 - 本质上升子序列:在普通上升子序列的基础上,附加了一个条件:只计数“本质不同”的子序列。也就是说,如果两个上升子序列
b1和b2虽然来自a的不同位置,但只要它们包含的元素值完全相同(顺序也相同),它们就被视为同一个子序列,只计算一次。
举个例子,序列a = [1, 2, 2, 3]。
- 它的普通上升子序列有很多,比如
[1],[2](来自第一个2),[2](来自第二个2),[1, 2](取第一个1和第一个2),[1, 2](取第一个1和第二个2),[2, 3],[1, 2, 3]等等。 - 但它的本质上升子序列是哪些呢?
[1],[2],[1, 2],[3],[1, 3],[2, 3],[1, 2, 3]。注意,这里[2]只算一个,尽管在a中出现了两次;同样,[1, 2]也只算一个。所以,本质不同的上升子序列总数是7。
2.2 与经典LIS问题的根本区别
很多同学一看到“上升子序列”,第一反应就是动态规划求长度,状态定义为dp[i]表示以a[i]结尾的最长上升子序列长度。但“本质上升子序列”要求的是数量,而且是去重后的数量。这带来了几个根本性的区别:
- 目标不同:经典LIS求的是最大长度(一个数值),本题求的是所有不同序列的总数(一个数值)。
- 状态定义迁移:求数量时,
dp[i]通常定义为以a[i]结尾的、满足条件的子序列的个数。但直接套用会遇到严重的重复计数问题。 - 去重是核心难点:如何避免对值相同的子序列进行重复计数,是本题最大的挑战。例如上例中,以第一个
2和第二个2结尾的、内容为[2]的子序列,我们只能算一次。以它们结尾的、内容为[1, 2]的子序列,也只能算一次。
2.3 常见错误思路与难点解析
在模拟测试中,我见过也自己犯过以下几种典型错误:
- 错误思路1:暴力枚举+集合去重。生成所有可能的子序列,判断是否上升,然后放入一个集合(Set)中去重。理论上可行,但时间复杂度是 O(2^n),
n稍微大一点(比如50)就完全不可行。国赛数据规模通常n在 1000 以上,甚至 10^5,这条路走不通。 - 错误思路2:简单修改LIS的DP计数。定义
dp[i]为以a[i]结尾的本质上升子序列个数。转移时,dp[i] = 1 + sum(dp[j]),其中j < i且a[j] < a[i]。这个思路的问题在于,它会把所有以a[j]结尾的子序列后面接上a[i],但对于值相同的a[i](比如多个相同的2),从不同j转移过来可能会产生内容完全相同但结尾位置不同的子序列,导致重复计数。例如,对于a = [1, 2, 2],按此计算:dp[1]=1 ([1]),dp[2]=1+dp[1]=2 ([2], [1,2]),dp[3]=1+dp[1]=2 ([2], [1,2]),总和为5。但实际上本质不同的只有[1],[2],[1,2]这3个。这里dp[2]和dp[3]都计算了[2]和[1,2],导致重复。 - 难点:如何设计状态和转移方程,使得对于每个不同的子序列值组合,只在它“第一次”可能被生成的位置被计数一次?这就需要我们更精细地考虑“以某个值结尾”,而不是“以某个位置结尾”。
注意:这里的“第一次”是一个关键思想。我们需要保证,对于任何一个本质上升子序列,当它的最后一个元素(最大值)在序列中首次出现时,我们就完成对它的计数,后续再出现相同的值,就不再重复计数。
3. 核心解决方案:基于“结尾值”的动态规划
经过对问题的拆解,我们意识到需要摆脱“以位置i结尾”的思维定式,转而采用“以数值v结尾”的状态定义。这是解决去重问题的关键一跳。
3.1 状态定义与转移方程
我们假设序列a中元素的取值范围是有限的,或者我们可以对其进行离散化(这是处理大数据范围的常用技巧)。设所有可能出现的数值经过排序和映射后,得到一个从1到M的整数范围。
我们定义dp[v]:表示以数值v结尾的、本质不同的上升子序列的个数。
现在考虑如何转移。对于一个本质上升子序列,它的最后一个元素是v。那么它可能由两种方式构成:
- 子序列只包含
v本身,即[v]。 - 子序列由某个以小于
v的数值u结尾的子序列,后面加上v构成。
因此,转移方程可以初步写为:dp[v] = 1 + sum(dp[u]),其中u是所有小于v的、在序列中出现过的数值。
但是,这里还有一个关键点:我们是在遍历原序列a的过程中来计算dp的。当我们处理到a[i] = v时,我们需要用当前时刻所有小于v的dp[u]之和来更新dp[v]。并且,如果序列中后面又出现了另一个v,我们该如何处理?根据“本质不同”的原则,后面出现的相同值v不应该产生新的、以v结尾的本质子序列,因为所有可能的以v结尾的子序列,在第一次遇到v时就已经被枚举了。
所以,我们得到最终的处理逻辑:
- 初始化一个数组
dp,长度为M+1(数值范围),所有元素为0。同时,维护一个前缀和数组prefix_sum,用于快速计算所有小于v的dp值之和。 - 遍历原序列
a的每一个元素x。 - 对于当前的
x,我们计算sum = 1 + prefix_sum[x-1]。这里的1代表子序列[x],prefix_sum[x-1]代表所有以小于x的值结尾的子序列后面接上x所能形成的新子序列数量。 - 然后,我们检查
dp[x]的当前值。- 如果
dp[x]是0,说明这是第一次遇到数值x。那么,dp[x]的新值就是sum。接着,我们需要更新前缀和数组prefix_sum,从索引x开始到M,每个位置都加上sum(因为新增加了sum个以x结尾的子序列,所有大于x的数值在后续计算时,都应该能“看到”这些新序列作为转移来源)。 - 如果
dp[x]不是 0,说明之前已经遇到过x。根据本质不同的原则,此时新增的子序列都是重复的。但是,注意我们的sum计算是基于当前的前缀和,它包含了之前所有小于x的dp值。这次计算出的sum与第一次计算出的dp[x]的差值,就代表了由于在第一次遇到x之后,序列中又出现了新的、小于x的数值所构成的子序列,这些子序列后面接上当前这个x,会形成新的、以前未计数过的、以x结尾的本质子序列吗?答案是:不会。因为子序列的结尾值已经是x,而序列内容是本质不同的关键。在第一次遇到x时,所有可能的小于x的结尾值都已经在prefix_sum中被考虑了(尽管它们对应的子序列数量后续可能会增长)。后续再遇到x,用增长后的prefix_sum计算,得到的sum增量,实际上对应的是“在第一次遇到x之后才出现的小于x的元素”与“当前这个x”组成的序列。但这些序列,是否与之前已有的以x结尾的序列重复?仔细思考:假设之前有一个以u结尾的子序列S,在第一次遇到x时,S+[x]已经被计入dp[x]。现在,在第一次遇到x之后,序列中又出现了一个新的数值w(w < x),并且形成了新的以w结尾的子序列T。那么T+[x]这个序列,它的结尾是x,内容与S+[x]不同(因为T不同于S)。它应该是一个新的本质子序列!所以,当再次遇到x时,我们需要增加的是这部分“新出现的小于x的结尾值所构成的新子序列”后面接上x所产生的数量,即sum - dp[x]。然后更新dp[x] += (sum - dp[x]),并同样更新前缀和。 - 简化实现:实际上,我们可以统一处理。无论
dp[x]是否为0,我们都计算sum = 1 + prefix_sum[x-1]。那么本次对总答案的新增贡献就是sum - dp[x]。然后我们执行:dp[x] = sum,并更新前缀和(将sum - old_dp[x]这个增量,加到从x开始的前缀和上)。当dp[x]初始为0时,sum - 0 = sum,逻辑与第一种情况一致。
- 如果
3.2 算法流程与示例演算
让我们用a = [1, 2, 2, 3]来手动演算一下,假设数值范围M=3。
- 初始化
dp = [0,0,0,0](索引1到3),prefix_sum = [0,0,0,0](同样索引1到3,prefix_sum[i]表示dp[1]到dp[i]的和)。 - 遍历
a[0]=1:sum = 1 + prefix_sum[0] = 1 + 0 = 1。(prefix_sum[0]我们视为0)- 新增贡献
delta = sum - dp[1] = 1 - 0 = 1。 - 更新
dp[1] = 1。 - 更新前缀和:从索引1开始,
prefix_sum[1] += 1=>prefix_sum = [0,1,1,1](为了快速更新,我们通常用树状数组或线段树,这里用朴素描述理解)。实际上prefix_sum变为[0,1,1,1]表示dp[1]=1, dp[2]=0, dp[3]=0的和是1。 - 总答案
ans = 1。
- 遍历
a[1]=2:sum = 1 + prefix_sum[1] = 1 + 1 = 2。(prefix_sum[1]是dp[1]的和,即1)delta = sum - dp[2] = 2 - 0 = 2。- 更新
dp[2] = 2。 - 更新前缀和:从索引2开始,
prefix_sum[2] += 2=>prefix_sum = [0,1,3,3]。 ans = 1 + 2 = 3。- 解释:新增的2个子序列是
[2]和[1,2]。
- 遍历
a[2]=2(第二个2):sum = 1 + prefix_sum[1] = 1 + 1 = 2。(注意,prefix_sum[1]仍然是1,因为dp[1]没变过)delta = sum - dp[2] = 2 - 2 = 0。- 更新
dp[2] = 2(不变)。 - 更新前缀和:增量为0,所以不更新。
ans = 3 + 0 = 3。- 解释:没有新增任何本质不同的以2结尾的子序列。
- 遍历
a[3]=3:sum = 1 + prefix_sum[2] = 1 + 3 = 4。(prefix_sum[2]是dp[1]+dp[2]=1+2=3)delta = sum - dp[3] = 4 - 0 = 4。- 更新
dp[3] = 4。 - 更新前缀和:从索引3开始,
prefix_sum[3] += 4=>prefix_sum = [0,1,3,7]。 ans = 3 + 4 = 7。- 解释:新增的4个子序列是
[3],[1,3],[2,3],[1,2,3]。
最终结果ans = 7,与之前分析一致。
3.3 数据结构优化:树状数组(Fenwick Tree)
在上面的流程中,我们频繁进行两种操作:
- 查询前缀和:
prefix_sum[x-1]。 - 单点更新后更新前缀和:将
delta加到dp[x]上,并需要将delta加到所有prefix_sum[j](j >= x)上。
朴素的前缀和数组在更新时需要O(M)的时间,总时间复杂度为O(n * M),在M很大时不可接受。这正是树状数组(或线段树)的经典应用场景。树状数组可以在O(log M)的时间内完成单点更新和前缀和查询。
因此,我们的算法优化为:
- 初始化一个树状数组
bit,长度为M+1,初始为0。bit维护的就是我们上面提到的dp数组的树状结构,bit.query(x)可以快速得到prefix_sum[x](即dp[1]到dp[x]的和)。 - 遍历序列
a的每个元素x(离散化后的值)。 sum = 1 + bit.query(x-1)。delta = sum - current_dp_value。我们需要知道current_dp_value,可以额外用一个数组dp_val记录,也可以再用一个树状数组?不,我们只需要知道dp[x]的当前值。我们可以用一个数组last来记录每个数值x对应的当前dp[x]值。last[x] = sum。bit.update(x, delta)。这个操作会将delta加到dp[x]上,并更新树状数组。ans += delta。
这样,总时间复杂度就降到了O(n log M),通常可以应对n和M在10^5级别的数据。
4. 完整代码实现与逐行解析
下面给出基于C++的完整实现,包含离散化步骤和树状数组优化。我假设输入序列a的长度为n,存储在vector<int> a中。
#include <iostream> #include <vector> #include <algorithm> using namespace std; const int MOD = 1000000007; // 通常结果会要求取模,这里先加上 // 树状数组类 class Fenwick { private: vector<long long> tree; int n; public: Fenwick(int size) : n(size), tree(size + 1, 0) {} // 更新位置x,增加delta void update(int x, long long delta) { while (x <= n) { tree[x] = (tree[x] + delta) % MOD; x += x & -x; } } // 查询前缀和 [1, x] long long query(int x) { long long res = 0; while (x > 0) { res = (res + tree[x]) % MOD; x -= x & -x; } return res; } }; int main() { int n; cin >> n; vector<int> a(n); for (int i = 0; i < n; ++i) { cin >> a[i]; } // 1. 离散化 vector<int> b = a; sort(b.begin(), b.end()); b.erase(unique(b.begin(), b.end()), b.end()); // 去重,得到所有不同的值 int m = b.size(); // 离散化后的数值范围 // 离散化映射函数 auto get_id = [&](int val) { return lower_bound(b.begin(), b.end(), val) - b.begin() + 1; // 映射到1..m }; // 2. 初始化树状数组和dp值记录数组 Fenwick bit(m); vector<long long> last(m + 1, 0); // last[i] 记录离散化后值i对应的当前dp值 long long ans = 0; // 3. 遍历原序列 for (int val : a) { int id = get_id(val); // 获取离散化后的id // 计算以当前值结尾的新增子序列数量 // sum = 1 (序列[val]自身) + 所有小于val的结尾值对应的子序列总数 long long sum = (1 + bit.query(id - 1)) % MOD; // 新增的、不重复的部分 = sum - 上一次计算出的以val结尾的子序列数 long long delta = (sum - last[id] + MOD) % MOD; // 加MOD防止负数 if (delta > 0) { // 更新树状数组,相当于更新了dp[id] bit.update(id, delta); // 更新last记录 last[id] = sum; // 累加答案 ans = (ans + delta) % MOD; } } cout << ans << endl; return 0; }逐行解析与关键点:
- 离散化:这是处理数值范围大但数量有限的经典操作。
b数组存储了a中所有不同的值并排序。get_id函数通过二分查找将原值映射到1到m的连续整数。这保证了树状数组的大小m与序列中不同元素的数量相关,而不是与原值范围相关,极大提升了效率。 - 树状数组
bit:它维护的是离散化后,以每个值id结尾的dp值(即本质上升子序列个数)的树状结构。bit.query(id-1)就等价于我们之前分析的prefix_sum[id-1],即所有小于当前值的dp之和。 - 数组
last:用于记录每个离散化后的值id对应的、当前计算出的dp值(即last[id])。我们需要它来计算delta。 - 核心循环:
sum = 1 + bit.query(id - 1):计算理论上以当前值val结尾的所有可能的新子序列数量(包括可能重复的)。delta = sum - last[id]:这是精髓。last[id]是之前计算出的、以val结尾的子序列数量。sum是基于当前所有小于val的子序列信息重新计算的总数。它们的差值delta,就是自从上一次处理val之后,由于新出现的小于val的元素所产生的新子序列,后面接上当前这个val所构成的、全新的、以前没计数过的本质子序列数量。如果delta为0,说明没有新增。bit.update(id, delta):将这新增的delta个子序列数量,累加到树状数组的id位置。这相当于更新了dp[id],并且这个更新会影响到所有大于id的位置的前缀和查询。last[id] = sum:更新记录。ans += delta:将新增的数量加入总答案。
- 取模:题目通常要求结果对一个大质数(如
1e9+7)取模。我们在所有加法和更新操作中都进行了取模,并在计算delta时(sum - last[id] + MOD) % MOD防止负数出现。
5. 常见问题、调试技巧与扩展思考
5.1 典型错误与排查
错误:结果比预期小很多。
- 检查点1:离散化是否正确?确保
get_id函数对相同的原值返回相同的id。lower_bound在排序后的b中查找是正确的。 - 检查点2:
delta的计算和更新逻辑。确认是sum - last[id],而不是sum - bit.query(id)。bit.query(id)是前缀和,不是dp[id]的当前值。last数组是必须的。 - 检查点3:初始化。
last数组初始为0,bit初始全0。ans初始为0。 - 检查点4:取模运算。在取模环境下,
delta可能为负数(当sum < last[id]时,虽然理论上不会,但取模后可能发生)。所以要用(sum - last[id] + MOD) % MOD。
- 检查点1:离散化是否正确?确保
错误:结果溢出或不对(未取模时)。
- 检查点:数据范围。本质上升子序列的数量可能增长非常快,指数级。
n较大时,数量会超过long long的范围。务必在计算过程中就进行取模,而不是最后才取模。sum、delta、ans的每一次运算都要取模。
- 检查点:数据范围。本质上升子序列的数量可能增长非常快,指数级。
错误:遇到重复元素时答案错误。
- 核心检查:再次理解
delta的含义。当第二次遇到val时,sum是基于当前所有小于val的子序列总数计算的。如果在这两次val之间,没有出现新的、小于val的数值,或者这些新数值没有形成新的子序列,那么sum不会变,delta为0,正确。如果出现了新的、小于val的数值并形成了新子序列,那么sum会增大,delta就是这些新子序列接上当前val所产生的新序列数。这是符合“本质不同”定义的。
- 核心检查:再次理解
5.2 调试技巧
- 小数据手工模拟:就像我们前面用
[1,2,2,3]做的那样,在纸上或注释里写出每一步的id,sum,last[id],delta,bit的状态(可以打印bit.tree数组),以及ans。这是理解算法和定位错误最有效的方法。 - 打印关键变量:在循环内打印每个元素的
id,sum,last[id],delta。 - 测试边界用例:
- 空序列:答案应为0。
- 所有元素相同:如
[5,5,5],本质上升子序列只有[5]一个,答案应为1。 - 严格递增序列:如
[1,2,3],本质上升子序列为[1],[2],[3],[1,2],[1,3],[2,3],[1,2,3],共7个。 - 严格递减序列:如
[3,2,1],本质上升子序列为[3],[2],[1],共3个。
5.3 扩展思考与变种
如果不要求“本质不同”,而是求所有上升子序列的数量(包括位置不同)。
- 这个问题更简单。定义
dp[i]为以第i个元素结尾的上升子序列个数。转移方程:dp[i] = 1 + sum(dp[j]),对于所有j < i且a[j] < a[i]。总答案就是sum(dp[i])。可以用树状数组优化到O(n log n)。 - 与本题的区别在于,它不关心子序列是否由相同的值构成,只关心位置。所以每个位置都是独立的,即使值相同。
- 这个问题更简单。定义
如果要求输出具体的本质上升子序列,而不仅仅是数量。
- 难度暴增。数量可能是指数级的,无法全部输出。通常题目会限制条件,比如只输出字典序第K小的。这就需要结合DP计数和递归构造,是更高级的题型。
“本质不下降子序列”数量。
- 将条件从严格递增 (
<) 改为非严格递增 (<=)。此时,状态转移时,对于相同的值,也需要考虑转移。但“本质不同”的定义依然存在。解法类似,但在离散化和比较时需要小心。通常,为了处理<=,我们可以在离散化时保留相同值,但在树状数组查询时,查询的是bit.query(id)(包含等于的情况),而不是id-1。同时,去重的逻辑需要调整:当遇到相同的val时,新产生的子序列可能会与之前val产生的序列重复吗?会的。例如[1,2,2],以第二个2结尾的[1,2]与以第一个2结尾的[1,2]是同一个本质子序列。所以我们的delta计算逻辑sum - last[id]依然有效,但这里的sum计算用的是bit.query(id)。
- 将条件从严格递增 (
5.4 国赛实战建议
- 理解优先于记忆:不要死记硬背这个题的代码。务必理解“以值结尾”的状态定义、
delta去重的原理、以及树状数组如何优化前缀和查询与更新。这样即使题目稍有变化(比如求长度不超过K的本质上升子序列数量),你也能灵活调整。 - 模板化组件:将离散化、树状数组这两个通用组件写成自己熟悉的模板,比赛时能快速无误地敲出来。
- 仔细审题:国赛题目的描述往往非常精确。一定要分清是“本质不同”还是“位置不同”,是“上升”还是“不下降”,结果是否取模。
- 测试用例:编码完成后,务必用上面提到的几个边界用例和简单用例测试,确保基本逻辑正确。