C++ 编程竞赛 · 复盘报告

郝一诺,我们一起把这场比赛复盘清楚

这不是一份成绩单,而是一份「下次能多拿分」的攻略。看完它,你会发现:很多分丢得很可惜,但每一处都能学会。

🎯 先说好的:你做对了什么

复盘从表扬开始,这些都是真本事,要记住它们。

  • 你敢交、敢写。四道题你都交了代码,没有空着——这本身就是态度分。比赛里很多人看到难题就直接放弃了。
  • 倍数题(number)你的主体思路是对的,拿到了 80 分。你想到了用循环一项一项造出 7、77、777……再判断能不能整除,这个大方向完全正确,只差最后一层窗户纸。
  • 正方形题你想到了用「访问标记数组 vis」防止重复,也想到了要把矩阵边界先填满——说明你已经有「初始化」的意识了,这很难得。

这次的得分长这样

① number 倍数
80
思路对,差取模
② square 正方形
20
蒙对,存争议
③ ball 滚雪球
0
4 个坑全踩
④ game 网球赛
30
直接输出-1

两次评测分数对比

比赛 LemonLime
110 / 400
本机 g++ 复核
130 / 400
满分(目标)
400 / 400

两次分数差的那 20 分,全部在「正方形」这道题上。这中间藏着一个很重要的道理,下面专门讲。

关于那「争议的 20 分」(square): 比赛时这题判了 0 分,但在我的电脑上重新跑却得了 20 分。这不是评测不公平,恰恰相反——它说明你这份代码「不可靠」。 你的程序里有一种叫「未定义行为」的毛病:同一份代码、同一个输入,换一台电脑、换一个编译选项,答案就会变。 比如同一个测试点,用 -O2 编译输出是空的,用 -O0 编译又输出 2。

这就像考试时蒙答案:这次蒙对了,下次就不一定。竞赛要的是「无论在哪台机器上都稳定正确」的代码,靠运气拿到的分随时会飞走。所以我们不追求蒙那 20 分,而要学会写「在哪都对」的程序。

number · K 的倍数 得 80 分 · 离满分最近

数列 7, 77, 777, 7777 …… 第一次出现 K 的倍数是第几项?没有就输出 -1。(K 最大 100 万)

你写的代码(核心部分)

😟 你的写法
😎 正确写法
long long sum=0;
for(long long i=1; i<=K; i++) {
    sum = sum*10+7;        // 一直在累加真实的大数字
    if(sum%K==0) { j=i; break; }
}
cout<<j;
long long sum=0;
for(long long i=1; i<=K; i++) {
    sum = (sum*10+7) % K;    // 每一步都取余数,数字永远很小
    if(sum==0) { j=i; break; }
}
cout<<j;

你看,只改了一个地方:把 sum*10+7 变成 (sum*10+7)%K。就这一处,从 80 分变满分。

为什么会丢这 20 分?(点开看清楚)

问题出在「数字会越长越大」。数列是 7、77、777……到第 19 项左右,这个数已经接近 7.7 × 1018,而 long long 能装的最大数大约是 9.2 × 1018。再往后,数字「装不下了,溢出」,就像水杯倒满还继续倒,溢出来的部分变成了垃圾值。

一旦溢出,sum 就成了乱码,sum % K 算出来也全是错的。于是答案比较大的那几个测试点(比如答案是 168 的第 11 个点、答案是 148 的第 13 个点)你都算不出来,错误地输出了 -1。

核心思想叫「边算边取模」:我们只关心「能不能被 K 整除」,也就是只关心余数。而余数永远在 0 到 K-1 之间(最多 100 万都不到),永远不会溢出。所以每一步都先 %K,既不影响判断,又彻底躲开了溢出。这是数论题里最最常用的一招,一定要记住。

square · 最大全 0 正方形 蒙到 20 分 · 最该反思

给一个 n×n 的方格图,每格是 0 或 1,求「里面全是 0 的最大正方形」的边长。(n 最大 3000)

你写的代码

😟 你的写法(DFS 包围盒)
😎 正确写法(动态规划)
int ans,cur,xmin,ymin,xmax,ymax;   // 这些是全局变量,会被反复污染!
void dp(int x,int y){
    if(vis[x][y]) return;
    vis[x][y]=1;
    xmax=max(xmax,max(xmin,x));      // 用"走过的点的包围盒"猜边长
    ymax=max(ymax,max(ymin,y));
    if(a[x+1][y]=='0' && a[x][y+1]=='0' && a[x+1][y+1]=='0'){
        dp(x+1,y); dp(x,y+1);    // 递归向右下扩展,n=3000 时栈会爆
    }
    cur=min(xmax-xmin+1,ymax-ymin+1);
}
// 来自标准答案 std/square.cpp —— 经典动态规划
int n,ans;
char g[3003][3003];
int f[3003][3003];  // f[i][j]=以(i,j)为右下角的最大全0正方形边长
int main(){
    cin>>n;
    for(int i=1;i<=n;i++)
      for(int j=1;j<=n;j++){
        cin>>g[i][j];
        if(g[i][j]=='1') f[i][j]=0;
        else f[i][j]=min(f[i-1][j],min(f[i-1][j-1],f[i][j-1]))+1;
        ans=max(ans,f[i][j]);
      }
    cout<<ans;
}
正解一句话记忆法: 一个格子如果是 0,那么「以它为右下角的最大正方形边长」= min(上面、左边、左上 三个邻居的边长) + 1
打个比方:要在你右下角搭一个更大的正方形,得看你的「上、左、左上」三位邻居谁的正方形最小,木桶能装多少水由最短的板决定——取最小的那个再加 1,就是你能撑起的最大正方形。
你的算法为什么是错的?(这题最值得看)

① 算法本身就不成立。你用 DFS 一路向右下走,再用「走过的点的最小/最大坐标」围成一个长方形(包围盒)来估算边长。但是——包围盒里不保证全是 0!而且 xmax / ymax 是全局变量,跨多次调用一直累加、从不清零,会互相污染。

反例:一个 4×4 的图,前两行全是 0,后两行右半边是 1。正确答案是 2(只能放 2×2),但你的程序会输出 4

② 未定义行为(UB)。这是最危险的。同一份代码、同一个输入(测试点 02),用 -O2 编译输出「空」,用 -O0 编译输出「2」。结果会随机器和编译选项漂移——这就是「靠运气」的代码,今天对明天错。

③ 递归太深会爆栈。n 最大 3000,DFS 一路递归下去层数太多,电脑的「栈」放不下,直接崩溃(测试点 14–17 全部运行时错误 RE)。

那 20 分哪来的?测试点 01、03 你恰好输出 2,刚好等于答案,纯属巧合。这就是前面说的「争议 20 分」的真相。

小贴士:正解里用 cin>>g[i][j] 一个一个读字符也行,但 n=3000 时有 900 万个字符,更快的做法是用 scanf("%s", g[i]+1) 整行读入,避免超时。

ball · 滚雪球(IPO) 得 0 分 · 4 个坑全踩了

手里有 m 元,n 个项目各要成本 c、能净赚 p,最多做 k 个;每次只能做「现在买得起的」。求最后有多少钱。(数值可达 10 亿,答案可达 1000 亿!)

你写的代码

😟 你的写法
😎 正确写法(堆 + 贪心)
int main(){
    int n,k,m;                       // ① m 用 int!答案上千亿必溢出
    cin>>n>>k>>m;
    t a[n+1];                       // ④ 变长数组开在栈上,n=10万会爆栈(RE)
    for(int i=0;i<n;i++) cin>>a[i].c>>a[i].p;
    sort(a,a+1,cmp);                // ② 致命笔误!只排了 a[0] 一个元素=没排序
    for(int i=0;i<k;i++)
      for(int j=0;j<n;j++)          // ③ 双重循环 10万×10万=百亿次,超时
      { ... }
}
// 来自第1名 马秀哲 ball.cpp —— 非常干净
#define int long long          // 一句话让所有 int 都变 long long,告别溢出
pair<int,int> a[100001];
priority_queue<int> q;        // 大根堆:自动把利润最大的浮到堆顶
signed main(){
    cin>>n>>k>>m;
    for(int i=1;i<=n;i++) cin>>a[i].first>>a[i].second;
    sort(a+1,a+n+1);             // 按成本从小到大排好
    while(k--){
        while(cc<=n && a[cc].first<=m){  // 把现在买得起的都放进堆
            q.push(a[cc].second); cc++;
        }
        if(!q.empty()){ m+=q.top(); q.pop(); } // 取利润最大的去做
    }
    cout<<m;
}
四个坑,挨个讲清楚(最值得收藏)

① 用 int 装大数 答案能到 1000 亿,而 int 最多只能装约 21 亿。结果大数据点你输出了 1000000376,正确答案是 10750038174——数字太大装不下,溢出成乱码。钱、计数、答案这类可能很大的量,一律用 long long

② sort(a,a+1,cmp) 这是最可惜的一处笔误。sort(a, a+1, ...) 表示「只排序从 a[0] 到 a[1] 之间」,也就是只有 1 个元素,等于完全没排序!应该是 sort(a, a+n, cmp)。因为没排序,你的贪心顺序全乱了(小数据你输出 4,正确是 14)。

③ O(n×k) 双重循环 你每做一个项目就把所有 n 个项目从头扫一遍。n 和 k 都是 10 万时,要算 100 亿次,电脑 1 秒只能算几亿次——必然超时。正解用「堆」把找最大利润的操作从「扫一遍」降到「直接取堆顶」,快了几千倍。

④ 变长数组 t a[n+1] 这种「长度由变量决定」的数组开在「栈」上,n=10 万时栈放不下,程序直接崩溃(段错误 RE)。应该在函数外面开成固定大小的全局数组 a[100005]

正解思路(贪心 + 大根堆):先把项目按成本从小到大排序;用一个指针,每轮把「成本 ≤ 当前资金」的项目全丢进大根堆;然后取堆顶(利润最大的)去做,钱变多了,又能解锁更多项目……重复 k 次。这就是「滚雪球」:钱越滚越多。

game · 网球循环赛 得 30 分 · 但最可惜

N 名选手循环赛,每人按给定顺序依次比赛,每天每人最多一场,求最少需要几天,办不到则 -1。(N 最大 1000)

你写的代码

int main(){
    cout<<-1;        // 直接放弃,赌答案是 -1
    return 0;
}

你恰好「蒙」中了 10 个答案确实是 -1 的测试点,拿了 30 分。但其实……

一诺,这题最让我心疼。放弃思考是最可惜的。这题确实难(要用「拓扑排序求最长路」),但哪怕你只写一个笨办法去模拟比赛过程,也能拿到比 30 分多得多的分。竞赛里有个心法:难题不一定要满分,能多拿一分是一分。下次遇到不会的,先写个能跑的暴力版本交上去,绝不空着、绝不直接投降。
这题正确思路是什么?(拓展一下,看个热闹也好)

把「每一场比赛」当成一个点。规则要求每个选手必须按自己给定的顺序打,所以对同一个选手来说,「他的上一场」必须排在「他的下一场」之前——这就在两场比赛之间连了一条「先后」的箭头。

这样会得到一张「有向图」。最少天数 = 这张图里最长的一条箭头链有多长(这条链上的比赛只能一天打一场,没法并行)。如果图里出现了「转圈」(A 要在 B 前、B 又要在 A 前),那就是死循环,输出 -1。

第 3 名张柏林用了一种很聪明的直接模拟写法:用 nxt[i] 记录选手 i 下一个要打谁,每一「天」让所有「双方都想打对方」的配对同时开打,打一对算一天,直到全部打完。没有人能打就说明卡住了,输出 -1。这题他拿了满分 100。

🏆 高手是怎么做的

前三名的代码,差别不在「聪明」,而在「习惯」。

🥇
马秀哲
ball 100 · number 100 · square 100 · game 95
395 分
🥈
曹书坤
ball 80 · number 100 · square 100 · game 95
375 分
🥉
张柏林
ball 80 · number 90 · square 100 · game 100
370 分

他们共同的好习惯(你完全学得会)

习惯一:可能很大的数,提前用 long long

马秀哲在 number 和 ball 里都用了一招——开头写一句 #define int long long,让整份代码的 int 自动变成大整数,从此不用担心溢出。这正是你 ball 题丢分的根源。

// 马秀哲 number.cpp 开头
#define int long long
signed main(){ ... }

习惯二:数论题「边算边取模」

和你 number 题正解一模一样的思想。马秀哲每一步都 %k,数字永远很小,绝不溢出。

// 马秀哲 number.cpp
int l=7;
for(int i=1;i<=1000001;i++){
    if(l>=k && l%k==0){ cout<<i; return 0; }
    l%=k; l*=10; l+=7;   // 先取模再继续算
}

习惯三:选对算法 + 用现成的「工具」(堆、DP)

square 三位高手都用了同一套经典动态规划(和标准答案一致);ball 用大根堆 priority_queue。他们不硬写慢办法,而是用对的算法。看马秀哲的 square,干净到只有十几行:

// 马秀哲 square.cpp
if(c=='0') dp[i][j]=min(dp[i-1][j],min(dp[i][j-1],dp[i-1][j-1]))+1;
ans=max(ans,dp[i][j]);

习惯四:变量初始化、数组开够、代码简洁

他们的数组都开在函数外(全局区,自动清零、不会爆栈),大小一次开够(如 a[100005]);代码短、变量少、不绕弯。代码越简单,出错越少——这是高手最朴素也最厉害的秘密。

💡 给郝一诺的三条最重要建议

别的都可以慢慢学,这三条请先记住。

会很大的数,一律 long long;数论题,边算边取模

钱、计数、几个数连乘、累加很多次——只要可能超过 21 亿,就用 long long。判断整除、防溢出时,每步都 %K。这一条就能帮你把 number 补成满分、把 ball 救活。

写完先「自己当评测机」:用小例子手算验证,再交

你的 sort(a,a+1,cmp) 笔误、square 的包围盒错误,只要自己拿一个小例子(比如那个 4×4 反例)跑一遍、对一下答案,当场就能发现。交之前花 1 分钟手动验一个小数据,比交完丢分强一百倍。

难题也绝不空着,先拿暴力分;远离「靠运气」的代码

game 直接输出 -1 太可惜了——先写个能跑的笨办法也能多拿分。同时记住 square 那「飘忽不定的 20 分」的教训:我们要的是无论在哪台机器都正确的代码,而不是碰巧蒙对的代码。

一诺,你这次的底子其实不差:number 思路对、square 有初始化意识、四题都敢交。
把上面三条养成习惯,下次这 400 分里,至少 200 分是稳稳能拿的。我们下次见,加油!💪