看到这个标题我就忍不住点进来了——“大姨的最高分数”。一看就是那种算法群里流传出来的接地气题名,前缀和加long long,两个关键词直接写在标题里,生怕别人不知道这题的坑在哪。实际上这道题的核心根本不是它的故事外壳,而是要求你读懂“前面所有数中的某个数”到底在说什么,然后顺手把答案类型开成long long。我先把完整题面给大家还原一下,再把思路和代码一步步拆开讲。
题目背景大概是这样的:LL老师带娃爬山,路上坑坑洼洼,她看到当前位置比前面所有位置都低的时候,就把这个坑填到前面没有比它更高的位置为止,然后再往前走。下山之后她把这个过程抽象成了一个算法题。题面是:给定n个数,第i个数是a_i,还有一个整数m,初始值是0。对于每个a_i,如果它比它前面的所有数中的某个数小,那么就把a_i和m同时加上1。最后问你m是多少。
这题看起来很绕,实际上翻译成人话就是:从左往右扫一遍,数一数有多少个元素,它小于它左边的某个元素。如果你能把这个条件想清楚,后面的优化就顺理成章了。
1. 先把题目读明白:大姨填坑的故事到底想说啥
1.1 故事背景与题面还原
很多算法题都爱套一个生活场景,这道题也一样。爬山路上遇到坑,把坑填到前面没有更高的位置,这本质上就是在维护一个“当前已经见过的最大高度”。当你站在一个新位置,发现它低于前面某一个位置,那这个坑就得填,填到前面最高点那么高,否则就不需要填。回到题面上,这个“填坑”动作被抽象成:如果a_i比前面的某个数小,则a_i和m同时加1。
注意这里的“同时加上1”其实是一个干扰项。a_i加1只是故事里“把坑填高”的具象化操作,它并不会影响后续的比较逻辑,因为后面比较的是原始的a数组,而不是填高之后的值。所以这个条件等价于:如果a_i小于max(a_1, a_2, ..., a_{i-1}),那么计数加一。
这个转化是整个题目的第一个关键点。如果你在考试或者比赛时没有看出来“前面所有数中的某个数”就是“前缀最大值”,那么很容易写出一个暴力双重循环,然后在一个大数据的测试点上超时。但一旦看出来,这题就变成了一个简单的线性扫描题。
1.2 拿一组样例手算一遍
光说不练假把式,我随手造一个样例,比如数组是[3, 1, 4, 2, 5]。我们手动模拟一遍,感受一下这个过程。
初始时m=0,前缀最大值mx初始为第一个数3。然后从第二个数开始扫:
- i=2,a=1,1小于3,所以计数m加1,m变成1,此时mx不变,仍然是3。
- i=3,a=4,4不小于3,所以不计数,mx更新为4。
- i=4,a=2,2小于4,计数m加1,m变成2,mx保持4。
- i=5,a=5,5不小于4,所以不计数,mx更新为5。
最终m=2。也就是说,在[3, 1, 4, 2, 5]这个序列里,有2个数(1和2)各自小于前面的某个数。
再验证一个边界样例,比如严格递增序列[1, 2, 3, 4, 5]。从头到尾,每个数都大于等于前一个数,所以mx一直在更新,m始终为0。这很合理,因为递增序列中不存在某个数比前面的某个数小。
再看严格递减序列[5, 4, 3, 2, 1]。初始mx=5,第二个数4小于5,计数加1;第三个数3小于5,计数加1;依此类推,最后m=4。这也很直观,除了第一个数,其余所有数都比第一个数小,所以答案是n-1。
1.3 一句话概括题目本质
这一节其实就是帮你提炼核心。这题的本质是:给你一个序列,统计有多少个位置i满足a_i小于前缀最大值。这里的“前缀最大值”指的就是max(a_1, a_2, ..., a_{i-1})。只要能用这一句话概括,代码就非常好写了,而这也是“前缀思想”在算法竞赛中最基础的应用形态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 思路拆解:暴力枚举为什么被淘汰,前缀统计为什么优雅
2.1 暴力做法:每个数都往前找一次
初学者拿到这题的第一反应,通常是一个二重循环。外层枚举每一个i,内层枚举j从1到i-1,一旦发现某个a_j大于a_i,就计数加1然后跳出内层循环。这个思路非常直观,完全按照题面翻译而来,肯定是对的,但效率很差。
为什么差?因为最坏情况下,比如整个数组是严格递减序列[5, 4, 3, 2, 1],每一次内层循环都要从头扫到当前位置才能找到那个比当前数大的“前面的某个数”。时间复杂度就是1+2+3+...+n,也就是O(n^2)。当n为10^5时,总操作量约5×10^9,在常见的评测机上根本跑不完;当n为10^6时,那就更不用谈了。
所以暴力做法只适合用来对拍,或者说只适合n很小时当作暴力验证答案的脚本,绝对不能作为正式题解提交。这也是算法学习中很经典的一条经验:先写暴力验证思路,再写正解。
2.2 前缀最大值:把O(n^2)变成O(n)
既然暴力慢在“每次都要往前重新找一遍最大值”,那我们就提前把“当前已经扫过的最大值”记下来。从左往右遍历,用一个变量mx记录当前已经出现过的元素最大值,然后在遍历到a_i时,只需要比较a_i是否小于mx,就能判断它是否小于前面某个数。
如果a_i < mx,说明它确实比前面的某个数小,答案加1,mx保持原样。如果a_i >= mx,说明它是新的最大值,答案不变,同时更新mx = a_i。这一步更新不能漏,否则后续所有判断都会出错。整个过程只需要一次遍历,时间复杂度O(n),空间复杂度O(1),甚至不用额外开数组存前缀最大值。
这里要特别强调一下为什么“a_i < mx”能够完全等价于“a_i小于前面的某个数”。如果mx本身就大于a_i,那自然存在一个数(即mx)比a_i大;反过来,如果a_i小于前面某个数,那这个数不可能比mx大,所以mx一定也大于a_i。因此两者是等价的。
2.3 前缀和、前缀最大值与“前缀思想”
你可能要问了,这题明明没有用前缀和数组,为什么标题里要写“前缀和”呢?其实这里的“前缀和”并不单指sum[i] = sum[i-1] + a[i]那种数值累加,而是更广义的“前缀信息统计”。前缀和是一种维护前缀区间数值总和的手段,前缀最大值是一种维护前缀区间最值的手段,它们都属于“前缀思想”的范畴。
很多新手容易把“前缀和”理解成一个具体的数组,却忽略了它的本质是“复用已经扫描过的信息”。本题目里记录mx,本质上就是记录了一个前缀区间的信息,只是我们不需要像前缀和那样把每一步结果都存下来而已。如果你把这层想通了,以后再遇到“左边第一个比它大的”、“前面有几个小于它的”这类题,就会自然地往前缀方向去靠。
3. 完整代码与“开ll”的细节
3.1 C++标准写法
直接上代码,这是我在本地测试过的版本,用C++17可以流畅运行:
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n);
for (int i = 0; i < n; ++i) {
cin >> a[i];
}
long long ans = 0;
long long mx = a[0];
for (int i = 1; i < n; ++i) {
if (a[i] < mx) {
++ans;
} else {
mx = a[i];
}
}
cout << ans << '\n';
return 0;
}
注意几个细节。第一,mx初始化为a[0],然后循环从i=1开始,这正好规避了第一个数没有“前面的数”的问题。第二,当a[i] < mx时,mx保持不变,不需要更新;当a[i] >= mx时,才更新mx。第三,读入用vector
3.2 为什么答案要开ll:一个int溢出的具体例子
这是很多人忽略的点,也是题目标题里专门写“记得答案开ll”的原因。你可能会觉得,答案最多不就是n吗?n如果是10^5,int完全存得下。问题在于,你在比赛里不可能只做这一道题,很多题目会变着花样让你统计组合数、区间和、方案数,答案可能瞬间超过int上限。
比如有一种变体题,让你统计有多少对(i, j)满足条件,那答案就可能到n*(n-1)/2。当n=10^5时,这个值是4,999,950,000,明显超出int的21亿上限,直接溢出成负数。我当年就吃过这种亏,答案在本地样例上跑得好好的,一交上去WA,后来调了很久才发现是int溢出了。
回到本题,如果n最大是10^6,那么答案最多是10^6-1,int确实没问题。但你要知道,很多前缀和、差分题目为了防止大家钻空子,会把n开大到10^6甚至10^7,或者把a_i的取值范围放大到10^9以上。一旦涉及累加,中间结果就很容易超过int范围。所以养成“涉及统计、累加、前缀和、方案数,一律开long long”的习惯,可以帮你少踩很多坑。
3.3 Java、Python等语言实现时要注意什么
如果你用Java,对应的类型是long,读入可以用Scanner,但在大输入量时建议用BufferedReader加速。写法逻辑一样:
java复制long ans = 0;
long mx = a[0];
for (int i = 1; i < n; ++i) {
if (a[i] < mx) {
ans++;
} else {
mx = a[i];
}
}
System.out.println(ans);
如果你用Python,就不太需要担心int溢出,因为Python的int是任意精度的。但要注意的是Python的list读取和循环性能,在n达到10^6级别时,要尽量用sys.stdin.buffer.read来一次性读入数据,否则容易超时。代码可以简洁地写:
python复制import sys
def main():
data = list(map(int, sys.stdin.buffer.read().split()))
n = data[0]
a = data[1:]
ans = 0
mx = a[0]
for i in range(1, n):
if a[i] < mx:
ans += 1
else:
mx = a[i]
print(ans)
if __name__ == "__main__":
main()
Python的优势是随便开大数,劣势是常数大,所以Python选手更要学会用快读。
4. 做题时最容易踩的坑
4.1 边界条件:第一个数、相等元素、单调递增序列
这类前缀统计问题,边界条件是WA的重灾区。首先是第一个数。第一个数没有前面的数,所以它永远不会被计数。上面代码把循环从i=1开始,mx初始化为a[0],完美解决了这个问题。要是你把循环写成从i=0开始,再判断i==0跳过也行,但不够干净。
然后是相等元素。题目说的是“比它前面的所有数中的某个数小”,注意是“小”,不是“小于等于”。所以当a[i]等于mx时,不能计数,只需要更新mx(如果严格要求mx是最大值,相等更新不更新结果一样,但建议更新)。如果你把它写成了if (a[i] <= mx),那相等元素也会被计数,答案就会偏大,这就是典型的边界写错。
最后是单调递增序列。当整个序列严格递增时,mx每次都会被更新,ans始终为0。如果按这个用例验证你的代码,输出0就是对的。很多人会想当然地认为“后面比前面大应该也计数”,其实题目根本没这个意思,所以这类反直觉用例用来对拍特别有效。
4.2 读入输出的坑:%lld、关闭同步
C++选手最常见的问题是scanf和printf与long long的格式符不对应。用scanf读long long,必须是%lld,不能写成%d;用printf输出long long,同样是%lld,不能写成%d。如果你用cin和cout,那一般没事,但别忘了加ios::sync_with_stdio(false)和cin.tie(nullptr),不然遇到大数据量输入,C++的流同步会拖慢速度,可能直接超时。
还有一种情况是cin和scanf混用。有些同学图省事,读入用scanf,输出用cout,或者反过来。这在某些编译器下会出问题,因为C标准IO和C++ IO是不同步的,一旦混用,数据顺序可能错乱。我的建议是,要么全用scanf/printf,要么全用cin/cout并关同步,不要混着来。
4.3 示例代码与自测用例
如果你写完代码想快速自测,可以直接跑下面这组用例:
| 输入 | 输出 |
|---|---|
| 5 3 1 4 2 5 | 2 |
| 5 1 2 3 4 5 | 0 |
| 5 5 4 3 2 1 | 4 |
| 1 100 | 0 |
| 6 7 7 7 7 7 7 | 0 |
第4个用例n=1,表示只有一个数,答案应该是0,因为不存在“前面的数”。第5个用例所有数都相等,答案也应该是0。这些用例能帮你快速检查出“第一个数边界”和“等号错误”这两类最常见的问题。
5. 扩展一下:前缀和与差分的经典联动
5.1 前缀和:从单点判断到区间查询
很多时候,题目不会只让你统计“前面有没有更大的”,而是会问“区间[l, r]的和是多少”,并且有m次询问。如果每次询问都暴力跑一遍区间,复杂度是O(n*m),基本不可能通过。这时前缀和数组就派上用场了。预处理pre[i] = pre[i-1] + a[i],之后每次查询区间和就是pre[r] - pre[l-1],时间复杂度O(1)。这就是前缀和最常见的应用。
回到本题,虽然它只需要维护前缀最大值,但如果你把“前缀最大值”数组preMax[i] = max(preMax[i-1], a[i])先预处理出来,再用它来判断,实际上也能做,只是没必要浪费O(n)空间。这两种思路本质上是同一个套路,都是“提前算好前缀信息,后续直接复用”。
5.2 差分:把区间修改变成O(1)
差分和前缀和是一对好搭档。前缀和用来快速查询区间和,差分则用来快速执行区间修改。差分数组d[i] = a[i] - a[i-1],如果你想给区间[l, r]整体加上v,只需要d[l] += v,d[r+1] -= v,最后再对差分数组求前缀和,就能还原出修改后的原数组。这种操作在需要多次区间加、最后统一查询的场景下特别常见。
有些同学会把前缀和和差分搞混,其实一句话就能分清:前缀和是“从前往后累加”,差分是“从后往前还原”。两者经常一起出现,比如“对数组做若干次区间加操作,然后询问多次区间和”,那就是先用差分处理修改,再用前缀和处理查询,前后衔接流畅。
5.3 彩蛋:当“ll”出现在STM32的LL库
搜索“ll”的时候,你会发现结果里冒出一堆嵌入式开发的内容,尤其是STM32的LL库。LL库是Low Layer的缩写,是意法半导体提供的一套底层操作库,经常配合USART、DMA一起使用。比如STM32G031的USART DMA收发,用LL库写起来比标准库更简洁,比HAL库更底层。这里的LL和算法题里的long long完全是两个世界的东西,但搜索引擎不管这些,它们都会算进“ll”这个关键词里,所以有时候会看到很奇怪的结果混进来。
对我来说,这反而是一种提醒:在写题解时,把关键变量写清楚,把类型说明白,把坑踩平,才能真正帮助到那些被这些“同名不同义”的关键词绕晕的新手。如果你将来读的是一本正经的嵌入式文档,千万别把里面的“ll”当成long long来理解。
6. 我平时写这类题的习惯
最后分享一个我自己的小习惯。只要涉及前缀统计、累加计数、区间和这类题,我会先把变量类型全部写成long long,哪怕题目给的n看起来很小。这不仅是为了防止溢出,更是为了让自己在写代码时不用反复思考“这里会不会越界”,把有限的注意力留给真正的逻辑。
还有一个习惯是:每道题写完正解之后,我都会随手写一个暴力的对拍程序,跑一些小随机数据,对比两个程序的输出。这题虽然简单,但暴力对拍能帮我确认边界条件没有写错。比如n=1、所有数相等、严格递增、严格递减这些极端情况,用几组小数据一测就心里有底了。
如果你也是那种“每次交题WA了才想起来看边界”的选手,那建议把本文提到的几个坑整理成一个checklist,每次提交前快速过一遍。不要嫌麻烦,很多WA都是几秒钟的检查就能避免的。希望这篇题解能帮你在下次遇到前缀统计类题目时,少走几步弯路。
