C++实现置换密码:从古典密码原理到STL容器实战
2026/7/4 13:40:23 网站建设 项目流程

1. 项目概述:从“乱序”到“有序”的古典密码艺术

最近在整理一些古典密码学的学习笔记,正好翻到了置换密码(Transposition Cipher)这个老朋友。别看它原理简单,在C++里实现一遍,从零开始构建加解密流程,对于理解数据操作、算法设计和字符串处理这些基本功,帮助巨大。很多朋友一提到C++和算法,就觉得是面试八股文或者竞赛专用,其实不然。像置换密码这种项目,就是一个绝佳的练手机会:它不涉及复杂的数学理论,核心逻辑清晰,但实现起来却能考验你对数组、字符串、循环和边界条件的把控能力。我这次就带大家手把手实现一个完整的置换密码加解密程序,并附上详细注释的源码,无论是C++新手想找个有成就感的入门项目,还是老手想温故知新,都能从中找到乐趣。

简单来说,置换密码就像玩一个“字符搬家”的游戏。它不改变字符本身(比如把‘A’变成‘B’),而是打乱字符出现的顺序。想象一下你写了一张纸条“ATTACKATDAWN”(拂晓进攻),然后约定好一个规则,比如“每5个字符一行,按列读取”。加密过程就是按这个规则重新排列字符,而解密则是逆向操作,恢复原有的顺序。这种加密方式在计算机诞生前就被广泛使用,虽然以现代密码学的标准来看其安全性几乎为零,但它所蕴含的“排列”思想,却是理解更复杂加密算法(如许多分组密码的置换层)的一块重要基石。

2. 核心原理与算法设计思路拆解

2.1 置换密码的本质:基于位置的游戏

置换密码的安全性完全依赖于密钥所定义的“排列规则”。这个规则决定了明文中的每一个字符在密文中应该出现在什么位置。最常见的实现方式是列置换密码。我们以它为例来剖析核心思想。

假设我们的明文是:“HELLOWORLD”。我们选择一个数字密钥,比如“3142”。这个密钥的含义是:

  1. 首先,将明文按密钥长度(这里是4)进行分组,并排列成一个矩阵(最后一行不足时常用‘X’或其它字符填充)。

    列号: 1 2 3 4 H E L L O W O R L D X X (填充了两个‘X’)
  2. 密钥“3142”定义了新的列读取顺序。我们不是从左到右(1,2,3,4)按列读取,而是按照密钥数字从小到大的顺序所对应的原始列号来读取。

    • 密钥中数字‘1’对应第3列。
    • 密钥中数字‘2’对应第1列。
    • 密钥中数字‘3’对应第4列。
    • 密钥中数字‘4’对应第2列。 因此,读取顺序是:第3列 -> 第1列 -> 第4列 -> 第2列。
  3. 按照这个顺序,从上到下读取每一列,得到密文:

    • 第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++中需要处理动态内存或使用vector<vector<char>>,对于这个场景有点“杀鸡用牛刀”。我设计了一种更清晰、更容易理解且效率不错的方法,核心是std::deque(双端队列)和std::map(映射)。

为什么选择std::deque我们需要一种结构来模拟“按列写入,按特定顺序按列读出”的行为。deque支持高效的头部和尾部插入删除,但对我们更重要的是,我们可以用多个deque来代表不同的列。dequevector在频繁的头部操作上更有优势,不过在这个具体场景中,两者性能差异不大,选择deque更多是概念上的清晰——每一列就像一个队列。

核心设计思路:

  1. 加密

    • 根据密钥,创建N个(密钥长度)空的deque<char>,每个代表一列。
    • 将明文字符依次推入第1列、第2列...第N列,然后再从第1列开始循环(即按行填充)。
    • 根据密钥决定的顺序(从小到大读取密钥数字对应的原始列索引),依次将各列deque中的字符连接起来,形成密文。
  2. 解密

    • 这是关键且稍复杂的一步。我们需要知道每个列在密文中贡献了多少个字符。
    • 首先,根据明文长度和密钥长度,计算矩阵的行数(向上取整)和最后一行的有效字符数。
    • 由此可以计算出,前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::vector<int> 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_H

generateKeyIndex函数是核心之一。它的任务是将像“3142”这样的密钥,转换为一组明确的、从0开始的索引顺序。例如,“3142”应该被转换为[2, 0, 3, 1]。这个向量的意思是:在加密读取列时,第一个读的是原始第2列(索引为2),第二个读的是原始第0列,以此类推。我们通过排序密钥字符并记录其原始位置来实现这个转换。

// TranspositionCipher.cpp 关键部分 void TranspositionCipher::generateKeyIndex(const std::string& key) { keyLength = static_cast<int>(key.length()); keyIndex.resize(keyLength); // 创建一个向量,存储(字符, 原始位置)对 std::vector<std::pair<char, int>> keyWithPos; for (int i = 0; i < keyLength; ++i) { keyWithPos.emplace_back(key[i], i); } // 按字符的ASCII码排序(对于数字密钥,就是数字大小排序) std::sort(keyWithPos.begin(), keyWithPos.end(), [](const std::pair<char, int>& a, const std::pair<char, 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. 创建列容器,每个列用一个deque<char>表示 std::vector<std::deque<char>> 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向量:keyLengthdeque<char>columns[0]代表第一列,columns[1]代表第二列,以此类推。
  • 填充循环:i % keyLength这个操作是精髓。当i=0时,col=0,字符放入第0列;i=1col=1... 这完美模拟了“按行书写”的动作。
  • 读取循环:keyIndex存储了读取顺序。keyIndex[0]是第一个要读取的列的原始索引,我们通过columns[originalCol]拿到那一列的所有字符,按顺序追加。由于之前是按行从上到下push_back的,所以deque里的顺序自然就是该列从上到下的字符顺序。

3.3 解密函数实现详解

解密函数是算法的难点,需要逆向思维。我们知道密文是按keyIndex顺序,将各列字符拼接而成的。所以,我们需要:

  1. 计算出每一列在加密后有多少个字符(即该列deque的长度)。
  2. 根据这个长度,从密文中切分出对应的子串,还原到对应的列deque中。
  3. 最后,按行(即按原始填充顺序)从各列deque中取出字符。
std::string TranspositionCipher::decrypt(const std::string& ciphertext) { if (ciphertext.empty() || keyLength == 0) { return ciphertext; } int textLen = static_cast<int>(ciphertext.length()); // 1. 计算矩阵的行数和最后一行的列数 int rows = (textLen + keyLength - 1) / keyLength; // 向上取整除法 int fullCols = textLen % keyLength; // 最后一行的有效列数 if (fullCols == 0) { fullCols = keyLength; // 如果能整除,则所有列都是满的 } // 2. 计算每一列的长度(字符数) // 列长度分布:前 fullCols 列,每列有 rows 个字符;后面的列,每列有 rows-1 个字符。 std::vector<int> colLength(keyLength, rows - 1); // 先全部初始化为 rows-1 for (int i = 0; i < fullCols; ++i) { colLength[i] = rows; // 前 fullCols 列是满行 } // 注意:这里的列索引 i 指的是“原始列索引”。 // 3. 重新创建列容器 std::vector<std::deque<char>> 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顺序遍历keyIndexkeyIndex[readSeq]告诉我们现在处理的是哪个原始列。然后我们根据该列的colLength,从密文中切出对应数量的字符,push_back到该列的deque中。这一步完美逆转了加密时“按顺序读取各列并拼接”的过程
  • 按行读取:我们用双层循环模拟最初按行填充的动作。外层循环r遍历行,内层循环c遍历原始列。如果当前行r小于该列的长度colLength[c],说明这个位置有字符,我们就从该列dequefront()取出(因为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++ -std=c++11 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_cast<int>(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 性能优化与代码健壮性

  1. 使用reserve预分配字符串空间:如代码所示,在encryptdecrypt函数中,我们对结果字符串使用了reserve。这避免了在循环中多次push_back可能引发的内存重新分配和复制,对于长字符串能提升性能。
  2. 输入验证:我们的代码对空字符串和空密钥做了简单处理。在生产环境中,还需要验证密钥是否有效(例如,是否包含重复字符导致歧义?是否全为数字或字母?)。对于解密,理论上应验证密文长度是否合理。
  3. 处理大小写和非字母字符:古典密码通常只处理大写字母。我们的实现直接处理所有字符。一个更严谨的做法是在加密前将输入统一转换为大写,并过滤掉非字母字符。解密后,如果需要,可以尝试恢复原始格式(但这需要额外信息)。
  4. 使用std::vector代替std::deque:经过测试,在这个特定场景下,由于我们主要进行push_back和顺序访问,std::vector<char>的性能通常优于std::deque<char>,因为其内存连续,缓存友好。你可以将columns的类型改为vector<string>,每列用一个string存储,push_back字符,最后直接+=拼接,代码更简洁。

5.3 常见问题与调试技巧实录

问题1:解密出来的文本末尾多了乱码或空格。

  • 原因:最可能是在解密函数的“按行读取”环节,循环边界处理有误。当明文长度不是密钥长度的整数倍时,最后几列在最后一行是没有字符的。如果内层循环for (int c = 0; c < keyLength; ++c)无条件地尝试从每列取字符,就会取到无效值(可能是未初始化的内存或上一轮残留的数据)。
  • 排查:仔细检查if (r < colLength[c])这个条件。确保colLength数组计算正确,并且这个条件过滤掉了那些在当前行没有字符的列。可以在解密函数中打印出rowsfullColscolLength数组的值进行验证。

问题2:密钥包含重复字符时,加密解密结果不稳定或错误。

  • 原因:我们的generateKeyIndex函数使用std::sort,对于重复字符,排序是稳定的,但“按字符排序决定读取顺序”对于重复字符本身就有歧义。例如密钥“AA”,两列谁先谁后?经典定义通常要求密钥无重复字符。
  • 解决:在构造函数中增加检查,如果密钥包含重复字符,可以抛出异常,或者采用更复杂的规则(如按字符首次出现位置)。对于学习项目,建议直接规定密钥字符必须唯一。

问题3:密文长度很长时,程序运行似乎正常,但解密结果偶尔不对。

  • 原因:可能是整数溢出或类型转换问题。代码中大量使用intsize_tstring.length()返回类型)混用。在极长的字符串下,static_cast<int>(text.length())可能导致溢出。
  • 解决:统一使用size_t类型来操作字符串长度和索引。修改函数签名和内部变量类型,避免有符号/无符号转换警告和潜在溢出。

调试技巧

  • 单元测试:为encryptdecrypt函数编写小型单元测试,覆盖边界情况(空串、单字符、长度刚好是密钥倍数、长度非倍数)。
  • 打印中间状态:在加密解密函数的关键步骤,临时打印columns的内容、colLength数组、切片位置等。这是理解算法和数据流最直观的方式。
  • 使用调试器:在IDE(如VS Code, CLion, Visual Studio)中设置断点,单步执行,观察变量值的变化,尤其关注循环索引和容器状态。

这个C++置换密码项目虽然不大,但“麻雀虽小,五脏俱全”。它涉及了字符串处理、容器使用、算法设计、边界条件判断等多个核心编程概念。自己动手实现一遍,再尝试添加填充功能、支持文件加密解密、或者设计一个交互式的命令行界面,你会对C++和古典密码有更扎实的理解。编程的乐趣,往往就藏在这些亲手将原理变为代码的过程之中。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询