第一次在百炼OJ上刷到2818这道题时看到标题“密码”两个字我第一反应是它跟加密算法有关系可能是凯撒密码或者简单的移位替换。点进去读题才发现这其实是道非常经典的置换变换题给定一条置换规则让你把一段字符串反复变换k次输出最终结果。这类题在OJ上难度不算高但坑特别多——字符串可能带空格、变换次数k会很大、置换方向容易搞反任何一个小问题都能让你Debug到怀疑人生。这篇文章就把这道题的完整思路、代码细节和排错经验都梳理一遍给正在练“循环分解”和“置换”这类基础算法的同学做个参考。1. 题目到底在考什么一次置换的重复应用1.1 从一个字符怎么跑到另一个位置讲起先回到题目本身。题目会给你一个整数n然后给出一组1到n的排列比如p[1]、p[2]……p[n]这组排列就是置换规则。接着给你一段待处理的字符串s再给你一个变换次数k。你要做的就是按照这条规则把字符串连续变换k次最后把变换结果输出来。这里的“置换”可以理解成一种位置重排原本在第i个位置的字符经过一次变换后会跑到第p[i]个位置上去。比如n等于3p等于{2, 3, 1}那字符串abc经过一次变换后a从位置1跑到位置2b从位置2跑到位置3c从位置3跑到位置1结果就是cab。要注意的是有些题目在描述置换方向时习惯反着写也就是“第i个位置上的字符来自原串的第p[i]个位置”。这两种定义在数学上互为逆置换实现出来的代码方向完全不同。我个人踩过这个坑所以后面会专门讲怎么用一组小数据快速验证你的方向写没写反。理解了“一次变换”之后题目难度就落在“连续变换k次”上。如果只做1次变换直接开一个临时数组循环赋值就行三分钟搞定。但k一旦给到10^9硬模拟就直接超时了。这也是这题真正想拉开差距的地方你能不能识别出“重复变换”背后的数学结构然后找到一个跟k无关、只跟n有关的解法。1.2 模拟k次的代价与隐藏陷阱很多同学第一反应就是写个双重循环外层跑k次内层对每个位置做交换。这种做法在n比较小、k比较小的时候没问题但题目里k是可以给得很大的。假设n等于100k等于10^9外层循环10^9次每次还要处理100个位置哪怕编译器优化再猛也是妥妥的超时。更麻烦的是连续做k次变换时如果每次都完全重新复制一遍字符串复杂度会叠加得很夸张。你可能会想能不能每次只交换两个位置但置换不是简单的两两交换它可能把3个、5个甚至更多位置串成一个环单纯两两换是描述不了这种结构的。所以这题真正的考点并不是“怎么模拟”而是“怎么把k次变换压缩成一次计算”。这个压缩思路就是我在下一节要重点拆解的循环分解法。刷题刷到一定量之后你会发现凡是题目里出现“重复操作”“周期”“轮换”这类字眼大概率都要往循环分解上想这算是置换类题型的通用套路了。1.3 哪些人适合先刷这道题如果你正在准备算法竞赛或者刚进入“数据结构与算法”的基础训练阶段这道题非常适合作为置换环节的入门题。它不像KMP、归并排序那样需要背一堆模板也不像动态规划那样需要很强的状态设计直觉它考察的核心就是一个“找规律”的能力你能不能把看似复杂的k次变换化简成若干个循环内的简单旋转。另外这道题对代码基本功也有很好的检验作用。字符串的读入、空格的保留、数组索引从0开始还是从1开始、循环边界的处理任何一个地方出问题都会导致WA。我见过不少同学算法思路完全正确结果卡在读入上折腾一晚上。所以这道题刷下来你收获的不只是置换算法还有对OJ输入输出细节的敏感度。如果你是那种已经在刷排序算法、贪心算法想换换思维方式的选手也可以把这道题当个调剂。它的代码量不大但能让你练到一种很多算法题都用得上的思想分解成独立子结构再分别处理。2. 核心算法思路循环分解与幂运算化简2.1 把置换拆成互不相交的循环要理解这道题的最优解法得先认识置换的一个重要性质任意一个置换都可以唯一分解成若干个互不相交的循环。所谓“互不相交”就是每个位置只会出现在一个循环里不会同时属于两个循环。怎么理解循环呢你可以把置换想象成一张“位置跳转图”当前位置是i按规则跳转到p[i]再从p[i]跳转到p[p[i]]……一直跳下去最终一定会跳回起点因为位置总数是有限的而且p是排列不存在一个位置被两个位置同时指向的情况。这样一个闭合的跳转路径就构成了一个循环。比如p等于{2, 3, 1}时1跳到22跳到33跳回1所以{1, 2, 3}就组成了一个长度为3的循环。再比如p等于{2, 1, 4, 3}那么{1, 2}是一个长度为2的循环{3, 4}是另一个长度为2的循环。找到这些循环的办法很简单开一个访问标记数组从1到n逐个扫描遇到没访问过的位置就沿着p一路走下去把走过的位置都记下来顺便标记已访问。走到底之后这一组位置就是一个循环。把所有位置扫完置换就完全分解开了。这个分解过程本身只需要O(n)的时间因为每个位置最多被访问一次。代码量也不大核心就是一个while循环加一个vis数组。难点不在于怎么找循环而在于想明白“为什么要找循环”。2.2 k次变换的本质是循环内旋转位置找到循环之后解题的关键洞察就来了一次变换就是在每个循环内部把所有字符沿着循环方向移动一个位置。做了k次变换就相当于在这个循环内部连续移动k个位置。更精确地说在一个长度为len的循环里某个字符在循环中的序号为j变换k次之后它的序号会变成(j k)模len。因为是绕圈走的所以序号对len取模就能回到循环内部。这里k可以非常大但取模之后真正有效的移动步数只有k % len步。这个道理跟一群人围成一圈跳舞很像。不管这支舞跳了多少拍每个人最终的位置只跟“总拍数除以人数后的余数”有关。比如5个人围一圈跳了13拍每个人相当于只往前挪了3个位置剩下10拍等于在原地绕了两整圈没有实际影响。所以对于每一个循环我只需要把循环里的字符统一移位k % len步就可以一次性得到这个循环的最终形态。所有循环都处理完整个字符串的最终结果也就出来了。整个算法的时间复杂度是O(n)和k的大小完全无关这才是本题真正要求的“高效做法”。2.3 为什么这个方案比硬模拟稳得多硬模拟的复杂度是O(nk)而循环分解法是O(n)当k达到10^9时两者差距是数量级的。更重要的是循环分解法不仅解决复杂度问题还让代码逻辑更清晰你只需要关心每个循环内部怎么转不需要关心循环和循环之间的耦合关系因为它们本来就不会互相影响。这里还要补充一个容易忽略的细节多个循环之间互不影响意味着我可以单独处理每个循环用独立的结果数组或者临时字符数组来存放。这样就不需要频繁修改原字符串减少了出错风险。我最初刷这道题时一度想用“快速幂”的思路把k次置换看成置换的k次方然后尝试用倍增去算。后来发现没必要因为置换这种“复合操作”和普通的数乘不一样它天然是离散的循环结构直接拆循环再取模比快速幂还要直观。真正的快速幂在置换题里也有用但一般是用来处理更复杂的变形对2818这道题来说属于杀鸡用牛刀。3. 完整实现与逐段代码讲解3.1 C整体框架与核心数据结构下面给出我实际提交通过的C代码框架里面包含完整的循环分解和字符移位逻辑。#include bits/stdc.h using namespace std; int main() { int n; while (cin n n) { vectorint p(n 1); for (int i 1; i n; i) { cin p[i]; } getchar(); // 吃掉整数行末尾的换行符 string s; getline(cin, s); int k; cin k; getchar(); // 吃掉k行末尾的换行符 // 若字符串长度不足n按要求补足空格 while ((int)s.size() n) { s ; } vectorchar ans(n 1, ); vectorbool vis(n 1, false); for (int i 1; i n; i) { if (!vis[i]) { vectorint cyc; int cur i; while (!vis[cur]) { vis[cur] true; cyc.push_back(cur); cur p[cur]; } int len (int)cyc.size(); int shift k % len; for (int j 0; j len; j) { int from cyc[(j - shift len) % len]; ans[cyc[j]] s[from - 1]; } } } for (int i 1; i n; i) { cout ans[i]; } cout endl; } return 0; }这个代码里p数组我用的是1-based下标因为题目给的置换规则本身就是1到n的位置编号。选1-based可以避免“位置编号”和“数组下标”差1带来的混乱。字符串s本身是0-based的所以我在取字符时用了s[from - 1]这个细节很多教程不会提醒你但写错一处就是WA。3.2 循环分解部分的实现细节循环分解的核心代码就几行while (!vis[cur]) { vis[cur] true; cyc.push_back(cur); cur p[cur]; }从当前起点i出发只要没访问过就把当前节点加入循环列表然后跳到下一个节点p[cur]。因为置换的性质决定了这条路必然绕回起点所以一定会退出。要注意的是起点选取外层for循环从1到n逐个检查vis保证每个循环只会被处理一次也不会漏掉任何一个位置。这个写法有几个好处。第一它天然支持多个循环的拆分不需要额外处理循环之间的边界。第二vis数组保证了时间复杂度是O(n)每个节点只会入队一次。第三如果某个循环长度为1也就是p[i]等于i那这个位置本身就是个自循环处理起来非常自然len等于1shift等于k对1取模等于0字符原地不动符合直觉。我见过有同学试图用“一个节点有没有被加入过”来判断是否完成循环而不是用vis数组结果在复杂样例上出现死循环。建议还是老老实实开一个bool数组这样最稳。3.3 字符移位与答案生成的注意事项移位部分是这个题最容易出错的环节。代码里我写的是int from cyc[(j - shift len) % len]; ans[cyc[j]] s[from - 1];这里的意思是说最终在cyc[j]这个位置上的字符应该是原来在cyc[(j - shift) mod len]这个位置上的字符。换句话说整个循环要往“前”挪shift步所以当前槽位要从前面第shift个位置“拉”字符过来。加len再取模是为了保证下标不出现负数。这里的方向定义必须和前面第1题里的置换方向一致。如果你发现输出结果跟样例差了一位或者整体乱了多半就是这里的方向写反了。我建议写完代码后先用一组特别小的数据手动验证比如n等于2p等于{2, 1}s等于“ab”k等于1看结果是不是“ba”。如果是方向就是对的如果输出是“ab”或者“aa”赶紧回头检查from的计算逻辑。另外补空格也要注意。原题要求字符串长度如果不足n需要在末尾补空格到n位。我用的方法是while循环检查size并追加空格。如果你想写得紧凑一点也可以一次性计算出需要补多少空格用append(n - s.size(), )来处理。4. 实战排错与常见问题速查4.1 字符串带空格时怎么读入这是2818这题最容易坑人的地方没有之一。题目里的待变换字符串是可能包含空格的所以不能用cin s来读否则只在第一个空格处截断后面的内容全丢。正确姿势是用getline(cin, s)一次读一整行。但用了getline之后新的问题来了前面的cin n和cin p[i]在读完后行尾的换行符还留在缓冲区里如果直接getline会先读到一个空串。所以必须在读完整数列之后调用一次getchar()把那个换行符吃掉。同样读完k之后也要再getchar一下否则下一组数据的getline又会读到空行。我一开始没注意这个细节样例全部通过但交上去WA了好多次。后来加了两个getchar()问题立刻解决。这种输入缓冲区的坑几乎每个OJ上的字符串题都会遇到属于必须掌握的通用经验。4.2 置换方向写反了怎么排查这个问题的典型表现是小样例输出完全对但自己构造的复杂数据总是差那么一点点或者样例直接就不对字符集整体错位。解决方法是先确定题目到底用的是“跟随后继”的方向还是“来自前驱”的方向。你可以用n等于2这种最小规模的数据去试。比如p等于{2, 1}s等于“ab”一次变换后如果期望是“ba”说明规则是“第i位字符跑到p[i]位”也就是我代码里的写法。如果期望是“ab”不变说明规则是“第i位字符来自p[i]位”那你就得在移位时换成反向逻辑。更稳妥的做法是在拿到题目后第一时间读清楚样例说明甚至可以把样例输入手算一遍用自己的代码跑一遍确保理解一致再写核心逻辑。说实话方向问题完全可以通过一个5分钟的验证规避别像我当初一样写完一版然后反复试错浪费时间。4.3 边界条件k等于0和n等于0的处理k等于0时按题意变换0次输出原字符串。我的代码里shift会对len取模但k为0时shift为0from等于cyc[j]本身所以ans会原样复制字符串结果正确不需要特判。n等于0时题目规定这是输入结束标记所以while循环的条件是cin n n这样读到0就退出不会进入死循环。这是POJ类题目的典型格式把它写在读入条件里比在循环内break要干净。还有一个小边界当字符串长度刚好等于n时补空格循环不会执行没有问题。当字符串长度大于n时我见过一些同学收到RE实际上是访问越界。一般来说题目会保证长度不超过n或者要求截断但我还是建议你在读入后顺手判断一下如果超长就resize到n这样更保险。4.4 时间超限和答案错误的常见原因如果交上去TLE十有八九是你在用最朴素的k次模拟。这个问题在数据量大时会非常明显。解决办法就是回到第2节的循环分解思路而不是在代码上做局部优化。有些同学可能会想用“k mod n”来简化但这里不能直接对n取模因为n不是循环长度多个循环的长度各不一样你必须对每个循环分别计算k % len。如果交上去WA优先检查三件事第一字符串读入是否完整特别是带空格的行第二数组下标有没有差1尤其是从1-based的位置编号转换到0-based的字符数组第三方向有没有写反。这三件事解决掉绝大多数WA都能变AC。另外有些版本的原题要求每组输出之间有空行或者每组输出单独一行这点要看题目的输出格式描述。纽提交错格式也会判PE或WA建议提交前先把样例输出的空格、换行都对照一遍。5. 从2818延伸开去的置换类算法思维5.1 同类变体题怎么识别刷完2818之后你会发现很多题目都是它的变体。比如有一类问题是“给你一个置换求它要作用多少次才能回到原始排列”这其实就是在求置换的阶也就是所有循环长度的最小公倍数。还有一类问题是“只问某个特定位置的字符经过k次变换后在哪个位置”那只需要单独追踪一个循环不需要处理全部字符串复杂度还能进一步降低。再比如二维网格的置换像是在矩阵里做若干次旋转、镜像、行列交换本质上也是置换的思想只不过把位置编号变成二维坐标你需要先给每个格子编号再把它映射成一维位置序列。理解了循环分解这类题目上手会快很多。这些变体的共同特征就是题目里会出现“操作重复多次”“周期变化”“问最终状态”等关键词。你一旦识别出这个特征先别急着模拟先想想能不能用循环分解把操作批量处理。5.2 快速幂、KMP和置换思想的横向对比有同学会把置换的多次复合和快速幂联系起来这个方向是对的。快速幂解决的是“重复做同一种可结合的运算”的加速问题置换复合恰好满足结合律所以理论上也可以用倍增法求置换的k次幂。但在2818这种题里循环分解比快速幂更直观因为它直接把问题的解暴露出来了每个小循环内旋转k % len步。而KMP、归并排序这类题和置换的思路又不太一样。KMP的核心在于部分匹配表归并排序的核心在于分治合并它们都更强调“比较”和“顺序”而置换题强调的是“位置映射”和“周期”。不过它们有个共同点都是对数据结构的某种抽象理解刷题时不能只背模板要理解背后的结构性质。从算法体系上看置换和循环分解是群论入门里最简单的部分但应用面很广。字符串洗牌、密码学中的多表替换、棋盘状态变换很多场景都会用到。我建议你把“循环分解”当作一个独立的知识点记在笔记里配合poj 2818这道题作为第一道练习题以后再遇到同类题就不慌了。5.3 给刷题者的几条实在建议最后说几条我自己刷这道题和同类题时总结出的经验。第一不管题目看起来多简单先手算样例确认置换方向再动手写代码这个习惯能帮你省下至少半小时的调试时间。第二输入输出格式一定以题目的样例为准特别是涉及空格的字符串读入方式要反复确认。第三善用小的打印调试在找循环之后把每个循环的元素打出来看一眼就知道自己的分解逻辑对不对了。我还想特别强调一点算法题不是背出来的是“试”出来的。2818这道题我刷完一遍之后又自己改了好几个版本比如尝试用快速幂实现、尝试把方向反转、尝试只处理单个位置的查询每一次改动都能对置换有更深的理解。这种“一题多改”的做法比盲目刷十道同类型的新题效果要更好。如果你刚刷完这道题建议你顺手再做一道类似的置换题目把一个循环拆开、处理、验证的流程练熟。刷题最怕的是似懂非懂只要你能在不看题解的情况下独立把循环分解的代码写出来并且解释清楚为什么k要按循环长度取模这道题就算真正吃透了。