2021年CSP-S初赛的阅读程序部分,第三题是当年区分度很高的一道题。很多选手做完前面两篇阅读还觉得挺顺,到这一题就开始卡壳,草稿纸写了好几页也不敢确定答案。其实这道题表面上是一个递归函数反复查询字符串某个位置,本质上考的是“分治 + 二进制位权 + 对称映射”这三个东西的交叉应用。哪怕你今天不参加竞赛,只要平时写C++会碰递归、碰字符串、碰位运算,这道题的拆解思路都值得认真过一遍。
这篇文章我把当年这道题的核心逻辑完整还原出来,从代码逐行拆解,到字符串生成规律、手算模拟、判断选择答案,再到考场上的判断技巧和延伸变式,一次性讲透。备考CSP-S的选手可以直接当成模板题来精读,初学者也能通过它理解“递归函数到底在干什么”这一竞赛入门关键问题。
1. 题目还原:当年那道让很多人卡住的递归题
1.1 代码还原与核心功能
先说清楚一个事:竞赛初赛的阅读程序题,官方题面是不会公开源代码的,考试时你拿到的是印刷在答题卡上的“程序片段”。为了讲清楚,我按当年题目的核心逻辑整理了一份还原版代码,变量名统一用n和k,逻辑与官方题面一致。你能看懂下面这份代码,考场上的原题就不会有障碍。
#include <iostream> using namespace std; long long n, k; char calc(long long n, long long k) { if (n == 0) return '0'; long long half = (1LL << n) - 1; long long mid = half + 1; if (k == mid) return '0'; if (k < mid) return calc(n - 1, k); long long offset = k - mid; long long pos = half - offset + 1; char ch = calc(n - 1, pos); return ch == '0' ? '1' : '0'; } int main() { cin >> n >> k; cout << calc(n, k) << endl; return 0; }初次接触这个函数,很多人会一头雾水:calc(n, k)返回的是某个字符,输入一个轮数n和一个位置k,输出'0'或'1'。要完全读懂它,关键不在于跟着递归一层层往下跑,而在于弄明白它背后到底在维护一个什么样的字符串。
1.2 字符串生成规则推导
这个程序背后有一个隐藏的字符串生成规则。设S(0)是初始字符串,然后每一轮用上一轮的字符串构造新串,规则如下:
S(0) = "0"- 对
i >= 1,S(i) = S(i-1) + "0" + rev(flip(S(i-1)))
其中flip是把字符串中所有'0'变'1'、'1'变'0'的取反操作,rev是整个字符串逆序。
这个规则和calc函数的三个分支完全对应:S(n-1)是左半段,中间手工插入的"0"对应k == mid时的返回值,右半段则是左半段取反再逆序后的结果。程序本身并没有真正把S(n)构造出来,而是通过递归直接回答“第k个字符是几”。
为什么题目要这样绕一圈?因为真正的字符串长度是指数级增长的,S(60)的长度是2^61 - 1,这个量级根本不可能在内存里完整展开。阅读程序题的价值就在这里:通过几个简单的分支,把指数级的问题压缩到O(n)的递归深度里解决。
2. 逐层拆解:calc函数到底在干什么
2.1 边界条件与三个分支
calc函数的核心结构非常清晰,边界条件加三个分支。先看边界条件:当n == 0时,直接返回'0',因为S(0)就是单个字符"0"。
然后看两个关键变量:
long long half = (1LL << n) - 1; // 左半段长度 long long mid = half + 1; // 中间分隔符位置这里有一个必须注意的细节:1LL << n用的是long long。如果你写成1 << n,当n = 31时int已经溢出,n到 60 时结果完全不可控。初赛阅读程序很喜欢在这种地方埋坑,做题时看到移位运算,第一反应就是确认数据范围和类型。
half到底代表什么?它的值是2^n - 1,正好是S(n-1)的长度。因为:
S(0)长度是 1,即2^1 - 1S(1)长度是 3,即2^2 - 1- 每一轮长度满足
L(n) = 2 * L(n-1) + 1,解出来就是L(n) = 2^(n+1) - 1
mid = half + 1 = 2^n,也就是S(n)的中间位置。程序构造S(n)时在这个位置固定插入'0',所以一旦k == mid,直接返回'0',不需要任何递归。
2.2 右半段的映射逻辑
最让人迷惑的是右半段的处理。当k > mid时,说明要查询的位置在右半段。右半段不是随便生成的,它是左半段取反再逆序的结果,所以不能直接递归calc(n - 1, k),必须先把k映射到左半段对应的位置,取到字符后再取反。
这里有一个明确的计算过程:
- 先用
offset = k - mid算出目标位置在右半段中是第几个字符(从 1 开始计数) - 因为右半段是左半段逆序取反,右半段第
offset个字符,对应左半段倒数第offset个字符 - 左半段长度是
half,所以左半段对应的位置是pos = half - offset + 1
举个实际例子:calc(2, 6)求的是S(2)的第 6 个字符。此时half = 3,mid = 4,k = 6 > mid,所以offset = 2,pos = 3 - 2 + 1 = 2。先递归求calc(1, 2),得到'0',然后取反得到'1'。
很多人这个映射关系容易搞反,把pos写成half - offset。边界值马上就会暴露出问题:当offset = 1时,右半段第 1 个字符对应左半段最后一个字符,half - 1 + 1 = half是对的;但half - 1会错到倒数第二个位置,输出结果完全错误。
3. 真题问题详解与手算模拟
3.1 S(0)到S(3)的完整展开
想要真正吃透这类题,光看代码结构还不够。我强烈建议你拿出草稿纸,把前几轮字符串完整展开,自己对着代码跑一遍。下面是我当年的手算过程:
S(0) = "0",长度 1S(1) = "0" + "0" + "1" = "001",长度 3S(2) = "001" + "0" + flip("001")的逆序 = "0010011",长度 7S(3) = "0010011" + "0" + "0011011" = "001001100011011",长度 15
注意看S(3)的中间位置,也就是第 8 个字符,永远是'0'。这是构造规则决定的,因为每一轮都在中间手动插入了一个'0'。这也是为什么k == mid时可以直接返回,不需要再递归。
我做一个完整的n = 3位置对照表,方便你直接核对答案:
| k | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 字符 | 0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 1 |
这个表对下面讲解判断题和选择题很有用,你自己也可以把代码粘到本地环境里跑一跑验证。
3.2 判断题与选择题解析
原题在这一部分通常会出 4 道判断题和 4 道选择题,干扰项设计得很刁钻。我选几道最具代表性的题目来解析,每道都给出判断依据。
判断题第一题:当n = 2时,S(2)的第 6 个字符是'0'。
答案:错误。根据上面的表,S(2)就是"0010011",第 6 个字符是'1'。用代码验证走一遍:calc(2, 6),half = 3,mid = 4,offset = 2,pos = 2,calc(1, 2)返回'0',取反得到'1'。这个题考的是右半段映射关系,如果映射公式写错,很容易以为第 6 位是'0'。
判断题第二题:当n = 3,k = 8时,程序输出'0'。
答案:正确。第 8 位就是S(3)的中间位置,构造时固定插入'0',所以直接命中k == mid分支,返回'0'。这个题非常基础,但它是理解整个程序结构的试金石:如果你在中间位置上也走了递归,说明没有看穿字符串生成规则。
选择题第一题:calc(3, 10)的输出是'0'还是'1'?
答案:'0'。手算一遍:calc(3, 10),half = 7,mid = 8,k = 10 > mid,offset = 2,pos = 7 - 2 + 1 = 6。递归calc(2, 6),根据前面判断题的结论它是'1',取反后得到'0'。
选择题第二题:当n = 60时,S(n)的长度是多少?
答案:2^61 - 1。长度公式是L(n) = 2^(n+1) - 1。这里就能看出long long的必要性,2^61已经远超int范围。选项里通常还有2^60 - 1这种迷惑项,区别就在指数是n+1还是n。
选择题第三题:calc函数的时间复杂度是多少?
答案:O(n),等价于O(log k),因为k的最大值约为2^(n+1)。每次递归n减 1,递归深度最多n + 1层,每层内的计算都是常数时间,没有循环,没有重复子问题。选项中的O(2^n)是典型的干扰项,它描述的是直接构造字符串的复杂度,不是这个递归查询的复杂度。
4. 这类题的核心考点与考场判断技巧
4.1 阅读程序三步法
做阅读程序题,最忌讳的就是拿到代码立刻从头到尾逐行“模拟执行”。代码一长,模拟到一半就容易晕,尤其是递归函数。我自己总结了一个三步法,专治这种“一眼望不到头”的代码。
第一步,先看主函数,明确输入输出。主函数就是cin >> n >> k; cout << calc(n, k);,所以这道题的本质是一个“给轮数和位置,输出字符”的查询问题。
第二步,确认递归终止条件和分支结构,不要纠结每一行怎么执行,先看它把问题分成了哪几类情况。这道题就三类:位置在左半段、位置在正中间、位置在右半段。第一类和第三类都递归,中间位置直接返回。
第三步,带最小的数据手动跑一遍。不要从n = 100开始跑,先跑n = 0、n = 1、n = 2,把结果写下来找规律。一旦发现S(2)和S(3)的规律,复杂分支的正确性就很容易验证了。
4.2 常见陷阱清单
这道题能成为当年区分度高的题,不是因为它代码长,而是因为题目在细节上埋了好几个“雷”。我整理了一份易错点清单,都是实战中容易出错的地方:
| 陷阱类型 | 具体表现 | 正确做法 |
|---|---|---|
| 移位溢出 | 用1 << n代替1LL << n | 涉及大数一律用long long,养成写1LL的习惯 |
| 边界顺序 | 先判断k < mid再判断k == mid | 先判断等于,再判断小于,顺序反了中间位置会走错分支 |
| 映射方向 | 右半段映射时把pos写成half - offset | 右半段第1个对应左半段最后1个,公式是half - offset + 1 |
| 取反对象 | 对递归结果取反时写成return ch; | 字符是'0'或'1',必须显式转换,不能用!ch |
| 字符串长度 | 认为S(n)长度是2^n | 长度是2^(n+1) - 1,位置k从 1 开始计数 |
还有一个非常隐蔽的问题:不要把字符'0'和数字0混为一谈。代码里返回的是char类型,比较和取反都是字符操作。如果你在考场上直接写if (ch == 0)去判断,逻辑上完全错误。
5. 从这道题延伸开去:分治思想在竞赛中的串联
5.1 与快速幂、归并排序、线段树的联系
这道题的核心思维是“不构造整体,通过分治直接定位答案”。这种思维在竞赛中无处不在,最典型的就是快速幂。
快速幂求a^b,思路是每次把指数减半,根据指数奇偶性决定要不要乘底数,最终把O(b)次乘法压到O(log b)次。这和calc函数非常像:每次递归把n减 1,根据k的位置决定是直接返回、继续递归还是映射后取反。表面上一个算数、一个查字符串,底层都是同一个套路:把问题规模指数级缩小。
归并排序和线段树也是同一个思想。归并排序每次把区间对半拆,排序后再合并;线段树查询区间信息时,如果查询区间完全落在左子树或右子树,就直接往那个方向走,只有跨越中点时才同时往两边走。如果你把这道题的mid当成线段树的中点数轴,把k < mid当成“完全在左区间”,把k > mid当成“完全在右区间”,逻辑是完全对应的。
所以这道题虽然只是初赛的一道阅读程序题,但它背后串联的知识点是整个分治体系。你在复习快速幂时,不要只记代码模板,要理解“每次规模减半”这个本质。
5.2 延伸练习与变式思路
这类“生成长字符串 + 查询某个位置”的题,在竞赛里有不少变体。最常见的是把flip和rev的顺序互换:有的规则是“右半段 = 逆序后再取反”,有的是“右半段 = 取反后再逆序”。这两种规则下,映射公式会完全不同,做题时一定要跟着代码的生成顺序走,不能背死公式。
另一个变体是改变中间分隔符。有的题目在中间插入的是'1',有的题目插入了多个字符,还有的题目每一轮插入的字符会变化。一旦中间位置不是固定值,k == mid分支就不能直接返回,需要再增加一层判断。
如果这道题改成“要求输出整个S(n)”,就不是阅读程序题了,而是一道模拟题。模拟题可以直接用string拼接,但空间复杂度是O(2^n),只能处理很小的n。能理解“查询时反推、不构造整体”这一层,再遇到这类变式题,思路就会清晰很多。
6. 考场上的临场判断与个人实战经验
实话说,我第一次做这道题的时候也栽过,不是栽在递归理解上,而是栽在“非要画出完整递归树”这个执念上。一道阅读程序题,最忌讳的是把时间和草稿纸全部耗尽在模拟上。考场上的策略应该是:先看函数接口,再看分支结构,然后用最小的数据验证规律,最后带着规律去核对答案。
具体到这个题,calc(n, k)的输出只和两个东西有关:n的奇偶递归深度,以及k在每一层的相对位置。你可以在草稿纸中央画一条竖线表示mid,左边写上“直接递归”,右边写上“映射+取反”,然后一层一层往下带。画到第三层时,规律就已经非常明显了,不需要再往深处走。
关于验证代码,我建议备考选手在自己的开发环境里跑一跑这类题。比如 VSCode 配好 C++ 环境,新建一个calc.cpp,把上面的还原代码粘进去,编译运行,输入几组n和k,对比手算结果。这种“手算 + 机器验证”的闭环练上三次,你对递归函数的敏感度会有非常明显的提升。配置方法很简单:VSCode 安装 C/C++ 插件,装好编译器,F6 一键编译运行即可,网上教程很多,这里不展开。
还有一个细节:读题时先看k的位置范围。如果题目给定1 <= k <= 2^(n+1)-1,说明输入总是合法的,代码里的if (k == mid)只是逻辑分支,不需要担心越界。如果题目没有保证合法范围,那就要额外考虑k超出字符串长度时程序会访问到什么位置,这经常是判断题的考察点。
回到 2021 年这道 CSP-S 阅读程序第 3 题,它真正想考察的,不只是你能不能读懂一个递归函数,而是你能不能快速建立起“指数级对象用小规模递归处理”的分治直觉。这种直觉在后续的提高组赛题里非常重要,很多看似复杂的题目,核心往往就是几行递归加一个巧妙的映射公式。
最后分享一个我自己实际用的小技巧:复习阅读程序时,不要只看一遍答案就翻篇。遇到递归类代码,先在草稿纸上写出前几层展开,然后把代码里的变量名全部换成中文描述,比如把half写成“左半段长度”,把mid写成“中间位置”,把offset写成“右半段偏移量”。这样做的意义在于,逼迫自己把代码逻辑翻译成自然语言,翻译顺畅了,说明真的懂了;翻译卡壳的地方,就是备考需要补的漏洞。这道题如果也能这样顺畅翻译下来,我相信你离正式比赛拿到高分已经不远了。