1. 问题分析与解法概述
今天我们来探讨一个经典的动态规划问题——统计数组中所有递增子序列的数量。给定一个长度为n的整数数组,我们需要计算其中所有严格递增子序列的个数(包括单元素子序列)。这个问题看似简单,但当n达到2×10^5时,传统的O(n²)解法就会超时,因此我们需要更高效的算法。
1.1 问题重述与理解
首先明确几个关键点:
- 子序列是指从原数组中按顺序选取的元素,不要求连续
- 严格递增意味着后一个元素必须大于前一个元素
- 相同元素但位置不同视为不同的子序列
- 结果需要对10^9+7取模
以样例输入[2,1,3]为例,正确的递增子序列有:
- [2]
- [1]
- [3]
- [2,3]
- [1,3]
总数为5,与样例输出一致。
1.2 暴力解法与优化思路
最直观的解法是枚举所有可能的子序列并检查是否递增,但这样时间复杂度为O(2^n),显然不可行。
稍好的方法是动态规划:定义dp[i]为以a[i]结尾的递增子序列数量,转移方程为:
dp[i] = 1 + Σdp[j] (对所有j < i且a[j] < a[i])
这样时间复杂度为O(n²),对于n=2×10^5来说仍然不够。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高效解法:离散化与线段树
2.1 离散化处理
由于元素值范围很大(1≤xi≤10^9),但实际不同的值数量有限,我们可以先对数组进行离散化——将原始值映射到连续的整数索引,同时保持大小关系不变。
离散化步骤:
- 复制原始数组并排序
- 去重
- 对每个元素,使用二分查找确定其在去重后数组中的位置
这样,原始的大范围数值就被压缩到0到m-1的范围(m为不同元素的数量)。
2.2 线段树优化
为了高效计算Σdp[j] (a[j] < a[i]),我们可以使用线段树来维护以某个离散化值结尾的所有dp值之和。线段树支持两种操作:
- 单点更新:将dp[i]加到其对应的离散化位置上
- 区间查询:查询小于当前值的所有dp值之和
这样,每次转移的时间复杂度从O(n)降为O(log m),整体复杂度变为O(n log n)。
3. 代码实现详解
3.1 线段树实现
cpp复制struct Tree{
int n;
vector<long long> st;
Tree(int nn){
init(nn);
}
void init(int nn){
n = 1;
while(n < max(1,nn)){
n <<= 1;
}
st.assign(2*n, 0);
}
void add(int p, int val){
p += n;
st[p] = (st[p] + val) % MOD;
for(p >>= 1; p >= 1; p >>= 1){
st[p] = (st[p*2] + st[p*2+1]) % MOD;
}
}
long long query(int l, int r){
if(l > r) return 0;
long long res = 0;
int ll = l + n, rr = r + n;
while(ll <= rr){
if(ll % 2 == 1){
res = (res + st[ll++]) % MOD;
}
if(rr % 2 == 0){
res = (res + st[rr--]) % MOD;
}
ll >>= 1;
rr >>= 1;
}
return res;
}
};
这个线段树实现使用了数组表示的完全二叉树结构,支持:
- 初始化时自动扩展到最近的2的幂次大小
- add操作在O(log n)时间内更新一个位置的值
- query操作在O(log n)时间内查询区间和
3.2 主算法流程
cpp复制int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n;
cin >> n;
vector<long long> a(n);
for(int i = 0; i < n; i++){
cin >> a[i];
}
// 离散化处理
vector<long long> nums = a;
sort(nums.begin(), nums.end());
nums.erase(unique(nums.begin(), nums.end()), nums.end());
int m = nums.size();
Tree tree(m);
long long ans = 0;
for(int i = 0; i < n; i++){
// 获取当前元素的离散化索引
int index = int(lower_bound(nums.begin(), nums.end(), a[i]) - nums.begin());
// 查询小于当前元素的所有dp值之和
long long num = 0;
if(index){
num = tree.query(0, index-1);
}
// 计算dp[i] = 1 + sum
num = (num + 1) % MOD;
// 累加到总答案
ans = (ans + num) % MOD;
// 更新线段树
tree.add(index, num);
}
cout << ans;
return 0;
}
4. 关键点解析与注意事项
4.1 离散化的必要性
离散化不仅减少了数值范围,更重要的是:
- 使线段树的大小可控(最多n个不同值)
- 保持元素间的相对大小关系
- 使区间查询变得可行
注意:离散化后元素顺序必须保持原大小关系,这是正确性的关键。
4.2 线段树的区间查询
查询区间[0, index-1]时:
- 如果index=0,表示没有比当前元素更小的值,直接返回0
- 否则查询从0到index-1的所有dp值之和
4.3 模运算处理
由于结果可能很大,所有运算都需要对MOD=1e9+7取模:
- 线段树更新时取模
- 查询结果取模
- 中间计算结果取模
常见错误:忘记在中间步骤取模,导致溢出。
5. 复杂度分析与优化对比
5.1 时间复杂度
- 离散化:O(n log n)(排序占主导)
- 线段树操作:每次O(log n),共n次
- 总复杂度:O(n log n)
5.2 空间复杂度
- 线段树需要O(n)空间
- 离散化数组需要O(n)空间
- 总空间:O(n)
5.3 替代方案比较
- 树状数组:也可以实现类似功能,代码更简洁
- 平衡二叉搜索树:理论上可行,但实现复杂且常数较大
- 分块处理:虽然时间复杂度稍高,但实现简单
在实际编码竞赛中,线段树或树状数组是最常用的选择。
6. 扩展与变种问题
6.1 非严格递增子序列
如果允许相等元素,只需修改查询条件为a[j] ≤ a[i],对应离散化后的查询区间变为[0, index]。
6.2 最长递增子序列计数
如果需要统计最长递增子序列的数量,可以结合长度信息,使用类似但更复杂的线段树结构。
6.3 二维问题
在二维平面上给定点集,统计满足x和y都严格递增的子序列数量,可以使用二维线段树或分治策略。
7. 实际应用与总结
这类问题在以下场景有实际应用:
- 基因组序列分析
- 时间序列模式识别
- 金融数据分析
通过这个问题的解决,我们学到了:
- 如何将大范围数据离散化处理
- 线段树在动态统计问题中的应用
- 动态规划与数据结构的结合技巧
在实现时,特别注意边界条件和模运算的处理。线段树的实现细节(如区间查询的写法)对正确性和效率都有重要影响。
