常见算法之前缀树

265 阅读5分钟

一、基础知识

前缀树,又称为字典树,它用一个树状的数据结构存储一个字典中的所有单词。如果一个字典中包含单词"can"、"cat"、"come"、"do"、"i"、"in"和"inn",那么保存该字典所有单词的前缀树如图所示。

image.png

前缀树是一棵多叉树,一个节点可能有多个子节点。前缀树中除根节点外,每个节点表示字符串中的一个字符,而字符串由前缀树的路径表示。前缀树的根节点不表示任何字符。例如,从图中前缀树的根节点开始找到字符'c'对应的节点,接着经过字符'a'对应的节点到达字符'n'对应的节点,该路径表示字符串"can"。

如果两个单词的前缀(即单词最开始的若干字符)相同,那么它们在前缀树中对应的路径的前面的节点是重叠的。例如,"can"和"cat"的前两个字符相同,它们在前缀树对应的两条路径中最开始的3个节点(根节点、字符'c'和字符'a'对应的节点)重叠,它们共同的前缀之后的字符对应的节点一定是在最后一个共同节点的子树中。例如,"can"和"cat"的共同前缀"ca"在前缀树中的最后一个节点是第3层的第1个节点,两个字符串共同的前缀之后的字符'n'和't'都在最后一个公共节点的子树之中。

字符串在前缀树中的路径并不一定终止于叶节点。如果一个单词是另一个单词的前缀,那么较短的单词对应的路径是较长的单词对应的路径的一部分。例如,在图中,字符串"in"对应的路径是字符串"inn"对应的路径的一部分。

如果前缀树路径到达某个节点时它表示了一个完整的字符串,那么字符串最后一个字符对应的节点有特殊的标识。例如,图中字符串最后一个字符对应的节点都用灰色背景标识。从根节点出发到达表示字符'i'的节点,由于该节点被标识为字符串的最后一个字符,因此此时路径表示的字符串"i"是字典中的一个单词。接着往下到达表示字符'n'的节点,这个节点也被标识为字符串的最后一个字符,因此此时路径表示的字符串"in"是字典中的一个单词。接着往下到达另一个表示字符'n'的节点,该节点也有同样的标识,因此此时路径表示的字符串"inn"是字典中的另一个单词。

二、常见算法

1、实现前缀树

题目:请设计实现一棵前缀树Trie,它有如下操作。

  • 函数insert,在前缀树中添加一个字符串。
  • 函数search,查找字符串。如果前缀树中包含该字符串,则返回true;否则返回false。
  • 函数startWith,查找字符串前缀。如果前缀树中包含以该前缀开头的字符串,则返回true;否则返回false。

golang代码实现:

type Trie struct {
   Next []*Trie
   Char uint8
   Tag int8
}

// Constructor
func NewTrie() Trie {
   return Trie{}
}


func (this *Trie) Insert(word string) {
   node := this
   //fmt.Println("node:", node)
   over := false
   for i := 0; i < len(word); i++ {
      if i == len(word)-1 {
         over = true
      }
      node = this.listInsert(node, word[i], over)
   }
}

func (this *Trie) listInsert(node *Trie, char uint8, over bool) *Trie {
   have := false
   var res *Trie
   //fmt.Println("insert", node)
   for i, item := range node.Next {
      if item.Char == char {
         have = true
         if over {
            node.Next[i].Tag = 1
         }
         return node.Next[i]
      }
   }
   if !have {
      res = &Trie{Char: char}
      if over {
         res.Tag = 1
      }
      node.Next = append(node.Next, res)
   }

   return res
}

func (this *Trie) Search(word string) bool {
   node := this
   var over bool
   for i, _ := range word {
      if i == len(word) - 1 {
         over = true
      }
      node = this.listSearch(node, word[i], over)
      if node == nil {
         return false
      }
   }
   return true
}

func (this *Trie) listSearch(node *Trie, char uint8, over bool) *Trie {
   var res *Trie
   for _, item := range node.Next {
      if item.Char == char {
         if over {
            if item.Tag == 1 {
               return item
            }
         } else {
            return item
         }
      }
   }
   return res
}

func (this *Trie) StartsWith(prefix string) bool {
   node := this
   for i, _ := range prefix {
      node = this.listSearch(node, prefix[i], false)
      if node == nil {
         return false
      }
   }
   return true
}

2、替换单词

题目:英语中有一个概念叫词根。在词根后面加上若干字符就能拼出更长的单词。例如,"an"是一个词根,在它后面加上"other"就能得到另一个单词"another"。现在给定一个由词根组成的字典和一个英语句子,如果句子中的单词在字典中有它的词根,则用它的词根替换该单词;如果单词没有词根,则保留该单词。请输出替换后的句子。例如,如果词根字典包含字符串["cat","bat","rat"],英语句子为"the cattle was rattled by the battery",则替换之后的句子是"the cat was rat by the bat"。

解题思路:

将字段构建为前缀树,然后单词去匹配前缀树,最早匹配到的单词就是需要的最小前缀单词。

golang代码:

func replaceWords(dictionary []string, sentence string) string {

   trie := NewTrie()
   for _, item := range dictionary {
      trie.Insert(item)
   }

   words := strings.Split(sentence, " ")
   var res []string
   var pre string
   var mat bool
   for _, item := range words {
      pre, mat = trieDict(&trie, item)
      if !mat {
         res = append(res, item)
      } else {
         res = append(res, pre)
      }
   }

   return strings.Join(res, " ")
}

func trieDict(node *Trie, word string) (prefix string, match bool) {
   var (
      have, over bool
   )
   for i, _ := range word {
      have, over, node = listDict(node, word[i])
      if have {
         prefix += string(word[i])
         if over {
            match = true
            break
         }
      } else {
         break
      }
   }
   return
}

func listDict(node *Trie, char uint8) (have, over bool, next *Trie) {
   for _, item := range node.Next {
      if item.Char == char {
         have = true
         if item.Tag == 1 {
            over = true
         }
         next = item
         return
      }
   }
   return
}

3、最短的单词编码

题目:输入一个包含n个单词的数组,可以把它们编码成一个字符串和n个下标。例如,单词数组["time","me","bell"]可以编码成一个字符串"time#bell#",然后这些单词就可以通过下标[0,2,5]得到。对于每个下标,都可以从编码得到的字符串中相应的位置开始扫描,直到遇到'#'字符前所经过的子字符串为单词数组中的一个单词。例如,从"time#bell#"下标为2的位置开始扫描,直到遇到'#'前经过子字符串"me"是给定单词数组的第2个单词。给定一个单词数组,请问按照上述规则把这些单词编码之后得到的最短字符串的长度是多少?如果输入的是字符串数组["time","me","bell"],那么编码之后最短的字符串是"time#bell#",长度是10。

解题思路:

  • 将单词翻转一下,写入前缀树中
  • 深度优先遍历前缀树,遍历出所有最长单词,然后求拼在一起的长度。

golang代码:

func minimumLengthEncoding(words []string) int {

   trie := NewTrie()
   for _, item := range words {
      trie.Insert(invert(item))
   }

   var list []int
   dfsTrie(&trie, 0, &list)

   var sum int
   for _, item := range list {
      sum += item
      sum++
   }

   return sum
}

func dfsTrie(node *Trie, n int, list *[]int) {
   if len(node.Next) == 0 {
      *list = append(*list, n)
      return
   }
   n++
   for _, item := range node.Next {
      dfsTrie(item, n, list)
   }
   return
}

func invert(s string) (r string) {
   for i := len(s) - 1; i >= 0; i-- {
      r += string(s[i])
   }
   return
}