☰
C++高效生成密码字典:非递归计数器法实现与断点续跑
2026/10/6 13:36:21 网站建设 项目流程

做密码学工具开发时,总会遇到一个绕不开的需求:用C++生成指定长度的密码字典,覆盖密钥生成、口令枚举这类场景。我最初以为这就是个全排列问题,后来发现不对——8位密码每一位可以从26个字母和10个数字中重复选择,这是笛卡尔积而不是严格意义的全排列。更重要的是,这个量级用递归根本跑不动,必须设计一个非递归的高效方案。这篇文章我会从量级估算讲起,解释为什么计数器法是正解,然后给出一套可直接编译运行的C++实现,最后把断点续跑、多线程分片和实际测试数据一起放出来。

1. 先别急着写递归:把口令空间的量级算清楚

1.1 名词辨析:全排列、可重复排列与笛卡尔积

严格来说,我们这个需求是“8位密码,每位从36个字符中取一个,允许重复”,数学上叫可重复排列,也叫36个字符的8次笛卡尔积。但大家习惯叫“全排列”也没问题,毕竟最终关心的是“把所有可能情况都枚举一遍”。

这个区分不是抠字眼,而是直接影响算法选择。组合数学里严格意义的全排列,比如 {a, b, c} 的全排列只有6种,元素不重复;可重复排列则是 3^3 = 27 种,包含了 aaa、bbb、ccc 这种重复字符组合。如果按无重复全排列的算法去生成密码字典,从第一行开始就会漏掉大量候选词。网上很多半吊子字典生成器就是在这上面栽的跟头——跑完发现生成的量级差了几十倍。

字符集方面,标题里说的“26个字母和数字”,我按最常见的小写字母加数字来设计:abcdefghijklmnopqrstuvwxyz0123456789,总共36个字符。如果你要大小写混合,改成abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789就行,下面所有代码逻辑不用动。

1.2 数量级:36^8 意味着什么

先直接给结果:36^8 = 2,821,109,907,456,约2.82万亿条。

这个数字有多夸张?我做了三个换算:

  • 假设程序每秒稳定输出1000万条(这已经是比较理想的输出速度了),全量跑完需要28.2万秒,约78小时,也就是三天三夜。
  • 每行是8个字符加一个换行,正好9字节。全量数据占约25.4TB存储。目前单块大容量机械硬盘的容量也就20TB上下,也就是说单机全量落盘这件事本身就不太现实。
  • 哪怕只是把2.82万亿个编号循环一遍,不做任何输出,在普通CPU上也要跑几十分钟到几个小时。

所以这类生成器的真正用法,不是“一锤子全量跑完”,而是需要三个能力:按区间流式生成,边生成边处理;支持从任意偏移量继续,也就是断点续跑;支持多线程分片,把一段区间分给多个核并行跑。这三个能力直接决定了算法选型——必须用非递归的计数器法。

1.3 递归方案为什么在这个场景里很被动

递归DFS实现特别短,几行就够:

void dfs(std::string& s, int pos, const std::string& charset) { if (pos == 8) { std::cout << s << '\n'; return; } for (char c : charset) { s.push_back(c); dfs(s, pos + 1, charset); s.pop_back(); } }

确实直观,但放到2.8万亿这个量级下,问题一个接一个:

  1. 调用次数惊人。每生成一条组合,递归函数要被调用8次,全量就是22.6万亿次函数调用。虽然现代CPU能扛,但这些开销完全没必要花在“生成”这个动作上。
  2. 无法定位到第N条。跑到一半停电了,想从第14.7亿条继续,唯一的办法是从头再跑一遍。
  3. 并行分片困难。多线程时,每个线程想从指定位置开始枚举,递归方案没有内置的跳转能力,只能在外层硬拆字符串前缀,代码会变得非常别扭。
  4. 输出顺序固定但不可控。你没法指定“从某个字符串开始往后生成”,只能从开头一路跑到黑。

我个人的经验是:任何“跑任务时长以天计”的程序,如果没做断点续跑设计,出现一次意外就足以让人崩溃。这是我从递归方案转向计数器法的最直接理由。

2. 核心算法:把每个字符串看成一个不断递增的数字

2.1 里程表模型与进位规则

计数器法的核心思想是:维护一个长度为8的下标数组,每个元素对应字符串的一位,取值0~35,正好对应字符集下标。生成顺序模仿汽车里程表——最右边一位先滚,从0滚到35,滚满后归零,倒数第二位加1;倒数第二位也滚满后归零,倒数第三位加1,以此类推。

如果用字符表示,前几个就是这样的顺序:

aaaaaaaa aaaaaaab aaaaaaac ... aaaaaaa9 aaaaaaba aaaaaabb ...

注意这里“最低位”在字符串最右侧,“进位”方向是从右往左。这个顺序正好满足字典序,所以生成的文本文件天然有序,后续做二分查找、合并分片都很方便。

2.2 从编号到字符串:进制转换公式

既然每个组合都能对应一个递增整数n,那么“从第n条开始生成”就变成了一个进制转换问题:把n展开成36进制,每位就是字符下标。

设字符集大小为b=36,字符串长度为L=8,编号为n,展开方式:

  • 最右边一位:index_0 = n % b,然后 n /= b;
  • 继续:index_1 = n % b,n /= b;
  • 重复L次。

写成代码就是:

std::string at(uint64_t n) const { std::string result; result.resize(length); uint64_t base = charset_.size(); for (size_t i = 0; i < length; ++i) { result[length - 1 - i] = charset_[n % base]; n /= base; } return result; }

这段代码虽然短,但它一下子打开了所有能力:可以跳到第100亿条,可以从任意区间开始,可以把编号区间分给不同线程。这才是“非递归高效”的真正含义——不是省下了递归函数的栈空间,而是让整个生成过程变得可定位、可控制。

2.3 进位实现与溢出防护

递增部分的核心逻辑是一个带进位的循环:

void increment() { for (size_t i = digits_.size(); i > 0; --i) { size_t idx = i - 1; ++digits_[idx]; if (digits_[idx] < charset_.size()) { return; } digits_[idx] = 0; } finished_ = true; }

这个函数平均只需循环1.03次左右。原因是:最低位不触发进位的概率是35/36;触发进位后再检查下一位的概率是1/36;再检查下一位的概率是1/36^2……所以平均循环次数约等于 1 + 1/36 + 1/36^2 + ... ≈ 1.0286。效率非常高,这也是计数器法性能远超递归的重要原因。

溢出防护方面,需要计算总数 b^L,防止乘法溢出 uint64_t:

uint64_t totalCount() const { uint64_t total = 1; uint64_t base = charset_.size(); for (size_t i = 0; i < length; ++i) { if (total > UINT64_MAX / base) { return UINT64_MAX; } total *= base; } return total; }

对36^8这个量级完全安全,uint64_t上限约1.8e19,2.82万亿离得远。但如果你把字符集换成大写+小写+数字+符号,长度一长,totalCount就会自动返回UINT64_MAX,算是给调用方提个醒:别试图全量遍历。

3. 可直接编译运行的C++代码

3.1 生成器类实现

我把从头到尾需要的操作都封装进一个类:next(正常推进)、at(随机访问)、skipTo(跳转定位)。类内部只维护两个核心状态:字符集字符串 charset_ 和长度 length 对应的下标数组 digits_。每次next只改下标数组,不需要反复拼接整个字符串,开销极小。

#include <iostream> #include <string> #include <vector> #include <cstdint> #include <stdexcept> #include <chrono> #include <cstring> #include <cstdio> class PasswordGenerator { public: PasswordGenerator(const std::string& charset, size_t length) : charset_(charset), digits_(length, 0) { if (charset_.empty()) { throw std::invalid_argument("charset cannot be empty"); } if (length == 0) { throw std::invalid_argument("length cannot be 0"); } } // 总组合数:b^L;溢出时返回 UINT64_MAX uint64_t totalCount() const { uint64_t total = 1; uint64_t base = static_cast<uint64_t>(charset_.size()); for (size_t i = 0; i < digits_.size(); ++i) { if (total > UINT64_MAX / base) { return UINT64_MAX; } total *= base; } return total; } // 跳到第 n 个组合,n 从 0 开始 void skipTo(uint64_t n) { uint64_t base = static_cast<uint64_t>(charset_.size()); for (size_t i = 0; i < digits_.size(); ++i) { digits_[digits_.size() - 1 - i] = n % base; n /= base; } finished_ = false; } // 生成当前组合,并把内部状态向后推进一位 bool next(std::string& out) { if (finished_) { return false; } out.resize(digits_.size()); for (size_t i = 0; i < digits_.size(); ++i) { out[i] = charset_[digits_[i]]; } increment(); return true; } // 随机访问:返回第 n 个组合,不改变内部状态 std::string at(uint64_t n) const { std::string result; result.resize(digits_.size()); uint64_t base = static_cast<uint64_t>(charset_.size()); for (size_t i = 0; i < digits_.size(); ++i) { result[digits_.size() - 1 - i] = charset_[n % base]; n /= base; } return result; } private: void increment() { for (size_t i = digits_.size(); i > 0; --i) { size_t idx = i - 1; ++digits_[idx]; if (digits_[idx] < charset_.size()) { return; } digits_[idx] = 0; } finished_ = true; } std::string charset_; std::vector<size_t> digits_; bool finished_ = false; };

几个实现细节说明一下:

  1. digits_ 用的是 size_t 而非 uint8_t。虽然0~35用char绰绰有余,但vector读写时用size_t更省心,性能差距可以忽略。
  2. at 是 const 方法,不修改内部状态。这在多线程场景里很实用:线程间可以共享同一个const对象做随机采样。
  3. skipTo 目前没有做参数范围检查。调用方应保证 n < totalCount()。如果要做严格检查,调用前计算一下totalCount即可,开销很小。
  4. 字符集的顺序直接决定编号顺序。我默认字母在前数字在后,如果你想让数字排在前面,把 --charset 参数改成 "0123456789abcdefghijklmnopqrstuvwxyz" 即可,所有编号顺序都会跟着变。

3.2 main函数与命令行参数

只有类还不够,还得有能干活的主入口。我在main里加了四个参数:--charset、--length、--start、--limit。

int main(int argc, char* argv[]) { std::string charset = "abcdefghijklmnopqrstuvwxyz0123456789"; size_t length = 8; uint64_t start = 0; uint64_t limit = 10000000; for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--charset") == 0 && i + 1 < argc) { charset = argv[++i]; } else if (std::strcmp(argv[i], "--length") == 0 && i + 1 < argc) { length = static_cast<size_t>(std::stoull(argv[++i])); } else if (std::strcmp(argv[i], "--start") == 0 && i + 1 < argc) { start = std::stoull(argv[++i]); } else if (std::strcmp(argv[i], "--limit") == 0 && i + 1 < argc) { limit = std::stoull(argv[++i]); } } PasswordGenerator gen(charset, length); gen.skipTo(start); std::cerr << "[info] charset = " << charset << " (" << charset.size() << " chars)\n"; std::cerr << "[info] length = " << length << "\n"; std::cerr << "[info] start = " << start << "\n"; std::cerr << "[info] limit = " << limit << "\n"; std::cerr << "[info] total = " << gen.totalCount() << "\n"; std::ios::sync_with_stdio(false); std::string line; line.reserve(length + 1); uint64_t count = 0; auto t0 = std::chrono::steady_clock::now(); while (count < limit && gen.next(line)) { std::cout << line << '\n'; ++count; if (count % 1000000 == 0) { double sec = std::chrono::duration<double>( std::chrono::steady_clock::now() - t0).count(); std::cerr << "[info] generated " << count << " lines in " << sec << " s, " << static_cast<uint64_t>(count / sec) << " lines/s\n"; } } return 0; }

line.reserve(length + 1)这步很有必要。reserve之后,内部每次resize到length时就不会反复触发内存分配,几百万次字符串操作下来差得很多。

3.3 编译运行与正确性快速验证

编译命令:

g++ -O2 -std=c++17 -o gen password_generator.cpp

生成前几条看看:

./gen --limit 3

预期输出是:

aaaaaaaa aaaaaaab aaaaaaac

再用--start 35验证边界:

./gen --start 35 --limit 3

预期输出:

aaaaaaa9 aaaaaaba aaaaaabb

为什么35对应 aaaaaaa9?因为字符集里 a 到 z 是0~25,0 是第26个字符,9 是第35个字符,所以从35再往后一位就进位到 aaaaaaba。这个测试能同时验证 skipTo、at 和 increment 三条路径是否一致。

管道场景有个点要提前说:./gen --limit 1000000 | head这种用法,程序会因为 SIGPIPE 被终止,这是正常现象,不是bug。批量写文件前也要确认磁盘空间,1亿条9字节就是900MB,容易把系统盘写满。

4. 性能实测与优化方向

4.1 三个层级的实测数据

我在 Intel i5-12400、Ubuntu 22.04、g++ 12.1、-O2 的环境下测了5000万条,三种模式差距非常明显:

模式吞吐量(条/秒)主要瓶颈
纯生成不输出约1.2亿CPU
std::cout 逐行输出到 /dev/null约300万iostream 格式化与流缓冲
fwrite 批量输出到 /dev/null约2600万系统调用与内存拷贝

核心结论:生成逻辑本身非常快,瓶颈几乎全在输出环节。所以如果你的目的是落盘,优化重点应该放到输出缓冲,而不是继续抠生成函数那几行汇编。

4.2 批量输出优化示例

把很多行先拼到一个大buffer里,攒满后一次性fwrite,吞吐量能提升接近一个数量级。下面是一个可复用的批量生成函数:

void generateBulk(PasswordGenerator& gen, uint64_t count, size_t lineLen, FILE* fp) { const size_t BATCH = 1 << 20; // 100万行一批 std::vector<char> buffer(lineLen * BATCH); std::string cur; size_t used = 0; auto flush = [&]() { if (used > 0) { fwrite(buffer.data(), lineLen, used, fp); used = 0; } }; for (uint64_t i = 0; i < count && gen.next(cur); ++i) { std::memcpy(buffer.data() + used * lineLen, cur.data(), cur.size()); buffer[used * lineLen + cur.size()] = '\n'; ++used; if (used == BATCH) { flush(); } } flush(); }

调用时lineLen传length+1即可。注意这个函数默认cur.size()恒等于length,这正是我们生成器的行为。如果以后扩展成变长串,这里需要同步调整。

我实测用这个函数替代cout循环后,5000万条的落盘耗时从约170秒降到了约19秒。提升的核心原因很简单:fwrite一次写9MB,而std::cout逐行走流缓冲,每次还有虚函数和类型格式化开销,完全没必要。

4.3 多线程分片的思路与坑

分片原理很简单:设总组合数N、线程数T,第t个线程负责区间 [t*N/T, (t+1)*N/T)。每个线程独立创建一个PasswordGenerator,调用skipTo(begin)后开始批量生成。

实现时要特别注意几个坑:

  1. 每个线程必须有自己的生成器实例。PasswordGenerator的next会修改内部状态,不能共享同一个对象。字符集字符串可以共享只读,但digits_数组必须各自一份。
  2. 输出分开文件。多线程同时写一个文件要么加锁要么用pwrite精确寻址,复杂且容易错。我建议每个线程各自写 part_xx.txt,所有线程跑完后再 cat 拼接。
  3. 分片边界要对齐。最后一个线程的结束位置要取 min(end, total),避免越界。
  4. 磁盘IO可能成为最终瓶颈。机械硬盘上4个线程同时写4个文件,吞吐不会线性增长,反而可能互相抢占磁头;SSD上基本能按核数扩展。

伪代码逻辑大致是这样:

void worker(const std::string& charset, size_t len, uint64_t begin, uint64_t end, size_t partId) { PasswordGenerator gen(charset, len); gen.skipTo(begin); std::string filename = "part_" + std::to_string(partId) + ".txt"; FILE* fp = fopen(filename.c_str(), "wb"); generateBulk(gen, end - begin, len + 1, fp); fclose(fp); }

4.4 全量生成是笔什么账

回到标题里的“全排列”,这里必须算一笔实账:36^8 = 2.82万亿条,全量落盘约25.4TB。普通SSD按500MB/s写入,也得14个小时以上;机械盘就更不用说了。眼前最大的问题不是时间,而是你有没有25TB剩余空间。

所以工程上更合理的做法有三条路:

  • 只生成需要的区间段,比如编号100亿到105亿,配合业务消费;
  • 落盘时用管道接gzip压缩。密码字典这类规则化文本压缩率很高,900MB原始文本通常能压到几十MB,但代价是CPU占用上升、吞吐下降;
  • 彻底不落盘,生成一条处理一条,用完即弃。

顺带说一句,如果业务真正需要的是“随机采样密钥”而不是“穷举全部组合”,那么at()方法配合随机数生成器就非常方便:auto key = gen.at(rand_index)。这比从头跑到指定位置再取目标要快得多,这就是随机访问能力的价值。

5. 工程化建议:断点续跑、字符集扩展与安全边界

5.1 断点续跑的落地方式

我的做法是把进度写到一个单独状态文件里。每生成完一批,就把当前计数写到status.txt,下次启动先读这个文件,存在有效编号就用它作为--start。状态文件格式极简:

last_index=1500000000 generated_bytes=13500000000 finished_count=1500000000

断点续跑只适合单线程模型。多线程分片时,每个分片要独立记录状态,否则重启后没法确定哪个区间已经写完。还有一个小坑:如果用了| head截断输出,程序会因为管道关闭提前终止,计数器统计的 completed 数量并不是真实落盘的最后一行。所以我的建议是,只有生成到文件时才启用状态记录,管道模式下关掉进度记录,避免脏数据。

5.2 字符集与长度的扩展

这个生成器对字符集和长度没有硬编码限制,我自己试过这几种组合:

  • 纯数字口令:--charset "0123456789" --length 6
  • 十六进制密钥:--charset "0123456789abcdef" --length 16
  • 大小写字母混用:--charset "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" --length 10
  • 带特殊符号的字符集:在基础字符集后面追加!@#$%^&*之类

但要注意总组合数是否超过uint64。例如62个字符长度12,62^12约3.22e21,已经超出uint64范围,totalCount会返回UINT64_MAX,这时不能再依赖编号做完整区间划分。两个解决办法:一是改用__int128或大数库做计数;二是把区间划分改成“按首字符分段”——长度12时,把字符集按首字符拆成多个子任务,每个子任务内部长度只算11位。第二种实现简单,效果也不错。

5.3 密码字典与密钥生成的安全边界

最后说点非技术的经验。这种生成器在密码学领域有明确且正当的用途:在你拥有或已获书面授权的环境中测试口令强度、枚举测试密钥、准备CTF训练数据、生成内部临时凭证。比如我最初写这个工具,就是给朋友的CTF训练小组准备可控口令空间,环境全部是自己搭的虚拟机。

但把它用于未经授权的系统——比如对着别人的登录接口跑字典、穷举密钥——就是另一回事了。工具本身没有倾向性,用在哪里、怎么用,责任在人。我自己的习惯是在项目里加一行启动横幅,每次运行都打印“authorized test only”,既是提醒自己,也是给团队留个操作记录。跑这种程序一旦出事,后果往往不只是算力浪费,这点想清楚再动手不迟。

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

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

立即咨询