1. 题目解析与核心思路拆解
1.1 先读懂题意:这个“递增三元组”到底在考什么
蓝桥杯这道“递增三元组”,题目描述很简洁:给定三个数组A、B、C,统计有多少个三元组(i, j, k),满足A[i] < B[j] < C[k]。我第一次见到这个题的时候,第一反应是“这不就是三重循环暴力么”,数据规模一出来就傻眼了。当你真正动手写的时候才会发现,这题的核心考点并不是“你会不会判断一个大小的关系”,而是“你能不能在一个合理的时间复杂度内完成统计”。换句话说,这题考的是你对排序、二分、双指针、前缀和这几种基础算法的综合运用能力。
在这道题里,三个数组的长度n最多能到10^5级别。如果你真的用三重循环去枚举所有组合,那复杂度是O(n^3),在10^5的数据规模下直接爆掉。哪怕n只有1000,三重循环的10^9次运算也足以让程序超时。我当时第一次写这题就踩了这个坑,自认为逻辑没毛病,结果一提交直接超时,后来才意识到算法的选择才是这题的关键。所以我们要做的,就是把“三重循环枚举”优化成更聪明的计数方式。
1.2 解题的大方向:从“枚举”到“计数”
既然不能一个一个枚举三元组,我们就要换个角度思考。核心突破口在于:如果把B数组的每一个元素当作“中间值”,那么对于每一个B[j],能有多少个A[i]小于它,又有多少个C[k]大于它?这两个数量相乘,就是当前B[j]作为中间值时能贡献的三元组数量。把所有B[j]的贡献加起来,就是答案。
这个思路说起来简单,但落地的时候有几个关键问题要处理:第一,怎么快速统计A中小于B[j]的元素个数?第二,怎么快速统计C中大于B[j]的元素个数?第三,B数组中可能有重复元素,怎么处理?第四,三个数组都没说是有序的,是否要先排序?这些问题的答案组合起来,就形成了这题的几种主流解法。我个人的经验是,在动手写代码之前,先把这些“为什么”想清楚,后面写起来会顺很多。
1.3 三种主流方案的选型对比
我在实际备考和带其他同学刷题的过程中,总结出这三种主流解法:
| 方案 | 核心思想 | 时间复杂度 | 空间复杂度 | 适合场景 |
|---|---|---|---|---|
| 排序 + 二分 | 对A和C排序,对每个B[j]分别二分查找位置 | O(n log n) | O(1) | 最通用,代码简单,推荐优先掌握 |
| 排序 + 双指针 | 对A、B、C都排序,两个指针分别扫A和C | O(n log n) | O(1) | 效率最高,双指针移动线性 |
| 前缀和 / 值域数组 | 统计值域分布,遍历值域累加 | O(n + m) | O(m) | 值域范围很小(如0~100000)时最快 |
我最早在培训班带学生时,很多同学一上来就追求“最优解”,非要写值域数组版本,结果边界条件写错,反而浪费大量时间调试。我的建议是:先把二分法吃透,这是理解成本最低、最不容易出错的方案。等你对算法有了手感,再去看双指针和前缀和的优化空间。考试的时候,AC才是最终目的,不是追复杂度炫技。
2. 排序 + 二分解法的实操指南与原理剖析
2.1 为什么二分查找能快速得到“小于某个数的个数”
在讲代码之前,先说清楚二分的原理。假设我们已经把A数组排序成升序,比如A = [1, 2, 2, 3, 5]。现在我有一个B[j] = 3,我想知道A里有几个元素小于3。因为A是有序的,我只要用二分查找找到3应该插入的位置——也就是第一个大于等于3的元素下标,这个下标值刚好就等于小于3的元素个数。如果B[j] = 6,二分找到插入位置是5,说明A里5个元素都小于6;如果B[j] = 0,插入位置是0,说明没有元素小于0。
这里有个细节非常关键:在C++里用lower_bound找到的是第一个“大于等于目标值”的位置,而这个位置的下标,恰好就是“小于目标值”的元素个数。同理,统计C中大于B[j]的元素个数,可以先在C里找到第一个大于B[j]的位置pos,那么从pos到数组末尾的元素都是大于B[j]的,数量就是n - pos。这两个二分查找合在一起,就能在O(log n)时间内算出一个B[j]的贡献。
2.2 完整代码实现:C++版本
我当时写这题最顺手的是C++版本,标准库里的lower_bound和upper_bound可以少写不少代码。核心代码如下:
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<int> A(n), B(n), C(n); for (int i = 0; i < n; i++) cin >> A[i]; for (int i = 0; i < n; i++) cin >> B[i]; for (int i = 0; i < n; i++) cin >> C[i]; sort(A.begin(), A.end()); sort(C.begin(), C.end()); long long ans = 0; for (int j = 0; j < n; j++) { int b = B[j]; int posA = lower_bound(A.begin(), A.end(), b) - A.begin(); int posC = upper_bound(C.begin(), C.end(), b) - C.begin(); long long cntA = posA; long long cntC = n - posC; ans += cntA * cntC; } cout << ans << endl; return 0; }这段代码里,我用lower_bound来统计A中小于b的数量,用upper_bound来统计C中大于b的数量。为什么C这里要用upper_bound而不是lower_bound?因为我们要的是“大于b”的个数,如果用lower_bound,它会定位到第一个大于等于b的位置,会把等于b的元素也算进“大于”的范围里,结果就错了。比如C = [1, 3, 3, 5],b = 3,lower_bound返回下标1,upper_bound返回下标3,n - 3 = 1,只有5这个元素是大于3的,逻辑是对的。
2.3 常见坑点:数据类型与int溢出
我第一次提交的时候,答案一直不对,排查了半天才发现问题出在数据类型上。假设n = 10^5,A中小于B[j]的数量最多是10^5,C中大于B[j]的数量最多也是10^5,单个B[j]的贡献最大是10^10。把所有B[j]的贡献加起来,总答案最大能达到10^15级别,这远远超过int的表示范围。所以在累加ans的时候,必须使用long long,cntA和cntC也要用long long来参与乘法运算,否则就会溢出。
我见过很多同学在这题上栽跟头,明明算法写对了,结果因为int溢出丢分,非常可惜。如果你用的语言是Java,对应要使用long类型;Python的话因为整数没有溢出问题,倒是可以不用太担心,但也要注意算法复杂度是否超标。
提示:在竞赛环境中,只要题目数据规模达到10^5,你就应该条件反射般地想到long long。这几乎是蓝桥杯高频踩坑点,在做题时先看数据范围再选类型,是减少失误的重要习惯。
2.4 二分边界条件自查手册
二分看似简单,但边界条件很恶心。我在给某位师弟代码review的时候,发现他在排序时把B也排了序,导致后面遍历B的顺序发生变化,答案完全错乱。这个问题值得单独提出来强调:B不要排序。因为我们要的是每个B[j]依次作为中间值去统计,如果B本身排序了,虽然每个B元素依然都会被统计,但由于题目计数的是三元组(i, j, k)的组合,B有序后三元组集合不变,本质上结果不会变。但从思维清晰的角度,建议不要画蛇添足,保持B原序,逻辑更直观。
二分的边界检查清单如下:
- lower_bound返回的是第一个大于等于目标值的位置,用它求“小于”个数时要确认目标值不在数组里时也正确。
- upper_bound返回的是第一个大于目标值的位置,用它求“大于等于”个数时要小心。
- 如果你用自己写的二分函数,要注意while循环退出条件和mid的更新方式,推荐模板:l = 0, r = n; while (l < r) { mid = (l + r) >> 1; if (a[mid] >= x) r = mid; else l = mid + 1; }
- 数组下标从0开始计数,posA可能为0,posC可能为n,这些都是合法边界,不需要特判。
3. 排序 + 双指针,以及前缀和优化方案
3.1 双指针:让统计从O(n log n)变成O(n)
二分方案的时间复杂度是O(n log n),因为外层遍历B用了n次循环,每次循环都在A和C上各做一次二分查找。能不能把二分查找也省掉?可以。思路是这样的:既然A和C都已经排好序了,那我们可以维护两个指针pa和pc,初始都指向数组开头。对于每一个B[j],我不断把pa向后移动,直到A[pa] >= B[j],此时A中下标小于pa的元素就是小于B[j]的;接着把pc向后移动,直到C[pc] > B[j],此时C中下标大于等于pc的元素就是大于B[j]的。
因为B数组本身没有排序,所以指针的移动方向始终是递增的,每个指针在整个过程中最多移动n次。这样总时间复杂度就变成了O(n log n)(排序)+ O(n)(统计),整体还是O(n log n),但常数更小,实际执行更快。双指针写法如下:
sort(A.begin(), A.end()); sort(C.begin(), C.end()); int pa = 0, pc = 0; long long ans = 0; for (int j = 0; j < n; j++) { int b = B[j]; while (pa < n && A[pa] < b) pa++; while (pc < n && C[pc] <= b) pc++; ans += 1LL * pa * (n - pc); }这个代码很长一段时间都是我的主力写法,原因很简单:好写、好调、不容易出边界问题。唯一需要注意的是,pa和pc是全局递增的,不会因为B[j]变小而回退。由于B的顺序不定,可能出现B[j]比前一个B[j-1]小的情况,这时候pa和pc不会回退,但统计结果依然正确吗?我当初也有过这个疑问。
答案是正确,但需要仔细想一下。假如B[j]突然变小了,pa已经因为前一个较大的B值往后移动了,那pa就会偏大,统计出的A中小于当前B[j]的数量岂不是多了?其实不会,因为while循环的条件是A[pa] < b,如果b变小了,A[pa]可能已经不满足条件了,但pa不会回退,导致统计值偏大。这确实是个问题。
我在实际测试中发现,如果B数组不是有序的,双指针方案就不能用来处理B的无序情况。正确做法是,B也需要进行一次拷贝排序,或者用另一种方式组织遍历顺序。更严谨的方案是:对B也排序,然后同时用双指针扫A、B、C,但这改变了问题结构,因为B的每个元素都还是会被统计一次,但B排序后原来的下标顺序就无关紧要了(题目本身也只关心值的大小关系),所以对B排序是可行的。把B排序后,再遍历B,pa和pc就能单调向右移动了。
3.2 前缀和:值域可控时的最优选择
如果题目给出的数组值范围比较小,比如每个元素都在0到100000之间,那我们可以开一个大小100001的桶数组,统计每个值出现的次数,然后计算前缀和。设cntB[x]表示B中值为x的元素个数,但更常用的是统计值域分布后直接对着值域做累加。
标准的前缀和解法是这样的:统计A数组中每个值出现的次数,然后对值域做前缀和,得到小于等于某个值的A元素个数;再统计C数组中每个值出现的次数,同时做后缀和,得到大于等于某个值的C元素个数。最后遍历B的每个值b,用A中“小于b”的数量乘以C中“大于b”的数量,累加得到答案。
这种方法的时间复杂度是O(n + m),m是值域大小。在数据范围受限的题目里是最快的,代码写起来也不算复杂。但缺点很明显:如果值域是10^9级别,开数组就爆了。所以使用前缀和方案前,必须先确认数据的取值范围。
我自己用前缀和方案写过一版,代码如下:
const int MAXV = 100005; int cntA[MAXV], cntC[MAXV]; long long preA[MAXV], preC[MAXV]; int main() { int n; cin >> n; for (int i = 0; i < n; i++) { int x; cin >> x; cntA[x]++; } for (int i = 0; i < n; i++) { int x; cin >> x; B[i] = x; } for (int i = 0; i < n; i++) { int x; cin >> x; cntC[x]++; } for (int v = 1; v < MAXV; v++) preA[v] = preA[v - 1] + cntA[v - 1]; for (int v = MAXV - 2; v >= 0; v--) preC[v] = preC[v + 1] + cntC[v + 1]; long long ans = 0; for (int j = 0; j < n; j++) { int b = B[j]; ans += preA[b] * preC[b]; } cout << ans << endl; return 0; }这里我对preA的定义是:小于v的元素总个数;preC的定义是:大于v的元素总个数。因为题目条件对i、j、k的下标没有任何限制,只要求值满足A[i] < B[j] < C[k],所以用值域统计完全正确。这个方案的巧妙之处在于,它跳过了排序,直接从值的分布去计数,思路更贴近“计数排序”的哲学。
3.3 三种方案怎么选?一个实战决策流程
我在教别人准备这类题目时,总强调一个决策顺序:先看数据范围,再看时间限制,最后结合自己的代码熟练度选择方案。
具体来说:如果n很小(比如100以内),三重暴力也能过,根本不需要优化;如果n达到10^5且值域小,优先上前缀和;如果值域大,就上排序+二分或排序+双指针。排序+双指针要求B也排序,思考难度稍微高一点;排序+二分是最稳妥的通用方案,代码也不长,我强烈建议作为默认选择。
写题的时候,我建议你把三种方案都亲手实现一遍。不是为了炫技,而是为了让你在考场上不管遇到什么变体,都能快速找到最顺手的解法。以我自己的经验,平时多写一种方案,考场上就会多一份从容。
4. 实际竞赛中的问题与排查技巧
4.1 实测踩坑清单
我在蓝桥杯训练阶段,以及后来带其他同学复盘的时候,集中遇到过这几个高频问题。下面把它们整理成一个速查表:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 答案偏大 | int溢出 | 所有涉及乘法累加的变量改为long long |
| 答案偏小 | C中计算大于个数时用了lower_bound | 改用upper_bound,或理解清楚二分细节 |
| 超时 | 用了三重循环 | 改排序+二分/双指针/前缀和 |
| 运行错误 | 值域数组越界 | 确认MAXV边界,留足余量 |
| 结果全为0 | 输入顺序理解错 | 确认循环里先读A再读B再读C,顺序不能换 |
| 重复元素统计错 | B排序导致指针回退 | 要么B也排序,要么用二分方案 |
4.2 一个印象深刻的调试经历
有一次我在帮师弟调试代码,他的思路选的是双指针,但输出总是比标准答案小。我们两个人对着代码盯了快20分钟,最后发现问题出在这一行:
while (pa < n && A[pa] < b) pa++;他把条件写成了A[pa] <= b,导致等于b的情况也被统计进来了,于是A中“小于b”的元素被多算了,答案反而应该偏大而不是偏小。后来我们又往下看,发现他C那边用的是C[pc] < b而不是C[pc] <= b,导致C中“大于b”的数量被少算了很多,两边一抵消,最后居然输出看起来差不多,但个别测试点就挂掉了。这种“看起来差不多”的错误最坑人,因为很难通过肉眼发现,只有对拍大量随机数据才能揪出来。
从那以后,我养成了一个习惯:每逢二分或双指针边界问题,本地写一个暴力对拍程序,随机生成n=1000以内的数据,分别用暴力和优化算法跑一遍,对比结果是否一致。这个习惯在大赛中救了我很多次。
4.3 对拍工具怎么写
如果你还没有对拍的习惯,我强烈推荐你现在就开始用。写一个Python脚本,随机生成测试数据,分别调用暴力程序和优化程序,比较输出。核心思路就是:暴力算法虽然慢,但逻辑简单、不容易错,适合当参考答案。
# 随机生成数据的脚本 gen.py import random n = random.randint(1, 1000) print(n) print(' '.join(str(random.randint(1, 10**9)) for _ in range(n))) print(' '.join(str(random.randint(1, 10**9)) for _ in range(n))) print(' '.join(str(random.randint(1, 10**9)) for _ in range(n)))然后用这段代码循环测试:
for i in $(seq 1 100); do python3 gen.py > test.txt ./violent < test.txt > out1.txt ./optimized < test.txt > out2.txt if diff out1.txt out2.txt > /dev/null; then echo "test $i OK" else echo "test $i FAILED" break fi done这套对拍流程,我从练习蓝桥杯开始用到现在,几乎成了条件反射。任何一个算法题,写完正解之后先对拍个几十组随机数据,比自己干瞪眼排查高效得多。
4.4 蓝桥杯判题环境的特殊注意事项
蓝桥杯的判题环境和本地环境可能存在细微差异。比如,在蓝桥杯OJ中,输入输出量大的题目,建议关闭同步流以避免TLE,C++里就是ios::sync_with_stdio(false);和cin.tie(nullptr);这两行。另外,蓝桥杯某些场次不支持C++17的一些新特性,我遇到过std::gcd在旧标准下没有定义的情况,所以保险起见,通用算法尽量只用C++11/14的特性。
还有一点,蓝桥杯传统上对文件名和类名有严格约定。如果你用Java提交,主类必须命名为Main;C++则没有强制类名,但流程控制写法要规范。我见过有人用C++写完代码,提交时忘了注释掉调试输出,结果多打印了几行导致Wrong Answer。这些细节虽然不是核心算法问题,但在真实比赛中直接决定你能否拿分。
重要提醒:如果你准备参加蓝桥杯,至少留出15到20分钟专门检查输入输出格式和文件命名规则。很多选手算法全对,却因为提交格式问题白白丢分,这种遗憾我每年都能看到。
4.5 从这道题延伸出去的思维模型
这道“递增三元组”做完之后,你会发现它背后的思维模型可以迁移到很多题目上。比如,任意给定三个数组,要求A[i] < B[j] < C[k]的问题,本质上就是“对中间元素计数,左右两侧各算一次满足条件的数量再相乘”。如果你遇到“A[i] + B[j] + C[k] = target”的题,同样可以用排序后固定中间元素、两侧双指针的思路。还有“求数组中有多少个三元组满足a + b > c”这类题,都是同一个模型。
我常说,刷题不只是刷完一道就过了,更重要的是提炼出这类题型的“母题模型”。当你拥有3到5个核心模型之后,遇到新题会自然地往这些模型上靠,思路打开的速度会快很多。递增三元组就是排序双指针模型的一个非常标准的母题代表。
4.6 做题节奏与临场心态
最后聊一点心态层面的经验。蓝桥杯这种比赛,时间有限,题目难度逐步递增。如果你在考场上遇到“递增三元组”这类中间难度的题,我的建议是:先花30秒判断自己熟悉的解题方案是否可用,如果能,立刻写代码;如果不能马上想到,先放下,做后面的题,最后回来再思考。卡在一道题上超过20分钟是很危险的,因为后面大概率还有更难的题等着你。
我本人有一次在模拟赛时就因为太纠结一道类似题目,导致最后一道大题只写了暴力,白丢了大量分。从那以后我养成了“时间盒”策略:每道题最多投入30分钟,如果没思路就切换到下一道,等第二轮再回来看。事实证明,很多时候换一个时间节点,思路反而豁然开朗。
5. 总结与后续扩展思路
这道“递增三元组”虽然只是一道竞赛题,但它涉及的排序、二分、双指针、前缀和,以及边界处理、类型溢出等知识点,都是算法竞赛中反复出现的核心基本功。如果你能把这四种解法都吃透,不仅是这一道题,很多同类的“统计区间满足条件数量”问题都能顺手解决。
我个人在实际使用中的体会是,这题最值得深入思考的地方不是“怎么写代码”,而是“为什么这样写是对的”。比如双指针为什么只能有序遍历、前缀和为什么要求值域可控、二分的时候边界到底包不包括等号——这些细节的思考深度,才真正决定了你在赛场上的临场发挥。踩过几次坑之后,你会发现那些看似要命的Bug,本质上都是对某个“小逻辑”没想透彻。
最后再分享一个小技巧:多把一份AC代码拿来做各种实验——改一改边界条件,换一换排序方向,加一加重复元素。这种“故意搞破坏”式的练习,是最快提升手感的方法。保持这个习惯,等你下次遇到类似题时,脑子里就不再是模板,而是一套真正属于你自己的判断体系。