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 核心流程:从文件到查询

整个流程分三步走:

  1. 读取文件:按行读入,存入 textLines
  2. 解析每行:拆出单词,更新 wordLineswordCount
  3. 处理查询:根据查询表达式,对 set<int> 做集合运算。

我习惯把第二步和第三步分开。解析时只做数据填充,查询时只做集合运算。职责单一,后期改起来也爽。

下面这张图把整体架构画清楚了:

文本查询引擎核心流程 文本文件(.txt) 按行读取 → textLines 解析单词 → wordLines + wordCount 逻辑查询 → 集合运算(AND/OR/NOT)

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::isalphastd::tolower。这样 "Hello," 和 "hello" 会被视为同一个词。如果你需要保留数字,把 isalpha 换成 isalnum 就行。

30.5 逻辑查询:集合运算的艺术

查询引擎的核心,说白了就是对 std::set<int> 做交、并、差运算。STL 的 <algorithm> 里直接提供了 set_intersectionset_unionset_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