java单词匹配算法_前端学数据结构与算法(八): 单词前缀匹配神器-Trie树的实现及其应用...
前言
繼二叉樹、堆之后,接下來介紹另外一種樹型的數(shù)據(jù)結(jié)構(gòu)-Trie樹,也可以叫它前綴樹、字典樹。例如我們再搜索引擎里輸入幾個關(guān)鍵字之后,后續(xù)的內(nèi)容會自動續(xù)上。此時我們輸入的關(guān)鍵詞也就是前綴,而后面的就是與之匹配的內(nèi)容,而這么一個功能底層的數(shù)據(jù)結(jié)構(gòu)就是Trie樹。那到底什么是Trie樹?還是三個步驟來熟悉它,首先了解、然后實現(xiàn)、最后應(yīng)用。
什么是Trie樹?
這是一種多叉樹,它主要解決的問題是能在一組字符串里快速的進行某個字符串的匹配。而它的這種高效正是建立在算法的以空間換時間的思想上,因為字符串的每一個字符都會成為一個樹的節(jié)點,例如我們把這樣一組單詞['bag', 'and', 'banana', 'ban', 'am', 'board', 'ball']進行Trie化后就會成為以下這樣:
根節(jié)點為空,因為子節(jié)點都的存儲的單詞開頭的緣故。Trie樹的本質(zhì)就是將單詞之間的公共前綴合并起來,這也就會造成單詞ban和banana公用同一條路徑,所以需要在單詞的結(jié)尾處給一個標識符,表示該字符為一個單詞的結(jié)束。所以當輸入關(guān)鍵詞ba后,只需要遍歷后面的節(jié)點就可以將bag、banana、ball單詞呈現(xiàn)給用戶。是不是很酷~
從零實現(xiàn)一顆Trie樹
之前我們介紹的都是二叉樹,所以使用左右孩子表示這個很方便,但Trie樹是一種多叉樹,如果僅僅只是存儲小寫字母,那么每個父節(jié)點的子節(jié)點最多就有26個子孩子。所以子節(jié)點我們都使用單個字符作為其key來存儲,這樣無論多少個子節(jié)點都沒問題。Trie主要是操作就是兩個,一個是往樹里添加單詞、另一個是查詢樹里是否有某個單詞。class Node { // 節(jié)點類
constructor(isWord = false) {
this.isWord = isWord // 該節(jié)點是否是單詞的結(jié)尾
this.next = new Map() // 子孩子使用map存儲
}
}
class Trie { // 實例類
constructor() {
this.root = new Node()
}
...
}
往Trie里增加單詞(add)
將單詞拆解為單個的字符,而每個字符就是一個Node類的實例,最后當單詞達到末尾時,將最后字符Node節(jié)點的isWord屬性設(shè)置為true即可。class Trie {
...
add(word) { // 之后的應(yīng)用里有遍歷的寫法
const _helper = (node, word) => {
if (word === '') { // 遞歸到底,單詞已經(jīng)不能被拆解了
node.isWord = true // 將上一個字符標記為單詞結(jié)尾
return
}
const c = word[0] // 從單詞的首字母開始
if (!node.next.has(c)) { // 如果孩子節(jié)點里不包含該字符
node.next.set(c, new Node()) // 設(shè)置為新的孩子節(jié)點
}
_helper(node.next.get(c), word.slice(1)) // 繼續(xù)拆解單詞的其他字符
}
_helper(this.root, word) // 加入到根節(jié)點之下
}
}
通過add方法,就可以構(gòu)建一顆Trie樹了,但構(gòu)建它最大的意義是能快速的進行查詢,所以我們還需要一個search方法,能快速的查詢該單詞是否在Trie樹里。
查詢Trie里的單詞(search)
因為已經(jīng)有一顆Trie樹了,所以要查詢也很簡單,只需要將要查詢的單詞分解為字符逐層向下的和Trie樹節(jié)點進行匹配即可,只要有一個節(jié)點Trie樹里沒有,就可以判斷Trie樹不存在這個單詞,單詞分解完畢之后,返回最后停留那個節(jié)點的isWord屬性即可。class Trie {
search(word) {
const _helper = (node, word) => {
if (word === '') { // 已經(jīng)不能拆解了
return node.isWord // 返回停留節(jié)點的isWord屬性
}
const c = word[0]
if (!node.next.get(c)) { // 只要有節(jié)點不匹配
return false // 表示沒有
}
return _helper(node.next.get(c), word.slice(1)) // 逐層向下
}
return _helper(this.root, word) // 從根節(jié)點開始
}
}
輸出Trie樹里的每個單詞(log)
這個方法僅僅是個人在熟悉Trie樹時添加一個方法,每次調(diào)用打印出樹里所有的單詞,方便調(diào)試時使用。class Trie {
...
log() { // 根之前的打印匹配的前綴類似,只需要調(diào)整
const ret = []
const _helper = (node, path) => {
if (node.isWord) {
ret.push(path.join('')) // 將單詞放入結(jié)果里
}
for (const [key, value] of node.next) { // 遍歷每一個孩子節(jié)點
path.push(key) // 加入單詞路徑
_helper(value, path)
path.pop() // 回溯
}
}
_helper(this.root, [])
console.log(ret)
}
}
返回前綴匹配的單詞
這個方法純粹也是個人所加,很多介紹介紹Trie樹的資料不會寫這個方法,個人覺得這是很能結(jié)合Trie樹特性的一個方法,因為僅僅作為精確查詢來說,還真沒比哈希表、紅黑樹優(yōu)勢多少。但如果只是返回匹配前綴的單詞,這個優(yōu)勢就很大了。像輸入法的自動聯(lián)想、IDE的自動補全功能都可以用這個方法實現(xiàn)。class Trie {
...
match(prefix) {
if (prefix === '') {
return []
}
let cur = this.root
for (let i = 0; i < prefix.length; i++) { // 首先找到前綴停留的節(jié)點
const c = prefix[i]
if (!cur.next.get(c)) { // 前綴都不匹配,那肯定沒這單詞了
return []
}
cur = cur.next.get(c) // cur就是停留的節(jié)點
}
const ret = []
const _helper = (node, path) => {
if (node.isWord) { // 如果是一個單詞
ret.push(prefix + path) // 將其添加到返回結(jié)果里
}
for (const [key, value] of node.next) {
path += key // 記錄匹配的路徑
_helper(value, path) // 遞歸向下查找
path = path.slice(0, -1) // 回溯
}
}
_helper(cur, '') // 從cur開始向下匹配
return ret // 返回結(jié)果
}
}
Trie樹的應(yīng)用
首先應(yīng)用嘗試一下上述我們實現(xiàn)的這個Trie類:const trie = new Trie();
const words = ['bag', 'and', 'banana', 'an', 'am', 'board', 'ball'];
words.forEach(word => {
trie.add(word); // 構(gòu)建Trie樹
});
trie.log() // 打印所有單詞
console.log(trie.match('ba')) // ['bag', 'banana', 'ball']
學(xué)習(xí)Trie樹最重要就是學(xué)習(xí)它處理問題的思想,接下來我們拿力扣幾道字典樹相關(guān)的問題,來看看如果巧妙的使用Trie樹思想解答它們。
720 - 詞典中最長的單詞 ↓給出一個字符串數(shù)組words組成的一本英語詞典。從中找出最長的一個單詞,
該單詞是由words詞典中其他單詞逐步添加一個字母組成。若其中有多個可行的答案,
則返回答案中字典序最小的單詞。若無答案,則返回空字符串。
示例
輸入:words = ["a", "banana", "app", "appl", "ap", "apply", "apple"]
輸出:"apple"
解釋:
"apply"和"apple"都能由詞典中的單詞組成。但是"apple"的字典序小于"apply"。
簡單來說就是找到最長的單詞,但這個單詞必須是其他的單詞一步步累加起來的,所以不能出現(xiàn)跨級跳躍的情況。思路就是我們把這個字典轉(zhuǎn)化為一個Trie樹,在樹里給每個單詞做好結(jié)束的標記,只能是單詞的才能往下進行匹配,所以進行深度優(yōu)先遍歷,但其中只要有一個字符不是單詞,就結(jié)束這條路接下來的遍歷,最后返回匹配到最長的單詞長度即可。這個字典轉(zhuǎn)化為Trie樹后如下圖:
很明顯banana直接淘汰,因為這個字符串的第一個字符就不是一個單詞,而最后角逐的就是apply和apple,因為這兩個單詞一路都是踏著其他單詞而來。實現(xiàn)代碼如下:class Node {
constructor(isWord) {
this.isWord = isWord
this.next = new Map()
}
}
class Trie {
constructor() {
this.root = new Node()
}
add(word) { // 構(gòu)建樹
const _helper = (node, word) => {
if (word === '') {
node.isWord = true
return
}
const c = word[0]
if (!node.next.get(c)) {
node.next.set(c, new Node())
}
_helper(node.next.get(c), word.slice(1))
}
_helper(this.root, word)
}
}
var longestWord = function (words) {
const trie = new Trie()
words.forEach(word => { // 將字符集合構(gòu)建為trie樹
trie.add(word)
})
let res = '' // 保存最長單詞
const _helper = (node, path) => {
if (path.length > res.length || (path.length === res.length && res > path)) {
res = path
// 只要匹配到單詞長度大于已存單詞長度 或者 相等時取小的那位
// 更新最長單詞
}
for (const [key, value] of node.next) { // 遍歷多叉樹
if (!value.isWord) { // 只要這個節(jié)點不是單詞結(jié)尾,就略過
continue
}
path += key // 將這個單詞加入到路徑里
_helper(value, path) // 繼續(xù)向下匹配
path = path.slice(0, -1) // 遍歷完一個分支后,減去這個分支字符
}
}
_helper(trie.root, '')
return res // 返回最長單詞
};
677 - 鍵值映射 ↓實現(xiàn)一個 MapSum 類里的兩個方法,insert?和?sum。
對于方法?insert,你將得到一對(字符串,整數(shù))的鍵值對。
字符串表示鍵,整數(shù)表示值。如果鍵已經(jīng)存在,那么原來的鍵值對將被替代成新的鍵值對。
對于方法 sum,你將得到一個表示前綴的字符串,你需要返回所有以該前綴開頭的鍵的值的總和。
示例:
輸入: insert("apple", 3), 輸出: Null
輸入: sum("ap"), 輸出: 3
輸入: insert("app", 2), 輸出: Null
輸入: sum("ap"), 輸出: 5
簡單來說就是首先輸入一些單詞以及對應(yīng)的權(quán)重,然后再輸入前綴之后,把每個匹配的單詞的權(quán)重值累加即可。這次的解題思路就和之前match方法很像,我們把insert的單詞放入一顆Trie樹里,單詞結(jié)尾也就是該單詞對應(yīng)的權(quán)重值。所以首先定位前綴最后停留的節(jié)點,然后遍歷的把之后的節(jié)點都遍歷一遍,累加其權(quán)重值即可。代碼如下:class Node { // 節(jié)點類
constructor(val = 0) {
this.val = val // 權(quán)重值, 默認為0
this.next = new Map()
}
}
var MapSum = function () { // 題目需要的類
this.root = new Node()
};
MapSum.prototype.insert = function (key, val) {
let cur = this.root
for (let i = 0; i < key.length; i++) {
const c = key[i]
if (!cur.next.get(c)) {
cur.next.set(c, new Node())
}
cur = cur.next.get(c)
}
cur.val = val
};
MapSum.prototype.sum = function (prefix) {
let cur = this.root
for (let i = 0; i < prefix.length; i++) {
const c = prefix[i]
if (!cur.next.get(c)) { // 前綴都不匹配,直接返回0
return 0
}
cur = cur.next.get(c) // 前綴匹配完了之后,cur就是停留的節(jié)點
}
let res = 0 // 總權(quán)重值
const _helper = node => {
res += node.val
// 遍歷cur之后的每一個節(jié)點即可,因為不是單詞的權(quán)重值為0
for (const item of node.next) {
_helper(item[1])
}
}
_helper(cur)
return res
};
648 - 單詞替換 ↓在英語中,我們有一個叫做?詞根(root)的概念,它可以跟著其他一些詞組成另一個較長的單詞——
我們稱這個詞為?繼承詞(successor)。例如,詞根an,跟隨著單詞?other(其他),可以形成新的單詞?another(另一個)。
現(xiàn)在,給定一個由許多詞根組成的詞典和一個句子。你需要將句子中的所有繼承詞用詞根替換掉。
如果繼承詞有許多可以形成它的詞根,則用最短的詞根替換它。
你需要輸出替換之后的句子。
示例1:
輸入:
dictionary = ["cat","bat","rat"],
sentence = "the cattle was rattled by the battery"
輸出:"the cat was rat by the bat"
示例2:
輸入:
dictionary = ["a","b","c"],
sentence = "aadsfasf absbs bbab cadsfafs"
輸出:"a a b c"
思路我們還是使用Trie樹,將所有的前綴(詞根)構(gòu)建為一顆Trie樹,然后遍歷的把每個單詞與這顆前綴樹進行匹配,當前綴樹到達結(jié)尾時,就把原來字符串換為該詞根即可。如圖所示:
代碼如下:class Node {
constructor(idWord = false) {
this.isWord = idWord
this.next = new Map()
}
}
class Trie {
constructor() {
this.root = new Node()
}
add(word) {
const _helper = (node, word) => {
if (word === '') {
node.isWord = true
return
}
const c = word[0]
if (!node.next.get(c)) {
node.next.set(c, new Node())
}
_helper(node.next.get(c), word.slice(1))
}
_helper(this.root, word)
}
change(words) {
for (let i = 0; i < words.length; i++) {
const word = words[i]
let cur = this.root
let dict = ''
for (let j = 0; j < word.length; j++) {
const c = word[j] // 遍歷每個單詞的每個字符
if (cur.next.get(c)) { // 如果單詞有匹配的詞根
dict += c // 記錄遍歷的詞根
cur = cur.next.get(c) // 向下遍歷
if (cur.isWord) { // 當詞根到底時
words[i] = dict // 將記錄的詞根替換掉單詞
break // 不用再遍歷單詞之后的字符了
}
} else {
break // 如果沒有匹配的詞根,直接換下一個單詞
}
}
}
return words.join(' ') // 返回新的字符串
}
}
var replaceWords = function (dictionary, sentence) {
const trie = new Trie()
dictionary.forEach(dict => {
trie.add(dict) // 構(gòu)建樹
})
return trie.change(sentence.split(' ')) // 將單詞拆分
};
這題轉(zhuǎn)換的Trie樹就是三條獨立的分支,如果Trie樹長這樣,其實就完全沒必要使用Trie樹,所以這也是使用Trie樹的場景局限性。
最后
通過上述實現(xiàn)與應(yīng)用,相信大家已經(jīng)對Trie有了足夠的了解,這是一種非常優(yōu)秀的解決問題的思想,場景使用得當時,能發(fā)揮出巨大的優(yōu)勢。如果場景不符合,那就盡量不使用這種數(shù)據(jù)結(jié)構(gòu)吧。因為...我們來總結(jié)下這種數(shù)據(jù)結(jié)構(gòu)的優(yōu)缺點:
優(yōu)點性能高效,從任意多的字符串中匹配某一個單詞的時間復(fù)雜度,最多僅為該單詞的長度而已。
前綴匹配,像搜索及IDE自動補全的場景,使用Trie樹就非常適合。
缺點對數(shù)據(jù)要求嚴苛,如果字符集合公共的前綴并不多時(第三題就是這個情況),表現(xiàn)并不好。因為每個節(jié)點不僅僅可以存儲小寫字母,還包括大寫字母、數(shù)字等,這樣的話,一顆Trie樹就會異常龐大,會非常消耗內(nèi)存。
JavaScript沒有現(xiàn)成的類使用,要自己手寫且要保證沒bug,麻煩。
總結(jié)
以上是生活随笔為你收集整理的java单词匹配算法_前端学数据结构与算法(八): 单词前缀匹配神器-Trie树的实现及其应用...的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 双机高速互联
- 下一篇: 前端html与css学习笔记总结篇(超详