返回列表

基于 Trie 字典树的自动补全前后端实现

李云梦

技术栈

后端语言 Java,数据库 mongodb

需求

不引入额外技术栈的情况下,在搜索框实现对标题字段的简单自动补全功能。 不要对后端尤其是数据库造成太大负担。

总体思路

因为功能比较简单,所以没打算引用ES之类的搜索引擎。需要利用先有的工具来实现。

旧的方案是:前端每次检测到输入框有字符键入的时候,就通过接口请求后端,后端再到数据库进行模糊查询。这种方法效果还可以,问题是情况数据库过于频繁,而且是前后都匹配的模糊查询,所以比较消耗性能,在负载高的时候会有一些延迟。

Google 了一下,网上大部分的自动补全教程都推荐使用 Trie (字典树、前缀树)来实现,对 Trie 不了解的可以看这里。Trie 确实是实现自动补全功能最完美的数据结构,只要根据输入框中的内容生成 Trie,前端就可以单独完成自动补全的功能,不用再请求后端,而且速度很快。

问题在于,后端如何维护 Trie 呢?

需要补全的内容是列表的标题字段,标题字段保存在各个记录中。直观的想法是对所有标题进行一次查询,用查询出来的全部标题列表构建成一个全量 Trie,保存到某个地方(数据库或者缓存)。每次前端会传一个前缀过来,后端把全量 Trie 加载到内存,根据前缀查询出子 Trie ,把子 Trie 返回给前端。

这样做理论上可行,不过有几个缺点。1)标题的新增、修改和删除,都需要实时同步到 Trie 中,而 Trie 数据全局唯一,如果遇到频繁的标题修改或删除,可能会遇到并发问题,要么加锁影响性能,要么不加锁数据一致。2)数据量大的时候,Trie 对象可能会非常庞大,占用大量内存。

维护一个全局 Trie 的想法似乎不太好,另一个思路是动态的维护 Trie 。

可以确定的一点是,前端至少会在用户第一次输入的时候请求一次后端,此时标题的前缀是知道的,后端要返回的只是这个前缀的 Trie 。所以可以利用这个前缀在数据库进行一次左侧匹配的模糊查询,把以这个前缀开头的标题先都查出来,在内存中以这个标题列表为输入构建一个 Trie ,返回给前端。后续的自动补全肯定只能是这个 Trie 中的内容,其他的标题是不用关心的。

也可以直接返回列表给前端,前端自行构建 Trie 。

用这个方法就做到了只查询一次数据库,就获取了后续操作中的全补全数据。前端只需要在用户清空搜索框的时候,才重新请求后端获取新的数据 ,其他情况下都可以独立完成补全功能。

后端实现

后端主要有两步工作,1)以前缀字符串进行数据库的模糊匹配查询,这个操作不管是在关系型数据库还是nosql中都比较简单。2) 根据列表构建一个 Trie。最后会贴上一个 Java 的 Trie 实现。第2步的工作也可以交给前端去做。

前端实现

前端使用 Trie 实现自动补全的文章很多,这里随便贴两个仅供参考:

https://www.npmjs.com/package/trie-autocomplete
这个是前端的 Trie 库,可以直接用通过列表创建 Trie ,后端的第二步可以省去,直接返回列表即可。

https://blog.csdn.net/weixin_34014277/article/details/88754452

一些细节:

如果以某个字符开头的标题太多,构建出来的 Trie 会非常大,补全的下拉列表会很长。此时限制查询的列表数据,或者不补全,等待输入后续的内容,缩小 Trie 的范围。太长的下拉列表后面的内容可能不会有人看。

Trie Java实现:


import lombok.Data;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Data
public class Trie implements Serializable {

    /**
     * 根据字符串列表构建字典树
     * @param contents 列表
     * @return Trie
     */
    public static Trie buildTrie(List<String> contents){
        Trie trie = new Trie();
        if(contents != null && contents.size() > 0){
            for (String content : contents) {
                trie.insert(content);
            }
        }
        return trie;
    }

    /**
     * 根据前缀获取关联内容。最多10条
     * @param prefix 前缀字符串
     * @return 关联内容列表
     */
    public List<String> getSubs(String prefix){
        return getSubs(prefix,10);
    }

    /**
     * 根据前缀获取关联内容,可指定最大返回数量
     * @param prefix 前缀字符串
     * @param maxSize 最大返回数量
     * @return 关联内容列表
     */
    public List<String> getSubs(String prefix,int maxSize){
        List<String> list = new ArrayList<>();
        TrieNode node = findPrefix(prefix);
        if(node != null){
            getContentsByDFS(node,list,maxSize);
        }
        return list;
    }

    /**
     * 以深度优先的方式遍历node下所有的字符串,添加到list中。最多maxSize条
     * @param node 前缀节点
     * @param list 结果列表
     * @param maxSize 结果最大数量
     */
    private void getContentsByDFS(TrieNode node,List<String> list,int maxSize){

        HashMap<Character, TrieNode> children = node.getChildren();
        for (Map.Entry<Character, TrieNode> entry : children.entrySet()) {
            TrieNode trieNode = entry.getValue();
            if(trieNode.isEnd){
                list.add(trieNode.content);
                if(list.size() >= maxSize){
                    return;
                }
            }

            getContentsByDFS(trieNode,list,maxSize);

            if(list.size() >= maxSize){
                return;
            }
        }
    }

    /**
     * 字典树节点
     */
    @Data
    static class TrieNode{
        // 子节点
        private HashMap<Character, TrieNode> children = new HashMap<>();
        // 当前节点代表的字符串内容(isEnd == true 时有值)
        private String content;
        // 是否是字符串结尾
        private boolean isEnd;
    }

    // 根节点
    private TrieNode root = new TrieNode();

    /**
     * 插入一个字符串到字典树中
     * @param word 字符串
     */
    public void insert(String word) {
        TrieNode current = root;

        for (char l: word.toCharArray()) {
            HashMap<Character, TrieNode> children = current.getChildren();
            if(!children.containsKey(l)){
                children.put(l,new TrieNode());
            }
            current = children.get(l);
        }
        current.setEnd(true);
        current.setContent(word);
    }

    /**
     * 判断一个字符串是否存在于字典树中
     * @param word 字符串
     * @return 是否存在
     */
    public boolean contains(String word) {
        TrieNode current = findPrefix(word);
        return current != null && current.isEnd();
    }

    /**
     * 根据前缀字符串查询节点。如果不包含此前缀返回null
     * @param prefix 前缀字符串
     * @return TrieNode
     */
    public TrieNode findPrefix(String prefix) {
        TrieNode current = root;
        for (int i = 0; i < prefix.length(); i++) {
            char ch = prefix.charAt(i);
            TrieNode node = current.getChildren().get(ch);
            if (node == null) {
                return null;
            }
            current = node;
        }
        return current;
    }

    public static void main(String[] args) {
        ArrayList<String> contents = new ArrayList<>();
        contents.add("一3二");
        contents.add("一");
        contents.add("一四333");
        contents.add("一四456");
        contents.add("一四4444");
        contents.add("一四555");
        contents.add("一四6666");
        contents.add("二#77777");
        contents.add("0");
        contents.add("life");

        Trie trie = Trie.buildTrie(contents);

        List<String> subs = trie.getSubs("一");
        System.out.println(String.join(";",subs));

        subs = trie.getSubs("一",4);
        System.out.println(String.join(";",subs));

        subs = trie.getSubs("一四");
        System.out.println(String.join(";",subs));

        subs = trie.getSubs("一四",2);
        System.out.println(String.join(";",subs));

        subs = trie.getSubs("一四4");
        System.out.println(String.join(";",subs));
    }
}