前言:
也是前輩推薦的,一本好書《柔性字符串匹配》分享推薦一下,本文章內容部分是參考別的網站上的,如有侵權請及時聯系我,匯總這個文章旨在擴展視野學習,能在實際工作提供一些思路
BF (Brute Force)暴力匹配算法
作為最簡單、最暴力的字符串匹配算法,BF 算法的思想可以用一句話來概括,那就是,我們在主串中,檢查起始位置分別是 0、1、2…n-m 且長度為 m 的 n-m+1 個子串,看有沒有跟模式串匹配的。
理論上的最壞情況時間復雜度是 O(n*m),但是,統計意義上,大部分情況下,算法執行效率要比這個高很多。
朴素字符串匹配算法思想簡單,代碼實現也非常簡單。簡單意味着不容易出錯,如果有 bug 也容易暴露和修復。
1 /* 2 模式匹配之BF(Brute Force)暴力算法 3 */ 4 # include<iostream> 5 # include<string> 6 7 using namespace std; 8 9 /* 10 * 返回子串t在串s第一次出現的位置(從1開始) 11 */ 12 int patternMatch_BF(string s, string t) 13 { 14 int i = 1, j = 1; 15 while (i <=s.length()&& j <=t.length())//兩個串都沒掃描完 16 { 17 if (s[i-1] == t[j-1])//該位置上字符相等,就比較下一個字符 18 { 19 i++; 20 j++; 21 } 22 else 23 { 24 i = i - j + 2; //否則,i為上次掃描位置的下一位置 25 j = 1; //j從1開始 26 } 27 28 } 29 if (j > t.length()) 30 return (i - t.length()); 31 return -1; 32 } 33 34 int main() 35 { 36 string mstr = "sdsajdijaois"; 37 string sstr = jao; 38 39 int result = patternMatch_BF(mstr, sstr); 40 if (result==-1) 41 cout <<endl<< "匹配失敗" << endl; 42 else 43 cout << endl<<"子串在主串中的位置為:" << result << endl; 44 return 0; 45 }
RK(Rabin-Karp) 算法
BF算法每次檢查主串與子串是否匹配,需要依次比對每個字符,所以 BF 算法的時間復雜度就比較高,是 O(n*m)。
RK 算法的思路是這樣的:
我們通過哈希算法對主串中的 n-m+1 個子串分別求哈希值,然后逐個與模式串的哈希值比較大小。如果某個子串的哈希值與模式串相等,那就說明對應的子串和模式串匹配了。
模式串哈希值與每個子串哈希值之間的比較的時間復雜度是 O(1),總共需要比較 n-m+1 個子串的哈希值,所以,這部分的時間復雜度也是 O(n)。所以,RK 算法整體的時間復雜度就是 O(n)。
跟 BF 算法相比,效率提高了很多。不過這樣的效率取決於哈希算法的設計方法,如果存在沖突的情況下,時間復雜度可能會退化。極端情況下,哈希算法大量沖突,時間復雜度就退化為 O(n*m)
1 class Solution 2 { 3 public: 4 int RK(const string& src,const string& dst) 5 { 6 int srcLength = src.size(); 7 int dstLength = dst.size(); 8 9 powValue = new int[dstLength-1]; 10 hashValue = new int[srcLength-dstLength+1]; 11 12 for(int i=0;i<dstLength;++i) 13 { 14 powValue[i]=1; 15 for(int j =i;j>0;--j) 16 { 17 powValue[i]*=26; 18 } 19 } 20 //計算子串的hash值 21 int dstHash = 0; 22 for(int j = 0;j<dstLength;++j) 23 { 24 dstHash+= powValue[dstLength-j-1]*(dst[j]-'a'); 25 } 26 27 //首先計算第一個子串的hash值 28 hashValue[0]=0; 29 for(int j = 0;j<dstLength;++j) 30 { 31 hashValue[0]+= powValue[dstLength-j-1]*(src[j]-'a'); 32 } 33 if(hashValue[0]==dstHash) 34 return 0; 35 for(int i =1;i<srcLength;++i) 36 { 37 //通過前一個子串的hash值計算當前的hash值 38 hashValue[i]=(hashValue[i-1]-powValue[dstLength-1]*(src[i-1]-'a'))*26+(src[i+dstLength-1]-'a'); 39 if(hashValue[i]==dstHash) 40 return i; 41 42 } 43 return -1; 44 } 45 private: 46 int* powValue;//申請內存提前保存26進制值,減少計算時間。 47 int* hashValue;//儲存已經計算過的子串的hash值。 48 };
BM(Boyer-Moore)算法
BM算法思想的本質上就是在進行模式匹配的過程中,當模式串與主串的某個字符不匹配的時候,能夠跳過一些肯定不會匹配的情況,將模式串往后多滑動幾位。
BM算法尋找是否能多滑動幾位的原則有兩種,分別是 壞字符規則 和 好后綴規則。
壞字符規則:
我們從模式串的末尾往前倒着匹配,當我們發現某個字符無法匹配時,我們把這個無法匹配的字符叫做壞字符(主串中的字符)。此時記錄下壞字符在模式串中的位置si,然后拿壞字符在模式串中查找,如果模式串中並不存在這個字符,那么可以將模式串直接向后滑動m位,如果壞字符在模式串中存在,則記錄下其位置xi,那么模式串向后移動的位數就是si-xi,(可以在確保si>xi,執行減法,不會出現向前移動的情況)。如果壞字符在模式串中多次出現,那我們在計算xi的時候,選擇最靠后的那個,這樣不會因為讓模式串滑動過多,導致本來可能匹配的情況被略過。
好后綴規則:
在我們反向匹配模式串時,遇到不匹配時,記錄下當前位置j位壞字符位置。把已經匹配的字符串叫做好后綴,記作{u}。我們拿它在模式串中查找,如果找到了另一個跟{u}相匹配的字串{u*}, 那么我們就將模式串滑動到字串{u*}與主串{u}對齊的位置。如下圖所示:
如果在模式串中找不到另一個等於{u}的子串,我們就直接將模式串滑動到主串中{u}的后面,因為之前的任何一次往后滑動,都沒有匹配主串中{u}的情況。但是這種滑動做法有點太過頭了,可以看下面的例子,如果直接滑動到好后綴的后面,可能會錯過模式串與主串可以匹配的情況。如下圖:
當模式串滑動到前綴與主串中{u}的后綴有部分重合的時候,並且重回部分相等的時候,就可能會存在完全匹配的情況。所以針對這種情況我們不僅要看好后綴在模式串中,是否有另一個匹配的字串,我們還要考察好后綴的后綴字串是否存在跟模式串的前綴字串匹配的情況。如下圖所示:
最后總結如何確定模式串向后滑動的位數,我們可以分別計算好后綴和壞字符往后滑動的位數,然后取兩個數中最大的。
BM算法性能分析
BM算法的內存消耗:整個算法使用了三個額外數組,其中bc數組的大小和字符集大小有關,suffix數組和prefix數組的大小和模式串長度m有關。
如果我們處理字符集很大的模式匹配問題,bc數組對內存消耗會比較多。好后綴規則和壞字符規則是獨立的,如果對內存要求苛刻,那么可以只使用好后綴規則。不過效率也會下降一些。
1 #include <cstdio> 2 #include <cstdlib> 3 #include <iostream> 4 using namespace std; 5 const int size = 256; 6 //將模式串字符使用hash表示 7 void generateBC(char b[], int m, int bc[]){ 8 //b是模式串, m是模式串的長度, bc是散列表 9 //bc的下標是字符集的ASCII碼,數組值是每個字符在模式串中出現的位置。 10 for(int i=0; i<size; i++){ 11 bc[i]=-1; 12 } 13 for(int i=0; i<m; i++){ 14 int ascii = (int)b[i]; 15 bc[ascii] = i; 16 } 17 } 18 /* 19 求suffix數組和prefix數組 20 suffix數組的下標K表示后綴字串的長度,數組值對應存儲的是,在模式串中跟好后綴{u}相匹配的子串{u*} 21 的起始下標值。 22 prefix數組是布爾型。來記錄模式串的后綴字串是否能匹配模式串的前綴子串。 23 24 */ 25 void generateGS(char b[], int m, int suffix[], bool prefix[]){ 26 for(int i=0; i<m;i++){ 27 suffix[i] = -1; 28 prefix[i] = false; 29 } 30 for(int i=0; i<m-1; ++i){ 31 int j = i; 32 int k =0; //公共后綴字串長度 33 while(j >=0 && b[j] == b[m-1-k]){ 34 //與b[0, m-1]求公共后綴字串 35 --j; 36 ++k; 37 suffix[k] = j+1; //j+1表示公共后綴字串在b[0,i]中的起始下標 38 } 39 if(j == -1) prefix[k] = true;//如果公共后綴字串也是模式串的前綴字串 40 41 } 42 } 43 44 //j表示壞字符對應的模式串中的字符下標,m是模式串的長度 45 //計算在好后綴規則下模式串向后移動的個數 46 int moveByGS(int j, int m, int suffix[], bool prefix[]){ 47 int k= m-1-j; //好后綴的長度 48 if(suffix[k] != -1) return j - suffix[k] +1; 49 for(int r = j+2; r<= m-1; ++r){ 50 if(prefix[m-r] == true){ 51 return r; 52 } 53 } 54 return m; 55 } 56 57 //BM算法 58 int BM(char a[], int n, char b[], int m){ 59 int suffix[m]; 60 bool prefix[m]; 61 62 int bc[size];//bc記錄模式串中每個字符最后出現的位置 63 64 generateBC(b,m,bc); //構建字符串hash表 65 generateGS(b,m, suffix,prefix); //計算好后綴和好前綴數組 66 67 int i=0; //表示主串與模式串對齊的第一個字符 68 while(i<=n-m){ 69 int j; 70 for(j=m-1; j>=0; j--){ //模式串從后往前匹配 71 if(a[i+j]!= b[j]) break; //壞字符對應的模式串下標是j,即i+j 位置是壞字符的位置si 72 } 73 if(j < 0){ 74 return i; //匹配成功,返回主串與模式串第一個匹配的字符的位置 75 } 76 //這里x等同於將模式串往后滑動j-bc[(int)a[i+j]]位 77 //bc[(int)a[i+j]]表示主串中壞字符在模式串中出現的位置xi 78 int x = i + (j - bc[(int)a[i+j]]); 79 80 int y =0; 81 if(j < m-1){//如果有好后綴的話,計算在此情況下向后移動的位數y 82 y = moveByGS(j, m, suffix, prefix); 83 } 84 i = i + max(x, y); //i更新位可以后移較多的位置 85 86 } 87 return -1; 88 } 89 90 int main(){ 91 char a[] = "aaaabaaba"; 92 char b[] = "aaaa"; 93 int i = BM(a,9,b,2); 94 printf("%d\n", i); 95 return 0; 96 }
KMP算法
KMP 算法的核心思想,跟上一節講的 BM 算法非常相近。我們假設主串是 a,模式串是 b。在模式串與主串匹配的過程中,當遇到不可匹配的字符的時候,我們希望找到一些規律,可以將模式串往后多滑動幾位,跳過那些肯定不會匹配的情況
1 //str1為主串,str2為模式串 2 #include <iostream> 3 #include <string> 4 #include <vector> 5 using namespace std; 6 void getnext(const string &str2,vector<int> next) 7 { next.clear(); 8 next.resize(str2.size()); 9 if (str2.length()== 1) 10 { 11 next[0]=-1; 12 return ; 13 } 14 next[0]=-1; 15 next[1]=0; 16 int len= str2.length(); 17 int i=2,cn=0;//cn為最長前綴的后一個字符 18 while(i<len) 19 { 20 if (str2[i-1]==str2[cn]) //如果前一個字符和cn對應的值相等 21 next[i++]=++cn;//如果相等則此處的值為,cn+1 22 else if (cn>0) 23 cn=next[cn];//不等的話繼續往前推 24 else 25 next[i++] =0;//不等的話並未沒法往前推就變為0 26 } 27 28 } 29 int kmp( const string &str1, const string &str2,vector<int> & next) 30 { 31 int i1 = 0, i2 = 0; 32 while (i1<str1.length() && i2<str2.length()) 33 { 34 if (str1[i1]==str2[i2])//兩者比對,相等則主串和模式串都加加 35 { 36 i1++; 37 i2++; 38 } 39 else if (next[i2]==-1)//兩者沒有匹配則進一步判斷i2是否還有回退的資格,如果等於-1說明已經退到頭了,則只能i1++; 40 { 41 i1++; 42 } 43 else//還可以退,則i2回到到next數組指定的位置再進行比對 44 i2=next[i2]; 45 } 46 return i2 == str2.length()?i1-i2:-1; 47 //如果str2已經掃描完了說明已經找到了,返回str1中找到的起始位置;如果沒有掃描完說明沒有找到返回-1; 48 } 49 int main() 50 { 51 string str1,str2; 52 cin>>str1>>str2; 53 vector<int> next; 54 int k; 55 k=kmp(str1,str2,next); 56 return k; 57 }
Trie樹
Trie 樹,也叫“字典樹”。顧名思義,它是一個樹形結構。它是一種專門處理字符串匹配的數據結構,用來解決在一組字符串集合中快速查找某個字符串的問題。
舉個簡單的例子來說明一下。我們有 6 個字符串,它們分別是:how,hi,her,hello,so,see。我們希望在里面多次查找某個字符串是否存在。如果每次查找,都是拿要查找的字符串跟這 6 個字符串依次進行字符串匹配,那效率就比較低,有沒有更高效的方法呢?這個時候,我們就可以先對這 6 個字符串做一下預處理,組織成 Trie 樹的結構,之后每次查找,都是在 Trie 樹中進行匹配查找。
Trie 樹的本質,就是利用字符串之間的公共前綴,將重復的前綴合並在一起。
如果要在一組字符串中,頻繁地查詢某些字符串,用 Trie 樹會非常高效。
構建 Trie 樹的過程,需要掃描所有的字符串,時間復雜度是 O(n)(n 表示所有字符串的長度和)。
但是一旦構建成功之后,后續的查詢操作會非常高效。如果要查詢的字符串長度是 k,那我們只需要比對大約 k 個節點,就能完成查詢操作。跟原本那組字符串的長度和個數沒有任何關系。所以說,構建好 Trie 樹后,在其中查找字符串的時間復雜度是 O(k),k 表示要查找的字符串的長度。
字符串中包含的字符集不能太大,要求字符串的前綴重合比較多,如果字符集太大,那存儲空間可能就會浪費很多。即便可以優化,但也要付出犧牲查詢、插入效率的代價。
代碼參考:https://blog.csdn.net/weixin_41427400/article/details/79949422
1 #ifndef TRIETREE_H 2 #define TRIETREE_H 3 4 #include <iostream> 5 #include <string> 6 #include <vector> 7 using namespace std; 8 9 // 定義R為常量 10 const int R = 256; 11 12 // 重定義樹節點,便於操作 13 typedef struct TreeNode *Position; 14 15 /* color枚舉,儲存元素:Red, Black*/ 16 enum Color {Red, Black}; 17 18 /* TrieTree節點 19 * 儲存元素: 20 * coloe:節點顏色,紅色代表有此單詞,黑色代表沒有 21 * Next:下一層次節點 22 */ 23 struct TreeNode { 24 Color color; 25 Position Next[R]; 26 }; 27 28 /* TrieTree類(前綴樹) 29 * 接口: 30 * MakeEmpty:重置功能,重置整顆前綴樹 31 * keys:獲取功能,獲取TrieTree中的所有單詞,並儲存在一個向量中 32 * Insert:插入功能,向單詞樹中插入新的單詞 33 * Delete:刪除功能,刪除單詞樹的指定單詞 34 * IsEmpty:空函數,判斷單詞樹是否為空 35 * Find:查找函數,查找對應的單詞,並返回查找情況:查找到返回true,否則返回false 36 * LongestPrefixOf:查找指定字符串的最長前綴單詞; 37 * KeysWithPrefix:查找以指定字符串為前綴的單詞; 38 * KeysThatMatch:查找匹配對應字符串形式的單詞,"."表示任意單詞 39 */ 40 class TrieTree 41 { 42 public: 43 // 構造函數 44 TrieTree(); 45 // 析構函數 46 ~TrieTree(); 47 48 // 接口函數 49 void MakeEmpty(); 50 vector <string> keys(); 51 void Insert(string); 52 void Delete(string); 53 54 bool IsEmpty(); 55 bool Find(string) const; 56 string LongestPrefixOf(string) const; 57 vector <string> KeysWithPrefix(string) const; 58 vector <string> KeysThatMatch(string) const; 59 60 private: 61 // 輔助功能函數 62 void MakeEmpty(Position); 63 void Insert(string, Position &, int); 64 void Delete(string, Position &, int); 65 66 Position Find(string, Position, int) const; 67 int Search(string, Position, int, int) const; 68 void Collect(string, Position, vector <string> &) const; // 對應KeysWithPrefix() 69 void Collect(string, string, Position, vector <string> &) const; // 對應KeysThatMatch() 70 71 // 數據成員 72 Position Root; // 儲存根節點 73 }; 74 75 #endif
1 #include "TrieTree.h" 2 3 /* 構造函數:初始化對象 4 * 參數:無 5 * 返回值:無 6 */ 7 TrieTree::TrieTree() { 8 Root = new TreeNode(); 9 if (Root == NULL) { 10 cout << "TrieTree申請失敗!" << endl; 11 return; 12 } 13 14 // 根節點為黑色節點 15 Root->color = Black; 16 for (int i = 0; i < R; i++) 17 Root->Next[i] = NULL; 18 } 19 20 /* 析構函數:對象消亡時回收儲存空間 21 * 參數:無 22 * 返回值:無 23 */ 24 TrieTree::~TrieTree() { 25 MakeEmpty(Root); // 調用重置函數,從樹根開始置空 26 } 27 28 /* 重置函數:重置TrieTree 29 * 參數:無 30 * 返回值:無 31 */ 32 void TrieTree::MakeEmpty() { 33 // 將根節點的下一層節點置空 34 for (char c = 0; c < R; c++) 35 if (Root->Next[c] != NULL) 36 MakeEmpty(Root->Next[c]); 37 } 38 39 /* 重置函數:重置指定節點 40 * 參數:tree:想要進行重置額節點 41 * 返回值:無 42 */ 43 void TrieTree::MakeEmpty(Position tree) { 44 // 置空下一層節點 45 for (char c = 0; c < R; c++) 46 if (tree->Next[c] != NULL) 47 MakeEmpty(tree->Next[c]); 48 49 // 刪除當前節點 50 delete tree; 51 tree = NULL; 52 } 53 54 /* 獲取函數:獲單詞樹中的所有單詞,並返回儲存的向量 55 * 參數:無 56 * 返回值:vector<string>:儲存單詞樹中所有單詞的向量 57 */ 58 vector <string> TrieTree::keys() { 59 // 返回所有以""為前綴的單詞,即所有單詞 60 return KeysWithPrefix(""); 61 } 62 63 /* 插入函數:向TrieTree中插入指定的單詞 64 * 參數:key:想要進行插入的字符串 65 * 返回值:無 66 */ 67 void TrieTree::Insert(string key) { 68 // 從根節點開始遞歸插入 69 Insert(key, Root, 0); 70 } 71 72 /* 插入驅動函數:將指定的單詞進行遞歸插入 73 * 參數:key:想要進行插入的單詞,tree:當前遞歸節點,d:當前檢索的字符索引 74 * 返回值:無 75 */ 76 void TrieTree::Insert(string key, Position &tree, int d) { 77 // 若沒有節點則生成新節點 78 if (tree == NULL) { 79 tree = new TreeNode(); 80 if (tree == NULL) { 81 cout << "新節點申請失敗!" << endl; 82 return; 83 } 84 85 tree->color = Black; 86 for (int i = 0; i < R; i++) 87 tree->Next[i] = NULL; 88 } 89 90 // 若檢索到最后一位,則改變節點顏色 91 if (d == key.length()) { 92 tree->color = Red; 93 return; 94 } 95 96 // 檢索下一層節點 97 char c = key[d]; 98 Insert(key, tree->Next[c], d + 1); 99 } 100 101 /* 刪除函數:刪除TrieTree中的指定單詞 102 * 參數:key:想要刪除的指定元素 103 * 返回值:無 104 */ 105 void TrieTree::Delete(string key) { 106 // 從根節點開始遞歸刪除 107 Delete(key, Root, 0); 108 } 109 110 /* 刪除驅動函數:將指定單詞進行遞歸刪除 111 * 參數:key:想要進行刪除的單詞,tree:當前樹節點,d:當前的索引下標 112 * 返回值:無 113 */ 114 void TrieTree::Delete(string key, Position &tree, int d) { 115 // 若未空樹則返回 116 if (tree == NULL) 117 return; 118 119 // 檢索到指定單詞,將其顏色變黑 120 if (d == key.length()) 121 tree->color = Black; 122 123 // 檢索下一層節點 124 else { 125 char c = key[d]; 126 Delete(key, tree->Next[c], d + 1); 127 } 128 129 // 紅節點直接返回 130 if (tree->color == Red) 131 return; 132 133 // 若未黑節點,且無下層節點則刪除該節點 134 for (int i = 0; i < R; i++) 135 if (tree->Next[i] != NULL) 136 return; 137 138 delete tree; 139 tree = NULL; 140 } 141 142 /* 空函數:判斷TrieTree是否為空 143 * 參數:無 144 * 返回值:bool:空樹返回true,非空返回false 145 */ 146 bool TrieTree::IsEmpty() { 147 for (int i = 0; i < R; i++) 148 if (Root->Next[i] != NULL) 149 return false; 150 return true; 151 } 152 153 /* 查找函數:在TrieTree中查找對應的單詞,並返回查找結果 154 * 參數:key:想要查找的單詞 155 * 返回值:bool:TrieTree中有key返回true,否則返回false 156 */ 157 bool TrieTree::Find(string key) const { 158 // 查找key最后字符所在節點 159 Position P = Find(key, Root, 0); 160 161 // 無節點則返回false 162 if (P == NULL) 163 return false; 164 165 // 根據節點顏色返回 166 if (P->color == Red) 167 return true; 168 else 169 return false; 170 } 171 172 /* 查找驅動函數:在TrieTree中查找指定的單詞並返回其最后的字符所在節點 173 * 參數:key:想要進行查找的單詞,tree:當前遞歸查找的樹節點,d:當前檢索的索引 174 * 返回值:Position:單詞最后字符所在的節點 175 */ 176 Position TrieTree::Find(string key, Position tree, int d) const { 177 // 節點不存在則返回空 178 if (tree == NULL) 179 return NULL; 180 181 // 若檢索完成,返回該節點 182 if (d == key.length()) 183 return tree; 184 185 // 檢索下一層 186 char c = key[d]; 187 return Find(key, tree->Next[c], d + 1); 188 } 189 190 /* 最長前綴驅動:獲取最長前綴在指定字符串中的所有下標 191 * 參數:key:用於查找的字符串,tree:當前的遞歸節點,d:當前檢索的索引,length:當前最長前綴的長度 192 * 返回值:int:最長前綴的長度 193 */ 194 int TrieTree::Search(string key, Position tree, int d, int length) const { 195 // 空樹則返回當前前綴的長度 196 if (tree == NULL) 197 return length; 198 199 // 更新前綴長度 200 if (tree->color == Red) 201 length = d; 202 203 // 檢索到末尾則返回長度 204 if (d == key.length()) 205 return length; 206 207 // 檢索下一層 208 char c = key[d]; 209 return Search(key, tree->Next[c], d + 1, length); 210 } 211 212 /* 最長前綴函數:獲取指定字符串中,在TrieTree中存在的最長前綴 213 * 參數:key:想要進行查找的字符串 214 * 返回值:string:最長的前綴單詞 215 */ 216 string TrieTree::LongestPrefixOf(string key) const { 217 // 獲取最長前綴的下標 218 int Length = Search(key, Root, 0, 0); 219 return key.substr(0, Length); 220 } 221 222 /* 前綴查找驅動:將當前層次所有符合前綴要求的單詞存入向量 223 * 參數:key:指定的前綴,tree:當前的節點層次,V:用於儲存的向量 224 * 返回值:無 225 */ 226 void TrieTree::Collect(string key, Position tree, vector <string> &V) const{ 227 // 空節點直接返回 228 if (tree == NULL) 229 return; 230 231 // 紅節點則壓入單詞 232 if (tree->color == Red) 233 V.push_back(key); 234 235 // 檢索下一層節點 236 for (char i = 0; i < R; i++) 237 Collect(key + i, tree->Next[i], V); 238 } 239 240 /* 前綴查找:查找TrieTree中所有以指定字符串為前綴的單詞 241 * 參數:key:指定的前綴 242 * 返回值:vector<string>:儲存了所有目標單詞的向量 243 */ 244 vector <string> TrieTree::KeysWithPrefix(string key) const { 245 vector <string> V; 246 // 搜集目標單詞到向量V 247 Collect(key, Find(key, Root, 0), V); 248 return V; 249 } 250 251 /* 單詞匹配驅動:搜集當前層次中所有匹配成功的單詞 252 * 參數:pre:匹配前綴單詞,pat:用於指定形式的字符串,tree:當前的檢索層次,V:用於儲存匹配成功單詞的向量 253 * 返回值:無 254 */ 255 void TrieTree::Collect(string pre, string pat, Position tree, vector <string> &V) const { 256 // 獲取前綴的長度 257 int d = pre.length(); 258 259 // 空樹直接返回 260 if (tree == NULL) 261 return; 262 263 // 若前綴長度與指定單詞相同且當前節點為紅色,則壓入前綴 264 if (d == pat.length() && tree->color == Red) 265 V.push_back(pre); 266 267 // 若只是長度相同直接返回 268 if (d == pat.length()) 269 return; 270 271 // 檢索下一層節點 272 char next = pat[d]; 273 for (char c = 0; c < R; c++) 274 if (next == '.' || next == c) 275 Collect(pre + c, pat, tree->Next[c], V); 276 } 277 278 /* 單詞匹配函數:搜集TrieTree中所以匹配指定字符串形式的單詞 279 * 參數:pat:用於指定形式的字符串 280 * 返回值:vector<string>:儲存所有目標單詞的向量 281 */ 282 vector <string> TrieTree::KeysThatMatch(string pat) const { 283 vector <string> V; 284 // 搜集所有匹配的單詞到向量V 285 Collect("", pat, Root, V); 286 return V; 287 }
AC自動機(Aho-Corasick 多模式匹配算法)
假設需要模式串有上萬個,通過單模式串匹配算法(比如 KMP 算法),需要掃描幾千遍。很顯然,這種處理思路比較低效。
Trie 樹就是一種多模式串匹配算法。我們用Trie樹可以對上千個模式串字典進行預處理,構建成 Trie 樹結構。這個預處理的操作只需要做一次,如果字典動態更新了,比如刪除、添加了一個模式串,那我們只需要動態更新一下 Trie 樹就可以了。
AC 自動機算法,全稱是 Aho-Corasick 算法。其實,Trie 樹跟 AC 自動機之間的關系,就像單串匹配中朴素的串匹配算法,跟 KMP 算法之間的關系一樣,只不過前者針對的是多模式串而已。所以,AC 自動機實際上就是在 Trie 樹之上,加了類似 KMP 的 next 數組,只不過此處的 next 數組是構建在樹上罷了。
使用Aho-Corasick算法需要三步:
1.建立模式的Trie
2.給Trie添加失敗路徑
3.根據AC自動機,搜索待處理的文本
下面說明這三步:
1)建立多模式集合的Trie樹
Trie樹也是一種自動機。對於多模式集合{"say","she","shr","he","her"},對應的Trie樹如下,其中紅色標記的圈是表示為接收態:
2)為多模式集合的Trie樹添加失敗路徑,建立AC自動機
構造失敗指針的過程概括起來就一句話:設這個節點上的字母為C,沿着他父親的失敗指針走,直到走到一個節點,他的兒子中也有字母為C的節點。然后把當前節點的失敗指針指向那個字母也為C的兒子。如果一直走到了root都沒找到,那就把失敗指針指向root。
使用廣度優先搜索BFS,層次遍歷節點來處理,每一個節點的失敗路徑。
特殊處理:第二層要特殊處理,將這層中的節點的失敗路徑直接指向父節點(也就是根節點)。
3)根據AC自動機,搜索待處理的文本
從root節點開始,每次根據讀入的字符沿着自動機向下移動。
當讀入的字符,在分支中不存在時,遞歸走失敗路徑。如果走失敗路徑走到了root節點,則跳過該字符,處理下一個字符。
因為AC自動機是沿着輸入文本的最長后綴移動的,所以在讀取完所有輸入文本后,最后遞歸走失敗路徑,直到到達根節點,這樣可以檢測出所有的模式。
3.Aho-Corasick算法代碼示例(https://www.cnblogs.com/xudong-bupt/p/3433506.html)
模式串集合:{"nihao","hao","hs","hsr"}
待匹配文本:"sdmfhsgnshejfgnihaofhsrnihao
1 #include<iostream> 2 #include<string.h> 3 #include<malloc.h> 4 #include <queue> 5 using namespace std; 6 7 typedef struct node{ 8 struct node *next[26]; //接收的態 9 struct node *par; //父親節點 10 struct node *fail; //失敗節點 11 char inputchar; 12 int patterTag; //是否為可接收態 13 int patterNo; //接收態對應的可接受模式 14 }*Tree,TreeNode; 15 char pattern[4][30]={"nihao","hao","hs","hsr"}; 16 17 /** 18 申請新的節點,並進行初始化 19 */ 20 TreeNode *getNewNode() 21 { 22 int i; 23 TreeNode* tnode=(TreeNode*)malloc(sizeof(TreeNode)); 24 tnode->fail=NULL; 25 tnode->par=NULL; 26 tnode->patterTag=0; 27 for(i=0;i<26;i++) 28 tnode->next[i]=NULL; 29 return tnode; 30 } 31 32 /** 33 將Trie樹中,root節點的分支節點,放入隊列 34 */ 35 int nodeToQueue(Tree root,queue<Tree> &myqueue) 36 { 37 int i; 38 for (i = 0; i < 26; i++) 39 { 40 if (root->next[i]!=NULL) 41 myqueue.push(root->next[i]); 42 } 43 return 0; 44 } 45 46 /** 47 建立trie樹 48 */ 49 Tree buildingTree() 50 { 51 int i,j; 52 Tree root=getNewNode(); 53 Tree tmp1=NULL,tmp2=NULL; 54 for(i=0;i<4;i++) 55 { 56 tmp1=root; 57 for(j=0;j<strlen(pattern[i]);j++) ///對每個模式進行處理 58 { 59 if(tmp1->next[pattern[i][j]-'a']==NULL) ///是否已經有分支,Trie共用節點 60 { 61 tmp2=getNewNode(); 62 tmp2->inputchar=pattern[i][j]; 63 tmp2->par=tmp1; 64 tmp1->next[pattern[i][j]-'a']=tmp2; 65 tmp1=tmp2; 66 } 67 else 68 tmp1=tmp1->next[pattern[i][j]-'a']; 69 } 70 tmp1->patterTag=1; 71 tmp1->patterNo=i; 72 } 73 return root; 74 } 75 76 /** 77 建立失敗指針 78 */ 79 int buildingFailPath(Tree root) 80 { 81 int i; 82 char inputchar; 83 queue<Tree> myqueue; 84 root->fail=root; 85 for(i=0;i<26;i++) ///對root下面的第二層進行特殊處理 86 { 87 if (root->next[i]!=NULL) 88 { 89 nodeToQueue(root->next[i],myqueue); 90 root->next[i]->fail=root; 91 } 92 } 93 94 Tree tmp=NULL,par=NULL; 95 while(!myqueue.empty()) 96 { 97 tmp=myqueue.front(); 98 myqueue.pop(); 99 nodeToQueue(tmp,myqueue); 100 101 inputchar=tmp->inputchar; 102 par=tmp->par; 103 104 while(true) 105 { 106 if(par->fail->next[inputchar-'a']!=NULL) 107 { 108 tmp->fail=par->fail->next[inputchar-'a']; 109 break; 110 } 111 else 112 { 113 if(par->fail==root) 114 { 115 tmp->fail=root; 116 break; 117 } 118 else 119 par=par->fail->par; 120 } 121 } 122 } 123 return 0; 124 } 125 126 /** 127 進行多模式搜索,即搜尋AC自動機 128 */ 129 int searchAC(Tree root,char* str,int len) 130 { 131 TreeNode *tmp=root; 132 int i=0; 133 while(i < len) 134 { 135 int pos=str[i]-'a'; 136 if (tmp->next[pos]!=NULL) 137 { 138 tmp=tmp->next[pos]; 139 if(tmp->patterTag==1) ///如果為接收態 140 { 141 cout<<i-strlen(pattern[tmp->patterNo])+1<<'\t'<<tmp->patterNo<<'\t'<<pattern[tmp->patterNo]<<endl; 142 } 143 i++; 144 } 145 else 146 { 147 if(tmp==root) 148 i++; 149 else 150 { 151 tmp=tmp->fail; 152 if(tmp->patterTag==1) //如果為接收態 153 cout<<i-strlen(pattern[tmp->patterNo])+1<<'\t'<<tmp->patterNo<<'\t'<<pattern[tmp->patterNo]<<endl; 154 } 155 } 156 } 157 while(tmp!=root) 158 { 159 tmp=tmp->fail; 160 if(tmp->patterTag==1) 161 cout<<i-strlen(pattern[tmp->patterNo])+1<<'\t'<<tmp->patterNo<<'\t'<<pattern[tmp->patterNo]<<endl; 162 } 163 return 0; 164 } 165 166 /** 167 釋放內存,DFS 168 */ 169 int destory(Tree tree) 170 { 171 if(tree==NULL) 172 return 0; 173 queue<Tree> myqueue; 174 TreeNode *tmp=NULL; 175 176 myqueue.push(tree); 177 tree=NULL; 178 while(!myqueue.empty()) 179 { 180 tmp=myqueue.front(); 181 myqueue.pop(); 182 183 for (int i = 0; i < 26; i++) 184 { 185 if(tmp->next[i]!=NULL) 186 myqueue.push(tmp->next[i]); 187 } 188 free(tmp); 189 } 190 return 0; 191 } 192 193 int main() 194 { 195 char a[]="sdmfhsgnshejfgnihaofhsrnihao"; 196 Tree root=buildingTree(); ///建立Trie樹 197 buildingFailPath(root); ///添加失敗轉移 198 cout<<"待匹配字符串:"<<a<<endl; 199 cout<<"模式"<<pattern[0]<<" "<<pattern[1]<<" "<<pattern[2]<<" "<<pattern[3]<<" "<<endl<<endl; 200 cout<<"匹配結果如下:"<<endl<<"位置\t"<<"編號\t"<<"模式"<<endl; 201 searchAC(root,a,strlen(a)); ///搜索 202 destory(root); ///釋放動態申請內存 203 return 0; 204 }
Hyperscan
Hyperscan是一款來自於Intel的高性能的正則表達式匹配庫。它是基於X86平台以PCRE為原型而開發的,並以BSD許可開源在https://01.org/hyperscan。在支持PCRE的大部分語法的前提下,Hyperscan增加了特定的語法和工作模式來保證其在真實網絡場景下的實用性。與此同時,大量高效算法及IntelSIMD*指令的使用實現了Hyperscan的高性能匹配。Hyperscan適用於部署在諸如DPI/IPS/IDS/FW等場景中,目前已經在全球多個客戶網絡安全方案中得到實際的應用。此外,Hyperscan還支持和開源IDS/IPS產品Snort(https://www.snort.org)和Suricata (https://suricata-ids.org)集成,使其應用更加廣泛。
原理
Hyperscan以自動機理論為基礎,其工作流程主要分成兩個部分:編譯期(compiletime)和運行期(run-time)。
編譯期
Hyperscan 自帶C++編寫的正則表達式編譯器。如圖1所示,它將正則表達式作為輸入,針對不同的IA平台,用戶定義的模式及特殊語法,經過復雜的圖分析及優化過程,生成對應的數據庫。另外,生成的數據庫可以被序列化后保存在內存中,以供運行期提取使用。
運行期
Hyperscan的運行期是通過C語言來開發的。圖2展示了Hyperscan在運行期的主要流程。用戶需要預先分配一段內存來存儲臨時匹配狀態信息,之后利用編譯生成的數據庫調用Hyperscan內部的匹配引擎(NFA, DFA等)來對輸入進行模式匹配。Hyperscan在引擎中使用Intel處理器所具有的SIMD指令進行加速。同時,用戶可以通過回調函數來自定義匹配發生后采取的行為。由於生成的數據庫是只讀的,用戶可以在多個CPU核或多線程場景下共享數據庫來提升匹配擴展性。
特點
功能多樣
作為純軟件產品,Hyperscan支持Intel處理器多平台的交叉編譯,且對操作系統無特殊限定,同時支持虛擬機和容器場景。Hyperscan 實現了對PCRE語法的基本涵蓋,對復雜的表達式例如”.*”和”[^>]*”不會有任何支持問題。在此基礎上,Hyperscan增加了不同的匹配模式(流模式和塊模式)來滿足不同的使用場景。通過指定參數,Hyperscan能找到匹配的數據在輸入流中的起始和結束位置。更多功能信息請參考http://01org.github.io/hyperscan/dev-reference/。
大規模匹配
根據規則復雜度的不同,Hyperscan能支持幾萬到幾十萬的規則的匹配。與傳統正則匹配引擎不同,Hyperscan支持多規則的同步匹配。在用戶為每條規則指定獨有的編號后,Hypercan可以將所有規則編譯成一個數據庫並在匹配過程中輸出所有當前匹配到的規則信息。
流模式(streaming mode)
Hyperscan主要分為兩種模式:塊模式 (blockmode)和流模式 (streaming mode). 其中塊模式為狀態正則匹配引擎具有的模式,即對一段現成的完整數據進行匹配,匹配結束即返回結果。流模式是Hyperscan為網絡場景下跨報文匹配設計的特殊匹配模式。在真實網絡場景下,數據是分散在多報文中。若有數據在尚未到達的報文中時,傳統匹配模式將無法適用。在流模式下,Hyperscan可以保存當前數據匹配的狀態,並以其作為接收到新數據時的初始匹配狀態。如圖3所示,不管數據”xxxxabcxxxxxxxxdefx”以怎樣的形式被分散在以時間順序到達的報塊中,流模式保證了最后匹配結果的一致性。另外,Hyperscan對保存的匹配狀態進行了壓縮以減少流模式對內存的占用。Hyperscan流模式解決了數據完整性問題,極大地簡化用戶網絡流處理的過程。
高性能及高擴展性
Hyperscan在不同規則集下,單核性能可實現3.6Gbps~23.9Gbps。而且Hyperscan具有良好的擴展性,隨着使用核數的增加,匹配性能基本處於線性增長的趨勢。在網絡場景中,同一規則庫往往需要匹配多條網絡流。Hypercan的高擴展性為此提供了有力的支持。
Hyperscan與DPDK的整合方案
DPDK (http://dpdk.org)作為高速網絡報文處理轉發套件,在業界得到了極為廣泛的應用。Hyperscan能與DPDK整合成為一套高性能的DPI解決方案。圖4展示了Hyperscan與DPDK整合后的性能數據。我們在測試中使用了真實的規則庫並以http流量作為輸入。Hyperscan與DPDK的結合實現了較高的性能,且隨着包大小的增長,性能可以到達物理的極限值。
pcre VS hyperscan對比
PCRE簡介
PCRE是Perl Compatible Regular Expressions的簡稱,是一款十分流行的用C語言編寫的正則表達式匹配庫,其靈感來源於Perl語言中的正則表達式功能,其語法比POSIX和其他許多正則表達式庫更強大更靈活。
測試結果及分析:
從以上結果中分析得知: 1.hyperscan性能優於pcre; 2.pcre由於是串行匹配多條規則,因此會隨着規則數的增加,性能線性下降, hyperscan則不會; 3.匹配最后一條規則時性能較不匹配要低; 4.hyperscan占用的靜態空間和動態空間都會大於pcre; 由於hyperscan是靜態庫同時又較大,所以編譯出來的bin文件會比較大,在選型時可以考慮在規則量小時用pcre,當規則數量達到千條以上時再用hyperscan替換性價比較高
測試方法說明
1.規則數按10、100、1000分布; 2.分別統計匹配耗時輸出到文件; 3.每輪測試發送1000條請求; 4.統計輸出耗時的平均值;
展望:Hyperscan與Snort的集成
Hyperscan作為一款高性能的正則表達式匹配庫,非常適用於部署在諸如DPI/IPS/IDS/NGFW等網絡解決方案中。Snort (https://www.snort.org) 是目前應用最為廣泛的開源IDS/IPS產品之一,其核心部分涉及到大量純字符串及正則表達式的匹配工作。Hyperscan集成到Snort中將顯著提升Snort的總體性能
Snort簡介
如圖1所示,Snort主要分成五個部分。報文解析器負責從不同的網絡接口接收到報文,並對報文內容進行初步的解析。預處理器是對解析過的報文進一步處理的插件,其功能包括HTTP URI歸一化,報文整合,TCP流重組等。檢測引擎是Snort當中最為核心的部分。它根據現有的規則,對報文數據進行匹配。匹配的性能對Snort總體性能起着至關重要的作用。假如匹配成功,則依據規則中定義的行為通知日志及報警系統。該系統可輸出相應的警報或者日志。用戶也可以定義輸出模塊來以特定形式(例如數據庫,XML文件)保存警報或日志。
Hyperscan 的集成
如圖2所示,Hyperscan與Snort的集成主要集中在以下四個方面:
純字符串匹配
用戶可以在Snort規則中定義匹配特定的字符串,並在相應報文中尋找該字符串。Snort中采用了Boyer-Moore算法進行匹配。我們用Hyperscan對這一算法進行替換以提升匹配性能。
PCRE匹配
Snort中使用了PCRE來作為正則表達式匹配的引擎。Hyperscan兼容了PCRE的語法規則,但不支持少數回溯及斷言語法。但是Hyperscan本身自帶有PCRE的預處理功能(PCRE Prefiltering),可以通過對PCRE規則進行變換以兼容Hyperscan。實際規則產生的匹配是變換后的規則所產生匹配的子集。因此可以使用Hyperscan進行預先掃描,若不產生匹配則實際規則也無匹配。若產生了匹配,可以通過PCRE的掃描來確認是否有真正的匹配。由於Hyperscan的總體性能高於PCRE,Hyperscan的預先過濾可以避免PCRE匹配帶來的過大時間開銷。
多字符串匹配
Snort中另外一個重要的匹配過程是多字符串的匹配。多字符串的匹配可以快速過濾掉無法匹配的規則以減少需要逐條匹配的規則數從而提升匹配的性能。Snort中使用了Aho-Corasick算法進行多字符串的匹配。我們用Hyperscan替代了這一算法並且帶來了顯著的性能提升。
Http預處理
除了引擎的匹配算法的集成,我們在預處理器中也添加了Hyperscan。在做Http預處理時,我們利用了Hyperscan搜索相關關鍵字來進一步加速預處理的流程。
性能數據
我們選取了Snort自帶的VRT 規則(8683條)作為測試規則,同時以存有真實網絡流量信息的PCAP文件作為輸入進行測試。圖3展示了在Broadwell-EP平台下,原生Snort和經過Hyperscan加速的Snort在單核單線程下的性能對比。我們可以看到,Hyperscan極大提升了Snort的匹配性能,總體性能約是原始Snort性能的6倍。另外,我們對原生Snort與經過Hyperscan優化后的Snort在內存消耗方面進行了比較。由於原生Snort依賴於Aho-Corasick算法,需要將所有規則轉化成Trie樹結構,因此占用較大的內存。而Hyperscan擁有自身優化過的匹配引擎進行匹配,大量減少了匹配過程中對內存的消耗。如圖4所示,在這個測試中,總體上原始Snort所占用的內存是經過Hyperscan優化后的Snort的12倍。
參考
數據結構與算法之美 王爭
Intel高性能正則表達式匹配庫——Hyperscan
https://zhuanlan.zhihu.com/p/40603962
http://www.cs.jhu.edu/~langmea/resources/lecture_notes/boyer_moore.pdf
A new proof of the linearity of the Boyer-Moore string searching algorithm
Tight bounds on the complexity of the Boyer-Moore string matching algorithm