东华OJ这套题的编号比较靠前,难度本身不高,但我一直觉得基础题比难题更容易暴露真实水平。21到25这五题,我断断续续刷了三天,中间因为空行、数组边界、二分终止条件这些不起眼的细节,来回交了好几次。这篇就把自用记录整理成文,如果你正好也在东华OJ做题,或者刚开始接触这类在线判题系统,可以直接拿思路去对照,少走一点弯路。
我给自己定的目标是:每道题都能说清楚“为什么这么写”,而不是“碰巧过了”。所以这篇文章不光是代码堆砌,还会把每道题的思路来源、复杂度变化、WA(Wrong Answer)时的排查过程都写出来。东华OJ的评测对格式要求比较严,这类细节在实际刷题中特别容易被忽略。
1. 东华OJ题号21-25在刷题序列中是什么定位
1.1 东华OJ的平台特点与我的使用背景
东华OJ是东华大学的在线判题系统,常见于数据结构、算法设计课程和ACM校内训练。和洛谷、POJ、HDUOJ这些平台相比,它的题量不算大,题目更贴近课堂教学内容,题面大多是简化过的经典问题,很少出现特别偏门的算法或数据结构。评测机对输入输出格式比较严格,空格、换行、大小写都可能成为WA的原因,这点刚开始用的时候容易不习惯。
题号21-25属于题库里比较靠前的一组,整体难度在基础入门档。但基础不代表无脑,我刷下来发现,这五题覆盖了几种非常核心的算法模式:因子枚举、辗转相除、双指针、素数筛、二分查找。后面做链表、线段树、动态规划时,很多底层操作还是会回到这些模式上。
需要说明一点:不同学期、不同老师建的题库,题号对应的题目可能不完全一样。我按自己在账号里刷到的版本来记录,题目大意尽量贴合原题,但如果你看到的题号对不上,重点看思路,不要纠结具体编号。算法这种东西,换个马甲还是同一个内核。
1.2 给“自用”记录定的三条原则:完整、可复现、有坑必记
整理记录前,我先给自己立了三条规矩。
第一,题目大意必须写在开头。方便以后回看时不用重新打开OJ,也能在写题解时快速回忆场景。第二,代码必须能直接提交并通过,不写半截伪代码,不留下“这里省略若干行”这种模糊地带。第三,也是最要命的,每个WA的原因都得记下来,哪怕只是少了一个换行符。
前两条是方便后续复习,第三条才是真正值钱的部分。因为刷题最怕的不是不会写,而是写对了却不知道为什么对,错了也不知道为什么错。21-25这五题正好把格式化输出、数组越界、二分终止条件这些基础坑集中暴露了一遍。我在记录里给每个坑都标了解决时间、问题现象、排查步骤,后面章节会专门展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 21-25题逐题复盘:思路、代码与易错点
2.1 第21题:完数判定
题目大意:输入一个正整数n,输出1到n之间的所有完数,每个完数占一行。完数指恰好等于它所有真因子之和的数,例如6 = 1 + 2 + 3。
我最初的思路很直接:对每个数i,从1到i-1枚举所有可能因子,能整除就累加。这个写法的循环次数约等于n的平方,当n到10000时就是上亿次操作,东华OJ的1秒时限很容易卡住。所以必须优化因子枚举范围。
关键推论是:如果j是i的因子,那么i/j也一定是i的因子。也就是说,我只需要枚举j从2到sqrt(i),找到一个因子时,就把j和i/j同时累加进因子和。这样单个数的时间从O(i)降到O(sqrt(i))。
cpp复制#include <iostream>
using namespace std;
int main() {
int n;
cin >> n;
for (int i = 2; i <= n; i++) {
int sum = 1; // 1 是所有大于1的数的真因子
for (int j = 2; j * j <= i; j++) {
if (i % j == 0) {
sum += j;
if (j != i / j) { // 防止平方因子重复加
sum += i / j;
}
}
}
if (sum == i) {
cout << i << endl;
}
}
return 0;
}
这里有几个容易错的地方。第一个是sum的初始值必须是1,不是0,因为1是任何大于1整数的真因子。第二个是循环要从i=2开始,1不算完数。第三个是j != i / j的判断不能漏,比如i=36时,j=6时会把6加两次,完数判断就会被污染。
第四点是我最开始完全没想到的:题目要求的是“每个完数占一行”,还是“所有完数用空格分隔”?我按空格分隔实现了一次,结果WA。仔细读题才发现每个占一行。这种题面细节在OJ上非常常见,刷题的第一步永远是确认输出格式。
2.2 第22题:最大公约数与最小公倍数
题目大意:输入两个正整数a和b,输出它们的最大公约数和最小公倍数,中间用空格隔开。
最大公约数直接用辗转相除法,这是欧几里得留给我们的经典算法。原理一句话:gcd(a, b) = gcd(b, a % b)。因为a和b的公因数,一定也是b和a%b的公因数,反过来也成立,所以可以不断缩小问题规模,直到余数为0。
cpp复制#include <iostream>
using namespace std;
int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
int main() {
int a, b;
cin >> a >> b;
int g = gcd(a, b);
cout << g << " " << a / g * b << endl;
return 0;
}
最小公倍数不是硬算出来的,而是基于一个恒等式:a * b = gcd(a, b) * lcm(a, b)。所以lcm = a / gcd * b。
这里有一个非常重要的习惯:先除后乘,不要先乘后除。如果写a * b / g,当a和b都接近int上限时,a * b会直接溢出成负数,结果全错。虽然东华OJ这题的数据范围不一定卡到这个程度,但养成先除后乘的习惯能省掉很多隐蔽的溢出问题。
还要注意递归gcd时,并不要求调用前a一定大于b。假如输入是a=6, b=12,第一次递归会变成gcd(6, 6 % 12),也就是gcd(6, 6),再下一步gcd(6, 0)返回6,结果依旧正确。所以不用单独做大小判断。
2.3 第23题:回文串判断
题目大意:输入一个字符串s,判断它是否为回文串,如果是输出Yes,否则输出No。回文串是指正读和反读一样的字符串,例如"abcba"。
最容易想到的思路是把字符串反转过来再比较,但这样需要额外的O(n)空间。更清爽的做法是双指针:一个指针从开头往右走,一个指针从末尾往左走,每次比较两个指针指向的字符,只要遇到不相等就直接判定不是回文。
cpp复制#include <iostream>
#include <string>
using namespace std;
int main() {
string s;
cin >> s;
int left = 0;
int right = (int)s.size() - 1;
bool ok = true;
while (left < right) {
if (s[left] != s[right]) {
ok = false;
break;
}
left++;
right--;
}
cout << (ok ? "Yes" : "No") << endl;
return 0;
}
这题最大的坑在s.size()的返回值类型上。size()返回的是size_t,也就是无符号整数。如果直接写int right = s.size() - 1,当字符串为空时,s.size()是0,0 - 1在无符号语义下会变成一个巨大的正数,而不是-1。这会直接导致越界访问。我记录里特意标了一笔:字符串下标操作前,先把s.size()强转成int再参与运算。
另一个容易忽略的情况是单字符字符串。比如输入"a",left=0,right=0,while循环条件0 < 0不成立,直接输出Yes。这个分支天然正确,但如果不清楚为什么正确,很容易在写的时候多加一个多余判断,反而引入bug。
我在这题下面备注了一句:如果题目改成“忽略空格和大小写再判断回文”,就不能用cin直接读字符串了,因为cin遇到空格会截断。那种情况需要改用getline(cin, s),并额外对每个字符做tolower()和过滤处理。21-25这组里我没遇到变体,但提前想清楚是值得的。
2.4 第24题:素数筛法
题目大意:输入一个正整数n,统计1到n之间素数的个数。
判断素数的朴素方法是枚举2到sqrt(i),对每个数独立判断,整体复杂度大约O(n√n)。n小的时候没问题,n大到几十万上百万就会很吃力。所以这题我直接用埃氏筛(Eratosthenes筛法),一次性预处理出1到n的所有素数标记。
埃氏筛的核心思想是:如果一个数i是素数,那么i的倍数2i、3i、4i...一定都不是素数。从2开始,依次把每个素数的倍数标记成合数,剩下的就是素数。
cpp复制#include <iostream>
#include <vector>
using namespace std;
int main() {
int n;
cin >> n;
vector<char> isPrime(n + 1, 1); // char,不要用 bool/vector<bool>
if (n >= 0) isPrime[0] = 0;
if (n >= 1) isPrime[1] = 0;
for (int i = 2; 1LL * i * i <= n; i++) {
if (isPrime[i]) {
for (long long j = 1LL * i * i; j <= n; j += i) {
isPrime[(int)j] = 0;
}
}
}
int cnt = 0;
for (int i = 2; i <= n; i++) {
if (isPrime[i]) cnt++;
}
cout << cnt << endl;
return 0;
}
筛法里的优化点很多。第一,内层循环从i * i开始,而不是从2 * i开始。因为对于任意小于i的倍数,比如k*i(k<i),这个合数已经在枚举素数k时被标记过了,重复标记只会浪费时间。第二,判断条件i * i <= n要防止int溢出,所以我写成1LL * i * i <= n,把乘积提升到long long再比较。
第三,也是最容易踩的坑:vector<bool>在C++里是一个特化版本,内部按位存储,返回的不是真正的bool&引用。用它做筛法正确性没问题,但一旦涉及取地址、引用绑定等操作,就会报出各种各样的编译错误。我在这题改用vector<char>,每个元素占一个字节,语义和操作都跟普通数组一致,完全避开这个坑。具体排查过程在第4章再展开说。
2.5 第25题:二分查找
题目大意:输入一个非降序排列的数组长度n、目标值m,以及n个整数,输出m在数组中的位置(从1开始计数),如果找不到输出-1。
二分查找的思路不复杂:维护一个查找区间[left, right],每次取中点mid,比较a[mid]和目标值。相等就找到了;目标值比中间值大,说明目标值在右半边,收缩左边界;目标值比中间值小,收缩右边界。每次把搜索范围缩小一半,复杂度O(log n)。
cpp复制#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, m;
cin >> n >> m;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
int left = 0, right = n - 1;
int ans = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (a[mid] == m) {
ans = mid + 1; // 题目要求从1开始计数
break;
} else if (a[mid] < m) {
left = mid + 1;
} else {
right = mid - 1;
}
}
cout << ans << endl;
return 0;
}
写二分最容易出问题的是循环条件和边界更新的搭配方式。我用的是while (left <= right),配合left = mid + 1和right = mid - 1。这套组合里,每个分支都会让区间严格缩小,不会出现死循环,循环结束时如果没找到,ans保持-1。
有一个细节值得单独说:mid的计算。很多人习惯写(left + right) / 2,这在数据量小的时候没问题,但当left和right都接近int上限时,两者相加就会溢出。换成left + (right - left) / 2之后,从根本上避免了加法溢出。自用记录里我特意把这种“安全写法”固定成模板,以后写排序和二分相关题目都用这一套。
3. 刷这五题时反复用到的底层原理
3.1 枚举范围为什么要缩到sqrt(n):数学依据与实测
第21题的完数判断和第24题的素数判断,都用到了同一个数学事实:一个正整数n如果不是完全平方数,那么它的因子一定是成对出现的,一个小于等于sqrt(n),另一个大于等于sqrt(n)。所以只要枚举到sqrt(n),就可以通过“找到一个因子时同时处理它的配对因子”来覆盖全部因子。
以n=10000为例。朴素枚举从1到9999,需要做9999次取模运算;优化后从2枚举到100,只需要99次取模,每次还顺带处理了配对因子。计算量差了差不多100倍。n达到10万、100万时,这个差距会进一步放大到几百倍、几千倍。OJ评测限时通常只有1秒,时间复杂度差的不是一点半点。
这个思想在第24题里也有体现。素数筛内层循环从i * i开始,就是利用了小于i的倍数已经被更小的素数标记过这一性质,避免重复遍历。说白了,这些优化本质上都是在找“哪部分枚举是多余的”。
3.2 边界条件与循环不变式:回文和二分背后的共性
回文判断和二分查找,表面上看完全不同,但代码结构上都依赖一个“循环不变式”:在每轮循环开始前,我们要查找或者说要考察的区间,一定是[left, right]这样一个明确的范围。
回文判断中,left左边和right右边的字符都已经被确认是对称相等的。每一轮循环只比较当前left和right指向的字符,然后向中间收缩。如果中途发现不相等,立即确认不是回文;如果left和right相遇或者交错,说明所有字符都匹配完毕,确认是回文。
二分查找中,不变式是:如果目标值存在,那它一定在区间[left, right]里。每轮循环取中点比较,要么命中直接结束,要么把一半区间排除掉,然后继续维护这个不变式。关键是区间收缩时,left = mid + 1和right = mid - 1必须写正确,否则就会破坏“目标值一定在区间内”这个前提,可能出现死循环或者漏解。
第4章会专门记录我在这类边界条件上栽的一次跟头,那次教训比看懂代码深刻得多。
3.3 时空复杂度在OJ上意味着什么
把21-25这五题放在一起看,复杂度区别非常明显:
| 题号 | 算法 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 第21题 | 因子枚举优化 | O(n√n) | O(1) |
| 第22题 | 辗转相除法 | O(log min(a,b)) | O(1) |
| 第23题 | 双指针 | O(len) | O(1) |
| 第24题 | 埃氏筛 | O(n log log n) | O(n) |
| 第25题 | 二分查找 | O(log n) | O(1) |
为什么要关心复杂度?因为OJ判题有时间和空间限制。一个O(n²)的代码,在n=1000时还能跑,到n=10000就开始超时,到n=100000几乎必然超时。第21题我一开始写的就是O(n²),本地测试了几个小数字没问题,一提交就TLE(Time Limit Exceeded),这时候才意识到必须优化因子枚举。
另外,第24题是这五题里唯一需要额外数组的题目。筛法需要O(n)的空间来存素数标记,空间复杂度从O(1)涨到O(n)。在n达到10的7次方或更高的题目里,这种空间开销就需要认真考虑了。实际工程里常说“空间换时间”,在OJ题目里也同样成立,关键是心里有数,知道自己写的代码大概占了多少内存。
4. 真正让我卡住的三个地方:排查链路与教训
4.1 完数题输出格式的坑:多了一个空行
第21题我第一次提交返回WA,本地跑却看不出问题。当时很郁闷,代码逻辑我已经检查过好几遍,完数判断也正确。
我把排查链路一步步重建出来就会发现:问题根本不出在逻辑,而在输出。我的代码在找到完数时执行cout << i << endl;,每个完数后面都换行了。这看起来没什么问题,但有些OJ的判题对“行尾空格”和“多余空行”非常敏感。当输出完最后一个数字后,如果程序又额外输出了一个空白行,评测器会把多出来的换行视为多余字符,然后返回WA。
当时我怎么发现的?我把输出重定向到文件,再用十六进制工具观察末尾字节,看到最后一个数字后面多了一个0A换行符,正好是题目描述里没要求的。修正方案是提前把所有结果存起来,最后统一按题目要求的格式输出,而不是边找边打印。
这个坑给我最大的启发是:OJ的判题是逐字节比较的,不是人眼看“差不多就行”。所以写输出时要想清楚:末尾该不该有换行、行间该不该有空格、字母大小写是否匹配,这些细节和算法本身同样重要。
4.2 vector的认知误区:代理对象的陷阱
第24题筛素数,我第一次用vector<bool>写,代码在本地编译运行都正常,但当我尝试把isPrime[i]当作普通布尔变量传入某个函数时,编译报错。一开始我以为是编译器问题,后来查资料才明白,vector<bool>不是普通容器。
C++标准库里的vector<bool>是一个特化版本,为了节省空间,内部按位存储每个布尔值。这意味着operator[]返回的不是bool&引用,而是一个叫_Bit_reference的代理对象。日常的读、写操作没感觉,但一旦做取地址、绑定引用、或者把它传给需要bool&参数的函数,就会遇到类型不匹配的编译错误。
排查链路是:报错信息看不懂 → 简化代码逐行定位 → 发现是vector<bool>的锅 → 上网确认特化机制 → 改成vector<char>解决问题。vector<char>每个元素占一个字节,功能和普通数组一模一样,不会出现代理对象问题。这个坑在OJ提交时不容易暴露,但在项目代码里很容易碰到。以后凡是需要“可变布尔数组”,我都直接用vector<char>。
4.3 二分查找死循环:循环条件与边界更新搭配
第25题我最初写的是另一种版本,用的while (left < right),内部更新是left = mid。这在一些数据上能正常跑,但在数组长度为2的特定情况下直接死循环。
手动模拟一下就明白了。假设数组是[1, 3],目标是3。left=0,right=1,mid=0。a[0]=1小于3,所以left=mid=0,区间依然是[0,1],没有任何缩小。下一轮循环还是同样的结果,于是无限循环。
排查链路是这样的:先怀疑大数据超时,后来发现小数据也会卡住;打印每次循环的left、right、mid,发现left和right在某几次迭代里完全不变化,才意识到是更新逻辑有问题。
最后我把代码改成经典版:while (left <= right),命中即返回,否则left = mid + 1或right = mid - 1。这套写法保证每次循环区间都在缩小,不可能死循环。教训是:二分模板不能靠背,要理解循环不变式。只要你能说清楚“每轮循环后,目标值为什么还在新区间内”,写出来的二分就不会错。
5. 从东华OJ到其他OJ:这套解法还能怎么迁移
5.1 对拍测试:让代码不只“看起来对”
刷完21-25,我发现一个非常有用的技巧:对拍。用两个程序跑同一组随机输入,比较输出是否一致,可以快速发现隐藏bug。
我常用的做法是:写一个暴力解法当“标准答案”,再写一个优化解法当“测试对象”,然后用脚本生成随机数据,把两个程序的输出做diff。如果diff为空,说明这一次测试中优化版本和暴力版本行为一致;如果diff不为空,就说明优化版在某条数据上出了问题,再针对性缩小数据范围去定位。
造数据可以用Python,简单直接:
python复制import random
n = random.randint(1, 1000)
print(n)
for _ in range(n):
print(random.randint(1, 10000), end=" ")
print()
生成完数据,用重定向分别跑两次程序,再比对输出文件:
bash复制python gen.py > input.txt
./brute < input.txt > output_brute.txt
./fast < input.txt > output_fast.txt
diff output_brute.txt output_fast.txt
这个方法在刷题前期非常划算。第25题的二分死循环,如果当时我早一点写对拍,就不用在人肉模拟上花那么多时间。当然,对拍只能证明“在这些随机数据上没问题”,不能完全替代严格的正确性分析,但它能帮你提前筛掉一大半低级错误。
5.2 把每题解法沉淀成自己的模板库
刷完这五题,我把用到的基础算法整理成了模板,放在一个单独的文件里,每条都写上适用场景、边界条件、复杂度。这不是说背模板就完事,而是要有一个自己的“工具箱”,碰到新题时能快速调用已经验证过的代码片段。
举个例子,我整理的二分模板一般长这样:
cpp复制int binarySearch(const vector<int>& a, int target) {
int left = 0, right = (int)a.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (a[mid] == target) return mid;
else if (a[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
旁边注释会写清楚:这个模板适用于“有序数组中查找是否存在目标值”,如果题目要找第一个大于等于目标值的位置,就得换用lower_bound那套写法。把模板按场景分类,比一个模板打天下靠谱得多。
21到25这组题刷完,最大的体会是“基础算法不是背会的,是磨出来的”。每道题光看题解觉得自己懂了,关掉题解自己写一遍,各种边界问题就全冒出来了。我把这些WA和排查过程记下来,不是为了显得自己笨,而是为了下次再遇到时能直接跳过同一条沟。东华OJ的题号可以变,题库可以换,但这些基础算法和调试方法是通用的,走到哪个OJ上都用得上。
