☰
洛谷P5728旗鼓相当的对手:暴力枚举与边界条件的经典入门题
2026/10/6 4:24:21 网站建设 项目流程

在洛谷逛题单的时候,P5728这道“深基5.例5”的旗鼓相当的对手,几乎是每个入门选手都会碰到的一道题。它看起来特别简单,不就是两两比较一下成绩嘛,可真要上手写,你会发现里面埋了不少小坑:有人忘了取绝对值,有人三重循环写成了全排列,还有人答案翻倍却死活查不出来。这篇博文就把这道题从里到外拆开讲清楚,从题目思路到多语言代码,再到常见的报错和迷之WA,一次给你说明白。不管你是刚开始刷题的大一新生,还是准备蓝桥杯、ACM的新手,这道题都值得你停下来认真看完。

这道题本质上是“数组 + 枚举”的经典组合:给你N个学生的三科成绩,让你统计有多少对学生满足“旗鼓相当”的条件。它本身不涉及任何高深算法,但它是训练“读题能力”和“边界意识”的好素材。很多选手到了后期遇到WA,不是算法不对,而是边界条件没考虑清楚。P5728就是把这个问题浓缩到了一个很简单的模型里,让你用最小的代价去体会这种思维。

1. 题目背景解读与核心思路

1.1 题面到底在说什么

先回到题面本身。输入的第一行是一个整数N,表示学生人数。接下来N行,每行三个整数,分别代表一个学生的语文、数学、英语成绩。题目要求输出一个整数,表示“旗鼓相当的对手”的对数。什么样的两个人算旗鼓相当?条件是同时满足:语文成绩之差的绝对值不超过5,数学成绩之差的绝对值不超过5,英语成绩之差的绝对值不超过5,且三科总分之差的绝对值不超过10。

注意“且”这个字,四个条件缺一不可。很多人做到后面发现样例过不了,就是因为少写了一个总分判断,或者把“差值绝对值”写成了“差值本身”。这道题的核心词是“绝对值”和“不超过”,数据处理上必须用abs函数,边界上必须允许等于5和等于10的情况存在。

另外要判断的是一对选手的组合数,不是排列数。也就是说,学生A和学生B如果满足条件,只能算一对,不能因为“先看A再看B”和“先看B再看A”就算两次。这是后面写循环的时候最需要注意的点。

1.2 数据范围决定了暴力解法可行

做任何一道题,先看数据范围是一等一的好习惯。P5728给的N上限是1000。N等于1000时,两两组合有多少种?直接用组合数公式C(1000, 2) = 1000×999/2 = 499500,不到50万。对于现代CPU来说,50万次循环加上几次整数比较,运行时间连1毫秒都用不到。

所以这道题根本不需要任何优化技巧,O(N^2)的暴力枚举就是最合理的方案。有些同学看到题目第一反应是排序、离散化、双指针,这属于杀鸡用牛刀。当然,如果N变成了10万,那才需要考虑别的思路,到后面第6节我会展开说。但在N≤1000的条件下,暴力就是最简单、最不容易出错、最容易验证正确性的方案。

写代码之前先算一下复杂度,这是很多新手容易忽略的环节。不估算复杂度,就可能写出一个O(N^3)的循环还浑然不觉,到N=1000的时候可能就是几亿次操作,直接给你跑出一个TLE。P5728用两重循环就足够,三重循环纯属多余。

1.3 从题面到算法的三步转化

把题面翻译成代码,总共就三步。

第一步,确定存储方式。每个学生有语文、数学、英语、总分四个属性,需要一个结构体或者几个并行的数组来存。第二步,预处理总分。在读入每个学生的三科成绩后,立刻算出总分并保存,避免后面每比较一对就重新加一遍三科成绩。第三步,双循环枚举所有组合。外层循环固定第一个学生i,内层循环从i+1开始枚举第二个学生j,保证每一对学生只被统计一次。

三步走完,剩下的事情就是写四个if判断,然后用一个计数器累加。整个思路没有任何分支嵌套的复杂性,但每一步都有对应的细节。存储方式用结构体还是平行数组,预处理放在什么位置,循环下标从0开始还是从1开始,这些看似不起眼的选择,决定了代码的清晰程度和排错难度。

2. 存储方案:结构体与平行数组的取舍

2.1 为什么推荐用结构体

一个学生的成绩信息包含语文、数学、英语、总分,共四个整型变量。最朴素的做法是开三个数组分别存三科成绩,再开一个数组存总分。这样写不是不行,但会有两个问题:第一,数组一多,代码里就会出现a[i]、b[i]、c[i]、sum[i]四处下标,读起来很割裂;第二,如果想在判断函数里把一个学生作为整体传进去,平行数组做不到,只能传四个参数,或者传递下标。

用结构体就好办多了。把学生定义成一个struct,四个成员变量放在一起,每个学生就是结构体数组里的一个元素。判断时写s[i].chinese、s[j].chinese,语义清晰,一眼就能看出是在比较语文成绩。我自己的习惯是给结构体起名叫Student,成员用chinese、math、english、sum,这样代码的可读性最好。有的同学喜欢用a、b、c这种简短名字,也不是不行,但等你看一个月之后回来看自己的代码,大概率会忘记a到底代表语文还是数学。

结构体还有一个额外的好处:它为后面学习类、对象、以及更复杂的排序比较函数打基础。在洛谷后面的题目里,你会频繁遇到“按照某个关键字排序”的需求,那时候结构体配sort的cmp函数就是标准解法。P5728让你提前熟悉结构体的使用,这笔账怎么算都不亏。

2.2 总分预计算:一次算好,反复使用

从数学角度讲,每个人三科成绩的和,无论比较多少次都不会变。那么在读入数据时顺手把sum算出来存好,是最划算的。假设N=1000,两两组合近50万次,如果不预计算,每次比较都要执行三次加法再算总分差,总计算量就是150万次加法。预计算只需要在输入时做1000次加法,后面全部直接用减法和abs,性能高出一个数量级。

当然,在N=1000的数据范围下,这种性能差异根本体现不出来,但在写代码时养成“能预计算就预计算”的习惯非常重要。特别是当你从入门题过渡到更复杂的题目时,预计算和前缀和的思想会反复出现。P5728给你一个应用的场景,让你在简单题里就建立这种意识。

预计算的位置也有讲究。最保险的写法是在读入三科成绩后立刻执行sum = chinese + math + english,因为此时数据完整且连续。有人喜欢单独开一个循环再算一次总分,也不是不行,但那样要多写一个循环,而且如果中途有人改了数组数据,容易忘记同步更新。紧跟着读入算总分,逻辑上一气呵成。

3. 条件判断与边界细节

3.1 四个条件缺一不可

旗鼓相当的定义中有四个限制条件:语文成绩差不超过5,数学成绩差不超过5,英语成绩差不超过5,总分差不超过10。四个条件用逻辑与&&连接,中间任何一个不成立,这一对都不能计数。

举个反例你就明白为什么必须检查全部四个条件。有两位同学,A的成绩是90、80、70,总分240;B的成绩是85、80、75,总分240。A和B总分一模一样,差为0,一看就是“旗鼓相当”的好苗子。但仔细看数学成绩,A是80,B也是80,没问题;语文差5,也没问题;英语差5,没问题的前提是边界取等成立。这四个人都满足,所以这对是符合条件的。

但如果B的英语是76呢?A英语70,B英语76,差6,超过了5。虽然总分还是240对240,总分差完全满足,但英语单科的差距已经超出了“旗鼓相当”的范围,这对就不能算。这就是为什么题面特别强调“各科成绩之差的绝对值都不超过5,且总分之差的绝对值不超过10”,四个条件独立且必须同时满足。

我还见过一种错误写法:把两个学生的总分分别存到sum[i]和sum[j],判断的时候只写了abs(sum[i] - sum[j]) <= 10,完全没有检查单科成绩。这样写样例大概率是过的,因为样例数据碰巧满足,但一旦遇到单科差距大、总分接近的数据,立刻WA。做题不能只盯着样例,要盯着题面每一个字。

3.2 绝对值与边界:差5和差6的区别

“绝对值”三个字,用代码表示就是abs函数。在C++中,整数的绝对值用abs(),在 或 中声明;在Java中是Math.abs();在Python中是内置的abs()。

为什么要强调绝对值?因为如果不取绝对值,直接判断s[i].chinese - s[j].chinese <= 5,当i的语文成绩小于j时,这个差值就是负数。负数是永远小于5的,所以条件恒成立,这就会导致那些差的很大的一对学生被错误地计入答案。比如A语文60,B语文90,A减B等于-30,-30<=5成立,实际语文差了30,早就超过5了,但你的代码会把他们当成旗鼓相当的对手。

边界问题同样关键。“不超过5”在数学上表达为“≤5”,也就是差值为5时仍然满足条件。很多同学在写判断的时候会不小心写成<5,导致差值为5的合法情形被漏掉。同理,总分差“不超过10”允许等于10。这类边界问题在入门题里经常出现,P5728的测试数据中一定会包含差值为5和10的用例,就是为了检测你有没有考虑清楚。

4. 多语言参考实现

4.1 C++ 完整代码与逐行注释

C++是洛谷上最常用的语言,下面这份代码我加上详细的注释。语言版本选C++17,洛谷的编译器完全支持。

#include <bits/stdc++.h> using namespace std; struct Student { int chinese, math, english; int sum; }; Student s[1005]; int main() { int n; cin >> n; for (int i = 1; i <= n; i++) { cin >> s[i].chinese >> s[i].math >> s[i].english; s[i].sum = s[i].chinese + s[i].math + s[i].english; } int ans = 0; for (int i = 1; i <= n; i++) { for (int j = i + 1; j <= n; j++) { if (abs(s[i].chinese - s[j].chinese) <= 5 && abs(s[i].math - s[j].math) <= 5 && abs(s[i].english - s[j].english) <= 5 && abs(s[i].sum - s[j].sum) <= 10) { ans++; } } } cout << ans << endl; return 0; }

下标从1开始是为了让第i个学生的下标i读起来更自然。数组开1005而不是1000,是因为下标最大用到n,n为1000时1005留出余量,这是洛谷选手的传统艺能。四个判断条件用&&连接,格式上每个条件占一行,缩进对齐,逻辑上非常清晰。ans是计数器,输出前不需要初始化之外的多余操作,直接cout即可。

<bits/stdc++.h>是洛谷等GCC环境支持的万能头文件,包含几乎所有标准库。有的学校OJ在Windows环境用Visual Studio,不支持这个头文件,那时候就需要换成具体的头文件,比如#include 、#include 。我个人的建议是:在洛谷刷题用<bits/stdc++.h>没问题,但如果以后参加比赛,最好还是熟悉一下显式包含需要的头文件。

4.2 Java 与 Python 实现要点

Java代码在洛谷上有一个特别的约束:主类名必须叫作Main,否则无法通过编译。这是我见过不少Java选手踩过的坑。写Java版时,把Student定义成静态内部类或普通类都可以,但记得Scanner读入不要忘记import java.util.Scanner。

import java.util.Scanner; public class Main { static class Student { int chinese, math, english, sum; } public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); Student[] s = new Student[n + 1]; for (int i = 1; i <= n; i++) { s[i] = new Student(); s[i].chinese = sc.nextInt(); s[i].math = sc.nextInt(); s[i].english = sc.nextInt(); s[i].sum = s[i].chinese + s[i].math + s[i].english; } int ans = 0; for (int i = 1; i <= n; i++) { for (int j = i + 1; j <= n; j++) { if (Math.abs(s[i].chinese - s[j].chinese) <= 5 && Math.abs(s[i].math - s[j].math) <= 5 && Math.abs(s[i].english - s[j].english) <= 5 && Math.abs(s[i].sum - s[j].sum) <= 10) { ans++; } } } System.out.println(ans); sc.close(); } }

Python版本最简洁,直接用元组存储四个值,循环时用enumerate或者range都行。

n = int(input()) students = [] for _ in range(n): chinese, math, english = map(int, input().split()) students.append((chinese, math, english, chinese + math + english)) ans = 0 for i in range(n): for j in range(i + 1, n): a = students[i] b = students[j] if (abs(a[0] - b[0]) <= 5 and abs(a[1] - b[1]) <= 5 and abs(a[2] - b[2]) <= 5 and abs(a[3] - b[3]) <= 10): ans += 1 print(ans)

Python的元组下标0、1、2、3分别代表语文、数学、英语和总分。用变量a、b分别把两个学生的数据取出来,判断时就不需要反复写students[i]这种长串,代码读起来清爽很多。值得一提的是,Python的缩进决定逻辑层级,这里的缩进和注释我都按洛谷上的习惯写好,直接复制提交就能过。

4.3 用一组测试数据验证代码正确性

写完了代码,一定要自己造一组数据验证一下。我常用的验证方式是设计一个包含边界情况的小用例:

4 70 80 75 72 79 74 85 90 88 80 82 86

手工算一下答案。1号学生语文70、数学80、英语75,总分225;2号学生72、79、74,总分225。1和2比较:语文差2、数学差1、英语差1、总分差0,四个条件全部满足,这是一对。1和3比较:语文差15,超了,不行。1和4比较:语文差10、英语差11,都不行。2和3比较:语文差13,不行。2和4比较:语文差8、英语差12,不行。3和4比较:语文差5、数学差8,语文刚好相等临界,但数学超了,不行。

所以正确答案是1。这组数据包含了两对关键边界:3和4的语文差恰好等于5,验证了“不超过”允许取等;1和3总分差38远大于10,验证了条件筛选的正常逻辑。把这组数据喂给代码,如果输出1,说明基本的判断逻辑没问题;再试几组其他数据,就能覆盖更多分支。

5. 常见错误与排查技巧

5.1 重复计数:i<j 的重要性

统计的是对数的数量,不是排列的数量。C++代码中外层循环i从1到n,内层循环j从i+1到n,保证每一对(i,j)满足i<j,这样(i,j)组合只会出现一次。如果写成j从1到n,那么(1,2)和(2,1)会被统计两次,答案直接翻倍。

我曾经在群里帮一个同学debug,他问为什么答案刚好是正确结果的两倍。我一看代码,两层循环都是1到n,还专门用if(i == j) continue跳过自己和自己比较,但(i,j)和(j,i)依然被当成了两组。这个错误在数据规模小的时候特别隐蔽,因为样例可能恰好只有一对或两对,翻倍之后可能和正确答案巧合一致,或者翻倍后差距不大让你以为是自己另外的条件写错了。实际上就是最单纯的重复计数问题。

还有一个相对隐蔽的重复计数点:如果数组下标从0开始,内层循环写成j=i;如果从1开始,内层写成j=i+1。两种写法都能得到正确答案,但混着用就容易出问题。我的建议是统一从1开始,读入时下标从1赋值,循环也相应调整,这种风格在洛谷很多题解中都很常见,不容易搞混。

5.2 条件运算符:&& 与 || 的区别

四个条件需要用逻辑与&&连接,这是题面“同时满足”的直接翻译。用得最多的是&&。用||连接的效果是只要有一个条件成立就算旗鼓相当,那几乎所有的两两组合都会被算进去,答案会大的离谱。

我见过有人把条件写成这样的:

if (abs(s[i].chinese - s[j].chinese) <= 5 || abs(s[i].math - s[j].math) <= 5)

这就是明显的逻辑错误。还有人在换行时格式不统一,可能漏掉一个abs,或者把<=写成>=。符号写错不报编译错误,代码能跑,但结果就是WA,而且很难一眼看出来。我的排查手段是:先检查abs函数是否对四个差值都用了,再检查比较符号是不是<=,最后数一遍是不是恰好有四个<= 5和一个<= 10。把这道题的判断条件当成一个清单来核对,比盯着屏幕空想要高效得多。

5.3 数组越界与初始化问题

结构体数组声明为s[1005]或者s[1005]可以容纳下标0到1004。下标从1开始使用时,最大下标是n,n的最大值是1000,所以1005足够。有些同学粗心写成s[1000],当n=1000时最后一个元素的下标是1000,数组只能访问0到999,直接越界。这种越界在洛谷的评测环境中通常表现为莫名其妙的Runtime Error,或者有时候数组越界会覆盖其他变量的值,导致答案时对时错。

初始化问题相对少见,但也不能忽略。如果你定义的是局部数组,比如在main函数里面定义的s[1005],不会自动清零。不过本题中每个下标都会在输入循环中被赋值,所以不需要额外初始化。如果你是开了一个计数器数组来统计某些东西,才需要注意清零问题。对于P5728来说,只要数组开够大,几乎不存在初始化相关的坑。

5.4 洛谷提交时要注意的细节

洛谷的在线评测系统对语言有严格的要求。C++代码直接复制过去,语言选择“C++11”或“C++17”都行。Java代码的主类必须叫Main,且不带package语句。Python代码注意Python版本,有的题目支持PyPy3和Python3,选哪个都行,但Python的输入处理用sys.stdin.readline会比input()快一些,大数据量时更有优势。P5728的数据量不大,两种输入方式都可以,但养成用stdin.readline的习惯对以后刷难题有帮助。

另外,洛谷对代码长度和提交频率也有一定限制,但正常使用不会碰到。如果你把代码交上去得到WA,不要慌着改一个地方就交一次,那样既浪费时间又容易把评测账号的提交次数刷掉。正确做法是先在本地把测试数据跑一遍,想清楚原因再重新提交。我在打基础的时候,经常在本地故意构造各种边界数据来测试自己的代码,比如全部成绩都相同、全部成绩差值刚好5、N=1的特殊情况等。

6. 延伸扩展:数据变大了怎么办

6.1 只有总分条件时:排序加双指针

如果题目改成一个简化版本:只需统计总分差不超过10的对数,不关心单科成绩。这时N可能会变成10万,O(N^2)的枚举直接超时。简单做法是把所有人的总分排序,然后用双指针统计。排序后数组有序,对于每个左端点i,用右指针向右扩展到第一个满足总分差大于10的位置,这个区间内所有j和i都能形成合法对。双指针的均摊复杂度是O(N),配合排序的O(N log N),可以轻松处理10万甚至更大的数据。

这个方法的核心思想是:当条件只和单一属性有关时,排序破坏了原来的无序状态,让“和当前元素差不超过限制”的所有元素聚在一起,通过移动指针快速统计。P5728之所以不需要这么做,是因为它同时有三个单科条件和一个总分条件,条件多了之后排序就失去了直接意义。

6.2 多维条件时:这道题是三维偏序的雏形

如果强行把P5728扩展成N=10万的完整版本,要同时满足三科成绩差和总分差四个条件,这就变成了一个经典的多维偏序计数问题。简单来说,就是统计二维或三维空间中满足特定偏序关系的点对数量。解决多维偏序的常规思路是分治,比如CDQ分治配合树状数组,或者用树套树等高级数据结构。这种内容的难度就远远超出入门范畴了。

P5728的真正教学价值在于:它以最小的复杂度、最直观的方式,让你提前接触“多维条件配对计数”这个模型。等你以后学到二维偏序、三维偏序时,再回头看这道题,你会明白当时用的暴力枚举是朴素而可靠的,也能理解为什么数据范围增加后必须引入新的算法。

对我个人而言,每过一段时间我都会把这道题翻出来重新看一遍。不是为了AC它,而是提醒自己:再复杂的题目都是从最基础的存储、循环、判断开始搭建的。把简单题做透,把边界想清楚,比盲目刷难题有用得多。P5728就是这样一个起点,它不欺负新人,但会认真考察你是不是一个细心的人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询