30. STL 实战项目二:基于 STL 的简易文本搜索引擎
终于到了最后一个实战项目。说实话,每次带新人做这个项目,我都会想起自己刚入行时写的一个蹩脚搜索工具——那时候还不知道用 unordered_map,硬是用 vector 加二分查找拼了一个,性能惨不忍睹。后来重构时换成哈希表,速度直接起飞。
今天这个项目,我们做一个简易的文本搜索引擎。它不复杂,但足够让你把 unordered_map、set、算法和 lambda 揉在一起用一遍。你想想看,搜索引擎的核心无非就是:建索引、查索引、排序结果。STL 正好把这几个环节都包圆了。
30.1 项目需求与整体设计
我们要实现的功能很简单:
- 读入一批文本文件(比如几篇英文文章)
- 对每个文件建立倒排索引(inverted index)
- 支持多关键词查询,返回包含所有关键词的文档列表
- 按匹配度排序(关键词出现次数越多,排名越靠前)
核心数据结构就两个:
unordered_map<string, set<int>>—— 关键词到文档 ID 集合的映射unordered_map<int, string>—— 文档 ID 到文件路径的映射
为什么用 set?因为我们要做交集运算——查询多个关键词时,取它们对应文档 ID 集合的交集。而 set 自带 set_intersection 算法支持,省心省力。
核心思路:倒排索引 + 集合交集 + 频率排序。说白了就是把「文档里有啥词」反过来变成「这个词出现在哪些文档里」。
30.2 构建倒排索引
建索引的第一步是分词。我习惯用 istringstream 配合 copy 算法来做,配合 lambda 清洗标点符号。
#include <iostream>
#include <fstream>
#include <sstream>
#include <unordered_map>
#include <set>
#include <vector>
#include <algorithm>
#include <cctype>
using Index = std::unordered_map<std::string, std::set<int>>;
using DocMap = std::unordered_map<int, std::string>;
// 清洗单词:去掉首尾标点,转小写
std::string clean_word(const std::string& word) {
std::string result;
std::copy_if(word.begin(), word.end(), std::back_inserter(result),
[](char c) { return std::isalpha(c); });
std::transform(result.begin(), result.end(), result.begin(),
[](char c) { return std::tolower(c); });
return result;
}
void build_index(Index& index, int doc_id, const std::string& content) {
std::istringstream stream(content);
std::string word;
while (stream >> word) {
word = clean_word(word);
if (!word.empty()) {
index[word].insert(doc_id);
}
}
}
这里有个细节:index[word] 如果 word 不存在,会自动创建一个空的 set。这是 unordered_map 的 operator[] 行为——我当年第一次用的时候还担心会崩溃,其实它很安全。
小技巧:如果你不想让不存在的 key 自动插入,可以用 find() 或 at()。但这里我们就是要插入,所以 operator[] 正合适。
30.3 多关键词查询与集合交集
查询时,用户输入多个关键词,我们要找出同时包含这些词的文档。说白了就是求多个 set 的交集。
std::set<int> intersect_sets(const std::vector<std::set<int>>& sets) {
if (sets.empty()) return {};
std::set<int> result = sets[0];
for (size_t i = 1; i < sets.size(); ++i) {
std::set<int> temp;
std::set_intersection(
result.begin(), result.end(),
sets[i].begin(), sets[i].end(),
std::inserter(temp, temp.begin())
);
result.swap(temp);
}
return result;
}
std::set<int> search(const Index& index, const std::vector<std::string>& query_words) {
std::vector<std::set<int>> sets;
for (const auto& word : query_words) {
auto it = index.find(word);
if (it != index.end()) {
sets.push_back(it->second);
} else {
// 有一个词没找到,直接返回空
return {};
}
}
return intersect_sets(sets);
}
我曾经踩过一个坑:set_intersection 要求输入的两个集合必须是有序的。而 std::set 默认就是升序排列,所以没问题。但如果你用 unordered_set,那就完蛋了——它无序,交集结果会乱套。
注意:set_intersection 只适用于有序集合。如果你用 unordered_set,需要先排序,或者自己手写哈希交集。我个人建议直接用 set,省心。
30.4 按匹配度排序
找到文档后,我们按关键词出现总次数排序。这里需要统计每个文档中所有查询词的出现次数。我选择用 unordered_map<int, int> 来统计文档 ID 到频率的映射,然后用 sort 配合 lambda 排序。
std::vector<int> rank_documents(const Index& index,
const std::set<int>& doc_ids,
const std::vector<std::string>& query_words) {
std::unordered_map<int, int> freq;
for (int doc_id : doc_ids) {
int count = 0;
for (const auto& word : query_words) {
auto it = index.find(word);
if (it != index.end() && it->second.count(doc_id)) {
// 这里简化处理:每个词在文档中只算一次
// 实际项目应该统计词频
count++;
}
}
freq[doc_id] = count;
}
std::vector<int> sorted_docs(doc_ids.begin(), doc_ids.end());
std::sort(sorted_docs.begin(), sorted_docs.end(),
[&freq](int a, int b) {
return freq[a] > freq[b]; // 降序
});
return sorted_docs;
}
lambda 在这里的作用就是「按频率降序」。你想想看,如果没有 lambda,你得单独写一个比较函数,还得把 freq 传进去,多麻烦。lambda 直接捕获外部变量,干净利落。
30.5 完整流程与测试
把上面几块拼起来,主函数大概长这样:
int main() {
Index index;
DocMap doc_map;
// 模拟两篇文档
std::vector<std::string> docs = {
"Hello world! This is a test document.",
"Hello again. The world is big."
};
for (int i = 0; i < docs.size(); ++i) {
build_index(index, i, docs[i]);
doc_map[i] = "doc_" + std::to_string(i) + ".txt";
}
// 查询 "hello world"
std::vector<std::string> query = {"hello", "world"};
auto doc_ids = search(index, query);
if (doc_ids.empty()) {
std::cout << "No results found.\n";
} else {
auto ranked = rank_documents(index, doc_ids, query);
for (int id : ranked) {
std::cout << "Found in: " << doc_map[id] << "\n";
}
}
return 0;
}
输出结果:
Found in: doc_0.txt
Found in: doc_1.txt
两篇都包含 "hello" 和 "world",所以都返回了。如果只查 "hello",结果也一样。但如果查 "test",就只有第一篇。
30.6 知识体系总览
下面这张图把整个项目的核心逻辑串起来了:
从输入到输出,每一步都对应着 STL 的某个组件。你仔细看,unordered_map 负责索引存储,set 负责文档 ID 集合,set_intersection 做交集,sort 配合 lambda 做排序。没有一句废话,全是 STL 的拿手好戏。
30.7 避坑指南与扩展思路
最后分享几个我实际踩过的坑:
- 分词太粗糙:上面的
clean_word只保留了字母,但实际文本可能有数字、连字符、撇号。比如 "don't" 会被拆成 "dont","C++" 会变成空字符串。如果你做中文搜索,还得用分词库。 - 内存占用:如果文档量很大(比如几十万篇),
unordered_map里每个词都存一个set会非常吃内存。我曾经遇到过内存爆炸的情况,后来改用vector加压缩存储才解决。 - 性能瓶颈:交集运算时,如果某个词出现在大量文档中(比如 "the"),
set_intersection会遍历整个集合。实际搜索引擎会跳过停用词,或者用跳表优化。
扩展思路:你可以给每个文档加一个词频统计(TF),然后用 TF-IDF 排序。STL 的 map 和 accumulate 算法可以帮你轻松计算。试试看?
好了,这个项目就到这里。它虽然简单,但麻雀虽小五脏俱全——哈希表、集合、算法、lambda 全用上了。如果你能自己从头写一遍,STL 的这几个核心组件就算真正掌握了。
公众号:蓝海资料掘金营,微信deep3321