第一次做这道题的时候,我盯着“校验值”的定义看了二十分钟没动手。不是因为题目有多长,而是这个定义绕得厉害:又要选数对,又要最大化平方和,还要分段数最少。更难受的是,题目写着“ACM”,明摆着提示你二分和倍增,可真要往代码上落地,边界条件一个比一个阴。这道题就是 AcWing 109 / CH 0601 上的“天才ACM”,一道非常典型的“二分答案思路 + 倍增扩展实现”的题。今天我把完整思路、证明、两种写法和踩坑点都拆开讲一遍。
如果你刷题卡在这道题上,或者刚学完二分、倍增想找个综合题练手,这篇应该能帮你从“看着题解写代码”变成“自己会推结论”。
1. 题目到底在问什么:校验值、分段与最小段数
1.1 题意拆解:校验值不是“所有数的和”,而是“最大平方差和”
题目给了一个长度为 N 的整数数列 A,还给了一个正整数 M 和一个大整数 T。对任意一个区间/集合 S,定义一种叫“校验值”的东西,计算规则是:
从 S 中取出若干对互不重复的数,每对数的差的平方相加,要求这个总和尽可能大。最多能取 M 对,但如果 S 里的数不够 2M 个,就尽量多取。这个“最大总和”就是 S 的校验值。
举个例子。假设区间是 [5, 1, 2, 6],M = 2。
先排序得到 [1, 2, 5, 6]。最多取 2 对数,也就是 4 个数,区间正好 4 个数。怎么配?
- (1,6) 和 (2,5):差的平方和 = (6-1)² + (5-2)² = 25 + 9 = 34
- (1,5) 和 (2,6):平方和 = (5-1)² + (6-2)² = 16 + 16 = 32
- (1,6) 和 (2,5) 明显更大。
所以校验值就是 34。
而题目要做的事情是:把整个数列 A 切成若干连续段,每一段的校验值都不能超过 T,求最少能切成多少段。
这是一个典型的最优化问题。数据范围通常 N 能到 5e5 甚至更大,T 和平方和动不动超过 int,所以时间复杂度和 long long 问题都得认真对待。
1.2 一个关键的数学结论:排序后首尾配对就是最优配对
很多初学者卡在第一步:校验值到底怎么算最快?枚举所有配对方案肯定不可能,因为组合数爆炸。但有一个重要结论:把区间内所有数排序后,取最小的数和最大的数配对、次小的数和次大的数配对,这样得到的平方和最大。
证明思路不复杂,用交换论证。
假设当前有一对 (a, d) 和 (b, c),并且 a ≤ b ≤ c ≤ d。如果配对方式是 (a, c) 和 (b, d),那么校验值是 (c-a)² + (d-b)²。如果改成 (a, d) 和 (b, c),校验值是 (d-a)² + (c-b)²。作差:
(d-a)² + (c-b)² - [(c-a)² + (d-b)²]
展开化简得到 2(d-c)(b-a) ≥ 0。也就是说,把较大的数和较小的数配在一起,值只会更大或不变。所以最后的极值一定出现在“数值最小的那一端”和“数值最大的那一端”之间配对。
再进一步:如果区间长度超过 2M,也就是可选的数比需要的多,那么中间那部分“不上不下”的数根本不会被选到。因为我们要取 M 对,只关心最小的 M 个数和最大的 M 个数。中间的数既不是足够小,也不是足够大,参与配对反而是浪费。
这个结论直接决定代码怎么写:排序后,取前一半的较小值和后一半的较大值,从两端向中间配对,只算前面那段平方差和就行。
1.3 贪心拆段的正确性:合法区间是单调的
接下来是“最少分段”这一步。很多人习惯性地想 DP,但 n 到 5e5 直接劝退。这里其实可以直接贪心:每次从当前位置开始,取一段最长的合法区间作为一个段,然后从这段后面继续,段数就是最少的。
贪心为什么是正确的?因为校验值具有单调性:一个区间合法,那么它的任意子区间也一定合法。因为子区间的元素更少,可配对的组合只会变少,而且平方差和也不可能变大。
用反证法描述:假设最优解第一段是 [1, r'],而我们贪心选的第一段是最长合法前缀 [1, R],且 R > r'。由于 [1, R] 合法,我们把第一段扩展到 R,剩余部分从 R+1 开始继续分。原本从 r'+1 开始的分段方案依然可以套用到从 R+1 开始的部分,段数不可能变多。所以贪心每次取最长合法段,必然得到最小段数。
于是问题被简化成了:给定起点 l,每次找最远的 r,使得区间 [l, r] 的校验值 ≤ T。只要能高效完成这个“找最远右端点”的操作,整道题就解完了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二分与倍增的抉择:为什么这道题注定要用倍增
2.1 朴素做法:从每个起点一点点往右探,复杂度 O(n² log n)
最直观的写法是:起点 l 固定,r 从 l 开始每次加 1,每次都对 [l, r] 排序算校验值,直到不合法。这个做法在每段都很短的时候似乎还行,但构造数据让每一段只有一个元素的话,总共要对大约 n 个长度为 1 的区间排序,没问题;可怕的是每段的长度可能很大,而且段数也很多。最坏情况下,r 从 l 移动到 R 的过程中,每次移动都要重排一次整个区间,这个复杂度能到 O(n² log n)。
N 是 5e5 的时候,这种复杂度连跑完输入都悬。
2.2 二分右端点:看似可行,但为什么不是首选
既然要求最远的合法右端点,很多人的第一反应是二分。从当前起点 l 开始在 [l, n] 上二分找 r,每次 check(mid) 就对 [l, mid] 排序并计算校验值。这样做每段只需要 O(log n) 次 check,理论上似乎不错。
但问题在于:如果当前段最终长度很短,二分一开始可能尝试一个非常大的 mid,比如 n/2,这会导致对很长的区间做一次昂贵排序,然后再失败。最坏情况下,每段都只有几个元素,但每次二分都要把大区间排一遍,段数又多,整体复杂度会退化得很厉害。
再有,二分每次检查的区间是任意的,前面检查的结果很难复用到后面。比如先 check 了 [l, n/2],失败后又 check [l, n/4],两次排序几乎没有重叠利用。
所以这道题虽然很多人归到“二分”标签下,但真正实现时,倍增是更自然、更优秀的做法。
2.3 倍增的策略:步长指数增长,从近到远逼近
倍增法的思路是:不直接去想最终右端点多远,而是从一个很小的步长开始,尝试扩展 1、2、4、8... 个元素,一步步逼近上限。
具体流程是:
- 令当前合法右端点为 r,初始 r = l。
- 令试探步长 p = 1。
- 每次尝试把区间扩展到 [l, r + p]。
- 如果校验值 ≤ T,说明扩展成功,让 r = r + p,p 翻倍。
- 如果校验值 > T,说明扩展失败,让 p 减半,再尝试小一点的步长。
- 当 p 变成 0 时,r 就是当前段能到达的最远位置。
看起来很像“二分答案”,但倍增是从小到大试探,天然避免了“一开始就试大区间”的问题。比如当前段最终长度只有 3,倍增最多试到 p = 4 就会失败,然后 p 缩到 2、1,每次尝试的区间总长也就几十,根本不会去做 n/2 规模的排序。
这也是这道题最核心的思想:不是所有找边界问题都适合二分,当“check 代价和区间长度强相关”时,倍增经常是更稳的选择。
3. 核心实现:从最简代码到高效归并优化
3.1 先写一个最简实现:每次 sort 整个区间
先给一个最直接的实现,适合理解思路,也适合在数据量不大的时候跑。
cpp复制#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 500010;
int n, m;
ll T;
ll a[N], tmp[N];
ll sq(ll x) { return x * x; }
// 计算区间 [l, r] 的校验值,需要先排序
ll calc(int l, int r) {
int len = r - l + 1;
for (int i = l; i <= r; i++) tmp[i] = a[i]; // 或者用 memcpy
sort(tmp + l, tmp + r + 1);
int cnt = min(m, len / 2);
ll res = 0;
for (int i = 0; i < cnt; i++) {
res += sq(tmp[r - i] - tmp[l + i]);
}
return res;
}
int main() {
int K;
scanf("%d", &K);
while (K--) {
scanf("%d%d%lld", &n, &m, &T);
for (int i = 1; i <= n; i++) scanf("%lld", &a[i]);
int ans = 0;
int st = 1;
while (st <= n) {
int r = st;
int p = 1;
while (p) {
if (r + p <= n && calc(st, r + p) <= T) {
r += p;
p <<= 1;
} else {
p >>= 1;
}
}
ans++;
st = r + 1;
}
printf("%d\n", ans);
}
return 0;
}
这段代码非常短,逻辑也清晰。calc 函数每次对区间整体 sort,然后首尾配对计算校验值。复杂度大约 O(n log²n),常数不小,但适合拿来对照理解倍增过程。
实际提交时,如果评测机数据比较松,这种实现有时也能过,但遇到极限数据会悬。因为每一次 calc 都在做整个区间长度的排序,倍增过程中失败尝试也会产生大量重复排序。
3.2 高效实现:维护有序区间,新增段排序归并
更标准的做法是:始终维护当前已知合法区间的有序结果。每次尝试扩展时,只需要对新增的那一小段排序,然后归并到旧有序数组里。这样每次排序的长度等于步长 p,而不是整个区间长度。
整理一下状态:
- b:数组,存放当前字符合法区间排序后的结果,长度是 lenB。
- r:当前合法右端点。
- p:当前尝试步长。
当尝试扩展 [l, r+p] 时:
- 把新增区间 [r+1, r+p] 复制出来排序,得到有序数组 add。
- 把 b 和 add 归并成一个新的有序数组。
- 计算新数组的校验值。
- 如果合法,就把新数组作为新的 b,更新 r,并让 p 翻倍。
- 如果不合法,就丢掉新数组,p 减半,继续试。
注意,b 始终是“当前已知合法区间”的有序版本。尝试失败时,r 没有动,所以 b 也不需要回滚。这个设计非常巧妙。
下面给出可用的高效实现:
cpp复制#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 500010;
int n, m;
ll T;
ll a[N];
ll b[N], add[N], merged[N];
ll sq(ll x) { return x * x; }
ll calcFromSorted(ll arr[], int len) {
int cnt = min(m, len / 2);
ll res = 0;
for (int i = 0; i < cnt; i++) {
res += sq(arr[len - 1 - i] - arr[i]);
}
return res;
}
int main() {
int K;
scanf("%d", &K);
while (K--) {
scanf("%d%d%lld", &n, &m, &T);
for (int i = 1; i <= n; i++) scanf("%lld", &a[i]);
int ans = 0;
int st = 1;
while (st <= n) {
// 初始化:已知合法区间 [st, st],有序数组 b 里只有一个元素
int r = st;
int lenB = 1;
b[0] = a[st];
int p = 1;
while (p) {
// 如果当前步长已经超出数组结尾,只能缩小步长
if (r + p > n) {
p >>= 1;
continue;
}
// 1. 排序新增区间 [r+1, r+p]
int lenAdd = p;
for (int i = 0; i < lenAdd; i++) {
add[i] = a[r + 1 + i];
}
sort(add, add + lenAdd);
// 2. 归并 b 和 add 到 merged
int i = 0, j = 0, k = 0;
while (i < lenB && j < lenAdd) {
if (b[i] < add[j]) merged[k++] = b[i++];
else merged[k++] = add[j++];
}
while (i < lenB) merged[k++] = b[i++];
while (j < lenAdd) merged[k++] = add[j++];
// 3. 计算校验值
if (calcFromSorted(merged, k) <= T) {
// 扩展成功,更新 b 和 r
lenB = k;
for (int t = 0; t < k; t++) b[t] = merged[t];
r += p;
p <<= 1;
} else {
// 扩展失败,p 减半,重新尝试
p >>= 1;
}
}
ans++;
st = r + 1;
}
printf("%d\n", ans);
}
return 0;
}
这里的核心优化就是把“每次对整个 [l, r+p] 排序”改成了“每次只对新增的 p 个元素排序,然后线性归并”。时间复杂度从 O(n log²n) 降到了 O(n log n)。对于 N = 5e5 的极限数据,这个优化是质的差别。
3.3 校验值计算的 3 个常见边界条件
第一,cnt = min(m, len / 2)。很多初学者直接写成 cnt = m,一旦区间长度小于 2M,就会越界访问或者算错。题目说的“不足则取尽量多对”就是这个意思。
第二,当 len 是奇数时,中间那个数不会参与配对。比如排序后的数组是 [1,2,3,10,11],M=2,只能取 4 个数,中间那个 3 会被跳过。配对应该是 (1,11) 和 (2,10),3 根本不用。所以计算时从两端向中间取数,跳过中间元素是自然发生的。
第三,校验值可能非常大。差值最大可以到 1e9 级别,平方以后就是 1e18,必须用 long long。T 也要读成 long long,否则读入阶段就可能溢出。
另外一个容易被忽视的点:如果 r + p > n,不能直接退出倍增循环,而是要把 p 缩小继续试。因为 p 可能一开始很大,比如 n=5,p=1,扩展成功后 p=2,然后 r+p 可能变成 7 超过 n,此时 p=4 尝试失败,p=2 可能还是失败,p=1 可能成功。所以需要把 p 不断减半,直到 p=0。
4. 复杂度分析与实测表现
4.1 最简实现和归并优化的复杂度差异
两种实现虽然代码差不多,但复杂度差了一档。
| 实现方式 | 每次尝试排序的长度 | 每段尝试次数 | 总体复杂度 |
|---|---|---|---|
| 直接 sort 整个区间 | r+p - st + 1 | O(log L) | O(n log² n),常数大 |
| 新增区间排序 + 归并 | p | O(log L) | O(n log n) |
归并优化的关键在于:排序的部分只针对新增段,而不是整个已知合法区间。倍增过程中 p 是不断翻倍或减半的,累计起来每个元素被排序的次数是对数级别,而归并操作又是线性的。所以总复杂度接近 O(n log n)。
当然,这里有个细节:失败尝试时,我们仍然会对新增区间排序。比如 p=8 失败,排序了 8 个元素;p=4 又可能失败,再排序 4 个元素。但因为 p 是按 2 的幂变化的,这些重复排序的总量依然是 O(n log n),不会变成 O(n²)。
4.2 实测中的表现:sort 也能过?关键看评测机与数据
我在本地用小数据对拍过很多次,最简实现和归并实现的结果完全一致。但在大数据上差别明显。
构造一个 N=5e5,每段长度差不多 1e5 的数据,直接 sort 整个区间的实现会在 calc 里反复排序长区间,耗时明显偏高。归并优化版本则平稳得多。
有些 OJ 的评测机比较快,或者数据不是极限构造,最简 sort 也能 AC。但竞赛不是赌评测机,我认为还是写归并优化版更稳妥。毕竟从最简版到优化版,只是多了一个归并函数,代码量增加不大。
另外提一个工程小技巧:使用静态数组而不是 vector,避免频繁扩容带来的性能损失。上面代码里 b、add、merged 都是全局数组,简单直接。如果换成 vector,在每一段开始时 b.clear(); b.push_back(a[st]); 也能用,但注意 clear 不会释放容量,反复使用多了以后内存占用会比较大。
4.3 如何验证代码的正确性
这种算法题,最容易犯的错误是倍增边界条件写错。我建议写一个暴力对拍程序:
- 小数据范围:n ≤ 8,m ≤ 3,a[i] 在 0~10 之间,T 在 0~50 之间。
- 暴力做法:枚举所有可能的划分点,用 DFS 枚举每个段的右边界,对每个段排序计算校验值,取最小段数。
- 随机生成若干组数据,对比暴力结果和倍增结果。
如果对比 1000 组没有差异,基本可以放心提交。对拍脚本很简单,网上也有模板,这里就不展开代码了。
一个常见的错误样例:当区间是 [1, 2, 3],m=2,T=1 时,校验值 = (3-1)² + (2-2)? 不对,2 是中间数,实际只能取 1 对,校验值是 4。如果代码里写成了 cnt = len / 2,那 len=3,len/2=1,正确。但如果写成了 cnt = m,就会试图取 2 对,必然出问题。这样的小样例很适合做单元测试。
5. 相关题目与扩展思考
5.1 变体:如果校验值定义不同,算法还成立吗
这道题里,校验值需要先排序才能高效计算。如果换一个统计量,比如区间异或和、区间按位或、区间最大值与最小值之差,只要它满足“区间合法则子区间合法”的单调性,贪心拆段的想法都成立。
但倍增过程能不能用,取决于 check 函数能不能在扩展时快速维护。比如区间异或和可以用前缀和 O(1) 算,那直接二分右端点可能更好;如果 check 需要排序,那就得用归并优化来降低排序代价。理解这层关系,比背这道题的模板更有价值。
5.2 倍增思想在其他算法题中的应用
“从某个起点开始,用指数步长逼近最远位置”这个套路,还广泛出现在以下场景:
- LCA 最近公共祖先:预处理 up[k][v] 表示 v 往上跳 2^k 步的祖先,查询时按二进制位拼凑。
- 区间最值查询 ST 表:用长度为 2^k 的区间覆盖查询区间。
- 字符串哈希配合二分/倍增找最长公共前缀。
- 一些“跳石头”“跳台阶”类问题,也可以用倍增将朴素跳跃优化到 O(log n)。
回到“天才ACM”这道题本身,我觉得它最妙的地方是把二分和倍增结合到了一起:思想上用二分答案来理解“最长合法前缀”,实现上却用倍增来避免大区间排序的浪费。刷一遍这道题,相当于把贪心、排序、归并、倍增、复杂度分析全复习了一遍。如果你能把最简版改成归并优化版,并且说清楚为什么每段只尝试 O(log n) 次,这道题才算真正吃透了。
