1. 问题分析与建模
这道题目要求我们找到一个子序列,使得在原序列和所有可能的单点变化序列中,这个子序列都是不降的。换句话说,我们需要找到一个"超级稳定"的子序列,它能够抵抗任何单点变化的影响。
1.1 问题重述
给定一个长度为n的序列a,以及m个可能的单点变化(x,y),表示a[x]可以变成y。我们需要找到一个子序列,使得:
- 在原序列中是不降的
- 在任何单点变化后的序列中也是不降的
1.2 关键观察
每个位置i有三个关键值:
- val[i]:原始值
- minv[i]:可能变成的最小值(包括原始值)
- maxv[i]:可能变成的最大值(包括原始值)
对于子序列中的两个位置i<j:
- 必须满足maxv[i] ≤ val[j](i位置取最大值时不超过j的原始值)
- 必须满足val[i] ≤ minv[j](i的原始值不超过j位置取最小值时)
1.3 动态规划模型
我们可以定义dp[i]表示以第i个元素结尾的最长合法子序列长度。转移方程为: dp[i] = max{dp[j]} + 1,其中j满足:
- j < i
- maxv[j] ≤ val[i]
- val[j] ≤ minv[i]
2. 算法设计与优化
直接按照上述DP模型实现的时间复杂度是O(n²),对于n=1e5的数据显然无法通过。我们需要更高效的算法。
2.1 CDQ分治思想
CDQ分治是一种分治算法,主要用于解决三维偏序问题。在这里,我们可以将问题转化为: 对于每个i,我们需要统计满足以下条件的j:
- j < i(位置关系)
- maxv[j] ≤ val[i](第一维偏序)
- val[j] ≤ minv[i](第二维偏序)
2.2 具体实现步骤
预处理阶段:
- 读取输入数据
- 初始化每个位置的minv和maxv(初始等于原始值)
- 处理所有变化,更新对应位置的minv和maxv
CDQ分治阶段:
- 将区间[l,r]分成[l,mid]和[mid+1,r]
- 递归处理左半区间
- 对左半区间按val排序,右半区间按minv排序
- 双指针扫描,用树状数组维护maxv的限制
- 恢复右半区间的顺序(按id排序)
- 递归处理右半区间
树状数组维护:
- 用于在双指针扫描时快速查询满足maxv[j] ≤ val[i]的dp[j]最大值
- 需要离散化处理maxv和val的值域
2.3 时间复杂度分析
- CDQ分治:O(n log n)
- 每次分治中的排序:O(n log n)
- 树状数组操作:每次O(log n),总共O(n log n)
- 总时间复杂度:O(n log² n)
3. 代码实现详解
3.1 数据结构定义
struct nd { int val, minv, maxv, id; // 原权值、最小权值、最大权值、编号 } a[MAXN]; class BIT { private: int tr[MAXN]; int lowbit(int &x) { return x & (-x); } public: void clear(int pos) { for (int i = pos; i <= n; i += lowbit(i)) tr[i] = 0; } void upd(int pos, int x) { for (int i = pos; i <= n; i += lowbit(i)) tr[i] = max(tr[i], x); } int qry(int pos) { int res = 0; for (int i = pos; i; i -= lowbit(i)) res = max(res, tr[i]); return res; } } tree;3.2 CDQ分治核心
void cdq(int l, int r) { if (l == r) return; int mid = (l + r) >> 1; cdq(l, mid); // 左半区间按val排序,右半区间按minv排序 sort(a + l, a + mid + 1, cmp_val); sort(a + mid + 1, a + r + 1, cmp_min); // 双指针扫描 int i = l, j = mid + 1; for (; j <= r; ++j) { while (i <= mid && a[i].val <= a[j].minv) { tree.upd(a[i].maxv, dp[a[i].id]); i++; } dp[a[j].id] = max(dp[a[j].id], tree.qry(a[j].val) + 1); } // 清空树状数组 while (i > l) tree.clear(a[--i].maxv); // 恢复右半区间的顺序 sort(a + mid + 1, a + r + 1, cmp_id); cdq(mid + 1, r); }3.3 主程序流程
int main() { // 读取输入 n = rd(), m = rd(); for (int i = 1; i <= n; ++i) a[i].val = a[i].minv = a[i].maxv = rd(), a[i].id = i; // 处理变化 for (int i = 1; i <= m; ++i) { int x = rd(), y = rd(); a[x].minv = min(a[x].minv, y); a[x].maxv = max(a[x].maxv, y); } // 初始化DP数组 for (int i = 1; i <= n; ++i) dp[i] = 1; // CDQ分治求解 cdq(1, n); // 输出结果 int max_res = 0; for (int i = 1; i <= n; ++i) max_res = max(max_res, dp[i]); wt(max_res), putchar('\n'); return 0; }4. 关键优化技巧
4.1 离散化处理
由于数值范围可能很大(1e5),直接作为树状数组的下标会浪费空间。我们可以对val和maxv进行离散化:
vector<int> vals; for (int i = 1; i <= n; ++i) { vals.push_back(a[i].val); vals.push_back(a[i].maxv); } sort(vals.begin(), vals.end()); vals.erase(unique(vals.begin(), vals.end()), vals.end()); auto getid = [&](int x) { return lower_bound(vals.begin(), vals.end(), x) - vals.begin() + 1; };然后在树状数组操作时使用离散化后的id。
4.2 快速IO优化
对于大规模数据输入输出,使用快速的读写函数可以显著提升性能:
inline int rd() { int x = 0; char c = getchar(); int f = 1; while (c < '0' || c > '9') { if (c == '-') f = -f; c = getchar(); } while (c >= '0' && c <= '9') { x = (x << 3) + (x << 1) + c - '0'; c = getchar(); } return x * f; } inline void wt(int x) { if (x < 0) { x = -x; putchar('-'); } if (x > 9) wt(x / 10); putchar(x % 10 + '0'); }5. 常见问题与调试技巧
5.1 边界条件处理
- 空序列情况:虽然题目保证n≥1,但在其他类似问题中需要注意
- 所有变化都使值变小/变大:确保minv和maxv正确更新
- 重复变化:同一个位置可能多次变化,要正确更新minv和maxv
5.2 调试建议
- 小数据测试:先用手算能出结果的小数据测试
- 打印中间结果:在CDQ分治过程中打印区间和排序后的数组
- 检查树状数组:确保更新和查询操作正确,特别是边界情况
5.3 性能优化
- 避免不必要的排序:在CDQ分治中,只有必要的区间才排序
- 内存访问优化:结构体数组按缓存友好方式排列
- 减少函数调用:将简单操作用内联函数实现
6. 算法扩展与变种
6.1 支持多个同时变化
如果题目改为允许最多k个位置同时变化,我们可以:
- 对每个位置,考虑所有可能的变化组合
- 定义更复杂的状态表示(如bitset表示哪些位置变化了)
- 使用更高维的DP或状态压缩
6.2 带权值的最长子序列
如果每个位置有权值,要求权值和最大的合法子序列,我们可以:
- 修改DP状态为最大权值和
- 树状数组维护最大权值而非长度
- 转移方程变为dp[i] = max{dp[j] + w[i]}
6.3 在线版本
如果变化是动态的(随时可能增加或删除),我们可以:
- 使用分块或线段树等数据结构维护区间信息
- 每次变化后重新计算受影响的部分
- 可能需要牺牲一些时间复杂度来支持动态操作
在实际编码比赛中,理解CDQ分治的思想比记住模板更重要。这种分治策略可以应用于许多三维偏序问题,关键在于如何将问题转化为适合CDQ分治的形式。