这道题我印象太深了。每年准备北大计算机考研复试的同学,基本都会在OJ上撞见它——题目编号3405,名字叫“W的密码”。字符串长度不超过100,三个移位参数,输出变换后的结果。听起来像个五分钟能写完的签到题,但真正动手做的时候,不少人会在分组下标、移位取模、多组输入这几个地方翻车。我当年第一次做这题的时候也被“循环右移”这个表述坑过,后来在论坛上看到好几个人问同样的问题,才知道这是题目翻译导致的经典歧义。今天就把这道题的完整思路、实现细节、常见坑一次讲透,不管你是刚开始刷机试还是准备冲刺复试,这篇都值得收藏。
1. 这道题到底在考什么:先读懂题面
1.1 题目规则梳理
先冷静把题目规则拆开看。输入数据是这么构成的:每一组测试数据包含一个字符串,以及三个整数a、b、c。字符串由字母和数字等字符组成,长度不超过100。a、b、c分别表示对三组字符进行循环右移的位数。当a、b、c同时为0时,整个输入结束。
关键规则有两条。
第一条是分组规则。字符串中的字符按位置从1开始计数,第1、4、7、10...个字符属于第一组,第2、5、8、11...个字符属于第二组,第3、6、9、12...个字符属于第三组。说白了就是把整个字符串按“位置模3”分成三个子序列。
第二条是移位规则。第一组中的字母循环右移a位,第二组中的字母循环右移b位,第三组中的字母循环右移c位。这个“循环右移”是凯撒密码式的位移,也就是每个字母独立在字母表里向后移动指定位置,超过末尾就从开头继续。比如'a'右移1位变成'b','z'右移1位变成'a'。大写字母在大写字母表里循环,小写字母在小写字母表里循环。数字、标点这类非字母字符保持原样。
1.2 容易被绕晕的三个点
第一个坑是分组下标的起点。题目说的是第1、4、7个字符属于第一组,但写代码时数组下标从0开始。很多新手直接拿i%3来分组,却忘了对应关系:下标0、3、6才是“第1、4、7个”。换句话说,第一组对应的是i%3==0,第二组对应i%3==1,第三组对应i%3==2。用错了分组顺序,整个输出就全错位了。
第二个坑是“字母循环右移”的理解。我见过有人把题目理解成“每组字符序列整体向右轮转”——比如第一组是"aXc",右移1位变成"caX"。这不是本意。原题的规则是每个字母自己在字母表里向后移动,非字母不参与移动也不参与计数。这种歧义大部分来自网上中文题面翻译不精确,后来在北大OJ的原题语境里默认就是逐个字母做凯撒位移。
第三个坑是参数a、b、c可能很大。字母表一共26个字母,所以移动26位等于没动,移动27位等于移动1位。如果直接拿超大整数去算(char + bigNum) % 26,很容易越界。正确做法是先把步数对26取模,再参与计算。
1.3 多组输入和结束条件
还有一个很容易在考试时突然卡住的点:输入是多组数据,且以“a==0 && b==0 && c==0”作为结束标志。每组数据是“一行字符串 + 一行三个整数”的结构。正常用cin >> str读取字符串没有问题,但如果题目里字符串可能包含空格,就得改用getline。北大机试通常不会在字符串里塞空格,不过我还是建议你养成用getline+ignore的习惯,后面会专门讲这个坑。
2. 整体设计思路与方案选型
2.1 直接遍历法:最符合直觉的做法
这题最直接的思路就是遍历原字符串,对每个字符判断它属于第几组,然后调用一个移位函数处理它。
以0下标为例:
- i % 3 == 0,属于第一组,使用参数a;
- i % 3 == 1,属于第二组,使用参数b;
- i % 3 == 2,属于第三组,使用参数c。
对每个字符,先判断它是不是字母,如果是就根据它属于第几组做对应的凯撒位移;不是字母就直接保留。
这种做法的优势是逻辑简单,不需要额外的存储空间,时间复杂度O(n),空间复杂度O(1)。原题字符串长度不超过100,性能完全不是问题。
2.2 分组取出再放回法:另一种常见思路
网上还有一种解法,先把第一组、第二组、第三组的字符分别取出来放到三个临时数组里,然后对每个数组中的字母进行移位,最后再按原位置放回去。
这个思路在逻辑上更贴近题面描述,但实现起来多了一步“放回”操作,稍不留神就会把位置搞混。比如你用一个数组保存了所有属于第一组的字符位置,移位之后又得遍历这个位置数组,把修改后的字符填回原字符串,容易多写不少代码。
我个人更推荐第一种直接遍历法。既然可以一次扫描解决问题,就不必引入额外结构徒增风险。机试场上时间紧,代码越短越不容易出bug。
2.3 复杂度分析和边界条件
时间复杂度自然是O(n),这里n是字符串长度。对每个字符只做常数次判断和一次赋值,哪怕字符串长度拉满到1000甚至10000,也毫无压力。
边界条件值得提前想清楚:
- 字符串为空时,循环不执行,直接输出空行;
- a、b、c为0时,输出原字符串原样;
- 字符串中全是数字或标点,没有任何字母,所有字符保持原样输出;
- 字符串长度不足3时,比如长度为1,那么只有下标0属于第一组,其他组没有字符,程序依然正常工作。
这些边界情况虽然简单,但往往就是测试数据里最阴人的部分。
2.4 移位函数的核心逻辑
移位函数是整个程序的核心,代码很短但要注意细节:
char shift(char ch, int k) { k %= 26; if (ch >= 'a' && ch <= 'z') { return 'a' + (ch - 'a' + k) % 26; } if (ch >= 'A' && ch <= 'Z') { return 'A' + (ch - 'A' + k) % 26; } return ch; }这里先用k %= 26把步数压缩到0到25,避免后面计算数值溢出。然后判断大小写字母,用字符减去基准字符得到相对偏移,加上步数后对26取模,再加回基准字符。非字母直接返回原字符。
好多新手会问:为什么不能直接ch + k再加判断?因为'a'的ASCII码是97,'z'是122,如果ch是'z',k是1,直接相加得到123,这个值对应的是'{',不是'a'。所以必须用相对偏移的方式处理。
3. 完整代码实现(C++版)与逐段讲解
3.1 基础版代码:直接读取字符串
下面这段代码适合题目明确说明字符串不含空格的情况。它是机试里最稳妥的写法,简单直接:
#include <iostream> #include <string> using namespace std; char shift(char ch, int k) { k %= 26; if (ch >= 'a' && ch <= 'z') { return 'a' + (ch - 'a' + k) % 26; } if (ch >= 'A' && ch <= 'Z') { return 'A' + (ch - 'A' + k) % 26; } return ch; } int main() { string str; int a, b, c; while (cin >> str >> a >> b >> c) { if (a == 0 && b == 0 && c == 0) { break; } for (int i = 0; i < (int)str.size(); i++) { if (i % 3 == 0) { str[i] = shift(str[i], a); } else if (i % 3 == 1) { str[i] = shift(str[i], b); } else { str[i] = shift(str[i], c); } } cout << str << endl; } return 0; }这段代码有个细节值得注意:while (cin >> str >> a >> b >> c)一次性读取字符串和三个整数。如果遇到文件结束或者读取失败,cin会返回false,循环自动结束。这样即使没有0 0 0结束标志,也能安全退出。
3.2 支持空格的进阶版代码
如果你在OJ上遇到字符串可能包含空格的题目,就得换一种读取方式。典型写法是:
#include <iostream> #include <string> using namespace std; char shift(char ch, int k) { k %= 26; if (ch >= 'a' && ch <= 'z') { return 'a' + (ch - 'a' + k) % 26; } if (ch >= 'A' && ch <= 'Z') { return 'A' + (ch - 'A' + k) % 26; } return ch; } int main() { string str; int a, b, c; while (getline(cin, str)) { cin >> a >> b >> c; cin.ignore(); if (a == 0 && b == 0 && c == 0) { break; } for (int i = 0; i < (int)str.size(); i++) { if (i % 3 == 0) { str[i] = shift(str[i], a); } else if (i % 3 == 1) { str[i] = shift(str[i], b); } else { str[i] = shift(str[i], c); } } cout << str << endl; } return 0; }这里的关键是cin.ignore()。cin >> a >> b >> c读完三个整数后,行尾的换行符还留在输入缓冲区里,如果不用ignore把它丢掉,下一轮getline就会读到一个空行,导致程序逻辑错乱。这是新手最容易踩的坑。
3.3 为什么建议用int(str.size())做类型转换
str.size()返回的是size_t类型,本质是unsigned long long之类的无符号整数。如果字符串长度为0,i < (int)str.size()中的i是int,两者比较没问题。但如果直接写str.size() - 1这种表达式,在字符串为空时会出现无符号整数下溢,得到一个巨大的数,循环就乱了。
我在代码里习惯写(int)str.size(),就是提前把这个隐患掐掉。虽然本体的for (int i = 0; i < str.size(); i++)在很多编译器下也能正常跑,但既然有更稳的写法,为什么不直接用呢。
3.4 验证样例
拿最简单的样例验证一下:
输入:
abcabcabc 1 1 1字符串"abcabcabc",三个参数都是1。按下标0到8遍历:
- 下标0是'a',属于第一组,移位1位变成'b';
- 下标1是'b',属于第二组,移位1位变成'c';
- 下标2是'c',属于第三组,移位1位变成'd';
- 下标3是'a',属于第一组,变成'b';
- 以此类推。
输出就是:
bcdbcdbcd再测试一个包含数字的用例:
输入:
a1b2c3 1 1 1- 下标0是'a',第一组,变成'b';
- 下标1是'1',第二组,非字母,保持'1';
- 下标2是'b',第三组,变成'c';
- 下标3是'2',第一组,非字母,保持'2';
- 下标4是'c',第二组,变成'd';
- 下标5是'3',第三组,非字母,保持'3'。
输出:
b1c2d3这两个自测用例能覆盖字母移位和非字母保持两种核心行为,建议你在本地把它跑通再说。
4. 常见错误与调试实录
4.1 分组下标错位:一错错一排
这是我看到过最多的错误。因为字符串在程序里从下标0开始,新手很容易把第一组写成i%3==1,结果整个字符串的处理全部错位。比如字符串"abcdef",正确分组应该是:
| 原字符 | 下标 | 正确分组 | 错误分组(如果i%3==1当第一组) |
|---|---|---|---|
| a | 0 | 第一组 | 第三组 |
| b | 1 | 第二组 | 第一组 |
| c | 2 | 第三组 | 第二组 |
| d | 3 | 第一组 | 第三组 |
一眼就能看出,一旦分组起点错了,每个字符用的移位参数全都不对,输出面目全非。调试的时候如果发现输出结果规律性地“整体错位”,优先检查分组判断条件。
4.2 移位参数没有先取模
a、b、c的值可能非常大,比如10000。如果不先对26取模,在做(ch - 'a' + k)时,k的数值会很大,虽然char加int会自动提升为int,但最后对26取模的结果不会错,问题是有些编译器或者后续处理可能出现意外。更重要的是,如果某个版本的实现里你直接对char类型做运算再赋回char,可能因为溢出得到负数,最后变成乱码。
我在实际写代码时习惯在shift函数第一行就写k %= 26,这属于防守型编程,成本几乎为零,却能避免很多隐蔽问题。
4.3 多组输入的读取死循环
用while (getline(cin, str))配合cin >> a >> b >> c时,如果忘记cin.ignore(),第一次读取正常,第二次getline会直接读到上一次残留的换行符,返回一个空字符串,然后整组数据全部错乱。更严重的可能造成死循环,程序一直空转。
有个简单的验证方法:在本地调试时输入两组数据,观察第二次读到的str是不是空串。如果是,就说明缓冲区的换行符没清干净。
4.4 非字母字符处理遗漏
有个不太容易想到的错误是:有的同学写了移位函数,但只处理了小写字母,忘记大写字母。或者反过来。题目明确说了字符可能包含数字,且大小写字母都可能出现,所以移位函数必须同时覆盖'a'-'z'和'A'-'Z'两个区间。
我自己在机试中遇到过一个大写字母测试点,当时因为只处理了小写字母,WA到怀疑人生。后来养成了习惯:凡是跟字符相关的题目,读题后第一件事就是确认大小写是否都要处理。
4.5 常见问题速查表
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 输出整体错位 | 分组判断的模数起点写错 | 打印i和i%3,对比题目分组 |
| 字母移位结果乱码 | 移位参数未取模或字符运算溢出 | 检查shift函数第一行是否有k%=26 |
| 第二组测试数据读取错误 | 缺少cin.ignore() | 在cin >> a >> b >> c后加cin.ignore() |
| 大写字母没变 | 移位函数漏掉大写分支 | 检查是否同时处理'A'-'Z' |
| 数字或标点被改动 | 非字母分支处理错误 | 确认非字母原样返回 |
5. 举一反三:这类题的变形与刷题建议
5.1 和本题相似的机试题目
这道题是字符串处理类题目里的经典代表。和它相似的题还有几类:
- 纯凯撒密码题:给定一个字符串和一个偏移量,把所有字母统一移位;
- 分组加密类:把字符串按一定规则分组,每组用不同参数处理;
- 字符串原地修改类:要求在不增加额外数组的情况下完成字符替换。
这些题核心考点都一样:字符ASCII操作、取模运算、下标映射、输入输出处理。把“W的密码”吃透,这些题基本就是改改参数的事情。
5.2 我在刷题时的一些习惯
第一,拿到题目先在草稿纸上手动模拟一个样例,不要急着写代码。把分组、移位、非字母保留这些步骤在纸上过一遍,很多逻辑错误在写代码之前就能暴露。
第二,写完代码后用几组边界数据自测,包括空字符串、全数字字符串、只有一个字符的字符串、参数为0的情况。这些数据用不了几分钟,但能帮你挡掉一半以上的WA。
第三,如果提交后WA,不要盲目改代码。先构造一个自己能算出答案的小样例,一步步跟踪程序输出,定位第一个出错的字符,再反向找原因。字符串处理题调试起来其实很快,关键是别慌。
5.3 推荐的学习路线
如果你正在准备考研机试,我的建议是把这类基础题放在刷题计划的第一周攻克。字符串处理、简单模拟、排序、查找这些基本功过了之后,后面做图论、动态规划才会顺手。这道题完全可以作为热身题,每天默写一遍,直到不用看代码就能一次通过。
我自己刷题多了之后,慢慢发现一个规律:机试场上真正卡住人的,往往不是算法本身,而是这种“明明看懂了题,代码写出来却不对”的挫败感。而消除这种感觉的唯一办法,就是把基础题练到形成肌肉记忆。“W的密码”就是值得形成肌肉记忆的题目之一。