实战:利用 C++ 编写一个简单的敏感词过滤工具

构建高效敏感词过滤工具:C++实战与深度解析

在当今数字互联的时代,内容安全与合规性变得前所未有的重要。无论是社交媒体平台、在线论坛、电商评论区,还是企业内部的沟通系统,都离不开对用户生成内容(UGC)的有效管理。其中,敏感词过滤是内容审核的第一道防线,它能自动识别并处理不当言论,从而维护平台的健康生态,保护用户免受不良信息的侵害,并规避潜在的法律和声誉风险。

本讲座将带领大家深入探讨如何利用 C++ 语言,从零开始构建一个简单而高效的敏感词过滤工具。我们将从最基础的思路出发,逐步优化数据结构与算法,最终实现一个基于 Trie 树(前缀树)的实用工具。在此过程中,我们将不仅关注代码实现,更会深入剖析其背后的原理、性能考量以及在实际应用中可能遇到的挑战与解决方案。

一、理解敏感词过滤:问题与挑战

在着手构建工具之前,我们首先需要清晰地定义“敏感词过滤”这一问题,并识别其固有的挑战。

1. 什么是敏感词过滤?

敏感词过滤的核心任务是在一段文本中识别出预先定义为“敏感”的词汇或短语,并对其进行相应的处理,例如:

  • 检测 (Detection): 仅仅判断文本中是否存在敏感词。
  • 定位 (Localization): 找出敏感词在文本中的具体位置。
  • 替换/屏蔽 (Replacement/Masking): 将敏感词替换为星号或其他占位符,或直接删除。
  • 阻止 (Blocking): 如果文本包含敏感词,则阻止其发布或提交。

2. 核心需求分析

一个实用的敏感词过滤工具通常需要满足以下基本需求:

  • 高效性: 能够快速地处理大量文本和庞大的敏感词库。
  • 准确性: 能够准确识别敏感词,同时尽量减少误报(将非敏感词识别为敏感词)和漏报(未能识别出敏感词)。
  • 灵活性: 能够方便地添加、删除敏感词。
  • 易用性: 提供简洁的 API 供其他模块调用。
  • 健壮性: 能够处理各种异常情况,如空文本、特殊字符等。

3. 常见挑战

在实际应用中,敏感词过滤面临诸多挑战:

  • 性能瓶颈: 敏感词库可能包含数十万甚至数百万词条,而待检测的文本量更是巨大。如何在这两者之间实现高效匹配是关键。
  • 大小写不敏感: 用户输入往往不区分大小写,工具需要能识别“Sensitive”和“sensitive”。
  • 变体与变形: 用户可能使用谐音、拼音缩写、错别字、符号分隔(如 "S.E.N.S.I.T.I.V.E")、甚至倒序等方式来规避过滤。
  • 多语言支持: 现代应用通常需要处理多种语言的敏感词。
  • 上下文语义: 某些词单独看是敏感的,但在特定语境下却是正常的。例如,“草泥马”是敏感词,但“草”和“泥马”分开则不是。这超出了简单模式匹配的范畴,通常需要结合自然语言处理(NLP)技术。
  • 内存消耗: 庞大的敏感词库可能占用大量内存。

本次实战,我们将重点解决高效性、大小写不敏感以及基础的变体(如连续字符)识别问题,并为更复杂的挑战留下扩展空间。

二、敏感词过滤核心算法与数据结构选型

选择合适的数据结构和算法是构建高效工具的关键。我们将从几种常见的思路进行比较。

1. 朴素字符串查找 (Naive String Search)

最直观的方法是遍历敏感词库中的每一个词,然后使用 std::string::findstrstr 等函数在待检测文本中查找该词。

原理:
对于每个敏感词 $W_i$,在文本 $T$ 中进行一次子串查找。

优点:

  • 实现简单,易于理解。

缺点:

  • 效率极低。如果敏感词库有 $N$ 个词,文本长度为 $M$,每个词平均长度为 $L$,则最坏情况下时间复杂度接近 $O(N times M times L)$。对于大型词库和长文本,这是不可接受的。

示例代码 (概念性):

#include <iostream>
#include <string>
#include <vector>
#include <set> // 假设词库已去重并排序

// 简单的大小写转换函数
std::string to_lower_naive(const std::string& s) {
    std::string lower_s = s;
    for (char& c : lower_s) {
        c = std::tolower(c);
    }
    return lower_s;
}

// 朴素查找的示例函数
bool containsSensitiveWordNaive(const std::string& text, const std::vector<std::string>& sensitiveWords) {
    std::string lower_text = to_lower_naive(text);
    for (const std::string& word : sensitiveWords) {
        std::string lower_word = to_lower_naive(word);
        if (lower_text.find(lower_word) != std::string::npos) {
            return true;
        }
    }
    return false;
}

/*
int main() {
    std::vector<std::string> sensitiveWords = {"badword", "evil", "illegal"};
    std::string text1 = "This is a normal sentence.";
    std::string text2 = "This sentence contains a bAdWoRd.";

    std::cout << "Text 1 contains sensitive word: " << containsSensitiveWordNaive(text1, sensitiveWords) << std::endl;
    std::cout << "Text 2 contains sensitive word: " << containsSensitiveWordNaive(text2, sensitiveWords) << std::endl;

    return 0;
}
*/

2. 使用哈希表/集合 (Hash Table/Set)

将敏感词库存储在 std::unordered_set<std::string> 中,可以实现 O(1) 平均时间复杂度的查找。然而,这只能用于检查一个 完整的单词 是否敏感。对于子串匹配(例如,文本中包含“badword”,而敏感词是“bad”),这种方法需要我们遍历文本的每一个子串,然后检查子串是否在集合中,这同样效率低下。

原理:
将所有敏感词预先插入哈希表。对于待检测文本,生成其所有可能的子串,并查询这些子串是否存在于哈希表中。

优点:

  • 单个单词查找速度快。

缺点:

  • 生成所有子串并查找的开销巨大,实际应用不适合。
  • 主要用于精确匹配,而非子串模式匹配。

示例代码 (概念性):

#include <unordered_set>

// 使用unordered_set进行精确查找
bool isExactSensitiveWord(const std::string& word, const std::unordered_set<std::string>& sensitiveWordsSet) {
    std::string lower_word = to_lower_naive(word); // 假设to_lower_naive已定义
    return sensitiveWordsSet.count(lower_word);
}

// 如果要实现子串查找,依然需要遍历所有子串,效率不高
bool containsSensitiveWordSet(const std::string& text, const std::unordered_set<std::string>& sensitiveWordsSet) {
    std::string lower_text = to_lower_naive(text);
    // 这种方式依然是对每个可能的子串进行查找,效率不高
    for (int i = 0; i < lower_text.length(); ++i) {
        for (int j = i; j < lower_text.length(); ++j) {
            std::string sub = lower_text.substr(i, j - i + 1);
            if (sensitiveWordsSet.count(sub)) {
                return true;
            }
        }
    }
    return false;
}

3. Trie 树(前缀树 / Prefix Tree)

Trie 树是一种用于存储字符串的树形数据结构,其特点是能够最大化地利用字符串的公共前缀来节省存储空间。更重要的是,它能高效地进行前缀查找和多模式匹配。

原理:

  • 结构: 树的每个节点代表一个字符。从根节点到任意一个节点的路径构成一个字符串。
  • 存储: 所有字符串的公共前缀只存储一次。
  • 查找: 沿着字符路径向下遍历,如果到达的某个节点被标记为“词尾”,则表示找到了一个完整的词。

优点:

  • 高效查找: 查找一个词的时间复杂度只与词的长度有关,与词库大小无关 (O(L),L为词长)。
  • 多模式匹配: 在文本中查找多个模式(敏感词)时,Trie 树能将文本的每次遍历与词库的查找操作融合在一起,显著提高效率。
  • 空间优化: 对于具有大量公共前缀的词汇,可以节省存储空间。

缺点:

  • 实现相对复杂。
  • 如果字符集很大(如 Unicode 字符),或者词库中的词汇前缀重复度不高,Trie 树的节点数量会非常庞大,可能导致内存消耗较高。

4. Aho-Corasick 算法

Aho-Corasick 算法是 Trie 树的一种高级变体,它在 Trie 树的基础上增加了“失败指针”(failure links),使得能够在一次文本扫描中同时匹配多个模式。它是多模式匹配问题的最优算法之一。

原理:

  • 构建一个 Trie 树(称为“模式树”或“字典树”)。
  • 为 Trie 树中的每个节点构建一个“失败指针”,指向在模式树中能匹配当前节点最长后缀的节点。
  • 在文本中进行匹配时,如果当前字符无法匹配,则通过失败指针回溯,而无需重新从文本开头或模式开头开始匹配。

优点:

  • 时间复杂度达到最优:O(M + N + K),其中 M 为文本长度,N 为模式总长度,K 为匹配次数。
  • 能够找出文本中所有模式的出现。

缺点:

  • 实现复杂度高。

选型决策:

对于本次“简单敏感词过滤工具”的实战,我们选择 Trie 树 作为核心数据结构。它在性能上比朴素方法有质的飞跃,实现难度适中,且能够清晰地展示多模式匹配的核心思想。Aho-Corasick 算法虽然更优,但其实现复杂性超出了“简单工具”的范畴,我们将在讨论高级优化时提及。

三、基于 Trie 树的敏感词过滤工具设计

确定了使用 Trie 树后,我们来设计工具的类结构和核心方法。

1. TrieNode 结构体设计

Trie 树的每个节点需要存储以下信息:

  • 指向子节点的指针集合:表示当前字符的下一个可能字符。
  • 一个布尔标志:指示从根节点到当前节点形成的路径是否构成一个完整的敏感词。

为了支持中文字符或更广泛的字符集,以及保持通用性,我们选择 std::map<char, TrieNode*> 来存储子节点。对于纯英文小写字母的场景,可以使用 TrieNode* children[26] 数组来优化空间和查找速度。

#include <map>
#include <string>
#include <vector>
#include <algorithm> // For std::tolower
#include <iostream>  // For debug/main
#include <fstream>   // For file operations
#include <set>       // For unique words in findSensitiveWords

// Trie树节点定义
struct TrieNode {
    std::map<char, TrieNode*> children; // 存储子节点,键为字符,值为指向子节点的指针
    bool isEndOfWord;                   // 标记是否为某个敏感词的结尾

    // 构造函数
    TrieNode() : isEndOfWord(false) {}

    // 析构函数:递归释放所有子节点内存,避免内存泄漏
    ~TrieNode() {
        for (auto const& [key, val] : children) {
            delete val; // 递归删除子节点
        }
    }
};

2. SensitiveWordFilter 类设计

我们将把敏感词过滤工具封装为一个类,提供清晰的接口。

class SensitiveWordFilter {
private:
    TrieNode* root; // Trie树的根节点

    // 辅助函数:将字符串转换为小写
    std::string to_lower(const std::string& s) const {
        std::string lower_s = s;
        for (char& c : lower_s) {
            c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
        }
        return lower_s;
    }

public:
    // 构造函数
    SensitiveWordFilter();

    // 析构函数
    ~SensitiveWordFilter();

    // 添加一个敏感词到词库
    void addSensitiveWord(const std::string& word);

    // 从文件中加载敏感词库
    void loadSensitiveWordsFromFile(const std::string& filepath);

    // 检查文本是否包含敏感词
    bool containsSensitiveWord(const std::string& text) const;

    // 找出文本中所有敏感词(返回敏感词列表)
    std::set<std::string> findSensitiveWords(const std::string& text) const;

    // 过滤文本,将敏感词替换为指定字符
    std::string filterText(const std::string& text, char replacementChar = '*') const;
};

3. 核心方法实现

接下来,我们逐一实现 SensitiveWordFilter 类中的关键方法。

a. 构造函数与析构函数

构造函数简单地初始化 Trie 树的根节点。析构函数负责释放根节点及其所有子节点占用的内存。由于 TrieNode 的析构函数已实现递归删除,这里只需删除根节点即可。

// 构造函数:初始化根节点
SensitiveWordFilter::SensitiveWordFilter() {
    root = new TrieNode();
}

// 析构函数:删除根节点,其析构函数会递归删除所有子节点
SensitiveWordFilter::~SensitiveWordFilter() {
    delete root;
}

b. addSensitiveWord:添加敏感词

这个方法负责将一个敏感词插入到 Trie 树中。它会遍历敏感词的每个字符,如果当前字符对应的子节点不存在,则创建新节点,然后向下移动。当所有字符处理完毕后,将最后一个节点标记为 isEndOfWord = true。为了支持大小写不敏感,我们会先将敏感词转换为小写。

// 添加一个敏感词到词库
void SensitiveWordFilter::addSensitiveWord(const std::string& word) {
    std::string lower_word = to_lower(word); // 转换为小写
    TrieNode* curr = root;
    for (char c : lower_word) {
        if (curr->children.find(c) == curr->children.end()) {
            // 如果子节点不存在,则创建新节点
            curr->children[c] = new TrieNode();
        }
        curr = curr->children[c]; // 移动到下一个节点
    }
    curr->isEndOfWord = true; // 标记为词的结尾
}

c. loadSensitiveWordsFromFile:从文件加载敏感词

这个方法允许我们从一个文本文件中批量加载敏感词。假设文件中每行一个敏感词。

// 从文件中加载敏感词库
void SensitiveWordFilter::loadSensitiveWordsFromFile(const std::string& filepath) {
    std::ifstream file(filepath);
    if (!file.is_open()) {
        std::cerr << "Error: Could not open sensitive word file: " << filepath << std::endl;
        return;
    }

    std::string word;
    while (std::getline(file, word)) {
        if (!word.empty()) {
            addSensitiveWord(word); // 添加每个读取到的词
        }
    }
    file.close();
    std::cout << "Loaded sensitive words from: " << filepath << std::endl;
}

d. containsSensitiveWord:检查文本是否包含敏感词

这个方法遍历待检测文本。对于文本中的每一个字符,都尝试从 Trie 树的根节点开始进行匹配。如果匹配到某个敏感词的完整路径,则立即返回 true

// 检查文本是否包含敏感词
bool SensitiveWordFilter::containsSensitiveWord(const std::string& text) const {
    std::string lower_text = to_lower(text); // 转换为小写进行匹配

    // 遍历文本的每一个字符作为敏感词的起始点
    for (int i = 0; i < lower_text.length(); ++i) {
        TrieNode* curr = root;
        // 从当前字符开始,在Trie树中进行匹配
        for (int j = i; j < lower_text.length(); ++j) {
            char c = lower_text[j];
            if (curr->children.count(c)) {
                curr = curr->children.at(c);
                if (curr->isEndOfWord) {
                    return true; // 找到一个敏感词
                }
            } else {
                break; // 当前路径无法匹配,跳出内层循环
            }
        }
    }
    return false; // 遍历完所有可能,未发现敏感词
}

e. findSensitiveWords:找出文本中所有敏感词

此方法与 containsSensitiveWord 类似,但它会继续寻找所有可能的敏感词,并将它们收集起来。为了避免重复添加,我们使用 std::set<std::string> 存储结果。

// 找出文本中所有敏感词(返回敏感词列表)
std::set<std::string> SensitiveWordFilter::findSensitiveWords(const std::string& text) const {
    std::string lower_text = to_lower(text); // 转换为小写进行匹配
    std::set<std::string> foundWords;

    for (int i = 0; i < lower_text.length(); ++i) {
        TrieNode* curr = root;
        // 记录当前匹配到的敏感词的起始索引
        int match_start_idx = i;

        for (int j = i; j < lower_text.length(); ++j) {
            char c = lower_text[j];
            if (curr->children.count(c)) {
                curr = curr->children.at(c);
                if (curr->isEndOfWord) {
                    // 找到一个敏感词,将其子串添加到结果中
                    // 注意:这里取的是原始文本的子串,以保留原始大小写
                    foundWords.insert(text.substr(match_start_idx, j - match_start_idx + 1));
                }
            } else {
                break; // 当前路径无法匹配
            }
        }
    }
    return foundWords;
}

f. filterText:过滤文本并替换敏感词

这是工具的核心功能。它会扫描文本,找出所有敏感词,然后将它们替换为指定的字符(默认为 *)。为了处理重叠的敏感词,我们的策略是:对于文本中的每一个起始位置,都尝试找到从该位置开始的 最长 敏感词。找到后,将其替换,并将文本扫描的指针跳过已替换的区域,以避免重复处理和简化逻辑。

// 过滤文本,将敏感词替换为指定字符
std::string SensitiveWordFilter::filterText(const std::string& inputText, char replacementChar) const {
    std::string text_for_detection = to_lower(inputText); // 用于检测的文本,小写
    std::string result = inputText;                       // 最终输出的文本,保留原始大小写

    for (int i = 0; i < text_for_detection.length(); ++i) {
        TrieNode* curr = root;
        int longest_match_len = 0;      // 记录从当前i位置开始找到的最长敏感词的长度
        int current_match_end_idx = -1; // 记录最长敏感词的结束位置(不包含)

        // 从当前字符i开始,尝试匹配所有可能的敏感词
        for (int j = i; j < text_for_detection.length(); ++j) {
            char c = text_for_detection[j];
            if (curr->children.count(c)) {
                curr = curr->children.at(c);
                if (curr->isEndOfWord) {
                    // 找到了一个敏感词,更新最长匹配信息
                    longest_match_len = (j - i + 1);
                    current_match_end_idx = j + 1;
                }
            } else {
                break; // 无法继续匹配,中断当前路径
            }
        }

        // 如果从i位置开始找到了敏感词
        if (longest_match_len > 0) {
            // 将敏感词在原始结果字符串中替换掉
            for (int k = i; k < current_match_end_idx; ++k) {
                result[k] = replacementChar;
            }
            // 将外层循环的索引i跳过已替换的敏感词部分
            // 这样可以避免对已替换区域的字符再次进行匹配,提高效率
            // 并且确保替换的是最长匹配,避免"abc"和"abcd"同时是敏感词时只替换"abc"
            i = current_match_end_idx - 1; // -1 是因为外层循环还会有一个 i++
        }
    }
    return result;
}

关于 filterText 中重叠词的处理策略:

当前 filterText 的逻辑是,对于文本中的每个起始位置 i,它会尝试找到从 i 开始的 最长 敏感词。一旦找到并替换,它会将 i 跳到这个最长词的末尾之后,从而避免重复处理。

优点:

  • 逻辑相对简单,易于实现。
  • 对于“A”和“AB”都是敏感词,文本是“ABC”的情况,它会优先替换“AB”,这是通常期望的行为。
  • 效率较高,因为每个字符通常只被处理一次。

局限性:

  • 这种策略可能不会捕获所有重叠的敏感词。例如,如果敏感词是 {"apple", "pleas"},文本是 "pineapple"。
    • 从 ‘p’ 开始,它会找到 "apple" (假设 ‘a’ 是敏感词的开头)。
    • 替换 "apple" -> "**apple"
    • i 会跳到 "apple" 之后。
    • "pleas" 将不会被检测到,因为它与 "apple" 重叠。
  • 若要捕获所有重叠词(例如,检测出 "apple" 和 "pleas"),需要更复杂的算法,如 Aho-Corasick,或者在 findSensitiveWords 中收集所有匹配的区间 (start, end),然后对这些区间进行合并或排序,再进行替换。但对于一个“简单”工具而言,当前策略已足够实用。

四、完整代码示例与测试

我们将把上述所有代码组织起来,并编写一个 main 函数进行测试。

1. 准备敏感词文件 sensitive_words.txt

在项目目录下创建 sensitive_words.txt,内容如下:

fuck
shit
badword
illegal
sensitive
damnit
asshole

2. 完整的 main.cpp 文件

#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm> // For std::tolower
#include <fstream>   // For file operations
#include <set>       // For std::set in findSensitiveWords

// Trie树节点定义
struct TrieNode {
    std::map<char, TrieNode*> children; // 存储子节点,键为字符,值为指向子节点的指针
    bool isEndOfWord;                   // 标记是否为某个敏感词的结尾

    // 构造函数
    TrieNode() : isEndOfWord(false) {}

    // 析构函数:递归释放所有子节点内存,避免内存泄漏
    ~TrieNode() {
        for (auto const& [key, val] : children) {
            delete val; // 递归删除子节点
        }
    }
};

// 敏感词过滤器类
class SensitiveWordFilter {
private:
    TrieNode* root; // Trie树的根节点

    // 辅助函数:将字符串转换为小写
    // 使用static_cast<unsigned char>以正确处理负值char(如果char是有符号的)
    std::string to_lower(const std::string& s) const {
        std::string lower_s = s;
        for (char& c : lower_s) {
            c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
        }
        return lower_s;
    }

public:
    // 构造函数
    SensitiveWordFilter();

    // 析构函数
    ~SensitiveWordFilter();

    // 添加一个敏感词到词库
    void addSensitiveWord(const std::string& word);

    // 从文件中加载敏感词库
    void loadSensitiveWordsFromFile(const std::string& filepath);

    // 检查文本是否包含敏感词
    bool containsSensitiveWord(const std::string& text) const;

    // 找出文本中所有敏感词(返回敏感词列表)
    std::set<std::string> findSensitiveWords(const std::string& text) const;

    // 过滤文本,将敏感词替换为指定字符
    std::string filterText(const std::string& text, char replacementChar = '*') const;
};

// ------------------- SensitiveWordFilter 方法实现 -------------------

SensitiveWordFilter::SensitiveWordFilter() {
    root = new TrieNode();
}

SensitiveWordFilter::~SensitiveWordFilter() {
    delete root;
}

void SensitiveWordFilter::addSensitiveWord(const std::string& word) {
    std::string lower_word = to_lower(word);
    TrieNode* curr = root;
    for (char c : lower_word) {
        if (curr->children.find(c) == curr->children.end()) {
            curr->children[c] = new TrieNode();
        }
        curr = curr->children[c];
    }
    curr->isEndOfWord = true;
}

void SensitiveWordFilter::loadSensitiveWordsFromFile(const std::string& filepath) {
    std::ifstream file(filepath);
    if (!file.is_open()) {
        std::cerr << "Error: Could not open sensitive word file: " << filepath << std::endl;
        return;
    }

    std::string word;
    while (std::getline(file, word)) {
        if (!word.empty()) {
            addSensitiveWord(word);
        }
    }
    file.close();
    std::cout << "Loaded sensitive words from: " << filepath << std::endl;
}

bool SensitiveWordFilter::containsSensitiveWord(const std::string& text) const {
    std::string lower_text = to_lower(text);

    for (int i = 0; i < lower_text.length(); ++i) {
        TrieNode* curr = root;
        for (int j = i; j < lower_text.length(); ++j) {
            char c = lower_text[j];
            if (curr->children.count(c)) {
                curr = curr->children.at(c);
                if (curr->isEndOfWord) {
                    return true;
                }
            } else {
                break;
            }
        }
    }
    return false;
}

std::set<std::string> SensitiveWordFilter::findSensitiveWords(const std::string& text) const {
    std::string lower_text = to_lower(text);
    std::set<std::string> foundWords;

    for (int i = 0; i < lower_text.length(); ++i) {
        TrieNode* curr = root;
        int match_start_idx = i;

        for (int j = i; j < lower_text.length(); ++j) {
            char c = lower_text[j];
            if (curr->children.count(c)) {
                curr = curr->children.at(c);
                if (curr->isEndOfWord) {
                    foundWords.insert(text.substr(match_start_idx, j - match_start_idx + 1));
                }
            } else {
                break;
            }
        }
    }
    return foundWords;
}

std::string SensitiveWordFilter::filterText(const std::string& inputText, char replacementChar) const {
    std::string text_for_detection = to_lower(inputText);
    std::string result = inputText;

    for (int i = 0; i < text_for_detection.length(); ++i) {
        TrieNode* curr = root;
        int longest_match_len = 0;
        int current_match_end_idx = -1;

        for (int j = i; j < text_for_detection.length(); ++j) {
            char c = text_for_detection[j];
            if (curr->children.count(c)) {
                curr = curr->children.at(c);
                if (curr->isEndOfWord) {
                    longest_match_len = (j - i + 1);
                    current_match_end_idx = j + 1;
                }
            } else {
                break;
            }
        }

        if (longest_match_len > 0) {
            for (int k = i; k < current_match_end_idx; ++k) {
                result[k] = replacementChar;
            }
            i = current_match_end_idx - 1;
        }
    }
    return result;
}

// ------------------- Main 函数用于测试 -------------------

int main() {
    SensitiveWordFilter filter;

    // 1. 加载敏感词库
    filter.loadSensitiveWordsFromFile("sensitive_words.txt");

    // 2. 动态添加敏感词
    filter.addSensitiveWord("expert");
    filter.addSensitiveWord("programming");
    filter.addSensitiveWord("c++");

    std::cout << "n--- Testing Sensitive Word Filter ---" << std::endl;

    // 测试文本
    std::vector<std::string> testTexts = {
        "Hello, this is a normal sentence.",
        "That's a badword you said!",
        "FUCK this shit, it's totally IlLeGaL.",
        "I am an EXPERT in C++ programming.",
        "Sensitive data here.",
        "Damnit, this is an A$$hole.",
        "Let's talk about c++ programming expert."
    };

    // 测试 containsSensitiveWord
    std::cout << "n--- containsSensitiveWord ---" << std::endl;
    for (const auto& text : testTexts) {
        bool contains = filter.containsSensitiveWord(text);
        std::cout << "Text: "" << text << ""nContains sensitive word: " << (contains ? "Yes" : "No") << std::endl;
    }

    // 测试 findSensitiveWords
    std::cout << "n--- findSensitiveWords ---" << std::endl;
    for (const auto& text : testTexts) {
        std::set<std::string> found = filter.findSensitiveWords(text);
        std::cout << "Text: "" << text << ""nFound words:";
        if (found.empty()) {
            std::cout << " None";
        } else {
            for (const auto& word : found) {
                std::cout << " [" << word << "]";
            }
        }
        std::cout << std::endl;
    }

    // 测试 filterText
    std::cout << "n--- filterText ---" << std::endl;
    for (const auto& text : testTexts) {
        std::string filteredText = filter.filterText(text);
        std::cout << "Original: "" << text << ""nFiltered: "" << filteredText << """ << std::endl;
    }

    std::cout << "n--- Testing with custom replacement char ---" << std::endl;
    std::string customTest = "This is a badword, and another illegal activity.";
    std::string customFiltered = filter.filterText(customTest, '#');
    std::cout << "Original: "" << customTest << ""nFiltered: "" << customFiltered << """ << std::endl;

    std::cout << "n--- Testing overlapping words (longest match strategy) ---" << std::endl;
    filter.addSensitiveWord("apple");
    filter.addSensitiveWord("ple"); // "ple" is a shorter word, but "apple" is longer
    std::string overlapText = "A pineapple is a fruit.";
    std::cout << "Sensitive words added: 'apple', 'ple'" << std::endl;
    std::cout << "Original: "" << overlapText << ""nFiltered: "" << filter.filterText(overlapText) << """ << std::endl;
    // Expected: If 'apple' is found first, 'ple' in 'pineapple' might be missed if it's part of 'apple'.
    // Our current longest match logic will filter 'apple' in 'pineapple' if 'apple' is a sensitive word.
    // If text is "applet", and "apple" is sensitive, it will filter "apple"
    // In "pineapple", if "apple" is sensitive, "apple" will be found starting at index 2, and filtered.
    // The 'ple' in "pineapple" will not be filtered because 'i' advances past 'apple'.

    return 0;
}

编译与运行:

使用 C++ 编译器(如 g++)编译:

g++ main.cpp -o sensitive_filter -std=c++17

运行:

./sensitive_filter

预期输出摘要:

Loaded sensitive words from: sensitive_words.txt

--- Testing Sensitive Word Filter ---

--- containsSensitiveWord ---
Text: "Hello, this is a normal sentence."
Contains sensitive word: No
Text: "That's a badword you said!"
Contains sensitive word: Yes
...

--- findSensitiveWords ---
Text: "Hello, this is a normal sentence."
Found words: None
Text: "That's a badword you said!"
Found words: [badword]
Text: "FUCK this shit, it's totally IlLeGaL."
Found words: [fuck] [illegal] [shit]
...

--- filterText ---
Original: "Hello, this is a normal sentence."
Filtered: "Hello, this is a normal sentence."
Original: "That's a badword you said!"
Filtered: "That's a ******* you said!"
Original: "FUCK this shit, it's totally IlLeGaL."
Filtered: "**** this ****, it's totally *******."
...

--- Testing with custom replacement char ---
Original: "This is a badword, and another illegal activity."
Filtered: "This is a #######, and another ####### activity."

--- Testing overlapping words (longest match strategy) ---
Sensitive words added: 'apple', 'ple'
Original: "A pineapple is a fruit."
Filtered: "A pine***** is a fruit." 
# Explanation: 'apple' is found and replaced. The 'ple' within 'pineapple' is skipped because 'apple' was the longest match.

五、性能考量、优化与进阶讨论

我们已经构建了一个功能性的 Trie 树敏感词过滤工具。但作为一名专家,我们需要进一步探讨其性能、潜在优化以及在真实世界场景中的应用。

1. 性能分析

  • Trie 树构建:

    • 时间复杂度:假设有 $N$ 个敏感词,所有敏感词的总长度为 $L{total}$。构建 Trie 树的时间复杂度为 $O(L{total})$。
    • 空间复杂度:最坏情况下,每个字符都需要一个独立的节点,空间复杂度为 $O(L_{total} times Sigma)$,其中 $Sigma$ 是字符集大小。在我们的实现中,std::map 会增加额外的开销。
  • 敏感词查找/过滤 (containsSensitiveWord, filterText):

    • 时间复杂度:对于长度为 $M$ 的文本,最坏情况下,每个字符都需要从根节点开始遍历到最长敏感词的长度 $L{max}$。因此,时间复杂度约为 $O(M times L{max})$。
    • 在最佳情况下(例如,文本中没有敏感词,或者敏感词很短且不重叠),Trie 树的查找效率接近线性 $O(M)$。

2. 优化方向

a. 内存优化:

  • std::map vs std::unordered_map vs 数组: 我们的实现使用了 std::map<char, TrieNode*>std::map 是基于红黑树实现的,查找时间复杂度为 $O(log Sigma)$,并占用更多内存(每个节点存储红黑树的颜色、父节点等信息)。
    • *`std::unordered_map<char, TrieNode>:** 基于哈希表,平均查找时间复杂度为 $O(1)$,内存开销通常比std::map` 小,但仍有哈希表的额外开销。
    • *`TrieNode children[26]` (或更大固定数组):** 如果字符集是固定且较小(例如,仅英文小写字母),使用固定大小的数组是最高效的方式。查找时间复杂度为 $O(1)$,内存开销固定。对于更大的字符集(如扩展 ASCII 或部分 Unicode),数组可能变得非常大,导致稀疏存储和内存浪费。
  • Trie 节点池: 当大量创建和销毁 TrieNode 对象时,频繁的 newdelete 会带来性能开销和内存碎片。可以实现一个自定义的内存池,预分配一块大内存,然后从中分配 TrieNode 对象。

b. 性能优化 (运行时):

  • Aho-Corasick 算法: 对于需要查找所有匹配项(包括重叠项),并且对性能要求极高的场景,Aho-Corasick 算法是最佳选择。它在构建阶段增加了失败指针,使得文本的遍历只需一次,时间复杂度达到 $O(M + L_{total} + K)$,其中 $K$ 是匹配的总次数。
  • 并行处理: 对于大规模文本数据,可以将文本分割成块,在多个线程或进程中并行进行过滤。每个线程可以拥有独立的 SensitiveWordFilter 实例(只读),或者共享一个全局实例。

c. 字符串处理优化:

  • 字符编码: 我们的工具假设 char 可以直接代表一个字符,这对于 ASCII 字符是没问题的。但对于 UTF-8 编码的中文字符或多字节字符,char 无法直接表示一个字符。需要使用 std::string 的 UTF-8 处理库(如 ICU 库),或者将字符串转换为 std::u32string 来按码点处理。
  • 文本规范化: 在进行过滤前,可以对文本进行一系列预处理,以提高过滤效果和准确性:
    • 去除标点符号和特殊字符: this.is.badword -> thisisbadword
    • 全角半角转换: BADWORD -> BADWORD
    • 同义词/变体替换: 将常见的“错别字”、“拼音缩写”或“谐音字”转换为标准形式。
    • 数字/字母混淆处理: b4dword -> badword

3. 误报与漏报处理

  • 误报 (False Positives): 将正常词识别为敏感词。
    • 解决方案: 精心维护敏感词库,避免添加过于宽泛的词。引入白名单机制,明确指定某些词即使包含敏感片段也是合法的。
    • 词语边界检测: 我们的 Trie 树会匹配子串。如果“ass”是敏感词,那么“grass”中的“ass”也会被匹配。如果需要只匹配完整词,需要在 Trie 节点中增加一个 isWholeWord 标志,并且在匹配时检查前后字符是否为单词边界(空格、标点符号等)。
  • 漏报 (False Negatives): 未能识别出敏感词。
    • 解决方案: 持续更新和扩展敏感词库。结合黑名单(敏感词库)和灰名单(潜在敏感词,需要人工复审)。
    • 模糊匹配/相似度算法: 对于用户故意规避过滤的变体(如 b@d_w0rd),可以引入编辑距离(Levenshtein distance)、Jaccard 相似度等算法,或者使用正则表达式。这会增加计算开销。

4. 真实世界应用场景

  • 内容审核系统: 作为后端服务的一部分,对用户提交的评论、帖子、昵称等进行实时或离线过滤。
  • 即时通讯: 过滤聊天消息中的不当言论。
  • 搜索引擎: 过滤搜索查询中的非法关键词。
  • 文本分析: 识别文本中的特定主题或关键词。

六、展望:从简单工具到企业级解决方案

本次讲座,我们成功地从零开始,利用 C++ 构建了一个基于 Trie 树的敏感词过滤工具。这个工具在处理效率上远超朴素方法,并能有效应对大小写不敏感和子串匹配的挑战。

然而,企业级的敏感词过滤系统远比我们这里实现的要复杂得多。它可能涉及以下高级特性:

  • 动态更新词库: 支持不停机地添加、删除敏感词。
  • 分布式部署: 在大规模并发场景下,通过负载均衡和分布式缓存提供服务。
  • 规则引擎: 支持更复杂的过滤规则,如组合词、上下文敏感词、正则匹配等。
  • 机器学习/深度学习: 结合 NLP 模型来识别更隐晦的、语义相关的敏感内容,而不仅仅是基于词表的匹配。
  • 用户自定义黑白名单: 允许用户或管理员定制过滤规则。
  • 审计与报告: 记录过滤日志,提供统计报告。

本次实战为我们提供了一个坚实的基础,理解了敏感词过滤的核心原理和技术选型。掌握了 Trie 树这种高效的数据结构,你便拥有了应对字符串匹配挑战的强大工具。在此基础上,结合对性能、内存、编码、语义等更深层次的理解,你将能够构建出更加强大和健壮的内容安全解决方案。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注