信奥刷题打卡到第2966题,刚好抽到P5957 [POI 2017] Flappy Bird。光看名字以为是个休闲题,点进去才发现是一件需要冷静建模的优化题。它的题面说得很游戏化:一只小鸟从(0,0)出发往右飞,时间每一格横坐标加1,你可以选择点击一次让它上升1格,或者不点让它下降1格,途中有一堆管道要求你飞过某一横坐标时高度落在一个区间里,最后问你飞到x=X时最少点多少次。数据范围大得吓人,我第一反应是写BFS,结果状态都开不下。这篇文章就记录一下我最终是怎么用区间维护把它做成O(n log n)的,包括那些WA得稀里糊涂的边界问题。
1. 题意拆解:Flappy Bird在信奥里长什么样
1.1 输入输出与真实约束
先别被游戏外壳骗了。题目给的是n个管道和终点横坐标X,每个管道有三项信息:横坐标x、下界low、上界high,表示小鸟飞到x这一列时,高度必须落在[low, high]区间内。初始位置是(0,0),高度为0。地面也是硬约束,任何时刻高度不能小于0。管道本身不算太厚,可以把它理解成在平面上画n条线段,每一条线段是“x固定时y的一个可行范围”。
我们要求的是到达x=X时的最小点击次数,也就是说终点高度不限制,只要能到就行。如果整个过程中存在某一列没有任何可行路径,就输出-1。
从建模的角度看,横坐标x就是时间步t。每个时间步你有两个决策:点击,y加1,代价是1;不点击,y减1,代价是0。管道则是在特定时间点上对状态y的区间约束。所以它本质是一个离散时间、连续状态的最短路径问题,而不是大家以为的“模拟游戏操作”问题。
1.2 为什么暴力一定过不了
最朴素的做法是维护一个DP数组dp[y]表示当前x位置到达高度y的最小点击数。每个新位置都要从旧位置转移,复杂度是O(X*H),其中X是最大横坐标,H是最大高度。题目里X和H都可能到1e9级别,不管是时间还是空间都完全不可接受。
有人可能会想用BFS按层扩展,每一层对应一个x坐标,每个状态是一个高度值。但你仔细算一下,每一层最多可能有O(H)个状态,X层之后总状态数还是O(X*H),炸得明明白白。
这题的核心价值就在于:管道数量n是相对小的,横坐标其实只有n个关键点需要我们处理。连续的大空间被n条约束切成了n段,每一段内部没有任何限制时,我们要做的事就只是把高度可能性整体平移,然后交给下一个管道去截断。理解了这一点,后续算法就很自然了。
1.3 题目的真正考点
这道题考的是两件事:第一,能不能从规则里提炼出可优化结构;第二,在优化过程中是否注意到奇偶性这种隐藏不变量。它不考任何高级数据结构,考的是建模能力和对细节的敏感度。我一开始自以为看破了“区间维护”,把代码写得飞快,结果提交一看全WA。后来才发现奇偶性没处理,小数据碰巧都对了,大数据全部翻车。这道题给我上的最深刻一课就是:每一步高度变化±1,会导致“横坐标与高度的奇偶性永远一致”,这个不变量比什么优化都重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法推导:区间维护与奇偶性修正
2.1 一个关键结论:点击次数由终点高度决定
设从起点到终点一共走了X步,期间点了c次,那么下降次数是X-c。终点高度y满足:
y = c - (X - c) = 2c - X
所以c = (y + X) / 2。这个式子说明一件很关键的事:无论中间怎么飞,最终停在哪个高度,点击次数就被唯一确定了。想要让点击数最小,本质上就是让终点高度尽量小,只要这个高度是合法的、可行的。
这个结论帮我完成了从“求最小操作数”到“求最小可达高度”的转换。后面所有工作都围绕一个东西:在满足所有管道约束的前提下,找出终点处能够达到的最小合法高度。
2.2 区间维护的完整推导
维护一个区间[l, r],表示当前横坐标位置上,所有可行路径可能达到的高度集合。初始时在x=0,只有高度0,所以l=r=0。
假设当前处理到的横坐标是lastX,下一个管道在nx,中间距离d=nx-lastX。从某个高度h出发,走d步之后,每一步不是+1就是-1,所以能到达的高度范围是[h-d, h+d]。对整个区间[l, r]来说,所有可能性并起来就变成了[l-d, r+d]。
这里要注意地面的下限0。l-d可能变成负数,但实际高度不能小于0,所以要执行l = max(0, l - d)。右边界r+d只可能增大,不需要和0取max,因为r本身非负。
接下来撞管道约束:高度必须落在[low, high]内。取交集就是:
l = max(l, low)
r = min(r, high)
如果l > r,说明没有任何一条路径能同时满足前面的所有管道和当前管道,直接判无解。
2.3 奇偶性不是小问题
每次高度变化±1,所以奇偶性一定会翻转。从高度0出发,当横坐标为x时,高度y必然满足y和x奇偶性相同。如果x是奇数,y一定是奇数;x是偶数,y一定是偶数。这个约束伴随整个飞行过程。
取完交集的区间[l, r]是连续整数区间,里面可能同时包含奇数和偶数。但当前位置x只允许其中一种奇偶性的高度存在,所以必须将l调整为“不小于l且与x同奇偶的最小值”,将r调整为“不大于r且与x同奇偶的最大值”。调整后如果l>r,同样是不可达。
很多人会把这步当作可有可无的细节,实际上它是正确性的关键。举个例子,管道在x=2处限制高度为[10,10],初始0经过两步最多到2,明显不可达;区间法会得到[l,r]交完变[10,10],然后按x=2调奇偶,l调整成10,r调整成10,最终l>r吗?10与2同奇偶,没问题,但之前的l min是0,经过两步扩展后是[0,2],与[10,10]本来交集就为空。所以其实前面已经发现了。不过换个场景,管道在x=3处限制[10,11],扩展区间先交成[10,11],按x=3调奇偶,l变成11,r变成11,看起来可行,但实际真的是可行的吗?从0出发三步到11是不可能的,因为三步最多到3。等等,这里还有一个中间过程:从x=2到x=3的距离是1,如果前面已经积累了很多高度,是可以到11的。这里只是说明奇偶性会让区间从[10,11]缩到[11,11],防止你错误地选择10。
最后处理完所有管道,还要走到终点X。从最后一个管道到X之间没有约束了,只需要再扩展一次区间:l = max(0, l-d2),r = r+d2,其中d2 = X-lastX。然后再做一次奇偶修正,得到终点可达区间。取调整后的l作为目标高度,它就是能满足所有约束的最小终点高度,答案自然就是(l+X)/2。
2.4 一个完整的手算例子
我拿小数据实测一遍。n=1,X=4,管道在x=2,low=1,high=3。
初始:x=0,l=0,r=0。
第一个管道距离2:扩展后l=max(0,0-2)=0,r=0+2=2。
与管道[1,3]取交集:l=1,r=2。
按x=2调整奇偶:x=2是偶数,l=1调整成2,r=2不用动,区间变成[2,2]。
处理完管道,到终点X=4距离2:扩展l=max(0,2-2)=0,r=2+2=4,按x=4偶数调整,区间[0,4]里最低偶数是0,所以l=0,r=4。
答案=(0+4)/2=2。
验证一下路径:x=1时点击一次到高度1,x=2时再点一次到2,正好落在管道区间里;x=3不点降到1,x=4不点降到0。总点击2次,正确。
3. C++实现细节:从伪代码到可提交代码
3.1 数据结构和输入处理
管道信息用一个结构体存,包含x、low、high三个字段。由于输入不保证按x升序,必须先排序。排序依据只有x,如果x相同,一般按low或high排都无所谓,因为相同x的管道会被连续处理,但理论上同一列不应该有两个管道,不过还是把low小的放前面更稳。
所有数值都用long long,不要用int。题目数据范围大,横坐标、高度、最终答案都可能超过32位整数的安全范围。我在本地用int跑小数据没问题,提交之后一上大样例就溢出,改成long long才过,算是第一个教训。
3.2 核心循环代码
先贴出最核心的循环片段。
cpp复制#include <bits/stdc++.h>
using namespace std;
struct Node {
long long x, low, high;
};
long long adjLow(long long v, long long pos) {
if ((v & 1LL) != (pos & 1LL)) v++;
return v;
}
long long adjHigh(long long v, long long pos) {
if ((v & 1LL) != (pos & 1LL)) v--;
return v;
}
int main() {
int n;
long long X;
cin >> n >> X;
vector<Node> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i].x >> a[i].low >> a[i].high;
}
sort(a.begin(), a.end(), [](const Node& u, const Node& v) {
return u.x < v.x;
});
long long l = 0, r = 0, lastX = 0;
bool ok = true;
for (int i = 0; i < n; i++) {
long long d = a[i].x - lastX;
l = max(0LL, l - d);
r = r + d;
l = max(l, a[i].low);
r = min(r, a[i].high);
l = adjLow(l, a[i].x);
r = adjHigh(r, a[i].x);
if (l > r) {
ok = false;
break;
}
lastX = a[i].x;
}
if (ok) {
long long d = X - lastX;
l = max(0LL, l - d);
r = r + d;
l = adjLow(l, X);
r = adjHigh(r, X);
if (l > r) ok = false;
}
if (!ok) cout << -1 << "\n";
else cout << (l + X) / 2LL << "\n";
return 0;
}
代码逻辑和前面推导完全对应。注意adjLow和adjHigh的处理:只有当v和pos奇偶性不同时才加1或减1。如果v已经满足奇偶性就直接返回,不要动它。
3.3 奇偶调整函数的具体形式
adjLow的作用是把左边界抬高到合法奇偶性。比如v=3,pos=4,v是奇数,pos是偶数,那么最小合法高度是4,所以返回4。adjHigh则是把右边界压低到合法奇偶性。比如v=8,pos=3,v是偶数,pos是奇数,那最大合法高度是7,所以返回7。
用位运算(v & 1LL)判断奇偶性比%2更快,也更不容易写出意图不明的代码。判断完再决定加1还是减1,不要先加后判断,那样会漏掉v本身就满足条件的情况。拿小数据在纸上推一推,这个函数的返回值会非常直觉。
3.4 无解判断的完整链路
无解可能出现在两个位置。第一是处理某个管道时,交集加奇偶修正后l>r,说明当前约束和之前的可能性冲突,直接跳出。第二是处理完全部管道后,在终点处扩展并修正奇偶时发现l>r,说明终点高度和奇偶性无法匹配。
这两个判断缺一不可。最初我写完第一处判断就提交,结果好几个测试点WA。后来发现有一种情况是前面所有管道都合法,但最后一小段距离和终点X的奇偶性不匹配,导致终点高度不可达。补上第二处判断后瞬间全绿。
3.5 复杂度分析
排序O(n log n),循环O(n),空间O(n)。n再大,只要2e5或1e6级别都能轻松跑过。代码里唯一稍微费点功夫的是排序,连离散化都不用。这也是这个解法最优雅的地方:把看似复杂的飞行动态压缩成一个区间,每次只做常数次运算。
4. 踩坑实录:最容易出错的三个细节
4.1 管道顺序不排序,直接按输入处理
这道题的输入并不保证管道按照x从小到大给。我一开始看到题目背景觉得POI的题应该很严谨,默认它已经有序,结果提交后一堆WA。查了半天发现,其实在第二个管道的位置就乱了。
假设第一个管道在x=5,第二个管道在x=3,如果不去排序,循环会先处理x=5的约束,把从0到5的可能性全部压缩一遍,然后用这个结果去处理x=3的约束。可实际上小鸟到x=3时根本还没碰到x=5的管道,顺序完全反了。
排序过后问题消失。这种排序陷阱在很多带坐标的题里都特别常见,包括区间合并、扫描线、离散化。我现在的习惯是,只要题目输入里有坐标类数据,并且后续处理依赖顺序,一律先排序再说,不赌出题人的仁慈。
4.2 忽略地面0的约束
高度不能小于0这一点,在扩展区间时特别容易漏。我第一次写的时候只写了r=r+d,忘了l=max(0,l-d),结果在小数据上偶尔出错。
给你一个反例:n=2,X=10,第一个管道在x=1,low=3,high=3,第二个管道在x=2,low=3,high=3。初始l=r=0。如果忽略地面约束,第一步扩展之后l=-1,r=1,再和[3,3]取交,l=-1,r=1,l>r,无解。但这里其实也不会误判,因为管道本身限制在高处。真正的误判场景是:管道上界很高,但下界为0,地面约束导致某些看起来可行的路径其实不可能。
简单说就是当l-d变成负数后,取max(0)可以把那些“看似从负高度爬上来”的非法路径挡在门外。没有这一步,后续的区间交集可能算出一个实际上无法达到的高度范围。这个优化一行代码,漏掉就是灾难。
4.3 奇偶修正后没重新检查l<=r
这是最隐蔽的一个坑。假设当前处理位置x=5,管道区间是[10,11]。先取交集得到l=10,r=11,看起来没问题。但x=5是奇数,10是偶数,11是奇数,所以l要调整成11,r保持11,区间变成[11,11],合法。
但如果管道区间是[10,10],x=5,l=10,r=10,奇偶调整后l=11,r=9,l>r,无解。这时候如果代码只在取交集后检查一次l<=r,会漏掉“调整后l>r”的情况,继续往下跑,最后给出一个完全错误的答案。
所以每做完一次奇偶修正,都必须立刻检查区间是否为空。我后来把检查逻辑封装成一个小函数,每次调整完都调用一次,彻底避免了这个坑。
4.4 如何构造对拍数据验证
这种细节如此多的题,单靠手算两三个样例根本不够。我的做法是写一个暴力验证程序,用状态BFS处理小数据,再和区间维护算法对比。
暴力版思路很简单:用一个集合存当前x所有可达高度和对应最小点击数,x每加1,就遍历集合里所有高度,分别生成“上”“下”两个新状态,然后剔除管道不允许和小于0的高度。n不超过10,X不超过20的时候,跑得飞快。
然后随机生成大量测试数据,一张一张对比两个程序的输出。我第一次对拍就发现一个反例,最后定位到是奇偶性问题。没有对拍,这种WA可能要在评测系统里反复交好多次才能猜出来。建议大家以后遇到这种边界条件多的题,先花十分钟写个暴力对拍机,省下来的时间远多于写暴力本身。
5. 从这道题延伸出去的刷题方法论
5.1 “游戏规则翻译”题的建模套路
Flappy Bird这类题在信奥里不算罕见。它们的特点是题目包装华丽,游戏规则感很强,但实际抽象出来就是一个状态转移问题。我总结了一条固定套路:先忽略所有装饰性描述,把每一条规则改写成数学表达式;然后寻找不变量和可压缩维度;最后再考虑怎么用合适的数据结构实现。
在这道题里,不变量是“横坐标与高度同奇偶”,可压缩维度是“把所有可达高度压成一个连续区间”。如果一开始就盯着游戏画面去模拟,永远也想不到还能这么做。建模这一步想清楚,后面代码实现非常快。
5.2 区间维护思想还能用在哪
区间维护在很多题目里都能派上用场。比如给出一系列区间和移动步数,询问是否存在一条路径能穿过所有区间;或者带上下界的速度控制系统;甚至某些差分约束问题也能转化为区间压缩模型。
核心逻辑都差不多:维护一个当前可行区间,每走一段距离就整体扩张,遇到约束就截断,遇到隐藏规则就修正,最后看区间是否为空。遇到类似题,先想一想能不能用几个变量代表一整段状态,而不是老老实实把每个状态都存下来。
5.3 刷题打卡的一点体会
第2966题这个编号对我是有意义的,它代表每天刷一点的累积。打卡这种事,最忌讳只追求数量不动脑子。像P5957这道题,如果只是把题解抄一遍提交通过,那等于白刷。我后来花时间把暴力版、区间维护版、对拍脚本放在一起反复研究,才真正搞懂奇偶性和地面约束是怎么配合的。
刷题记录写下来,再回头看就能发现很多题目之间其实有联系。Flappy Bird的区间思想,在之后做某些模拟题时又遇到好几次。我现在越来越觉得,信奥刷题的价值不在于会做多少原题,而在于积累一套从陌生题面到成熟解法的转换能力。这道题就是一次非常经典的转换练习。
