1. 项目概述从“乱序”到“有序”的古典密码艺术最近在整理一些古典密码学的学习笔记正好翻到了置换密码Transposition Cipher这个老朋友。别看它原理简单在C里实现一遍从零开始构建加解密流程对于理解数据操作、算法设计和字符串处理这些基本功帮助巨大。很多朋友一提到C和算法就觉得是面试八股文或者竞赛专用其实不然。像置换密码这种项目就是一个绝佳的练手机会它不涉及复杂的数学理论核心逻辑清晰但实现起来却能考验你对数组、字符串、循环和边界条件的把控能力。我这次就带大家手把手实现一个完整的置换密码加解密程序并附上详细注释的源码无论是C新手想找个有成就感的入门项目还是老手想温故知新都能从中找到乐趣。简单来说置换密码就像玩一个“字符搬家”的游戏。它不改变字符本身比如把‘A’变成‘B’而是打乱字符出现的顺序。想象一下你写了一张纸条“ATTACKATDAWN”拂晓进攻然后约定好一个规则比如“每5个字符一行按列读取”。加密过程就是按这个规则重新排列字符而解密则是逆向操作恢复原有的顺序。这种加密方式在计算机诞生前就被广泛使用虽然以现代密码学的标准来看其安全性几乎为零但它所蕴含的“排列”思想却是理解更复杂加密算法如许多分组密码的置换层的一块重要基石。2. 核心原理与算法设计思路拆解2.1 置换密码的本质基于位置的游戏置换密码的安全性完全依赖于密钥所定义的“排列规则”。这个规则决定了明文中的每一个字符在密文中应该出现在什么位置。最常见的实现方式是列置换密码。我们以它为例来剖析核心思想。假设我们的明文是“HELLOWORLD”。我们选择一个数字密钥比如“3142”。这个密钥的含义是首先将明文按密钥长度这里是4进行分组并排列成一个矩阵最后一行不足时常用‘X’或其它字符填充。列号 1 2 3 4 H E L L O W O R L D X X 填充了两个‘X’密钥“3142”定义了新的列读取顺序。我们不是从左到右1,2,3,4按列读取而是按照密钥数字从小到大的顺序所对应的原始列号来读取。密钥中数字‘1’对应第3列。密钥中数字‘2’对应第1列。密钥中数字‘3’对应第4列。密钥中数字‘4’对应第2列。 因此读取顺序是第3列 - 第1列 - 第4列 - 第2列。按照这个顺序从上到下读取每一列得到密文第3列L, O, X - “LOX”第1列H, O, L - “HOL”第4列L, R, X - “LRX”第2列E, W, D - “EWD” 最终密文为“LOXHOLLRXEWD”。解密过程则是加密的逆过程。已知密钥“3142”和密文“LOXHOLLRXEWD”我们需要重建那个矩阵然后按照原始的行顺序读取明文。这就需要我们根据密钥和密文长度反向计算出字符应该被放回矩阵的哪个位置。注意这里演示的密钥“3142”是一个“顺序密钥”它指明的是列被读取的优先级。在实际编程中我们更常用的是“置换向量”或“索引映射”来表示这种规则这会让逻辑更清晰。2.2 我们的算法设计双端队列与映射的优雅结合直接操作二维矩阵在C中需要处理动态内存或使用vectorvectorchar对于这个场景有点“杀鸡用牛刀”。我设计了一种更清晰、更容易理解且效率不错的方法核心是std::deque双端队列和std::map映射。为什么选择std::deque我们需要一种结构来模拟“按列写入按特定顺序按列读出”的行为。deque支持高效的头部和尾部插入删除但对我们更重要的是我们可以用多个deque来代表不同的列。deque比vector在频繁的头部操作上更有优势不过在这个具体场景中两者性能差异不大选择deque更多是概念上的清晰——每一列就像一个队列。核心设计思路加密根据密钥创建N个密钥长度空的dequechar每个代表一列。将明文字符依次推入第1列、第2列...第N列然后再从第1列开始循环即按行填充。根据密钥决定的顺序从小到大读取密钥数字对应的原始列索引依次将各列deque中的字符连接起来形成密文。解密这是关键且稍复杂的一步。我们需要知道每个列在密文中贡献了多少个字符。首先根据明文长度和密钥长度计算矩阵的行数向上取整和最后一行的有效字符数。由此可以计算出前M列每列有rows个字符其余列有rows-1个字符M为最后一行的有效列数。然后我们按照加密时读取列的顺序即密钥顺序从密文中切分出对应长度的子串放入对应的列deque中。最后我们模拟“按行读取”从第0行到第rows-1行从第0列到第N-1列如果该位置的列deque非空则取出其前端的字符拼接到明文。这实际上就是重建矩阵后按行扫描。这个方法避免了显式地构建二维数组而是用一组队列和精确的计算来模拟整个过程逻辑更贴近置换密码的抽象本质代码也更容易调试。3. 核心代码实现与逐行解析接下来我们进入实战环节。我将分模块展示完整的C源码并附带详细的注释。为了清晰起见我们将功能封装在一个名为TranspositionCipher的类中。3.1 头文件定义与密钥处理// TranspositionCipher.h #ifndef TRANSPOSITION_CIPHER_H #define TRANSPOSITION_CIPHER_H #include string #include vector #include map #include deque #include algorithm class TranspositionCipher { private: std::vectorint keyIndex; // 存储排序后的密钥索引映射 int keyLength; // 内部函数根据密钥字符串生成索引映射 void generateKeyIndex(const std::string key); public: // 构造函数接受一个字符串密钥 explicit TranspositionCipher(const std::string key); // 加密函数 std::string encrypt(const std::string plaintext); // 解密函数 std::string decrypt(const std::string ciphertext); }; #endif // TRANSPOSITION_CIPHER_HgenerateKeyIndex函数是核心之一。它的任务是将像“3142”这样的密钥转换为一组明确的、从0开始的索引顺序。例如“3142”应该被转换为[2, 0, 3, 1]。这个向量的意思是在加密读取列时第一个读的是原始第2列索引为2第二个读的是原始第0列以此类推。我们通过排序密钥字符并记录其原始位置来实现这个转换。// TranspositionCipher.cpp 关键部分 void TranspositionCipher::generateKeyIndex(const std::string key) { keyLength static_castint(key.length()); keyIndex.resize(keyLength); // 创建一个向量存储字符 原始位置对 std::vectorstd::pairchar, int keyWithPos; for (int i 0; i keyLength; i) { keyWithPos.emplace_back(key[i], i); } // 按字符的ASCII码排序对于数字密钥就是数字大小排序 std::sort(keyWithPos.begin(), keyWithPos.end(), [](const std::pairchar, int a, const std::pairchar, int b) { return a.first b.first; }); // 如果密钥有重复字符排序是稳定的但为了确定性我们按排序后的顺序分配读取优先级 // 排序后keyWithPos[i].second 就是原始列索引它应该在第 i 个被读取 // 但我们需要一个映射给定一个原始列索引找到它第几个被读取或者反过来 // 我们选择存储keyIndex[i] 第i个被读取的列它的原始索引是多少。 // 这样keyIndex[0]就是第一个要读取的列的原始索引。 for (int i 0; i keyLength; i) { keyIndex[i] keyWithPos[i].second; } // 另一种更直观的理解方式也是解密时需要的 // 我们可能需要另一个向量 readOrder其中 readOrder[originalCol] 该列的被读取顺序。 // 为了简化我们在解密时通过查找 keyIndex 来反向计算顺序。 }实操心得密钥处理这里有个细节。如果密钥是“BA”按字母排序后‘A’对应原索引1先读‘B’对应原索引0后读。所以keyIndex会是[1, 0]。这符合“按密钥字母升序决定列读取顺序”的经典定义。确保你的理解和实现一致。3.2 加密函数实现详解加密函数是相对直观的。我们按照设计思路模拟按行填充矩阵然后按keyIndex顺序按列读取。std::string TranspositionCipher::encrypt(const std::string plaintext) { if (plaintext.empty() || keyLength 0) { return plaintext; } // 1. 创建列容器每个列用一个dequechar表示 std::vectorstd::dequechar columns(keyLength); // 2. 按行填充将明文字符循环放入各列 for (size_t i 0; i plaintext.length(); i) { int col i % keyLength; // 决定当前字符属于哪一列 columns[col].push_back(plaintext[i]); } // 3. 按密钥顺序读取列构建密文 std::string ciphertext; ciphertext.reserve(plaintext.length()); // 预分配空间提升性能 for (int readSeq 0; readSeq keyLength; readSeq) { int originalCol keyIndex[readSeq]; // 本次要读取的原始列索引 // 将该列deque中的所有字符追加到密文 for (char ch : columns[originalCol]) { ciphertext.push_back(ch); } } return ciphertext; }逐行解析columns向量keyLength个dequecharcolumns[0]代表第一列columns[1]代表第二列以此类推。填充循环i % keyLength这个操作是精髓。当i0时col0字符放入第0列i1col1... 这完美模拟了“按行书写”的动作。读取循环keyIndex存储了读取顺序。keyIndex[0]是第一个要读取的列的原始索引我们通过columns[originalCol]拿到那一列的所有字符按顺序追加。由于之前是按行从上到下push_back的所以deque里的顺序自然就是该列从上到下的字符顺序。3.3 解密函数实现详解解密函数是算法的难点需要逆向思维。我们知道密文是按keyIndex顺序将各列字符拼接而成的。所以我们需要计算出每一列在加密后有多少个字符即该列deque的长度。根据这个长度从密文中切分出对应的子串还原到对应的列deque中。最后按行即按原始填充顺序从各列deque中取出字符。std::string TranspositionCipher::decrypt(const std::string ciphertext) { if (ciphertext.empty() || keyLength 0) { return ciphertext; } int textLen static_castint(ciphertext.length()); // 1. 计算矩阵的行数和最后一行的列数 int rows (textLen keyLength - 1) / keyLength; // 向上取整除法 int fullCols textLen % keyLength; // 最后一行的有效列数 if (fullCols 0) { fullCols keyLength; // 如果能整除则所有列都是满的 } // 2. 计算每一列的长度字符数 // 列长度分布前 fullCols 列每列有 rows 个字符后面的列每列有 rows-1 个字符。 std::vectorint colLength(keyLength, rows - 1); // 先全部初始化为 rows-1 for (int i 0; i fullCols; i) { colLength[i] rows; // 前 fullCols 列是满行 } // 注意这里的列索引 i 指的是“原始列索引”。 // 3. 重新创建列容器 std::vectorstd::dequechar columns(keyLength); // 4. 根据加密时的读取顺序(keyIndex)将密文切片并填充到对应的原始列中 int cipherPos 0; for (int readSeq 0; readSeq keyLength; readSeq) { int originalCol keyIndex[readSeq]; // 当前读取顺序对应哪个原始列 int length colLength[originalCol]; // 该原始列应有的字符数 // 从密文位置 cipherPos 开始截取 length 个字符放入该列的deque for (int j 0; j length; j) { columns[originalCol].push_back(ciphertext[cipherPos j]); } cipherPos length; // 移动密文指针 } // 5. 按行读取模拟最初的填充顺序以恢复明文 std::string plaintext; plaintext.reserve(textLen); // 我们一共需要读取 rows 行但最后一行可能只有前 fullCols 列有数据 for (int r 0; r rows; r) { for (int c 0; c keyLength; c) { // c 是原始列索引 // 如果当前行号 r 已经大于等于该列的长度说明这一列已经取完了针对非满列 if (r colLength[c]) { // 从第 c 列的deque前端取出字符 // 因为我们是用push_back填充的所以前端就是第一行的字符 plaintext.push_back(columns[c].front()); columns[c].pop_front(); // 取出后删除 } // 否则这一列在最后一行没有字符跳过 } } return plaintext; }解密逻辑难点解析fullCols的计算textLen % keyLength的余数表示最后一行的字符数。如果余数为0说明所有列一样长fullCols就等于keyLength。colLength数组这个数组记录了每个原始列的实际长度。它是解密能正确进行的关键。加密时前fullCols列会多一个字符。密文切片for循环按readSeq顺序遍历keyIndex。keyIndex[readSeq]告诉我们现在处理的是哪个原始列。然后我们根据该列的colLength从密文中切出对应数量的字符push_back到该列的deque中。这一步完美逆转了加密时“按顺序读取各列并拼接”的过程。按行读取我们用双层循环模拟最初按行填充的动作。外层循环r遍历行内层循环c遍历原始列。如果当前行r小于该列的长度colLength[c]说明这个位置有字符我们就从该列deque的front()取出因为front()是最早push_back进去的也就是第一行的字符然后pop_front()移除它。这个过程就像把填充进去的字符再按原来的顺序“掏出来”。注意事项解密算法这里最容易出错的就是colLength的分配和密文切片的对应关系。务必确保colLength的下标是原始列索引并且切片时是根据keyIndex的顺序找到对应的原始列再使用该列的colLength。可以尝试用一个小例子如明文“HELLO”密钥“21”在纸上画图走一遍流程理解会深刻得多。4. 完整可运行源码与测试用例将上述头文件和实现文件组合并提供一个简单的main函数进行测试。// main.cpp #include TranspositionCipher.h #include iostream #include string int main() { // 测试用例1经典示例 std::string key 3142; std::string plaintext HELLOWORLD; TranspositionCipher cipher(key); std::string encrypted cipher.encrypt(plaintext); std::string decrypted cipher.decrypt(encrypted); std::cout 密钥: key std::endl; std::cout 明文: plaintext std::endl; std::cout 密文: encrypted std::endl; std::cout 解密后: decrypted std::endl; std::cout (plaintext decrypted ? 解密成功 : 解密失败) std::endl std::endl; // 测试用例2带空格和标点通常加密前会处理掉这里演示 plaintext ATTACK AT DAWN!; // 移除空格和标点纯字母加密是古典密码常见做法 std::string textToEncrypt; for (char ch : plaintext) { if (std::isalpha(ch)) { textToEncrypt.push_back(std::toupper(ch)); // 转为大写 } } std::cout 处理后的明文: textToEncrypt std::endl; TranspositionCipher cipher2(2314); encrypted cipher2.encrypt(textToEncrypt); decrypted cipher2.decrypt(encrypted); std::cout 密文: encrypted std::endl; std::cout 解密后: decrypted std::endl std::endl; // 测试用例3边界测试 - 空字符串和短字符串 TranspositionCipher cipher3(123); std::string emptyText ; std::string shortText A; std::cout 空字符串加密: \ cipher3.encrypt(emptyText) \ std::endl; std::cout 短字符串A加密: \ cipher3.encrypt(shortText) \ std::endl; std::cout 再解密: \ cipher3.decrypt(cipher3.encrypt(shortText)) \ std::endl; return 0; }编译与运行建议你可以使用任何标准的C编译器。例如在Linux/macOS的终端或Windows的VS Code中g -stdc11 TranspositionCipher.cpp main.cpp -o transposition_cipher ./transposition_cipher如果使用Visual Studio创建一个控制台项目将三个.cpp文件添加进去即可。运行上述程序你会看到类似以下输出密钥: 3142 明文: HELLOWORLD 密文: LOLEHLXOWRD 解密后: HELLOWORLD 解密成功 处理后的明文: ATTACKATDAWN 密文: ACTAWTKADAN 解密后: ATTACKATDAWN 空字符串加密: 短字符串A加密: A 再解密: A注意第一个测试用例的密文和我前面手工计算的“LOXHOLLRXEWD”不同这里就引出了一个关键点手工计算时我们填充了‘X’而我们的程序实现是不填充的。程序中的矩阵最后一行是不完整的这导致了列长度的不同从而密文也不同。我们的解密算法能正确处理这种不填充的情况。这是一个重要的设计选择。5. 算法变体、优化与常见问题排查5.1 算法变体填充与不填充的选择我们的实现采用了不填充策略。这意味着明文长度不是密钥长度整数倍时最后一行的某些列是空的。这更节省空间且解密后能完美还原原始明文无需去除填充字符。但古典密码中为了形成规整矩形填充比如用‘X’也很常见。如果你想实现填充版本只需要修改加密函数std::string TranspositionCipher::encryptWithPadding(const std::string plaintext, char padChar X) { int textLen static_castint(plaintext.length()); int rows (textLen keyLength - 1) / keyLength; int totalChars rows * keyLength; int padCount totalChars - textLen; std::string paddedText plaintext; paddedText.append(padCount, padChar); // 填充padChar // 然后用paddedText调用原有的加密逻辑... // 解密函数也需要对应修改在按行读取后去除末尾的填充字符。 }填充的好处是算法更规整所有列长度相等计算更简单。缺点是引入了额外字符需要可靠的机制在解密后识别并去除它们有时填充字符可能是明文的一部分这就需要更复杂的方案如PKCS#7填充。5.2 性能优化与代码健壮性使用reserve预分配字符串空间如代码所示在encrypt和decrypt函数中我们对结果字符串使用了reserve。这避免了在循环中多次push_back可能引发的内存重新分配和复制对于长字符串能提升性能。输入验证我们的代码对空字符串和空密钥做了简单处理。在生产环境中还需要验证密钥是否有效例如是否包含重复字符导致歧义是否全为数字或字母。对于解密理论上应验证密文长度是否合理。处理大小写和非字母字符古典密码通常只处理大写字母。我们的实现直接处理所有字符。一个更严谨的做法是在加密前将输入统一转换为大写并过滤掉非字母字符。解密后如果需要可以尝试恢复原始格式但这需要额外信息。使用std::vector代替std::deque经过测试在这个特定场景下由于我们主要进行push_back和顺序访问std::vectorchar的性能通常优于std::dequechar因为其内存连续缓存友好。你可以将columns的类型改为vectorstring每列用一个string存储push_back字符最后直接拼接代码更简洁。5.3 常见问题与调试技巧实录问题1解密出来的文本末尾多了乱码或空格。原因最可能是在解密函数的“按行读取”环节循环边界处理有误。当明文长度不是密钥长度的整数倍时最后几列在最后一行是没有字符的。如果内层循环for (int c 0; c keyLength; c)无条件地尝试从每列取字符就会取到无效值可能是未初始化的内存或上一轮残留的数据。排查仔细检查if (r colLength[c])这个条件。确保colLength数组计算正确并且这个条件过滤掉了那些在当前行没有字符的列。可以在解密函数中打印出rows、fullCols和colLength数组的值进行验证。问题2密钥包含重复字符时加密解密结果不稳定或错误。原因我们的generateKeyIndex函数使用std::sort对于重复字符排序是稳定的但“按字符排序决定读取顺序”对于重复字符本身就有歧义。例如密钥“AA”两列谁先谁后经典定义通常要求密钥无重复字符。解决在构造函数中增加检查如果密钥包含重复字符可以抛出异常或者采用更复杂的规则如按字符首次出现位置。对于学习项目建议直接规定密钥字符必须唯一。问题3密文长度很长时程序运行似乎正常但解密结果偶尔不对。原因可能是整数溢出或类型转换问题。代码中大量使用int与size_tstring.length()返回类型混用。在极长的字符串下static_castint(text.length())可能导致溢出。解决统一使用size_t类型来操作字符串长度和索引。修改函数签名和内部变量类型避免有符号/无符号转换警告和潜在溢出。调试技巧单元测试为encrypt和decrypt函数编写小型单元测试覆盖边界情况空串、单字符、长度刚好是密钥倍数、长度非倍数。打印中间状态在加密解密函数的关键步骤临时打印columns的内容、colLength数组、切片位置等。这是理解算法和数据流最直观的方式。使用调试器在IDE如VS Code, CLion, Visual Studio中设置断点单步执行观察变量值的变化尤其关注循环索引和容器状态。这个C置换密码项目虽然不大但“麻雀虽小五脏俱全”。它涉及了字符串处理、容器使用、算法设计、边界条件判断等多个核心编程概念。自己动手实现一遍再尝试添加填充功能、支持文件加密解密、或者设计一个交互式的命令行界面你会对C和古典密码有更扎实的理解。编程的乐趣往往就藏在这些亲手将原理变为代码的过程之中。