Codeforces 序列计数三题:树状数组、递减三元组与逆序奇偶
“有多少对”“有多少组三元组”看起来需要多重循环,但很多位置限制可以由扫描方向自动保证,剩下的只是一个动态前缀计数问题。另一类题甚至不需要维护完整计数:如果只问奇偶性,就应先研究操作如何改变奇偶性。
这篇从 前缀和与差分 接出一条序列计数支线:扫描保证位置关系,树状数组回答大小关系,不变量决定哪些状态根本不用保存。
1. 学习路线与官方约束
题名、难度、标签与约束于 2026-09-28 核对 Codeforces 官方题目页。下面的说明是重新组织的题意,不替代原题。
| 官方题目 | 难度 | 官方标签 | 本文的切入点 |
|---|---|---|---|
| 459D · Pashmak and Parmida’s problem | 1800 | data structures, divide and conquer, sortings | 先预处理频次,再扫描频次的分布 |
| 61E · Enemy is weak | 1900 | data structures, trees | 固定中间位置,把三元组拆成左右选择 |
| 911D · Inversion Counting | 1800 | brute force, math | 初始计数可以用树状数组,后续只更新奇偶性 |
| 题目 | 数据边界 | 时间 / 空间限制 | 会影响实现的条件 |
|---|---|---|---|
| 459D | 1 ≤ n ≤ 10^6,1 ≤ a[i] ≤ 10^9 |
3 秒 / 256 MB | 数值可以重复,答案可能超过 32 位 |
| 61E | 3 ≤ n ≤ 10^6,1 ≤ a[i] ≤ 10^9 |
5 秒 / 256 MB | 所有数值互不相同,三元组数须用 64 位 |
| 911D | 1 ≤ n ≤ 1500,1 ≤ m ≤ 2×10^5 |
2 秒 / 256 MB | 初始数组是 1..n 的排列,反转操作依次累积 |
flowchart TD A["枚举位置组合太慢"] --> B["固定一个端点或中间位置"] B --> C["用扫描方向保证位置关系"] C --> D["树状数组维护值域前缀计数"] D --> E["459D:比较两侧出现次数"] D --> F["61E:左右选择数相乘"] D --> G["911D:只求初始逆序奇偶"] G --> H["反转后仅更新一个奇偶位"]
建议先手算一遍第二节的树状数组,再做 459D;61E 用来练习“枚举中间”,911D 用来提醒自己:有数据结构也不意味着每次操作都需要它。
2. 树状数组:动态维护一个计数前缀
从静态前缀和到动态前缀和
假设 freq[x] 表示已扫描元素中数值为 x 的数量。查询“小于 x 的元素数”等于 freq[1]+...+freq[x-1]。
普通前缀和查询很快,但插入一个元素就可能需要修改后面所有前缀。树状数组(Fenwick tree)把前缀拆成若干二进制长度的块:单点增加和前缀求和都只需 O(log n)。
令 lowbit(x) = x & -x。使用从 1 开始的下标,tree[x] 保存区间 [x-lowbit(x)+1, x] 的总和。
| 节点 x | lowbit(x) | 保存的区间 |
|---|---|---|
| 1 | 1 | [1,1] |
| 4 | 4 | [1,4] |
| 6 | 2 | [5,6] |
| 7 | 1 | [7,7] |
| 8 | 8 | [1,8] |
查询 sum(7) 时,依次访问 7、6、4,拼出 [7,7] + [5,6] + [1,4],既不漏项也不重叠。每次减去 lowbit,会清除当前最低的一个二进制 1,因此步数不超过二进制位数。
在长度 8 的树中给位置 3 加一,依次更新 3、4、8。这些正是沿树状数组祖先链覆盖位置 3 的块;跳转 x += lowbit(x) 越过不包含该位置的块,从而保持每个节点的区间和定义。
离散化不是改变比较关系
值域到 10^9,不能直接开这么大的计数数组。把原数组复制、排序、去重,再把每个数替换成从 1 开始的排名:相等仍相等,大小次序不变。比如 [100,7,100,42] 变成 [3,1,3,2]。
| 想查询什么 | 已插入数量为 seen 时的公式 | 等号在哪一边 |
|---|---|---|
| 严格小于 x | sum(x-1) |
不包含 x |
| 小于等于 x | sum(x) |
包含 x |
| 严格大于 x | seen-sum(x) |
扣掉小于等于 x |
| 大于等于 x | seen-sum(x-1) |
只扣掉严格小于 x |
sum(0)=0 是合法空前缀;add(0,1) 则会永远停在 0。离散化排名必须加一。三份代码都保留完整的树状数组定义,便于分别提交。
3. 第一题:459D Pashmak and Parmida’s problem
把两段频率变成两个数组
定义 L[i] 为 a[i] 在 [1,i] 中的出现次数,R[i] 为 a[i] 在 [i,n] 中的出现次数。目标是统计 i<j 且 L[i]>R[j] 的点对。
直接枚举所有 (i,j) 即使预处理了 L、R,仍需 O(n²)。注意第二个条件比较的是出现次数,不是 a[i] 与 a[j] 的大小。原数值只负责确定哪些位置属于同一种值。
用数组 [1,2,1,1,2,2,1] 手算:
| 位置(1 起) | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| a | 1 | 2 | 1 | 1 | 2 | 2 | 1 |
| L | 1 | 1 | 2 | 3 | 2 | 3 | 4 |
| R | 4 | 3 | 3 | 2 | 2 | 1 | 1 |
从右向左,让位置条件自动成立
处理 i 之前,树状数组只存所有 j>i 的 R[j]。因此当前贡献就是 sum(L[i]-1);查询之后才加入 R[i]。
| 扫描 i | 右侧已插入的 R(按位置列出) | 小于 L[i] 的数量 | 累计答案 |
|---|---|---|---|
| 7 | 空 | 0 | 0 |
| 6 | 1 | 1 | 1 |
| 5 | 1,1 | 2 | 3 |
| 4 | 2,1,1 | 3 | 6 |
| 3 | 2,2,1,1 | 2 | 8 |
| 2 | 3,2,2,1,1 | 0 | 8 |
| 1 | 3,3,2,2,1,1 | 0 | 8 |
flowchart LR A["离散化原数值"] --> B["正向求 L,反向求 R"] B --> C["从右向左处理 i"] C --> D["查询 sum of L[i]-1"] D --> E["插入 R[i]"] E --> C
正确性可用归纳说明:最右位置处理前树为空,与“只含右侧”一致;查询准确统计全部合法右端点;插入当前 R 后,恰好形成下一个位置的右侧集合。每一对合法 (i,j) 只在处理 i 时计入一次。
完整 C++17
1 |
|
排序、二分映射和树状数组扫描总时间 O(n log n),额外空间 O(n)。树节点只存不超过 n 的元素数量,用 int 足够;答案不超过 n(n-1)/2,用 long long。
| 边界 | 应有结果或性质 | 能抓住的错误 |
|---|---|---|
| n=1 | 0 | 把当前位置提前插入 |
| 所有值不同 | L、R 都是 1,答案 0 | 把严格大于写成大于等于 |
| 三个相同值 | 只有 (2,3),答案 1 |
比较原数值而不是频次 |
| 百万个相同值 | floor((n-1)²/4) |
32 位答案溢出 |
最后一个公式来自 L[i]=i、R[j]=n-j+1,合法条件为 i<j 且 i+j>n+1;按 j 对 max(0,2j-n-2) 求和即可得到该闭式。
4. 第二题:61E Enemy is weak
固定中间项,让两个选择独立
要统计 i<j<k 且 a[i]>a[j]>a[k]。三重枚举是 O(n³);即使固定 j 后分别线性扫描左右,也要 O(n²)。
定义 greaterLeft[j] 为左边严格较大元素数,smallerRight[j] 为右边严格较小元素数。固定 j 后,任何一个合法左端点与任何一个合法右端点组合都会满足条件,所以贡献为两者乘积。
对 [10,8,3,1]:
| 中间位置 j | a[j] | 左侧较大数 | 右侧较小数 | 贡献 |
|---|---|---|---|---|
| 1 | 10 | 0 | 3 | 0 |
| 2 | 8 | 1 | 2 | 2 |
| 3 | 3 | 2 | 1 | 2 |
| 4 | 1 | 3 | 0 | 0 |
答案为 4。每个三元组有唯一中间位置,因此求和不会重复。
两遍扫描的循环不变量
正向扫描到零起下标 j 时,树中有 j 个先前元素;j-sum(rank[j]) 就是左侧严格较大数。清空计数状态、重新建立一棵树后反向扫描,sum(rank[j]-1) 给出右侧严格较小数。两遍都先查询、后插入。
flowchart TD A["保持大小关系的离散化"] --> B["正向扫描:已见数量减去不大于当前的数量"] B --> C["保存 greaterLeft"] C --> D["使用一棵新的空树反向扫描"] D --> E["求右边严格较小元素数"] E --> F["左右数量相乘,累加到答案"]
这并不是把整个数组排序后数三元组。排序的只是辅助映射表,原位置顺序不能改变。两遍树状数组分别保证左右位置关系,排名查询保证值的大小关系,乘法原理与唯一中间点共同给出正确性。
完整 C++17
1 |
|
时间 O(n log n),空间 O(n)。1LL 必须在乘法之前参与运算,而不是让两个 int 先相乘、溢出后才赋给 long long。
| 输入形态 | 答案 | 检查重点 |
|---|---|---|
| 严格递增 | 0 | 不等号方向 |
| 三个数严格递减 | 1 | 中间位置是否计入自身 |
| n 个数严格递减 | n(n-1)(n-2)/6 |
三元组上界 |
| n=1,000,000 严格递减 | 166,666,166,667,000,000 | 既超过 32 位,也超过 JavaScript Number 精确整数范围 |
独立验证器用 JavaScript BigInt 计算最后一行的期望值,避免让测试程序自身的精度问题掩盖错误。题目保证数值互异;代码仍使用严格比较的标准写法,不把互异条件当成省略等号分析的借口。
5. 第三题:911D Inversion Counting
不要维护题目没有询问的信息
逆序对是 i<j 且 a[i]>a[j] 的点对。每次反转 [l,r] 后,题目只问逆序对数是奇数还是偶数。
直接反转数组、重算逆序数,即使每次计数用树状数组也要 O(m n log n)。只保留一个奇偶位,才能充分利用问题结构。
初始奇偶性可以用 O(n²) 暴力求出:n 仅为 1500,这已经足够。下面使用树状数组统一前两题的知识,初始为 O(n log n);真正关键不是这点优化,而是之后每次只需 O(1)。
一次反转为什么只看长度
令区间长度 len=r-l+1,把所有数对分成三类。
| 数对位置 | 反转的影响 | 对总奇偶性的影响 |
|---|---|---|
| 两个都在区间外 | 位置与数值均未变 | 无 |
| 一个在区间内、一个在区间外 | 区间内数值集合不变,外部元素仍全部在它们左侧或右侧 | 与该外部元素有关的逆序对总数不变 |
| 两个都在区间内 | 相对先后顺序颠倒,且数值不同 | 每一对的逆序状态翻转 |
区间内共有 len(len-1)/2 对。某一对从 0 变 1 或从 1 变 0,对模 2 的计数都是翻转。因此新奇偶性等于旧奇偶性异或 C(len,2) mod 2。
互异条件非常重要:[1,1] 反转后仍无逆序对,不能套用“长度 2 必翻转”。本题是排列,而且反转保持排列性质,所以每轮证明都继续成立。
| len mod 4 | C(len,2) 的奇偶 | 是否翻转答案 |
|---|---|---|
| 0 | 偶 | 否 |
| 1 | 偶 | 否 |
| 2 | 奇 | 是 |
| 3 | 奇 | 是 |
flowchart LR A["原排列"] --> B["求初始逆序奇偶位"] B --> C["读入一次反转区间"] C --> D["计算区间内数对数量的奇偶"] D --> E["异或并输出 odd 或 even"] E --> C
累积操作的手算
从 [1,2,4,3] 开始,逆序数为 1。下面为了验证推导才列出实际数组;正式程序不需要真的反转。
| 操作 | 实际数组 | 逆序数 | 仅用奇偶位得到的输出 |
|---|---|---|---|
反转 [1,1] |
[1,2,4,3] |
1 | odd |
反转 [1,4] |
[3,4,2,1] |
5 | odd |
再反转 [1,4] |
[1,2,4,3] |
1 | odd |
反转 [2,3] |
[1,4,2,3] |
2 | even |
完整 C++17
1 |
|
总时间 O(n log n + m),额外空间 O(n)。读完初始数组后,树状数组不再使用,也不需要修改其中的信息;它完成的任务只是产生初始奇偶位。
6. 三题一起复盘:到底在树里存什么
| 问题 | 树的坐标轴 | 节点累计内容 | 算法真正依赖的不变量 |
|---|---|---|---|
| 459D | 出现次数 1..n |
右侧有多少个 R 落在该范围 | 查询前只含严格右侧位置 |
| 61E | 数值的离散化排名 | 已扫描元素的数量 | 正向只有左侧,反向只有右侧 |
| 911D 初始阶段 | 排列值 1..n |
先前元素的数量 | 每对逆序在其右端点被计入 |
| 911D 查询阶段 | 不再需要树 | 一个奇偶位 | 反转影响的奇偶只由长度决定 |
| 常见错误 | 为什么错 | 最小修正 |
|---|---|---|
| 把 459D 的原数值作为查询阈值 | 题目比较的是频次 | 分开值的排名与出现次数 |
| 查询之前先插入当前位置 | 扫描集合可能包含自己 | 明确循环入口的不变量 |
把 <x 写成 sum(x) |
多计等值项 | 严格小于用 sum(x-1) |
| 两遍扫描共用未重置的树 | 左侧数据污染右侧统计 | 建立一棵新的空树 |
| 将原数组整体排序后计数 | 丢失位置条件 | 只排序辅助映射数组 |
| 用 int 保存三元组答案 | 大输入溢出 | 乘法起点和答案均使用 64 位 |
| 911D 每次从初始奇偶重新算 | 操作是累计的 | 始终更新当前 parity |
| 把排列证明套到重复值数组 | 相等数对不翻转逆序状态 | 检查证明用到的条件 |
能否说清“坐标轴是什么”,通常比能否背出 lowbit 更能判断是否理解了树状数组。需要更复杂的区间摘要时,再读 线段树三题;只有加法计数与前缀查询时,树状数组通常更轻巧。
7. 可复现的独立验证
验证程序在 tests/verify-fenwick-three-article.cjs,直接从本文提取三份 C++,使用 g++ -std=c++17 -O2 -Wall -Wextra -pedantic 编译,要求无警告。它不复用正文算法作为参考答案:
- 459D:直接枚举位置对,逐段统计相应数值出现次数;另枚举三值小数组并加入高值随机数据。
- 61E:小数据直接枚举所有三元组;百万长度递减数组用 BigInt 的组合数独立核验。
- 911D:对小排列真实执行每次反转,再用双循环重算逆序对;覆盖连续反转和单点区间,另测 20 万次查询。
- 三题都保留官方样例与本文手算,检查小边界、最大规模和输出格式。
在项目根目录执行:
1 | node tests/verify-fenwick-three-article.cjs |
编译产物保留在验证目录,脚本不清理缓存或构建目录。测试能增加实现的可信度,但扫描不变量、乘法计数和反转奇偶性的证明仍是算法成立的依据。