30、综合实战:基于STL构建一个简易的文本查询引擎(支持单词统计、行号记录、逻辑查询)
学到这里,STL 的各个组件咱们都过了一遍。说实话,光看知识点容易飘,得找个东西串起来。这一章,我们就拿 STL 搭一个真正的玩具——一个文本查询引擎。
别被「引擎」两个字吓到。说白了,就是给一个文本文件,能回答你:某个单词出现在哪些行?出现了几次?还能支持 AND、OR 这样的逻辑组合查询。我当年在公司做日志分析工具时,第一版就是这么干的。
30.1 需求拆解:我们要做什么?
先理清楚目标。一个文本查询引擎,核心就三件事:
- 单词统计:每个单词在全文出现了多少次。
- 行号记录:每个单词出现在哪些行(行号从 1 开始)。
- 逻辑查询:支持查询单词 A AND 单词 B(同时出现)、单词 A OR 单词 B(至少一个出现)、单词 A NOT 单词 B(出现 A 但不出现 B)。
嗯,这里要注意:我们只做精确匹配,不做词干还原。比如 "running" 和 "run" 是两个不同的词。实际项目中要不要做归一化?看场景。我做过一个英文文档检索,当时就踩了没做词干还原的坑,查 "apple" 找不到 "apples"。
30.2 数据结构设计:选对容器,事半功倍
选容器是 STL 编程的第一步。我们来分析一下:
- 单词到行号的映射:一个单词对应多个行号,行号不重复。用
std::map<std::string, std::set<int>>。为什么用 set?因为行号自动排序、去重,查询时直接拿迭代器范围就行。 - 单词到词频的映射:一个单词对应一个整数。用
std::unordered_map<std::string, int>。哈希表查找 O(1),统计时快。 - 原始文本行存储:按行号索引,方便输出上下文。用
std::vector<std::string>。
你想想看,如果不用 set 而用 vector,每次插入行号前还得判断是否重复,多麻烦。STL 帮你选好了轮子,别自己造。
std::map<std::string, std::set<int>> wordLines; —— 单词 → 行号集合std::unordered_map<std::string, int> wordCount; —— 单词 → 出现次数std::vector<std::string> textLines; —— 行号 → 行内容
30.3 核心流程:从文件到查询
整个流程分三步走:
- 读取文件:按行读入,存入
textLines。 - 解析每行:拆出单词,更新
wordLines和wordCount。 - 处理查询:根据查询表达式,对
set<int>做集合运算。
我习惯把第二步和第三步分开。解析时只做数据填充,查询时只做集合运算。职责单一,后期改起来也爽。
下面这张图把整体架构画清楚了:
30.4 代码实现:一步一步来
先写文件读取和解析部分。我习惯把解析逻辑封装成一个类,叫 TextQuery。
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <map>
#include <set>
#include <unordered_map>
#include <algorithm>
class TextQuery {
public:
// 读取文件并构建索引
void loadFile(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
throw std::runtime_error("无法打开文件: " + filename);
}
std::string line;
int lineNum = 0;
while (std::getline(file, line)) {
textLines.push_back(line);
++lineNum;
parseLine(line, lineNum);
}
}
// 获取某个单词的行号集合
std::set<int> getLines(const std::string& word) const {
auto it = wordLines.find(word);
if (it != wordLines.end()) {
return it->second;
}
return {};
}
// 获取单词出现次数
int getCount(const std::string& word) const {
auto it = wordCount.find(word);
if (it != wordCount.end()) {
return it->second;
}
return 0;
}
// 获取某行的文本内容
std::string getText(int lineNum) const {
if (lineNum >= 1 && lineNum <= static_cast<int>(textLines.size())) {
return textLines[lineNum - 1];
}
return "";
}
private:
void parseLine(const std::string& line, int lineNum) {
std::istringstream iss(line);
std::string word;
while (iss >> word) {
// 简单清洗:转小写,去标点
std::string cleaned;
for (char ch : word) {
if (std::isalpha(ch)) {
cleaned += std::tolower(ch);
}
}
if (!cleaned.empty()) {
wordLines[cleaned].insert(lineNum);
wordCount[cleaned]++;
}
}
}
std::vector<std::string> textLines;
std::map<std::string, std::set<int>> wordLines;
std::unordered_map<std::string, int> wordCount;
};
std::isalpha 和 std::tolower。这样 "Hello," 和 "hello" 会被视为同一个词。如果你需要保留数字,把 isalpha 换成 isalnum 就行。
30.5 逻辑查询:集合运算的艺术
查询引擎的核心,说白了就是对 std::set<int> 做交、并、差运算。STL 的 <algorithm> 里直接提供了 set_intersection、set_union、set_difference。
我曾经在写一个日志过滤工具时,手动用循环做集合交集,写了一堆 bug。后来发现 STL 已经封装好了,直接调用,又快又稳。
class QueryEngine {
public:
QueryEngine(const TextQuery& tq) : textQuery(tq) {}
// 单词查询
std::set<int> wordQuery(const std::string& word) const {
return textQuery.getLines(word);
}
// AND 查询:两个单词同时出现的行
std::set<int> andQuery(const std::string& word1, const std::string& word2) const {
auto set1 = textQuery.getLines(word1);
auto set2 = textQuery.getLines(word2);
std::set<int> result;
std::set_intersection(set1.begin(), set1.end(),
set2.begin(), set2.end(),
std::inserter(result, result.begin()));
return result;
}
// OR 查询:至少一个单词出现的行
std::set<int> orQuery(const std::string& word1, const std::string& word2) const {
auto set1 = textQuery.getLines(word1);
auto set2 = textQuery.getLines(word2);
std::set<int> result;
std::set_union(set1.begin(), set1.end(),
set2.begin(), set2.end(),
std::inserter(result, result.begin()));
return result;
}
// NOT 查询:出现 word1 但不出现 word2 的行
std::set<int> notQuery(const std::string& word1, const std::string& word2) const {
auto set1 = textQuery.getLines(word1);
auto set2 = textQuery.getLines(word2);
std::set<int> result;
std::set_difference(set1.begin(), set1.end(),
set2.begin(), set2.end(),
std::inserter(result, result.begin()));
return result;
}
private:
const TextQuery& textQuery;
};
std::set_intersection 等算法要求输入集合必须是有序的。我们用了 std::set,天然有序,没问题。如果你用 std::unordered_set,那就得先排序,否则结果会乱。
30.6 输出结果:让查询看得见
光有集合还不够,得把结果展示出来。我习惯写一个辅助函数,打印每个匹配行的行号和内容。
void printResult(const TextQuery& tq, const std::set<int>& lines,
const std::string& queryDesc) {
std::cout << "=== " << queryDesc << " ===\n";
std::cout << "匹配行数: " << lines.size() << "\n";
for (int lineNum : lines) {
std::cout << "行 " << lineNum << ": "
<< tq.getText(lineNum) << "\n";
}
std::cout << "\n";
}
你看,整个查询引擎的核心代码不到 200 行。STL 帮我们处理了排序、去重、集合运算这些脏活累活。你想想看,如果手写红黑树、手写哈希表,那得多少行?
30.7 避坑指南:我踩过的几个坑
- 空行处理:空行也会被
getline读入,行号会递增。如果你不想统计空行,可以在parseLine里判断line.empty()然后跳过。 - 大小写敏感:我一开始没做转小写,结果查 "The" 和 "the" 返回不同结果。用户反馈说体验很差。后来统一转小写,世界清净了。
- 标点符号:单词后面跟着逗号、句号,如果不处理,"hello," 和 "hello" 会被当成两个词。用
isalpha过滤一下就好。 - 大文件性能:如果文件有几百万行,
std::map的插入会变慢。我后来换成了std::unordered_map,词频统计快了一个数量级。但行号集合还是用std::set,因为需要有序。
30.8 扩展思路:还能怎么玩?
这个引擎虽然简单,但扩展性不错。你可以:
- 支持短语查询(比如 "hello world" 作为一个整体)。需要把连续两个单词作为一个 key。
- 支持通配符查询(比如 "hel*" 匹配所有以 hel 开头的单词)。遍历
wordLines的 key 做前缀匹配就行。 - 支持查询结果缓存。如果同一个查询反复执行,把结果
set<int>缓存起来,避免重复计算。
我个人觉得,这个实战最大的价值不是代码本身,而是让你体会到:STL 的容器和算法,组合起来能解决多少实际问题。你不需要重复造轮子,只需要把轮子拼对位置。
公众号:蓝海资料掘金营,微信deep3321