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