日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 编程资源 > 编程问答 >内容正文

编程问答

数据结构与算法之美笔记——基础篇(下):图、字符串匹配算法(BF 算法和 RK 算法、BM 算法和 KMP 算法 、Trie 树和 AC 自动机)

發(fā)布時(shí)間:2024/3/13 编程问答 45 豆豆
生活随笔 收集整理的這篇文章主要介紹了 数据结构与算法之美笔记——基础篇(下):图、字符串匹配算法(BF 算法和 RK 算法、BM 算法和 KMP 算法 、Trie 树和 AC 自动机) 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

如何存儲(chǔ)微博、微信等社交網(wǎng)絡(luò)中的好友關(guān)系?圖。實(shí)際上,涉及圖的算法有很多,也非常復(fù)雜,比如圖的搜索、最短路徑、最小生成樹、二分圖等等。我們今天聚焦在圖存儲(chǔ)這一方面,后面會(huì)分好幾節(jié)來(lái)依次講解圖相關(guān)的算法。

如何理解“圖”?

我們前面講過(guò)了樹這種非線性表數(shù)據(jù)結(jié)構(gòu),今天我們要講另一種非線性表數(shù)據(jù)結(jié)構(gòu),(Graph)。和樹比起來(lái),這是一種更加復(fù)雜的非線性表結(jié)構(gòu)。

圖中的元素我們就叫作頂點(diǎn)(vertex)。

圖中的一個(gè)頂點(diǎn)可以與任意其他頂點(diǎn)建立連接關(guān)系。我們把這種建立的關(guān)系叫作(edge)。

我們就拿微信舉例子吧。我們可以把每個(gè)用戶看作一個(gè)頂點(diǎn)。如果兩個(gè)用戶之間互加好友,那就在兩者之間建立一條邊。所以,整個(gè)微信的好友關(guān)系就可以用一張圖來(lái)表示。其中,每個(gè)用戶有多少個(gè)好友,對(duì)應(yīng)到圖中,就叫作頂點(diǎn)的(degree),就是跟頂點(diǎn)相連接的邊的條數(shù)。

微博的社交關(guān)系跟微信還有點(diǎn)不一樣,或者說(shuō)更加復(fù)雜一點(diǎn)。微博允許單向關(guān)注,也就是說(shuō),用戶 A 關(guān)注了用戶 B,但用戶 B 可以不關(guān)注用戶 A。那我們?nèi)绾斡脠D來(lái)表示這種單向的社交關(guān)系呢?(帶箭頭

我們可以把剛剛講的圖結(jié)構(gòu)稍微改造一下,引入邊的“方向”的概念。

如果用戶 A 關(guān)注了用戶 B,我們就在圖中畫一條從 A 到 B 的帶箭頭的邊,來(lái)表示邊的方向。如果用戶 A 和用戶 B 互相關(guān)注了,那我們就畫一條從 A 指向 B 的邊,再畫一條從 B 指向 A 的邊。我們把這種邊有方向的圖叫作“有向圖”。以此類推,我們把邊沒(méi)有方向的圖就叫作“無(wú)向圖”。

無(wú)向圖中有“度”這個(gè)概念,表示一個(gè)頂點(diǎn)有多少條邊。在有向圖中,我們把度分為入度(In-degree)和出度(Out-degree)。

頂點(diǎn)的入度,表示有多少條邊指向這個(gè)頂點(diǎn);頂點(diǎn)的出度,表示有多少條邊是以這個(gè)頂點(diǎn)為起點(diǎn)指向其他頂點(diǎn)。對(duì)應(yīng)到微博的例子,入度就表示有多少粉絲,出度就表示關(guān)注了多少人。

QQ 中的社交關(guān)系要更復(fù)雜的一點(diǎn)。不知道你有沒(méi)有留意過(guò) QQ 親密度這樣一個(gè)功能。QQ 不僅記錄了用戶之間的好友關(guān)系,還記錄了兩個(gè)用戶之間的親密度,如果兩個(gè)用戶經(jīng)常往來(lái),那親密度就比較高;如果不經(jīng)常往來(lái),親密度就比較低。如何在圖中記錄這種好友關(guān)系的親密度呢?

這里就要用到另一種圖,帶權(quán)圖(weighted graph)。在帶權(quán)圖中,每條邊都有一個(gè)權(quán)重(weight),我們可以通過(guò)這個(gè)權(quán)重來(lái)表示 QQ 好友間的親密度。

如何在內(nèi)存中存儲(chǔ)圖這種數(shù)據(jù)結(jié)構(gòu)呢?

鄰接矩陣存儲(chǔ)方法:簡(jiǎn)單,浪費(fèi)內(nèi)存空間

圖最直觀的一種存儲(chǔ)方法就是,鄰接矩陣(Adjacency Matrix)。

鄰接矩陣的底層依賴一個(gè)二維數(shù)組。對(duì)于無(wú)向圖來(lái)說(shuō),如果頂點(diǎn) i 與頂點(diǎn) j 之間有邊,我們就將 A[i][j] 和 A[j][i] 標(biāo)記為 1;對(duì)于有向圖來(lái)說(shuō),如果頂點(diǎn) i 到頂點(diǎn) j 之間,有一條箭頭從頂點(diǎn) i 指向頂點(diǎn) j 的邊,那我們就將 A[i][j] 標(biāo)記為 1。同理,如果有一條箭頭從頂點(diǎn) j 指向頂點(diǎn) i 的邊,我們就將 A[j][i] 標(biāo)記為 1。對(duì)于帶權(quán)圖,數(shù)組中就存儲(chǔ)相應(yīng)的權(quán)重。

如果我們存儲(chǔ)的是稀疏圖(Sparse Matrix),也就是說(shuō),頂點(diǎn)很多,但每個(gè)頂點(diǎn)的邊并不多,那鄰接矩陣的存儲(chǔ)方法就更加浪費(fèi)空間了。比如微信有好幾億的用戶,對(duì)應(yīng)到圖上就是好幾億的頂點(diǎn)。但是每個(gè)用戶的好友并不會(huì)很多,一般也就三五百個(gè)而已。如果我們用鄰接矩陣來(lái)存儲(chǔ),那絕大部分的存儲(chǔ)空間都被浪費(fèi)了。

但這也并不是說(shuō),鄰接矩陣的存儲(chǔ)方法就完全沒(méi)有優(yōu)點(diǎn)。首先,鄰接矩陣的存儲(chǔ)方式簡(jiǎn)單、直接,因?yàn)榛跀?shù)組,所以在獲取兩個(gè)頂點(diǎn)的關(guān)系時(shí),就非常高效。其次,用鄰接矩陣存儲(chǔ)圖的另外一個(gè)好處是方便計(jì)算。這是因?yàn)?#xff0c;用鄰接矩陣的方式存儲(chǔ)圖,可以將很多圖的運(yùn)算轉(zhuǎn)換成矩陣之間的運(yùn)算。比如求解最短路徑問(wèn)題時(shí)會(huì)提到一個(gè)Floyd-Warshall 算法,就是利用矩陣循環(huán)相乘若干次得到結(jié)果。

鄰接表存儲(chǔ)方法

鄰接表(Adjacency List)。

鄰接表是不是有點(diǎn)像散列表?每個(gè)頂點(diǎn)對(duì)應(yīng)一條鏈表,鏈表中存儲(chǔ)的是與這個(gè)頂點(diǎn)相連接的其他頂點(diǎn)。

圖中畫的是一個(gè)有向圖的鄰接表存儲(chǔ)方式,每個(gè)頂點(diǎn)對(duì)應(yīng)的鏈表里面,存儲(chǔ)的是指向的頂點(diǎn)。對(duì)于無(wú)向圖來(lái)說(shuō),也是類似的,不過(guò),每個(gè)頂點(diǎn)的鏈表中存儲(chǔ)的,是跟這個(gè)頂點(diǎn)有邊相連的頂點(diǎn),你可以自己畫下。

鄰接矩陣存儲(chǔ)起來(lái)比較浪費(fèi)空間,但是使用起來(lái)比較節(jié)省時(shí)間。相反,鄰接表存儲(chǔ)起來(lái)比較節(jié)省空間,但是使用起來(lái)就比較耗時(shí)間。

就像圖中的例子,如果我們要確定,是否存在一條從頂點(diǎn) 2 到頂點(diǎn) 4 的邊,那我們就要遍歷頂點(diǎn) 2 對(duì)應(yīng)的那條鏈表,看鏈表中是否存在頂點(diǎn) 4。而且,我們前面也講過(guò),鏈表的存儲(chǔ)方式對(duì)緩存不友好。所以,比起鄰接矩陣的存儲(chǔ)方式,在鄰接表中查詢兩個(gè)頂點(diǎn)之間的關(guān)系就沒(méi)那么高效了。

在散列表那幾節(jié)里,我講到,在基于鏈表法解決沖突的散列表中,如果鏈過(guò)長(zhǎng),為了提高查找效率,我們可以將鏈表?yè)Q成其他更加高效的數(shù)據(jù)結(jié)構(gòu),比如平衡二叉查找樹等。我們剛剛也講到,鄰接表長(zhǎng)得很像散列。所以,我們也可以將鄰接表同散列表一樣進(jìn)行“改進(jìn)升級(jí)”。

我們可以將鄰接表中的鏈表改成平衡二叉查找樹。實(shí)際開發(fā)中,我們可以選擇用紅黑樹。這樣,我們就可以更加快速地查找兩個(gè)頂點(diǎn)之間是否存在邊了。當(dāng)然,這里的二叉查找樹可以換成其他動(dòng)態(tài)數(shù)據(jù)結(jié)構(gòu),比如跳表、散列表等。除此之外,我們還可以將鏈表改成有序動(dòng)態(tài)數(shù)組,可以通過(guò)二分查找的方法來(lái)快速定位兩個(gè)頂點(diǎn)之間否是存在邊。

如何存儲(chǔ)微博、微信等社交網(wǎng)絡(luò)中的好友關(guān)系?

數(shù)據(jù)結(jié)構(gòu)是為算法服務(wù)的,所以具體選擇哪種存儲(chǔ)方法,與期望支持的操作有關(guān)系。針對(duì)微博用戶關(guān)系,假設(shè)我們需要支持下面這樣幾個(gè)操作:

  • 判斷用戶 A 是否關(guān)注了用戶 B;
  • 判斷用戶 A 是否是用戶 B 的粉絲;
  • 用戶 A 關(guān)注用戶 B;
  • 用戶 A 取消關(guān)注用戶 B;
  • 根據(jù)用戶名稱的首字母排序,分頁(yè)獲取用戶的粉絲列表;
  • 根據(jù)用戶名稱的首字母排序,分頁(yè)獲取用戶的關(guān)注列表。

關(guān)于如何存儲(chǔ)一個(gè)圖,前面我們講到兩種主要的存儲(chǔ)方法,鄰接矩陣和鄰接表。因?yàn)樯缃痪W(wǎng)絡(luò)是一張稀疏圖,使用鄰接矩陣存儲(chǔ)比較浪費(fèi)存儲(chǔ)空間。所以,這里我們采用鄰接表來(lái)存儲(chǔ)。

不過(guò),用一個(gè)鄰接表來(lái)存儲(chǔ)這種有向圖是不夠的。我們?nèi)ゲ檎夷硞€(gè)用戶關(guān)注了哪些用戶非常容易,但是如果要想知道某個(gè)用戶都被哪些用戶關(guān)注了,也就是用戶的粉絲列表,是非常困難的。

基于此,我們需要一個(gè)逆鄰接表。鄰接表中存儲(chǔ)了用戶的關(guān)注關(guān)系,逆鄰接表中存儲(chǔ)的是用戶的被關(guān)注關(guān)系。對(duì)應(yīng)到圖上,鄰接表中,每個(gè)頂點(diǎn)的鏈表中,存儲(chǔ)的就是這個(gè)頂點(diǎn)指向的頂點(diǎn),逆鄰接表中,每個(gè)頂點(diǎn)的鏈表中,存儲(chǔ)的是指向這個(gè)頂點(diǎn)的頂點(diǎn)。如果要查找某個(gè)用戶關(guān)注了哪些用戶,我們可以在鄰接表中查找;如果要查找某個(gè)用戶被哪些用戶關(guān)注了,我們從逆鄰接表中查找。

基礎(chǔ)的鄰接表不適合快速判斷兩個(gè)用戶之間是否是關(guān)注與被關(guān)注的關(guān)系,所以我們選擇改進(jìn)版本,將鄰接表中的鏈表改為支持快速查找的動(dòng)態(tài)數(shù)據(jù)結(jié)構(gòu)。選擇哪種動(dòng)態(tài)數(shù)據(jù)結(jié)構(gòu)呢?紅黑樹、跳表、有序動(dòng)態(tài)數(shù)組還是散列表呢?

因?yàn)槲覀冃枰凑沼脩裘Q的首字母排序,分頁(yè)來(lái)獲取用戶的粉絲列表或者關(guān)注列表,用跳表這種結(jié)構(gòu)再合適不過(guò)了。這是因?yàn)?#xff0c;跳表插入、刪除、查找都非常高效,時(shí)間復(fù)雜度是 O(logn),空間復(fù)雜度上稍高,是 O(n)。最重要的一點(diǎn),跳表中存儲(chǔ)的數(shù)據(jù)本來(lái)就是有序的了,分頁(yè)獲取粉絲列表或關(guān)注列表,就非常高效。

如果對(duì)于小規(guī)模的數(shù)據(jù),比如社交網(wǎng)絡(luò)中只有幾萬(wàn)、幾十萬(wàn)個(gè)用戶,我們可以將整個(gè)社交關(guān)系存儲(chǔ)在內(nèi)存中,上面的解決思路是沒(méi)有問(wèn)題的。但是如果像微博那樣有上億的用戶,數(shù)據(jù)規(guī)模太大,我們就無(wú)法全部存儲(chǔ)在內(nèi)存中了。這個(gè)時(shí)候該怎么辦呢?

我們可以通過(guò)哈希算法等數(shù)據(jù)分片方式,將鄰接表存儲(chǔ)在不同的機(jī)器上。你可以看下面這幅圖,我們?cè)跈C(jī)器 1 上存儲(chǔ)頂點(diǎn) 1,2,3 的鄰接表,在機(jī)器 2 上,存儲(chǔ)頂點(diǎn) 4,5 的鄰接表。逆鄰接表的處理方式也一樣。當(dāng)要查詢頂點(diǎn)與頂點(diǎn)關(guān)系的時(shí)候,我們就利用同樣的哈希算法,先定位頂點(diǎn)所在的機(jī)器,然后再在相應(yīng)的機(jī)器上查找。

另外一種解決思路,就是利用外部存儲(chǔ)(比如硬盤),因?yàn)橥獠看鎯?chǔ)的存儲(chǔ)空間要比內(nèi)存會(huì)寬裕很多。數(shù)據(jù)庫(kù)是我們經(jīng)常用來(lái)持久化存儲(chǔ)關(guān)系數(shù)據(jù)的,所以我這里介紹一種數(shù)據(jù)庫(kù)的存儲(chǔ)方式。

我用下面這張表來(lái)存儲(chǔ)這樣一個(gè)圖。為了高效地支持前面定義的操作,我們可以在表上建立多個(gè)索引,比如第一列、第二列,給這兩列都建立索引。

微信好友關(guān)系存儲(chǔ)方式。無(wú)向圖,也可以使用鄰接表的方式存儲(chǔ)每個(gè)人所對(duì)應(yīng)的好友列表。為了支持快速查找,好友列表可以使用紅黑樹存儲(chǔ)。

字符串匹配算法之

比較簡(jiǎn)單的、好理解的,它們分別是:BF 算法和 RK 算法。單模式串匹配的算法,一個(gè)串跟一個(gè)串進(jìn)行匹配

比較難理解、但更加高效的,它們是:BM 算法和 KMP 算法。在一個(gè)串中同時(shí)查找多個(gè)串,它們分別是 Trie 樹和 AC 自動(dòng)機(jī)。

RK 算法是 BF 算法的改進(jìn),它巧妙借助了我們前面講過(guò)的哈希算法,讓匹配的效率有了很大的提升。那RK 算法是如何借助哈希算法來(lái)實(shí)現(xiàn)高效字符串匹配的呢

BF 算法

BF 算法中文叫作暴力匹配算法,也叫樸素匹配算法。從名字可以看出,這種算法的字符串匹配方式很“暴力”,當(dāng)然也就會(huì)比較簡(jiǎn)單、好懂,但相應(yīng)的性能也不高。

先定義兩個(gè)概念,分別是主串模式串。(在A中查找B,A主串,長(zhǎng)度n;B模式串,長(zhǎng)度m)

,BF 算法的思想可以用一句話來(lái)概括,我們?cè)谥鞔?#xff0c;檢查起始位置分別是 0、1、2…n-m 且長(zhǎng)度為 m 的 n-m+1 個(gè)子串,看有沒(méi)有跟模式串匹配的

在極端情況下,比如主串是“aaaaa…aaaaaa”(省略號(hào)表示有很多重復(fù)的字符 a),模式串是“aaaaab”。我們每次都比對(duì) m 個(gè)字符,要比對(duì) n-m+1 次,所以,這種算法的最壞情況時(shí)間復(fù)雜度是 O(n*m)。

盡管理論上,BF 算法的時(shí)間復(fù)雜度很高,是 O(n*m),但在實(shí)際的開發(fā)中,它卻是一個(gè)比較常用的字符串匹配算法。為什么這么說(shuō)呢?原因有兩點(diǎn)。

第一,實(shí)際的軟件開發(fā)中,大部分情況下,模式串和主串的長(zhǎng)度都不會(huì)太長(zhǎng)。而且每次模式串與主串中的子串匹配的時(shí)候,當(dāng)中途遇到不能匹配的字符的時(shí)候,就可以就停止了,不需要把 m 個(gè)字符都比對(duì)一下。所以,盡管理論上的最壞情況時(shí)間復(fù)雜度是 O(n*m),但是,統(tǒng)計(jì)意義上,大部分情況下,算法執(zhí)行效率要比這個(gè)高很多。

第二,樸素字符串匹配算法思想簡(jiǎn)單,代碼實(shí)現(xiàn)也非常簡(jiǎn)單。簡(jiǎn)單意味著不容易出錯(cuò),如果有 bug 也容易暴露和修復(fù)。在工程中,在滿足性能要求的前提下,簡(jiǎn)單是首選。這也是我們常說(shuō)的KISS(Keep it Simple and Stupid)設(shè)計(jì)原則。

所以,在實(shí)際的軟件開發(fā)中,絕大部分情況下,樸素的字符串匹配算法就夠用了。

RK 算法

RK 算法,其實(shí)就是剛剛講的 BF 算法的升級(jí)版。

BF 算法,如果模式串長(zhǎng)度為 m,主串長(zhǎng)度為 n,那在主串中,就會(huì)有 n-m+1 個(gè)長(zhǎng)度為 m 的子串,我們只需要暴力地對(duì)比這 n-m+1 個(gè)子串與模式串,就可以找出主串與模式串匹配的子串。

但是,每次檢查主串與子串是否匹配,需要依次比對(duì)每個(gè)字符,所以 BF 算法的時(shí)間復(fù)雜度就比較高,是 O(n*m)。我們對(duì)樸素的字符串匹配算法稍加改造,引入哈希算法,時(shí)間復(fù)雜度立刻就會(huì)降低。

RK 算法的思路是這樣的:

我們通過(guò)哈希算法對(duì)主串中的 n-m+1 個(gè)子串分別求哈希值,然后逐個(gè)與模式串的哈希值比較大小。如果某個(gè)子串的哈希值與模式串相等,那就說(shuō)明對(duì)應(yīng)的子串和模式串匹配了(這里先不考慮哈希沖突的問(wèn)題,后面我們會(huì)講到)。因?yàn)?strong>哈希值是一個(gè)數(shù)字,數(shù)字之間比較是否相等是非常快速的,所以模式串和子串比較的效率就提高了。

不過(guò),通過(guò)哈希算法計(jì)算子串的哈希值的時(shí)候,我們需要遍歷子串中的每個(gè)字符。盡管模式串與子串比較的效率提高了,但是,算法整體的效率并沒(méi)有提高。有沒(méi)有方法可以提高哈希算法計(jì)算子串哈希值的效率呢?

這就需要哈希算法設(shè)計(jì)的非常有技巧了。我們假設(shè)要匹配的字符串的字符集中只包含 K 個(gè)字符,我們可以用一個(gè) K 進(jìn)制數(shù)來(lái)表示一個(gè)子串,這個(gè) K 進(jìn)制數(shù)轉(zhuǎn)化成十進(jìn)制數(shù),作為子串的哈希值。表述起來(lái)有點(diǎn)抽象,我舉了一個(gè)例子,看完你應(yīng)該就能懂了。

比如要處理的字符串只包含 a~z 這 26 個(gè)小寫字母,那我們就用二十六進(jìn)制來(lái)表示一個(gè)字符串。我們把 a~z 這 26 個(gè)字符映射到 0~25 這 26 個(gè)數(shù)字,a 就表示 0,b 就表示 1,以此類推,z 表示 25。

在十進(jìn)制的表示法中,一個(gè)數(shù)字的值是通過(guò)下面的方式計(jì)算出來(lái)的。對(duì)應(yīng)到二十六進(jìn)制,一個(gè)包含 a 到 z 這 26 個(gè)字符的字符串,計(jì)算哈希的時(shí)候,我們只需要把進(jìn)位從 10 改成 26 就可以

這個(gè)哈希算法你應(yīng)該看懂了吧?現(xiàn)在,為了方便解釋,在下面的講解中,我假設(shè)字符串中只包含 a~z 這 26 個(gè)小寫字符,我們用二十六進(jìn)制來(lái)表示一個(gè)字符串,對(duì)應(yīng)的哈希值就是二十六進(jìn)制數(shù)轉(zhuǎn)化成十進(jìn)制的結(jié)果。

這種哈希算法有一個(gè)特點(diǎn),在主串中,相鄰兩個(gè)子串的哈希值的計(jì)算公式有一定關(guān)系。我這有個(gè)個(gè)例子,你先找一下規(guī)律,再來(lái)看我后面的講解。

從這里例子中,我們很容易就能得出這樣的規(guī)律:相鄰兩個(gè)子串 s[i-1] 和 s[i](i 表示子串在主串中的起始位置,子串的長(zhǎng)度都為 m),對(duì)應(yīng)的哈希值計(jì)算公式有交集,也就是說(shuō),我們可以使用 s[i-1] 的哈希值很快的計(jì)算出 s[i] 的哈希值。如果用公式表示的話,就是下面這個(gè)樣子:

不過(guò),這里有一個(gè)小細(xì)節(jié)需要注意,那就是 26^(m-1) 這部分的計(jì)算,我們可以通過(guò)查表的方法來(lái)提高效率。我們事先計(jì)算好 260、261、262……26(m-1),并且存儲(chǔ)在一個(gè)長(zhǎng)度為 m 的數(shù)組中,公式中的“次方”就對(duì)應(yīng)數(shù)組的下標(biāo)。當(dāng)我們需要計(jì)算 26 的 x 次方的時(shí)候,就可以從數(shù)組的下標(biāo)為 x 的位置取值,直接使用,省去了計(jì)算的時(shí)間。

我們開頭的時(shí)候提過(guò),RK 算法的效率要比 BF 算法高,現(xiàn)在,我們就來(lái)分析一下,RK 算法的時(shí)間復(fù)雜度到底是多少呢?

整個(gè) RK 算法包含兩部分,計(jì)算子串哈希值模式串哈希值與子串哈希值之間的比較。第一部分,我們前面也分析了,可以通過(guò)設(shè)計(jì)特殊的哈希算法,只需要掃描一遍主串就能計(jì)算出所有子串的哈希值了,所以這部分的時(shí)間復(fù)雜度是 O(n)

模式串哈希值與每個(gè)子串哈希值之間的比較的時(shí)間復(fù)雜度是 O(1),總共需要比較 n-m+1 個(gè)子串的哈希值,所以,這部分的時(shí)間復(fù)雜度也是 O(n)。所以,RK 算法整體的時(shí)間復(fù)雜度就是 O(n)。

這里還有一個(gè)問(wèn)題就是,模式串很長(zhǎng),相應(yīng)的主串中的子串也會(huì)很長(zhǎng),通過(guò)上面的哈希算法計(jì)算得到的哈希值就可能很大,如果超過(guò)了計(jì)算機(jī)中整型數(shù)據(jù)可以表示的范圍,那該如何解決呢?

剛剛我們?cè)O(shè)計(jì)的哈希算法是沒(méi)有散列沖突的,也就是說(shuō),一個(gè)字符串與一個(gè)二十六進(jìn)制數(shù)一一對(duì)應(yīng),不同的字符串的哈希值肯定不一樣。因?yàn)槲覀兪腔谶M(jìn)制來(lái)表示一個(gè)字符串的,你可以類比成十進(jìn)制、十六進(jìn)制來(lái)思考一下。實(shí)際上,我們?yōu)榱四軐⒐V德湓谡蛿?shù)據(jù)范圍內(nèi),可以犧牲一下,允許哈希沖突。這個(gè)時(shí)候哈希算法該如何設(shè)計(jì)呢?

哈希算法的設(shè)計(jì)方法有很多,我舉一個(gè)例子說(shuō)明一下。假設(shè)字符串中只包含 a~z 這 26 個(gè)英文字母,那我們每個(gè)字母對(duì)應(yīng)一個(gè)數(shù)字,比如 a 對(duì)應(yīng) 1,b 對(duì)應(yīng) 2,以此類推,z 對(duì)應(yīng) 26。我們可以把字符串中每個(gè)字母對(duì)應(yīng)的數(shù)字相加,最后得到的和作為哈希值。這種哈希算法產(chǎn)生的哈希值的數(shù)據(jù)范圍就相對(duì)要小很多了。

不過(guò),你也應(yīng)該發(fā)現(xiàn),這種哈希算法的哈希沖突概率也是挺高的。當(dāng)然,我只是舉了一個(gè)最簡(jiǎn)單的設(shè)計(jì)方法,還有很多更加優(yōu)化的方法,比如將每一個(gè)字母從小到大對(duì)應(yīng)一個(gè)素?cái)?shù),而不是 1,2,3……這樣的自然數(shù),這樣沖突的概率就會(huì)降低一些。

那現(xiàn)在新的問(wèn)題來(lái)了。之前我們只需要比較一下模式串和子串的哈希值,如果兩個(gè)值相等,那這個(gè)子串就一定可以匹配模式串。但是,當(dāng)存在哈希沖突的時(shí)候,有可能存在這樣的情況,子串和模式串的哈希值雖然是相同的,但是兩者本身并不匹配。

實(shí)際上,解決方法很簡(jiǎn)單。當(dāng)我們發(fā)現(xiàn)一個(gè)子串的哈希值跟模式串的哈希值相等的時(shí)候,我們只需要再對(duì)比一下子串和模式串本身就好了。當(dāng)然,如果子串的哈希值與模式串的哈希值不相等,那對(duì)應(yīng)的子串和模式串肯定也是不匹配的,就不需要比對(duì)子串和模式串本身了。

RK 算法是借助哈希算法對(duì) BF 算法進(jìn)行改造,即對(duì)每個(gè)子串分別求哈希值,然后拿子串的哈希值與模式串的哈希值比較,減少了比較的時(shí)間。所以,理想情況下,RK 算法的時(shí)間復(fù)雜度是 O(n),跟 BF 算法相比,效率提高了很多。不過(guò)這樣的效率取決于哈希算法的設(shè)計(jì)方法,如果存在沖突的情況下,時(shí)間復(fù)雜度可能會(huì)退化。極端情況下,哈希算法大量沖突,時(shí)間復(fù)雜度就退化為 O(n*m)。但也不要太悲觀,一般情況下,沖突不會(huì)很多,RK 算法的效率還是比 BF 算法高的。

BM 算法

BM算法原理

BM算法定義了兩個(gè)規(guī)則:

壞字符規(guī)則:當(dāng)文本串中的某個(gè)字符跟模式串的某個(gè)字符不匹配時(shí),我們稱文本串中的這個(gè)失配字符為壞字符,此時(shí)模式串需要向右移動(dòng),移動(dòng)的位數(shù) = 壞字符在模式串中的位置 - 壞字符在模式串中最右出現(xiàn)的位置。此外,如果"壞字符"不包含在模式串之中,則最右出現(xiàn)位置為-1。
好后綴規(guī)則:當(dāng)字符失配時(shí),后移位數(shù) = 好后綴在模式串中的位置 - 好后綴在模式串上一次出現(xiàn)的位置,且如果好后綴在模式串中沒(méi)有再次出現(xiàn),則為-1。

下面舉例說(shuō)明BM算法。例如,給定文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”,現(xiàn)要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。

  • 首先,“文本串"與"模式串"頭部對(duì)齊,從尾部開始比較。”S“與”E“不匹配。這時(shí),”S“就被稱為"壞字符”(bad character),即不匹配的字符,它對(duì)應(yīng)著模式串的第6位。且"S“不包含在模式串”EXAMPLE“之中(相當(dāng)于最右出現(xiàn)位置是-1),這意味著可以把模式串后移6-(-1)=7位,從而直接移到”S"的后一位。

  • 依然從尾部開始比較,發(fā)現(xiàn)"P“與”E“不匹配,所以”P“是"壞字符”。但是,"P“包含在模式串”EXAMPLE"之中。因?yàn)椤癙”這個(gè)“壞字符”對(duì)應(yīng)著模式串的第6位(從0開始編號(hào)),且在模式串中的最右出現(xiàn)位置為4,所以,將模式串后移6-4=2位,兩個(gè)"P"對(duì)齊。

  • 依次比較,得到 “MPLE”匹配,稱為"好后綴"(good suffix),即所有尾部匹配的字符串。注意,“MPLE”、“PLE”、“LE”、"E"都是好后綴。

  • 發(fā)現(xiàn)“I”與“A”不匹配:“I”是壞字符。如果是根據(jù)壞字符規(guī)則,此時(shí)模式串應(yīng)該后移2-(-1)=3位。問(wèn)題是,有沒(méi)有更優(yōu)的移法?

  • 更優(yōu)的移法是利用好后綴規(guī)則:當(dāng)字符失配時(shí),后移位數(shù) = 好后綴在模式串中的位置 - 好后綴在模式串中上一次出現(xiàn)的位置,且如果好后綴在模式串中沒(méi)有再次出現(xiàn),則為-1。所有的“好后綴”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的頭部出現(xiàn),所以后移6-0=6位。可以看出,“壞字符規(guī)則”只能移3位,“好后綴規(guī)則”可以移6位。每次后移這兩個(gè)規(guī)則之中的較大值。這兩個(gè)規(guī)則的移動(dòng)位數(shù),只與模式串有關(guān),與原文本串無(wú)關(guān)。

  • 繼續(xù)從尾部開始比較,“P”與“E”不匹配,因此“P”是“壞字符”,根據(jù)“壞字符規(guī)則”,后移 6 - 4 = 2位。因?yàn)槭亲詈笠晃痪褪?#xff0c;尚未獲得好后綴。

好后綴加深理解

由上可知,BM算法不僅效率高,而且構(gòu)思巧妙,容易理解。壞字符規(guī)則相對(duì)而言比較好理解,好后綴如果還不理解,我這里再繼續(xù)舉個(gè)例子解釋一下,這里加深理解。

  • 如果模式串中存在已經(jīng)匹配成功的好后綴,則把目標(biāo)串與好后綴對(duì)齊,然后從模式串的最尾元素開始往前匹配。

  • 如果無(wú)法找到匹配好的后綴,找一個(gè)匹配的最長(zhǎng)的前綴,讓目標(biāo)串與最長(zhǎng)的前綴對(duì)齊(如果這個(gè)前綴存在的話)。模式串[m-s,m] = 模式串[0,s] 。

  • 如果完全不存在和好后綴匹配的子串,則右移整個(gè)模式串。

先實(shí)現(xiàn)好字符規(guī)則

BM算法還是很好理解的,其實(shí)如果你之前學(xué)習(xí)KMP算法你也會(huì)有同樣的感受,KMP算法理解起來(lái)不是很難,但是重點(diǎn)在于怎么去實(shí)現(xiàn)next數(shù)組。BM算法也是,原理理解起來(lái)其實(shí)非常的容易,不過(guò)怎么去實(shí)現(xiàn),沒(méi)有一套標(biāo)準(zhǔn)的代碼。不過(guò)可以研究別人的代碼,然后實(shí)現(xiàn)一套盡量適合精簡(jiǎn)的代碼。還是一樣,一步一步來(lái),我們先來(lái)實(shí)現(xiàn)好字符規(guī)則。好字符規(guī)則的代碼如下,我會(huì)在代碼中必要的地方加入注釋,輔助理解,代碼是最好的老師。

public static void getRight(String pat, int[] right) {//首先創(chuàng)建一個(gè)模式串的字符位置的數(shù)組,初始化為-1,就是用于記錄模式串//中,每個(gè)字符在模式串中的相對(duì)位置,這里直接用的是256,也//就是ASCII碼的最大值,當(dāng)然,如果你的字符串中只限制了26個(gè)//字符,你也可以直接使用26for (int i = 0; i < 256; i++) {right[i] = -1;}//值得一提的是,通過(guò)這種方式,可以你會(huì)發(fā)現(xiàn),如果模式串中存在相同的//字符,那么right數(shù)組中,記錄的是最右的那個(gè)字符的位置for (int j = 0; j < pat.length(); j++) {right[pat.charAt(j)] = j;} }public static int Search(String txt, String pat, int[] right) {int M = txt.length();//主串的長(zhǎng)度int N = pat.length();//模式串的長(zhǎng)度int skip;//用于記錄跳過(guò)幾個(gè)字符for (int i = 0; i < M - N; i += skip) {skip = 0;//每次進(jìn)入循環(huán)要記得初始化為0for (int j = N - 1; j >= 0; j--) {//不相等,意味著出現(xiàn)壞字符,按照上面的規(guī)則移動(dòng)if (pat.charAt(j) != txt.charAt(i + j)) {skip = j - right[txt.charAt(i + j)];//skip之所以會(huì)小于1,可能是因?yàn)閴淖址谀J酱凶钣业奈恢?#xff0c;可能//在j指向字符的右側(cè),就是已經(jīng)越過(guò)了。if (skip < 1) skip = 1;break;}}//注意了這個(gè)時(shí)候循環(huán)了一遍之后,skip如果等于0,意味著沒(méi)有壞字符出現(xiàn),所以//匹配成功,返回當(dāng)前字符i的位置if (skip == 0)return i;}return -1; }

完整BM實(shí)現(xiàn)

上面的代碼不難理解,相信你已經(jīng)看懂了,那么接下來(lái)也不用單獨(dú)來(lái)講好后綴的實(shí)現(xiàn),直接上完整的實(shí)現(xiàn)代碼。因?yàn)橥暾腂M實(shí)現(xiàn)中,就是比較壞字符規(guī)則以及好后綴規(guī)則,哪個(gè)移動(dòng)的字符數(shù)更多,就使用哪個(gè)。老樣子,下面的代碼中我盡量的加注釋。

public static int pattern(String pattern, String target) {int tLen = target.length();//主串的長(zhǎng)度int pLen = pattern.length();//模式串的長(zhǎng)度//如果模式串比主串長(zhǎng),沒(méi)有可比性,直接返回-1if (pLen > tLen) {return -1;}int[] bad_table = build_bad_table(pattern);// 獲得壞字符數(shù)值的數(shù)組,實(shí)現(xiàn)看下面int[] good_table = build_good_table(pattern);// 獲得好后綴數(shù)值的數(shù)組,實(shí)現(xiàn)看下面for (int i = pLen - 1, j; i < tLen;) {System.out.println("跳躍位置:" + i);//這里和上面實(shí)現(xiàn)壞字符的時(shí)候不一樣的地方,我們之前提前求出壞字符以及好后綴//對(duì)應(yīng)的數(shù)值數(shù)組,所以,我們只要在一邊循環(huán)中進(jìn)行比較。還要說(shuō)明的一點(diǎn)是,這里//沒(méi)有使用skip記錄跳過(guò)的位置,直接針對(duì)主串中移動(dòng)的指針i進(jìn)行移動(dòng)for (j = pLen - 1; target.charAt(i) == pattern.charAt(j); i--, j--) {if (j == 0) {//指向模式串的首字符,說(shuō)明匹配成功,直接返回就可以了System.out.println("匹配成功,位置:" + i);//如果你還要匹配不止一個(gè)模式串,那么這里直接跳出這個(gè)循環(huán),并且讓i++//因?yàn)椴荒苤苯犹^(guò)整個(gè)已經(jīng)匹配的字符串,這樣的話可能會(huì)丟失匹配。 // i++; // 多次匹配 // break;return i;}}//如果出現(xiàn)壞字符,那么這個(gè)時(shí)候比較壞字符以及好后綴的數(shù)組,哪個(gè)大用哪個(gè)i += Math.max(good_table[pLen - j - 1], bad_table[target.charAt(i)]);}return -1; }//字符信息表 public static int[] build_bad_table(String pattern) {final int table_size = 256;//上面已經(jīng)解釋過(guò)了,字符的種類int[] bad_table = new int[table_size];//創(chuàng)建一個(gè)數(shù)組,用來(lái)記錄壞字符出現(xiàn)時(shí),應(yīng)該跳過(guò)的字符數(shù)int pLen = pattern.length();//模式串的長(zhǎng)度for (int i = 0; i < bad_table.length; i++) {bad_table[i] = pLen; //默認(rèn)初始化全部為匹配字符串長(zhǎng)度,因?yàn)楫?dāng)主串中的壞字符在模式串中沒(méi)有出//現(xiàn)時(shí),直接跳過(guò)整個(gè)模式串的長(zhǎng)度就可以了}for (int i = 0; i < pLen - 1; i++) {int k = pattern.charAt(i);//記錄下當(dāng)前的字符ASCII碼值//這里其實(shí)很值得思考一下,bad_table就不多說(shuō)了,是根據(jù)字符的ASCII值存儲(chǔ)//壞字符出現(xiàn)最右的位置,這在上面實(shí)現(xiàn)壞字符的時(shí)候也說(shuō)過(guò)了。不過(guò)你仔細(xì)思考//一下,為什么這里存的壞字符數(shù)值,是最右的那個(gè)壞字符相對(duì)于模式串最后一個(gè)//字符的位置?為什么?首先你要理解i的含義,這個(gè)i不是在這里的i,而是在上面//那個(gè)pattern函數(shù)的循環(huán)的那個(gè)i,為了方便我們稱呼為I,這個(gè)I很神奇,雖然I是//在主串上的指針,但是由于在循環(huán)中沒(méi)有使用skip來(lái)記錄,直接使用I隨著j匹配//進(jìn)行移動(dòng),也就意味著,在某種意義上,I也可以直接定位到模式串的相對(duì)位置,//理解了這一點(diǎn),就好理解在本循環(huán)中,i的行為了。//其實(shí)仔細(xì)去想一想,我們分情況來(lái)思考,如果模式串的最//后一個(gè)字符,也就是匹配開始的第一個(gè)字符,出現(xiàn)了壞字符,那么這個(gè)時(shí)候,直//接移動(dòng)這個(gè)數(shù)值,那么正好能讓最右的那個(gè)字符正對(duì)壞字符。那么如果不是第一個(gè)//字符出現(xiàn)壞字符呢?這種情況你仔細(xì)想一想,這種情況也就意味著出現(xiàn)了好后綴的//情況,假設(shè)我們將最右的字符正對(duì)壞字符bad_table[k] = pLen - 1 - i;}return bad_table; }//匹配偏移表 public static int[] build_good_table(String pattern) {int pLen = pattern.length();//模式串長(zhǎng)度int[] good_table = new int[pLen];//創(chuàng)建一個(gè)數(shù)組,存好后綴數(shù)值//用于記錄最新前綴的相對(duì)位置,初始化為模式串長(zhǎng)度,因?yàn)橐馑季褪钱?dāng)前后綴字符串為空//要明白lastPrefixPosition 的含義int lastPrefixPosition = pLen;for (int i = pLen - 1; i >= 0; --i) {if (isPrefix(pattern, i + 1)) {//如果當(dāng)前的位置存在前綴匹配,那么記錄當(dāng)前位置lastPrefixPosition = i + 1;}good_table[pLen - 1 - i] = lastPrefixPosition - i + pLen - 1;}for (int i = 0; i < pLen - 1; ++i) {//計(jì)算出指定位置匹配的后綴的字符串長(zhǎng)度int slen = suffixLength(pattern, i);good_table[slen] = pLen - 1 - i + slen;}return good_table; }//前綴匹配 private static boolean isPrefix(String pattern, int p) {int patternLength = pattern.length();//模式串長(zhǎng)度//這里j從模式串第一個(gè)字符開始,i從指定的字符位置開始,通過(guò)循環(huán)判斷當(dāng)前指定的位置p//之后的字符串是否匹配模式串前綴for (int i = p, j = 0; i < patternLength; ++i, ++j) {if (pattern.charAt(i) != pattern.charAt(j)) {return false;}}return true; }//后綴匹配 private static int suffixLength(String pattern, int p) {int pLen = pattern.length();int len = 0;for (int i = p, j = pLen - 1; i >= 0 && pattern.charAt(i) == pattern.charAt(j); i--, j--) {len += 1;}return len; }

理解一下上面代碼,這里我針對(duì)上面代碼舉個(gè)例子,計(jì)算之后的兩張表的數(shù)值如下:

版權(quán)聲明:本文為CSDN博主「BoCong-Deng」的原創(chuàng)文章,遵循CC 4.0 BY-SA版權(quán)協(xié)議,轉(zhuǎn)載請(qǐng)附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/DBC_121/article/details/105569440

KMP算法

KMP算法是一種字符串匹配算法,可以在 O(n+m) 的時(shí)間復(fù)雜度內(nèi)實(shí)現(xiàn)兩個(gè)字符串的匹配。本文將引導(dǎo)您學(xué)習(xí)KMP算法,閱讀大約需要30分鐘。

字符串匹配問(wèn)題

所謂字符串匹配,是這樣一種問(wèn)題:“字符串 P 是否為字符串 S 的子串?如果是,它出現(xiàn)在 S 的哪些位置?” 其中 S 稱為**主串;P 稱為模式串**。下面的圖片展示了一個(gè)例子。

主串是莎翁那句著名的 “to be or not to be”,這里刪去了空格。“no” 這個(gè)模式串的匹配結(jié)果是“出現(xiàn)了一次,從S[6]開始”;“ob”這個(gè)模式串的匹配結(jié)果是“出現(xiàn)了兩次,分別從s[1]、s[10]開始”。按慣例,主串和模式串都以0開始編號(hào)。
  字符串匹配是一個(gè)非常頻繁的任務(wù)。例如,今有一份名單,你急切地想知道自己在不在名單上;又如,假設(shè)你拿到了一份文獻(xiàn),你希望快速地找到某個(gè)關(guān)鍵字(keyword)所在的章節(jié)……凡此種種,不勝枚舉。
  我們先從最樸素的Brute-Force算法開始講起。

Brute-Force

顧名思義,Brute-Force是一個(gè)純暴力算法。說(shuō)句題外話,我懷疑,“暴力”一詞在算法領(lǐng)域表示“窮舉、極低效率的實(shí)現(xiàn)”,可能就是源于這個(gè)英文詞。
  首先,我們應(yīng)該如何實(shí)現(xiàn)兩個(gè)字符串 A,B 的比較?所謂字符串比較,就是問(wèn)“兩個(gè)字符串是否相等”。最樸素的思想,就是從前往后逐字符比較,一旦遇到不相同的字符,就返回False;如果兩個(gè)字符串都結(jié)束了,仍然沒(méi)有出現(xiàn)不對(duì)應(yīng)的字符,則返回True。實(shí)現(xiàn)如下:

既然我們可以知道“兩個(gè)字符串是否相等”,那么最樸素的字符串匹配算法 Brute-Force 就呼之欲出了——

  • 枚舉 i = 0, 1, 2 … , len(S)-len§
  • 將 S[i : i+len§] 與 P 作比較。如果一致,則找到了一個(gè)匹配。

現(xiàn)在我們來(lái)模擬 Brute-Force 算法,對(duì)主串 “AAAAAABC” 和模式串 “AAAB” 做匹配:

這是一個(gè)清晰明了的算法,實(shí)現(xiàn)也極其簡(jiǎn)單。下面給出Python和C++的實(shí)現(xiàn):

我們成功實(shí)現(xiàn)了 Brute-Force 算法。現(xiàn)在,我們需要對(duì)它的時(shí)間復(fù)雜度做一點(diǎn)討論。按照慣例,記 n = |S| 為串 S 的長(zhǎng)度,m = |P| 為串 P 的長(zhǎng)度。
  考慮“字符串比較”這個(gè)小任務(wù)的復(fù)雜度。最壞情況發(fā)生在:兩個(gè)字符串唯一的差別在最后一個(gè)字符。這種情況下,字符串比較必須走完整個(gè)字符串,才能給出結(jié)果,因此復(fù)雜度是 O(len) 的。

由此,不難想到 Brute-Force 算法所面對(duì)的最壞情況:主串形如“AAAAAAAAAAA…B”,而模式串形如“AAAAA…B”。每次字符串比較都需要付出 |P| 次字符比較的代價(jià),總共需要比較 |S| - |P| + 1次,因此總時(shí)間復(fù)雜度是 O(|P|?(|S|?|P|+1))O(|P|\cdot (|S| - |P| + 1) )O(|P|\cdot (|S| - |P| + 1) ) . 考慮到主串一般比模式串長(zhǎng)很多,故 Brute-Force 的復(fù)雜度是 O(|P|?|S|)O(|P| \cdot |S|)O(|P| \cdot |S|) ,也就是 O(nm)的。這太慢了!

Brute-Force的改進(jìn)思路

經(jīng)過(guò)剛剛的分析,您已經(jīng)看到,Brute-Force 慢得像爬一樣。它最壞的情況如下圖所示:

我們很難降低字符串比較的復(fù)雜度(因?yàn)楸容^兩個(gè)字符串,真的只能逐個(gè)比較字符)。因此,我們考慮降低比較的趟數(shù)。如果比較的趟數(shù)能降到足夠低,那么總的復(fù)雜度也將會(huì)下降很多。  要優(yōu)化一個(gè)算法,首先要回答的問(wèn)題是“我手上有什么信息?” 我們手上的信息是否足夠、是否有效,決定了我們能把算法優(yōu)化到何種程度。請(qǐng)記住:盡可能利用殘余的信息,是KMP算法的思想所在
  在 Brute-Force 中,如果從 S[i] 開始的那一趟比較失敗了,算法會(huì)直接開始嘗試從 S[i+1] 開始比較。這種行為,屬于典型的“沒(méi)有從之前的錯(cuò)誤中學(xué)到東西”。我們應(yīng)當(dāng)注意到,一次失敗的匹配,會(huì)給我們提供寶貴的信息——如果 S[i : i+len§] 與 P 的匹配是在第 r 個(gè)位置失敗的,那么從 S[i] 開始的 (r-1) 個(gè)連續(xù)字符,一定與 P 的前 (r-1) 個(gè)字符一模一樣!

需要實(shí)現(xiàn)的任務(wù)是“字符串匹配”,而每一次失敗都會(huì)給我們換來(lái)一些信息——能告訴我們,主串的某一個(gè)子串等于模式串的某一個(gè)前綴。但是這又有什么用呢?

跳過(guò)不可能成功的字符串比較

有些趟字符串比較是有可能會(huì)成功的;有些則毫無(wú)可能。我們剛剛提到過(guò),優(yōu)化 Brute-Force 的路線是“盡量減少比較的趟數(shù)”,而如果我們跳過(guò)那些絕不可能成功的字符串比較,則可以希望復(fù)雜度降低到能接受的范圍。
  那么,哪些字符串比較是不可能成功的?來(lái)看一個(gè)例子。已知信息如下:

  • 模式串 P = “abcabd”.
  • 和主串從S[0]開始匹配時(shí),在 P[5] 處失配。

首先,利用上一節(jié)的結(jié)論。既然是在 P[5] 失配的,那么說(shuō)明 S[0:5] 等于 P[0:5],即"abcab". 現(xiàn)在我們來(lái)考慮:從 S[1]、S[2]、S[3] 開始的匹配嘗試,有沒(méi)有可能成功?
  從 S[1] 開始肯定沒(méi)辦法成功,因?yàn)?S[1] = P[1] = ‘b’,和 P[0] 并不相等。從 S[2] 開始也是沒(méi)戲的,因?yàn)?S[2] = P[2] = ‘c’,并不等于P[0]. 但是從 S[3] 開始是有可能成功的——至少按照已知的信息,我們推不出矛盾。

帶著“跳過(guò)不可能成功的嘗試”的思想,我們來(lái)看next數(shù)組。

next數(shù)組

next數(shù)組是對(duì)于模式串而言的。P 的 next 數(shù)組定義為:next[i] 表示 P[0] ~ P[i] 這一個(gè)子串,使得 前k個(gè)字符恰等于后k個(gè)字符 的最大的k. 特別地,k不能取i+1(因?yàn)檫@個(gè)子串一共才 i+1 個(gè)字符,自己肯定與自己相等,就沒(méi)有意義了)。

上圖給出了一個(gè)例子。P=“abcabd"時(shí),next[4]=2,這是因?yàn)镻[0] ~ P[4] 這個(gè)子串是"abcab”,前兩個(gè)字符與后兩個(gè)字符相等,因此next[4]取2. 而next[5]=0,是因?yàn)?#34;abcabd"找不到前綴與后綴相同,因此只能取0.

如果把模式串視為一把標(biāo)尺,在主串上移動(dòng),那么 Brute-Force 就是每次失配之后只右移一位;改進(jìn)算法則是每次失配之后,移很多位,跳過(guò)那些不可能匹配成功的位置。但是該如何確定要移多少位呢?

在 S[0] 嘗試匹配,失配于 S[3] <=> P[3] 之后,我們直接把模式串往右移了兩位,讓 S[3] 對(duì)準(zhǔn) P[1]. 接著繼續(xù)匹配,失配于 S[8] <=> P[6], 接下來(lái)我們把 P 往右平移了三位,把 S[8] 對(duì)準(zhǔn) P[3]. 此后繼續(xù)匹配直到成功。
  我們應(yīng)該如何移動(dòng)這把標(biāo)尺?很明顯,如圖中藍(lán)色箭頭所示,舊的后綴要與新的前綴一致(如果不一致,那就肯定沒(méi)法匹配上了)!

回憶next數(shù)組的性質(zhì):P[0] 到 P[i] 這一段子串中,前next[i]個(gè)字符與后next[i]個(gè)字符一模一樣。既然如此,如果失配在 P[r], 那么P[0]~P[r-1]這一段里面,前next[r-1]個(gè)字符恰好和后next[r-1]個(gè)字符相等——也就是說(shuō),我們可以拿長(zhǎng)度為 next[r-1] 的那一段前綴,來(lái)頂替當(dāng)前后綴的位置,讓匹配繼續(xù)下去!
  您可以驗(yàn)證一下上面的匹配例子:P[3]失配后,把P[next[3-1]]也就是P[1]對(duì)準(zhǔn)了主串剛剛失配的那一位;P[6]失配后,把P[next[6-1]]也就是P[3]對(duì)準(zhǔn)了主串剛剛失配的那一位。

如上圖所示,綠色部分是成功匹配,失配于紅色部分。深綠色手繪線條標(biāo)出了相等的前綴和后綴,其長(zhǎng)度為next[右端]. 由于手繪線條部分的字符是一樣的,所以直接把前面那條移到后面那條的位置。因此說(shuō),next數(shù)組為我們?nèi)绾我苿?dòng)標(biāo)尺提供了依據(jù)。接下來(lái),我們實(shí)現(xiàn)這個(gè)優(yōu)化的算法。

利用next數(shù)組進(jìn)行匹配

了解了利用next數(shù)組加速字符串匹配的原理,我們接下來(lái)代碼實(shí)現(xiàn)之。分為兩個(gè)部分:建立next數(shù)組、利用next數(shù)組進(jìn)行匹配。
  首先是建立next數(shù)組。我們暫且用最樸素的做法,以后再回來(lái)優(yōu)化:

如上圖代碼所示,直接根據(jù)next數(shù)組的定義來(lái)建立next數(shù)組。不難發(fā)現(xiàn)它的復(fù)雜度是 O(m2)O(m2)O(m2) 的。
  接下來(lái),實(shí)現(xiàn)利用next數(shù)組加速字符串匹配。代碼如下:

如何分析這個(gè)字符串匹配的復(fù)雜度呢?乍一看,pos值可能不停地變成next[pos-1],代價(jià)會(huì)很高;但我們使用攤還分析,顯然pos值一共頂多自增len(S)次,因此pos值減少的次數(shù)不會(huì)高于len(S)次。由此,復(fù)雜度是可以接受的,不難分析出整個(gè)匹配算法的時(shí)間復(fù)雜度:O(n+m).

快速求next數(shù)組

終于來(lái)到了我們最后一個(gè)問(wèn)題——如何快速構(gòu)建next數(shù)組。
  首先說(shuō)一句:快速構(gòu)建next數(shù)組,是KMP算法的精髓所在,核心思想是“P自己與自己做匹配”。
  為什么這樣說(shuō)呢?回顧next數(shù)組的完整定義:

  • 定義 “k-前綴” 為一個(gè)字符串的前 k 個(gè)字符; “k-后綴” 為一個(gè)字符串的后 k 個(gè)字符。k 必須小于字符串長(zhǎng)度。
  • next[x] 定義為: P[0]~P[x] 這一段字符串,使得k-前綴恰等于k-后綴的最大的k.

這個(gè)定義中,不知不覺(jué)地就包含了一個(gè)匹配——前綴和后綴相等。接下來(lái),我們考慮采用遞推的方式求出next數(shù)組。如果next[0], next[1], … next[x-1]均已知,那么如何求出 next[x] 呢?

來(lái)分情況討論。首先,已經(jīng)知道了 next[x-1](以下記為now),如果 P[x] 與 P[now] 一樣,那最長(zhǎng)相等前后綴的長(zhǎng)度就可以擴(kuò)展一位,很明顯 next[x] = now + 1. 圖示如下。

剛剛解決了 P[x] = P[now] 的情況。那如果 P[x] 與 P[now] 不一樣,又該怎么辦?

如圖。長(zhǎng)度為 now 的子串 A 和子串 B 是 P[0]~P[x-1] 中最長(zhǎng)的公共前后綴。可惜 A 右邊的字符和 B 右邊的那個(gè)字符不相等,next[x]不能改成 now+1 了。因此,我們應(yīng)該縮短這個(gè)now,把它改成小一點(diǎn)的值,再來(lái)試試 P[x] 是否等于 P[now].
  now該縮小到多少呢?顯然,我們不想讓now縮小太多。因此我們決定,在保持“P[0]~P[x-1]的now-前綴仍然等于now-后綴”的前提下,讓這個(gè)新的now盡可能大一點(diǎn)。 P[0]~P[x-1] 的公共前后綴,前綴一定落在串A里面、后綴一定落在串B里面。換句話講:接下來(lái)now應(yīng)該改成:使得 A的k-前綴等于B的k-后綴 的最大的k.
  您應(yīng)該已經(jīng)注意到了一個(gè)非常強(qiáng)的性質(zhì)——串A和串B是相同的!B的后綴等于A的后綴!因此,使得A的k-前綴等于B的k-后綴的最大的k,其實(shí)就是串A的最長(zhǎng)公共前后綴的長(zhǎng)度 —— next[now-1]!

來(lái)看上面的例子。當(dāng)P[now]與P[x]不相等的時(shí)候,我們需要縮小now——把now變成next[now-1],直到P[now]=P[x]為止。P[now]=P[x]時(shí),就可以直接向右擴(kuò)展了。

代碼實(shí)現(xiàn)如下:

應(yīng)用攤還分析,不難證明構(gòu)建next數(shù)組的時(shí)間復(fù)雜度是O(m)的。至此,我們以O(shè)(n+m)的時(shí)間復(fù)雜度,實(shí)現(xiàn)了構(gòu)建next數(shù)組、利用next數(shù)組進(jìn)行字符串匹配。

以上就是KMP算法。它于1977年被提出,全稱 Knuth–Morris–Pratt 算法。讓我們記住前輩們的名字:Donald Knuth(K), James H. Morris(M), Vaughan Pratt§.
  希望本文對(duì)你有幫助。 本文在我博客的url是 https://ruanx.pw/kmp/ , 以后可能會(huì)更新。


最后附上洛谷P3375 【模板】KMP字符串匹配 的Python和Java版代碼:

轉(zhuǎn)載自:

作者:阮行止
鏈接:如何更好地理解和掌握 KMP 算法? - 阮行止的回答 - 知乎
來(lái)源:知乎
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。

Trie樹

搜索引擎的搜索關(guān)鍵詞提示功能,我想你應(yīng)該不陌生吧?為了方便快速輸入,當(dāng)你在搜索引擎的搜索框中,輸入要搜索的文字的某一部分的時(shí)候,搜索引擎就會(huì)自動(dòng)彈出下拉框,里面是各種關(guān)鍵詞提示。

什么是“Trie 樹”?

Trie 樹,也叫“字典樹”。顧名思義,它是一個(gè)樹形結(jié)構(gòu)。它是一種專門處理字符串匹配的數(shù)據(jù)結(jié)構(gòu),用來(lái)解決在一組字符串集合中快速查找某個(gè)字符串的問(wèn)題。

當(dāng)然,這樣一個(gè)問(wèn)題可以有多種解決方法,比如散列表、紅黑樹,或者我們前面幾節(jié)講到的一些字符串匹配算法,但是,Trie 樹在這個(gè)問(wèn)題的解決上,有它特有的優(yōu)點(diǎn)。不僅如此,Trie 樹能解決的問(wèn)題也不限于此,我們一會(huì)兒慢慢分析。

現(xiàn)在,我們先來(lái)看下,Trie 樹到底長(zhǎng)什么樣子。

我舉個(gè)簡(jiǎn)單的例子來(lái)說(shuō)明一下。我們有 6 個(gè)字符串,它們分別是:how,hi,her,hello,so,see。我們希望在里面多次查找某個(gè)字符串是否存在。如果每次查找,都是拿要查找的字符串跟這 6 個(gè)字符串依次進(jìn)行字符串匹配,那效率就比較低,有沒(méi)有更高效的方法呢?

這個(gè)時(shí)候,我們就可以先對(duì)這 6 個(gè)字符串做一下預(yù)處理,組織成 Trie 樹的結(jié)構(gòu),之后每次查找,都是在 Trie 樹中進(jìn)行匹配查找。Trie 樹的本質(zhì),就是利用字符串之間的公共前綴,將重復(fù)的前綴合并在一起。最后構(gòu)造出來(lái)的就是下面這個(gè)圖中的樣子。

根節(jié)點(diǎn)不包含任何信息。每個(gè)節(jié)點(diǎn)表示一個(gè)字符串中的字符,從根節(jié)點(diǎn)到紅色節(jié)點(diǎn)的一條路徑表示一個(gè)字符串(注意:紅色節(jié)點(diǎn)并不都是葉子節(jié)點(diǎn))。

Trie 樹構(gòu)造的分解過(guò)程。構(gòu)造過(guò)程的每一步,都相當(dāng)于往 Trie 樹中插入一個(gè)字符串。當(dāng)所有字符串都插入完成之后,Trie 樹就構(gòu)造好了。

如何實(shí)現(xiàn)一棵 Trie 樹?

知道了 Trie 樹長(zhǎng)什么樣子,我們現(xiàn)在來(lái)看下,如何用代碼來(lái)實(shí)現(xiàn)一個(gè) Trie 樹。

從剛剛 Trie 樹的介紹來(lái)看,Trie 樹主要有兩個(gè)操作,一個(gè)是將字符串集合構(gòu)造成 Trie 樹。這個(gè)過(guò)程分解開來(lái)的話,就是一個(gè)將字符串插入到 Trie 樹的過(guò)程。另一個(gè)是在 Trie 樹中查詢一個(gè)字符串

了解了 Trie 樹的兩個(gè)主要操作之后,我們?cè)賮?lái)看下,如何存儲(chǔ)一個(gè) Trie 樹?

從前面的圖中,我們可以看出,Trie 樹是一個(gè)多叉樹。我們知道,二叉樹中,一個(gè)節(jié)點(diǎn)的左右子節(jié)點(diǎn)是通過(guò)兩個(gè)指針來(lái)存儲(chǔ)的,如下所示 Java 代碼。那對(duì)于多叉樹來(lái)說(shuō),我們?cè)趺创鎯?chǔ)一個(gè)節(jié)點(diǎn)的所有子節(jié)點(diǎn)的指針呢?

class BinaryTreeNode {char data;BinaryTreeNode left;BinaryTreeNode right; }

我先介紹其中一種存儲(chǔ)方式,也是經(jīng)典的存儲(chǔ)方式,大部分?jǐn)?shù)據(jù)結(jié)構(gòu)和算法書籍中都是這么講的。還記得我們前面講到的散列表嗎?借助散列表的思想,我們通過(guò)一個(gè)下標(biāo)與字符一一映射的數(shù)組,來(lái)存儲(chǔ)子節(jié)點(diǎn)的指針。這句話稍微有點(diǎn)抽象,不怎么好懂,我畫了一張圖你可以看看。

假設(shè)我們的字符串中只有從 a 到 z 這 26 個(gè)小寫字母,我們?cè)跀?shù)組中下標(biāo)為 0 的位置,存儲(chǔ)指向子節(jié)點(diǎn) a 的指針,下標(biāo)為 1 的位置存儲(chǔ)指向子節(jié)點(diǎn) b 的指針,以此類推,下標(biāo)為 25 的位置,存儲(chǔ)的是指向的子節(jié)點(diǎn) z 的指針。如果某個(gè)字符的子節(jié)點(diǎn)不存在,我們就在對(duì)應(yīng)的下標(biāo)的位置存儲(chǔ) null。

class TrieNode {char data;TrieNode children[26]; }

當(dāng)我們?cè)?Trie 樹中查找字符串的時(shí)候,我們就可以通過(guò)字符的 ASCII 碼減去“a”的 ASCII 碼,迅速找到匹配的子節(jié)點(diǎn)的指針。比如,d 的 ASCII 碼減去 a 的 ASCII 碼就是 3,那子節(jié)點(diǎn) d 的指針就存儲(chǔ)在數(shù)組中下標(biāo)為 3 的位置中。

描述了這么多,有可能你還是有點(diǎn)懵,我把上面的描述翻譯成了代碼,你可以結(jié)合著一塊看下,應(yīng)該有助于你理解。

public class Trie {private TrieNode root = new TrieNode('/'); // 存儲(chǔ)無(wú)意義字符// 往 Trie 樹中插入一個(gè)字符串public void insert(char[] text) {TrieNode p = root;for (int i = 0; i < text.length; ++i) {int index = text[i] - 'a';if (p.children[index] == null) {TrieNode newNode = new TrieNode(text[i]);p.children[index] = newNode;}p = p.children[index];}p.isEndingChar = true;}// 在 Trie 樹中查找一個(gè)字符串public boolean find(char[] pattern) {TrieNode p = root;for (int i = 0; i < pattern.length; ++i) {int index = pattern[i] - 'a';if (p.children[index] == null) {return false; // 不存在 pattern}p = p.children[index];}if (p.isEndingChar == false) return false; // 不能完全匹配,只是前綴else return true; // 找到 pattern}public class TrieNode {public char data;public TrieNode[] children = new TrieNode[26];public boolean isEndingChar = false;public TrieNode(char data) {this.data = data;}} }

Trie 樹的實(shí)現(xiàn),你現(xiàn)在應(yīng)該搞懂了。現(xiàn)在,我們來(lái)看下,在 Trie 樹中,查找某個(gè)字符串的時(shí)間復(fù)雜度是多少?

如果要在一組字符串中,頻繁地查詢某些字符串,用 Trie 樹會(huì)非常高效。構(gòu)建 Trie 樹的過(guò)程,需要掃描所有的字符串,時(shí)間復(fù)雜度是 O(n)(n 表示所有字符串的長(zhǎng)度和)。但是一旦構(gòu)建成功之后,后續(xù)的查詢操作會(huì)非常高效。

每次查詢時(shí),如果要查詢的字符串長(zhǎng)度是 k,那我們只需要比對(duì)大約 k 個(gè)節(jié)點(diǎn),就能完成查詢操作。跟原本那組字符串的長(zhǎng)度和個(gè)數(shù)沒(méi)有任何關(guān)系。所以說(shuō),構(gòu)建好 Trie 樹后,在其中查找字符串的時(shí)間復(fù)雜度是 O(k),k 表示要查找的字符串的長(zhǎng)度。

Trie 樹真的很耗內(nèi)存嗎?

前面我們講了 Trie 樹的實(shí)現(xiàn),也分析了時(shí)間復(fù)雜度。現(xiàn)在你應(yīng)該知道,Trie 樹是一種非常獨(dú)特的、高效的字符串匹配方法。但是,關(guān)于 Trie 樹,你有沒(méi)有聽過(guò)這樣一種說(shuō)法:“Trie 樹是非常耗內(nèi)存的,用的是一種空間換時(shí)間的思路”。這是什么原因呢?

剛剛我們?cè)谥v Trie 樹的實(shí)現(xiàn)的時(shí)候,講到用數(shù)組來(lái)存儲(chǔ)一個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)的指針。如果字符串中包含從 a 到 z 這 26 個(gè)字符,那每個(gè)節(jié)點(diǎn)都要存儲(chǔ)一個(gè)長(zhǎng)度為 26 的數(shù)組,并且每個(gè)數(shù)組存儲(chǔ)一個(gè) 8 字節(jié)指針(或者是 4 字節(jié),這個(gè)大小跟 CPU、操作系統(tǒng)、編譯器等有關(guān))。而且,即便一個(gè)節(jié)點(diǎn)只有很少的子節(jié)點(diǎn),遠(yuǎn)小于 26 個(gè),比如 3、4 個(gè),我們也要維護(hù)一個(gè)長(zhǎng)度為 26 的數(shù)組。

我們前面講過(guò),Trie 樹的本質(zhì)是避免重復(fù)存儲(chǔ)一組字符串的相同前綴子串,但是現(xiàn)在每個(gè)字符(對(duì)應(yīng)一個(gè)節(jié)點(diǎn))的存儲(chǔ)遠(yuǎn)遠(yuǎn)大于 1 個(gè)字節(jié)。按照我們上面舉的例子,數(shù)組長(zhǎng)度為 26,每個(gè)元素是 8 字節(jié),那每個(gè)節(jié)點(diǎn)就會(huì)額外需要 26*8=208 個(gè)字節(jié)。而且這還是只包含 26 個(gè)字符的情況。

如果字符串中不僅包含小寫字母,還包含大寫字母、數(shù)字、甚至是中文,那需要的存儲(chǔ)空間就更多了。所以,也就是說(shuō),在某些情況下,Trie 樹不一定會(huì)節(jié)省存儲(chǔ)空間。在重復(fù)的前綴并不多的情況下,Trie 樹不但不能節(jié)省內(nèi)存,還有可能會(huì)浪費(fèi)更多的內(nèi)存。

當(dāng)然,我們不可否認(rèn),Trie 樹盡管有可能很浪費(fèi)內(nèi)存,但是確實(shí)非常高效。那為了解決這個(gè)內(nèi)存問(wèn)題,我們是否有其他辦法呢?

我們可以稍微犧牲一點(diǎn)查詢的效率,將每個(gè)節(jié)點(diǎn)中的數(shù)組換成其他數(shù)據(jù)結(jié)構(gòu),來(lái)存儲(chǔ)一個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)指針。用哪種數(shù)據(jù)結(jié)構(gòu)呢?我們的選擇其實(shí)有很多,比如有序數(shù)組、跳表、散列表、紅黑樹等。

假設(shè)我們用有序數(shù)組,數(shù)組中的指針按照所指向的子節(jié)點(diǎn)中的字符的大小順序排列。查詢的時(shí)候,我們可以通過(guò)二分查找的方法,快速查找到某個(gè)字符應(yīng)該匹配的子節(jié)點(diǎn)的指針。但是,在往 Trie 樹中插入一個(gè)字符串的時(shí)候,我們?yōu)榱司S護(hù)數(shù)組中數(shù)據(jù)的有序性,就會(huì)稍微慢了點(diǎn)。

替換成其他數(shù)據(jù)結(jié)構(gòu)的思路是類似的,這里我就不一一分析了,你可以結(jié)合前面學(xué)過(guò)的內(nèi)容,自己分析一下。

實(shí)際上,Trie 樹的變體有很多,都可以在一定程度上解決內(nèi)存消耗的問(wèn)題。比如,縮點(diǎn)優(yōu)化,就是對(duì)只有一個(gè)子節(jié)點(diǎn)的節(jié)點(diǎn),而且此節(jié)點(diǎn)不是一個(gè)串的結(jié)束節(jié)點(diǎn),可以將此節(jié)點(diǎn)與子節(jié)點(diǎn)合并。這樣可以節(jié)省空間,但卻增加了編碼難度。這里我就不展開詳細(xì)講解了,你如果感興趣,可以自行研究下。

Trie 樹與散列表、紅黑樹的比較

實(shí)際上,字符串的匹配問(wèn)題,籠統(tǒng)上講,其實(shí)就是數(shù)據(jù)的查找問(wèn)題。對(duì)于支持動(dòng)態(tài)數(shù)據(jù)高效操作的數(shù)據(jù)結(jié)構(gòu),我們前面已經(jīng)講過(guò)好多了,比如散列表、紅黑樹、跳表等等。實(shí)際上,這些數(shù)據(jù)結(jié)構(gòu)也可以實(shí)現(xiàn)在一組字符串中查找字符串的功能。我們選了兩種數(shù)據(jù)結(jié)構(gòu),散列表和紅黑樹,跟 Trie 樹比較一下,看看它們各自的優(yōu)缺點(diǎn)和應(yīng)用場(chǎng)景。

在剛剛講的這個(gè)場(chǎng)景,在一組字符串中查找字符串,Trie 樹實(shí)際上表現(xiàn)得并不好。它對(duì)要處理的字符串有及其嚴(yán)苛的要求。

第一,字符串中包含的字符集不能太大。我們前面講到,如果字符集太大,那存儲(chǔ)空間可能就會(huì)浪費(fèi)很多。即便可以優(yōu)化,但也要付出犧牲查詢、插入效率的代價(jià)。

第二,要求字符串的前綴重合比較多,不然空間消耗會(huì)變大很多。

第三,如果要用 Trie 樹解決問(wèn)題,那我們就要自己從零開始實(shí)現(xiàn)一個(gè) Trie 樹,還要保證沒(méi)有 bug,這個(gè)在工程上是將簡(jiǎn)單問(wèn)題復(fù)雜化,除非必須,一般不建議這樣做。

第四,我們知道,通過(guò)指針串起來(lái)的數(shù)據(jù)塊是不連續(xù)的,而 Trie 樹中用到了指針,所以,對(duì)緩存并不友好,性能上會(huì)打個(gè)折扣。

綜合這幾點(diǎn),針對(duì)在一組字符串中查找字符串的問(wèn)題,我們?cè)诠こ讨?#xff0c;更傾向于用散列表或者紅黑樹。因?yàn)檫@兩種數(shù)據(jù)結(jié)構(gòu),我們都不需要自己去實(shí)現(xiàn),直接利用編程語(yǔ)言中提供的現(xiàn)成類庫(kù)就行了。

講到這里,你可能要疑惑了,講了半天,我對(duì) Trie 樹一通否定,還讓你用紅黑樹或者散列表,那 Trie 樹是不是就沒(méi)用了呢?是不是今天的內(nèi)容就白學(xué)了呢?

實(shí)際上,Trie 樹只是不適合精確匹配查找,這種問(wèn)題更適合用散列表或者紅黑樹來(lái)解決。Trie 樹比較適合的是查找前綴匹配的字符串,也就是類似開篇問(wèn)題的那種場(chǎng)景。

實(shí)際上,Trie 樹的這個(gè)應(yīng)用可以擴(kuò)展到更加廣泛的一個(gè)應(yīng)用上,就是自動(dòng)輸入補(bǔ)全,比如輸入法自動(dòng)補(bǔ)全功能、IDE 代碼編輯器自動(dòng)補(bǔ)全功能、瀏覽器網(wǎng)址輸入的自動(dòng)補(bǔ)全功能等等。

Trie 樹是一種解決字符串快速匹配問(wèn)題的數(shù)據(jù)結(jié)構(gòu)。如果用來(lái)構(gòu)建 Trie 樹的這一組字符串中,前綴重復(fù)的情況不是很多,那 Trie 樹這種數(shù)據(jù)結(jié)構(gòu)總體上來(lái)講是比較費(fèi)內(nèi)存的,是一種空間換時(shí)間的解決問(wèn)題思路。

盡管比較耗費(fèi)內(nèi)存,但是對(duì)內(nèi)存不敏感或者內(nèi)存消耗在接受范圍內(nèi)的情況下,在 Trie 樹中做字符串匹配還是非常高效的,時(shí)間復(fù)雜度是 O(k),k 表示要匹配的字符串的長(zhǎng)度。

但是,Trie 樹的優(yōu)勢(shì)并不在于,用它來(lái)做動(dòng)態(tài)集合數(shù)據(jù)的查找,因?yàn)?#xff0c;這個(gè)工作完全可以用更加合適的散列表或者紅黑樹來(lái)替代。Trie 樹最有優(yōu)勢(shì)的是查找前綴匹配的字符串,比如搜索引擎中的關(guān)鍵詞提示功能這個(gè)場(chǎng)景,就比較適合用它來(lái)解決,也是 Trie 樹比較經(jīng)典的應(yīng)用場(chǎng)景。

擴(kuò)展閱讀:Trie樹的開源庫(kù):Apache Commons、DAT(雙數(shù)組trie樹)、后綴樹

AC自動(dòng)機(jī)

很多支持用戶發(fā)表文本內(nèi)容的網(wǎng)站,比如 BBS,大都會(huì)有敏感詞過(guò)濾功能,用來(lái)過(guò)濾掉用戶輸入的一些淫穢、反動(dòng)、謾罵等內(nèi)容。你有沒(méi)有想過(guò),這個(gè)功能是怎么實(shí)現(xiàn)的呢?

實(shí)際上,這些功能最基本的原理就是字符串匹配算法,也就是通過(guò)維護(hù)一個(gè)敏感詞的字典,當(dāng)用戶輸入一段文字內(nèi)容之后,通過(guò)字符串匹配算法,來(lái)查找用戶輸入的這段文字,是否包含敏感詞。如果有,就用“***”把它替代掉。

我們前面講過(guò)好幾種字符串匹配算法了,它們都可以處理這個(gè)問(wèn)題。但是,對(duì)于訪問(wèn)量巨大的網(wǎng)站來(lái)說(shuō),比如淘寶,用戶每天的評(píng)論數(shù)有幾億、甚至幾十億。這時(shí)候,我們對(duì)敏感詞過(guò)濾系統(tǒng)的性能要求就要很高。畢竟,我們也不想,用戶輸入內(nèi)容之后,要等幾秒才能發(fā)送出去吧?我們也不想,為了這個(gè)功能耗費(fèi)過(guò)多的機(jī)器吧?那如何才能實(shí)現(xiàn)一個(gè)高性能的敏感詞過(guò)濾系統(tǒng)呢?這就要用到今天的多模式串匹配算法

基于單模式串和 Trie 樹實(shí)現(xiàn)的敏感詞過(guò)濾

我們前面幾節(jié)講了好幾種字符串匹配算法,有 BF 算法、RK 算法、BM 算法、KMP 算法,還有 Trie 樹。前面四種算法都是單模式串匹配算法,只有 Trie 樹是多模式串匹配算法。

我說(shuō)過(guò),單模式串匹配算法,是在一個(gè)模式串和一個(gè)主串之間進(jìn)行匹配,也就是說(shuō),在一個(gè)主串中查找一個(gè)模式串。多模式串匹配算法,就是在多個(gè)模式串和一個(gè)主串之間做匹配,也就是說(shuō),在一個(gè)主串中查找多個(gè)模式串。

盡管,單模式串匹配算法也能完成多模式串的匹配工作。例如開篇的思考題,我們可以針對(duì)每個(gè)敏感詞,通過(guò)單模式串匹配算法(比如 KMP 算法)與用戶輸入的文字內(nèi)容進(jìn)行匹配。但是,這樣做的話,每個(gè)匹配過(guò)程都需要掃描一遍用戶輸入的內(nèi)容。整個(gè)過(guò)程下來(lái)就要掃描很多遍用戶輸入的內(nèi)容。如果敏感詞很多,比如幾千個(gè),并且用戶輸入的內(nèi)容很長(zhǎng),假如有上千個(gè)字符,那我們就需要掃描幾千遍這樣的輸入內(nèi)容。很顯然,這種處理思路比較低效。

與單模式匹配算法相比,多模式匹配算法在這個(gè)問(wèn)題的處理上就很高效了。它只需要掃描一遍主串,就能在主串中一次性查找多個(gè)模式串是否存在,從而大大提高匹配效率。我們知道,Trie 樹就是一種多模式串匹配算法。那如何用 Trie 樹實(shí)現(xiàn)敏感詞過(guò)濾功能呢?

我們可以對(duì)敏感詞字典進(jìn)行預(yù)處理,構(gòu)建成 Trie 樹結(jié)構(gòu)。這個(gè)預(yù)處理的操作只需要做一次,如果敏感詞字典動(dòng)態(tài)更新了,比如刪除、添加了一個(gè)敏感詞,那我們只需要?jiǎng)討B(tài)更新一下 Trie 樹就可以了。

當(dāng)用戶輸入一個(gè)文本內(nèi)容后,我們把用戶輸入的內(nèi)容作為主串,從第一個(gè)字符(假設(shè)是字符 C)開始,在 Trie 樹中匹配。當(dāng)匹配到 Trie 樹的葉子節(jié)點(diǎn),或者中途遇到不匹配字符的時(shí)候,我們將主串的開始匹配位置后移一位,也就是從字符 C 的下一個(gè)字符開始,重新在 Trie 樹中匹配。

基于 Trie 樹的這種處理方法,有點(diǎn)類似單模式串匹配的 BF 算法。我們知道,單模式串匹配算法中,KMP 算法對(duì) BF 算法進(jìn)行改進(jìn),引入了 next 數(shù)組,讓匹配失敗時(shí),盡可能將模式串往后多滑動(dòng)幾位。借鑒單模式串的優(yōu)化改進(jìn)方法,能否對(duì)多模式串 Trie 樹進(jìn)行改進(jìn),進(jìn)一步提高 Trie 樹的效率呢?這就要用到 AC 自動(dòng)機(jī)算法了。

經(jīng)典的多模式串匹配算法:AC 自動(dòng)機(jī)

AC 自動(dòng)機(jī)算法,全稱是 Aho-Corasick 算法。其實(shí),Trie 樹跟 AC 自動(dòng)機(jī)之間的關(guān)系,就像單串匹配中樸素的串匹配算法,跟 KMP 算法之間的關(guān)系一樣,只不過(guò)前者針對(duì)的是多模式串而已。所以,AC 自動(dòng)機(jī)實(shí)際上就是在 Trie 樹之上,加了類似 KMP 的 next 數(shù)組,只不過(guò)此處的 next 數(shù)組是構(gòu)建在樹上罷了。如果代碼表示,就是下面這個(gè)樣子:

public class AcNode {public char data; public AcNode[] children = new AcNode[26]; // 字符集只包含 a~z 這 26 個(gè)字符public boolean isEndingChar = false; // 結(jié)尾字符為 truepublic int length = -1; // 當(dāng) isEndingChar=true 時(shí),記錄模式串長(zhǎng)度public AcNode fail; // 失敗指針public AcNode(char data) {this.data = data;} }

所以,AC 自動(dòng)機(jī)的構(gòu)建,包含兩個(gè)操作:

  • 將多個(gè)模式串構(gòu)建成 Trie 樹;
  • 在 Trie 樹上構(gòu)建失敗指針(相當(dāng)于 KMP 中的失效函數(shù) next 數(shù)組)。

關(guān)于如何構(gòu)建 Trie 樹,我們上一節(jié)已經(jīng)講過(guò)了。所以,這里我們就重點(diǎn)看下,構(gòu)建好 Trie 樹之后,如何在它之上構(gòu)建失敗指針?

我用一個(gè)例子給你講解。這里有 4 個(gè)模式串,分別是 c,bc,bcd,abcd;主串是 abcd。

Trie 樹中的每一個(gè)節(jié)點(diǎn)都有一個(gè)失敗指針,它的作用和構(gòu)建過(guò)程,跟 KMP 算法中的 next 數(shù)組極其相似。所以要想看懂這節(jié)內(nèi)容,你要先理解 KMP 算法中 next 數(shù)組的構(gòu)建過(guò)程。如果你還有點(diǎn)不清楚,建議你先回頭去弄懂 KMP 算法。

假設(shè)我們沿 Trie 樹走到 p 節(jié)點(diǎn),也就是下圖中的紫色節(jié)點(diǎn),那 p 的失敗指針就是從 root 走到紫色節(jié)點(diǎn)形成的字符串 abc,跟所有模式串前綴匹配的最長(zhǎng)可匹配后綴子串,就是箭頭指的 bc 模式串。

這里的最長(zhǎng)可匹配后綴子串,我稍微解釋一下。字符串 abc 的后綴子串有兩個(gè) bc,c,我們拿它們與其他模式串匹配,如果某個(gè)后綴子串可以匹配某個(gè)模式串的前綴,那我們就把這個(gè)后綴子串叫作可匹配后綴子串

我們從可匹配后綴子串中,找出最長(zhǎng)的一個(gè),就是剛剛講到的最長(zhǎng)可匹配后綴子串。我們將 p 節(jié)點(diǎn)的失敗指針指向那個(gè)最長(zhǎng)匹配后綴子串對(duì)應(yīng)的模式串的前綴的最后一個(gè)節(jié)點(diǎn),就是下圖中箭頭指向的節(jié)點(diǎn)。

計(jì)算每個(gè)節(jié)點(diǎn)的失敗指針這個(gè)過(guò)程看起來(lái)有些復(fù)雜。其實(shí),如果我們把樹中相同深度的節(jié)點(diǎn)放到同一層,那么某個(gè)節(jié)點(diǎn)的失敗指針只有可能出現(xiàn)在它所在層的上一層。

我們可以像 KMP 算法那樣,當(dāng)我們要求某個(gè)節(jié)點(diǎn)的失敗指針的時(shí)候,我們通過(guò)已經(jīng)求得的、深度更小的那些節(jié)點(diǎn)的失敗指針來(lái)推導(dǎo)。也就是說(shuō),我們可以逐層依次來(lái)求解每個(gè)節(jié)點(diǎn)的失敗指針。所以,失敗指針的構(gòu)建過(guò)程,是一個(gè)按層遍歷樹的過(guò)程。

首先 root 的失敗指針為 NULL,也就是指向自己。當(dāng)我們已經(jīng)求得某個(gè)節(jié)點(diǎn) p 的失敗指針之后,如何尋找它的子節(jié)點(diǎn)的失敗指針呢?

我們假設(shè)節(jié)點(diǎn) p 的失敗指針指向節(jié)點(diǎn) q,我們看節(jié)點(diǎn) p 的子節(jié)點(diǎn) pc 對(duì)應(yīng)的字符,是否也可以在節(jié)點(diǎn) q 的子節(jié)點(diǎn)中找到。如果找到了節(jié)點(diǎn) q 的一個(gè)子節(jié)點(diǎn) qc,對(duì)應(yīng)的字符跟節(jié)點(diǎn) pc 對(duì)應(yīng)的字符相同,則將節(jié)點(diǎn) pc 的失敗指針指向節(jié)點(diǎn) qc。

如果節(jié)點(diǎn) q 中沒(méi)有子節(jié)點(diǎn)的字符等于節(jié)點(diǎn) pc 包含的字符,則令 q=q->fail(fail 表示失敗指針,這里有沒(méi)有很像 KMP 算法里求 next 的過(guò)程?),繼續(xù)上面的查找,直到 q 是 root 為止,如果還沒(méi)有找到相同字符的子節(jié)點(diǎn),就讓節(jié)點(diǎn) pc 的失敗指針指向 root。

我將構(gòu)建失敗指針的代碼貼在這里,你可以對(duì)照著講解一塊看下,應(yīng)該更容易理解。這里面,構(gòu)建 Trie 樹的代碼我并沒(méi)有貼出來(lái),你可以參看上一節(jié)的代碼,自己實(shí)現(xiàn)。

public void buildFailurePointer() {Queue<AcNode> queue = new LinkedList<>();root.fail = null;queue.add(root);while (!queue.isEmpty()) {AcNode p = queue.remove();for (int i = 0; i < 26; ++i) {AcNode pc = p.children[i];if (pc == null) continue;if (p == root) {pc.fail = root;} else {AcNode q = p.fail;while (q != null) {AcNode qc = q.children[pc.data - 'a'];if (qc != null) {pc.fail = qc;break;}q = q.fail;}if (q == null) {pc.fail = root;}}queue.add(pc);}} }

通過(guò)按層來(lái)計(jì)算每個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)的失效指針,剛剛舉的那個(gè)例子,最后構(gòu)建完成之后的 AC 自動(dòng)機(jī)就是下面這個(gè)樣子:

AC 自動(dòng)機(jī)到此就構(gòu)建完成了。我們現(xiàn)在來(lái)看下,如何在 AC 自動(dòng)機(jī)上匹配主串?

我們還是拿之前的例子來(lái)講解。在匹配過(guò)程中,主串從 i=0 開始,AC 自動(dòng)機(jī)從指針 p=root 開始,假設(shè)模式串是 b,主串是 a。

  • 如果 p 指向的節(jié)點(diǎn)有一個(gè)等于 b[i] 的子節(jié)點(diǎn) x,我們就更新 p 指向 x,這個(gè)時(shí)候我們需要通過(guò)失敗指針,檢測(cè)一系列失敗指針為結(jié)尾的路徑是否是模式串。這一句不好理解,你可以結(jié)合代碼看。處理完之后,我們將 i 加一,繼續(xù)這兩個(gè)過(guò)程;
  • 如果 p 指向的節(jié)點(diǎn)沒(méi)有等于 b[i] 的子節(jié)點(diǎn),那失敗指針就派上用場(chǎng)了,我們讓 p=p->fail,然后繼續(xù)這 2 個(gè)過(guò)程。

關(guān)于匹配的這部分,文字描述不如代碼看得清楚,所以我把代碼貼了出來(lái),非常簡(jiǎn)短,并且添加了詳細(xì)的注釋,你可以對(duì)照著看下。這段代碼輸出的就是,在主串中每個(gè)可以匹配的模式串出現(xiàn)的位置。

public void match(char[] text) { // text 是主串int n = text.length;AcNode p = root;for (int i = 0; i < n; ++i) {int idx = text[i] - 'a';while (p.children[idx] == null && p != root) {p = p.fail; // 失敗指針發(fā)揮作用的地方}p = p.children[idx];if (p == null) p = root; // 如果沒(méi)有匹配的,從 root 開始重新匹配AcNode tmp = p;while (tmp != root) { // 打印出可以匹配的模式串if (tmp.isEndingChar == true) {int pos = i-tmp.length+1;System.out.println(" 匹配起始下標(biāo) " + pos + "; 長(zhǎng)度 " + tmp.length);}tmp = tmp.fail;}} }

解答開篇

AC 自動(dòng)機(jī)的內(nèi)容講完了,關(guān)于開篇的問(wèn)題,你應(yīng)該能解答了吧?實(shí)際上,我上面貼出來(lái)的代碼,已經(jīng)是一個(gè)敏感詞過(guò)濾的原型代碼了。它可以找到所有敏感詞出現(xiàn)的位置(在用戶輸入的文本中的起始下標(biāo))。你只需要稍加改造,再遍歷一遍文本內(nèi)容(主串),就可以將文本中的所有敏感詞替換成“***”。

所以我這里著重講一下,AC 自動(dòng)機(jī)實(shí)現(xiàn)的敏感詞過(guò)濾系統(tǒng),是否比單模式串匹配方法更高效呢?

首先,我們需要將敏感詞構(gòu)建成 AC 自動(dòng)機(jī),包括構(gòu)建 Trie 樹以及構(gòu)建失敗指針。

我們上一節(jié)講過(guò),Trie 樹構(gòu)建的時(shí)間復(fù)雜度是 O(m*len),其中 len 表示敏感詞的平均長(zhǎng)度,m 表示敏感詞的個(gè)數(shù)。那構(gòu)建失敗指針的時(shí)間復(fù)雜度是多少呢?我這里給出一個(gè)不是很緊確的上界。

假設(shè) Trie 樹中總的節(jié)點(diǎn)個(gè)數(shù)是 k,每個(gè)節(jié)點(diǎn)構(gòu)建失敗指針的時(shí)候,(你可以看下代碼)最耗時(shí)的環(huán)節(jié)是 while 循環(huán)中的 q=q->fail,每運(yùn)行一次這個(gè)語(yǔ)句,q 指向節(jié)點(diǎn)的深度都會(huì)減少 1,而樹的高度最高也不會(huì)超過(guò) len,所以每個(gè)節(jié)點(diǎn)構(gòu)建失敗指針的時(shí)間復(fù)雜度是 O(len)。整個(gè)失敗指針的構(gòu)建過(guò)程就是 O(k*len)。

不過(guò),AC 自動(dòng)機(jī)的構(gòu)建過(guò)程都是預(yù)先處理好的,構(gòu)建好之后,并不會(huì)頻繁地更新,所以不會(huì)影響到敏感詞過(guò)濾的運(yùn)行效率。

我們?cè)賮?lái)看下,用 AC 自動(dòng)機(jī)做匹配的時(shí)間復(fù)雜度是多少?

跟剛剛構(gòu)建失敗指針的分析類似,for 循環(huán)依次遍歷主串中的每個(gè)字符,for 循環(huán)內(nèi)部最耗時(shí)的部分也是 while 循環(huán),而這一部分的時(shí)間復(fù)雜度也是 O(len),所以總的匹配的時(shí)間復(fù)雜度就是 O(n*len)。因?yàn)槊舾性~并不會(huì)很長(zhǎng),而且這個(gè)時(shí)間復(fù)雜度只是一個(gè)非常寬泛的上限,實(shí)際情況下,可能近似于 O(n),所以 AC 自動(dòng)機(jī)做敏感詞過(guò)濾,性能非常高。

你可以會(huì)說(shuō),從時(shí)間復(fù)雜度上看,AC 自動(dòng)機(jī)匹配的效率跟 Trie 樹一樣啊。實(shí)際上,因?yàn)槭е羔樋赡艽蟛糠智闆r下都指向 root 節(jié)點(diǎn),所以絕大部分情況下,在 AC 自動(dòng)機(jī)上做匹配的效率要遠(yuǎn)高于剛剛計(jì)算出的比較寬泛的時(shí)間復(fù)雜度。只有在極端情況下,如圖所示,AC 自動(dòng)機(jī)的性能才會(huì)退化的跟 Trie 樹一樣。

多模式串匹配算法,AC 自動(dòng)機(jī)。單模式串匹配算法是為了快速在主串中查找一個(gè)模式串,而多模式串匹配算法是為了快速在主串中查找多個(gè)模式串。

AC 自動(dòng)機(jī)是基于 Trie 樹的一種改進(jìn)算法,它跟 Trie 樹的關(guān)系,就像單模式串中,KMP 算法與 BF 算法的關(guān)系一樣。KMP 算法中有一個(gè)非常關(guān)鍵的 next 數(shù)組,類比到 AC 自動(dòng)機(jī)中就是失敗指針。而且,AC 自動(dòng)機(jī)失敗指針的構(gòu)建過(guò)程,跟 KMP 算法中計(jì)算 next 數(shù)組極其相似。所以,要理解 AC 自動(dòng)機(jī),最好先掌握 KMP 算法,因?yàn)?AC 自動(dòng)機(jī)其實(shí)就是 KMP 算法在多模式串上的改造。

整個(gè) AC 自動(dòng)機(jī)算法包含兩個(gè)部分,第一部分是將多個(gè)模式串構(gòu)建成 AC 自動(dòng)機(jī),第二部分是在 AC 自動(dòng)機(jī)中匹配主串。第一部分又分為兩個(gè)小的步驟,一個(gè)是將模式串構(gòu)建成 Trie 樹,另一個(gè)是在 Trie 樹上構(gòu)建失敗指針。

總結(jié)

以上是生活随笔為你收集整理的数据结构与算法之美笔记——基础篇(下):图、字符串匹配算法(BF 算法和 RK 算法、BM 算法和 KMP 算法 、Trie 树和 AC 自动机)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。

如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。