30. STL 实战项目二:基于 STL 的简易文本搜索引擎

终于到了最后一个实战项目。说实话,每次带新人做这个项目,我都会想起自己刚入行时写的一个蹩脚搜索工具——那时候还不知道用 unordered_map,硬是用 vector 加二分查找拼了一个,性能惨不忍睹。后来重构时换成哈希表,速度直接起飞。

今天这个项目,我们做一个简易的文本搜索引擎。它不复杂,但足够让你把 unordered_mapset、算法和 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_mapoperator[] 行为——我当年第一次用的时候还担心会崩溃,其实它很安全。

小技巧:如果你不想让不存在的 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 知识体系总览

下面这张图把整个项目的核心逻辑串起来了:

简易文本搜索引擎核心流程 文本文件输入 分词 + 清洗(lambda) istringstream / copy_if / transform 倒排索引构建 unordered_map<string, set<int>> 查询 + 交集 + 排序 set_intersection / sort + lambda

从输入到输出,每一步都对应着 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 的 mapaccumulate 算法可以帮你轻松计算。试试看?

好了,这个项目就到这里。它虽然简单,但麻雀虽小五脏俱全——哈希表、集合、算法、lambda 全用上了。如果你能自己从头写一遍,STL 的这几个核心组件就算真正掌握了。


公众号:蓝海资料掘金营,微信deep3321