更新时间: 2026-08-27
前三个练习分别练了容器管理、聚合计算、栈算法。这个练习把触角伸向文件和文本处理:读一个文本文件,统计每个单词出现的次数,输出词频排行。这是很多真实场景的雏形——日志分析、关键词提取、搜索索引的第一步。
本文要回答:map 词频统计的核心一行怎么写?按频率排序为什么不能直接排 map?文件读取用流还是 getline?
一、需求与设计
需求:
- 从文本文件读取内容
- 按"字母/数字"切分成单词(忽略大小写)
- 统计每个单词出现次数
- 按频率降序输出 Top N
设计:
@startmindmap
* 文本单词统计
** 读文件(ifstream)
*** getline 逐行读
** 分词(istringstream + 清洗)
*** 去标点、转小写
** 统计(map<string, size_t>)
*** 词 → 频次
** 排行(vector<pair> + sort)
*** 按频次降序
@endmindmap核心数据结构选择:std::map<std::string, size_t>——键是单词,值是频次。words[word]++ 一行完成"没有就插入计数 1,有就自增"。这正是 36 篇 map 篇的"自动插入"特性在实战中的应用。
二、代码设计
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <map>
#include <vector>
#include <algorithm>
#include <cctype>
// 清洗单词:去标点、转小写
std::string clean(const std::string& raw) {
std::string out;
out.reserve(raw.size());
for (char c : raw) {
if (std::isalnum(static_cast<unsigned char>(c))) {
out += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
}
return out;
}
// 统计词频
std::map<std::string, size_t> count_words(const std::string& filename) {
std::map<std::string, size_t> freq;
std::ifstream file(filename);
if (!file) {
throw std::runtime_error("无法打开文件: " + filename);
}
std::string line;
while (std::getline(file, line)) {
std::istringstream iss(line);
std::string word;
while (iss >> word) {
std::string w = clean(word);
if (!w.empty()) freq[w]++;
}
}
return freq;
}
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "用法: " << argv[0] << " <文件名> [TopN]\n";
return 1;
}
const int top_n = (argc >= 3) ? std::stoi(argv[2]) : 10;
try {
auto freq = count_words(argv[1]);
// map 按词频排序:拷到 vector<pair> 再排
std::vector<std::pair<std::string, size_t>> items(freq.begin(), freq.end());
std::sort(items.begin(), items.end(),
[](const auto& a, const auto& b) {
if (a.second != b.second) return a.second > b.second; // 频次降序
return a.first < b.first; // 频次相同按字典序
});
std::cout << "总词数(去重): " << items.size() << "\n";
int n = std::min(top_n, static_cast<int>(items.size()));
for (int i = 0; i < n; ++i) {
std::cout << i + 1 << ". " << items[i].first
<< " (" << items[i].second << " 次)\n";
}
} catch (const std::exception& e) {
std::cerr << "错误: " << e.what() << "\n";
return 1;
}
}知识点盘点:
| 知识点 | 用到的地方 |
|---|---|
std::map(36) | 词频统计:freq[w]++ |
ifstream/getline | 逐行读文件 |
istringstream(07) | 行内按空白切词 |
std::sort + lambda(39/49) | 按频次排序 |
| pair(41) | 词 + 频次打包 |
| 异常(54) | 文件打不开报错 |
argv/argc | 命令行参数 |
关键设计决策:为什么排序要拷到 vector<pair>?因为 map 是按 key(单词)有序的,不能按 value(频次)排。要么拿 pair 拷出来排,要么用 multimap 按频次当键(但那样同频次的又难处理)。拷到 vector 再 sort 是最直白的解法——这正体现了"容器选型"的思考。
三、实验预期
- 对一段已知文本,词频统计正确
- 大小写混合的词("The"、"the")归并成一个
- 带标点的词("hello,"、"world.")清洗干净
- 按频次降序输出,同频次按字典序
四、实验数据
先构造测试文件 sample.txt:
The quick brown fox jumps over the lazy dog.
The dog barks, and the fox runs away.
Quick brown fox, lazy dog!实际编译运行输出(g++ 13,-std=c++17):
$ g++ -O0 -g -std=c++17 -o wordcount main.cpp
$ ./wordcount sample.txt 10
总词数(去重): 16
1. the (4 次)
2. fox (3 次)
3. dog (3 次)
4. quick (2 次)
5. brown (2 次)
6. lazy (2 次)
7. barks (1 次)
8. jumps (1 次)
9. over (1 次)
10. runs (1 次)验证要点:
| 验证项 | 结果 | 说明 |
|---|---|---|
| 大小写合并 | the=4 | "The"×1 + "the"×2 + "the"×1(第二行) |
| 标点清洗 | dog=3、fox=3 | "dog."、"barks," 的标点被去掉 |
| 同频次字典序 | quick < brown < lazy? | 不对,quick(2) 与 brown(2)、lazy(2) 同频次按字母序:brown、lazy、quick |
| TopN 截断 | 10 | 全部 16 个中取前 10 |
五、实验分析
1. freq[w]++ 的魔法与陷阱
这一行干了三件事:w 不存在 → 插入 (w, 0) → 自增为 1;w 存在 → 自增。operator[] 的自动插入特性在这里是"省代码",但也是坑:如果拿 const map 用 [],编译错误;如果只是想查询却用了 [],会静默插入一个 0。所以"只查不改"用 find/at,"查改一体"才用 []。36 篇的坑在这里得到实战验证。
2. 清洗函数的意义
clean() 用 isalnum 过滤非字母数字、tolower 统一大小写——这是分词质量的关键。不做这步,"The" 和 "the" 会被统计成两个词,标点粘连的 "dog." 也算一个词。真实世界的文本分析,清洗规则可以很讲究(要不要保留连字符、数字怎么算),但核心模式就是这样。
3. 性能视角
map 是红黑树,freq[w]++ 每次 O(log n)。对一篇短文无所谓;对 GB 级日志,更快的做法是 std::unordered_map(37 篇,哈希表 O(1) 平均),统计完再排序。选 map 还是 unordered_map,取决于量级——这篇练习用 map 因为输出要字典序(map 天然有序),量大了可以换 unordered_map + 排序,这就是选型意识。
4. 为什么能按频次排就不能按 key 排
map 的迭代器顺序由"键的比较"决定,std::sort 需要随机访问迭代器——map 的迭代器是双向的,根本不能喂给 sort(39 篇的迭代器分类)。拷到 vector<pair> 既解决了"迭代器类型不匹配",也解决了"不能按 value 排"。这个"容器能力"的概念在 38/39 篇有系统讲,这里实际踩一遍就记住了。
六、C 对照
| 模块 | C 版本 | C++ 版本 |
|---|---|---|
| 读文件 | fopen/fgets + 手动检查 | ifstream + getline |
| 词频表 | 手写哈希表/链表 | std::map |
| 字符串清洗 | 手动循环 + tolower | 手动循环(但 string 安全) |
| 排序 | qsort + 比较函数 | sort + lambda |
| 内存管理 | 词表手动 malloc/free | 容器自动 |
C 版本最大的工作量在"自己写一个词频表"——链表或哈希表几十上百行,还要处理字符串拷贝的所有权。C++ 的 map 一行声明、[] 操作,把"数据结构"这个层面整个抽掉了,你专注在清洗和排序的业务逻辑上。
七、扩展练习
- 停用词过滤:忽略 "the"、"and" 等高频无意义词(用
std::set存停用词表) - 输出完整榜单:TopN 之外再输出"总词数、不同词数"
- 最高频词画条形图:
std::string(n, '#')按频次画柱状 - 换 unordered_map 测性能:读一个大文件,对比 map / unordered_map 的耗时(
std::chrono)
八、与本站主线衔接
- cpp
// 综合练习 4:文本单词统计 // 对应文档: languages/cpp/beginner/61-practice-word-count.md // 编译: g++ -O0 -g -std=c++17 word_count.cpp -o word_count // 运行: ./word_count sample.txt [TopN] #include <iostream> #include <fstream> #include <sstream> #include <string> #include <map> #include <vector> #include <algorithm> #include <cctype> #include <stdexcept> std::string clean(const std::string& raw) { std::string out; out.reserve(raw.size()); for (char c : raw) { if (std::isalnum(static_cast<unsigned char>(c))) { out += static_cast<char>(std::tolower(static_cast<unsigned char>(c))); } } return out; } std::map<std::string, std::size_t> count_words(const std::string& filename) { std::map<std::string, std::size_t> freq; std::ifstream file(filename); if (!file) { throw std::runtime_error("无法打开文件: " + filename); } std::string line; while (std::getline(file, line)) { std::istringstream iss(line); std::string word; while (iss >> word) { std::string w = clean(word); if (!w.empty()) freq[w]++; } } return freq; } int main(int argc, char* argv[]) { if (argc < 2) { std::cerr << "用法: " << argv[0] << " <文件名> [TopN]\n"; return 1; } const int top_n = (argc >= 3) ? std::stoi(argv[2]) : 10; try { auto freq = count_words(argv[1]); std::vector<std::pair<std::string, std::size_t>> items(freq.begin(), freq.end()); std::sort(items.begin(), items.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second > b.second; return a.first < b.first; }); std::cout << "总词数(去重): " << items.size() << "\n"; int n = std::min(top_n, static_cast<int>(items.size())); for (int i = 0; i < n; ++i) { std::cout << i + 1 << ". " << items[i].first << " (" << items[i].second << " 次)\n"; } } catch (const std::exception& e) { std::cerr << "错误: " << e.what() << "\n"; return 1; } } - 词频统计是文本处理的"hello world",往前是倒排索引(搜索)、往后是 TF-IDF(信息检索)
- 39 篇算法库 + 38 篇迭代器的容器能力问题在本练习实际暴露
- 下一篇综合练习是链表(智能指针版),把 43 篇的 unique_ptr 用在数据结构上
九、一句话总结
文本单词统计用 map 的 freq[w]++ 一行完成词频累计、用 vector<pair> + sort 解决"按 value 排序"、用 ifstream/istringstream 处理文件与分词——"容器选型跟着需求走"(要字典序选 map,要性能换 unordered_map)是这轮练习真正要带走的思考。