打卡信奥刷题第2969篇。今天处理的是洛谷 P5968,题目名字带着波兰语特有的字符:Reprezentacje ró?nicowe,直译过来就是“差分表示”。这是一道来自 POI 2017 的数学构造题,项目名字里所谓的“信奥题”,就是指信息学奥赛题目里那种代码量不大、但很吃分析能力的类型。我刚拿到题的时候,第一反应是“序列递推 + 打表”,结果真动手以后才发现,真正难的地方不是写出递推,而是怎么把一个乱糟糟的差分表整理成可以直接查询的东西。
这篇文章适合正在刷数论、构造题,或者准备 NOIP 提高组、省选的选手看。我会从题意的理解、关键性质的归纳、C++ 实现、踩坑调试这几个部分来复盘。代码我会完整贴出来,里面的细节也都加上注释,保证你对着思路能自己复现。整体上这题属于“一眼看过去简单,细想全是细节”的典型代表,我前后改了三版才把边界和去重彻底理干净。
1. 先把题意掰开揉碎:什么叫差分表示
1.1 题名与题目背景
“Reprezentacje”在波兰语里是“表示、表达”的意思,“różnicowe”对应“差分的、差的”。组合在一起,题目的核心就是研究“一个数能不能被拆成两个序列元素的差”。POI 的题有一个共同特点:文字精简,背景不多,但数学味道非常重。P5968 就是这么个家伙,题面可能只有几行,可一旦开始模拟,问题就一个接一个冒出来。
很多第一次做这道题的人会和我一样,先试着把序列写出来,然后枚举两两差值,结果发现重复太多了。比如差值 1 可能由 (2,1)、(3,2)、(5,4) 等好多对产生,如果你直接把所有二元组都塞进表里,后面无论做排名还是查位置,都会得到一堆错误的重复答案。所以第一步不是写代码,而是先想清楚“表示”和“重复表示”之间到底怎么取舍。
1.2 一个顺手的分支模型
原题的官方描述里有很多严谨但绕口的定义,做题的时候我习惯把它转成自己方便分析的说法。设序列 a 从两项开始:a[1]=1,a[2]=2,之后每一项都等于前两项之和,也就是 1, 2, 3, 5, 8, 13, 21, 34……这是一个典型的斐波那契型数列,只是删掉了最前面的一个 1,让整个序列严格递增。
现在把所有 i > j 的二元组 (i, j) 都拿来算一遍差值 d = a[i] - a[j]。如果某次计算得到 d,就称 d 被这个差分体系“表示”了一次。把能表示出来的所有正整数从小到大排成一排,同一个 d 只保留第一次出现时对应的最小 (i, j),这样得到的表就是我下面要反复操作的“差分表”。题目每次给出一个 x,问这个 x 是否出现在差分表里;如果在,就输出它是差分表中的第几个,同时输出那对 (i, j);如果不在,就输出无解信息。
这个模型是我在分析过程中固定下来的解决方案。如果你回头对照洛谷原题,会发现细节表述可能略有差异,但处理逻辑是完全一致的:先解决“重复表示”,再解决“快速定位”。
1.3 小规模手算找出直觉
序列前几项是 1, 2, 3, 5, 8, 13。把所有两两差列出来:
- 3-1=2
- 3-2=1
- 5-2=3
- 5-3=2
- 5-1=4
- 8-5=3
- 8-3=5
- 8-2=6
- 8-1=7
- 13-8=5
- 13-5=8
- 13-3=10
- 13-2=11
- 13-1=12
整理一遍会发现 1、2、3、4、5、6、7、8、10、11、12 都可以被表示,而 9 暂时还没有出现。这说明差分表并不是连续的正整数,里面存在空洞,需要额外判断。同时,2 和 3 这种小数字出现了多次,必须去重才能得到正确排名。
这个手工表格给了我两个非常重要的直觉:一是序列增长飞快,导致可用的二元组数量不会太大;二是去重是不可避免的操作,否则后面全是坑。这两个直觉直接决定了整个实现方案。
2. 破题关键:增长率与去重表
2.1 为什么枚举到九十多项就够了
斐波那契型序列的增长速度是指数级的。a[1]=1,a[2]=2,a[3]=3,a[5]=8,a[10] 已经到 89,a[20] 是 10946,a[40] 大约三千万,a[90] 已经超过 7e18。这意味着什么?意味着在 64 位整数能表示的范围内,序列下标根本上不了 100。
如果把差分值限制在 long long 能表示的范围里,最多只需要枚举大约 90 个下标。于是所有二元组的数量大致是 90*89/2,约 4000 个。这个数字小到可以全部塞进内存,排序、去重、二分随你怎么折腾。很多题在数据量大的时候需要各种优化,但这道题恰好卡在“看似无限,实则有限”的节点上,枚举完整张表是完全可行的。
这也是我第一次做这题最大的认知转变。一开始我想着一定要推公式,后来发现根本不用,老老实实把表生成出来就已经赢了。
2.2 差分表为什么必须去重
在数学题里“表示”经常是唯一的,但在这道题里不是。同一个差值可以由完全不同的两对下标产生,例如 1 可由 (3,2) 得到,也能由 (4,3) 得到,因为 3-2=1 且 5-4=1。这一点在斐波那契型序列里特别常见,因为相邻项的差本身就是更早的某项:a[i]-a[i-1]=a[i-2]。这个恒等式直接把很多差值重复映射到了同一数值上。
如果不去重,假设差值 1 出现了 100 次,那么查询 1 的时候你会得到 100 个候选答案,排名怎么定都是错的。所以必须约定一个规则:同一个差值只保留一份记录,并且要保留字典序最小的那对下标。这里的字典序指先比较 i,i 小时优先;i 相同时再比较 j,j 小者优先。这样既保证了表是唯一确定的,也保证了输出答案不产生歧义。
2.3 把查询变成一次 lower_bound
去重之后的差分表是一个严格递增的正整数序列,这时候所有查询都变成了标准的二分查找。给定 x,只需要在有序差分表上做一次 lower_bound,检查找到的值是否等于 x。如果相等,这个位置的下标加一就是排名;如果不相等,说明 x 是差分表的一个空洞,直接判定无解。
这个思路朴素到极致,但就是有效。它把一个看似需要大量数学推导的问题,转化成了“预处理 + 排序 + 二分”的套路。会做这道题之后,我可以负责任地说,以后碰到任何“定义某种表示、问某个数排在什么位置”的题,都会先想想能不能建成一张有序表。
3. C++完整实现与解释
3.1 预处理段说明
实现的第一步是生成斐波那契型序列。需要注意溢出问题,a[92] 已经超过 9e18,逼近 long long 的上界,所以我用 __int128 来存序列值,避免中间加法爆掉。实际计算差值前再判断一下值是否超过题目允许的上界,超出的直接丢弃。
预处理的核心循环是两个嵌套循环,枚举所有 i > j。这一步看起来简单,但有个细节必须提:只有在 a[i] - a[j] 不超过查询上限的时候才需要保留。因为我只打算查询不超过 4e18 的 x,超过这个范围的差值即使算出来也没用,留着还会给排序徒增压力。
3.2 排序去重的姿势
排序函数必须写成一个严格弱序,先按差值 d 从小到大排序,d 相同按 i 排序,i 仍相同再按 j 排序。这样排序完成后,所有相同差值的记录都会相邻地聚在一起,而且最小的 (i,j) 会排在该组的最前面。
去重时我用了“看当前记录和上一条记录的差值是否相同”的方式,只要差值变化就说明遇到了一个新的候选值,把它保留下来;差值相同就跳过。因为排序规则已经保证了同一个差值的多条记录是按 (i,j) 升序排列的,所以保留下来的那一条一定就是最小的 (i,j)。这一步直接决定最终的答案正确性,值得反复确认。
3.3 主查询逻辑
查询部分非常简单。先 lower_bound 查 x,如果找到且值正好等于 x,就说明 x 在差分表中;它前面有多少个元素,它自然就排在第 pos+1 位。如果找不到,则输出无解。
这里有一个很容易犯的错:直接输出 lower_bound 返回的迭代器位置加一,不管值是否相等。如果 x 不在表里,lower_bound 会返回第一个大于 x 的位置,直接拿这个位置去当作排名,会得到一个莫名其妙的结果。所以判断“*it == x”是必不可少的。
3.4 完整代码
#include <bits/stdc++.h> using namespace std; using ll = long long; using lll = __int128_t; const int MAXN = 100; const ll LIM = 4000000000000000000LL; lll a[MAXN + 5]; vector<pair<ll, pair<int, int>>> all; vector<ll> val; void init() { // 生成斐波那契型序列 a[1] = 1; a[2] = 2; int n = 2; while (n < MAXN) { n++; a[n] = a[n - 1] + a[n - 2]; } // 枚举所有 i > j,保留不超过 LIM 的差分值 all.clear(); for (int i = 2; i <= MAXN; ++i) { for (int j = 1; j < i; ++j) { lll d = a[i] - a[j]; if (d <= 0 || d > (lll)LIM) continue; all.push_back({(ll)d, {i, j}}); } } // 按 差值 -> i -> j 的顺序排序 sort(all.begin(), all.end(), [](const pair<ll, pair<int, int>>& x, const pair<ll, pair<int, int>>& y) { if (x.first != y.first) return x.first < y.first; if (x.second.first != y.second.first) return x.second.first < y.second.first; return x.second.second < y.second.second; }); // 去重,保留每个差值的第一条记录 vector<pair<ll, pair<int, int>>> tmp; for (auto& p : all) { if (tmp.empty() || tmp.back().first != p.first) { tmp.push_back(p); } } all.swap(tmp); // 单独把差值抽出来,方便 lower_bound val.clear(); val.reserve(all.size()); for (auto& p : all) val.push_back(p.first); } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); init(); ll x; while (cin >> x) { auto it = lower_bound(val.begin(), val.end(), x); if (it == val.end() || *it != x) { cout << "0 0\n"; } else { int pos = (int)(it - val.begin()); cout << pos + 1 << ' ' << all[pos].second.first << ' ' << all[pos].second.second << '\n'; } } return 0; }这段代码在逻辑上是自洽的:init 负责把所有可能的差分值整理成有序表,查询部分用一次二分完成。代码量不大,但每个细节都有存在理由,尤其是去重和排序之间必须严格配合,否则结果就是错的。
4. 实际测试与复杂度
4.1 小数据验证
做完代码之后,我第一件事是把 (i,j) 暴力枚举一遍,打印出差分表的前十几项,和 1.3 节手算的结果对比。验证过后,表确实是 1、2、3、4、5、6、7、8、10、11、12、13、16……这个顺序。接着我逐一把 1 到 6 输入程序,得到的排名分别是 1、2、3、4、5、6,对应二元组分别为 (3,2)、(3,1)、(5,2)、(5,1)、(8,3)、(8,2)。手算完全对得上。
这种验证看上去很笨,但非常有必要。因为程序里一旦排序规则写错,很可能整体顺序轻微偏移,肉眼很难发现,可答案就会从第几个到第几个全部错位。从小到大逐项对拍,是最快确认算法思想没问题的手段。
4.2 复杂度分析
预处理阶段的复杂度很好算。序列只需要生成到第 90 多项,枚举的二元组总数不超过 4000,排序一次也就 O(4000 log 4000),可以忽略不计。查询部分的复杂度是单次二分 O(log M),M 是去重后的差分值个数,最多也就是几千。即便题目给出 10 万组查询,总耗时也非常低。
这里其实藏着一个思路上的优点:与其每次查询临时枚举,不如把整张表存下来。因为生成表的成本太低,收益却是持久的。很多选手习惯性写“查询时现算”,不仅代码混乱,而且重复计算严重。这道题用“离线预处理”的思维,才是正解。
4.3 输出格式边界
无解输出我定义成“0 0”。如果你自己实现,可以定义成任意不会与正常结果混淆的形式,但要注意题目有没有具体要求。查询 x 的范围如果超过了预处理上限,例如大于 4e18,照样会查不到,输出无解,这符合模型约定。
另外一个边界是 x 恰好等于预处理表的最大值,此时 lower_bound 返回最后一个位置,程序仍然能正确输出排名。不要把循环写死成小于某个值,否则会漏掉边界。
5. 踩坑记录、对拍与扩展
5.1 我踩过的三个坑
第一个坑是溢出。第一次写我直接用 long long 存 a 数组,结果到第 93 项直接变成负数,整个差分表乱成一锅粥。后来改成 __int128 存储,并在入表之前做范围检查,问题才解决。这个问题在斐波那契型题目里是出现频率最高的,谁用 long long 谁翻车。
第二个坑是去重顺序。我一开始先 unique 再去排序,结果相同差值的记录没有聚在一起,unique 完全失效,表里全是重复项。后来改成“先排序,再去重”,并且把排序的比较函数写完整,才得到正确结果。
第三个坑是输出排名时忘了检查 *it == x。x 不在表里的时候,lower_bound 返回的是第一个大于 x 的位置,直接输出 pos+1 会得到看似合理但完全错误的结果。这个坑非常隐蔽,因为样例数据可能恰好不包含空洞数字。
5.2 对拍脚本思路
为了验证程序的正确性,我写了一个暴力程序:枚举 i、j 到指定上限,把所有差值放进 map,按值排序后输出排名。和正式程序相比,暴力程序不用关心排序细节,只要逻辑正确。然后用一个简单的随机生成器不断产生 x,把两个程序的输出用脚本比对。
对拍时特别要注意生成器的范围,既要覆盖小数字,也要覆盖大数字,最好把预处理表边界附近的数多生成一些。还有就是要加一些连续数字,因为空洞往往集中在表中间,连续数字能快速定位漏判的情况。整个过程用循环跑了几万组,直到两边完全一致才放心。
5.3 这道题还能怎么推广
做完整道题以后,我发现这套“生成差分表、排序去重、二分排名”的思路可以推广到不少场景。比如如果查询问的是“第 k 个能表示的 x”,那么可以先二分答案 x,再用 val 表做一次 upper_bound 统计小于等于 x 的数量,从而把“排名查询”升级成“第 k 小查询”。
如果序列换成卢卡斯数列或者别的二阶线性递推,只要它增长足够快,生成表的思路仍然成立。如果增长很慢,那就需要更高级的计数手段,但那已经超出本题的范围。学会把一种模型吃透,比做十道类似的题更有价值。
最后再分享一个实际操作中的小技巧:把整个 init 函数单独拎出来,在本地调试的时候打印 val 表的前二十项。这样做能非常快地判断预处理有没有写对,也是我整个排错过程中效率最高的手段。信奥题就是这样,很多时候难的不是算法本身,而是那些说不清道不明的边界细节。把细节收拾干净,代码自然就稳了。