最近在刷某通信大厂的OD机考真题,C卷里有一道“特殊的加密算法”,把身边不少同事都卡住了。这道题题目不算长,但它同时考察了字符串处理、ASCII码操作、进制转换和多语言语法细节,属于那种“看得懂题、写不对代码”的典型题目。更麻烦的是,机考环境是双机位监控加ACM模式,代码得完整可运行,不能只写核心逻辑,输入输出错了照样零分。这篇文章我把这道题的完整拆解、推理过程和五种常用语言的实现差异一次性讲清楚,不管是第一次备考的新人,还是已经刷过不少题的老手,都能从中捞到点实际能用的东西。
1. 这题考的不是加密,而是这三层基本功
1.1 题型定位:表面是算法,实际是编码规约题
先还原一下题目的大致描述。题目会给两个输入字符串:第一个是待加密的明文字符串,第二个是密钥字符串。要求按照某种规则对明文逐字符加密,输出一个符合规定格式的密文串。常见规则是:取明文字符的ASCII码,与密钥对应位置字符的ASCII码做异或运算,再把结果转成两位大写十六进制数输出;密钥不够长时从头循环使用。
这类题在OD机考里被归类为“字符串处理+模拟”题型。和纯数据结构的题目不同,它的核心难点不在于算法设计,而在于你能不能把一个文字描述的规则,无歧义地翻译成代码,并且处理好各种边界条件。说得直接一点,它考的是编码规约能力——你平时写接口、写协议时能不能把字段规则和转换逻辑落实到位。
我在模拟和实际练习中把这道题做了好几遍,发现它最合理的解法思路其实特别简单:按位遍历、按位加密、拼接输出。时间复杂度O(n),空间复杂度O(n),没有任何需要优化算法的余地。正因为它没有弯弯绕,反而淘汰了不少人——大家不约而同在某个细节上翻车了。
1.2 三层基本功:ASCII映射、循环取模、格式化输出
把这道题拆开来看,它实际在检验三件基础到不能再基础的事情:
第一,ASCII码与字符的互相转换。题目不会直接给你ASCII码表,你得知道'a'是97、'A'是65这种常用的对应关系,并且知道在Java里(int) ch、Python里ord(c)、JavaScript里charCodeAt(0)分别能拿到码值。这些API差异,恰恰是跨语言刷题时最容易被绊倒的地方。
第二,循环取模的索引计算。假设明文字符串长度为n1,密钥字符串长度为n2,那么第i个明文字符应该对应密钥的第i % n2个字符。这个逻辑本身不难,但有个关键前提:你必须在动手编码前先判断密钥字符串是否为空。如果密钥为空,取模会直接抛异常,这属于拿到题目后应该第一时间想到的防御性处理。我见过几十份同学提交的代码,至少有三分之一在这一点上翻了车。
第三,格式化输出为两位大写十六进制。异或运算的结果是一个整数,范围在0到255之间。转十六进制时,如果结果是0x5,你得输出05而不是5;转出来的字母A到F必须是大写,不能是小写。这个细节几乎每个语言都有自己的坑,后面我会在语言对比部分展开。
1.3 机考环境里的隐藏要求:完整可编译、严格匹配输出
这里必须单独提醒一下:OD机考是ACM模式,不是LeetCode那种只补全函数核心逻辑的答题方式。你需要自己写好完整的输入读取、逻辑处理、结果输出,并且保证代码能一次性编译通过。
具体来说,Java要写完整的Main类,Python要从sys.stdin读取数据,C++要记得包含必要的头文件,Go要处理fmt.Scanln的返回值。这些在核心逻辑之外的“壳子代码”占了整个题目工作量的三到四成。很多人刷题时只练算法、不练IO,到了考场上反而被输入输出折腾得不行。我认为,备考阶段就应该按照ACM模式的标准来练习,把每次练习都当成一次完整的机考。
双机位监控意味着整个写代码的过程都会被记录,所以考场上的行为规范也要注意:不要切换出答题界面去查资料,不要试图偷看题解,安安静静把代码写完编译通过提交就好。倒不是说监控有多严格,而是这些额外的心理负担会影响你的解题状态。
2. 加密规则一步步推:从题目描述到完整实现
2.1 先把规则翻译成数据流
面对任何一道描述型的算法题,我都会先做一步“规则翻译”的工作:把题目的文字描述转成一个清晰的数据处理流程,然后再从流程推导代码结构。这道题的数据流其实只有四个阶段:
- 读取明文
plain和密钥key,获取长度n1、n2; - 校验密钥长度非零;
- 对每个下标
i从0到n1-1,取出明文第i个字符和密钥第i % n2个字符,分别转ASCII码后异或; - 把异或结果格式化为两位大写十六进制,依次拼接并输出。
如果题目规则和我刚才说的略有不同,比如改成了加法、减法、按位与,流程也基本不变,只是第三步的运算符号换一下。这就是把规则翻译成数据流的价值——你可以无视具体的加密运算,只关注处理流程本身。
2.2 一个完整的推导样例
假设明文是"Abc",密钥是"X9"。我们手动走一遍,验证规则理解是否正确,也方便写完后对照测试。
- 明文第0个字符
'A',ASCII码65;密钥第0个字符'X',ASCII码88。异或运算:65 ^ 88 = 120。转十六进制:120对应78。所以第一个输出字节是78。 - 明文第1个字符
'b',ASCII码98;密钥第1个字符'9',ASCII码57。异或:98 ^ 57 = 123。十六进制:123对应7B。第二个输出字节是7B。 - 明文第2个字符
'c',ASCII码99;密钥长度2,按照循环取模回到第0个字符,即'X'的88。异或:99 ^ 88 = 59。十六进制:59对应3B。第三个输出字节是3B。
最终输出是787B3B。这个样例跑通了,至少说明你对规则的理解没有大的偏差。真正写代码之前,我强烈建议先手工算一两个样例,特别是涉及循环取模的部分——很多时候算法错误不是代码写错,而是规则理解错了。
2.3 边界条件:什么时候会出问题
我对边界条件的处理原则是:在写代码之前,先把所有可能出问题的点列出来,然后逐个检查代码是否处理到位。
针对这道题,至少要考虑以下三种情况:
- 明文字符串长度为0:循环体不会执行,最终输出应该是空串。大多数语言直接跳过循环就能正确处理,但有些人的代码会在输出时多打一个换行,导致判题系统认为格式错误。
- 密钥字符串长度为0:必须提前返回或者抛出合理错误。由于密钥是外部输入,理论上判题数据不会给空密钥,但严谨的代码还是要防御。我的做法是在读取密钥后立刻判断长度,为空则直接返回。
- 明文是数字、空格、标点等非字母字符:这类题的规则通常不区分字符类型,所有字符都参与加密。ASCII码对全字符表有效,不需要额外判断是否是字母。
这些边界不一定会出现在判题数据里,但考虑到位后,你的代码会变得更健壮,思考也会更完整。
3. 五种语言实现对比:同一个逻辑,五种翻车方式
3.1 核心逻辑的跨语言伪码
在展示具体代码前,我先给出一段与语言无关的伪码,方便对照理解:
plain = readLine() key = readLine() n1 = length(plain) n2 = length(key) if n2 == 0: return result = "" for i = 0 to n1 - 1: p_char = plain[i] k_char = key[i % n2] xor_val = ord(p_char) ^ ord(k_char) // 按语言取ASCII码 hex_str = formatAsTwoDigitUppercaseHex(xor_val) result += hex_str print(result)后面的每一种语言实现,本质都是这段伪码的翻译。区别只在于:如何读入字符串、如何取ASCII码、如何格式化十六进制、如何处理字符和整数的类型转换。
3.2 Java实现:记住%02X和charAt
Java版本最标准也最稳,核心代码大致如下:
import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); String plain = sc.nextLine(); String key = sc.nextLine(); int n2 = key.length(); if (n2 == 0) { return; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < plain.length(); i++) { char pc = plain.charAt(i); char kc = key.charAt(i % n2); int xorVal = pc ^ kc; sb.append(String.format("%02X", xorVal)); } System.out.println(sb.toString()); } }这里有两个Java专属的注意点。第一,String.format("%02X", xorVal)的%02X是三段式格式说明:0表示不足两位时左补零,2表示最小宽度为两位,X表示大写十六进制。如果写成小写x,输出会变成78和7b而不是78和7B,判题直接不通过。第二,char类型之间做异或运算后,结果会自动提升为int,所以char ^ char的结果直接用整数接收是没问题的,不需要手动强转。但在一些比较老的教学代码里,我看到有人写int xorVal = (int) pc ^ (int) kc;,多此一举但不算错,也能跑。字符串拼接用StringBuilder而不是直接用+=,众所周知是为了性能,不过这道题的数据量不大,直接用+=其实也不会超时,更多是代码习惯问题。
3.3 Python实现:hex()的补零陷阱要小心
Python写起来最简短,核心逻辑如下:
import sys def main(): plain = sys.stdin.readline().strip() key = sys.stdin.readline().strip() n2 = len(key) if n2 == 0: return result = [] for i, ch in enumerate(plain): xor_val = ord(ch) ^ ord(key[i % n2]) result.append(f"{xor_val:02X}") print("".join(result)) if __name__ == "__main__": main()这段代码里最值得聊的是f"{xor_val:02X}"这个格式化写法。很多人习惯用Python内置的hex(xor_val),它会返回0x78这种带0x前缀的字符串,还得手动去掉前缀。而且如果异或结果小于0x10,hex()只会返回0x5,不会自动补零,你必须自己用zfill(2)。这不是说hex()不能用,而是它在本题场景下需要额外处理两步,不如直接用格式化字符串干净。
另外,sys.stdin.readline().strip()会把行尾的换行符去掉。如果漏掉strip(),明文末尾的\n会被当成一个ASCII码为10的字符参与加密,整个结果全错。这种错误特别隐蔽,因为字符串只要不是最后一个读取的,控制台里看不见差别。
3.4 JavaScript实现:padStart补齐最常用的两位
JavaScript在机考环境里通常跑在Node.js上,输入输出通过标准流处理。核心逻辑如下:
const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const lines = []; rl.on('line', (line) => { lines.push(line); if (lines.length === 2) { const [plain, key] = lines; if (key.length === 0) { process.exit(0); } let result = ''; for (let i = 0; i < plain.length; i++) { const xorVal = plain.charCodeAt(i) ^ key.charCodeAt(i % key.length); result += xorVal.toString(16).toUpperCase().padStart(2, '0'); } console.log(result); process.exit(0); } });JavaScript有几个容易出问题的点。第一,String.prototype.charCodeAt()返回的是字符的Unicode码点,对于ASCII字符来说和ASCII码一致,可以直接用。第二,toString(16)转出来的十六进制字符串是变量长度且默认小写,必须先toUpperCase()再padStart(2, '0')。顺序不能反:如果你先补零再转大写,padStart补进去的字符会是0,结果不受影响,但可读性不好;更关键的是不能只调用padStart不调用toUpperCase(),字母的大小写错误往往是判题扣分的重灾区。第三,Node.js的readline是异步的,你得等两行输入都到达后再处理,这和Java的阻塞式nextLine完全不同。如果把业务逻辑直接写在第一个line事件里,第二个密钥还没读到,代码打印出的结果必然是错的。
3.5 C++实现:无符号类型才是最大的隐藏坑
C++版本的代码本身不算复杂:
#include <cstdio> #include <iostream> #include <string> int main() { std::string plain, key; std::getline(std::cin, plain); std::getline(std::cin, key); int n2 = key.size(); if (n2 == 0) return 0; std::string result; result.reserve(plain.size() * 2); for (int i = 0; i < (int)plain.size(); i++) { unsigned char x = static_cast<unsigned char>(plain[i]); unsigned char y = static_cast<unsigned char>(key[i % n2]); unsigned int xorVal = x ^ y; char buf[4]; snprintf(buf, sizeof(buf), "%02X", xorVal); result += buf; } std::cout << result << std::endl; return 0; }为什么这里要强调unsigned char?这是C++最容易踩的坑。char在有符号平台上是有符号类型,取值范围可能是-128到127。如果某个字符的ASCII码大于127,比如扩展ASCII码里的é或者某些GBK编码下的汉字字节,直接读出来的char会是负数。负数参与异或运算,会先被隐式转换为带符号的int,再进行位运算,最终结果的十六进制可能是FFFFFF80这种8位形式,完全不符合输出要求。加上static_cast<unsigned char>强制转为无符号后,就不会出现符号扩展的问题。
另外一个细节是snprintf格式化时,要把xorVal作为unsigned int传入,否则在某些平台上%X匹配的是unsigned int,格式不匹配会触发编译告警甚至未定义行为。这些细节点在本地编译时不一定暴露,但在判题系统的严格编译选项下很可能变成编译错误或运行异常。
3.6 Go实现:格式化最顺手,但别忽略Scanln的返回值
Go版本的代码也比较直接:
package main import ( "fmt" ) func main() { var plain, key string _, err := fmt.Scanln(&plain) if err != nil { return } _, err = fmt.Scanln(&key) if err != nil { return } n2 := len(key) if n2 == 0 { return } result := make([]byte, 0, len(plain)*2) for i := 0; i < len(plain); i++ { xorVal := plain[i] ^ key[i%n2] result = append(result, fmt.Sprintf("%02X", xorVal)...) } fmt.Println(string(result)) }Go的fmt.Sprintf("%02X", xorVal)和Java用法一致,非常顺手。这里有一个Go特有的点:plain[i]在Go里直接就是byte类型,等价于无符号8位整数,所以不会像C++那样出现符号扩展问题。但要注意fmt.Scanln读取字符串时,如果输入串内部包含空格,它只会读到空格前的部分。这道题的输入是一行一个字符串,通常不含空格,所以问题不大;但如果题目描述里没有明确限制,保险起见,我会建议封装一个用bufio.Reader的ReadString('\n')来读取整行,这样才能万无一失。
3.7 语言实现差异对照与复杂度小结
| 语言 | 取ASCII码方式 | 两位大写十六进制格式化 | 最容易被忽略的坑 |
|---|---|---|---|
| Java | charAt()+ 自动转int | String.format("%02X", v) | %X大写,别写成%x |
| Python | ord() | f"{v:02X}" | 漏掉strip()导致隐藏换行符 |
| JavaScript | charCodeAt() | toString(16)+toUpperCase()+padStart | 异步读入,要等所有行齐再处理 |
| C++ | static_cast<unsigned char> | snprintf("%02X") | 有符号char的符号扩展 |
| Go | 直接索引得到byte | fmt.Sprintf("%02X", v) | Scanln遇空格截断 |
这道题所有语言的复杂度都是O(n)时间、O(n)空间,没有任何性能压力。真正拉开差距的,是刚才表格里列出的这些语言细节。
在这个小节结尾,我再给一个通用的自查清单:每写完一个语言版本,反问自己三句话——字符读取是否覆盖全字符范围;十六进制是否两位且大写;字符串拼接是否有不必要的性能浪费。三句话都对了,这题基本稳了。
4. 判题细节与“双机位”实战经验
4.1 输入输出格式:看似简单,实则杀机四伏
OD机考的判题系统只认标准输出,不认自己定义的任何格式。我见过有人在输出的时候加了前缀result:,也有人每个字节之间加了空格,还有人最后多打印了一行空行——这些全部会被判成答案错误。
这道题的输出格式要求很严格:连续输出十六进制字节,中间不能有空格、换行或任何其他字符,末尾一个换行符结束。为了实现这个目标,Java用StringBuilder、Python用列表append再join、C++用result.reserve预分配,都是为了尽量减少字符串拼接的损耗。虽然本题数据量小,拼接损耗可以忽略,但代码的工程素养会在机考的综合性评分里给你加分。
输入方面,需要注意一点:fmt.Scanln和cin >> plain遇到换行符就会停止,而Scanner.nextLine()和getline会读取整行。如果输入的字符串本身不包含空格,这几者一致;如果包含空格,只有后两者能正确处理。判题数据一般不会在字符串中间夹空格,但我的原则是选择最健壮的读取方式,宁可在其他方面多几行代码。
4.2 边界用例,哪怕三十秒也想一遍
在正式提交前,我习惯在本地跑完以下三组测试用例,全部通过才提交:
| 测试场景 | 输入 | 期望输出 | 说明 |
|---|---|---|---|
| 基本功能 | 明文ABC,密钥A | 异或结果预期 | 验证循环取模在密钥长度为1时的表现 |
| 混合字符 | 明文a1z#,密钥K# | 异或结果预期 | 验证非字母字符是否被正确处理 |
| 密钥循环 | 明文ABCD,密钥12 | 异或结果预期 | 验证i % n2在明文长度大于密钥长度时是否正确 |
注意,这里我没有给出具体的异或结果数值,因为每个人拿到的题目在规则细节上可能略有不同。重要的是方法:手动用ASCII码表算出第一个字节的期望值,然后程序输出这个值,说明规则理解正确;如果没有对应上,赶紧回看题目描述,而不是硬调代码。
4.3 机考现场的时间分配与调试策略
OD机考的题量通常不止一道,C卷包含多道不同难度的题目。我认为这道“特殊的加密算法”属于中低难度题,正常应该在20分钟内写完并调通。时间分配可以是:前5分钟读完题目、推样例、确认规则;接下来10分钟写代码、处理编译问题;最后5分钟跑边界用例、做最终提交。
如果提交后判题失败,不要立刻大改代码。先对比一下输出样例和期望输出的差异:差异在第几个字符?一般情况下,前几个字符能对上、后面全错,是循环取模的下标算错了;前几个字符就错,是规则理解或字符类型处理错了。定位到具体位置再改,效率最高。实在调不出来,可以先跳过做后面的题,把能拿的分拿到了再回来啃,这是机考的通用策略。
关于“双机位”,我想说点实际的:现在机考要求在另一台设备开摄像头对着你,所以桌面只能放身份证件、纸笔和必要的设备。不要在桌面上放一堆小抄,人工智能识别到疑似作弊行为会直接取消成绩。更重要的是心理状态,前面写代码,后头有摄像头,手别抖,思路要稳。平时练习时,最好也给自己模拟这种“被盯着写代码”的环境,考场上能省掉很多不必要的紧张。
5. 一点点个人实操体会
这道题算是我备考路上刷得比较有感触的一道:它不靠奇技淫巧,纯粹考验你有没有把每个基础API用对、每个边界想透。我前后用Java、Python和Go各写了一遍,每换一种语言都能发现上一版没注意的新问题,这让我强烈建议备考的朋友不要只盯一门语言——就算你只准备考Java,也花十分钟把Python版本敲一遍,很多关于数据流和边界条件的理解会突然通透了。
一个小技巧收尾:考试前把ASCII码表里'a'、'A'、'0'这几个常用字符的码值背下来,做题的时候能节省不少翻资料的时间。还有,所有字符串读取都养成习惯加strip()或等价的处理,不管题目里有没有空格,这能帮你避开一大片隐蔽的错误。祝考试顺利,一次过。