news 2026/10/17 13:25:16

魔法阵题解:四元组计数如何用前后缀和优化到O(m²/9)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
魔法阵题解:四元组计数如何用前后缀和优化到O(m²/9)

如果你去翻 NOIP 2016 普及组第四题《魔法阵》的讨论区,一定会看到这样的话:“我四层 for 都写出来了,为什么还是 0 分?”我第一次做这题时也卡了很久。题目要求统计 n 个魔法物品分别作为 A、B、C、D 的次数,看起来就是最朴素的四元组计数,但 n 到了几万量级,四层循环根本跑不动。更折磨人的是,这题不是光会枚举就能过,它需要你先看懂题面里三个条件的数学含义,然后才能想到用前缀和、后缀和去优化。

这篇文章就围绕魔法阵这道题的“难点解决”来写:先从题目条件讲起,解释为什么四重循环必炸;再把 A/B 和 C/D 拆成两个二元组,推导出 O(m²/9) 的做法;最后附上完整 C++ 代码和我实际提交时踩过的坑。无论你是准备 NOIP、CSP-J 还是单纯想练组合计数,这篇都能直接照着复现,省去你自己绕弯路。

1. 魔法阵最劝退的地方:题目读完,四层循环根本不敢写

1.1 原题到底要我们数什么

先把题意捋顺。有一堆魔法物品,每个物品有一个魔法值 X。要找一个四元组,四个物品分别叫 A、B、C、D。四元组必须满足:

  • Xa < Xb < Xc < Xd;
  • Xb - Xa = 2 * (Xd - Xc);
  • Xb - Xa < (Xc - Xb) / 3。

注意输出要求很特殊:不是让你输出“总共有多少个魔法阵”,而是让你输出每个物品分别作为 A、B、C、D 出现了多少次。也就是说,如果有多个魔法阵都包含同一个物品,这个物品对应角色的计数都要累加。

这导致一个直观想法会立刻浮上来:枚举 A、B、C、D 四个下标,检查条件,然后给四个物品的对应答案加 1。这个思路在数学上完全正确,但实际样例一上,就等着超时吧。

1.2 把三个条件画到数轴上

做组合计数题,第一步永远是先看条件能不能化简。原题三个条件很繁琐,但放到数轴上看就清楚多了。

设:

t = Xd - Xc

因为 Xa < Xb < Xc < Xd,所以 t 是正整数。于是第二个条件变成:

Xb - Xa = 2t

第三个条件:

2t < (Xc - Xb) / 3

两边乘 3,得到:

6t < Xc - Xb

因为魔法值都是整数,所以更准确的写法是:

Xc >= Xb + 6t + 1

把这几个式子组合起来,数轴上的距离关系就非常清晰了:

  • A 到 B 的距离是 2t;
  • B 到 C 的距离至少是 6t+1;
  • C 到 D 的距离是 t。

也就是说,一旦固定了 t,整个四元组在值域上的分布就固定下来了。A 和 B 距离近,C 和 D 距离近,但 B 和 C 之间必须隔开很大一段。

1.3 复杂度第一关:四边形枚举必炸

如果你直接四层循环枚举四个下标,即使排序后加上剪枝,复杂度也是 O(n^4)。n 到几百就够呛,更别说题目数据是几万级别,O(n^4) 跑完需要的时间可以用“天”来计算。

稍微聪明一点的做法是枚举 A 和 B,然后根据条件确定 C 和 D 的范围,再用循环累加。这大概是把 O(n^4) 降到了 O(n^3) 左右,仍然过不了大样例。因为题目想逼你做的事,不是少一层循环,而是换一种计数方式:不要按“四个点”去枚举,要按“差值 t”去枚举。

这也正是魔法阵这道题被放在普及组压轴位置的原因:它考的不是某个高级数据结构,而是你能不能从一堆不等式里抽出一个可优化的结构。

2. 断掉四元组:把 A/B 和 C/D 看成两个绑定的二元组

2.1 设 t = Xd - Xc 之后,数轴长这样

刚才已经推出:

  • Xb = Xa + 2t;
  • Xc >= Xb + 6t + 1;
  • Xd = Xc + t。

在数轴上画一下,大概是:

A — 2t — B — 至少 6t+1 — C — t — D

这个结构非常有意思:A 和 B 绑在一起,C 和 D 绑在一起,中间隔着一大段空白。也就是说,对于固定的 t,如果我们已经确定了一对合法的 A/B,那么任何一对满足 Xc > Xb + 6t 的 C/D 都能拼成一个魔法阵。

反过来说,如果我们先确定了一对合法的 C/D,那么任何一对满足 Xb <= Xc - 6t - 1 的 A/B 也都能拼成一个魔法阵。

A/B 和 C/D 之间的选择,只有“B 的值和 C 的值之间距离足够大”这一个约束,中间没有其它交叉条件了。这就是拆开的关键。

2.2 为什么固定 t 后 A/B 与 C/D 互不干扰

很多初学者会卡在这里:A/B 和 C/D 看起来明明是互相影响的,怎么说独立就独立?

其实没有完全独立,它们还受一个条件约束:B 与 C 的差值必须大于 6t。只不过这个条件只取决于 B 和 C 的值,和具体选哪个 A、哪个 D 没有关系。

换句话讲:

  • 对于一对 A/B,只要知道它的 B 值,就能确定“哪些 C 能配”;
  • 对于一对 C/D,只要知道它的 C 值,就能确定“哪些 B 能配”。

所以我们可以分别统计所有 A/B 组合和所有 C/D 组合,然后按 B 和 C 之间的位置关系做乘法。

用 t 来表示差值,A/B 的组合长这样:

AB[b] = cnt[b - 2t] * cnt[b]

意思是,以 b 作为 B 值时,A 必须是 b - 2t,那么 a 值有 cnt[b-2t] 种选法,b 值有 cnt[b] 种选法,这一对 A/B 就有 cnt[b-2t] * cnt[b] 种组合。

C/D 的组合长这样:

CD[c] = cnt[c] * cnt[c + t]

以 c 作为 C 值时,D 必须是 c + t,同样用乘法。

现在的问题就变成了:把 AB[b] 和 CD[c] 这两串值算出来,然后按条件“B 和 C 之间至少隔了 6t+1”配对计数。

2.3 先写出不优化的朴素公式

如果不优化,我们可以枚举每一个 B 值,然后内层枚举每一个合法的 C 值。伪代码如下:

for (int t = 1; t <= m; ++t) { for (int b = ...; b <= ...; ++b) { for (int c = b + 6 * t + 1; c <= m - t; ++c) { // AB组合数 * CD组合数,累加到答案上 } } }

这样还是 O(m^3) 量级,因为外层 t、中层 b、内层 c 都是值域大小级别的循环。但观察一下内层这个循环:当 b 固定之后,c 的合法范围是 b+6t+1 到 m-t,我们要算的是这一段里所有 CD[c] 的和。连续一段的和,不就是前缀和或后缀和最擅长的东西吗?

所以下一步很自然:用后缀和把所有 CD 求出来,用前缀和把所有 AB 求出来,两个线性扫描一拼,整个算法就变成了两重循环。

3. 让朴素公式跑起来:前后缀和 O(m²/9) 推导

3.1 后缀和:一口气算出所有 C/D 组合

对于固定 t,我们先预处理一个数组 suf:

suf[x] = cnt[x] * cnt[x+t] + cnt[x+1] * cnt[x+1+t] + ... 一直加到 x+t 不超过 m

也就是从 x 开始,所有合法 C/D 组合数的后缀和。

计算方式很简单,从后往前扫:

for (int x = m - t; x >= 1; --x) { suf[x] = suf[x + 1] + 1LL * cnt[x] * cnt[x + t]; }

为什么只扫到 m - t?因为如果 x + t > m,D 值超出上限,不可能存在合法的 C/D 组合。

有了 suf 之后,假设 B 的值为 b,那么合法 C 的范围是:

c >= b + 6t + 1

同时,c 最大只能是 m - t。这一段里所有 C/D 组合数就是:

suf[b + 6t + 1]

这是一个 O(1) 的查询。

3.2 前缀和:一口气算出所有 A/B 组合

同样的思想,我们再预处理一个前缀和数组 pref:

pref[x] = AB[1] + AB[2] + ... + AB[x]

其中 AB[b] = cnt[b - 2t] * cnt[b],表示以 b 为 B 值的 A/B 组合数。

计算方式:

for (int b = 1; b <= m; ++b) { pref[b] = pref[b - 1]; if (b >= 2 * t + 1) { pref[b] += 1LL * cnt[b - 2 * t] * cnt[b]; } }

注意,b 至少要 2t+1,否则 b - 2t 小于 1,没有合法的 A 值。

对于某个 C 值 c,合法的 B 需要满足:

b <= c - 6t - 1

那么所有能配上这个 C 的 A/B 组合数就是:

pref[c - 6t - 1]

同样是一个 O(1) 查询。

3.3 两条扫描线合流,累加四个角色

现在把两个式子合起来。

先看 B 对 A、B 自身的贡献。

固定 t,对于一个 B 值 b:

  • A 的值是 a = b - 2t;
  • 所有合法 C/D 组合数是 suf[b + 6t + 1];
  • 每个 A 物品可以搭配 cnt[b] 个 B 物品,所以对 A 的贡献是 cnt[b] * suf[b + 6t + 1];
  • 每个 B 物品可以搭配 cnt[a] 个 A 物品,所以对 B 的贡献是 cnt[a] * suf[b + 6t + 1]。

再固定 C 值 c:

  • D 的值是 d = c + t;
  • 所有合法 A/B 组合数是 pref[c - 6t - 1];
  • 每个 C 物品可以搭配 cnt[d] 个 D 物品,所以对 C 的贡献是 cnt[d] * pref[c - 6t - 1];
  • 每个 D 物品可以搭配 cnt[c] 个 C 物品,所以对 D 的贡献是 cnt[c] * pref[c - 6t - 1]。

这里有个非常容易想错的地方:题目要输出的是“每个物品作为某个角色的次数”,所以我累加的都是“单个物品”的次数,没有额外乘当前角色自己的 cnt。如果你把总的角色出现次数累到一个数上,输出前再除以对应角色的 cnt,理论也行,但很容易因为整除和边界搞错。直接按单个物品累加,输出时照抄就行。

3.4 可直接提交的 C++ 代码

完整代码如下,我加了一些注释,重点标注边界条件。

#include <bits/stdc++.h> using namespace std; const int MAXN = 40005; int n, m; int val[MAXN], cnt[MAXN]; long long ansA[MAXN], ansB[MAXN], ansC[MAXN], ansD[MAXN]; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m; for (int i = 1; i <= n; ++i) { cin >> val[i]; ++cnt[val[i]]; } // t 表示 Xd - Xc,也就是 C 到 D 的距离。 // 极限情况下,A 可以取 1,于是 D 的最小值是 9t + 2,所以 9t + 2 <= m。 for (int t = 1; 9 * t + 2 <= m; ++t) { // suf[x]:C >= x 且 D = C + t 的所有 (C,D) 组合数 vector<long long> suf(m + 3, 0); for (int x = m - t; x >= 1; --x) { suf[x] = suf[x + 1] + 1LL * cnt[x] * cnt[x + t]; } // 通过 B 的取值,累加 A 和 B 的贡献 // b 至少是 2t+1,至多让 b + 7t + 1 <= m for (int b = 2 * t + 1; b + 7 * t + 1 <= m; ++b) { int a = b - 2 * t; long long wayCD = suf[b + 6 * t + 1]; if (wayCD == 0) continue; // 每个 A 物品可以配 cnt[b] 个 B 物品 ansA[a] += 1LL * cnt[b] * wayCD; // 每个 B 物品可以配 cnt[a] 个 A 物品 ansB[b] += 1LL * cnt[a] * wayCD; } // pref[x]:B <= x 的所有 (A,B) 组合数 vector<long long> pref(m + 3, 0); for (int b = 1; b <= m; ++b) { pref[b] = pref[b - 1]; if (b >= 2 * t + 1) { pref[b] += 1LL * cnt[b - 2 * t] * cnt[b]; } } // 通过 C 的取值,累加 C 和 D 的贡献 // c 至少是 8t+2,这样才能让 B 有最小值 2t+1 for (int c = 8 * t + 2; c + t <= m; ++c) { long long wayAB = pref[c - 6 * t - 1]; if (wayAB == 0) continue; // 每个 C 物品可以配 cnt[c+t] 个 D 物品 ansC[c] += 1LL * cnt[c + t] * wayAB; // 每个 D 物品可以配 cnt[c] 个 C 物品 ansD[c + t] += 1LL * cnt[c] * wayAB; } } for (int i = 1; i <= n; ++i) { cout << ansA[val[i]] << ' ' << ansB[val[i]] << ' ' << ansC[val[i]] << ' ' << ansD[val[i]] << '\n'; } return 0; }

这个做法的复杂度是 O(m²/9),m 表示魔法值的最大值,而不是物品个数。外层 t 最多到 m/9,内层两个循环都是 O(m),所以总时间大概是 m² / 9 量级。对于 m 在几万以内的数据,完全够用。

4. 从 WA 到 AC:我踩过的三个坑

4.1 外层枚举上界写错,样例过了大数据死

我第一次写这段代码时,外层循环随手写成了:

for (int t = 1; t <= m; ++t)

然后大数据直接超时,小样例还看起来没问题。后来冷静算了一下才发现,t 根本不可能取到 m,因为一个魔法阵里 A 到 D 的最小距离是 9t+1。

为什么是 9t+1?看数轴:

  • A 到 B 是 2t;
  • B 到 C 最小是 6t+1;
  • C 到 D 是 t。

所以 A 到 D 最小距离是 2t + (6t+1) + t = 9t+1。这意味着如果 A 取最小值 1,D 至少是 9t+2。于是 t 必须满足:

9t + 2 <= m

也就是:

t <= (m - 2) / 9

如果 m 是几千,写成 t <= m 问题不大,就是额外空转很多次;如果 m 是几万,每多一倍的 t,内层就要多扫一整轮,超时就很正常了。

这个上界不是玄学,是从不等式一步推出来的,写代码前应该先算清楚。

4.2 同值魔法物品的乘法,乘多乘少都不对

题目里的魔法值可能有重复,比如两个物品的魔法值都是 5。它们在同一个魔法阵里不可能同时出现,因为 Xa < Xb < Xc < Xd 要求四个位置的值严格递增,同值物品不能占两个角色。

但同值物品会导致计数的乘法关系。假设 cnt[3] = 2,表示魔法值等于 3 的物品有两个。当某个 B 值 b 需要配 A 值 3 时,每个 B 物品都有 cnt[3] = 2 种 A 物品可选。

在代码里,我累加 A 的贡献时用的是:

ansA[a] += cnt[b] * wayCD;

这里没有乘 cnt[a],因为 ansA 存的是“单个魔法值为 a 的物品”的次数,而不是“所有魔法值为 a 的物品”的总次数。

有同学喜欢把 ansA[a] 存成总值,写成:

ansA[a] += cnt[a] * cnt[b] * wayCD;

然后输出时再除以 cnt[a]。这种做法不是不行,但两个问题:

  • 如果 cnt[a] = 0,除法会出现除零异常;
  • 如果中间过程中某个 cnt[a] 没有完全整除,最后输出会差 1 或 2,非常难排查。

所以我的建议是:从一开始就明确“数组存的是单物品次数”,后面的所有加法都按单物品的角度来写,输出时完全不用处理倍数。

4.3 int 溢出、下标越界和输出顺序

第三个坑是基本功,但很致命。

魔法阵的总数量在 n 很大时会非常大。n 是几万量级,所有可能的四元组数量是组合数级别,早已超过 int 范围,所以 ansA、ansB、ansC、ansD 必须开 long long。中间乘法的 cnt[b] * wayCD 也要小心,强烈建议写成 1LL * cnt[b] * wayCD,防止编译器先用 int 乘完再转 long long。

下标越界也容易出现在 suf 和 pref 的访问上。比如 b + 6t + 1 可能超过 m,这时候如果数组开小了,程序直接崩;如果碰巧没崩,读到的也是一个脏数据。稳妥做法是数组开到 m + 3,并且在循环条件里提前卡好边界。我代码里 b 的循环上界写的是 b + 7t + 1 <= m,正是为了保证访问 suf[b + 6t + 1] 时下标不会越界。

最后是输出顺序。题目要求每行四个数,分别是对应物品作为 A、B、C、D 的次数。这个顺序一旦搞反,全 WA。我自己对拍时经常栽在这一步,后来写了一个小暴力程序随机生成数据,才确认不是算法错了,而是输出顺序反了。

5. 这类“枚举差值+区间计数”的套路还能用在哪

5.1 四元组计数问题的通用拆法

魔法阵的核心思路,可以总结成一套方法论。

遇到四元组计数题,不要急着四层循环,先看条件能不能拆成“两个二元组”。如果四个元素之间存在两个独立的小结构,中间只靠一个“大小位置关系”连接,那大概率可以用前缀和或后缀和优化。

固定一个差值 t,把 A/B 和 C/D 分别看成两个整体:

  • A/B 二元组只和 B 的值有关;
  • C/D 二元组只和 C 的值有关;
  • 拼合条件只剩“B 和 C 之间距离足够大”这一个不等式。

这时候所有四元组数量就可以拆成:

四元组数量 = 每个合法 A/B 组合数 × 对应范围内所有 C/D 组合数

或者反过来:

四元组数量 = 每个合法 C/D 组合数 × 对应范围内所有 A/B 组合数

分别用前缀和、后缀和预处理,复杂度直接从 O(n^4) 跳到 O(m²) 甚至更低。

5.2 从魔法阵到其它竞赛题的迁移

这个套路在很多题里都能看到。比如一些组合计数的题目会要求统计满足多个不等式的四元组,或者统计满足差值关系的二元组配对,核心都是“拆二元组 + 区间统计”。

备赛的时候,可以把魔法阵和 NOIP 2016 提高组的《愤怒的小鸟》拿出来一起练。魔法阵考的是数学建模和前缀和优化,愤怒的小鸟考的是状态压缩 DP,两题都对“枚举方式的选择”非常讲究,能帮你建立起“先分析条件,再选算法”的做题习惯。

另外,这类题还特别适合练对拍。写一个 O(n^4) 的暴力版,限制 n <= 10,然后随机生成小数据,和自己的优化版跑同一组输入,比对输出。对拍能查出的问题往往是你自己盯半天都看不出来的边界 bug。

5.3 备赛时怎么练这道题

如果你想真正吃透魔法阵,建议按这个顺序练:

  • 第一天:不看题解,先把题目条件抄一遍,自己在纸上推出 A/B/C/D 之间的距离关系;
  • 第二天:尝试写 O(n^3) 版本,确认时间复杂度不对,但用来验证小数据;
  • 第三天:按本文思路写 O(m²/9) 版本,重点体会前缀和和后缀和的构造过程;
  • 第四天:关上题解,重新默写一遍完整代码,包括所有边界条件。

这道题的价值不在于代码本身,而在于那个“从枚举四个点,到枚举一个差值,再到两个区间统计”的思维转变。想通这一步之后,再遇到表面上很复杂的组合计数题,你就不会再怕了。

我当时踩完那几个坑之后,最大的体会是:竞赛题里最难的部分往往不是算法,而是把题目条件翻译成一个能动手优化的数学结构。魔法阵就是最好的例子。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 13:25:07

Java实现的可审计iOS签名服务系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 7:10:09

手写C-语言词法语法分析器:可调试可验证的编译原理实战项目

简介&#xff1a;本资源是面向计算机专业本科生及编译原理初学者的课程设计实践项目&#xff0c;聚焦C-语言&#xff08;C语言子集&#xff09;的词法与语法分析器自主实现&#xff0c;帮助学习者深入理解编译前端核心机制。压缩包共22个文件&#xff0c;含7个关键结果与配置文…

作者头像 李华
网站建设 2026/10/16 10:38:39

CSP初赛高效提分:程序设计知识原子化训练法

简介&#xff1a;本资源是专为CSP-J/S初赛&#xff08;原NOIP普及组/提高组初赛&#xff09;考生打造的程序设计基础知识精讲课件&#xff0c;面向信息学奥赛入门至进阶学习者&#xff0c;系统覆盖算法与数据结构核心考点。内容紧扣初赛大纲&#xff0c;深入解析算法定义、时间…

作者头像 李华
网站建设 2026/10/15 2:57:45

态势感知的本质:从数据采集到业务决策的认知跃迁

1. “态势感知”这个词&#xff0c;从第一天起就被我们念歪了“态势感知”这四个字&#xff0c;几乎贴满了所有安全厂商的PPT封面、白皮书首页和招标文件的技术要求章节。某次参与某高校实验室的网络安全能力评估项目时&#xff0c;我翻到一份刚提交的《校园网态势感知平台建设…

作者头像 李华
网站建设 2026/10/15 0:14:15

Windows电脑卡顿精准排查与四步优化指南

1. 这不是“重启大法”能解决的问题&#xff1a;当电脑卡顿成为日常&#xff0c;我们到底在对抗什么“电脑卡怎么办&#xff1f;”——这七个字背后&#xff0c;藏着数千万普通用户每天重复点击鼠标、盯着转圈光标时的焦灼。它不像蓝屏那样刺眼&#xff0c;也不像死机那样彻底&…

作者头像 李华
网站建设 2026/10/16 18:26:40

多语言混写调用链漏洞:AI编程时代的隐性陷阱与防御

1. 多语言混写为什么成了调用链漏洞的温床这两年AI编程助手普及之后&#xff0c;我观察到一个很明显的现象&#xff1a;一个项目里同时出现 Python、JavaScript、Go、Rust、Shell 甚至 SQL 的概率&#xff0c;比五年前高了不止一个量级。原因不复杂——AI 补全太顺手了&#xf…

作者头像 李华