IM敏感詞演算法原理和實現

ByteCode位元組碼聯盟發表於2021-08-24

 

效果

C++實現的程式碼請移步:

 

用法和效果:

int main() {
    std::vector<std::string> words = {
            // 字母
            "FUCK",     // 全大寫
            "FuCk",     // 混合
            "F&uc&k",   // 特殊符號
            "F&uc&&&k",  // 連續特殊符號
            "FUck",   // 全形大小寫混合
            "F。uc——k",  // 全形特殊符號
            "fUck",    // 全形半形混合
            "fU?ck",  // Emotion表情,測試 

            // 簡體中文
            "微信",
            "微——信",   // 全形符號
            "微【】、。?《》信", // 全形重複詞
            "微。信",
            "VX",
            "vx", // 小寫
            "V&X", // 特殊字元
            "微!., #$%&*()|?/@\"';[]{}+~-_=^<>信", // 30個特殊字元 遞迴
            "扣扣",
            "扣_扣",
            "QQ",
            "Qq",
    };

    Trie trie;
    trie.loadFromFile("word.txt");
    trie.loadStopWordFromFile("stopwd.txt");

    for (auto &item : words) {
        auto t1 = std::chrono::steady_clock::now();

        std::wstring result = trie.replaceSensitive(SBCConvert::s2ws(item));

        auto t2 = std::chrono::steady_clock::now();
        double dr_ms = std::chrono::duration<double, std::milli>(t2 - t1).count();
        std::cout << "[cost: " << dr_ms << " ms]" << item << " => " << SBCConvert::ws2s(result) << std::endl;
    }
    return 0;
}

  

load 653 words
load 46 stop words
[cost: 0.011371 ms]FUCK => ****
[cost: 0.003244 ms]FuCk => ****
[cost: 0.007321 ms]F&uc&k => ******
[cost: 0.005006 ms]F&uc&&&k => ********
[cost: 0.003155 ms]FUck => ****
[cost: 0.030613 ms]F。uc——k => F。uc——k
[cost: 0.006558 ms]fUck => ****
[cost: 0.006181 ms]fU?ck => *****
[cost: 0.00511 ms]微信 => **
[cost: 0.006742 ms]微——信 => 微——信
[cost: 0.012082 ms]微【】、。?《》信 => *********
[cost: 0.004329 ms]微。信 => ***
[cost: 0.004665 ms]VX => **
[cost: 0.003428 ms]vx => **
[cost: 0.005998 ms]V&X => ***
[cost: 0.031304 ms]微!., #$%&*()|?/@"';[]{}+~-_=^<>信 => ********************************
[cost: 0.004827 ms]扣扣 => **
[cost: 0.00585 ms]扣_扣 => ***
[cost: 0.00435 ms]QQ => **
[cost: 0.00346 ms]Qq => **

  

引言

很早之前就打算做這一塊,剛好最近有時間研究一下。網上一般都能找到很多資料,這裡簡單說一下我的理解吧。

PS:手機號匹配使用正規表示式,不屬於敏感詞範疇,請注意。

為了遮蔽一些黃牛推銷廣告,類似QQ、微信、手機號、……等等,我們希望都能替換為*號。這裡為了簡單起見,以微信舉例(並不是歧視),我們會遇到以下幾種情況:

  • 中文
    • 簡體字:微信
    • 繁體字:微信
    • 火星文(變形或者諧音):嶶信、威信
  • 中間帶特殊符號
    • 半形特殊符號(ASCII以內) :*&! #@(){}[] 等等,如微 信,微&&信,微_信
    • 全形(ASCII以外):中文的標點符號,一些emotion表情等,如微——信,微?信。
  • 首字母縮寫
    • 半形:如VX、WX。
    • 全形:vx、Wx。
  • ……

變化和組合確實太多了,所以我們在實現的時候需要有所取捨。如果我們要過濾簡體字、半形特殊符號和首字母縮寫這三種情況的敏感詞,那要怎麼處理呢?

字串查詢KMP

如果不考慮效能,直接使用string自帶的contians()函式,有多少個敏感詞做多少次的contains檢測即可。但是如果要過濾的文字有幾千字,敏感詞有上萬個,這個方案肯定是不合適的。

看一下trie樹是如何解決這個問題的。

Trie樹

Trie樹,即字典樹字首樹,是一種樹形結構。廣泛應用於統計和排序大量的字串(但不僅限於字串),所以經常被搜尋引擎系統用於文字詞頻統計。它的優點是最大限度地減少無謂的字串比較,查詢效率比較高

Trie的核心思想是空間換時間,利用字串的公共字首來降低查詢時間的開銷以達到提高效率的目的

在這裡插入圖片描述

Trie樹的基本性質

  1. 根節點不包含字元,除根節點外每一個節點都只包含一個字元;
  2. 從根節點到某一節點,路徑上經過的字元連線起來,為該節點對應的字串;
  3. 每個節點的所有子節點包含的字元都不相同
  4. 從第一字元開始有連續重複的字元只佔用一個節點,比如上面的 to 和 ten,中重複的單詞 t 只佔用了一個節點。

所以每個節點都應該有2個欄位:

  1. end_flag標誌:是否結束,即為葉子節點。
  2. map<key, value>:存放當前節點的值和子節點的值。

基礎入門

先來看一個題目: LeetCode 208. 實現 Trie (字首樹)

實現一個 Trie (字首樹),包含 insert, search, 和 startsWith 這三個操作。 示例:

Trie trie = new Trie();
trie.insert("apple");
trie.search("apple");   // 返回 true
trie.search("app");     // 返回 false
trie.startsWith("app"); // 返回 true
trie.insert("app");   
trie.search("app");     // 返回 true

說明:

  • 你可以假設所有的輸入都是由小寫字母 a-z 構成的。
  • 保證所有輸入均為非空字串。

分析與實現

我們先來把它畫成一個trie樹(以題目中的apple和app2個單詞為例):

在這裡插入圖片描述

 

通過insert(”apple“)insert("app“)2次函式呼叫,我們構建瞭如上圖所示的trie樹。我們發現:

  • apple和app共用1個字首,沒有多餘的節點建立,只是多了一個#結束節點。
  • 有2個#節點,只要遇到#就代表本次匹配結束,有多少個關鍵詞就應該有多少個#節點。

當然,增加結束節點的另外一個好處:我們可以判斷是startsWith還是equal

資料結構定義

因為root不包含字元,所以可以分別定義一個TrieNode代表節點,Trie代表樹。

TrieNode

#include <string>
#include <unordered_map>

// 代表一個節點
class TrieNode {
public:
  // 新增子節點
  void addSubNode(const char &c, TrieNode *subNode) { 
    subNodes_[c] = subNode; 
  }

  // 獲取子節點
  TrieNode *getSubNode(const char &c) { 
    return subNodes_[c];
  }

private:
  // 子節點字典(key是下級字元,value是下級節點)
  std::unordered_map<char, TrieNode *> subNodes_;
};

 

Trie

// 代表一顆Trie樹
class Trie {
public:
  // Inserts a word into the trie.
  void insert(std::string word);

  // Returns if the word is in the trie.
  bool search(std::string word);

  // Returns if there is any word in the trie that starts with the given prefix.
  bool startsWith(std::string prefix);
  
private:
  TrieNode *root_; // root節點,不儲存字元
};

 

insert實現

裡面有一個root欄位,代表根節點,再看一下insert操作:

void Trie::insert(std::string word) {
  TrieNode *curNode = root_;
  // 遍歷字串,字元作為key(注意中文一般有3個位元組,所以會變成3個節點,但是不影響匹配)
  for (int i = 0; i < word.length(); i++) {
    char c = word[i];
    TrieNode *subNode = curNode->getSubNode(c);

    // 如果沒有這個節點則新建
    if (subNode == nullptr) {
      subNode = new TrieNode();
      curNode->addSubNode(c, subNode);
    }
    // 下鑽,指向子節點
    curNode = subNode;
  }
  // 設定結束標識
  curNode->addSubNode(‘#’, new TrieNode());
}

  

search實現

bool Trie::search(std::string word) {
  TrieNode *curNode = root_;
  for (int i = 0; i < word.length(); i++) {
    curNode = curNode->getSubNode(word[i]);
    if (curNode == nullptr)
      return false;
  }
  return curNode->getSubNode('#') != nullptr;
}

  

startsWith實現

bool Trie::startsWith(std::string prefix) {
  TrieNode *curNode = root_;
  for (int i = 0; i < prefix.length(); i++) {
    curNode = curNode->getSubNode(prefix[i]);
    if (curNode == nullptr)
      return false;
  }
  // 和search的區別就在於這裡,不判斷下一個節點是否是結束節點
  return true;
}

  

如果執行,會輸出如下結果:

int main() {
  Trie t;
  t.insert("apple");
  printf("%d \n", t.search("apple"));   // 返回 true
  printf("%d \n", t.search("app"));     // 返回 false
  printf("%d \n", t.startsWith("app")); // 返回 true
  t.insert("app");
  printf("%d \n", t.search("app"));     // 返回 true
  printf("%d \n", t.search("this is apple")); // 返回 false,為什麼?
}

  

$ ./main 
1 
0 
1 
1 
0 

  

所以,我們會發現一個問題,最後一個“this is apple”明明包含“apple”,為什麼還是返回false?

其實很簡單,上面都是預設敏感詞在整個字串開始位置,我們只需要增加一個指標,不斷往後搜尋即可。

敏感詞搜尋

在這裡插入圖片描述

思路:

  1. 首先 p1 指標指向 root,指標 p2 和 p3 指向字串中的第一個字元。
  2. 演算法從字元 t 開始,檢測有沒有以 t 作為字首的敏感詞,在這裡就直接判斷 root 中有沒有 t 這個子節點即可。這裡沒有,所以將 p2 和 p3 同時右移。
  3. 一直移動p2和p3,發現存在以 a 作為字首的敏感詞,那麼就只右移 p3 繼續判斷 p2 和 p3 之間的這個字串是否是敏感詞(當然要判斷是否完整)。如果在字串中找到敏感詞,那麼可以用其他字串如 *** 代替。接下來不斷迴圈直到整個字串遍歷完成就可以了。

程式碼實現如下:

bool Trie::search(std::string word) {
  // TrieNode *curNode = root_;
  // for (int i = 0; i < word.length(); i++) {
  //   curNode = curNode->getSubNode(word[i]);
  //   if (curNode == nullptr)
  //     return false;
  // }
  // return curNode->getSubNode(kEndFlag) != nullptr;

  // 轉換成小寫
  transform(word.begin(), word.end(), word.begin(), ::tolower);
  bool is_contain = false;
  for (int p2 = 0; p2 < word.length(); ++p2) {
    int wordLen = getSensitiveLength(word, p2);
    if (wordLen > 0) {
      is_contain = true;
      break;
    }
  }
  return is_contain;
}

// 這裡增加一個函式,返回敏感詞的位置和長度,便於替換或者檢測邏輯
int Trie::getSensitiveLength(std::string word, int startIndex) {
  TrieNode *p1 = root_;
  int wordLen = 0;
  bool endFlag = false;

  for (int p3 = startIndex; p3 < word.length(); ++p3) {
    const char &cur = word[p3];
    auto subNode = p1->getSubNode(cur);
    if (subNode == nullptr) {
      break;
    } else {
      ++wordLen;
      // 直到找到尾巴的位置,才認為完整包含敏感詞
      if (subNode->getSubNode(kEndFlag)) {
        endFlag = true;
        break;
      } else {
        p1 = subNode;
      }
    }
  }

  // 注意,處理一下沒找到尾巴的情況
  if (!endFlag) {
    wordLen = 0;
  }
  return wordLen;
}

  

關於時間複雜度:

  • 構建敏感詞的時間複雜度是可以忽略不計的,因為構建完成後我們是可以無數次使用的。
  • 如果字串的長度為 n,則每個敏感詞查詢的時間複雜度為 O(n)

效能測試

為了方便測試,我們再增加一個函式:

std::set<SensitiveWord> Trie::getSensitive(std::string word) {
  // 轉換成小寫
  transform(word.begin(), word.end(), word.begin(), ::tolower);
  std::set<SensitiveWord> sensitiveSet;

  for (int i = 0; i < word.length(); ++i) {
    int wordLen = getSensitiveLength(word, i);
    if (wordLen > 0) {
      // 記錄找到的敏感詞的索引和長度
      std::string sensitiveWord = word.substr(i, wordLen);
      SensitiveWord wordObj;
      wordObj.word = sensitiveWord;
      wordObj.startIndex = i;
      wordObj.len = wordLen;

      // 插入到set集合中返回
      sensitiveSet.insert(wordObj);
      i = i + wordLen - 1;
    }
  }

  return sensitiveSet;
}

  

測試程式碼如下:

void test_time(Trie &t) {
  auto t1 = std::chrono::steady_clock::now();
  auto r = t.getSensitive("SHit,你你你你是傻逼啊你,說你呢,你個大笨蛋。");
  for (auto &&i : r) {
    std::cout << "[index=" << i.startIndex << ",len=" << i.len
              << ",word=" << i.word << "],";
  }
  std::cout << std::endl;

  // run code
  auto t2 = std::chrono::steady_clock::now();
  //毫秒級
  double dr_ms = std::chrono::duration<double, std::milli>(t2 - t1).count();
  std::cout << "耗時(毫秒): " << dr_ms << std::endl;
}

int main() {
  Trie t;

  t.insert("你是傻逼");
  t.insert("你是傻逼啊");
  t.insert("你是壞蛋");
  t.insert("你個大笨蛋");
  t.insert("我去年買了個表");
  t.insert("shit");

  test_time(t);
}

  

輸出:

$ ./main 
[index=0,len=4,word=shit],[index=16,len=12,word=你是傻逼],[index=49,len=15,word=你個大笨蛋],
耗時(毫秒): 0.093765

  

我這邊比較好奇,看一下string自帶的find函式實現的版本:

void test_time_by_find() {
  auto t1 = std::chrono::steady_clock::now();
  std::string origin = "SHit,你你你你是傻逼啊你,說你呢,你個大笨蛋。";
  std::vector<std::string> words;
  words.push_back("你是傻逼");
  words.push_back("你是傻逼啊");
  words.push_back("你是壞蛋");
  words.push_back("你個大笨蛋");
  words.push_back("我去年買了個表");
  words.push_back("shit");

  for (auto &&i : words) {
    size_t n = origin.find(i);
    if (n != std::string::npos) {
      std::cout << "[index=" << n << ",len=" << i.length() << ",word=" << i
                << "],";
    }
  }
  std::cout << std::endl;

  // run code
  auto t2 = std::chrono::steady_clock::now();
  //毫秒級
  double dr_ms = std::chrono::duration<double, std::milli>(t2 - t1).count();
  std::cout << "耗時(毫秒): " << dr_ms << std::endl;
}

  

輸出:

$ $ ./main 
[index=0,len=4,word=shit],[index=16,len=12,word=你是傻逼],[index=49,len=15,word=你個大笨蛋],
耗時(毫秒): 0.113505
[index=16,len=12,word=你是傻逼],[index=16,len=15,word=你是傻逼啊],[index=49,len=15,word=你個大笨蛋],[index=0,len=4,word=shit],
耗時(毫秒): 0.021829

  

上面那個是trie演算法實現的,耗時0.113505毫秒,下面是string的find版本,耗時0.021829毫秒,還快了5倍?這是為什麼?

中文替換為*的實現

通過 getSensitive() 函式,我們得到了敏感詞出現的位置和長度,那要怎麼替換成*號呢?

SHit,你你你你是傻逼啊你,說你呢,你個大笨蛋。

  

int main() {
    Trie t;
    t.insert("你是傻逼");
    t.insert("你個大笨蛋");
    t.insert("shit");

    std::string origin = "SHit,你你你你是傻逼啊你,說你呢,你個大笨蛋。";
    auto r = t.getSensitive(origin);

    for (auto &&i : r) {
        std::cout << "[index=" << i.startIndex << ",len=" << i.len
                  << ",word=" << i.word.c_str() << "]," << std::endl;
    }

    std::cout << t.replaceSensitive(origin) << std::endl;

    return 0;
}

  

執行後我們得到了一組敏感詞的資訊,包含起始位置,長度:

[index=0,len=4,word=shit],
[index=16,len=12,word=你是傻逼],
[index=49,len=15,word=你個大笨蛋],

  

這裡有個問題,因為Linux下使用utf8,1個漢字實際佔用3個位元組,這也就導致,我們如果直接遍歷進行替換,會發現*多出了2倍。

# 錯誤,漢字的部分*號翻到3倍
****,你你你************啊你,說你呢,***************。

# 期望
****,你你你****啊你,說你呢,*****。

  

在解決這個問題之前,我們先來了解一下Unicode、UTF8和漢字的關係。

Unicode編碼

Unicode( 統一碼、萬國碼、單一碼)是電腦科學領域裡的一項業界標準,包括字符集、編碼方案等。Unicode 是為了解決傳統的字元編碼方案的侷限而產生的,它為每種語言中的每個字元設定了統一併且唯一的 二進位制編碼,以滿足跨語言、跨平臺進行文字轉換、處理的要求。1990年開始研發,1994年正式公佈。

我們來看一下It's 知乎日報的Unicode編碼是怎麼樣的?

I 0049
t 0074
' 0027
s 0073
  0020
知 77e5
乎 4e4e
日 65e5
報 62a5

  

每一個字元對應一個十六進位制數字。

計算機只懂二進位制,因此,嚴格按照unicode的方式(UCS-2),應該這樣儲存:

I 00000000 01001001
t 00000000 01110100
' 00000000 00100111
s 00000000 01110011
  00000000 00100000
知 01110111 11100101
乎 01001110 01001110
日 01100101 11100101
報 01100010 10100101

  

這個字串總共佔用了18個位元組,但是對比中英文的二進位制碼,可以發現,英文前9位都是0!浪費啊,浪費硬碟,浪費流量。怎麼辦?UTF。

UTF8

UTF-8(8-bit Unicode Transformation Format)是一種針對Unicode的可變長度字元編碼,也是一種字首碼,又稱萬國碼。由Ken Thompson於1992年建立。它可以用來表示Unicode標準中的任何字元,且其編碼中的第一個位元組仍與ASCII相容,這使得原來處理ASCII字元的軟體無須或只須做少部份修改,即可繼續使用。因此,它逐漸成為電子郵件、網頁及其他儲存或傳送文字的應用中,優先採用的編碼。

UTF-8是這樣做的:

  1. 單位元組的字元,位元組的第一位設為0,對於英語文字,UTF-8碼只佔用一個位元組,和ASCII碼完全相同

  2. n個位元組的字元(n>1),第一個位元組的前n位設為1,第n+1位設為0,後面位元組的前兩位都設為10,這n個位元組的其餘空位填充該字元unicode碼,高位用0補足

這樣就形成了如下的UTF-8標記位:

0xxxxxxx
110xxxxx 10xxxxxx
1110xxxx 10xxxxxx 10xxxxxx
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
... ...

  

於是,”It's 知乎日報“就變成了:

I 01001001
t 01110100
' 00100111
s 01110011
  00100000
知 11100111 10011111 10100101
乎 11100100 10111001 10001110
日 11100110 10010111 10100101
報 11100110 10001010 10100101

  

和上邊的方案對比一下,英文短了,每個中文字元卻多用了一個位元組。但是整個字串只用了17個位元組,比上邊的18個短了一點點。

部分漢字編碼範圍

Unicode符號範圍(十六進位制)UTF-8編碼(二進位制)
0000 0000-0000 007F(1個位元組) 0xxxxxxx
0000 0080-0000 07FF(2個位元組) 110xxxxx 10xxxxxx
0000 0800-0000 FFFF(3個位元組) 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF(4個位元組) 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

以漢字“嚴”為例,演示如何實現UTF-8編碼。

已知“嚴”的unicode是4E25(100111000100101),根據上表,可以發現 4E25處在第三行的範圍內(0000 0800-0000 FFFF),因此“嚴”的UTF-8編碼需要三個位元組,即格式是“1110xxxx 10xxxxxx 10xxxxxx”。

然後,從“嚴”的最後一個二進位制位開始,依次從後向前填入格式中的x,多出的位補0。這樣就得到了,“嚴”的UTF-8編碼是 “11100100 10111000 10100101”,轉換成十六進位制就是E4B8A5。

100   111000   100101
1110xxxx 10xxxxxx 10xxxxxx # 從後往前放
1110x100 10111000 10100101 # 多出的x補0
11100100 10111000 10100101

  

實現漢字替換為*

方法一:通過判斷中文和佔用位元組數手動處理

/** @fn
  * @brief linux下一個中文佔用三個位元組,windows佔兩個位元組
  * 參考:https://blog.csdn.net/taiyang1987912/article/details/49736349
  * @param [in]str: 字串
  * @return
  */
std::string chinese_or_english_append(const std::string &str) {
    std::string replacement;
    //char chinese[4] = {0};
    int chinese_len = 0;
    for (int i = 0; i < str.length(); i++) {
        unsigned char chr = str[i];
        int ret = chr & 0x80;
        if (ret != 0) { // chinese: the top is 1
            if (chr >= 0x80) {
                if (chr >= 0xFC && chr <= 0xFD) {
                    chinese_len = 6;
                } else if (chr >= 0xF8) {
                    chinese_len = 5;
                } else if (chr >= 0xF0) {
                    chinese_len = 4;
                } else if (chr >= 0xE0) {
                    chinese_len = 3;
                } else if (chr >= 0xC0) {
                    chinese_len = 2;
                } else {
                    throw std::exception();
                }
            }
            // 跳過
            i += chinese_len - 1;

            //chinese[0] = str[i];
            //chinese[1] = str[i + 1];
            //chinese[2] = str[i + 2];
        } else /** ascii **/ {
        }
        replacement.append("*");
    }
    return replacement;
}

std::string Trie::replaceSensitive(const std::string &word) {
    std::set<SensitiveWord> words = getSensitive(word);
    std::string ret;
    int last_index = 0;
    for (auto &item : words) {
        std::string substr = word.substr(item.startIndex, item.len);
        std::string replacement = chinese_or_english_append(substr);

        // 原始內容
        ret.append(word.substr(last_index, item.startIndex - last_index));

        // 替換內容
        ret.append(replacement);
        last_index = item.startIndex + item.len;
    }

    // append reset
    ret.append(word.substr(last_index, word.length() - last_index));

    return ret;
}

  

方法二(推薦):使用wstring代替string。遍歷wstring時,item為wchar_t型別(4個位元組),直接使用wchar_t作為unordered_map的key,這樣無須特殊處理中文替換為*的問題。

停頓詞實現

遇到特殊字元,比如微&信微-信微&&信如何繼續識別?其實很簡單,直接忽略該詞往下查詢即可:

int Trie::getSensitiveLength(std::string word, int startIndex) {
    TrieNode *p1 = root_;
    int wordLen = 0;
    bool endFlag = false;

    for (int p3 = startIndex; p3 < word.length(); ++p3) {
        const char &cur = word[p3];
        auto subNode = p1->getSubNode(cur);
        if (subNode == nullptr) {
        		// 遇到停頓詞,跳過該詞繼續搜尋字串
            if (cur == '&' ||
                cur == '-'||
                cur == ' ') {
                ++wordLen;
                continue;
            }

            break;
        } else {
            // ...
        }
    }

	  // ...
}

  

全形和半形實現

參考:C/C++ -- 判斷字串中存在中文sensitivewd-filter

除了特殊符號之外,還有一種特殊情況會使我們的過濾演算法失效,那就是全形(除ASCII碼之外的),如下:

  • 字母全形:
    • 大寫的:ABCDEFG
    • 小寫的:abcdefg
  • 符號:
    • 中文標點:【】、。?!()……——;‘等等

在遊戲中,我們也能經常看見使用全形繞過敏感詞過濾的現象,比如混用半形和全形:Fuck,V【信等,那要怎麼處理呢?

實際上,類似大小寫處理一樣,我們只需要把全形字母和一些中文符號轉換成對應的半形即可。其他非常規的全形特殊符號,直接在停頓詞裡面增加即可。

在這裡插入圖片描述 在這裡插入圖片描述

通過查閱上述的ASCII編碼表和Unicode編碼表,我們發現他們存在一定的對應關係:

ASCIIUNICODE
! (0x21) !(0xFF01)
"(0x22) “(0xFF02)
#(0x23) #(0xFF03)
... ...
A(0x41) A(0xFF21)
B(0x42) B(0xFF22)
... ...
~(0x7E) ~(0xFF5E)

ASCII碼 !到~之間的字元,和Unicode碼中 0xFF01到0xFF5E字元 一一對應 ,所以全形轉換到半形只需要減去一個固定的偏移即可。

const wchar_t kSBCCharStart = 0xFF01; // 全形!
const wchar_t kSBCCharEnd = 0xFF5E; // 全形~
// 全形空格的值,它沒有遵從與ASCII的相對偏移,必須單獨處理
const wchar_t kSBCSpace = 0x508;    // 全形空格

// ASCII表中除空格外的可見字元與對應的全形字元的相對偏移
const wchar_t kConvertStep = kSBCCharEnd - kDBCCharEnd;

int SBCConvert::qj2bj(const wchar_t &src) {
    // 偏移,轉換到對應ASCII的半形即可
    if (src >= kSBCCharStart && src <= kSBCCharEnd) {
        return (wchar_t) (src - kConvertStep);
    } else if (src == kSBCSpace) { // 如果是全形空格
        return kDBCSpace;
    }
    return src;
}

  

上面為什麼返回一個int值呢?

其實我們所看到的文字,背後的原理就是一串數字(Unicode編碼)而已

// Linux 環境下,string是utf8編碼
int main() {
    std::wstring str = L"嚴~A";
    for (wchar_t &item : str) {
        int unicode = static_cast<int>(item);
        std::cout << std::hex << unicode << std::endl;
    }
    return 0;
}

  

4e25
ff5e
41

  

同樣,也可以轉回去:

#include <string>
#include <locale>
#include <codecvt>

std::string ws2s(const std::wstring &wstr) {
    using convert_typeX = std::codecvt_utf8<wchar_t>;
    std::wstring_convert<convert_typeX, wchar_t> converterX;

    return converterX.to_bytes(wstr);
}

int main() {
    wchar_t ws[4] = {};
    ws[0] = 0x4e25;
    ws[1] = 0xff5e;
    ws[2] = 0x41;
    std::cout << ws2s(ws) << std::endl;
    return 0;
}

  

嚴~A

  

敏感詞庫

總結

演算法時間複雜度對比:

KMPTrie樹DFAAC自動機
O(LenA + LenB) * m log(n) 未知 未知

最後,C++實現的程式碼請移步:

參考

文章:

視訊:

網站:

相關文章