ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CSP-J初赛选择题提分:补码、进制、格雷码与栈序列

CSP-J初赛选择题提分:补码、进制、格雷码与栈序列

简介:CSP-J(CCF非专业级别软件能力认证入门组)初赛笔试备赛常受真题零散、答案缺失困扰,这份文档正围绕这一痛点整理,面向备考第一轮的中小学生与初次接触信息学奥赛的选手,也可作为教练课堂练习、机构模拟自测的现成材料。包内仅含1个doc文件,体积约17KB,轻量易取,下载后无需额外工具即可直接打开阅读和打印。目前已有797人学习。内容覆盖初赛高频考点:32位int类型的存储范围、多进制混合运算、按部门分组抽人的组合计数、4位格雷码序列判别、1MB折算二进制位、C++基本数据类型与循环语句辨析,以及入栈出栈合法顺序判定等。每道题均给出答案并附推导过程,例如组合题按“某部门抽2人”分类累加、栈题逐元素模拟进出,便于对照订正错题、补齐知识盲点,适合考前限时自测使用。

1. 拿到这份 CSP-J 初赛 doc 先别急着对答案:从 32 位 int 那道题说起

大多数人打开一份 CSP-J 初赛试题 doc,动作是固定的:从头做到尾,翻到答案页对一遍,错的划个红圈,然后关掉。这套流程看着闭环,实际漏掉了初赛提分最值钱的一环——每道选择题背后都藏着一条可以在纸上推、可以用代码验、可以固化成模板的推理链。就拿第一题 32 位 int 的存储范围来说,四个选项的区间只差一个数,考的是补码表示里"负数比正数多一个"这个不对称性;把这条吃透,同类题以后再也不用背。这份 2024 年 CSP-J 组初赛的部分试题与答案解析,题量不大,但覆盖了数据表示、进制换算、排列组合、格雷码、存储单位、语言语法和栈的出栈序列,考点密度相当高。它适合两类人:刚接触入门组第一轮、想摸清题型分布的新手,以及代码能写但选择题总在细节上翻车的熟手。

2. 考点地图:7 道选择题和 1 道栈题分别落在哪个知识域

2.1 从题目反推考纲:五个知识域与它们的分布

第一轮笔试的题目看起来零散,但它们并不是随机撒点,而是按固定的几个知识域铺开的。把这份卷子里的 8 道题摊开归类,会得到一张很清晰的地图:

  • 计算机基础与数据表示:int 存储范围、1MB 等于多少 bit、C++ 基本数据类型,共 3 题;
  • 进制与编码:混合进制表达式求值、4 位格雷码序列判定,共 2 题;
  • 组合数学与计数:10 人分 3 个部门选 4 人,共 1 题;
  • 程序设计语言语法:C++ 的循环语句集合,共 1 题;
  • 数据结构:给定入栈顺序判定出栈序列的合法性,共 1 题。

先做归类这件事的价值在于,它把"我要刷多少题"换成了"我要补几个域"。归类之后你会发现一个关键比例:这 8 道题里纯记忆性的只有 2 题(基本数据类型、循环语句),剩下 6 题都能在考场现场推出来。这个比例直接决定了复习策略——初赛的分数主要来自"能不能在草稿纸上把过程写清楚",而不是"背了多少条结论"。很多人复习初赛的方式是背题,遇到原题会、换个数字就不会,问题就出在这里。

还有一层更实际的考虑。第一轮是笔试,没有编译器、没有 IDE、没有报错信息,所有验证都得靠手。这就要求对每个知识域的准备必须落到"可手算、可自查"的程度:进制题要能在纸上标底数,组合题要能列出分类表,栈题要能画出三态变化。归类完成之后,接下来每一节的解法路径都是围绕这个目标设计的。

2.2 每类题的「最快解法路径」怎么定

同一个知识域,解法路径选错了,考场上是会实打实丢时间的。下面按题型说清楚我一般怎么切。

int 范围题的切法是抓不对称性。32 位有符号整数的范围是 -2^31 到 2^31-1,也就是 -2147483648 到 2147483647。注意左端点的绝对值比右端点大 1,因为 0 占了正数侧的一个编码位。四个选项里,凡是写成"两端绝对值相等"的(-2147483647~+2147483647、-2147483648~+2147483648),都可以直接划掉,剩下的那个就是答案。这条判据比背数字可靠得多,也不怕题目改成 64 位。

存储单位题的切法是记推导链而不是记结果。1KB = 1024B,1MB = 1024KB,1B = 8bit,所以 1MB = 1024 × 1024 × 8 = 8388608 bit。把链条记住,题目怎么改(问 1GB、问多少 bit、问多少字节)都能现算;只记 8388608 这个数,换个单位就废了。

混合进制题的切法只有一条铁律:先把每个数的底数标出来,再统一转成十进制,最后按原运算符算。绝对不要在混合进制上直接做加减乘,那是最容易翻车的地方。这道题的原式是 (14₈ - 1010₂) × D₁₆ - 1101₂,四个数分属三种进制,任何一个底数标错,结果就完全对不上选项。

组合计数题的切法是分类枚举优于补集。这道题要求"每个部门至少 1 人",用总数 C(10,4) 减去有空部门的情况属于容斥,交集部分很容易重复减或者漏减。而按"哪个部门出 2 人"来切分,三类互斥且完备,加起来就是答案,不需要回加。

格雷码题的切法是只用一条判据:相邻两个码字异或之后,二进制里只能有 1 个 1。背序列是没用的,题目随便给一组看着像的序列就能骗过记忆。栈序列题同理,靠"感觉有没有逆序"必然出错,正确做法是老老实实模拟入栈出栈。

2.3 一张表把题目、考点、易错点对齐

把上面这些整理成一张表,打印出来贴在桌前,做完一遍就在"高频错点"那一列打勾。打勾最多的行,就是你下一轮复习的重点。

题号题干关键词知识域主要考点高频错点
第 1 题32 位 int 存储范围数据表示补码、有符号整数边界误以为正负两端对称
第 2 题混合进制表达式求值进制换算八/二/十六进制互转下标丢失导致把 14₈ 读成十进制 148
第 3 题10 人选 4 人,每部门至少 1 人组合计数分类枚举、组合数计算用补集算漏掉交集回加
第 4 题4 位格雷码序列编码格雷码定义、相邻位差异靠背序列而非验证相邻异或
第 5 题1MB 等于多少 bit存储单位KB/MB/bit 换算链漏乘 8 或把 1024 当 1000
第 6 题不是 C++ 基本数据类型语言语法基本类型与复合类型区分把 struct 当成基本类型
第 7 题不是 C++ 循环语句语言语法各语言控制结构差异把 repeat-until 当成 C++ 语法
第 8 题入栈 1~6,哪个出栈序列不可能数据结构栈的 LIFO 特性、模拟只用逆序对判断,忽略中间态

这张表还有个用法:把"主要考点"那一列单独抄出来,就成了一份八行的复习清单。如果某个考点你连名字都觉得陌生,那它对应的题无论做对做错,都要重新过一遍——做对可能只是蒙对。

3. 手工推演:进制、组合、格雷码、栈序列四类题的算穿流程

3.1 混合进制表达式:先把所有底数标出来再动笔

原题是计算 (14₈ - 1010₂) × D₁₆ - 1101₂ 的十进制结果,选项是 13、14、15、16。这道题在 doc 里显示成了 "(148-10102)∗D16-11012",下标全部退化成了普通字符,看起来像是十进制数 148 和 10102 在做运算——这是从网页或 PDF 复制到纯文本时最常见的失真,后面避坑那一章会专门讲怎么处理。

正确的读法是四个数各自带底数:

原式中的数实际底数十进制值换算过程
14八进制121×8 + 4 = 12
1010二进制101×8 + 0×4 + 1×2 + 0 = 10
D十六进制13D 对应 13
1101二进制131×8 + 1×4 + 0×2 + 1 = 13

代回原式:(12 - 10) × 13 - 13 = 2 × 13 - 13 = 26 - 13 = 13,对应选项 A。

推演过程里有三个容易出岔子的地方。第一处是运算优先级,括号先算,然后乘,最后减,顺序错了会得到 2 × 0 = 0 这类离谱结果。第二处是 D₁₆ 这个写法,字母 D 在十六进制里是合法的数字字符,但它在纯文本里和变量名长得一样,读题时要靠上下文判断——如果一个式子里同时出现 A 到 F 的字母且没有声明变量,那基本就是十六进制数字。第三处是最后一个 1101₂,它前面是个减号,很容易被误读成乘方或者下标的一部分。

我自己做这类题的习惯是:读完题干先用笔在草稿纸上重写一遍,每个数后面用括号标底数,比如写成 (14)(8进制) 这种形式,然后再开始算。多花十秒重写,比算错重来省时间。这个习惯在考场上尤其值钱,因为第一轮笔试没有草稿自动保存,改起来很麻烦。

3.2 组合计数:按「哪个部门出 2 人」分类枚举

原题是 10 名员工分在 3 个部门,A 部门 4 人、B 部门 3 人、C 部门 3 人,从中选 4 人组成工作组,每个部门至少 1 人,问有多少种选法。

先想清楚人数分配。4 个人分到 3 个部门,每部门至少 1 人,那么分配方案只可能是 (2,1,1) 这一种形态——因为如果按 (3,1,0) 分就有部门没人,违反条件。而 (2,1,1) 里那个"2"落在哪个部门,正好构成三类互斥的情况,这就是天然的切分点。

逐类计算:

  • A 部门出 2 人,B、C 各出 1 人:C(4,2) × C(3,1) × C(3,1) = 6 × 3 × 3 = 54;
  • B 部门出 2 人,A、C 各出 1 人:C(3,2) × C(4,1) × C(3,1) = 3 × 4 × 3 = 36;
  • C 部门出 2 人,A、B 各出 1 人:C(3,2) × C(4,1) × C(3,1) = 3 × 4 × 3 = 36。

三类相加:54 + 36 + 36 = 126,对应选项 B。

这里值得多说一句组合数的算法。C(n,k) 的展开是 n! / (k! × (n-k)!),但考场上没必要算阶乘,用"分子从 n 开始往下乘 k 项,分母是 k!"更快。比如 C(4,2) = 4×3 / 2×1 = 6,C(3,2) = 3×2 / 2×1 = 3。小数字用这个算法基本不会错。

再说说为什么不用补集。总选法是 C(10,4) = 210。要减掉的情况是"A 部门没人"、"B 部门没人"、"C 部门没人"这三种,但两两之间有交集(比如 A、B 都没人,那 4 个人全从 C 的 3 人里出,根本不可能选出 4 个),三三之间也有交集。容斥公式写起来是 210 - [C(6,4) + C(7,4) + C(7,4)] + [...] - [...],每一项都要判断是否为空集,稍不留神就多减或者少减。这套流程在纸面上出错概率远高于分类枚举,所以我的建议是:只要题目条件里出现"每个 X 至少 1 个"且规模不大,优先分类。

3.3 格雷码:从定义递推到 4 位序列

格雷码的定义是:一组二进制编码,任意两个相邻码字之间恰好只有 1 位不同。注意是"恰好 1 位",不是"至多 1 位",也不是"至少 1 位"。这个定义就是全部判据,不需要记任何序列。

题目给了四个 4 位码字序列作为选项,问哪个对应数组 0 至 8 的格雷码。逐个用相邻异或验证,异或结果里 1 的个数必须等于 1:

选项 A:0000,0001,0011,0010,0110,0111,0101,1000。前七对相邻码字都只差 1 位,但最后一步 0101 和 1000 异或得 1101,有 3 位不同,直接排除。

选项 B:0000,0001,0011,0010,0110,0111,0100,0101。检查 0111 和 0100 这一对,异或得 0011,有 2 位不同,排除。

选项 C:0000,0001,0011,0010,0100,0101,0111,0110。检查 0010 和 0100 这一对,异或得 0110,有 2 位不同,排除。

选项 D:0000,0001,0011,0010,0110,0111,0101,0100。逐对验证:

相邻对异或结果不同位数
0000 / 000100011
0001 / 001100101
0011 / 001000011
0010 / 011001001
0110 / 011100011
0111 / 010100101
0101 / 010000011

七对全部只差 1 位,答案就是 D。

这套验证流程在纸上大概需要一到两分钟,但它有个巨大优势:不依赖任何记忆,题目怎么改都能用。而且它还能反向用来生成——如果题目问"下一个码字是什么",只要保证和当前码字差 1 位就行(当然唯一性要靠标准生成规则,第 4 章会给公式)。

还有个细节要注意:题干写的是"数组 0 至 8",但四个选项都只有 8 个码字,不是 9 个。这说明题干表述和选项之间存在一点不严格,实际要判的是 0 到 7 这 8 个码字。遇到这类情况,以选项的个数和形态为准,不要因为题干写"0 至 8"就去找第 9 个码字,那会在考场上白耗时间。

3.4 出栈序列:用「栈内 + 已出 + 未入」三态表判非法

原题是空栈,入栈元素依次为 1、2、3、4、5、6,1 最先入栈,6 最后入栈,问哪个出栈顺序不可能。选项里 D 是答案,但重点是搞清楚"为什么不可能"。

判非法最稳的办法是模拟,把每一步的三态(栈内内容、已出序列、尚未入栈的元素)都记下来。以选项 D 的 1,3,5,2,4,6 为例:

要第一个输出 1,必须先把 1 入栈再立刻弹出,此时栈空,已出 [1],未入 [2,3,4,5,6]。要输出 3,得把 2、3 依次压入,再弹出 3,此时栈内 [2],已出 [1,3],未入 [4,5,6]。要输出 5,得压入 4、5,弹出 5,此时栈内 [2,4],已出 [1,3,5],未入 [6]。下一个要输出的是 2,但栈顶是 4,按 LIFO 规则必须先弹 4 才能碰到 2,所以这个序列不可能实现,答案是 D。

用同样的方法验证选项 C 的 2,4,6,5,3,1:压 1、2,弹 2;压 3、4,弹 4;压 5、6,弹 6;弹 5、3、1。全程合法,栈内状态依次是 [1] → [1,3] → [1,3] → [1,3] → [1,3,5] → [1,3] → [1] → 空。所以 C 是可能的。

选项 B 的 1,6,5,4,3,2 更容易看:1 进 1 出,然后 2 到 6 全部压入,再依次弹出 6、5、4、3、2。合法。选项 A 是全部压入再全部弹出,也是合法的。

这里有个血泪经验:很多人判这类题靠"序列里有没有逆序对"。比如看到 1,3,5,2,4,6,发现 5 在 2 前面,就断定非法。这个判据是错的——选项 C 的 2,4,6,5,3,1 里同样有大量"大数在小数前面"的情况,但它是合法的。栈的合法性只跟"某个元素出栈时,比它后出栈的元素是否还在栈里"有关,跟数值大小关系没有直接联系。所以别用直觉,用模拟。

4. 代码验答案:4 段可复现脚本把选择题变成可执行断言

4.1 先把运行环境固定住:Python 3.8+ 与 C++17

手工推演完之后,下一步是用代码把这些推理固化成可以反复运行的断言。这么做有两个好处:一是验证自己手算的结果,二是把这些题改造成可以带参数的函数,以后遇到同类题直接改输入。

环境要求不高:Python 3.8 及以上(因为要用 math.comb,3.8 才进标准库),C++17 用于验证 int 边界。不需要装任何第三方库,标准库够用。建议新建一个目录,四段脚本分别存成单独文件,命名上带题号,方便回头对照。

提示:这些脚本的定位是"验算器",不是"解题器"。目的是让你在怀疑自己手算结果时有个独立信源,而不是替代推演过程。考场上没有 Python,推演能力才是根本。

4.2 进制表达式与存储单位:两个函数搞定

# 混合进制求值:底数必须显式传入,绝不靠肉眼猜 def to_dec(digits: str, base: int) -> int: """把 digits 按 base 进制转成十进制,digits 允许 0-9 和 A-F""" value = 0 for ch in digits.upper(): d = int(ch, 36) # 借用 36 进制解析拿到单字符数值,A=10 ... F=15 if not 0 <= d < base: # 越界直接报错,避免把非法数字算进去 raise ValueError(f"数字 {ch} 不属于 {base} 进制") value = value * base + d # 秦九韶算法,逐位累乘 return value a = to_dec("14", 8) # 14(八进制) = 12 b = to_dec("1010", 2) # 1010(二进制) = 10 c = to_dec("D", 16) # D(十六进制) = 13 d = to_dec("1101", 2) # 1101(二进制) = 13 print("各项十进制值:", a, b, c, d) print("表达式结果:", (a - b) * c - d) # 期望 13,对应选项 A # 存储单位换算链:MB -> KB -> B -> bit mb = 1 print("1MB 的 bit 数:", mb * 1024 * 1024 * 8) # 期望 8388608,对应选项 D

逻辑上没什么花哨的,核心在第 4 行那个int(ch, 36):它把单个字符当作 36 进制数解析,'0' 到 '9' 得到 0 到 9,'A' 到 'Z' 得到 10 到 35。这样一行就解决了十六进制字母转数值的问题,不用写 if-else 分支。第 5 行的边界检查是必须的,否则把"8"传进 8 进制时会静默算出错误结果而不是报错,这属于典型的黑匣子行为。

参数说明:digits传字符串而不是整数,是因为像 "1010" 这样的二进制串用整数表示会丢掉前导零,而且没法区分 "10" 到底是二进制还是十进制。base必须显式给,这是这套代码最重要的设计——强制你在调用时想清楚每个数的底数,正好对应 3.1 节强调的"先标底数"。

4.3 组合计数与栈序列:把选择题变成暴力枚举

from math import comb # 组合计数:A 部门 4 人,B 部门 3 人,C 部门 3 人,选 4 人每部门至少 1 人 sizes = {"A": 4, "B": 3, "C": 3} total = 0 for two_in in ("A", "B", "C"): # 枚举哪个部门出 2 人 plan = {"A": 1, "B": 1, "C": 1} plan[two_in] = 2 # 该部门名额改成 2 ways = 1 for dept, k in plan.items(): ways *= comb(sizes[dept], k) # 三个部门各自从本部选人 print(f"{two_in} 出 2 人: {plan} -> {ways}") total += ways print("总方案数:", total) # 期望 126,对应选项 B # 栈序列合法性:严格按入栈出栈规则模拟 def stack_ok(seq): stack, nxt = [], 1 for x in seq: while nxt <= x: # 把 x 及之前所有还没入栈的压进去 stack.append(nxt) nxt += 1 if stack and stack[-1] == x: stack.pop() # 栈顶正好是待出元素,弹出 else: return False # 栈顶不是 x,这个顺序拿不到 return True for seq in [(6,5,4,3,2,1), (1,6,5,4,3,2), (2,4,6,5,3,1), (1,3,5,2,4,6)]: print(seq, "合法" if stack_ok(seq) else "非法")

第一段代码的关键在循环结构:外层枚举"哪个部门出 2 人",内层遍历三个部门调comb。comb(n, k)返回 C(n,k),底层用的是高效的乘法公式,不用担心阶乘溢出。这段代码的写法比手写 6×3×3 更抗改:题目如果把 A 部门改成 5 人,只需要改sizes字典里的一个数字,其余逻辑不动。

第二段代码是栈模拟的核心,nxt是"下一个还没入栈的元素编号"这个指针,stack是当前栈内容。对序列里的每个待出元素x,先把 1 到 x 之间所有还没入栈的压进去,然后检查栈顶是不是 x。这里有个前提假设:入栈顺序是 1 到 n 的递增序列,如果题目改成任意入栈序列,要把nxt <= x改成按下标推进,逻辑会复杂一些,但核心判据不变。

参数说明:seq必须是 1 到 n 的一个排列,元素不重复且都在范围内。如果传进去的序列有重复元素或者越界,函数会给出错误结果而不是报错,所以调用前最好加一句断言。

4.4 格雷码与 int 边界:一条公式 + 三个常量

# 标准 n 位格雷码生成公式:第 i 个码字 = i ^ (i >> 1) def gray(i: int) -> int: return i ^ (i >> 1) codes = [f"{gray(i):04b}" for i in range(8)] print("生成序列:", codes) # 期望 ['0000','0001','0011','0010','0110','0111','0101','0100'],与选项 D 一致 # 相邻校验:异或结果里必须恰好只有 1 个 1 def adjacent_ok(seq): for a, b in zip(seq, seq[1:]): diff = int(a, 2) ^ int(b, 2) if bin(diff).count("1") != 1: return False, (a, b) return True, None options = { "A": ["0000","0001","0011","0010","0110","0111","0101","1000"], "B": ["0000","0001","0011","0010","0110","0111","0100","0101"], "C": ["0000","0001","0011","0010","0100","0101","0111","0110"], "D": ["0000","0001","0011","0010","0110","0111","0101","0100"], } for key, seq in options.items(): ok, bad = adjacent_ok(seq) print(key, "合法" if ok else f"在 {bad[0]}/{bad[1]} 处断开")

gray(i)这一行公式来自格雷码与自然二进制的关系:g = n ^ (n >> 1),把自然数和它右移一位的结果异或就行。这个公式在纸面上也很好用——考场上如果题目问"第 5 个 4 位格雷码是多少",把 5 写成 0101,右移一位得 0010,异或得 0111,一步到位,不用从 0 开始一个个推。

f"{gray(i):04b}"里的04b是格式说明符,b表示二进制,04表示不足 4 位时左补零。这个细节很重要,如果不补零,"0000" 这种码字会输出成 "0",后面int(a, 2)虽然还能解析,但序列对齐会乱。

选项字典那段的用法是:把你怀疑的答案全塞进去,让程序一次性告诉你哪些合法。这比逐个手算快,也更容易发现自己漏掉的分支。

C++ 侧验证 int 边界:

#include <cstdio> #include <climits> #include <cmath> int main() { printf("sizeof(int) = %zu\n", sizeof(int)); // 常见实现是 4 printf("INT_MIN = %d\n", INT_MIN); // -2147483648 printf("INT_MAX = %d\n", INT_MAX); // 2147483647 printf("2^31 - 1 = %.0f\n", std::pow(2, 31) - 1); // 用浮点交叉验证右端点 int a = INT_MAX; printf("INT_MAX + 1 = %d\n", a + 1); // 未定义行为,不同编译器可能不同 return 0; }

这段的重点不是输出结果,而是最后一个 print 的注释:有符号整数溢出在 C++ 标准里是未定义行为,不是"回绕成 INT_MIN"。很多编译器在默认优化下确实会得到 -2147483648,但这只是实现巧合,不能当规范记。另外sizeof(int)也说明 int 的宽度不是标准固定的,标准只保证至少 16 位,今天常见的 4 字节是主流实现的约定。这两条决定了第 1 题的复习方式:记 INT_MIN 和 INT_MAX 这两个常量,别记溢出后的结果。

5. 避坑与排查:5 条从真题 doc 里踩出来的血泪经验

5.1 现象:按题干数字直算,结果和四个选项全对不上

原因:doc 从网页或 PDF 复制过来时,上下标全部退化成了普通字符。原题里的 14₈ 变成了 148,1010₂ 变成了 10102,D₁₆ 变成了 D16,1101₂ 变成了 11012。如果按字面当十进制算,(148 - 10102) × 16 - 11012 会得到一个很大的负数,跟选项完全不在一个量级。

解决:拿选项当校验器反推底数。原式算出来的答案必须落在 13 到 16 之间,那只有把这四个数理解成小数值的进制表示才可能。具体操作是:读完题干先看选项区间,再看式子里的数字形态,凡是出现 0 和 1 组成的长串,优先猜二进制;出现 8、9 以外的单个字母(A 到 F),优先猜十六进制。确认之后,用 4.2 节的to_dec函数逐个验一遍,把底数标死在草稿纸上再开始算。

5.2 现象:组合题算出来 119 或者 132,就是得不到 126

原因:用补集法算的时候容斥处理错了。具体来说,总选法 C(10,4) = 210 是对的,减掉"A 部门 0 人"的 C(6,4) = 15 也对,但接着减"B 部门 0 人"的 C(7,4) = 35 和"C 部门 0 人"的 C(7,4) = 35 时就出问题了——这三类之间存在交集,而且"B 没人且 C 没人"这种情况要求 4 个人全从 A 的 4 人里选,只有 1 种,A 没人且 B 没人则完全不可能。少加或多加一个交集项,结果就在 120 到 132 之间漂。

解决:换分类枚举,别用补集。这道题按"哪个部门出 2 人"切分,三类互斥完备,加起来就是答案,不存在容斥问题。判断标准很简单:只要题目条件是"每个 X 至少 1 个",且 X 的个数不超过 4,分类枚举的出错概率远低于补集。补集法适合"至少 1 个"的反面只有一种情况(比如"至少 1 个白球"的反面是"全是黑球")的场景。

5.3 现象:格雷码题盯着选项看了五分钟,每个选项的相邻对看着都"差不多"

原因:靠视觉判断二进制串"像不像只差一位"是不可靠的。0001 和 0011 看起来只差一位,0111 和 0100 看起来也只差一位,但后者实际差了两位。人眼对二进制串的位数变化不敏感,尤其是位数比较多的时候。

解决:用异或。把相邻两个码字做按位异或,结果里 1 的个数必须恰好是 1,多一个少一个都不行。这一步在纸上也能做——异或的规则是同为 0、异为 1,逐位扫一遍就行。4.4 节的adjacent_ok函数就是把这个过程固化下来,四个选项一次性跑完,哪个在哪个位置断开一目了然。从那以后我遇到格雷码题,第一反应永远是写异或,而不是比对序列。

另一个相关的坑是题干写"数组 0 至 8"但选项只有 8 个码字。遇到这种表述不严格的情况,以选项的形态为准,不要钻牛角尖去找第 9 个。

5.4 现象:栈序列题把合法选项判成了非法,或者反过来

原因:用"序列里有没有逆序对"当判据。这个判据的直觉来源是"栈是后进先出,所以大的应该先出",但栈的合法性跟元素数值大小没有必然联系,只跟"某个元素出栈时,比它后出栈的元素是否还压在栈里"有关。选项 C 的 2,4,6,5,3,1 里,6 在 5 前面、5 在 3 前面,看着全是逆序,但它是完全合法的。

解决:老老实实模拟。具体做法是维护两个状态——"下一个待入栈的编号"和"当前栈内容",对出栈序列的每个元素执行"入栈到该元素为止,然后检查栈顶"这两步。纸面上就用三态表(栈内 / 已出 / 未入)逐步记录,每一步都写清楚。4.3 节的stack_ok函数是同一个逻辑的代码版本,可以拿来对答案。

还有一种更隐蔽的错法:把入栈顺序当成可以随意调整的。题目明确说了"1 最先入栈,6 最后入栈",入栈顺序是固定的递增序列,不能为了凑出某个出栈顺序而改变入栈次序。读题时把这类约束画出来,能省掉很多返工。

5.5 现象:把 INT_MAX + 1 当作 -2147483648 记,做题或写代码时踩雷

原因:这是从"补码回绕"的民间说法里来的。确实有很多编译器在默认优化下会得到 -2147483648,但 C++ 标准里规定有符号整数溢出是未定义行为,编译器可以做任何事,包括直接优化掉这段代码。把它当成规范来记,遇到开优化编译或者换编译器就会翻车。

解决:只记两个常量,不记溢出结果。INT_MIN = -2147483648,INT_MAX = 2147483647,两者不对称的原因是 0 占了正数侧一个编码位。需要处理边界溢出时,用long long或者显式检查,别依赖回绕。另外sizeof(int)也别想当然,标准只保证至少 16 位,跨平台代码里该用<cstdint>里的int32_t就用。

6. 三遍复盘法:把一份 8 道题的卷子用出 3 倍效果

一份只有 8 道题的初赛卷,硬刷一遍大概二十分钟就没了。想让它产生持续价值,得把它当成三次不同的材料来用。

第一遍,限时闭卷,模拟真实状态。手机关掉,草稿纸只给一张,按第一轮笔试的节奏做,遇到卡壳超过三分钟的题先标记跳过。这一遍的唯一目的是拿到一份真实的初始成绩,不做任何查证。做完之后只统计两个数字:完全不会的有几道,蒙对的有几道。后一个数字特别关键——蒙对的题在成绩单上显示为"对",但在能力账本上是"错",混在一起就会高估自己。

第二遍,逐题写推导,把"对"和"会"分开。这一遍不看答案,对每道题(包括做对的)在草稿纸上完整写出推导过程,然后翻到答案解析逐行比对。比对的重点不是结果对不对,而是过程有没有跳步。凡是一步就写出答案、中间没有推导痕迹的题,都要在题号旁标记"未验证"。这份卷子里的进制题和组合题最容易出现这种情况——看着眼熟,答案脱口而出,但真让你重算一遍就卡住了。

第三遍,隔期重做,只看题干。做完第二遍之后隔三天做一次错题,隔七天再做一次。这一次把答案页遮住,也不允许看自己之前的推导,完全裸做。两次都做对的题可以划掉,任何一次出错的题回到第二遍重新走流程。这个循环会筛出真正的薄弱点,通常不超过三道。

配合这个流程,我做了一张错因归因表,做完第二遍之后按类别填:

错因类型典型表现这份卷子里的对应题补救动作
概念模糊知道考什么但说不清定义第 1 题 int 边界、第 4 题格雷码回到定义原文,用自己的话复述一遍
判据错误用"感觉"代替规则第 4 题背序列、第 8 题看逆序对换成可执行的判据(异或、模拟)
计算失误思路对但算出别的数第 2 题进制、第 3 题组合手算过程分步写,不跳步
读题失真把 14₈ 读成 148第 2 题混合进制先按选项区间反推底数,再动笔
记忆混淆记成回绕而非未定义第 5 题存储单位、int 溢出只记常量和公式,不记结论性"规律"

表里"判据错误"这一行是我自己踩得最狠的。早些年做栈序列题,我全靠扫一眼序列里有没有降序段,正确率大概六成,还以为是题出得怪。后来强迫自己每次都在纸上画三态表,正确率立刻上去了,代价是每道题多花一分半钟。这笔账算下来是划算的:一分半换一道题的分,比做错重来便宜太多。

还有个小技巧,适合把这份 doc 用得更透:把选择题的选项删掉,改造成填空题。原题问"32 位 int 的范围是()",就把选项全删,只看题干,逼自己从零写出答案。这一步比做完再对答案狠得多,因为它切断了"排除法"这条捷径。doc 格式的好处就在这里——你可以直接编辑,另存成一份无选项版本,隔一段时间做一次。同一份题,改造前是 8 道选择题,改造后是 8 道填空,等于凭空多出一套卷子。

最后说个我自己的习惯。每次复盘结束,我会在卷子首页写一句"下次遇到这类题,第一件事做什么",比如"进制题:先标底数"、"栈题:画三态表"、"格雷码:写异或"。这句短句比任何笔记都管用,因为它在考场上能被瞬间回忆起来,而长篇笔记不能。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进