T1
旅游
travel
两处判断漏了过道
85/ 100
给两个座位号:两位车厢号 \(c\),加三位座位号 \(s\)(前两位是排号,末位是 A–D 或 F,C 和 D 中间是过道)。要依次判断是不是同车厢、同排、过道同一侧、左右相邻,输出一个 4 位 01 串。
先说写得好的地方。四个串一次读进来,车厢直接用字符串比,没问题。输出是分段拼的:判不过就打 "0000"、"000"、"0",判过了打 "1",拼起来正好四位。这个细节说明格式婉璐是想清楚了的,不是碰巧对上。
两处判断都把过道漏掉了。第 25 行判"过道同一侧":\(\texttt{'A'}\) 是最小的字母,所以 \(s_1[2]\ge\texttt{'A'}\) 永远成立;\(\texttt{'F'}\) 是最大的,所以 \(s_2[2]\le\texttt{'F'}\) 也永远成立。这条式子实际起作用的是「\(s_2\) 在左 或 \(s_1\) 在右」,于是先落在 \(s_1\) 在右、\(s_2\) 在左的那些组合上:两张牌明明隔着过道,它答"同一侧"。接着第 32 行判相邻,用的是 \(\texttt{abs}(s_1[2]-s_2[2])==1\),而 C 和 D 的字符差正好是 1,这两种座位又被当成挨着坐。两处一起错,一条数据丢两位。
按婉璐的代码一行行推出来的结果
| 座位关系 | 正确答案 | 婉璐的输出 | 结果 |
| 同车厢、不同排 | 1000 | 1000 | 对 |
| 同排、B 与 C(同侧相邻) | 1111 | 1111 | 对 |
| 同排、A 与 C(同侧不相邻) | 1110 | 1110 | 对 |
| 同排、D 与 C(先右后左,跨过道) | 1100 | 1111 | 错:同侧、相邻都判反 |
| 同排、D 与 A(先右后左,跨过道且不相邻) | 1100 | 1110 | 错:跨过道判成同侧 |
婉璐的原文(travel.cpp 第 11–36 行)
if(c1==c2){ cout<<"1"; f++; } // 同车厢
else { cout<<"0000"; return 0; }
if(s1[0]==s2[0]&&s1[1]==s2[1]&&f==1){ cout<<"1"; f++; } // 同排
else { cout<<"000"; return 0; }
if((s1[2]>='A'&&s2[2]<='C')||(s1[2]>='D'&&s2[2]<='F')&&f==2){
cout<<"1"; f++; // 第 25 行:跨过道也判成同侧
else { cout<<"00"; return 0; }
if((int)abs(s1[2]-s2[2])==1&&f==3){ cout<<"1"; }
else { cout<<"0"; } // 第 32 行:C 与 D 差 1
这题我打算让婉璐这么写:先把字母压成编号
int p1 = s1[2] - 'A' - (s1[2] == 'F'); // A B C D F → 0 1 2 3 4
int p2 = s2[2] - 'A' - (s2[2] == 'F');
if (c1 != c2) cout << "0000";
else if (s1.substr(0,2) != s2.substr(0,2)) cout << "1000";
else if ((p1 <= 2) != (p2 <= 2)) cout << "1100"; // 一个在左、一个在右
else if (abs(p1 - p2) != 1) cout << "1110";
else cout << "1111";
这道题准备让婉璐重写一遍。先把 A 到 F 压成 \(0\ldots4\):过道左边是 \(0,1,2\),右边是 \(3,4\)。压完之后,同侧就是两边编号要么都 \(\le 2\)、要么都 \(>2\);相邻就是 \(|p_1-p_2|=1\)。题目里凡是"中间隔着什么"的说法,第一步都要变成能比较的数,这一步不做,后面怎么写都会漏。
知识点:字符串比较知识点:把字符压成编号易错:恒真条件易错:过道
T2
回文串
string
想法对,规模撑不住
68/ 100
字符串按 \(n\) 段压缩给出,第 \(i\) 段是 \(a_i\) 个字符 \(c_i\)。每一次可以改任意一个位置上的字符,问最少改几处能让整串变成回文串。
想法是对的。把"最少改几处"换成了"数一数有多少对对称位置不一样",一对不一样改一个就行。第 32 行的循环也只扫了前半段,正中间那个字符没有重复算。这个转化跟正解是一个路子,能想到这一步,说明回文这类题婉璐是理解的。
问题在数这一对数的方法上。为了把对称位置找出来,婉璐把 \(\sum a_i\) 个字符真拼成了一个字符串(第 26 行 s += c[i]),又用 re(s) 复制了一份倒过来的(第 28 行)。题面把输入压成 \(n\) 段,本身就是在说总长很大:这么拼,内存放不下,时间也过不去。68 分是那些总长还小的数据给的。
婉璐的原文(string.cpp 第 20–34 行)
for(int i=1;i<=n;i++){
cin>>a[i];
cin>>c[i];
for(int j=1;j<=a[i];j++){
s+=c[i]; // 第 26 行:把压缩串展开成实体
}
}
string ss=re(s); // 第 28 行:又复制一份,内存翻倍
for(int i=0;i<s.size()/2;i++){
if(s[i]!=ss[i]) sum++; // 数的是"不对称对",这一步对
}cout<<sum;
正解:不展开,在段上走
int l = 1, r = n, p = a[1], q = a[n], ans = 0;
while (l < r) {
int len = min(p, q); // 这一轮最多能配成多少对
if (c[l] != c[r]) ans += len; // 字符不同:这一轮的 len 个位置都得改
p -= len; q -= len;
if (p == 0) p = a[++l]; // 左块用完,换下一块
if (q == 0) q = a[--r]; // 右块用完,换前一块
}
cout << ans;
一句话就能说清:两头按段配成一对,这一对的字符不一样,就把这一轮能配成的对数加进答案。
$$\text{ans}=\sum_{\text{两端字符不同的那几轮}} \min(p,q)$$
区别只在"数谁":婉璐数的是字符,所以得先把字符拼出来;正解数的是段长,一直没离开输入。
这类题我只让婉璐记一句:输入是压缩的,就不要展开。以后看到 \(n\) 段、每段 \(a_i\) 个,先在草稿上估一下 \(\sum a_i\) 有多长,放不进内存就只能在"段"这一层做运算。
知识点:回文对称配对知识点:双指针按段推进易错:展开 \(\sum a_i\)易错:凭空多复制一份串
T3
按位与
and
文件名错,整题归零
0/ 100
给定 \(n\) 个非负整数,从中挑出恰好 \(m\) 个,让这 \(m\) 个数的按位与尽可能大,输出这个最大值。
婉璐写的是"把最大的 m 个数拿出来相与"。排序,ans = a[n],再往前取 \(m-1\) 个数一路 &=。这个想法直觉上说得通,但不是这题的正解。
不过 0 分不是这个贪心造成的,是文件名。第 9 行写的是 ans.out,题面要 and.out。评测机打开 and.out,里面什么都没有,整题不给分——连婉璐能过的那部分点也一起没了。我在平台上把这一处改对重交,45 分。
婉璐的原文(and.cpp 第 7–31 行)
int main(){
freopen("and.in", "r", stdin);
freopen("ans.out", "w", stdout); // 第 9 行:题面要 and.out
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
sort(a+1,a+1+n);
ans=a[n]; // 先拿最大值
for(int i=n-1;i>=n-m+1;i--) ans&=a[i]; // 再往前取 m-1 个
cout<<ans;
}
算法这一层也说清楚。按位与看的是二进制位,不是数值大小。拿三条数据举例:数组 \(8\ 7\ 6\)、\(m=2\),婉璐挑最大的两个 \(8,7\),\(8\&7=0\);而 \(7\&6=6\)。挑最大的几个,在小数据上就站不住。
反例验算:数组 8 7 6,选 2 个
| 选法 | 二进制 | 按位与 | 结果 |
| 8 与 7(婉璐挑的) | 1000 & 0111 | 0 | 0 |
| 7 与 6(最优) | 0111 & 0110 | 6 | 6 |
正解:从高位往下,问这一位能不能留
int ans = 0;
for (int i = 30; i >= 0; i--) {
int target = ans | (1 << i); // 试着把第 i 位也点亮
int cnt = 0;
for (int j = 1; j <= n; j++)
if ((a[j] & target) == target) cnt++; // 能凑齐 target 所有位的数
if (cnt >= m) ans = target; // 至少 m 个 → 这一位留得住
}
cout << ans;
这题有两件事要分开办。算法上,按位与的题思路是固定的:从高位到低位逐位判,判据是"能不能凑够 \(m\) 个数同时满足",不是谁数值大。文件名那件事,下一节单独说,那道题让婉璐自己再看一遍。
知识点:按位贪心知识点:可行性计数致命:输出文件名易错:按数值贪心
T4
纸牌游戏
card
方向对,代价算错
48/ 100
点数是 \(1\ldots m\) 的序列,每种点数至少出现一次。从每个起点 \(i=1\ldots n\) 各打一局:桌上没有这个点数就放到最右;桌上有了,就把这个点数连同它右边所有的牌都收回,再把新牌放最右。问每种点数 \(v\) 在这 \(n\) 局结束时还留在桌上的局数。
这题婉璐抓到点子上了。里面用 deque 维护"桌上每种点数只留一张",碰到重复就把左边一路退到这个点数只剩一张为止——这正是题目规则在做的事。这个观察就是正解的出发点,48 分不是白给的,说明小数据上确实跑通了。
代价算错了。外面 \(n\) 个起点,里面再扫一遍到 \(n\),中间每换一个起点还要把 \(3\times10^5\) 的计频数组整个清零一次。就清零这一步,最坏是 \(n\cdot M\approx 5\times10^5\times3\times10^5=1.5\times10^{11}\) 次,再叠上一层 \(n^2\) 的扫描,数据一大这一段就跑不完。48 分来自 \(n\) 小的那些点。
婉璐的原文(card.cpp 第 16–24 行)
for(int k=1;k<=n;k++){ // 每个起点都从头来一遍
q.clear();
memset(f,0,sizeof(f)); // 3×10⁵ 个 int,清 n 次
for(int i=k;i<=n;i++){
if(q.empty()) q.push_back(a[i]);
else if(f[a[i]]==0) q.push_back(a[i]);
else{
while(!q.empty()){
int t=q.back(); q.pop_back(); f[t]--;
if(f[a[i]]==0) break; // 收回该点数及其右侧
}
q.push_back(a[i]);
}
f[a[i]]++;
}
for(int i=1;i<=m;i++) if(f[i]==1) cnt[i]++;
}
正解走的是同一个观察,只是把它用在"所有起点一起算"上。一局打完桌面剩什么,只跟每种点数最后一次出现的位置有关,记作 \(l(u)\)。把 \(l(u)\) 后面那张牌的数值当成父节点
$$p(u)=a_{l(u)+1},\qquad f_u=\{u\}\cup f_{p(u)}$$
就得到一棵树,\(f_u\) 是从 \(u\) 一路走到根经过的点数。答案于是变成一次子树求和:\(\text{ans}_v=\sum_{u:\,v\in f_u} c_u\),其中 \(c_u\) 是点数 \(u\) 在整个序列里的出现次数。整体 \(O(n+m)\)。
还差的那一步,我上课会直接问。写完"每个起点都来一遍",要接着问一句:这些遍之间有没有一样的地方?婉璐的 deque 已经把"重复会把桌面收回去"算出来了,只是没往"换个起点也能接着用"上想。这一步想到,这题就能往上走,家长最关心的第四题也就是这里。
知识点:滑动窗口知识点:deque 维护正解:按最后出现位置建树 + 子树累加易错:逐步重算不清零