新生赛题解:排序、二分、前缀和与DFS枚举全解析
先聊两句:新生赛第一场,题目难度通常不会太大,但它的意义恰恰不在“难倒你”,而是让每个人都能在赛中学到东西、看清自己的弱点。这份题解我按完赛后的复盘视角来写,把那道题考什么、怎么想到做法、代码里有哪些坑全部过一遍。不管你是刚摸到键盘的萌新,还是已经刷过一点题想巩固基础的老手,这一场的内容都值得仔细读——它已经把最常见的几类基础考点浓缩得很完整了。
1. 题目梯度设计:为什么第一场训练赛就这六道题
先说这场比赛的选题逻辑。A、B题属于纯签到与语法题,目标是让大家建立信心、熟悉读题和提交流程;C、D题把排序和二分这两种最常用的“查找类工具”摆上桌,属于套路和思维各占一半;E题是一维前缀和,直接为后面二维前缀和、树上差分这类进阶内容做铺垫;F题用DFS枚举子集,是搜索类和状态压缩的启蒙题。
这六道题拼在一起,正好覆盖了新生前四周应该掌握的主线技术点。如果你能独立做出前四题,说明语法、STL、基础算法都过关了;做不出最后一题也很正常,那是分层设计的——保证每天都有新内容可以学。
1.1 做题顺序与时间分配的建议
我一般不建议按A到F的顺序死磕。正确节奏是:先把所有题都读一遍,花三分钟标注每道题的难度等级。比如这场的难度排序是A、B < C < E < D < F(D和E难度接近,看你的习惯),那就先写A和B拿保底分,再处理C和E,回头补D,最后剩余时间全部砸在F上。
实际做题时,每道题给自己设一个“死线”:签到题15分钟,排序题20分钟,二分题20分钟,前缀和10分钟,DFS题可以无限制。超时了就立刻放弃换下一题,不要在一棵树上吊死——罚时机制摆在那里,尤其要注意的是,一场比赛中反复提交错误答案,扣分可能比直接跳过还狠。
1.2 输入输出和编译期的常见坑
这一场我注意到有同学卡在“为什么本地跑对了,交上去就WA”上。大概率不是逻辑问题,而是输入输出细节:比如题目说多组数据,你只读了一组;比如字符串里有空格,你用了cin >> s;再比如没有关同步流,在数据量稍大的题目上超时。这些坑后面详细展开,建议先把这三行写在所有代码最前面:
bits/stdc++.h是竞赛专用万能头,日常工程里不要这么干,但训练赛和竞赛完全可以用。ios::sync_with_stdio(false)这行的意思简单讲就是让cin/cout不再和C的stdio同步,读写速度会快很多,代价是不能混用printf和cout输出了。
2. 签到题:陷阱藏在数据类型里
2.1 A题:从1加到n,你真的会算吗
题目简述:给定正整数n,求1+2+...+n的值。n的范围能够达到10^9。
这题表面上考循环,实际上考的是数据类型。如果写int ans = 0; for (int i = 1; i <= n; ++i) ans += i;,在n达到10^9时循环本身要跑十亿次,超时是必然的。更重要的是,int的表示范围大约是-2^31到2^31-1,也就是绝对值约2.1×10^9,而1加到10^9的结果是5×10^17量级,直接炸掉。
正确做法是等差求和公式:ans = n * (n + 1) / 2。这里有个次序问题:先乘再除,还是先除再乘?n和n+1里必有一个是偶数,所以安全写法是先算n * (n + 1),这里已经超过了int范围,必须用long long。
注意n * (n + 1) / 2中,因为n是long long,整个表达式会按long long计算,不会爆。你要是写成int n; int ans = n*(n+1)/2;,右边的整型乘法先按int算了再转long long,照样爆。
这类题的价值在哪?它教你的第一课是:看到数据范围先想需要什么类型,再想算法。凡是和1e9这个量级打交道,直接默认long long;排序坐标、差值、前缀和这类中间结果也经常爆int,养成习惯比记规则重要。
2.2 B题:判断回文串的两个细节
题目简述:给定一个仅由小写字母组成的字符串s,判断它是否是回文串,输出YES或NO。
最常规的思路是双指针,一个从开头往右,一个从结尾往左,每次比较两个字符是否相同。
但我实际改代码时发现,新手最容易在这里栽跟头的是没看见题目里“仅由小写字母组成”这个限制,自己脑补了各种大小写混合、空格、中文标点的情况,把题写得越来越复杂。记住:ACM的题面描述是经过严格推敲的,每一项约束都给了就有意义,没给就是不需要考虑。
另外有个string的细节:s.size()返回的是无符号类型。如果s为空串,s.size() - 1会变成一个很大的数而不是-1,j的初始值就不对。虽然这题输入保证非空,但如果你写s.size() - 2这类表达式,强转int总是更保险。背后原理是C++的整型提升规则:无符号和有符号运算时,有符号会先转成无符号。
3. 排序与二分:两个“万金油”算法,但用不好就会翻车
3.1 C题:结构体排序,比较器才是主菜
题目简述:输入n个学生,每个学生有姓名name(字符串)和成绩score(整数)。按以下规则输出:成绩高的在前;成绩相同则按姓名字典序从小到大。
这题考的语法点是结构体和sort的自定义比较器。代码骨架如下:
如果你习惯用lambda,也可以不写全局函数:
讲讲背后原理,免得你只是背模板:sort的比较器定义的是“a是否应该排在b前面”这个严格弱序关系。当两个元素相等时,比较器必须返回false——不管是a.score < b.score还是b.score < a.score都得是false。如果你写return a.score >= b.score,就违反了严格弱序,sort会行为未定义,轻则排序结果错乱,重则直接段错误。
这题容易错的还有字符串字典序比较。在C++里string直接用<比较,走的就是字典序(确切说按字符的ASCII码逐位比较),所以不用手写循环。
一个实战建议:写sort比较器时,传入const引用而不是值。比如const Student& a而不是Student a,避免每次比较都复制整个结构体。字符串在结构体里时,按值传会导致大量内存拷贝,数据一大性能就崩。
3.2 D题:二分的三种写法与死循环风险
题目简述:给定长度为n的升序数组a,进行q次查询,每次给出目标值x,输出x在数组中的下标(从0开始),若不存在输出-1。n和q都在10^5级别。
q次查询、n有10^5,暴力遍历每次O(n)会超时。二分能把单次查询降到O(log n),n=10^5时差不多17次比较就能定位,这是它存在的意义。
新手建议直接记住标准库:
对vector写法是lower_bound(a.begin(), a.end(), x) - a.begin()。lower_bound返回第一个大于等于x的迭代器,判断一下该位置的值是不是恰好等于x,就知道x存不存在了。这背后的思路是“先找插入点,再验证内容”,比手写二分省心得多,也几乎不可能写错边界。
如果你非要手写,我推荐这个左闭右闭的写法,逻辑最直观:
几个容易踩的细节:
- mid为什么写成
l + (r - l) / 2? 因为(l + r) / 2在l和r都接近2×10^9时,相加会溢出int。虽然这题数据量没那么大,但养成用减法代替加法的习惯,能帮你避免以后在坐标类问题上翻车。 l <= r和l < r的选择。这取决于你维护的是闭区间还是半开区间。闭区间必须用l <= r,否则当l和r相等时,最后那一个元素永远查不到。我这里用l <= r,因为这个边界最不容易记岔。- 如果改用
while (l < r),里面又写l = mid,就会死循环。原因是当l < r且mid计算后等于l时,区间没有缩小。所有二分死循环的根源只有一句话:区间没有严格缩小。
到这里可以顺带提一个高频事:学算法不要只背模板。二分有“查找下界”“查找上界”“答案最大化的二分”“答案最小化的二分”好几种变体,先理解闭区间、开区间和不变量的关系,再谈背代码。这题只要吃透lower_bound的语义,这四种变体日后都很容易解。
4. 前缀和与DFS枚举:高频套路模板提前认识
4.1 E题:一维前缀和,为何下标从1开始最省事
题目简述:给定n个整数a[1..n],m次询问,每次输入l、r,输出a[l]+a[l+1]+...+a[r]的值。n、m都到10^5级别。
如果对每个询问都从l加到r,最坏情况是每次O(n),m次就是O(n*m)=10^10,必炸。核心优化思路是预处理一个前缀和数组:s[i] = a[1] + a[2] + ... + a[i],这样区间[l, r]的和 = s[r] - s[l-1],O(1)回答一次询问。
为什么强调下标从1开始?这样s[i] = s[i-1] + a[i],区间和s[r] - s[l-1]在l=1时能自然退化成s[r] - s[0],不需要特判。你要是从0开始存,就得写if (l == 0) return s[r]之类的分支,没必要给自己找麻烦。
再提一个数据范围的问题:题目不说明的话,a[i]可能是10^9量级,前缀和达到10^9×10^5=10^14量级,s数组不声明成long long必炸。这可以说是前缀和题里最经典的WA原因了,改法只有一行,但眼睛没盯住就会挂。
这题往深了说,是后面“二维前缀和”和“树上差分”的前置。二维的情况是把求矩形区域和变成四个前缀和的组合,树上的情况是把路径拆成若干点到根的距离相加减去LCA的部分。学姐学长们常说的“套模板”不是叫你去背,而是让你在理解一维、二维之后,下一次看到差不多的场景时能立刻反应过来“这里可以用前缀和”。
4.2 F题:DFS枚举选与不选,搜索的入门姿势
题目简述:给定n个正整数,问有多少种选取方式(每个数最多选一次),使选出的数之和等于目标值S。n≤20,S不超过2000。
n只有20,直接枚举所有2^n种子集。怎么判断各种选法?可以从第0个数开始DFS,每次进入下一层时有两种分支——当前数选或不选。
等一下,上面的if (sum == S) ++cnt;有个问题:选了数变成正好等于S之后,如果后面有0,可能会重复计数;如果后面全是正整数,再选一定会超过S,所以if (sum > S) return;这个剪枝让sum == S分支后面不会再走下去——先判断剪枝,再判断相等,或者直接在这个分支里return,都能避免重复。
我更推荐这种写法:走到某个位置时先剪掉超界,然后如果累加和正好等于目标就计数并返回,否则继续递归两个分支。但核心不变:每个数只有“选”“不选”两条路,DFS天然枚举了全部2^n种组合。
复杂度是O(2^n),n=20就是约10^6,完全能跑。如果n到了30以上,2^n就扛不住了,到时候要用折半搜索(Meet in the Middle)或者动态规划来替代。这题就当作入门搜索的引子,把“一切可能都要走到”这种朴素的枚举思想建立起来。
由于一次性调用太多次DFS会有栈消耗的问题,实际竞赛里n=20完全不用担心递归深度,顶多20层,栈轻轻松松。
页面这里可能有人会问:为什么不直接写三重循环之类的?因为组合的数量是“指数级”的,写固定层数的循环只能处理固定个数的情况,而DFS用一层递归天然支持任意n。
5. 新生最容易栽的五个低级错误,按我现场看到的重灾区排个序
这一节是想让你拿着当自检清单用的,这些都是我在批改这场作业时真实看到的代码问题。
- 第1名:不开
long long。不是不会开,是嫌麻烦、没想到。凡涉及求和、乘法、坐标、区间,先问一句“结果会不会超过2×10^9”。这场的A、E两题都是重灾区。 - 第2名:数组越界。有人习惯从1开始读,数组却开成
int a[n](n为元素个数),结果a[n]越界却还用得很开心。建议数组直接开成a[100005]这种固定的、略大于上限的大小,多开5到10个空间防止意外越界。 - 第3名:
while(cin >> n)多组输入没有处理干净。这场虽然全是单组输入,但下一场就可能全是多组了。你要记住一个常识:读完所有数据再输出,边读边输出一般没问题,但切忌在读入中间就依赖后续输入的值做判断。 - 第4名:
cin >> char和cin >> string把整行的空格吃掉了。如果题目说“一行字符串可能包含空格”,必须用getline(cin, s);注意getline之前如果有cin留下的换行符,要先cin.ignore()掉,否则读出来是空行。 - 第5名:用了
printf又用了cin,还开着同步。关上同步后混用C和C++的输出,顺序可能是乱的,这也是很多人“本地对、OJ错”的经典来源之一。既然用了ios::sync_with_stdio(false),就统一用cin/cout;想用printf就别关同步。
再补充一个排查时的实用技巧——对拍。拿A题的正确答案作为答案生成器,然后用一个暴力程序当对照组,随机生成小数据跑几千次,看两个程序答案是否一致。Linux下用shell脚本就能做,Windows下拿python写一个也很简单。这不只是省时间,还能帮你建立“程序输出可复现、可验证”的工程素养,写工程代码的时候同样受益。
还有一点是读题习惯。ACM题面里每个条件都是有用的,尤其是“保证输入合法”“数据范围”这类描述。有时候题里给你n≤1000,就不会有人用O(n^2)算法卡你;但你没看到n≤10^5,还傻傻地写O(n^2),那TLE就怨不了别人。第一场的关键不是刷多少题,而是养成“先读题五分钟、再写代码十分钟、最后调试半小时”的正确节奏。
6. 写在最后:我的几点实在建议
这场训练赛结束后,我挑了几份比较有代表性的代码看了看,最明显的感受是:思路和代码量差别真的不大,但细节的差距肉眼可见。有人20分钟交完前四题扬长而去,有人卡在E题前缀和忘记开long long交了五发罚时——你以为他是不会做,其实他只是没养成检查数据范围的肌肉记忆。
我的建议是,从今天开始,做每一道题都固定走这套流程:第一,把题面中的n、输入范围、内存限制摘出来抄在草稿纸上;第二,确定算法前先算一遍时间和空间复杂度,不要靠猜;第三,写完代码后看一遍有没有数组开小、类型不对、边界忘处理的硬伤,再提交。
训练赛最大的价值不在那几分排名,而在于它逼你在限时条件下做决定:这题要不要做、用哪种写法、什么时候该跳。这种判断力只能靠一场一场练出来。下一场比赛大概率会引入栈、队列、贪心和简单动态规划,内容上会比今天更硬核;但也别把今天的基础不当回事——排序和二分,是后面所有算法题的骨架,永远躲不掉。
最后再分享一个小技巧:每次训练赛结束,不管做出来几题,花一刻钟把做出来的题重新写一遍,要求一次AC。这个过程能帮你把“碰巧过”变成“真的会”,成长速度比盲目刷三套新题都快。