
这几天陆续有学生拿着GESP五级真题来问我《相等序列》这道题到底怎么想。题目通常不长给你一个整数序列每次可以把相邻两个数相加合并成一个数问最少合并几次能让最终序列里的所有数字完全相等。我第一次见到它的时候也觉得奇怪数组又不让排序怎么还能把所有数变相等但只要把题意转化成“分段”这道五级题立刻变成了一套非常漂亮的枚举加贪心模板也很能体现GESP五级对数组、前缀和、整数运算和边界条件的综合要求。这篇文章就按我课堂上给学生讲的顺序从推导到代码再到哪些雷区绝对不能踩完整过一遍适合正在备考GESP五级、或者想带学生刷这类题目的老师参考。1. 先把“相等序列”翻译成数学题三个结论决定整道题的走向1.1 一次合并到底改变了什么从“数字”到“分段”相邻两个数相加合并这个操作最容易被忽略的性质是它完全不改变元素之间的相对顺序。你可能把第2个和第3个先合并也可能把第4个和第5个合并但无论合并顺序怎么变最终序列里的每一个数字都严格对应原数组里的一段连续子数组。这一点想通了整道题就变成一个关于“分段”的问题如果最终有若干个相等的数那就等于说原数组被切成了若干个连续段每一段的和都等于同一个值。很多人第一反应是求平均数、找最小公倍数甚至想排序全是因为没看穿“连续”这两个字。这道题里你不能跳着选元素不能交换位置所有操作都被限制在相邻关系里。所以真正要回答的问题是能不能把数组切成连续段使每段和相等能切几段一旦视角切换到这里后面的一切都顺了。1.2 N减块数为什么求最少次数要先求最多段数数组一开始有N个数每次合并会让数字数量减少1。假设合并了m次最终序列的长度就是N减m。如果最终有k个相等的数字那么N减m等于k反过来m就等于N减k。所以要找“最少合并次数”等价于找“最多能分成多少段并且每段和相等”。这个转换是整个解法的灵魂。拿一个简单例子感受一下[1,1,1,1]不需要任何合并因为它已经全相等了k等于4答案是0而[1,1,2]可以合并成[2,2]k等于2答案是1[1,2]只能合并成[3]k等于1答案也是1。你看一旦你把注意力从“怎么合并”转移到“最多几段”上操作次数就是一个简单的减法。否则直接去模拟合并过程面对N个数和无数种合并顺序根本没法下手。1.3 target只会出现在前缀和里枚举范围确定假设最终每一段的和是target。第一个段一定包含a[0]所以第一个段的和一定是从a[0]开始累加到某个位置的前缀和。换句话说target这个值根本不需要在整数范围里瞎猜它一定等于某个前缀和pre[i]。于是解法框架就出来了枚举每个前缀和当作target然后从前往后尝试切割判断这个target能不能把整个数组完整地切成若干段。能切完就记录段数最后在所有可行的target里取段数最大的那个。为什么枚举前缀和就够因为如果某个target真的可行那么a[0]所在的第一段的和就是这个target它自然等于某个前缀和而所有前缀和我们都枚举到了一个都不会漏。这个推导是整个算法的骨架代码反而是次要的。2. 完整实现C满分代码和Python对照写法2.1 splitCount怎么写核心判断只有几行对一个指定的target能不能把数组切成每段和都是target的连续块用贪心从前往后扫就行。维护当前这段的累加和cur逐个元素加进去cur刚好等于target说明这一段凑满了清零、段数加一cur大于target说明这段已经超了在数组元素非负的前提下后面再加只会更大直接判定失败cur小于target就继续往后累加。这里有一个关键前提这类分段题在GESP五级里通常都会说明数组元素是非负整数。如果题目没有这个保证cur大于target时不能直接剪枝因为后面可能出现负数把和拉回来解法会完全不同。后面我会专门讲这个坑。先看核心函数#include bits/stdc.h using namespace std; using ll long long; int n; vectorll a; int splitCount(ll target) { ll cur 0; int cnt 0; for (int i 0; i n; i) { cur a[i]; if (cur target) { cur 0; cnt; } else if (cur target) { return -1; } } return cur 0 ? cnt : -1; }注意最后一行扫完数组后必须检查cur是不是已经归零。如果最后一段刚好凑满target那么它在循环里就已经清零了如果cur还残留一个不是0的值说明最后一段没凑满这次尝试同样失败。这个判断漏掉的话程序会在很多边界数据上悄悄出错。2.2 主流程细节枚举候选、去重、统计与输出主函数里先把所有前缀和收集起来作为target候选。收集完之后排序去重这一步不是算法必需的但能避免重复尝试同一个目标值。比如数组里出现重复前缀和时同一个target会被试很多次白白浪费时间。去重之后逐个调用splitCount拿返回的段数更新最大值。所有候选都试完之后如果最大段数仍然是0说明没有任何一种target能把数组完整切分输出-1否则输出n减最大段数也就是最少合并次数。这里提醒一句不要一找到能切的target就break退出循环。虽然排序后target是从小到大枚举的在非负数组下通常target越小段数越多但为了稳妥初学者最好老老实实遍历全部候选用max持续更新。int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin n; a.resize(n); for (int i 0; i n; i) cin a[i]; vectorll candidates; ll sum 0; for (int i 0; i n; i) { sum a[i]; candidates.push_back(sum); } sort(candidates.begin(), candidates.end()); candidates.erase(unique(candidates.begin(), candidates.end()), candidates.end()); int maxBlocks 0; for (ll target : candidates) { int k splitCount(target); if (k 0) maxBlocks max(maxBlocks, k); } if (maxBlocks 0) cout -1 \n; else cout n - maxBlocks \n; return 0; }这段代码可以直接跑。C的sort和unique组合是去重最省事的写法想起unique之前必须先排序否则它只能去掉相邻重复项去不干净。这个细节很多刚学STL的同学吃过亏。2.3 Python版本大整数与边界处理Python写起来更短而且int类型天然支持大数前缀和累加到多大都不会溢出这是Python选手省心的地方。不过Python的常数比C大N到几千时还好真给到一万以上就要注意剪枝。输入用sys.stdin.buffer.read()一次性读进来比逐行input()快很多。import sys def split_count(target, a): cur 0 cnt 0 for v in a: cur v if cur target: cur 0 cnt 1 elif cur target: return -1 return cnt if cur 0 else -1 def main(): data list(map(int, sys.stdin.buffer.read().split())) n data[0] a data[1:1 n] candidates [] s 0 for v in a: s v candidates.append(s) candidates sorted(set(candidates)) max_blocks 0 for target in candidates: k split_count(target, a) if k 0: max_blocks max(max_blocks, k) print(-1 if max_blocks 0 else n - max_blocks) if __name__ __main__: main()Python里sorted(set(candidates))一步完成去重和排序比手动循环简洁。split_count的逻辑和C完全一致唯一的区别是Python不需要担心long long。2.4 复杂度与剪枝去重和整除判断帮你多拿几个隐藏分基础写法的时间复杂度是O(N^2)候选target最多N个每个target从前往后扫描一遍又是O(N)。N是几千的时候完全没问题但如果你发现测试数据给到10万就要考虑优化。最简单的优化是去掉重复候选前面已经做了。第二个优化是整除判断如果target可行那么数组总和total一定等于target乘以段数也就是说target必须是total的约数。在total不为0时可以先用total % target 0过滤掉大半无效候选。target等于0的情况单独看非负数组里只有全零数组能成功。这两个剪枝叠加之后实际扫描次数会大幅下降。再往深走还可以用哈希表记录每个前缀和的位置对每个target用“找下一段位置”的方式跳跃式推进但那是进阶内容五级考试用不上学有余力再研究。3. 考场最容易踩的4个坑每个都能让满分程序变零分3.1 target等于0怎么办不是无解可能是最优解我在实际判卷和带学生的过程中见过太多次有人看到前缀和是0就直接跳过觉得“段和怎么可能为0”。这个直觉在非负数组里害人不浅。全零数组的最优方案就是0次操作整个数组本身就全相等答案应该是0而不是-1。即便不是全零数组target等于0这个候选也不能无脑跳过。比如测试数据里塞一个0进去splitCount会把它当作一段和为0的独立段切掉后面的非零数字如果凑不成0函数自然会失败。所以正确做法是不特殊处理target0把它和其他候选一样交给splitCount判断。最后返回cur是否归零自然能得到正确结果。3.2 long long不能省前缀和会悄悄溢出这道题的坑经常藏在数据范围里。假设每个a[i]最大是10的9次方N是10万总和就能到10的14次方int根本存不下。GESP五级不会专门考溢出但它会在数据范围里给你埋雷。C选手务必把所有和累加相关的变量都用long long包括cur、前缀和sum、target。养成这个习惯之后以后遇到任何涉及累加的题目都能少返工。Python选手不用管溢出但要记住如果题目允许元素是负数那splitCount里的cur target剪枝就不成立了。负数数据会把这个简单贪心直接击穿看到负数立刻停下来重新想。3.3 最后一段没清零也算失败return写错直接全盘崩splitCount最后一行如果写成return cnt会漏掉一种很隐蔽的失败数组扫完了cur却还没归零。这说明最后一段没有凑成target。举个例子数组[1,2,2]target等于3扫描到1加2等于3切一段剩下一个2扫完cur等于2整个数组并不能分成[3,3]。如果直接返回cnt程序会误判成成功答案就错了。所以return前必须加一句三元判断cur 0 ? cnt : -1。这个错误非常隐蔽手算的时候容易忽略但它就是这类题目最高频的Bug之一。我在给学生的代码里至少看到过十几次同款问题。3.4 一旦找到能分的target就break你会丢掉更优解写程序的时候很多人会想“既然找到一个能切分的target直接输出不就行了”。如果枚举顺序恰好让你先遇到段数最多的那个target确实可以但如果你先遇到的是段数较少的方案一break就错过了更优解。举个例子[1,1,1,1]这个数组target等于2时可以分成2段target等于1时可以分成4段。如果先试到2就break答案就是2但正确答案是0次操作因为完全不需要合并。非负数组下确实有“target越小段数越多”的规律但加上target等于0等特殊情况后初学者很难保证枚举顺序绝对正确。最稳妥的写法是遍历全部候选用max不断更新最大段数。不要贪那一点运行时间正确性永远排第一。4. 常见问题速查表和对拍技巧给代码上双保险4.1 典型错误速查表我把这道题最常见的错误整理成一张表考试前扫一眼比翻代码有用现象可能原因排查思路全零数组输出-1把target等于0直接continue跳过不要特殊跳过target0统一交给splitCount判断大数据时超时候选target没有去重排序后用sort和unique去重再枚举答案总是差1合并次数按N减k加1算了从N个数到k个数一共需要N减k次合并手算有解但输出-1前缀和候选漏了第一个位置确保从a[0]开始累加进candidates负数用例答案错误误用了cur大于target剪枝确认题面是否保证非负不保证就得换思路这些错误里最后一个最容易让人抓狂。明明样例全过一交上去就挂多半就是忽略了负数场景。看题面时把“非负整数”四个字圈出来比什么都强。4.2 五分钟对拍小数据BFS验证贪心正确性考场上时间紧张但写完后花三分钟做一个轻量对拍能发现绝大多数边界Bug。方法很简单对随机生成的小数组用BFS直接模拟真实合并过程求出真实的最小步数再和你写的贪心代码结果比较。N很小比如不超过7的时候BFS的状态空间完全可控。from collections import deque def bfs_min_ops(arr): start tuple(arr) q deque([(start, 0)]) seen {start} while q: state, step q.popleft() if len(set(state)) 1: return step for i in range(len(state) - 1): nxt state[:i] (state[i] state[i 1],) state[i 2:] if nxt not in seen: seen.add(nxt) q.append((nxt, step 1)) return -1对拍逻辑就是随机生成数组分别跑bfs_min_ops和split解法比较结果是否一致。只要跑几十组随机数据就能建立起信心。我自己用这个方法抓到过不止一次“最后一段return”的错误。这个习惯比任何静态检查都管用强烈建议写题的时候养成。5. 它可能不是你想的那道题相等序列的三种常见亲戚5.1 亲戚一单点加减型答案在中位数里如果题面变成“每次可以把任意一个数字加1或者减1问最少操作多少次能让所有数字相等”那就不再是分段问题了而是一道经典的绝对值距离最小问题。解法是排序之后取中位数答案就是每个数与中位数的差的绝对值之和。用[1,2,100]举例平均值大约是34但把所有数都变成2只需要99步变成34需要131步。为什么中位数比平均值更优因为在一维数轴上让若干个点汇合到同一个位置总路程最小的汇合点就是中位数。平均值会被极大值拉偏。代码就是排序后累加abs(a[i] - a[n/2])复杂度O(NlogN)。这个变体和合并型题面目相似内核完全不同读题时一定要分清楚。5.2 亲戚二区间整体加减型差分数组一招解决如果操作改成“每次选一个连续区间整体加1或减1”这是差分数组的经典应用。构造差分数组d[i]等于a[i]减a[i-1]一次区间加减只会让差分数组的两个位置发生变化。最终所有元素相等等价于把差分数组内部全部消成0。最少操作数等于差分数组正数和与负数绝对和中的较大值。举个例子[1,2,2,1]的差分数组内部是[1,0,-1]正负和都是1答案就是1次[2,1,1,2]的差分数组内部是[-1,0,1]答案也是1次实际操作是把中间两个数整体加1得到[2,2,2,2]。这个公式在七级、八级题里还会反复出现五级阶段能看懂最好以后学差分数组的时候可以提前建立印象。5.3 考场上怎么一秒钟分辨“是哪一种题型”分辨的关键是看操作对象和操作效果我总结成一个表操作描述元素个数变化数学模型合并相邻两个元素个数变少分段和相等前缀和加贪心单独一个元素加1或减1个数不变中位数绝对差和连续区间整体加1或减1个数不变但成片改动差分数组正负和取较大值相邻两个元素一个加一个减个数不变且总和不变通常也走差分思路把这个表在脑子里过一遍读题速度会快很多。真正的高手不是多背模板而是看到操作就知道该往哪个数学模型上靠。这篇文章里最想强调的其实不是代码本身而是那句“先问自己一次操作之后什么变了什么没变”。合并让数字个数变少所以想到N减块数单点加减没变个数所以想到中位数区间加减改的是差值所以想到差分。我在实际教学中见过太多学生一上来就写循环模拟合并过程最后把自己绕晕。如果读题时多花三十秒做这个判断后面写代码会顺很多。另外一个经验是所有贪心解法写完后至少用一组“全零、全部相同、完全无法合并、只有一个数”的用例自测一遍这四类边界能拦住绝大多数低级错误。祝备考GESP五级的各位一次通过也欢迎交流你在这道题上踩过的坑。