我们都使用过主流的搜索引擎,谷歌、 bing,当然还有搜狗、百度之类。当你搜索某一关键词时,它会贴心在下拉框补全一些热门关键词,像下图这样:
搜索关键词提示
你点击某一关键词,页面就直接跳转到结果页面,这种显示搜索关键词提示功能,一定程度上节省用户的搜索时间。
能节省时间的东西就有价值,值得我们学习和使用。
但是,在公司内部的很多系统中,搜索框中都没有这个功能。如果你能实现这个功能,那么你的用户在使用时肯定会眼前一亮,顿生好感,领导看到后也会给你点赞。
这个功能实现非常简单,前端每输入一个字符,都去后端查询前辍相同的关键词返回到下拉列表中即可。前端的实现网上一搜一大堆,比如搜索关键字「搜索框自动补全」就有很多结果,这里就不说了。这里主要说下后端如何实现。
如果关键词数量并不大,我们可以使用最简单的字符串匹配算法,如 BF 算法,就是遍历所有关键词,找出前辍和输入的字符串匹配的并返回给前端即可,Python 语言还提供了字符串的 startswith 这种方法,实现起来就更简单了,简单就意味着不容易出错,没有 bug,在关键词少的情况下,可以优先选择这种方法。
如果关键词量较大,就需要考虑性能问题了,前辍树( Trie 树)就是高效解决这种问题的数据结构。先看一下前辍树的图:
trie树这棵前辍树根节点不存放数据,其他节点保存了 hello,her,hi,how,see,so 等关键词信息,如果查 he 前辍的单词可以很快返回 hello,her。
这种树的子节点数据并不固定,一般的算法教程在实现时都通过固定每个节点的指针数量来降低实现难度,比如使用一个下标与字符一一映射的数组灰存储子节点的指针,如下图所示:
一种实现方式
这种结构效率非常高,但是比较浪费空间,如果关键词有中文或者标点,更是无法复用。
好在 Python 语言有字典这种高效的数据结构,实现起来易如反掌:键可以作为父节点,值作为子节点,值又是一个字典,包含所有的子节点信息,这种字典里又有字典这种嵌套的方式实现的前辍树也叫字典树。先直观的感受下:
{'中': {-1: True, '国': {-1: True, '人': {-1: True}}, '华': {'人': {'民': {'共': {'和': {'国': {-1: True}}}}}}}}
这里 -1 是 True 表示到这里已经是一个完整的候选词了,上述字典树代表以下关键词:
中
中国
中国人
中华人民共和国
Trie 树的 Python 实现:
前辍树(Trie 树)主要有三个操作,第一个是就是一个将关键词插入到 Trie 树,第二个是在 Trie 树中查询一个关键词,第三个是返回 Trie 树中给定前辍的所有关键词。实现起来并不难,下面是我的一种实现方法:
# encoding = utf-8
class Trie(object):
def __init__(self):
"""
Initialize your data structure here.
"""
self.root = {}
self.end = -1
def insert(self, word):
"""
Inserts a word into the trie.
:type word: str
:rtype: void
"""
curNode = self.root
for c in word:
if not c in curNode:
curNode[c] = {}
curNode = curNode[c]
curNode[self.end] = True
def search(self, word):
"""
Returns if the word is in the trie.
:type word: str
:rtype: bool
"""
curNode = self.root
for c in word:
if not c in curNode:
return False
curNode = curNode[c]
# Doesn't end here
if not self.end in curNode:
return False
return True
def startsWith(self, prefix):
"""
Returns if there is any word in the trie that starts with the given prefix.
:type prefix: str
:rtype: bool
"""
curNode = self.root
for c in prefix:
if not c in curNode:
return False
curNode = curNode[c]
return True
def get_start(self,prefix):
'''
给出一个前辍,打印出所有匹配的字符串
:param prefix:
:return:
'''
def get_key(pre,pre_node):
result = []
if pre_node.get(self.end):
result.append(pre)
for key in pre_node.keys():
if key != self.end:
result.extend(get_key(pre+key,pre_node.get(key)))
return result
if not self.startsWith(prefix):
return []
else:
node = self.root
for p in prefix:
node = node.get(p)
else:
return get_key(prefix,node)
if __name__ == "__main__":
trie = Trie()
trie.insert("Python")
trie.insert("Python 算法")
trie.insert("Python web")
trie.insert("Python web 开发")
trie.insert("Python web 开发 视频教程")
trie.insert("Python 算法 源码")
trie.insert("Perl 算法 源码")
print(trie.search("Perl"))
print(trie.search("Perl 算法 源码"))
print((trie.get_start('P')))
print((trie.get_start('Python web')))
print((trie.get_start('Python 算')))
print((trie.get_start('P')))
print((trie.get_start('Python web')))
print((trie.get_start('Python 算')))
代码运行结果如下:
False
True
['Python', 'Python 算法', 'Python 算法 源码', 'Python web', 'Python web 开发', 'Python web 开发 视频教程', 'Perl 算法 源码']
['Python web', 'Python web 开发', 'Python web 开发 视频教程']
['Python 算法', 'Python 算法 源码']
Trie 的时间复杂度
如果要在一组关键词中,频繁地查询某些关键词,用 Trie 树会非常高效。构建 Trie 树的过程,需要扫描所有的关键词,时间复杂度是 O(n)(n 表示所有关键词的长度和)。但是一旦构建成功之后,后续的查询操作会非常高效。
每次查询时,如果要查询的关键词长度是 k,那我们只需要最多比对 k 个节点,就能完成查询操作。跟原本那组关键词的长度和个数没有任何关系。所以说,构建好 Trie 树后,在其中查找关键词的时间复杂度是 O(k),k 表示要查找的关键词的长度。
不想造轮子,学习下 marisa-trie
自己造轮子还要思考,编码,验证,但这是学习提升的最佳方式。如果急于应用没有时间造轮子,至少要学会如何使用轮子,下面的前辍树的轮子是一个日本人写的,大家可以学习应用下。
https://github.com/pytries/marisa-trie
https://marisa-trie.readthedocs.io/en/latest/tutorial.html
写在最后
上述只实现了搜索框智能提示的一小步,实际使用中,你可能还会遇到以下问题:
1、如果候选词过多,应该如何选择性的显示哪些关键词呢?
2、如果用户输入错误,如何仍按正确的拼写来显示候选关键词呢?
第一个问题比如好解决,我们可以按搜索的频度或关键词的搜索结果数来为每个关键词自动生成一个权重数,按权重从大到小选择性的显示前 n 条即可。
第二个问题涉及动态规划,大家可以先思考,如果有时间,会再写一篇文章。
其实 Trie 树在自动补全的需求上都可以大显身手,如输入法自动补全功能、IDE 代码编辑器自动补全功能、浏览器网址输入的自动补全功能等。
(完)
欢迎订阅微信公众号 somenzz,和你一起学习 Python。
网友评论